Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

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.

ReferenceLast updated

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 scope

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.

try:
    resp = client.post(url, json=body)
except urllib.error.HTTPError as err:
    rcpt = tt.receipt(err)           # a 429 refusal is still a receipt

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

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.