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.
On this page
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.
rcpt = tt.receipt(response) # an httpx/requests response, or a header mapping
rcpt = tt.last_receipt() # the most recent on the innermost open scopeconst rcpt = tt.receipt(response); // a Response, Headers, or a plain record
const rcpt = tt.lastReceipt(); // the most recent on the innermost open scopereceipt() 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.
try:
resp = client.post(url, json=body)
except urllib.error.HTTPError as err:
rcpt = tt.receipt(err) # a 429 refusal is still a receiptconst res = await tt.fetch(url, { method: "POST", body });
const rcpt = tt.receipt(res); // res.ok === false is still a receiptWhich 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 |
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:
>>> 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.
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:
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:
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
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:
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:
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:
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:
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.
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.
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?
walks the server-side diagnosis.
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.