# Python ↔ TypeScript parity

What is identical across the two bindings, what differs, and the reason each difference was kept rather than fixed.

Source: https://tokentriage.com/docs/sdks/parity

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';
import Details from '../../../components/Details.astro';

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](/docs/sdks/contract) covers how.

A demonstration rather than a claim: the same request through both SDKs, against the same proxy,
printing the same cost string.

```text
# 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.

```python
row = ledger.fetch(rcpt, wait_s=3)
```

```ts
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.

```python
with tt.step(agent="planner"):
    ...

@tt.observe(agent="planner")
def plan(): ...
```

```ts
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.

<Callout type="warning">
The forbidden pattern in both languages is `cost.usd or 0`. TypeScript's type system stops you
before narrowing; Python's does not. It turns "we could not price this" into "this was free".
</Callout>

### 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.

```text
# 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](/docs/sdks/adapters#choose-how-to-activate).

### 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](/docs/sdks/python#guardrail-refusals) and
[TypeScript](/docs/sdks/typescript#guardrail-refusals).

### 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`.

<Details summary="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.

</Details>

<NextSteps items={[
  { title: 'The contract and how it evolves', href: '/docs/sdks/contract', description: 'What holds the identical half identical, and what upgrading changes.' },
  { title: 'Python SDK', href: '/docs/sdks/python', description: 'The Python surface in full.' },
  { title: 'TypeScript SDK', href: '/docs/sdks/typescript', description: 'The TypeScript surface in full.' },
  { title: 'Cost in the SDK', href: '/docs/sdks/cost', description: 'The three cost states, and how each language reaches the number.' }
]} />
