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
18 changes: 16 additions & 2 deletions docs/verified-apis.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,28 @@ 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` |
| Auth: model-wallet ingress (alt to `client_id`/`client_secret`, #372) | `core/transport.py`, `core/config.py` — UNVERIFIED, not wired | UNVERIFIED | Wallet-backed model proxies auth via an **IdP-issued JWT + a client ID**, with **no `client_secret`**: the client ID travels as the `X-Client-Id` request header (selects the wallet) *and* as a `client_id` **JWT claim** (`#[authentication.properties.claims.client_id]`, read after the **JWT Validation** policy publishes verified claims); requires an org **IdP** (JWKS URL / signing key — "orgs without an IdP can't use model wallets"); the default **DataWeave Headers Transformation** + **Client ID Enforcement** policies are disabled. A **parallel** ingress model, NOT a replacement of the LIVE `client_id`/`client_secret` pair above. **Live-capture worklist:** confirm the exact `X-Client-Id` header name/casing, the JWT claim path, and the request auth shape against a wallet-enabled sandbox with an IdP configured — until then no `_verify.py` value is marked verified and no guard comes off (§12.8; a doc read is not a live capture). | — | `docs.mulesoft.com/general/exp-model-wallets-manage` (doc read, not a live probe) |
| Auth: model-wallet ingress (alt to `client_id`/`client_secret`, #372) | `core/transport.py`, `core/config.py` — contract verified; SDK wiring landed in #509 (`llm_proxy_auth="jwt"` + `llm_proxy_wallet_client_id` + `Donkey(llm_auth=…)`) | VERIFIED (LIVE) | Wallet-backed model proxies auth via an **IdP-issued JWT + a client ID**, with **no `client_secret`**: the client ID travels as the **`X-Client-Id`** request header (exact casing confirmed; value = the wallet's system-generated `clientId`, `ddk-model-wallet` here — read it from the omni API response, don't assume) which selects the wallet (echoed back as response header **`x-model-wallet-selected`**), *and* as a `client_id` **JWT claim** read by the LLM Proxy Core Policy at `#[authentication.properties.claims.client_id]` after the **JWT Validation** policy validates + publishes claims. **The JWT rides as `Authorization: Bearer <JWT>`** — this was an *assumption* from the doc read (Bearer is nowhere quoted on the source page); now **confirmed** (policy `jwtOrigin: httpBearerAuthenticationHeader`; missing header → `400 {"error":"JWT Token is required."}`, `www-authenticate: Bearer`; invalid/expired token → `401 {"error":"Invalid token."}`). No `client_id`/`client_secret` request headers are sent and the call still succeeds → the default **DataWeave Headers Transformation** + **Client ID Enforcement** policies are confirmed **disabled**. Requires an org **IdP** (JWKS URL / signing key — "orgs without an IdP can't use model wallets"). A **parallel** ingress model, NOT a replacement of the LIVE `client_id`/`client_secret` pair above. | 2026-09-21 | live probe — `tests/fixtures/anypoint/model_wallet/` (`ddk-model-wallet`, instance `21186246`, Sandbox); shape corroborates `docs.mulesoft.com/general/exp-model-wallets-manage` |
| 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`) |
| Supported providers | `llm/catalog.py`, `governance.py` | VERIFIED (CLI) | openai, azureopenai, gemini, **bedrock**, **anthropic** (each a `*-llm-provider-policy-flex` in org `68ef9520…`) | 2026-08-28 | `exchange:asset:list llm` |

**Note — single-header apikey/bearer convenience (no-op for DDK).** The default
proxy's `dataweave-headers-transformation` policy (the §6 policy-stack row) is a
gateway convenience for thin clients that carry only one auth field: it accepts a
**colon-joined credential in a single header** — `authorization: Bearer
<client_id>:<client_secret>` or `apikey: <client_id>:<client_secret>` — and splits
it back into the `client_id` / `client_secret` pair, but **only** when neither
header is already present. DDK always emits the two-header pair (LIVE-verified
above), so the request lands in the policy's pass-through branch and the
transformation is a **no-op** for DDK traffic — the `Authorization` bearer DDK
sends (an `llm_proxy_key` or the inert sentinel filling the OpenAI SDK's mandatory
`api_key` slot) is never read as a credential. DDK deliberately does **not** add
an apikey/bearer emit mode; the two-header pair is the single auth path in
client-id mode (`client_id` is also the §3 attribution unit).

## 3. Token attribution headers (**highest-priority unknown**, verification discipline)

Without these the core value proposition (per-agent cost attribution) does not work.
Expand Down Expand Up @@ -158,7 +172,7 @@ 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` |
| Per-agent attribution unit (model-wallet ingress, #372) | `core/config.py`, transport | UNVERIFIED | under a **model wallet**, the caller is identified by the JWT `client_id` claim + the `X-Client-Id` request header (not the `client_id`/`client_secret` pair) and requires an org IdP — see the §2 "model-wallet ingress" row for the full shape and live-capture worklist. Doc read only; nothing wired or verified. | — | `docs.mulesoft.com/general/exp-model-wallets-manage` (doc read) |
| Per-agent attribution unit (model-wallet ingress, #372) | `core/config.py`, transport — SDK wiring landed in #509 | VERIFIED (LIVE) | under a **model wallet**, the caller is identified by the JWT `client_id` claim (`ddk-model-wallet-client`) **and** the wallet's `X-Client-Id` request header (`ddk-model-wallet`), matched against the wallet's `predicates` (`group=ddk` AND `client_id=…`) — not the `client_id`/`client_secret` pair. Requires an org IdP. Budget counts against the matched wallet's `modelId` (`openai:gpt-5-mini`); see the §2 "model-wallet ingress" row for the full auth shape. | 2026-09-21 | live probe — `tests/fixtures/anypoint/model_wallet/` (`wallet.definition.json`, `jwt.claims.json`, `responses.success.headers.txt` → `x-model-wallet-selected`) |
| 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 | — | — |
Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "donkey-kit"
version = "0.1.0.dev3"
version = "0.1.0.dev4"
description = "An SDK for consuming Agent Fabric capabilities (governed model and tool access) from your own agent framework."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion python/src/donkey_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
PublicationAssetType,
)

__version__ = "0.1.0.dev3"
__version__ = "0.1.0.dev4"

__all__ = [
"STRICT",
Expand Down
22 changes: 22 additions & 0 deletions python/src/donkey_kit/core/_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ def blocked(what: str) -> NotImplementedError:
LLM_PROXY_CLIENT_ID_HEADER = "client_id"
LLM_PROXY_CLIENT_SECRET_HEADER = "client_secret"

# --- LLM proxy model-wallet ingress (docs/verified-apis.md §2/§3, #372) —
# VERIFIED (LIVE 2026-09-21) ------------
# The PARALLEL ingress model on a wallet-backed proxy: the caller is identified
# by an IdP-issued JWT + a client ID, with NO `client_secret`. The default
# DataWeave Headers Transformation + Client ID Enforcement policies are disabled;
# JWT Validation identifies the caller instead. These are confirmed names, not
# placeholders — captured live against `ddk-model-wallet` (instance 21186246,
# Sandbox), fixtures in tests/fixtures/anypoint/model_wallet/. The SDK does not
# yet emit this path (transport still sends only the CIE pair); wiring is #509.
# * the wallet-selector REQUEST header (value = the wallet's generated clientId);
LLM_PROXY_WALLET_CLIENT_ID_HEADER = "X-Client-Id"
# * the JWT rides as `Authorization: Bearer <JWT>` (jwtOrigin
# httpBearerAuthenticationHeader); Bearer was an assumption in the doc read,
# confirmed live;
LLM_PROXY_WALLET_JWT_HEADER = "Authorization"
LLM_PROXY_WALLET_JWT_SCHEME = "Bearer"
# * the JWT claim the LLM Proxy Core Policy reads as the caller id, published by
# JWT Validation and read via `#[authentication.properties.claims.client_id]`;
LLM_PROXY_WALLET_JWT_CLIENT_ID_CLAIM = "client_id"
# * the response header the gateway echoes naming the matched wallet.
LLM_PROXY_WALLET_SELECTED_HEADER = "x-model-wallet-selected"

# --- Region host map (docs/verified-apis.md §1) ------------------------------
# UNVERIFIED — Hyperforce region hosts in particular need confirmation.
REGION_HOSTS: dict[str, str] = {
Expand Down
73 changes: 67 additions & 6 deletions python/src/donkey_kit/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@
# determinism (e.g. an evaluation whose results are only comparable per-model).
OnModelSubstitution = Literal["off", "raise"]

# How the caller authenticates to the LLM proxy DATA plane (BG §1.1, #509).
# ``"client-id"`` (default): the LIVE-VERIFIED ``client_id``/``client_secret``
# request-header pair (client-id-enforcement, docs/verified-apis.md §2/§3). ``"jwt"``:
# a wallet-backed proxy where Client ID Enforcement is disabled and the caller is
# identified from an IdP-issued JWT validated by the JWT Validation policy, with
# NO ``client_secret`` — the parallel ingress captured live under #372
# (docs/verified-apis.md §2/§3). The mode is durable config even though the JWT itself is
# not: the rotating credential enters through an ``AuthProvider`` (see
# ``Donkey(llm_auth=...)``), never a config field. Inferring the mode from "no
# client_id set" is deliberately NOT done — it would turn a typo into a silent
# mode switch and make the per-mode missing-field report misleading (#509).
LlmProxyAuth = Literal["client-id", "jwt"]

_TOML_NAME = ".donkey-kit.toml"


Expand All @@ -61,6 +74,20 @@ class DonkeyConfig:
# so this is rarely needed; leave unset to use a sentinel.
llm_proxy_key: str | None = None # env: DONKEY_LLM_PROXY_KEY

# --- LLM proxy auth mode (BG §1.1, #509) ---
# Which data-plane ingress the proxy uses. Default ``"client-id"`` (the
# LIVE-VERIFIED CIE header pair above). ``"jwt"`` selects the model-wallet
# ingress: no ``client_secret``, an IdP JWT supplied dynamically via an
# ``AuthProvider`` (``Donkey(llm_auth=...)``), and a durable wallet-selector
# client ID sent as the ``X-Client-Id`` header (docs/verified-apis.md §2/§3, #372).
llm_proxy_auth: LlmProxyAuth = "client-id" # env: DONKEY_LLM_PROXY_AUTH
# The wallet-selector client ID sent as ``X-Client-Id`` in JWT mode — the
# wallet's system-generated clientId (read from the omni API, see the
# ddk-configure-llm-proxy-model-wallet skill). Durable, not rotating, so it IS
# a config field (the rotating JWT is not); required in JWT mode. Ignored in
# client-id mode.
llm_proxy_wallet_client_id: str | None = None # env: DONKEY_LLM_PROXY_WALLET_CLIENT_ID

# --- Attribution (real header names: see docs/verified-apis.md §3) ---
application_name: str | None = None # env: DONKEY_APP_NAME
business_group: str | None = None # env: DONKEY_BUSINESS_GROUP
Expand Down Expand Up @@ -136,6 +163,12 @@ def pick(env: str, key: str, default: object) -> object:
pick("DONKEY_LLM_PROXY_CLIENT_SECRET", "llm_proxy_client_secret", None)
),
llm_proxy_key=_opt(pick("DONKEY_LLM_PROXY_KEY", "llm_proxy_key", None)),
llm_proxy_auth=_as_llm_proxy_auth(
pick("DONKEY_LLM_PROXY_AUTH", "llm_proxy_auth", "client-id")
),
llm_proxy_wallet_client_id=_opt(
pick("DONKEY_LLM_PROXY_WALLET_CLIENT_ID", "llm_proxy_wallet_client_id", None)
),
application_name=_opt(pick("DONKEY_APP_NAME", "application_name", None)),
business_group=_opt(pick("DONKEY_BUSINESS_GROUP", "business_group", None)),
correlation_header=_opt(
Expand Down Expand Up @@ -196,12 +229,26 @@ def validated(self, *, need: str = "control_plane") -> DonkeyConfig:
elif need == "llm":
if not self.llm_proxy_url:
missing.append("llm_proxy_url (env DONKEY_LLM_PROXY_URL)")
if not self.llm_proxy_client_id:
missing.append("llm_proxy_client_id (env DONKEY_LLM_PROXY_CLIENT_ID)")
if not self.llm_proxy_client_secret:
missing.append(
"llm_proxy_client_secret (env DONKEY_LLM_PROXY_CLIENT_SECRET)"
)
if self.llm_proxy_auth == "jwt":
# Model-wallet ingress (#509): NO client_secret — CIE is disabled
# and the caller is identified from the JWT. The rotating JWT is
# supplied via an AuthProvider (Donkey(llm_auth=...)), not config,
# so it is not a "missing field" here — the provider-attached check
# lives where the provider is known (LLMClient.client()). What IS
# required here is the durable wallet-selector client ID.
if not self.llm_proxy_wallet_client_id:
missing.append(
"llm_proxy_wallet_client_id (env DONKEY_LLM_PROXY_WALLET_CLIENT_ID) "
"— the wallet-selector X-Client-Id, required in jwt auth mode"
)
else:
# client-id enforcement (default): the LIVE-VERIFIED CIE pair.
if not self.llm_proxy_client_id:
missing.append("llm_proxy_client_id (env DONKEY_LLM_PROXY_CLIENT_ID)")
if not self.llm_proxy_client_secret:
missing.append(
"llm_proxy_client_secret (env DONKEY_LLM_PROXY_CLIENT_SECRET)"
)
else:
raise ConfigError(f"Unknown capability {need!r} passed to validated().")

Expand Down Expand Up @@ -272,6 +319,20 @@ def _as_substitution(v: object) -> OnModelSubstitution:
return cast(OnModelSubstitution, token)


def _as_llm_proxy_auth(v: object) -> LlmProxyAuth:
"""Coerce and validate ``llm_proxy_auth`` (BG §1.1, #509). An unknown value is
a config mistake worth reporting up front — a silent fall-back to
``"client-id"`` would leave a caller who typed ``"oauth"`` believing they had
selected the wallet ingress. Validated at resolve time, like ``region`` and
``on_model_substitution``."""
token = str(v).strip().lower()
if token not in ("client-id", "jwt"):
raise ConfigError(
f"Unknown llm_proxy_auth {v!r}. Expected 'client-id' or 'jwt'."
)
return cast(LlmProxyAuth, 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
Loading
Loading