For anyone pointing the extension at agentgateway. Back to the README.
agentgateway (Apache-2.0, Linux Foundation) is the
second gateway this extension is tested against, in its llm: config mode. Notes below are from
agentgateway at commit 9d36620d (one week after v1.6.0); the walkthrough runs against the mock
gateway in this repo, started in its agentgateway mode.
- One listener, three paths. agentgateway picks the backend from the request body's
model; the path (/v1/messages,/v1/responses,/v1/chat/completions) only says which format the request is in, and agentgateway translates between formats where it can. The extension still sends each model over its native protocol: Claude to Messages, GPT to Responses, everything else to Chat Completions. - A per-caller model list.
GET /v1/modelsis synthesised by agentgateway from its config, filtered by each model's authorization rules. A model missing for one caller means that caller is not authorised for it (the same model is a 403 on request). - No metadata in the list. Every entry is
owned_by: "openai"with no context window, modalities or endpoints. The extension routes Claude by pi's catalog and theclaude-id before it looks at the owner, and fills metadata from pi's catalog or your config.
A CI OIDC token as the client's Bearer token (the WIF shape), with placeholder issuer and audience:
llm:
policies:
jwtAuth:
mode: strict # the default, optional, lets requests without a token through
providers:
- issuer: https://token.actions.example.com
audiences: [https://gateway.example.com]
jwks: { url: https://token.actions.example.com/.well-known/jwks } # remote JWKS
models:
- name: claude-sonnet-5
provider: anthropic
authorization:
rules:
- allow: 'jwt.repository == "example-org/example-repo"'
- name: gpt-6-luna
provider: openAIjwtAuth.mode: strict, a remote JWKS URL andaudiencesare the minimum; per-model authorization rules also filter/v1/models, so each caller lists only what it may use.- Never use a CEL
location.expressionfor the token (for example to also acceptx-api-key). agentgateway does not strip a token read that way, so the caller's JWT would be forwarded to the model provider. Send Bearer from the client instead (below). discovery: disabledlists wildcard models such asopenai/*literally; the extension drops them with a warning. Add the concrete ids you use withmodelsin the config file.
agentgateway reads the JWT from Authorization: Bearer only, on every path, including
/v1/messages, where pi natively sends x-api-key. Switch every API to Bearer, and point the
token file at the token your CI runner rewrites (it is re-read on every request, so rotation needs no
restart):
export INFERENCE_GATEWAY_BASE_URL=https://gateway.example.com # the root, or root + llm.pathPrefix; no /v1
export INFERENCE_GATEWAY_TOKEN_FILE=/path/to/oidc-token # minted with the gateway's audience
export INFERENCE_GATEWAY_AUTH_HEADER=bearer # Bearer on all three APIs and discoveryor, in the config file, "authHeader": "bearer" (or only { "anthropic-messages": "bearer" },
since the other targets already default to Bearer). This is the inverse of the per-API Praxis setup.
Models nothing identifies (no pi catalog entry under anthropic/openai, no claude- id) take the
owned_by: "openai" hint and go to Responses, which agentgateway translates for a chat backend. To
use Chat Completions directly, give them an api:
{ "providers": { "gateway": { "baseUrl": "https://gateway.example.com", "authHeader": "bearer",
"models": { "gemini-3.5-flash": { "api": "openai-completions" },
"oss/zai-org/glm-5-3": { "api": "openai-completions" } } } } }node scripts/mock-gateway.mjs 47813 --mode agentgateway & # Bearer only, owned_by "openai", an openai/* entry
export PI_CODING_AGENT_DIR=$(mktemp -d)
export INFERENCE_GATEWAY_BASE_URL=http://127.0.0.1:47813
export INFERENCE_GATEWAY_API_KEY=test-token # a token file in CI, as above
export INFERENCE_GATEWAY_AUTH_HEADER=bearer
cat > "$PI_CODING_AGENT_DIR/inference-gateway.json" <<'EOF'
{ "providers": { "gateway": { "baseUrl": "http://127.0.0.1:47813",
"models": { "gemini-3.5-flash": { "api": "openai-completions" },
"oss/zai-org/glm-5-3": { "api": "openai-completions" } } } } }
EOF$ pi -ne -e . --list-models | grep -E '^(provider|gateway) |pi-inference-gateway'
[pi-inference-gateway] gateway: ignored wildcard model id(s) openai/*: the gateway lists a routing pattern, not a model; add concrete ids via "models" in the config file (or INFERENCE_GATEWAY_EXTRA_MODELS)
provider model context max-out thinking images
gateway claude-sonnet-5 1M 128K yes yes
gateway gemini-3.5-flash 1.0M 65.5K yes yes
gateway gpt-6-luna 272K 128K yes yes
gateway oss/zai-org/glm-5-3 1M 131.1K yes no
$ pi -ne -e . --no-session -p --model gateway/claude-sonnet-5 "say hi" </dev/null
hi from /v1/messages as claude-sonnet-5
$ pi -ne -e . --no-session -p --model gateway/gemini-3.5-flash "say hi" </dev/null
hi from /v1/chat/completions as gemini-3.5-flash
$ pi -ne -e . --no-session -p --model gateway/gpt-6-luna "say hi" </dev/null
hi from /v1/responses as gpt-6-luna
$ pi -ne -e . --no-session -p --model gateway/oss/zai-org/glm-5-3 "say hi" </dev/null
hi from /v1/chat/completions as oss/zai-org/glm-5-3All four models are listed owned_by: "openai"; Claude still goes to /v1/messages. The mock's
log shows authorization(Bearer) on every request, /v1/models included. The same run passes on
pi 0.99.2.
| Symptom | Cause | Fix |
|---|---|---|
401 authentication failure: no bearer token found on Claude only |
pi's native x-api-key on /v1/messages |
INFERENCE_GATEWAY_AUTH_HEADER=bearer |
model discovery failed (model list request returned HTTP 401: ...) |
no token, a wrong or expired token, or a wrong audience | check the token file and the token's aud |
400 ... unsupported conversion: from Responses to provider anthropic (supported: [AnthropicMessages]) |
a Claude model on openai-responses (an api in your config, or an old snapshot) |
remove the api, or set "anthropic-messages" |
403 with a JSON "model authorization denied" error |
a claim rule on that model refuses your token | the gateway's per-model authorization rules |
404 ... model_not_found |
an id the gateway does not serve, or a wildcard id | use an id from --list-models, or add a concrete one via models |
400 ... unknown variant ..., expected one of none, minimal, low, medium, high, xhigh, max |
a reasoning_effort value from a copied thinkingLevelMap |
set it per model, see Thinking levels |
These errors are text/plain; the extension shows the start of such a body in discovery errors
(credentials redacted, or (body omitted) when a credential is under 8 characters; see
Security), and pi shows it for model requests (the walkthrough's two failure cases,
pasted from the mock):
$ INFERENCE_GATEWAY_AUTH_HEADER= pi -ne -e . --no-session -p --model gateway/claude-sonnet-5 "say hi" </dev/null
401 authentication failure: no bearer token found
$ INFERENCE_GATEWAY_API_KEY=wrong-token-value pi -ne -e . --list-models
[pi-inference-gateway] gateway: model discovery failed (model list request returned HTTP 401: authentication failure: no bearer token found); using 2 fallback model(s) plus pi's last saved list- Claude cannot be served on
/v1/responsesthrough agentgateway (a 400; the extension never sends it there unless you configure it). - Claude over
/v1/chat/completionsworks but loses signed-thinking replay. - Non-streaming
/v1/responsesreturns a 502 on an output item type agentgateway does not know. pi always streams, so it is not affected. x-api-keyis never accepted for JWT auth; use Bearer.- The classic
routes:mode does not serve/v1/models(501); use thellm:mode. - Gemini behind an agentgateway Vertex/Gemini provider is expected not to hit the
thought_signaturelimitation: agentgateway carries the signature inside the tool-call id (<id>__thought__<signature>), which pi replays verbatim. This is from agentgateway's source and has not been verified against a live Gemini model.