Skip to content

skeleton: define the last-call record once — #307 and #309 each assume a different container, and gateway identity is surfaced nowhere #362

Closed

Description

@amirkhan-ak-sf

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:

https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/python/src/donkey_kit/core/errors.py#L38-L49

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:

https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/python/src/donkey_kit/core/budget.py#L1-L10

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:

https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/python/src/donkey_kit/core/transport.py#L518-L527

https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/python/src/donkey_kit/core/transport.py#L720-L725

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:

https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/python/src/donkey_kit/simulator/fixtures.py#L66-L69

_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 off Donkey or Budget.
  • Populate the gateway identity fields from _on_response in 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-operation into its two components; an unrecognised
    shape leaves both None and never raises, per §0.3.
  • Add x-envoy-decorator-operation to 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:

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_id and environment_id populated on a 200
  • Absent or unparseable headers leave fields None, never 0 or "", and
    never raise on the caller's request path (§0.3)
  • Populated identically by the async and blocking transports
  • Populated under donkey.simulate() and donkey mock from 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 bare None indistinguishable
    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 that x-envoy-decorator-operation is now
    parsed, and what it is parsed into

Related

Activity

  1. added
    enhancementNew feature or request
    priority/p1Must-have: blocks a real adapter or CI gate
    size/m1-2 days, multiple files
    area:skeletonDonkeyAsyncClient, config, transport hooks, Donkey facade (1.1)
    area:telemetryOTel GenAI spans, correlation IDs, cost tags (1.6, 1.7)
    on Sep 11, 2026
  2. amirkhan-ak-sf commented on Sep 11, 2026

    @amirkhan-ak-sf
    CollaboratorAuthor

    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 ChatOpenAI or a CrewAI LLM at all." That is wrong
    for LangGraph — langchain_openai 1.6.0 exposes include_response_headers on
    ChatOpenAI.model_fields, which lands the headers in response_metadata. So the
    capability 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 — litellm was not installed where I checked.

    The substantive addition: the hook only fires on half the adapter surface.
    _on_response is only reached when the SDK owns the transport, i.e. when an
    adapter is handed _http_client(). Grepping for it gives a clean split:

    • Observes — donkey.openai(), langgraph (http_async_client), strands
      (client_args["http_client"]), openai_agents (http_client), anthropic
      (http_client)
    • Cannot observe — crewai, adk (LiteLLM owns the transport, and
      crewai.py states this outright), llamaindex and agent_framework (never
      pass 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 None on CrewAI reads as "the gateway sent no id" when the
    truth 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 in
    scope and in the acceptance criteria.

    Worth separating the two causes when this is picked up: the LiteLLM pair is a
    genuine impossibility, but llamaindex and agent_framework merely never pass
    a 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.

  3. self-assigned this
    on Sep 15, 2026
  4. tommasobolis commented on Sep 15, 2026

    @tommasobolis
    Collaborator

    Delivered to develop via #376 (squash-merged as f367c37). Will reach main on the next develop→main promotion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:adaptersFramework adapters and connection_kwargs() (1.8, 2.8)area:skeletonDonkeyAsyncClient, config, transport hooks, Donkey facade (1.1)area:telemetryOTel GenAI spans, correlation IDs, cost tags (1.6, 1.7)enhancementNew feature or requestpriority/p1Must-have: blocks a real adapter or CI gatesize/m1-2 days, multiple files

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions