Python ↔ TypeScript parity
What is identical across the two bindings, what differs, and the reason each difference was kept rather than fixed.
On this page
Most of this section teaches both languages at once, which is only defensible if the two bindings genuinely behave the same. This page is where that claim is checked, and where it stops.
Three behaviours are byte-identical by construction. Everything else is listed below, with the reason it was kept rather than made uniform.
What is identical
| Behaviour | Held identical by |
|---|---|
| the emitted header map | 27 emission vectors, compared byte for byte |
| the parsed receipt | 19 receipt vectors, compared as a canonical mapping |
| money rendering | 12 render vectors, compared character for character |
| the outcome and feedback request bodies | 12 more vectors, compared byte for byte |
| every diagnostic code | frozen; assertions match on the code, not the prose |
Both bindings execute all 70 vectors on every build, so those rows are checked rather than asserted. The contract covers how.
A demonstration rather than a claim: the same request through both SDKs, against the same proxy, printing the same cost string.
# Python # TypeScript
agent : planner agent : planner
step class : user_turn step class : user_turn
cost : $0.000027 (snapshot cost : $0.000027 (snapshot
litellm@2026-07-14#179affb) litellm@2026-07-14#179affb)
What differs
Synchronous versus async
Python’s ledger and check calls are synchronous. TypeScript’s return promises.
row = ledger.fetch(rcpt, wait_s=3)
const row = await tt.ledger.fetch(rcpt, { waitS: 3 });
An idiom choice per language rather than a capability difference. Emission and receipt parsing are synchronous in both, because neither touches the network.
Scope form
Python’s scopes are context managers and decorators. TypeScript’s take a callback.
with tt.step(agent="planner"):
...
@tt.observe(agent="planner")
def plan(): ...
await tt.step({ agent: "planner" }, async () => { ... });
TypeScript has no decorator form and no observe. The callback form is the only one that can hold
an AsyncLocalStorage frame, so this difference is imposed by the runtime rather than chosen.
Reaching a cost
TypeScript models Cost as a discriminated union, so cost.usd is a compile error until you
narrow on cost.state === "priced". Python keeps one class whose off-state fields are present and
None.
Python’s invariant is that usd, estimated and pricing_snapshot are None when the state is
not priced. Making them raise instead would add a new runtime failure mode inside the host
application, to buy a guarantee Python’s type system cannot enforce statically anyway.
Numbers in ledger rows
Money is safe in both, by different means, and the difference is visible on every other number.
TypeScript decodes ledger payloads preserving every number as its exact source literal, which means as a string. JavaScript has no float-parsing hook, and a reviver only ever sees an already-lossy float, so preserving the literal is the only way to keep a price exact.
Python decodes floats as Decimal, which touches floats only and leaves integers alone.
# the same row, fetched by each binding
Python row.usage['total_tokens'] == 120 row.cost.usd == Decimal('0.000027')
TypeScript row.usage.total_tokens === "120" row.cost.usd === "0.000027"
Money is identical either way, which is the field this protects. A caller reading a non-money number out of a raw block has to expect the difference.
A failed request through Python’s httpx adapter
The implicit per-request step closes with an error status everywhere except Python’s httpx adapter, where the record is absent instead.
| Adapter | On a transport error |
|---|---|
Python requests |
a step record with status=error |
TypeScript fetch |
a step record with status=error |
Python httpx |
no step record |
httpx’s documented hook interface has a request hook and a response hook and no error hook. Closing the gap would mean reaching into a private transport attribute, contradicting the rule the adapter is built on. What it means for you is on adapters.
A guardrail refusal: raised in Python, narrowed in TypeScript
| Binding | What a guardrail refusal looks like |
|---|---|
| Python | raise GuardrailRefusal from the SDK’s transport; except GuardrailRefusal |
| TypeScript | the official PermissionDeniedError / InternalServerError, read with guardrailRefusal(error) |
The fields, the recognition rule and the written sentence are identical, and both bindings are pinned against one shared fixture file, so neither can drift into a different reading of the same wire. What differs is how you reach them.
The provider packages force it. openai-python wraps only httpx.RequestError and re-raises
everything else unwrapped, so a distinct class survives the trip. openai-node converts anything
thrown from a custom fetch into APIConnectionError, so a class thrown there would reach you as
“connection error”, strictly worse than the status error it replaced. TypeScript instead rewrites
the refusal body so the official error carries the reason, and exports a narrower.
See Python and TypeScript.
Closed vocabularies are stricter in TypeScript
TypeScript narrows Mode, StepClass, StepStatus and the check-finding level to literal unions,
so a typo in a comparison fails to compile. The new Python scope and lifecycle APIs also use literal types.
The field sets are at full parity — the record, report, acknowledgement and receipt types were compared member by member and match, including members that are properties rather than fields. Only the strictness differs. These values are read-only projections of server bytes, so narrowing buys a typo check on the comparison side at the cost of changing a frozen typed surface.
Origin matching normalises differently
Python’s adapter origin check collapses case, a trailing dot, and internationalised names to
punycode, but not percent-encoded hosts or alternative IP-literal spellings. TypeScript uses the
platform URL object, which normalises more.
The direction matters. For this check, over-normalising is the hazard: a false match injects identity somewhere it should not go. The residual failure mode is a skipped injection and lost telemetry rather than a leak.
Naming, and one unshielded call
Python is snake_case, TypeScript is camelCase, applied consistently: fetch_trace and
fetchTrace, last_receipt and lastReceipt.
ledger.feedback() is the one ledger call that is not shielded in either binding. It raises
or rejects on failure, because a silently dropped write is worse than a loud failure. That is
parity, not a difference, and it is listed here because it surprises people.
What is not different, despite looking it
| Looks like a difference | Is not |
|---|---|
| Python has more adapters | TypeScript’s fetch covers what httpx and requests cover separately |
| TypeScript’s package has subpaths, Python has extras | the same opt-in shape, spelled per ecosystem |
| the two diagnostic texts differ for two codes | a bounded substitution: the API name and the absence literal |
init() takes different argument shapes |
both carry the same fields, including the default dimensions |
That last row was a genuine gap once. TypeScript’s init() had no dimension fields, so a program
ported from Python silently lost its process-wide defaults. It was closed; both now take agent,
user, tenant, feature and tags.
Where this list comes from, and how to check it is current
The divergences here are drawn from docs/SDK_PARITY.md in the product repository, which carries
a per-row cite into the shipped code or the test that pins it. The ones on this page were also
reproduced against both bindings while it was written.
If you need to know whether a difference still exists, the test names in that document are the place to look, not this page. Documentation of a moving surface is a snapshot; the tests are the gate.