Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/verified-apis.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,21 @@ attribution comes from the response `usage` block (§2). The
`x-anypoint-api-instance-id` **request** header (below) is the separate
agent→agent egress-telemetry path, not needed for direct LLM proxy calls.

As of #362, `core/lastcall.py` **parses** two of these response headers on the
happy path and surfaces them at `donkey.last_call`: `x-request-id` becomes
`LastCall.request_id` (the same value `classify()` surfaces as
`DonkeyError.request_id` on a refusal), and `x-envoy-decorator-operation`
(`api-instance-<instanceId>.<environmentId>.svc`) is split into
`LastCall.api_instance_id` (`21133858`) and `LastCall.environment_id`
(`3e6ce455-…`, a UUID — dashes but no dots, so a three-way split on `.` is
unambiguous). An absent or unrecognised shape leaves both `None` and never
raises (§0.3). The response `x-correlation-id` is **not** parsed into the record
yet — whether it echoes the client-sent id is unconfirmed (#300).

| Item | Where used | Status | Verified value | Date | Source |
|---|---|---|---|---|---|
| Per-agent attribution unit (direct proxy) | `core/config.py`, transport | VERIFIED (LIVE) | the `client_id`/`client_secret` credential pair = the agent identity; issued per client application | 2026-08-28 | live probe + `policy:list` |
| Gateway identity on response | `core/transport.py` (`x-llm-proxy-llm-provider` → `gen_ai.system`, #192) | VERIFIED (LIVE) | `x-envoy-decorator-operation: api-instance-21133858.3e6ce455-…svc`; `x-correlation-id`; `x-llm-proxy-llm-provider/-llm-model/-routing-type` | 2026-08-28 | `responses.success.headers.txt` |
| Gateway identity on response | `core/transport.py` (`x-llm-proxy-llm-provider` → `gen_ai.system`, #192); `core/lastcall.py` **parses** `x-envoy-decorator-operation` and `x-request-id` (#362) | VERIFIED (LIVE) | `x-envoy-decorator-operation: api-instance-21133858.3e6ce455-…svc`; `x-correlation-id`; `x-llm-proxy-llm-provider/-llm-model/-routing-type` | 2026-08-28 | `responses.success.headers.txt` |
| Agent→agent egress attribution header | `core/_verify.py` → transport | VERIFIED (build) | `x-anypoint-api-instance-id` → `agent-connection-telemetry` policy `sourceAgentId`; `tracing` labels `mulesoft.api.instance.id`, `mulesoft.api.type=llm` | 2026-08-28 | built `connection.json` (§12.6) |
| Business-group attribution header name | `core/_verify.py` → transport | UNVERIFIED | not surfaced as a request header in the direct-proxy path | — | — |
| Run correlation id **request** header (`X-Correlation-Id`, #195) | `core/_verify.py` `CORRELATION_ID_HEADER` → transport | UNVERIFIED | `x-correlation-id` is verified as a **response echo** (row above); that the gateway **reads** an inbound `X-Correlation-Id` as the run/trace join key is NOT confirmed. Placeholder, overridable via `correlation_header` / `DONKEY_CORRELATION_HEADER`. | — | — |
Expand Down
208 changes: 208 additions & 0 deletions python/src/donkey_kit/core/lastcall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""LastCall — the gateway's own metadata about the most recent governed model
call, read from the response (§2.3, #362, BG §1.1).

On a refusal, :class:`~donkey_kit.core.errors.DonkeyError` already hands the
developer the gateway's ids (``request_id`` and the two client-sent
correlation/call ids). On a ``200`` the same information was thrown on the
floor: consumed internally by :class:`~donkey_kit.core.budget.Budget`, turned
into an OTel span attribute that needs a backend to read, or dropped. So
"which gateway instance served this, and what id do I quote in a ticket?" was
answerable after a failure and unanswerable after a success. This record closes
that asymmetry — one named container, populated on every governed model call
from the LIVE-VERIFIED §3 response headers, reachable at ``donkey.last_call``.

**One container, defined once (#362).** #307 (cached/reasoning token counts) and
#309 (gateway routing + fallback) each sketched a *different* accessor for the
same single HTTP response (``donkey.budget.last_usage`` and
``donkey.last_routing``). Both add their fields to :class:`LastCall` rather than
inventing a parent — this module owns the container; those issues own their
field groups.

**Concurrency scope: contextvar, not instance (hazard #2).** ``Budget`` gets
away with instance-scoped last-write-wins state because a token window genuinely
is one shared resource — any recent observation is a valid observation. "The
last call" is not like that: under the parallel fan-out ``donkey.run()``
encourages, two concurrent calls race and an instance attribute would return
whichever landed last, which is *worse* than nothing because it looks
authoritative. So the record lives in a :class:`~contextvars.ContextVar`. A
framework-spawned ``asyncio`` task copies the current context at creation, so a
model call inside it sets *its own* task's record and never clobbers a sibling's
(and never leaks back to the parent that scattered the tasks). Each task reads
the call it actually made. This matches how ``core.telemetry`` already scopes the
correlation id.

**Three honest states (hazard #3).** A bare ``request_id is None`` is a lie of
omission on the adapters where the SDK does not own the transport (ADK, CrewAI,
LlamaIndex, MS Agent Framework — see
:attr:`donkey_kit.integrations._base.Adapter.observes_last_call`).
A developer there would read ``None`` as "the gateway sent no id" when the truth
is the SDK never saw the response at all. So the record carries a
:class:`LastCallStatus`: ``OBSERVED`` (a governed response populated it),
``UNOBSERVED`` (no governed model call has returned in this context yet), and
``UNAVAILABLE`` (this surface structurally cannot be observed). The conformance
suite asserts the exemption rather than skipping it (§8.1).

**The gateway correlation id is deliberately absent (hazard #1).** The response
``x-correlation-id`` is the gateway's own value; per #300 it is NOT confirmed to
be an echo of the id the client sent (the captured value is a dash-formatted
UUID, while ``new_correlation_id()`` returns un-dashed ``uuid4().hex``). Naming
it ``correlation_id`` here would collide with the run id on ``DonkeyError`` and
bake that ambiguity into the public API, so the field is not added until #300
lands and settles the semantics under an unambiguous name.

Every field traces to the LIVE-VERIFIED §3 "Gateway identity on response" row
(``responses.success.headers.txt``, 2026-08-28), so this is a consumption task,
not a verification one: no ``_verify.Unverified(...)`` guard, no
``UnverifiedValueWarning``. Unparseable or absent headers leave a field ``None``
and never raise on the caller's request path (§0.3).
"""

from __future__ import annotations

import re
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
import httpx

# VERIFIED (LIVE, docs/verified-apis.md §3, 2026-08-28). ``x-request-id`` is the
# gateway's own per-response id — the SAME header ``classify()`` surfaces as
# ``DonkeyError.request_id`` on a refusal, so the success path now reports it on
# identical terms. ``x-envoy-decorator-operation`` encodes the API-instance id
# and environment id as ``api-instance-<instanceId>.<environmentId>.svc``.
REQUEST_ID_HEADER = "x-request-id"
DECORATOR_OPERATION_HEADER = "x-envoy-decorator-operation"

# ``api-instance-21133858.3e6ce455-e3e8-4402-b830-9fcf07d9207b.svc`` → instance
# ``21133858`` + environment ``3e6ce455-…`` (a UUID; it carries dashes but no
# dots, so a plain three-way split on ``.`` is unambiguous). An unrecognised
# shape matches nothing and yields ``(None, None)`` — never a guess (§0.3).
_DECORATOR_RE = re.compile(r"^api-instance-(?P<instance>[^.]+)\.(?P<env>[^.]+)\.svc$")


def _utcnow() -> datetime:
return datetime.now(timezone.utc)


class LastCallStatus(str, Enum):
"""Why :class:`LastCall` fields are (or are not) populated — so a ``None``
is never ambiguous between "the gateway said nothing" and "the SDK never saw
the response" (hazard #3). ``str``-valued so it prints and logs cleanly."""

#: A governed model response populated this record.
OBSERVED = "observed"
#: No governed model call has returned in this context yet (a cold read).
UNOBSERVED = "unobserved"
#: This adapter surface cannot be observed — the SDK does not own its
#: transport (LiteLLM-backed) or was handed only ``default_headers`` — so no
#: response ever reaches the record here. Distinct from a mere cold read.
UNAVAILABLE = "unavailable"


def _parse_decorator_operation(raw: str | None) -> tuple[str | None, str | None]:
"""``(api_instance_id, environment_id)`` from ``x-envoy-decorator-operation``,
or ``(None, None)`` when the header is absent or not in the verified
``api-instance-<id>.<env>.svc`` shape. Never raises (§0.3)."""
if raw is None:
return None, None
m = _DECORATOR_RE.match(raw.strip())
if m is None:
return None, None
return m["instance"], m["env"]


@dataclass(frozen=True)
class LastCall:
"""What the gateway said about the most recent governed model call (§3, #362).

Reached at ``donkey.last_call``. Immutable: each governed response replaces
the context's record wholesale rather than mutating in place, so a reader
always sees one internally-consistent call. The gateway-identity fields land
here now; #307 (usage tokens) and #309 (routing/fallback) add theirs to this
same type.
"""

status: LastCallStatus
#: The gateway's own per-response id (``x-request-id``) — quote it in a
#: support ticket. Mirrors :attr:`DonkeyError.request_id` on the refusal path.
request_id: str | None = None
#: The API Manager instance id that served the call (from the decorator op).
api_instance_id: str | None = None
#: The environment id that served the call (from the decorator op).
environment_id: str | None = None
#: When the record was observed (freshness), UTC. ``None`` unless OBSERVED.
observed_at: datetime | None = None
#: For UNAVAILABLE, the adapter surface(s) that cannot observe; else ``None``.
surface: str | None = None

@property
def observed(self) -> bool:
"""True iff a governed response actually populated this record."""
return self.status is LastCallStatus.OBSERVED

@property
def available(self) -> bool:
"""False only when the current surface structurally cannot be observed
(LiteLLM-backed / ``default_headers``-only adapters); a plain cold read is
still ``available`` — it just has not observed anything yet."""
return self.status is not LastCallStatus.UNAVAILABLE

@classmethod
def from_response(cls, response: httpx.Response, *, now: datetime | None = None) -> LastCall:
"""Build an ``OBSERVED`` record from a governed response's §3 headers.

Always ``OBSERVED`` — the SDK saw a response — even if the gateway
carried no identity headers (each field then stays ``None``): "we saw the
response, it said nothing" is a true and different statement from "we
never saw a response". ``now`` is injectable for tests."""
api_instance_id, environment_id = _parse_decorator_operation(
response.headers.get(DECORATOR_OPERATION_HEADER)
)
return cls(
status=LastCallStatus.OBSERVED,
request_id=response.headers.get(REQUEST_ID_HEADER),
api_instance_id=api_instance_id,
environment_id=environment_id,
observed_at=now if now is not None else _utcnow(),
)


# The cold-read value: no governed model call has returned in this context yet.
# A single frozen instance is safe to share — it is immutable and carries no
# per-call state.
UNOBSERVED = LastCall(status=LastCallStatus.UNOBSERVED)


def unavailable(surface: str) -> LastCall:
"""The record for a surface that structurally cannot be observed — names the
adapter(s) so ``donkey.last_call`` reads "not available on this surface"
instead of an indistinguishable bare ``None`` (hazard #3)."""
return LastCall(status=LastCallStatus.UNAVAILABLE, surface=surface)


# Contextvar-scoped, never instance-scoped (hazard #2): the record for the call
# made in *this* context. Set by the transport's ``_on_response`` on every
# governed model response; read by ``donkey.last_call``. ``None`` means no call
# has been observed in this context — a cold read, surfaced as :data:`UNOBSERVED`.
_last_call: ContextVar[LastCall | None] = ContextVar("donkey_last_call", default=None)


def current_last_call() -> LastCall | None:
"""The record observed in this context, or ``None`` for a cold read. The
``donkey.last_call`` accessor turns a ``None`` into :data:`UNOBSERVED` or an
:func:`unavailable` record depending on which adapters have been used."""
return _last_call.get()


def observe_last_call(response: httpx.Response, *, now: datetime | None = None) -> LastCall:
"""Record the gateway identity from a governed model response into this
context (§2.3, #362). Called from the transport's ``_on_response`` beside
:meth:`Budget.observe`. Returns the record it set, for tests. ``now`` is
injectable; production uses the wall clock (UTC)."""
record = LastCall.from_response(response, now=now)
_last_call.set(record)
return record
22 changes: 18 additions & 4 deletions python/src/donkey_kit/core/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .config import DonkeyConfig
from .cost import CostTags
from .errors import classify
from .lastcall import observe_last_call
from .telemetry import (
POLICY_DECISION_ALLOW,
POLICY_DECISION_REFUSE,
Expand Down Expand Up @@ -588,10 +589,18 @@ async def _on_response(self, request: httpx.Request, response: httpx.Response) -
span end and ``classify()``.

Feeds the attached :class:`Budget` from the response's ``x-token-*``
headers (#185); a no-op when none is attached. A subclass that overrides
this hook must call ``super()._on_response(...)`` to keep budget tracking."""
headers (#185) and records the gateway identity of a governed model call
into ``donkey.last_call`` (#362); both are no-ops when not applicable. A
subclass that overrides this hook must call ``super()._on_response(...)``
to keep budget and last-call tracking."""
if self._budget is not None:
self._budget.observe(response)
# Only a model call feeds ``last_call`` — a token fetch or a registry
# GET shares this transport but is not "the last call" a developer means
# (#362). ``_request_model`` is the same signal ``send()`` uses to decide
# whether to open a GenAI span, so the two stay in step.
if _request_model(request) is not None:
observe_last_call(response)

async def _on_refusal(self, violation: object) -> None:
"""Refusal seam for Phase 2 reaction handlers. Defined here so the
Expand Down Expand Up @@ -795,10 +804,15 @@ def _on_request(self, request: httpx.Request) -> None:

def _on_response(self, request: httpx.Request, response: httpx.Response) -> None:
"""Called once with the final response returned to the caller. Feeds the
attached :class:`Budget` (#185); a no-op when none is attached. A subclass
that overrides this must call ``super()._on_response(...)``."""
attached :class:`Budget` (#185) and records a governed model call's
gateway identity into ``donkey.last_call`` (#362); both no-ops when not
applicable. A subclass that overrides this must call
``super()._on_response(...)``."""
if self._budget is not None:
self._budget.observe(response)
# Model calls only — see the async twin (#362).
if _request_model(request) is not None:
observe_last_call(response)

def _on_refusal(self, violation: object) -> None:
"""Refusal seam for Phase 2; no caller until ``classify()`` (#181)."""
Expand Down
40 changes: 40 additions & 0 deletions python/src/donkey_kit/donkey.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .core.budget import Budget
from .core.config import DonkeyConfig
from .core.cost import CostTags
from .core.lastcall import UNOBSERVED, LastCall, current_last_call, unavailable
from .core.telemetry import RunScope, run_scope
from .core.transport import (
DonkeyAsyncClient,
Expand Down Expand Up @@ -179,6 +180,45 @@ def budget(self) -> Budget:
so it is only as fresh as ``budget.observed_at`` (upstream gap #2)."""
return self._budget

@property
def last_call(self) -> LastCall:
"""The gateway's own metadata about the most recent governed model call in
this context — its ``request_id``, ``api_instance_id`` and
``environment_id`` (§3, #362). The success-path counterpart to the ids
:class:`~donkey_kit.core.errors.DonkeyError` hands you on a refusal.

Contextvar-scoped, not instance-scoped (hazard #2): under the parallel
fan-out ``donkey.run()`` encourages, each task reads the call *it* made,
never whichever sibling's response landed last. A framework-spawned task
copies the context at creation, so it sees its own record and never
clobbers the parent's.

Three honest states, never a bare ``None`` (hazard #3):

* **OBSERVED** — a governed response populated it (fields may still be
``None`` if the gateway sent no identity headers: "we saw the response,
it said nothing").
* **UNOBSERVED** — no governed model call has returned in this context yet.
* **UNAVAILABLE** — every adapter used on this Donkey routes outside our
transport (LiteLLM-backed ADK/CrewAI, or ``default_headers``-only
LlamaIndex / MS Agent Framework), so a response can never reach the
record. :attr:`LastCall.surface` names which. This is derived from the
adapters actually resolved, and the conformance suite asserts the
exemption rather than skipping it (§8.1).
"""
observed = current_last_call()
if observed is not None:
return observed
# No response reached the contextvar. Distinguish a cold read from a
# structurally-unobservable surface: if every adapter resolved on this
# Donkey routes outside our transport, say so by name instead of leaving
# an indistinguishable UNOBSERVED (hazard #3). An empty cache (raw client
# / not used yet) is a cold read, not UNAVAILABLE.
used = list(self._adapter_cache.values())
if used and all(not a.observes_last_call for a in used):
return unavailable(", ".join(sorted(self._adapter_cache)))
return UNOBSERVED

@overload
def openai(self, *, sync: Literal[False] = ..., **kw: Any) -> AsyncOpenAI: ...

Expand Down
10 changes: 10 additions & 0 deletions python/src/donkey_kit/integrations/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ class Adapter:
#: ImportError raised on access when it is not installed (§3.2).
extra: str = ""

#: Whether a governed model call through this adapter reaches ``donkey.last_call``
#: (#362). True when the adapter hands the framework our shared
#: :class:`DonkeyAsyncClient` (its ``_on_response`` observes the response);
#: False when the SDK does not own the transport — LiteLLM-backed adapters
#: (ADK, CrewAI) or adapters given only ``default_headers`` (LlamaIndex, MS
#: Agent Framework). A ``False`` here is why ``donkey.last_call`` reports
#: "not available on this surface" rather than a bare ``None`` (hazard #3),
#: and it is the fact the conformance suite asserts as an exemption (§8.1).
observes_last_call: bool = True

def __init__(self, cfg: DonkeyConfig, http_client: DonkeyAsyncClient) -> None:
self._cfg = cfg
self._http = http_client
Expand Down
3 changes: 3 additions & 0 deletions python/src/donkey_kit/integrations/adk.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@

class ADKAdapter(Adapter):
extra = "adk"
# LiteLLM owns the transport, so no response reaches donkey.last_call (#362,
# the same reason as the §8.1 correlation_id_propagated exemption).
observes_last_call = False

def connection_kwargs(self) -> dict[str, Any]:
"""Governed kwargs for a ``LiteLlm(model="openai/<id>", **kwargs)`` you
Expand Down
Loading