Gateway identity (last_call)
A refusal already tells you which gateway said no. donkey.last_call is the
success-path counterpart: after every governed call it records which gateway
served the request, what it actually routed to, and what the call cost —
without parsing headers or running a span backend. That matters because a
silent model substitution is otherwise invisible to your cost model and your
evals, and reading only total_tokens misses cached and reasoning tokens. If
your assumptions are pinned to one model, on_model_substitution="raise"
turns a swap into a hard ModelSubstituted error.
| Example | Shows | Needs |
|---|---|---|
| Narrative demo 10 | A cold UNOBSERVED record, one call populating identity, routing and usage, the substitution flag, and on_model_substitution="raise" | Nothing (simulator) |
| OpenAI script 08 | The full last_call record on a live call, then on_model_substitution="raise" | Proxy credentials |
Run it
make demo N=10════════════════════════════════════════════════════════════════════════════════════════
Demo 10 — last_call, routing, and per-call usage
The success-path counterpart to a typed refusal: who served this, what they served, and
what it cost.
════════════════════════════════════════════════════════════════════════════════════════
Run context
───────────
target mock
proxy base_url http://127.0.0.1:8080/
output masking on
credentials fake — the simulator enforces no auth
[1] A new process has not observed a call yet — and it says so
status unobserved
observed False
available True
request_id None
PASS UNOBSERVED — not None, not 0, not 'unknown'. A cold read is a named state.
A bare None would be a lie of omission: you could not tell 'the gateway sent no id'
from 'we never saw a response'. Budget uses the same honesty rule for an unobserved
window (demo 03). UNAVAILABLE is the third state, for adapters that never route
through our transport — LiteLLM-backed ADK and CrewAI, or default_headers-only
LlamaIndex. Those surfaces report UNAVAILABLE by name rather than looking like a cold
read.
[2] One governed call, and the record is the success-path counterpart
await client.responses.create(model=..., input=...)
donkey.last_call.served_model
donkey.last_call.total_tokens
donkey.last_call.substituted
status observed
observed True
request_id req_f85003861d5348c9a1d152c276082b07
requested_model gpt-4o
served_model gpt-5.1
served_provider openai
routing_type ModelBased
fallback False
substituted True
input_tokens 17
output_tokens 51
total_tokens 68
cached_tokens 0
cache_write_tokens 0
reasoning_tokens 0
PASS OBSERVED — the SDK saw the response, even if some fields stayed None
request_id is the gateway's own id (x-request-id) — quote it in a ticket. It is the
same field classify() puts on a DonkeyError after a refusal, now present on the 200 as
well. api_instance_id and environment_id are parsed from x-envoy-decorator-operation;
they are masked in this output.
[3] Routing, fallback, and the cost-relevant token counts
What the gateway did with the request
─────────────────────────────────────
requested gpt-4o
served openai/gpt-5.1
routing_type ModelBased
fallback False
substituted True
PASS substituted — asked for gpt-4o, gateway served gpt-5.1
Against the simulator this is the captured happy-path fixture talking: it was recorded
against gpt-5.1, and we asked for a different id. That is not a live failover — and it
is exactly the mismatch last_call is for. A silent substitution is otherwise invisible
to your cost model, your eval, and your latency dashboard.
What this call cost
───────────────────
input / output / total 17 / 51 / 68
cached_tokens 0
cache_write_tokens 0
reasoning_tokens 0
cached_tokens are billed at the cached rate; reasoning_tokens are output the developer
never sees. Reading only total_tokens draws the wrong conclusion about both cost and
latency. An absent count is None, never 0 — 0 here means the gateway reported zero,
which is a different statement. These are per-call; donkey.budget is the shared window
(demo 03).
The SDK never double-retries a fallback. It retries 502/503/504 with backoff, but a
503 the gateway already marked as a failover is left alone — a second recovery layer
stacked on a working first one just multiplies latency against an outage the gateway
already handled.
[4] Opt in, and a substitution is a hard error instead of a flag
donkey = Donkey.from_env(on_model_substitution="raise")
# raises ModelSubstituted when served_model != requested_model
Off by default: the call succeeds and last_call.substituted is True. Raise is for
callers whose eval, cost model and token assumptions are pinned to one model.
ModelSubstituted is deliberately not a PolicyViolation — the request was neither
refused nor failed, it succeeded against a model you did not choose. Same shape as
BudgetReserveReached: a client-side signal you opted into.
PASS ModelSubstituted — the 200 never reached the caller
requested_model gpt-4o
served_model gpt-5.1
served_provider openai
request_id req_f85003861d5348c9a1d152c276082b07
last_call.substituted True
The record still populated — observe happens before the raise — so a handler that
decides to accept the served completion can read last_call the same way. The exception
also carries the response.
The point
─────────
The refusal path already told you which gateway said no. The success path now tells
you which gateway said yes, what it actually served, and what that call cost — without
a span backend, without parsing headers, and without a second accessor for routing or
usage.
────────────────────────────────────────────────────────────────────────────────────────python "demos/human-made/openai/08 - last-call.py" # needs proxy credentialsKey code
Reading the record after a call (OpenAI script 08):
donkey = Donkey.from_env()
client = donkey.openai(sync=True)
print("before any call ", donkey.last_call.status.value)
reply = client.responses.create(model="gpt-4o", input="Say hello in exactly three words.")
last = donkey.last_call
print("status ", last.status.value)
print("request_id ", last.request_id)
print("requested_model ", last.requested_model)
print("served_model ", last.served_model)
print("served_provider ", last.served_provider)
print("routing_type ", last.routing_type)
print("fallback ", last.fallback)
print("substituted ", last.substituted)
print("total_tokens ", last.total_tokens)
print("cached_tokens ", last.cached_tokens)
print("reasoning_tokens", last.reasoning_tokens)Opting in to a hard error. The OpenAI client wraps the transport error, so the
typed ModelSubstituted is on __cause__:
strict = Donkey.from_env(on_model_substitution="raise")
strict_client = strict.openai(sync=True)
try:
strict_client.responses.create(model="gpt-4o", input="Say hello in exactly three words.")
print("NO RAISE substituted", strict.last_call.substituted)
except Exception as err:
hit = err if isinstance(err, ModelSubstituted) else err.__cause__
if not isinstance(hit, ModelSubstituted):
raise
print("requested_model ", hit.requested_model)
print("served_model ", hit.served_model)- Three named states, never a bare
None.UNOBSERVEDis a cold read;OBSERVEDmeans the SDK saw a response (fields may still beNoneif the gateway said nothing);UNAVAILABLEmarks adapter surfaces that never route through the SDK’s transport. request_idis the upstream provider’s id, passed through by the gateway —x-request-idfor OpenAI,x-amzn-requestidfor Amazon Bedrock,apim-request-idfor Azure OpenAI. Quote it to the provider’s support team. It is the same fieldclassify()puts on a refusal, now present on a 200 too, andNoneon a route whose provider forwards no id.- Absent counts are
None, never0.cached_tokensare billed at the cached rate;reasoning_tokensare output you never see. ModelSubstitutedis not aPolicyViolation. The request succeeded, against a model you did not choose.last_callis still populated before the raise.- A
provider/prefix is not a substitution. On a model-based routing proxy the gateway reports the served model without theprovider/prefix;substitutedignores a prefix that names the served provider (see Telemetry & cost).
The simulator’s captured success response was recorded against gpt-5.1, so
asking for any other model id shows up as a substitution in narrative demo
10. That is the fixture, not a live failover — and it is exactly the
mismatch last_call exists to surface.
Semantic routing: the matched topic and score
The call above went through a model-based proxy, where routing_type reads
ModelBased. A semantic-routing proxy instead classifies each prompt by
meaning and routes it to the matched topic’s provider and model — there
routing_type reads Semantic, and last_call carries two more fields the
model-based path leaves None:
matched_topic— which topic the prompt matched (e.g.Finance).routing_score— how close that match was, a bare0.xxsimilarity score.
Both come from the live-verified, semantic-only
x-llm-proxy-semantic-routing-success response header. The four routing fields
you already read (routing_type, fallback, served_provider, served_model)
are emitted identically to the model-based case, so the rest of the record reads
the same way — only these two are added.
You can exercise this branch offline: point the simulator at the captured
Semantic response by requesting the donkey-sim/success-semantic model id
(the same sentinel mechanism the simulator uses to force a
refusal shape, here forcing a happy-path variant).
client = donkey.openai(sync=True)
client.responses.create(
model="donkey-sim/success-semantic",
input="How does compound interest work?",
)
last = donkey.last_call
print("routing_type ", last.routing_type) # Semantic
print("matched_topic", last.matched_topic) # Finance
print("routing_score", last.routing_score) # 0.62matched_topic and routing_score are None on a model-based proxy — the
x-llm-proxy-semantic-routing-success header is semantic-only. An unparseable
message leaves each field None rather than guessing a value.
Learn more: Feature overview · Telemetry & cost
Source: narrative demo 10 · script 08