# Reading the receipt

The typed view of a response's x-tt-* headers, what happens to values it cannot parse, and which scope holds it.

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

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

Every response through TokenTriage carries `x-tt-*` headers describing what the proxy did with
the request. `receipt()` turns that header map into a read-only object with parsed fields.

It is string work on headers you already hold. No network call happens, nothing is cached, and
there is nothing to flush. That is the reason reading a receipt costs nothing measurable and
cannot fail in a way that affects your request.

## Getting one

Two routes. `receipt(source)` parses anything header-shaped, and `last_receipt()` returns the one
most recently recorded on your open scope.

<CodeTabs>
  <Fragment slot="python">
  ```python
  rcpt = tt.receipt(response)          # an httpx/requests response, or a header mapping
  rcpt = tt.last_receipt()             # the most recent on the innermost open scope
  ```
  </Fragment>
  <Fragment slot="typescript">
  ```ts
  const rcpt = tt.receipt(response);   // a Response, Headers, or a plain record
  const rcpt = tt.lastReceipt();       // the most recent on the innermost open scope
  ```
  </Fragment>
</CodeTabs>

`receipt()` also accepts an error response, which is how you read a refusal. The SDK does not
swallow or re-raise your client's HTTP errors; your client raises whatever it raises, and you
hand the response to `receipt()` to find out why.

<CodeTabs>
  <Fragment slot="python">
  ```python
  try:
      resp = client.post(url, json=body)
  except urllib.error.HTTPError as err:
      rcpt = tt.receipt(err)           # a 429 refusal is still a receipt
  ```
  </Fragment>
  <Fragment slot="typescript">
  ```ts
  const res = await tt.fetch(url, { method: "POST", body });
  const rcpt = tt.receipt(res);        // res.ok === false is still a receipt
  ```
  </Fragment>
</CodeTabs>

### Which scope holds it

`last_receipt()` reads from the **innermost open scope**, not from a process global. Under
concurrent fan-out a global slot would hand one task another task's request id, and the resulting
attribution looks plausible and is wrong.

The consequence catches people, so here it is as a table. Each row is one shape, and whether a
trace-level read finds the receipt:

| Where the call was made | Where the receipt lands | Visible at trace level afterwards |
|---|---|---|
| inside an explicit `step` | on that step | no |
| inside a `trace`, no explicit step | on the trace | yes |
| outside any scope | nowhere | no, plus `TT-SDK-013` |

<Callout type="warning">
Row one is the trap. Read the receipt inside the `step` block that made the call, or assign it to
a variable there. Reading it after the step closes returns nothing **and emits no diagnostic**,
because you are still inside the trace. `TT-SDK-013` only fires outside every scope.
</Callout>

When you need every receipt from a step rather than the last one, `step.receipts` holds them all
in order. That is the precise tool when one step makes several calls.

## The typed fields

| Field | From | What it is |
|---|---|---|
| `request_id` | `x-tt-request-id` | the ledger join key, `tt_` and 26 characters |
| `mode` | `x-tt-mode` | which rung of the trust ladder served it |
| `decision` | `x-tt-decision` | the tier chosen, when one was |
| `model_rewrite` | `x-tt-model-rewrite` | a `from`/`to` pair when the model was changed |
| `cache` | `x-tt-cache*` | status, and hit-only detail |
| `refusal` | refusal headers | why the request was turned away |
| `cost` | — | always absent in contract version 1 |
| `extra` | any other `x-tt-*` | unrecognised headers, verbatim |
| `status_code` | the response | when the source carried one |

The object is immutable, in both languages:

```python
>>> rcpt.request_id = "nope"
FrozenInstanceError: cannot assign to field 'request_id'
```

`cost_display` is a convenience that renders whatever cost state applies, and
`dashboard_url()` builds a deep link to the record page without touching the network. Both are
covered on [cost in the SDK](/docs/sdks/cost).

### Cache detail is hit-only

`cache` exists whenever `x-tt-cache` is present, and its sub-fields exist only when the server
stamped them. A miss carries a status and nothing else:

```text
cache miss    CacheInfo(status='miss', kind=None, age_s=None, …, verified=None)
```

`verified` is a tristate and the distinction matters. `true` and `false` are verifier verdicts;
the string `none` is a valid server value meaning no verifier ran, and it parses to absent rather
than into the malformed path:

```text
x-tt-cache-verified: true     → verified=True
x-tt-cache-verified: none     → verified=None      (a valid value, not a parse failure)
```

Absent and `none` are distinguishable, because the header is stamped only on semantic hits, so a
non-semantic hit's absence is told apart by `kind`. [Cache disclosure](/docs/build/caching-disclosure)
covers what the server is asserting with each.

## A parser that cannot throw

`receipt()` accepts any input without raising. That is a requirement rather than an aspiration:
it sits in your response path, and an instrumentation library that throws there has turned a
working request into an outage.

A header that fails its declared type parse lands **raw in `extra`** under its stripped name, and
the typed field stays absent:

```text
receipt({"x-tt-request-id": "tt_b", "x-tt-cache": "hit", "x-tt-cache-age": "not-a-number"})
  → cache.age_s = None
    extra = {'cache-age': 'not-a-number'}
```

Nothing is lost and nothing is invented. The same holds for a malformed `model_rewrite`, which
needs a non-empty name on both sides of the arrow:

```text
x-tt-model-rewrite: gpt-4o->gpt-4o-mini   → Rewrite(from_model='gpt-4o', to_model='gpt-4o-mini')
x-tt-model-rewrite: ->                    → extra = {'model-rewrite': '->'}
```

### A response that is not from TokenTriage

If `x-tt-request-id` is missing, every typed field is absent and one `TT-SDK-012` fires:

```text
TT-SDK-012: no x-tt-request-id in response (is base_url pointed at the proxy?)
```

You get a receipt object, not `None`, so `rcpt.request_id` is safe to read and returns nothing.
That matters because the return type says a receipt comes back; handing you `None` instead would
turn a fault into an `AttributeError` one frame later.

This is also the reason the whole receipt goes absent rather than partially populating: without a
request id there is no join key, so a `mode` field read off a stray header would name a request
nobody can look up.

## Headers this version has not met

Any `x-tt-*` response header the contract does not know is captured verbatim into `extra`, keyed
by its name with the `x-tt-` prefix removed:

```text
receipt({"x-tt-request-id": "tt_a", "x-tt-mode": "route",
         "x-tt-brand-new-thing": "hello", "x-ratelimit-remaining": "99"})
  → extra = {'brand-new-thing': 'hello'}
```

This is the forward-compatibility channel. When the server starts stamping a new header, an SDK
built before it existed still surfaces the value, and a later release promotes it to a typed
field without a shape change. Upgrading is additive.

Note what did *not* land in `extra`: `x-ratelimit-remaining`. Only `x-tt-*` names are captured in
version 1.

<Callout type="note">
`RateLimit` and `RateLimit-Policy` appear on some 429 responses, are not typed fields, and are
not routed into `extra` either, because they are not `x-tt-*` names. That gap is documented
rather than hidden; [the contract](/docs/sdks/contract#watch-items) lists it with the rest.
`Retry-After` *is* typed, as `refusal.retry_after_s`.
</Callout>

## Refusals

When a request is turned away, `refusal` carries the reason. Kind is decided by precedence:
`x-tt-refusal-type` gives kind `limit`, then a budget header gives `budget`, then a policy header
gives `policy`.

```text
status_code : 429
refusal     : Refusal(kind='budget', refusal_type=None, refused_by=None, retry_after_s=None)
```

`refused_by` is optional in every case, so treat it as extra detail rather than as the field that
tells you what happened. `kind` is the field that does. [Why was this refused?](/docs/build/governance-refusals)
walks the server-side diagnosis.

<Details summary="What the conformance corpus pins here">

Receipt parsing is one of the three behaviours held byte-identical across the two bindings. 19 of
the 70 shared vectors are receipt vectors, each naming the requirements it witnesses, and both
Python and TypeScript execute all of them on every CI run. A vector's expected value is a
canonical nine-key mapping, so a field that parsed differently in one language fails the build
rather than reaching a release.

</Details>

<NextSteps items={[
  { title: 'Cost in the SDK', href: '/docs/sdks/cost', description: 'Why Receipt.cost is empty, and the three states a cost can be in.' },
  { title: 'Fetching a costed row', href: '/docs/sdks/ledger', description: 'Where the dollar actually comes from, and the unhappy paths.' },
  { title: 'Diagnostics', href: '/docs/sdks/diagnostics', description: 'TT-SDK-012, TT-SDK-013, and the rest.' },
  { title: 'Header reference', href: '/docs/reference/headers', description: 'The wire fields these map from, and what the server stamps them for.' }
]} />
