diff --git a/docs/verified-apis.md b/docs/verified-apis.md index d9ff2bbd..230c7adb 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -88,7 +88,7 @@ request against the deployed gateway. | Ingress base URL the SDK targets | `llm/client.py` | VERIFIED (LIVE) | `https:////` — 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 `** — 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 | @@ -96,6 +96,20 @@ request against the deployed gateway. | `/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 +:` or `apikey: :` — 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. @@ -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 | — | — | diff --git a/python/pyproject.toml b/python/pyproject.toml index ced08528..9ecb576c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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" diff --git a/python/src/donkey_kit/__init__.py b/python/src/donkey_kit/__init__.py index b8717f7c..cdf3a03b 100644 --- a/python/src/donkey_kit/__init__.py +++ b/python/src/donkey_kit/__init__.py @@ -52,7 +52,7 @@ PublicationAssetType, ) -__version__ = "0.1.0.dev3" +__version__ = "0.1.0.dev4" __all__ = [ "STRICT", diff --git a/python/src/donkey_kit/core/_verify.py b/python/src/donkey_kit/core/_verify.py index eec21c2d..be1c3de4 100644 --- a/python/src/donkey_kit/core/_verify.py +++ b/python/src/donkey_kit/core/_verify.py @@ -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 ` (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] = { diff --git a/python/src/donkey_kit/core/config.py b/python/src/donkey_kit/core/config.py index 58457f05..06a02494 100644 --- a/python/src/donkey_kit/core/config.py +++ b/python/src/donkey_kit/core/config.py @@ -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" @@ -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 @@ -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( @@ -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().") @@ -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.""" diff --git a/python/src/donkey_kit/core/transport.py b/python/src/donkey_kit/core/transport.py index 1e82477a..f6f35de5 100644 --- a/python/src/donkey_kit/core/transport.py +++ b/python/src/donkey_kit/core/transport.py @@ -166,17 +166,28 @@ def attribution_headers(cfg: DonkeyConfig) -> dict[str, str]: def proxy_auth_headers(cfg: DonkeyConfig) -> dict[str, str]: - """The LLM-proxy consumer-auth request headers, LIVE-VERIFIED (docs/verified-apis.md §2/§3): - a ``client_id`` + ``client_secret`` pair enforced by ``client-id-enforcement``. - This pair IS the per-agent attribution identity, NOT a bearer token. - - Combined here with :func:`attribution_headers` so a single ``default_headers`` - snapshot carries both when handed to a native framework client. Missing - credentials are simply omitted — :meth:`DonkeyConfig.validated` is where the - absence is reported with actionable guidance. + """The LLM-proxy consumer-auth request headers for the current auth mode + (BG §1.1), combined with :func:`attribution_headers` so a single + ``default_headers`` snapshot carries both when handed to a native framework + client. Missing credentials are simply omitted — :meth:`DonkeyConfig.validated` + is where the absence is reported with actionable guidance. + + * ``client-id`` mode (default): the LIVE-VERIFIED ``client_id`` + + ``client_secret`` pair enforced by ``client-id-enforcement`` + (docs/verified-apis.md §2/§3). This pair IS the per-agent attribution identity. + * ``jwt`` mode (model-wallet ingress, #372/#509): the durable wallet-selector + ``X-Client-Id`` only — and NEVER the CIE pair (Client ID Enforcement is + disabled on a wallet proxy). The rotating JWT is NOT in this snapshot: it is + injected per-send by :meth:`DonkeyAsyncClient._inject_headers` from the + attached ``AuthProvider``, because a static snapshot cannot carry a + credential that rotates. """ headers = attribution_headers(cfg) + if cfg.llm_proxy_auth == "jwt": + if cfg.llm_proxy_wallet_client_id: + headers[_verify.LLM_PROXY_WALLET_CLIENT_ID_HEADER] = cfg.llm_proxy_wallet_client_id + return headers if cfg.llm_proxy_client_id: headers[_verify.LLM_PROXY_CLIENT_ID_HEADER] = cfg.llm_proxy_client_id if cfg.llm_proxy_client_secret: @@ -606,6 +617,13 @@ def __init__( **kw, # type: ignore[arg-type] ) + @property + def token_provider(self) -> AuthProvider | None: + """The attached :class:`AuthProvider`, if any. Read by ``LLMClient`` to + tell an attached JWT provider from a missing one in ``jwt`` auth mode + (#509) — the provider lives on the transport, not on ``DonkeyConfig``.""" + return self._token_provider + async def _inject_headers(self, request: httpx.Request) -> None: _apply_base_headers( self._cfg, @@ -613,15 +631,36 @@ async def _inject_headers(self, request: httpx.Request) -> None: ensure_correlation_id(), correlation_header=self._correlation_header, ) + # jwt auth mode (model-wallet ingress, #509/#372): stamp the durable + # wallet-selector ``X-Client-Id`` on every data-plane send (not just the + # default_headers snapshot), so adapters routed through this shared client + # carry it too. The name is VERIFIED (docs/verified-apis.md §2/§3). + if self._cfg.llm_proxy_auth == "jwt" and self._cfg.llm_proxy_wallet_client_id: + request.headers.setdefault( + _verify.LLM_PROXY_WALLET_CLIENT_ID_HEADER, + self._cfg.llm_proxy_wallet_client_id, + ) if self._token_provider is not None: token = await self._token_provider.token() - # Control plane uses OAuth2 client_credentials → ``Authorization: - # Bearer`` (VERIFIED docs/verified-apis.md §12.1). The LLM proxy (data - # plane) instead uses - # client_id/client_secret headers and gets NO token provider, so it - # never reaches here; ``setdefault`` also yields to the OpenAI SDK's - # own Authorization if one was set at the call site. - request.headers.setdefault("Authorization", f"Bearer {token}") + # Two token-bearing ingresses ride here, both as ``Authorization: + # Bearer`` (VERIFIED): the control-plane OAuth2 client_credentials token + # (docs/verified-apis.md §12.1) and — when jwt auth mode is selected — the + # data-plane model-wallet JWT (docs/verified-apis.md §2/§3, #372). The header + # name/scheme come from the verified wallet constants so there is one + # source; the control-plane token happens to use the identical shape. + header = _verify.LLM_PROXY_WALLET_JWT_HEADER + value = f"{_verify.LLM_PROXY_WALLET_JWT_SCHEME} {token}" + if self._cfg.llm_proxy_auth == "jwt": + # The OpenAI SDK pre-sets ``Authorization: Bearer `` from + # its mandatory key slot; a wallet proxy READS this header as the + # JWT, so we must OVERRIDE that sentinel with the fresh per-send + # token. ``setdefault`` would yield to the sentinel and 401. + request.headers[header] = value + else: + # Control plane: the OpenAI SDK is not in this path, and on a CIE + # data-plane call the proxy ignores Authorization — so yield to any + # call-site Authorization rather than clobber it. + request.headers.setdefault(header, value) # --- lifecycle hooks (the skeleton's attachment points, BG §1.1) -------- # These are the *logical* per-``send()`` seams the Phase 1 six-piece minimum diff --git a/python/src/donkey_kit/donkey.py b/python/src/donkey_kit/donkey.py index 08569f7f..96eb3334 100644 --- a/python/src/donkey_kit/donkey.py +++ b/python/src/donkey_kit/donkey.py @@ -128,6 +128,7 @@ def __init__( config: DonkeyConfig | None = None, *, auth: AuthProvider | None = None, + llm_auth: AuthProvider | None = None, ) -> None: self._cfg = config or DonkeyConfig.from_env() # Zero-config OTLP export (BG §1.6, #194): installs an exporter when an @@ -140,11 +141,19 @@ def __init__( self._auth, self._owned_auth_http = self._default_auth(self._cfg) else: self._auth = auth + # The data-plane (LLM proxy) credential is SEPARATE from the control-plane + # one (BG §1.1). In jwt/model-wallet mode (#509) the rotating JWT enters + # through this caller-supplied AuthProvider and drives the shared data-plane + # client; in the default client-id mode nothing changes — the control-plane + # provider (or None) rides the client exactly as before, and the CIE proxy + # ignores its bearer. So an existing client-id config is byte-identical. + self._llm_auth = llm_auth + data_plane_auth = llm_auth if self._cfg.llm_proxy_auth == "jwt" else self._auth # One Budget per Donkey (never global, BG §1.3 / #185): both transports feed # it in-band from every response's x-token-* headers. self._budget = Budget() self._http: DonkeyAsyncClient = build_http_client( - self._cfg, self._auth, budget=self._budget + self._cfg, data_plane_auth, budget=self._budget ) # Built only if someone asks for a blocking client, so the common async # path never opens a connection pool it will not use. diff --git a/python/src/donkey_kit/llm/client.py b/python/src/donkey_kit/llm/client.py index 95d17c89..1002a1de 100644 --- a/python/src/donkey_kit/llm/client.py +++ b/python/src/donkey_kit/llm/client.py @@ -80,6 +80,30 @@ def client(self, *, sync: bool = False, **kw: Any) -> AsyncOpenAI | OpenAI: """ self._cfg.validated(need="llm") + if self._cfg.llm_proxy_auth == "jwt": + # The rotating JWT enters through an AuthProvider on the shared async + # transport, never a config field (#509). Two things config alone + # cannot check, enforced here where the provider is known: + if sync: + # Proposal 6 / AC 7: jwt mode is async-only. The blocking + # DonkeyClient takes no AuthProvider (the protocol is async-only), + # so a sync client could only send a stale or absent token — never + # hand one back silently unauthenticated. + raise ConfigError( + "JWT / model-wallet auth mode (llm_proxy_auth='jwt') is async-only: " + "the credential is a rotating JWT fetched from an async AuthProvider, " + "and the blocking client cannot await it. Use the async client — " + "`donkey.llm.client()` / `donkey.openai()` without sync=True — or switch " + "to client-id auth for a synchronous caller." + ) + if self._http.token_provider is None: + # AC 1: jwt mode with no provider attached fails with actionable guidance. + raise ConfigError( + "llm_proxy_auth='jwt' requires an AuthProvider that supplies the " + "model-wallet JWT, but none is attached. Pass one when constructing " + "Donkey, e.g. `Donkey(llm_auth=StaticToken(jwt))` or a custom " + "AuthProvider that refreshes the token (see donkey_kit.core.auth)." + ) try: from openai import AsyncOpenAI, OpenAI except ImportError as exc: # pragma: no cover - install-time guidance diff --git a/python/tests/conformance/suite.py b/python/tests/conformance/suite.py index 2f7d5e19..d74c5bed 100644 --- a/python/tests/conformance/suite.py +++ b/python/tests/conformance/suite.py @@ -42,6 +42,13 @@ # outside our transport cannot observe it and record an asserted exemption # below rather than a bare None. "gateway_identity_observed", + # In jwt / model-wallet auth mode (#509) the rotating JWT is injected per-send + # from the attached AuthProvider, so an expired token is refreshed on the next + # request (and on a 401, via the transport's invalidate→retry-once loop). + # Holds only for adapters routed through our httpx client; the frameworks that + # build their own transport from a one-time default_headers snapshot pin the + # token at construction and record an asserted exemption below (BG §1.8). + "jwt_token_refreshed", ] # Documented, ASSERTED exemptions — published in the README (the conformance kit). A framework @@ -74,25 +81,47 @@ "gateway identity and reports UNAVAILABLE (#362)." ) +# jwt_token_refreshed has the SAME structural cause as the correlation-id and +# last-call exemptions (#509): a rotating model-wallet JWT can only be refreshed +# per-send by our transport. A framework that owns its transport (LiteLLM) or is +# handed only a one-time default_headers snapshot pins whatever token existed at +# construction and starts 401-ing after it expires — so jwt auth mode is +# unsupported on those adapters, asserted here rather than silently skipped. +_LITELLM_JWT_EXEMPTION = ( + "LiteLLM owns the transport; we cannot inject our httpx client, so a rotating " + "model-wallet JWT cannot be refreshed per-send and would expire. Use client-id " + "auth with this adapter, or route the raw/LangGraph client for jwt mode (#509)." +) +_DEFAULT_HEADERS_JWT_EXEMPTION = ( + "The adapter is handed only a static default_headers snapshot, which pins the " + "JWT at construction; without our httpx client the token cannot be refreshed " + "and 401s after expiry. jwt auth mode is async-only through our transport (#509)." +) + KNOWN_LIMITATIONS: dict[str, dict[str, str]] = { # ADK and CrewAI both reach models through LiteLLM (BG §1.8). "adk": { "correlation_id_propagated": _LITELLM_TRANSPORT_EXEMPTION, "gateway_identity_observed": _LITELLM_LAST_CALL_EXEMPTION, + "jwt_token_refreshed": _LITELLM_JWT_EXEMPTION, }, "crewai": { "correlation_id_propagated": _LITELLM_TRANSPORT_EXEMPTION, "gateway_identity_observed": _LITELLM_LAST_CALL_EXEMPTION, + "jwt_token_refreshed": _LITELLM_JWT_EXEMPTION, }, # LlamaIndex and MS Agent Framework get only a static default_headers snapshot, # no httpx client (BG §1.8). The snapshot deliberately excludes the per-run - # correlation ID and cannot observe last_call responses. + # correlation ID, cannot observe last_call responses, and cannot carry a + # rotating JWT (it pins the token at construction). "llamaindex": { "correlation_id_propagated": _DEFAULT_HEADERS_CORRELATION_EXEMPTION, "gateway_identity_observed": _DEFAULT_HEADERS_LAST_CALL_EXEMPTION, + "jwt_token_refreshed": _DEFAULT_HEADERS_JWT_EXEMPTION, }, "agent_framework": { "correlation_id_propagated": _DEFAULT_HEADERS_CORRELATION_EXEMPTION, "gateway_identity_observed": _DEFAULT_HEADERS_LAST_CALL_EXEMPTION, + "jwt_token_refreshed": _DEFAULT_HEADERS_JWT_EXEMPTION, }, } diff --git a/python/tests/conformance/test_transport_exemptions.py b/python/tests/conformance/test_transport_exemptions.py index 7d7e4289..0bb4f86c 100644 --- a/python/tests/conformance/test_transport_exemptions.py +++ b/python/tests/conformance/test_transport_exemptions.py @@ -39,6 +39,7 @@ _GATEWAY_SCENARIO = "gateway_identity_observed" _CORRELATION_SCENARIO = "correlation_id_propagated" +_JWT_SCENARIO = "jwt_token_refreshed" _HEADER_ONLY_ADAPTERS = {"llamaindex", "agent_framework"} @@ -50,7 +51,24 @@ def _adapter_class(attr: str) -> type[Adapter]: def test_scenario_is_registered() -> None: - assert {_GATEWAY_SCENARIO, _CORRELATION_SCENARIO} <= set(CONFORMANCE_SCENARIOS) + assert {_GATEWAY_SCENARIO, _CORRELATION_SCENARIO, _JWT_SCENARIO} <= set( + CONFORMANCE_SCENARIOS + ) + + +def test_jwt_exemption_recorded_for_transport_detached_adapters() -> None: + # A rotating model-wallet JWT can only be refreshed per-send by our transport + # (#509). The adapters that own their transport (LiteLLM) or take only a + # one-time default_headers snapshot pin the token at construction, so jwt auth + # mode is unsupported on them — asserted here, exactly like the last_call and + # correlation-id exemptions, never a silent skip. That is the SAME set of four + # non-transport-routed adapters. + exempted = { + adapter + for adapter, limits in KNOWN_LIMITATIONS.items() + if _JWT_SCENARIO in limits + } + assert exempted == {"adk", "crewai", "llamaindex", "agent_framework"} def test_every_known_limitation_names_a_real_scenario() -> None: diff --git a/python/tests/fixtures/anypoint/model_wallet/README.md b/python/tests/fixtures/anypoint/model_wallet/README.md new file mode 100644 index 00000000..57eeca98 --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/README.md @@ -0,0 +1,88 @@ +# Model-wallet JWT ingress — LIVE capture (docs/verified-apis.md §2/§3, #372) + +Captured **2026-09-21** from a real deployed, **wallet-enabled** Agent Network +LLM proxy in the DDK sandbox org `826ba985-9894-4f7b-ba71-6965832ef0d6`, env +**Sandbox** (`14d3b31e-4e3b-4d90-b77a-63c9d6b7ea6a`). Proxy asset +`ddk-model-wallet` v1.0, API Manager instance **`21186246`**, deployed to the +`shared-omni-gateway` Flex Gateway (v1.13.5), upstream `https://api.openai.com/v1/`, +routing to `openai:gpt-5-mini`. + +This is the **parallel ingress model** to the `client_id`/`client_secret` pair +captured in `../llm_proxy/` — a wallet-backed proxy identifies the caller from an +**IdP-issued JWT + a client ID**, with **no `client_secret`**. It is the first +live confirmation of the model-wallet rows in `docs/verified-apis.md` §2/§3 +(previously UNVERIFIED, doc-read only). See issue #372. + +## How the caller authenticates (verified live) + +- **JWT rides as `Authorization: Bearer `.** This was an *assumption* in the + source doc read (`Authorization: Bearer` is nowhere quoted on + `docs.mulesoft.com/general/exp-model-wallets-manage`); it is now **verified** — + the JWT Validation policy's `jwtOrigin` is `httpBearerAuthenticationHeader`, and + a request with no `Authorization` header is rejected with + `{"error":"JWT Token is required."}` (see `reject.jwt-missing.*`). +- **`X-Client-Id: ddk-model-wallet`** selects the wallet. The value is the + wallet's system-generated `clientId` (read from the omni API response — see + `wallet.definition.json`; here it mirrors the wallet name, but always read it + from the response, do not assume the format). The gateway echoes the match back + as the response header **`x-model-wallet-selected: ddk-model-wallet`**. +- **No `client_id`/`client_secret` request headers are sent** and the call still + succeeds — confirming the default **Client ID Enforcement** and **DataWeave + Headers Transformation** policies are disabled on a wallet proxy, and the caller + is identified purely from the JWT + `X-Client-Id`. +- **Claim path.** The LLM Proxy Core Policy's Client ID reads + `#[authentication.properties.claims.client_id]` — the `client_id` claim the JWT + Validation policy publishes after validating the token. The wallet's + `predicates` (`group=ddk` AND `client_id=ddk-model-wallet-client`) are matched + against the token's claims; both are present in `jwt.claims.json`. + +## Files + +- `request.success.http` — the exact request line + headers (JWT redacted) + body. + Note `Authorization: Bearer …` + `X-Client-Id: ddk-model-wallet`, and **no** + `client_id`/`client_secret` header. +- `jwt.claims.json` — decoded payload of the RS256 token from the DDK Keycloak IdP + (realm `master`). The raw JWT is **never stored**; opaque `jti`/`sub`/`sid` + redacted, `exp`/`iat` shown as placeholders. Load-bearing claims: `client_id`, + `group` (wallet predicates) and `aud` (JWT Validation audience boundary). +- `wallet.definition.json` — the wallet object from the omni apim-proxy API + (`GET …/model-wallet`), UUIDs redacted. Shows `clientId`, `predicates`, and the + routed `modelId` the budget counts against. +- `responses.success.body.json` — HTTP **200** body from + `POST /ddk-model-wallet/chat/completions`. OpenAI **Chat Completions** object, + returned verbatim, incl. the `usage` token block used for cost/budget attribution. +- `responses.success.headers.txt` — 200 response headers (`set-cookie` stripped). + Note `server: Anypoint Flex Gateway`, **`x-model-wallet-selected`**, the + `x-llm-proxy-*` governance headers (routing-type/provider/model + the + `…-cost-per-1m` pair + `…-request-success`), `x-envoy-decorator-operation` + (`api-instance-21186246..svc`), `x-correlation-id`, and passed-through + OpenAI `x-ratelimit-*` / `x-request-id`. +- `reject.jwt-missing.{body.json,headers.txt}` — HTTP **400** when no + `Authorization: Bearer` header is present. Anypoint policy envelope + `{"error":"JWT Token is required."}` + `www-authenticate: Bearer`. **No** + `x-llm-proxy-*` routing headers (rejected before routing). +- `reject.jwt-invalid.{body.json,headers.txt}` — HTTP **401** for a malformed / + unverifiable / expired Bearer token. Envelope `{"error":"Invalid token."}` + + `www-authenticate: Bearer`. Discriminator vs. the missing-token 400 is the + status code + body string; both carry `www-authenticate: Bearer` (distinct from + the CIE path's `www-authenticate: Client-ID-Enforcement` in `../llm_proxy/`). + +## Enforcement matrix (verified 2026-09-21) + +| Case | Sent | Status | Body | +|---|---|---|---| +| success | valid JWT (`aud` incl. `ddk-model-wallet`) + `X-Client-Id` | **200** | OpenAI completion + `usage` | +| missing JWT | `X-Client-Id`, no `Authorization` | **400** | `{"error":"JWT Token is required."}` | +| invalid JWT | garbage/expired Bearer + `X-Client-Id` | **401** | `{"error":"Invalid token."}` | + +(A validly-signed token whose `aud` does **not** include the wallet name is also a +`401` audience rejection — see the `ddk-configure-llm-proxy-model-wallet` skill's +matrix; not re-captured here.) + +## Auth / secrets + +The JWT is minted per-call from the DDK reference Keycloak IdP and is **never +written to disk** — only its decoded, redacted claims are stored. No Anypoint +credential, provider API key, or Bearer token appears in any file here; +`set-cookie` was stripped on capture. The wallet caps spend but does **not** grant +or deny proxy access — access is gated by JWT validity + audience. diff --git a/python/tests/fixtures/anypoint/model_wallet/jwt.claims.json b/python/tests/fixtures/anypoint/model_wallet/jwt.claims.json new file mode 100644 index 00000000..0b974217 --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/jwt.claims.json @@ -0,0 +1,18 @@ +{ + "_note": "Decoded payload of the RS256 access token minted by the DDK reference Keycloak IdP (realm master) for the wallet round-trip. The raw JWT is never stored. Opaque per-session identifiers (jti/sub/sid) redacted; the test user's profile claims are synthetic (RFC 2606 .invalid). The claims that MATTER to wallet ingress are client_id and group (the wallet predicates) and aud (the JWT Validation audience boundary).", + "iss": "https://keycloak-idp-fdb7079d9b71.herokuapp.com/realms/master", + "aud": ["ddk-model-wallet", "account"], + "azp": "ddk-model-wallet-client", + "typ": "Bearer", + "scope": "openid email profile", + "exp": "", + "iat": "", + "jti": "", + "sub": "", + "sid": "", + "preferred_username": "ddk-test-user", + "email": "ddk-test-user@example.invalid", + "email_verified": true, + "client_id": "ddk-model-wallet-client", + "group": "ddk" +} diff --git a/python/tests/fixtures/anypoint/model_wallet/reject.jwt-invalid.body.json b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-invalid.body.json new file mode 100644 index 00000000..4c973e41 --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-invalid.body.json @@ -0,0 +1 @@ +{"error":"Invalid token."} diff --git a/python/tests/fixtures/anypoint/model_wallet/reject.jwt-invalid.headers.txt b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-invalid.headers.txt new file mode 100644 index 00000000..8e0dfda8 --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-invalid.headers.txt @@ -0,0 +1,9 @@ +HTTP/1.1 401 Unauthorized +www-authenticate: Bearer +content-type: application/json; charset=UTF-8 +content-length: 26 +date: Mon, 21 Sep 2026 17:35:01 GMT +server: Anypoint Flex Gateway +x-correlation-id: d98e4f7e-7117-4101-8b46-6344aa3c6405 +Strict-Transport-Security: max-age=31536000; includeSubdomains +Connection: Keep-Alive diff --git a/python/tests/fixtures/anypoint/model_wallet/reject.jwt-missing.body.json b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-missing.body.json new file mode 100644 index 00000000..0725dfaf --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-missing.body.json @@ -0,0 +1 @@ +{"error":"JWT Token is required."} diff --git a/python/tests/fixtures/anypoint/model_wallet/reject.jwt-missing.headers.txt b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-missing.headers.txt new file mode 100644 index 00000000..26c1b41b --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/reject.jwt-missing.headers.txt @@ -0,0 +1,9 @@ +HTTP/1.1 400 Bad Request +www-authenticate: Bearer +content-type: application/json; charset=UTF-8 +content-length: 34 +date: Mon, 21 Sep 2026 17:35:00 GMT +server: Anypoint Flex Gateway +x-correlation-id: a70859ff-21a9-487a-a65b-ab1c4eb11a6b +Strict-Transport-Security: max-age=31536000; includeSubdomains +Connection: Keep-Alive diff --git a/python/tests/fixtures/anypoint/model_wallet/request.success.http b/python/tests/fixtures/anypoint/model_wallet/request.success.http new file mode 100644 index 00000000..ffe98645 --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/request.success.http @@ -0,0 +1,7 @@ +POST /ddk-model-wallet/chat/completions HTTP/1.1 +Host: shared-omni-gateway-qrpud8.5sc6y6-1.usa-e2.cloudhub.io +Content-Type: application/json +Authorization: Bearer +X-Client-Id: ddk-model-wallet + +{"model":"openai/gpt-5-mini","messages":[{"role":"user","content":"Reply with exactly: PONG"}],"max_completion_tokens":256} diff --git a/python/tests/fixtures/anypoint/model_wallet/responses.success.body.json b/python/tests/fixtures/anypoint/model_wallet/responses.success.body.json new file mode 100644 index 00000000..82104fc7 --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/responses.success.body.json @@ -0,0 +1,35 @@ +{ + "id": "chatcmpl-EQcEds2KujcYjoWeatMj1Vr0I5kq2", + "object": "chat.completion", + "created": 1790012211, + "model": "gpt-5-mini-2025-08-07", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "PONG", + "refusal": null, + "annotations": [] + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 75, + "total_tokens": 87, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 64, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null +} diff --git a/python/tests/fixtures/anypoint/model_wallet/responses.success.headers.txt b/python/tests/fixtures/anypoint/model_wallet/responses.success.headers.txt new file mode 100644 index 00000000..dd50b99f --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/responses.success.headers.txt @@ -0,0 +1,36 @@ +HTTP/1.1 200 OK +date: Mon, 21 Sep 2026 17:36:52 GMT +content-type: application/json +content-length: 776 +cf-ray: a3ead59fba47376a-CMH +cf-cache-status: DYNAMIC +server: Anypoint Flex Gateway +strict-transport-security: max-age=31536000; includeSubDomains; preload +x-content-type-options: nosniff +access-control-expose-headers: X-Request-ID,CF-Ray,CF-Ray +openai-organization: user-9c8mvuhcimhkzwlcpxtztz7a +openai-processing-ms: 1460 +openai-project: proj_UpRmBxcPjQR5y8Tap22ULwcw +openai-version: 2020-10-01 +x-openai-proxy-wasm: v0.1 +x-ratelimit-limit-requests: 500 +x-ratelimit-limit-tokens: 500000 +x-ratelimit-remaining-requests: 499 +x-ratelimit-remaining-tokens: 499992 +x-ratelimit-reset-requests: 120ms +x-ratelimit-reset-tokens: 0s +x-request-id: req_25529d00ebdc421e89cbaeb0ab9fe45f +alt-svc: h3=":443"; ma=86400 +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-mini +x-llm-proxy-input-tokens-cost-per-1m: 0.25 +x-llm-proxy-output-tokens-cost-per-1m: 2 +x-llm-proxy-model-based-routing-success: Request successfully matched. Provider: openai, Model: gpt-5-mini. +x-model-wallet-selected: ddk-model-wallet +x-llm-proxy-request-success: Request completed successfully. Provider: openai, Model: gpt-5-mini. +x-envoy-decorator-operation: api-instance-21186246.14d3b31e-4e3b-4d90-b77a-63c9d6b7ea6a.svc +x-correlation-id: c0c82722-3e83-4eb9-abab-42dcdde9f9e1 +Strict-Transport-Security: max-age=31536000; includeSubdomains +Connection: Keep-Alive diff --git a/python/tests/fixtures/anypoint/model_wallet/wallet.definition.json b/python/tests/fixtures/anypoint/model_wallet/wallet.definition.json new file mode 100644 index 00000000..2dfa9c49 --- /dev/null +++ b/python/tests/fixtures/anypoint/model_wallet/wallet.definition.json @@ -0,0 +1,28 @@ +{ + "_note": "GET https://omni.mulesoft.com/api/apim-proxy/organizations//environments//model-wallet -> .data[] for name=ddk-model-wallet. The wallet object the ingress matches callers against. Org/env/user UUIDs and budget-line id redacted; clientId, predicates, and the routed modelId are the load-bearing fields.", + "name": "ddk-model-wallet", + "clientId": "ddk-model-wallet", + "priority": 10, + "status": "active", + "syncStatus": "synced", + "predicates": [ + { "type": "jwtClaim", "claim": "group", "values": ["ddk"] }, + { "type": "jwtClaim", "claim": "client_id", "values": ["ddk-model-wallet-client"] } + ], + "actions": { + "tokenRateLimits": [ + { + "limit": 1000, + "modelId": "openai:gpt-5-mini", + "provider": "openai", + "model": "gpt-5-mini", + "timeWindow": "day" + } + ] + }, + "scope": { + "consumers": ["llm-gateway"], + "apiInstanceIds": ["*"] + }, + "budgetCount": 1 +} diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index ef685630..f2a24c99 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -132,6 +132,77 @@ def test_unknown_on_model_substitution_is_a_config_error( assert "error" in str(exc.value) +# --- llm_proxy_auth mode resolution + validation (BG §1.1, #509) ------------------ +# client-id by default (the LIVE-VERIFIED CIE pair); "jwt" selects the +# model-wallet ingress, which requires NO client_secret but DOES require the +# durable wallet-selector client id. An unknown mode fails loudly at resolve time. + + +def test_llm_proxy_auth_defaults_to_client_id( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolate_toml(tmp_path, monkeypatch) + assert DonkeyConfig().llm_proxy_auth == "client-id" # dataclass default + assert DonkeyConfig.from_env().llm_proxy_auth == "client-id" # resolved default + + +def test_llm_proxy_auth_from_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + _isolate_toml(tmp_path, monkeypatch) + monkeypatch.setenv("DONKEY_LLM_PROXY_AUTH", "jwt") + assert DonkeyConfig.from_env().llm_proxy_auth == "jwt" + + +def test_llm_proxy_auth_is_case_insensitive( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolate_toml(tmp_path, monkeypatch) + monkeypatch.setenv("DONKEY_LLM_PROXY_AUTH", "JWT") + assert DonkeyConfig.from_env().llm_proxy_auth == "jwt" + + +def test_unknown_llm_proxy_auth_is_a_config_error( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A typo like "oauth" must fail loudly at resolve time — a silent fall-back to + # "client-id" would leave a caller believing they had selected the wallet + # ingress (#509), mirroring on_model_substitution. + _isolate_toml(tmp_path, monkeypatch) + monkeypatch.setenv("DONKEY_LLM_PROXY_AUTH", "oauth") + with pytest.raises(ConfigError) as exc: + DonkeyConfig.from_env() + assert "oauth" in str(exc.value) + + +def test_wallet_client_id_from_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + _isolate_toml(tmp_path, monkeypatch) + monkeypatch.setenv("DONKEY_LLM_PROXY_WALLET_CLIENT_ID", "wallet-42") + assert DonkeyConfig.from_env().llm_proxy_wallet_client_id == "wallet-42" + + +def test_validated_jwt_requires_url_and_wallet_client_id_not_secret() -> None: + # jwt mode: CIE is disabled, so NO client_secret is required — the missing + # field is the durable wallet-selector client id, and the error names it (not + # client_secret). The rotating JWT is not a config field (it rides an + # AuthProvider), so its absence is checked in LLMClient, not here (#509). + cfg = DonkeyConfig(llm_proxy_auth="jwt") + with pytest.raises(ConfigError) as exc: + cfg.validated(need="llm") + msg = str(exc.value) + assert "llm_proxy_url" in msg + assert "llm_proxy_wallet_client_id" in msg + assert "client_secret" not in msg + + +def test_validated_jwt_passes_with_url_and_wallet_client_id() -> None: + cfg = DonkeyConfig( + llm_proxy_auth="jwt", + llm_proxy_url="https://proxy", + llm_proxy_wallet_client_id="wallet-42", + ) + # No client_id/client_secret needed in jwt mode (BG §1.1). + assert cfg.validated(need="llm") is cfg + + # --- cost-attribution tag resolution (docs/verified-apis.md §3, BG §1.7, #196) -------------------- diff --git a/python/tests/unit/test_llm_client_jwt.py b/python/tests/unit/test_llm_client_jwt.py new file mode 100644 index 00000000..d81e89cd --- /dev/null +++ b/python/tests/unit/test_llm_client_jwt.py @@ -0,0 +1,74 @@ +"""LLMClient guards for jwt / model-wallet auth mode (BG §1.1, #509). + +Two things config alone cannot check are enforced in ``LLMClient.client()``, +where the attached ``AuthProvider`` is known: + + * jwt mode is async-only (Proposal 6 / AC 7) — the blocking client takes no + provider and cannot await a rotating JWT, so ``sync=True`` raises. + * jwt mode with no provider attached raises with actionable guidance (AC 1). + +Both guards fire BEFORE the OpenAI SDK import, so they need no ``[llm]`` extra; +the one positive path that constructs a real client ``importorskip``s openai. +""" + +from __future__ import annotations + +import httpx +import pytest + +from donkey_kit.core.auth import StaticToken +from donkey_kit.core.config import DonkeyConfig +from donkey_kit.core.errors import ConfigError +from donkey_kit.core.transport import DonkeyAsyncClient +from donkey_kit.llm.client import LLMClient + + +def _jwt_cfg() -> DonkeyConfig: + return DonkeyConfig( + llm_proxy_auth="jwt", + llm_proxy_url="https://proxy", + llm_proxy_wallet_client_id="wallet-42", + ) + + +def _http(cfg: DonkeyConfig, auth) -> DonkeyAsyncClient: + # A MockTransport keeps construction network-free; the guards raise before + # any request is ever sent, so the handler is never invoked. + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + return DonkeyAsyncClient(cfg, auth, transport=transport) + + +async def test_jwt_sync_client_is_rejected_as_async_only() -> None: + # Even WITH a provider attached, sync=True is refused: the blocking client + # cannot await the rotating JWT (Proposal 6 / AC 7). + client = _http(_jwt_cfg(), StaticToken("jwt")) + try: + llm = LLMClient(_jwt_cfg(), client) + with pytest.raises(ConfigError, match="async-only"): + llm.client(sync=True) + finally: + await client.aclose() + + +async def test_jwt_without_provider_is_rejected_with_guidance() -> None: + client = _http(_jwt_cfg(), None) # no AuthProvider attached + try: + llm = LLMClient(_jwt_cfg(), client) + with pytest.raises(ConfigError, match="requires an AuthProvider") as exc: + llm.client() + finally: + await client.aclose() + # Actionable: names the Donkey(llm_auth=...) entry point (AC 1). + assert "llm_auth" in str(exc.value) + + +async def test_jwt_async_with_provider_passes_the_guards() -> None: + # The positive path constructs a real AsyncOpenAI, so it needs the SDK. + openai = pytest.importorskip("openai") + client = _http(_jwt_cfg(), StaticToken("jwt")) + try: + llm = LLMClient(_jwt_cfg(), client) + result = llm.client() # no ConfigError — provider present, async + finally: + await client.aclose() + assert isinstance(result, openai.AsyncOpenAI) diff --git a/python/tests/unit/test_model_wallet_contract.py b/python/tests/unit/test_model_wallet_contract.py new file mode 100644 index 00000000..75c27a60 --- /dev/null +++ b/python/tests/unit/test_model_wallet_contract.py @@ -0,0 +1,107 @@ +"""Pins the model-wallet JWT ingress contract to a LIVE capture from a real +wallet-backed proxy (`ddk-model-wallet`, instance 21186246) deployed to the +shared omni Flex Gateway and called end-to-end on 2026-09-21. See +tests/fixtures/anypoint/model_wallet/README.md and docs/verified-apis.md §2/§3 +(#372). + +This is the PARALLEL ingress model to the `client_id`/`client_secret` pair in +test_llm_proxy_contract.py: a wallet-backed proxy identifies the caller from an +IdP-issued JWT (`Authorization: Bearer`) + the wallet-selector `X-Client-Id` +header, with NO `client_secret`. These tests pin the observed request/response +*shape*; wiring the raw JWT-rejection → exception mapping into +core/errors.classify() (and the SDK-side auth mode) is #509, deliberately not +done here. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from donkey_kit.simulator.fixtures import parse_headers + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "anypoint" / "model_wallet" + + +def _headers(name: str) -> dict[str, str]: + return parse_headers((FIXTURES / name).read_text()) + + +def _load(name: str) -> object: + return json.loads((FIXTURES / name).read_text()) + + +def test_success_body_is_openai_chat_completion_passthrough_with_usage() -> None: + """docs/verified-apis.md §2: a wallet proxy returns the OpenAI object verbatim, + incl. the token usage block that the wallet counts against its budget.""" + body = _load("responses.success.body.json") + assert isinstance(body, dict) + assert body["object"] == "chat.completion" + assert body["model"].startswith("gpt-5-mini") + usage = body["usage"] + assert {"prompt_tokens", "completion_tokens", "total_tokens"} <= set(usage) + assert usage["total_tokens"] == usage["prompt_tokens"] + usage["completion_tokens"] + + +def test_success_headers_echo_matched_wallet_and_no_auth_challenge() -> None: + """docs/verified-apis.md §3: on a wallet hit the gateway echoes which wallet + matched (`x-model-wallet-selected`) alongside the usual routing headers, and + there is NO `www-authenticate` challenge — the JWT + X-Client-Id passed.""" + h = _headers("responses.success.headers.txt") + assert h["server"] == "Anypoint Flex Gateway" + assert h["x-model-wallet-selected"] == "ddk-model-wallet" + assert h["x-llm-proxy-llm-provider"] == "openai" + assert h["x-llm-proxy-routing-type"] == "ModelBased" + assert h["x-envoy-decorator-operation"].startswith("api-instance-21186246.") + assert "www-authenticate" not in h + + +def test_request_carries_bearer_jwt_and_x_client_id_but_no_secret() -> None: + """docs/verified-apis.md §2: the caller sends `Authorization: Bearer ` + + `X-Client-Id`, and NO client_id/client_secret request-header pair — the CIE + and DataWeave Headers Transformation policies are disabled on a wallet proxy. + (`Authorization: Bearer` was an assumption in the doc read; the live capture + confirms it.)""" + req = (FIXTURES / "request.success.http").read_text().lower() + assert "authorization: bearer" in req + assert "x-client-id: ddk-model-wallet" in req + assert "client_secret" not in req + # the CIE header pair must not appear as request headers on this ingress + assert "\nclient_id:" not in req + + +def test_missing_jwt_is_flat_string_400_with_bearer_challenge() -> None: + """docs/verified-apis.md §3 live capture: no `Authorization` header → 400 with a + flat-string Anypoint envelope and `www-authenticate: Bearer`, and NO + `x-llm-proxy-*` routing headers (rejected before routing). Pins the shape + classify() will type in #509; no classify() assertion here.""" + h = _headers("reject.jwt-missing.headers.txt") + body = _load("reject.jwt-missing.body.json") + assert h["www-authenticate"] == "Bearer" + assert not any(k.startswith("x-llm-proxy-") for k in h) + assert body == {"error": "JWT Token is required."} + assert isinstance(body["error"], str) + + +def test_invalid_jwt_is_flat_string_401_with_bearer_challenge() -> None: + """docs/verified-apis.md §3 live capture: a malformed/unverifiable/expired Bearer + token → 401, flat-string envelope `{"error":"Invalid token."}`, and + `www-authenticate: Bearer` (distinct from the CIE path's + `www-authenticate: Client-ID-Enforcement`).""" + h = _headers("reject.jwt-invalid.headers.txt") + body = _load("reject.jwt-invalid.body.json") + assert h["www-authenticate"] == "Bearer" + assert body == {"error": "Invalid token."} + assert isinstance(body["error"], str) + + +def test_wallet_definition_predicates_match_the_jwt_claims() -> None: + """docs/verified-apis.md §3: the caller is the JWT whose claims satisfy every + wallet predicate (group=ddk AND client_id=ddk-model-wallet-client), and the + wallet selector value equals its generated clientId.""" + wallet = _load("wallet.definition.json") + claims = _load("jwt.claims.json") + assert wallet["clientId"] == "ddk-model-wallet" + preds = {p["claim"]: p["values"] for p in wallet["predicates"]} + for claim, values in preds.items(): + assert claims[claim] in values diff --git a/python/tests/unit/test_transport.py b/python/tests/unit/test_transport.py index 4efbb8f4..7f4aece4 100644 --- a/python/tests/unit/test_transport.py +++ b/python/tests/unit/test_transport.py @@ -105,6 +105,116 @@ def test_proxy_auth_headers_omit_absent_credentials() -> None: assert proxy_auth_headers(DonkeyConfig(llm_proxy_url="https://proxy")) == {} +# --- jwt / model-wallet auth mode (BG §1.1, #372/#509) ---------------------- +# The wallet ingress disables Client ID Enforcement: no client_id/client_secret +# pair, a durable wallet-selector X-Client-Id, and a rotating JWT injected +# per-send from the attached AuthProvider (never a config field). + + +class _RotatingToken: + """An AuthProvider that hands out a fresh token on every ``token()`` call, so + a test can assert the transport reads it per-send rather than pinning once.""" + + def __init__(self) -> None: + self.n = 0 + + async def token(self) -> str: + self.n += 1 + return f"jwt-{self.n}" + + async def invalidate(self) -> None: + self.n += 1 # a refresh advances the token, like a real IdP rotation + + +def _jwt_cfg(**kw) -> DonkeyConfig: + return DonkeyConfig( + llm_proxy_auth="jwt", + llm_proxy_url="https://proxy", + llm_proxy_wallet_client_id="wallet-42", + **kw, + ) + + +def test_proxy_auth_headers_jwt_mode_carries_wallet_selector_not_cie() -> None: + """jwt mode (#509): the snapshot carries only the durable X-Client-Id + wallet selector — never the CIE pair (Client ID Enforcement is disabled), + and never the rotating JWT (that is injected per-send, not snapshotted).""" + headers = proxy_auth_headers(_jwt_cfg()) + assert headers["X-Client-Id"] == "wallet-42" + assert "client_id" not in headers + assert "client_secret" not in headers + assert "Authorization" not in headers + + +async def test_jwt_mode_injects_fresh_bearer_overriding_preset() -> None: + """The OpenAI SDK pre-sets Authorization from its mandatory api_key slot; a + wallet proxy READS that header as the JWT, so the transport must OVERRIDE the + preset with the fresh per-send token, and stamp the wallet selector too.""" + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(request.headers) + return httpx.Response(200) + + async with _client(handler, _jwt_cfg(), _RotatingToken()) as client: + # Simulate the OpenAI SDK's pre-set sentinel bearer on the request. + await client.get("https://x", headers={"Authorization": "Bearer sentinel"}) + + assert seen["authorization"] == "Bearer jwt-1" # overridden, not the sentinel + assert seen["x-client-id"] == "wallet-42" + + +async def test_jwt_token_refreshed_per_send() -> None: + """A rotating JWT is read from the provider on every send, so a second + request carries the next token — the refresh the wallet ingress needs.""" + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers["authorization"]) + return httpx.Response(200) + + async with _client(handler, _jwt_cfg(), _RotatingToken()) as client: + await client.get("https://x") + await client.get("https://x") + + assert seen == ["Bearer jwt-1", "Bearer jwt-2"] + + +async def test_jwt_401_refreshes_and_retries_once() -> None: + """A 401 invalidates the provider and re-sends once with the refreshed token + (BG §1.1) — the same loop the CIE path uses, now carrying the wallet JWT.""" + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers["authorization"]) + return httpx.Response(401) if len(seen) == 1 else httpx.Response(200) + + async with _client(handler, _jwt_cfg(), _RotatingToken()) as client: + resp = await client.get("https://x") + + assert resp.status_code == 200 + # First send jwt-1 (401) → invalidate advances → re-send with a fresh token. + assert seen[0] == "Bearer jwt-1" + assert seen[1] != seen[0] + + +async def test_client_id_mode_authorization_setdefault_preserves_preset() -> None: + """In the DEFAULT client-id mode the CIE proxy ignores Authorization, so the + transport must NOT clobber a caller-set bearer — setdefault, not override. + (The override behaviour above is jwt-mode-only.)""" + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(request.headers) + return httpx.Response(200) + + # Default client-id mode, but a token provider is attached (control-plane). + async with _client(handler, DonkeyConfig(), StaticToken("cp-token")) as client: + await client.get("https://x", headers={"Authorization": "Bearer preset"}) + + assert seen["authorization"] == "Bearer preset" # preserved, not overridden + + async def test_retries_on_503_then_succeeds() -> None: calls = {"n": 0} diff --git a/website/content/concepts/governance.mdx b/website/content/concepts/governance.mdx index bbff66d2..7bbe1a6f 100644 --- a/website/content/concepts/governance.mdx +++ b/website/content/concepts/governance.mdx @@ -14,8 +14,13 @@ The live-verified `openai-sdk` proxy carried this policy stack, all enabled: required; this is also the per-agent identity (see [Attribution](/concepts/attribution)). - **model-based-routing** — routes on the `model` field in the request body to a provider; response carries `x-llm-proxy-*` routing headers. -- **llm-proxy-core**, **cors**, **dataweave-headers-transformation**, - **openai-transcoding-policy**. +- **dataweave-headers-transformation** — a gateway convenience that splits a + colon-joined credential in a single `authorization: Bearer :` or + `apikey: :` header into the `client_id` / `client_secret` pair, but + **only** when neither header is already present. DDK always emits the two-header + pair itself, so this policy is a **no-op** for DDK traffic (see + [Configuration](/reference/configuration)). +- **llm-proxy-core**, **cors**, **openai-transcoding-policy**. - Optionally applied for capture: **llm-token-rate-limit** and **llm-pii-detection** — these produce the `429` and `403` rejection shapes in the [Error taxonomy](/errors). diff --git a/website/content/reference/configuration.mdx b/website/content/reference/configuration.mdx index e1673e0d..1bf47d0f 100644 --- a/website/content/reference/configuration.mdx +++ b/website/content/reference/configuration.mdx @@ -19,11 +19,64 @@ The three required values for the LLM proxy: Auth is a `client_id` / `client_secret` **header pair** (consumer auth), **not** a bearer token, and separate from any Anypoint control-plane credential. The OpenAI SDK still requires a non-empty `api_key` slot, which the proxy ignores. + + The stock gateway also accepts a **single** colon-joined header — + `authorization: Bearer :` or + `apikey: :` — which its + `dataweave-headers-transformation` policy splits back into the pair (see + [Governance](/concepts/governance)). That is a convenience for thin clients + with one auth field; **DDK does not use it and will not add an apikey/bearer + emit mode**. DDK always sends the two-header pair because `client_id` is the + per-agent [attribution](/concepts/attribution) unit, and a colon-joined value + is **not** an alternate auth: the policy ignores it once a `client_id` header + is present, and `validated(need="llm")` hard-requires the pair in client-id + mode. Missing required fields are reported **all at once** with their env-var names, so you fix configuration in a single pass rather than one error per run. +## JWT / model-wallet auth mode + +A **model-wallet** proxy identifies the caller from an IdP-issued **JWT** plus a +durable wallet-selector client id, with Client ID Enforcement disabled and **no +`client_secret`**. Select it with `DONKEY_LLM_PROXY_AUTH=jwt`: + +| Env var | `DonkeyConfig` field | Meaning | +|---|---|---| +| `DONKEY_LLM_PROXY_AUTH` | `llm_proxy_auth` | Data-plane auth mode: `client-id` (default) or `jwt`. | +| `DONKEY_LLM_PROXY_WALLET_CLIENT_ID` | `llm_proxy_wallet_client_id` | The wallet's system-generated client id, sent as the `X-Client-Id` header. Required in `jwt` mode. | + +In `jwt` mode the required fields are `llm_proxy_url` **and** +`llm_proxy_wallet_client_id` — **not** `client_id` / `client_secret`. The +rotating JWT is **not** a config value: it is supplied through an +[`AuthProvider`](/identity) passed as `Donkey(llm_auth=…)`, so the SDK can +re-fetch it as it rotates and refresh it once on a `401`: + +```python +from donkey_kit import Donkey, DonkeyConfig +from donkey_kit.core.auth import StaticToken # or your own rotating AuthProvider + +donkey = Donkey( + DonkeyConfig( + llm_proxy_url="https:////", + llm_proxy_auth="jwt", + llm_proxy_wallet_client_id="", + ), + llm_auth=StaticToken(""), # rides as Authorization: Bearer +) +``` + + + `jwt` mode is **async-only** — the rotating credential is fetched from an + async `AuthProvider`, so the blocking client (`sync=True`) is refused with an + actionable error. Use client-id auth for a synchronous caller. It also works + only for adapters routed through the SDK's shared HTTP client (the raw client + and LangGraph): frameworks handed a one-time `default_headers` snapshot (ADK, + CrewAI, LlamaIndex, MS Agent Framework) pin the token at construction and + cannot refresh it — an [asserted conformance exemption](/testing). + + ## Optional attribution | Env var | Meaning | diff --git a/website/public/concepts/governance.md b/website/public/concepts/governance.md index cdab80d5..de012835 100644 --- a/website/public/concepts/governance.md +++ b/website/public/concepts/governance.md @@ -12,8 +12,13 @@ The live-verified `openai-sdk` proxy carried this policy stack, all enabled: required; this is also the per-agent identity (see [Attribution](https://donkey-development-kit.github.io/donkey-development-kit/concepts/attribution.md)). - **model-based-routing** — routes on the `model` field in the request body to a provider; response carries `x-llm-proxy-*` routing headers. -- **llm-proxy-core**, **cors**, **dataweave-headers-transformation**, - **openai-transcoding-policy**. +- **dataweave-headers-transformation** — a gateway convenience that splits a + colon-joined credential in a single `authorization: Bearer :` or + `apikey: :` header into the `client_id` / `client_secret` pair, but + **only** when neither header is already present. DDK always emits the two-header + pair itself, so this policy is a **no-op** for DDK traffic (see + [Configuration](https://donkey-development-kit.github.io/donkey-development-kit/reference/configuration.md)). +- **llm-proxy-core**, **cors**, **openai-transcoding-policy**. - Optionally applied for capture: **llm-token-rate-limit** and **llm-pii-detection** — these produce the `429` and `403` rejection shapes in the [Error taxonomy](https://donkey-development-kit.github.io/donkey-development-kit/errors.md). diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index eb941814..ffc6741a 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -4466,8 +4466,13 @@ The live-verified `openai-sdk` proxy carried this policy stack, all enabled: required; this is also the per-agent identity (see [Attribution](https://donkey-development-kit.github.io/donkey-development-kit/concepts/attribution.md)). - **model-based-routing** — routes on the `model` field in the request body to a provider; response carries `x-llm-proxy-*` routing headers. -- **llm-proxy-core**, **cors**, **dataweave-headers-transformation**, - **openai-transcoding-policy**. +- **dataweave-headers-transformation** — a gateway convenience that splits a + colon-joined credential in a single `authorization: Bearer :` or + `apikey: :` header into the `client_id` / `client_secret` pair, but + **only** when neither header is already present. DDK always emits the two-header + pair itself, so this policy is a **no-op** for DDK traffic (see + [Configuration](https://donkey-development-kit.github.io/donkey-development-kit/reference/configuration.md)). +- **llm-proxy-core**, **cors**, **openai-transcoding-policy**. - Optionally applied for capture: **llm-token-rate-limit** and **llm-pii-detection** — these produce the `429` and `403` rejection shapes in the [Error taxonomy](https://donkey-development-kit.github.io/donkey-development-kit/errors.md). @@ -4978,9 +4983,60 @@ The three required values for the LLM proxy: a bearer token, and separate from any Anypoint control-plane credential. The OpenAI SDK still requires a non-empty `api_key` slot, which the proxy ignores. + The stock gateway also accepts a **single** colon-joined header — + `authorization: Bearer :` or + `apikey: :` — which its + `dataweave-headers-transformation` policy splits back into the pair (see + [Governance](https://donkey-development-kit.github.io/donkey-development-kit/concepts/governance.md)). That is a convenience for thin clients + with one auth field; **DDK does not use it and will not add an apikey/bearer + emit mode**. DDK always sends the two-header pair because `client_id` is the + per-agent [attribution](https://donkey-development-kit.github.io/donkey-development-kit/concepts/attribution.md) unit, and a colon-joined value + is **not** an alternate auth: the policy ignores it once a `client_id` header + is present, and `validated(need="llm")` hard-requires the pair in client-id + mode. + Missing required fields are reported **all at once** with their env-var names, so you fix configuration in a single pass rather than one error per run. +## JWT / model-wallet auth mode + +A **model-wallet** proxy identifies the caller from an IdP-issued **JWT** plus a +durable wallet-selector client id, with Client ID Enforcement disabled and **no +`client_secret`**. Select it with `DONKEY_LLM_PROXY_AUTH=jwt`: + +| Env var | `DonkeyConfig` field | Meaning | +|---|---|---| +| `DONKEY_LLM_PROXY_AUTH` | `llm_proxy_auth` | Data-plane auth mode: `client-id` (default) or `jwt`. | +| `DONKEY_LLM_PROXY_WALLET_CLIENT_ID` | `llm_proxy_wallet_client_id` | The wallet's system-generated client id, sent as the `X-Client-Id` header. Required in `jwt` mode. | + +In `jwt` mode the required fields are `llm_proxy_url` **and** +`llm_proxy_wallet_client_id` — **not** `client_id` / `client_secret`. The +rotating JWT is **not** a config value: it is supplied through an +[`AuthProvider`](https://donkey-development-kit.github.io/donkey-development-kit/identity.md) passed as `Donkey(llm_auth=…)`, so the SDK can +re-fetch it as it rotates and refresh it once on a `401`: + +```python +from donkey_kit import Donkey, DonkeyConfig +from donkey_kit.core.auth import StaticToken # or your own rotating AuthProvider + +donkey = Donkey( + DonkeyConfig( + llm_proxy_url="https:////", + llm_proxy_auth="jwt", + llm_proxy_wallet_client_id="", + ), + llm_auth=StaticToken(""), # rides as Authorization: Bearer +) +``` + + `jwt` mode is **async-only** — the rotating credential is fetched from an + async `AuthProvider`, so the blocking client (`sync=True`) is refused with an + actionable error. Use client-id auth for a synchronous caller. It also works + only for adapters routed through the SDK's shared HTTP client (the raw client + and LangGraph): frameworks handed a one-time `default_headers` snapshot (ADK, + CrewAI, LlamaIndex, MS Agent Framework) pin the token at construction and + cannot refresh it — an [asserted conformance exemption](https://donkey-development-kit.github.io/donkey-development-kit/testing.md). + ## Optional attribution | Env var | Meaning | diff --git a/website/public/reference/configuration.md b/website/public/reference/configuration.md index b87af599..91e760c5 100644 --- a/website/public/reference/configuration.md +++ b/website/public/reference/configuration.md @@ -17,9 +17,60 @@ The three required values for the LLM proxy: a bearer token, and separate from any Anypoint control-plane credential. The OpenAI SDK still requires a non-empty `api_key` slot, which the proxy ignores. + The stock gateway also accepts a **single** colon-joined header — + `authorization: Bearer :` or + `apikey: :` — which its + `dataweave-headers-transformation` policy splits back into the pair (see + [Governance](https://donkey-development-kit.github.io/donkey-development-kit/concepts/governance.md)). That is a convenience for thin clients + with one auth field; **DDK does not use it and will not add an apikey/bearer + emit mode**. DDK always sends the two-header pair because `client_id` is the + per-agent [attribution](https://donkey-development-kit.github.io/donkey-development-kit/concepts/attribution.md) unit, and a colon-joined value + is **not** an alternate auth: the policy ignores it once a `client_id` header + is present, and `validated(need="llm")` hard-requires the pair in client-id + mode. + Missing required fields are reported **all at once** with their env-var names, so you fix configuration in a single pass rather than one error per run. +## JWT / model-wallet auth mode + +A **model-wallet** proxy identifies the caller from an IdP-issued **JWT** plus a +durable wallet-selector client id, with Client ID Enforcement disabled and **no +`client_secret`**. Select it with `DONKEY_LLM_PROXY_AUTH=jwt`: + +| Env var | `DonkeyConfig` field | Meaning | +|---|---|---| +| `DONKEY_LLM_PROXY_AUTH` | `llm_proxy_auth` | Data-plane auth mode: `client-id` (default) or `jwt`. | +| `DONKEY_LLM_PROXY_WALLET_CLIENT_ID` | `llm_proxy_wallet_client_id` | The wallet's system-generated client id, sent as the `X-Client-Id` header. Required in `jwt` mode. | + +In `jwt` mode the required fields are `llm_proxy_url` **and** +`llm_proxy_wallet_client_id` — **not** `client_id` / `client_secret`. The +rotating JWT is **not** a config value: it is supplied through an +[`AuthProvider`](https://donkey-development-kit.github.io/donkey-development-kit/identity.md) passed as `Donkey(llm_auth=…)`, so the SDK can +re-fetch it as it rotates and refresh it once on a `401`: + +```python +from donkey_kit import Donkey, DonkeyConfig +from donkey_kit.core.auth import StaticToken # or your own rotating AuthProvider + +donkey = Donkey( + DonkeyConfig( + llm_proxy_url="https:////", + llm_proxy_auth="jwt", + llm_proxy_wallet_client_id="", + ), + llm_auth=StaticToken(""), # rides as Authorization: Bearer +) +``` + + `jwt` mode is **async-only** — the rotating credential is fetched from an + async `AuthProvider`, so the blocking client (`sync=True`) is refused with an + actionable error. Use client-id auth for a synchronous caller. It also works + only for adapters routed through the SDK's shared HTTP client (the raw client + and LangGraph): frameworks handed a one-time `default_headers` snapshot (ADK, + CrewAI, LlamaIndex, MS Agent Framework) pin the token at construction and + cannot refresh it — an [asserted conformance exemption](https://donkey-development-kit.github.io/donkey-development-kit/testing.md). + ## Optional attribution | Env var | Meaning |