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
34 changes: 31 additions & 3 deletions docs/verified-apis.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ request against the deployed gateway.
| Ingress base URL the SDK targets | `llm/client.py` | VERIFIED (LIVE) | `https://<ingress-gw-host>/<instance-path>/` — e.g. `https://agent-network-ingress-gw-zovwbn.jeg62f.usa-e2.cloudhub.io/openai-sdk/`. **No `/v1` at the ingress**; OpenAI path segment appended directly (`/responses`, and OpenAI-native routes) | 2026-08-28 | live probe |
| Endpoint/API surface | `llm/client.py` | VERIFIED (LIVE) | OpenAI **Responses API** works (`POST /openai-sdk/responses`, body `{model, input}`). Upstream registered as `https://api.openai.com/v1/`; `proxyUri http://0.0.0.0:8081/openai-sdk` | 2026-08-28 | `api:describe`, live probe |
| Auth: header name / model | `core/transport.py`, `core/config.py` | VERIFIED (LIVE) | **`client_id` + `client_secret` request headers** (NOT bearer) — enforced by `client-id-enforcement` 1.3.3. A consumer **credential pair**, mapped to an Anypoint client application | 2026-08-28 | live probe + `policy:list` |
| Model routing | `llm/client.py` | VERIFIED (LIVE) | `model-based-routing` 1.0.3 reads `model` from body → provider. Response headers `x-llm-proxy-routing-type: ModelBased`, `x-llm-proxy-llm-provider`, `x-llm-proxy-llm-model` | 2026-08-28 | live probe |
| Token accounting (cost attribution) | `llm/*`, telemetry | VERIFIED (LIVE) | response `usage: {input_tokens, output_tokens, total_tokens, input_tokens_details, output_tokens_details}`; also upstream `x-ratelimit-*` headers passed through (Go-style **duration-string** resets, e.g. `0s`/`12ms` — distinct from the gateway's own `x-llm-proxy-ratelimit`/`x-token-*` window, see §4) | 2026-08-28 | `responses.success.body.json` |
| Model routing | `llm/client.py`; consumed by `core/lastcall.py` + `core/transport.py` (#309) | VERIFIED (LIVE) | `model-based-routing` 1.0.3 reads `model` from body → provider. Response headers `x-llm-proxy-routing-type: ModelBased`, `x-llm-proxy-routing-fallback: false`, `x-llm-proxy-llm-provider: openai`, `x-llm-proxy-llm-model: gpt-5.1`, `x-llm-proxy-model-based-routing-success: Request successfully matched. …`. As of #309 the SDK surfaces these at `donkey.last_call` (`served_provider`/`served_model`/`routing_type`/`fallback`, `substituted` when served≠requested), emits them as `gen_ai.response.model` / `donkey.routing.type` / `donkey.routing.fallback` span attrs, and **does not retry** a `503` a fallback header already marked failed-over (§309, must not fight #183) | 2026-08-28 | live probe (`responses.success.headers.txt`) |
| Token accounting (cost attribution) | `llm/*`, telemetry | VERIFIED (LIVE) | response `usage: {input_tokens, output_tokens, total_tokens, input_tokens_details, output_tokens_details}` — the detail sub-objects carry `cached_tokens` / `cache_write_tokens` (under `input_tokens_details`) and `reasoning_tokens` (under `output_tokens_details`), surfaced per-call at `donkey.last_call` and on the span (#307, see §3); also upstream `x-ratelimit-*` headers passed through (Go-style **duration-string** resets, e.g. `0s`/`12ms` — distinct from the gateway's own `x-llm-proxy-ratelimit`/`x-token-*` window, see §4) | 2026-08-28 | `responses.success.body.json` |
| Request fields passed through | `llm/client.py` | VERIFIED (LIVE) | OpenAI body passed through verbatim; response returned verbatim (model `gpt-5.1`→`gpt-5.1-2025-11-13`, full Responses object) | 2026-08-28 | live probe |
| Streaming support | `llm/client.py` | VERIFIED (LIVE) | `"stream": true` → `200`, `content-type: text/event-stream`, chunked SSE (`event: response.created` / `response.in_progress` / …); same `x-llm-proxy-*` headers | 2026-08-28 | live probe (`responses.stream.*`) |
| `/models` endpoint | `llm/catalog.py` | VERIFIED (LIVE) | **Does not exist** — `GET /openai-sdk/models` → `404`, `x-llm-proxy-model-based-routing-success: Request passed through without model-based routing`. The proxy only routes requests carrying `model` in the body; no catalog endpoint. `llm/catalog.py` must source models elsewhere | 2026-08-28 | live probe (`models.notfound.headers.txt`) |
Expand Down Expand Up @@ -126,10 +126,38 @@ 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).

As of #307, `core/lastcall.py` also parses the per-call **usage token counts**
from the response *body* (the already-VERIFIED `usage` block, §2) and surfaces
them on the same record: `input_tokens` / `output_tokens` / `total_tokens`, plus
the cost-relevant `cached_tokens` / `cache_write_tokens` (from
`usage.input_tokens_details`) and `reasoning_tokens` (from
`usage.output_tokens_details`). Both the Responses-API (`input_tokens*`) and
Chat-Completions (`prompt_tokens*` / `completion_tokens*`) shapes are read. An
absent detail field (or an absent `usage` object entirely) leaves the field
`None`, **never a fabricated `0`** — a real `0` is a distinct, kept observation.
On a streamed response the counts land once the terminal SSE `usage` event has
been consumed, not at first read, and are also emitted as span attributes
(`gen_ai.usage.input_tokens`/`.output_tokens`, and `donkey.usage.cached_tokens`
/ `.cache_write_tokens` / `.reasoning_tokens` — the semconv pins no key for the
detail counts at the pinned version, so they live in the stable `donkey.*`
namespace). This is a *consumption* of the live-verified §2 shape, so it warrants
no `UnverifiedValueWarning`.

| 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
2 changes: 2 additions & 0 deletions python/src/donkey_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ContentSafetyBlocked,
DonkeyError,
GovernanceDrift,
ModelSubstituted,
PIIDetected,
PolicyViolation,
PromptInjectionBlocked,
Expand Down Expand Up @@ -74,6 +75,7 @@
"Governance",
"GovernanceCriteria",
"GovernanceDrift",
"ModelSubstituted",
"PIIDetected",
"PolicyBinding",
"PolicyPortability",
Expand Down
2 changes: 2 additions & 0 deletions python/src/donkey_kit/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ContentSafetyBlocked,
DonkeyError,
GovernanceDrift,
ModelSubstituted,
PIIDetected,
PolicyViolation,
PromptInjectionBlocked,
Expand Down Expand Up @@ -57,6 +58,7 @@
"DonkeyConfig",
"DonkeyError",
"GovernanceDrift",
"ModelSubstituted",
"PIIDetected",
"PolicyViolation",
"PromptInjectionBlocked",
Expand Down
28 changes: 28 additions & 0 deletions python/src/donkey_kit/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@

Region = Literal["us", "eu", "ca", "jp"]

# What to do when the gateway serves a different model than the one requested —
# a routing fallback substituted the model (§3, #309). ``"off"`` (default): the
# substitution is surfaced passively on ``donkey.last_call.substituted`` and the
# span, but the call succeeds. ``"raise"``: the transport raises
# :class:`~donkey_kit.core.errors.ModelSubstituted`, for callers who need model
# determinism (e.g. an evaluation whose results are only comparable per-model).
OnModelSubstitution = Literal["off", "raise"]

_TOML_NAME = ".donkey-kit.toml"


Expand Down Expand Up @@ -85,6 +93,10 @@ class DonkeyConfig:
# very content the platform just masked to whatever OTLP collector is wired
# up (#306, BG §1.6). Opting in is the developer assuming that obligation.
telemetry_capture_content: bool = False
# What to do when the gateway serves a different model than requested (§3,
# #309). Default "off" — the substitution is surfaced passively on
# ``donkey.last_call``; "raise" opts into a hard ``ModelSubstituted`` error.
on_model_substitution: OnModelSubstitution = "off"

# ----------------------------------------------------------------- factory
@classmethod
Expand Down Expand Up @@ -148,6 +160,9 @@ def pick(env: str, key: str, default: object) -> object:
telemetry_capture_content=_as_bool(
pick("DONKEY_TELEMETRY_CAPTURE_CONTENT", "telemetry_capture_content", False)
),
on_model_substitution=_as_substitution(
pick("DONKEY_ON_MODEL_SUBSTITUTION", "on_model_substitution", "off")
),
)

# --------------------------------------------------------------- derived
Expand Down Expand Up @@ -242,6 +257,19 @@ def _as_bool(v: object) -> bool:
return str(v).strip().lower() in ("1", "true", "yes", "on")


def _as_substitution(v: object) -> OnModelSubstitution:
"""Coerce and validate ``on_model_substitution`` (§3, #309). An unknown value
is a config mistake worth reporting up front — a silent fall-back to ``"off"``
would leave a caller who typed ``"error"`` believing they had opted into
strictness. Validated at resolve time, like ``region``."""
token = str(v).strip().lower()
if token not in ("off", "raise"):
raise ConfigError(
f"Unknown on_model_substitution {v!r}. Expected 'off' or 'raise'."
)
return cast(OnModelSubstitution, token)


def _load_toml() -> dict[str, object]:
"""Read ``[donkey]`` table from ``.donkey-kit.toml`` in cwd or
``$XDG_CONFIG_HOME``. Missing file is fine; malformed file raises."""
Expand Down
31 changes: 31 additions & 0 deletions python/src/donkey_kit/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,37 @@ def __init__(
self.reset_at = reset_at


class ModelSubstituted(DonkeyError):
"""The gateway served a *different* model than the one requested — a routing
fallback substituted the model, and ``on_model_substitution="raise"`` opted
the caller into treating that as an error (§3, #309).

Deliberately NOT a :class:`PolicyViolation`: the request was neither refused
nor failed — it succeeded, just against a model the developer did not choose.
It is a client-side determinism signal (like :class:`BudgetReserveReached`),
off by default; a caller who has not opted in gets the substitution surfaced
passively on ``donkey.last_call.substituted`` instead. Carries the response,
so a handler can still read the served completion if it decides to accept it.

``request_id`` (the gateway's own id) is inherited from :class:`DonkeyError`
and populated from the response, so a substitution can be quoted in a ticket
on the same terms as any other gateway event."""

def __init__(
self,
message: str,
*,
requested_model: str,
served_model: str,
served_provider: str | None = None,
**kw: Any,
) -> None:
super().__init__(message, **kw)
self.requested_model = requested_model
self.served_model = served_model
self.served_provider = served_provider


class PromptInjectionBlocked(PolicyViolation):
policy = "prompt-injection-protection"

Expand Down
Loading