Repository navigation
Description
Summary
Three open issues each propose hanging response-derived fields off a "last-call
record", and each invents a different name for it:
| Issue | Sketched accessor |
|---|---|
| #307 | donkey.budget.last_usage |
| #309 | donkey.last_routing |
| this one | gateway identity — currently nowhere |
None of them defines the container, and it does not exist:
$ rg -n 'last_call|last_routing|last_usage|LastCall|GatewayInfo' python/src
(no matches)
#309 says to "expose the served provider/model and the fallback flag on the
last-call record, beside the usage fields" — presupposing something #307 would
have created under a different name, on a different parent object. If both ship
independently we end up with donkey.budget.last_usage and
donkey.last_routing describing the same single HTTP response, split across two
parents by accident of authorship rather than by design.
This issue is the container itself, plus the one field group no other issue
claims: gateway identity.
The uncovered field group
python/tests/fixtures/anypoint/llm_proxy/responses.success.headers.txt,
captured live 2026-08-28:
x-request-id: req_f85003861d5348c9a1d152c276082b07
x-envoy-decorator-operation: api-instance-21133858.3e6ce455-e3e8-4402-b830-9fcf07d9207b.svc
x-correlation-id: d34a1c5a-a3af-4bcc-8fe1-217682d7d0e0
x-envoy-decorator-operation encodes the API instance id (21133858) and
the environment id (3e6ce455-…) — per docs/verified-apis.md §3 it is the
VERIFIED gateway identity on the response, and it is the only thing in the
response that points at a specific API Manager instance. Nothing in the SDK
reads it. Nothing reads the response x-correlation-id either, and
x-request-id is read only on the failure path.
Why it matters: the success path is asymmetric
On a refusal, DonkeyError hands the developer three correlated ids with
documented provenance:
On a 200, the developer gets none of it. Everything the gateway said about the
call is either consumed internally (Budget), turned into a span attribute that
requires OTel to be installed and a backend to view, or dropped on the floor.
So "which gateway instance served this, and what id do I quote in a ticket?" is
answerable after a failure and unanswerable after a success. The only way out
today is to drop to the raw response:
raw = await client.responses.with_raw_response.create(model="gpt-4o", input="…")
instance = raw.headers.get("x-envoy-decorator-operation")
response = await raw.parse()That is precisely the outcome core/budget.py opens by ruling out:
It also leaks the OpenAI SDK's response-wrapper API into governance code. The
capability is not missing under the framework adapters — it is fragmented,
which is worse for an abstraction layer, because the same governance question has
a differently-shaped answer per framework and one of them is a private attribute:
| Surface | How a developer reaches response headers today | Shape |
|---|---|---|
donkey.openai() |
with_raw_response |
AsyncAPIResponse.headers |
donkey.langgraph.chat_model() |
include_response_headers=True |
response_metadata["headers"] |
LiteLLM-backed (crewai, adk) |
_hidden_params |
private attribute |
(include_response_headers verified present on langchain_openai 1.6.0
ChatOpenAI.model_fields. The LiteLLM row is unverified — litellm was not
installed in the environment where this was checked.)
The attachment point already exists
Both transports have an _on_response hook that fires once with the final
response after retries and any 401 refresh settle, and both already feed
Budget from it:
A second observer slots in beside self._budget.observe(response) in both, with
no new plumbing and no change to any adapter.
But the hook only fires where the SDK owns the transport — which is 4 of the 8
adapters. This is the constraint that most shapes the design, so it is worth
stating up front rather than discovering it in review. Adapters that receive
_http_client() route through DonkeyAsyncClient, so _on_response fires and
the record populates. Adapters that do not are structurally unable to observe
anything:
| Surface | Gets _http_client() |
Record populates? |
|---|---|---|
donkey.openai() / donkey.llm.client() |
yes (self._http) |
yes |
langgraph |
yes (http_async_client) |
yes |
strands |
yes (client_args["http_client"]) |
yes |
openai_agents |
yes (http_client) |
yes |
anthropic |
yes (http_client) |
yes |
crewai |
no — LiteLLM owns the transport | never |
adk |
no — LiteLLM owns the transport | never |
llamaindex |
no — only default_headers passed |
never |
agent_framework |
no — only _openai_connection() |
never |
The two LiteLLM rows are structural and already documented as such — crewai.py
says "We CANNOT inject our httpx client — LiteLLM owns the transport", and both
it and adk.py cite the existing §8.1 correlation_id_propagated conformance
exemption. The llamaindex and agent_framework rows look different in kind:
those adapters simply never pass a client, which may be an unwired gap rather
than an impossibility, and is worth a separate look.
The simulator already preserves these headers, so donkey.simulate() and
donkey mock would populate the record for free:
_KEEP_EXACT holds x-correlation-id and x-request-id; _KEEP_PREFIX holds
x-llm-proxy-. Only x-envoy-decorator-operation would need adding.
Every field proposed here comes from the LIVE-VERIFIED §3 row, so unlike the two
#195 request-header names this needs no _verify.Unverified(...) guard and
emits no UnverifiedValueWarning. It is a consumption task, not a verification
one.
Three hazards to settle in review
1. correlation_id would mean two different things. On DonkeyError it is
the run id the client sent. The response x-correlation-id is the gateway's
own value, and per #300 it is not confirmed to be an echo of the client's —
the captured value is a dash-formatted UUID while new_correlation_id() returns
uuid4().hex with no dashes, which is suggestive of an independently minted id.
Giving both the same attribute name bakes that ambiguity into the public API.
Suggest gateway_correlation_id, and do not add the field at all until #300
lands if the semantics would be misleading.
2. "Last call" is a weaker concept than "budget window." Budget gets away
with last-write-wins mutable state because a token window genuinely is one
shared resource — any recent observation is a valid observation. The request id
of "the last call" is not like that: under the parallel fan-out donkey.run()
actively encourages, two concurrent calls race and the accessor returns
whichever landed last, which is worse than returning nothing because it looks
authoritative. This is the same class of bug #311 describes for Budget, but
without the mitigating "any observation is valid".
3. An empty record must not read as "the gateway said nothing." Following
from the adapter table above: a CrewAI or ADK user reading request_id is None
would reasonably conclude the gateway returned no id, when the truth is the SDK
never saw the response at all. Budget has the same exposure today and handles
it for humans with observed_at; a bare None here is a lie of omission on
half the adapter surface.
#195 already set the house pattern for this exact situation — "frameworks where
LiteLLM owns the transport cannot propagate a per-run ID; assert the exemption
rather than skipping" — so the record should distinguish not observed yet
from cannot be observed on this surface, and the conformance suite should carry
a row mirroring §8.1 correlation_id_propagated. Without that, this feature
silently misreports on four of eight adapters.
Recommend contextvar-scoped rather than instance-scoped, so each task reads
its own call's metadata. That also matches how correlation already works and
composes correctly with donkey.run(). Whatever is chosen, it should be stated
in the docstring and tested, not left implicit.
Scope
- Define the last-call record once, in one place, with one name, and settle
whether it hangs offDonkeyorBudget. - Populate the gateway identity fields from
_on_responsein both
transports:request_id,api_instance_id,environment_id,observed_at,
and (pending verify: whether the gateway echoes the client x-correlation-id or mints its own #300) the gateway correlation id under an unambiguous name. - Parse
x-envoy-decorator-operationinto its two components; an unrecognised
shape leaves bothNoneand never raises, per §0.3. - Add
x-envoy-decorator-operationto the simulator's_KEEP_EXACT. - Record the chosen concurrency scope in the docstring.
- Make the "cannot observe on this surface" state explicit and distinguishable
from "nothing observed yet", and document which adapters are exempt. - Add the conformance row mirroring §8.1
correlation_id_propagated.
Explicitly out of scope, so this does not collide:
- routing/fallback fields → skeleton: surface gateway-side routing and fallback, and never double-retry it #309
- cached/reasoning token fields → budget: surface cached and reasoning token counts from the live usage payload #307
- the
Budgetmonotonicity fix and the fan-out test → budget + telemetry: state and test the concurrency contract under parallel fan-out #311 - whether the gateway echoes the client correlation id → verify: whether the gateway echoes the client x-correlation-id or mints its own #300
Acceptance criteria
- One named record type, documented, with a single canonical accessor path
- budget: surface cached and reasoning token counts from the live usage payload #307 and skeleton: surface gateway-side routing and fallback, and never double-retry it #309 can add their fields to it without inventing a parent
-
request_id,api_instance_idandenvironment_idpopulated on a200 - Absent or unparseable headers leave fields
None, never0or"", and
never raise on the caller's request path (§0.3) - Populated identically by the async and blocking transports
- Populated under
donkey.simulate()anddonkey mockfrom the committed
fixtures - Concurrency scope stated in the docstring and covered by a fan-out test
- Reading the record on a LiteLLM-backed adapter (
crewai,adk) reports
"not available on this surface", never a bareNoneindistinguishable
from "the gateway sent nothing" - The adapter exemption is asserted in the conformance suite, not skipped
- No
UnverifiedValueWarning— every field traces to a VERIFIED §3 row -
docs/verified-apis.md§3 notes thatx-envoy-decorator-operationis now
parsed, and what it is parsed into
Related
- budget: surface cached and reasoning token counts from the live usage payload #307 — assumes
donkey.budget.last_usage; blocked on this being named - skeleton: surface gateway-side routing and fallback, and never double-retry it #309 — assumes
donkey.last_routing; same - verify: whether the gateway echoes the client x-correlation-id or mints its own #300 — must land before a gateway correlation-id field is meaningful
- budget + telemetry: state and test the concurrency contract under parallel fan-out #311 — the concurrency contract this must not violate
- budget: Budget as a first-class object parsed from x-token-* headers #185 — the
Budgetprecedent for header-to-property - telemetry: per-call and per-run correlation IDs via donkey.run() #195 — the sent-id side, for contrast in provenance
Activity
amirkhan-ak-sf commentedon Sep 11, 2026
Sharpened the description. Two changes, one of them a correction.
Correction. The original text claimed the raw-response escape hatch "does
not exist on a LangGraph
ChatOpenAIor a CrewAILLMat all." That is wrongfor LangGraph —
langchain_openai1.6.0 exposesinclude_response_headersonChatOpenAI.model_fields, which lands the headers inresponse_metadata. So thecapability is not missing under the adapters; it is fragmented across three
unrelated mechanisms with three different shapes, one of them a private
attribute. For an abstraction layer that is the stronger version of the same
argument, and the description now says so with a table. The LiteLLM row is
marked unverified —
litellmwas not installed where I checked.The substantive addition: the hook only fires on half the adapter surface.
_on_responseis only reached when the SDK owns the transport, i.e. when anadapter is handed
_http_client(). Grepping for it gives a clean split:donkey.openai(),langgraph(http_async_client),strands(
client_args["http_client"]),openai_agents(http_client),anthropic(
http_client)crewai,adk(LiteLLM owns the transport, andcrewai.pystates this outright),llamaindexandagent_framework(neverpass a client at all)
So a last-call record populates on 4 of 8 adapters and is structurally empty on
the other 4. That turns into a third design hazard, now in the description: a
bare
request_id is Noneon CrewAI reads as "the gateway sent no id" when thetruth is "the SDK never saw the response." #195 already set the precedent for
handling exactly this — assert the exemption rather than skipping — so the
record needs a distinguishable "not available on this surface" state plus a
conformance row mirroring §8.1
correlation_id_propagated. Both are now inscope and in the acceptance criteria.
Worth separating the two causes when this is picked up: the LiteLLM pair is a
genuine impossibility, but
llamaindexandagent_frameworkmerely never passa client, which may be an unwired gap rather than a hard limit. If it is, fixing
those two moves the record from 4/8 to 6/8 coverage and is probably worth doing
first.
feat(skeleton): define the last-call record once at donkey.last_call (#…
tommasobolis commentedon Sep 15, 2026
Delivered to
developvia #376 (squash-merged as f367c37). Will reachmainon the next develop→main promotion.fix(langgraph): report last_call after ChatOpenAI.ainvoke() (#893)