Model gateway

Model API keys never enter the workspace.

model-gateway accepts its own client token from the workspace, strips it, injects the real provider key, and forwards only allowlisted paths. Compose sets ANTHROPIC_BASE_URL=http://model-gateway:7434/anthropic and exports ANTHROPIC_AUTH_TOKEN from the gateway token file — Claude Code and the Anthropic SDKs work unchanged.

Note

Use ANTHROPIC_AUTH_TOKEN (sent as Authorization: Bearer). Don't set ANTHROPIC_API_KEY in the workspace — it's sent as x-api-key, which the gateway doesn't accept as a client token.

Configure routes

{
  "routes": [
    {
      "prefix": "/anthropic",
      "upstream": "https://api.anthropic.com",
      "allowPaths": ["^/v1/messages$", "^/v1/messages/count_tokens$", "^/v1/models$"],
      "inject": { "header": "x-api-key", "valueFile": "/run/secrets/anthropic_api_key" }
    }
  ],
  "budget": { "maxRequestsPerHour": 600, "maxGlobalRequestsPerHour": 3000, "maxRequestBytes": 8388608 },
  "maxConcurrent": 8
}

Other SDKs work the same way: point their base URL at http://model-gateway:7434/<prefix> and use the gateway token. Shells opened by docker compose exec or an IDE get ANTHROPIC_AUTH_TOKEN from /etc/profile.d/agentgate.sh; wrap non-interactive commands as bash -lc. An IDE's own assistant (Cursor, Copilot) never goes through the gateway; see IDEs & workspace profiles.

Setup

umask 077; openssl rand -hex 32 > ~/.agentgate-model-gateway-token

Set ANTHROPIC_API_KEY_PATH and AGENTGATE_MODEL_GATEWAY_CLIENT_TOKEN_PATH in .env. The key file must be mode 600 and owned by the container's node user, or the gateway returns 503. It's re-read on every request, so rotation needs no restart.

Guarantees

  • Only GET and POST to paths matching an anchored allowPaths regex; everything else is 404. Dot segments, //, and encoded separators are refused.
  • Client auth headers, cookies, x-agentgate-*, and forwarding headers are stripped. Redirects are refused.
  • Responses stream back unchanged (SSE works), minus set-cookie and any header containing the injected key.
  • A separate token from agentd's, so a compromised gateway can't call the broker.

Budgets

maxRequestsPerHour is a token bucket per client IP; maxGlobalRequestsPerHour spans all clients; maxRequestsPerHourPerRuntime is a bucket per attributed runtime (below). All return 429 RATE_LIMITED with Retry-After. maxRequestBytes (default 8 MiB) caps bodies; maxConcurrent and maxBufferedBytes return 503 GATEWAY_BUSY.

Attributing model traffic to a runtime

Add the broker's issuer keys to the gateway config and it verifies the runtime assertion a workspace sends, attributes the request to that runtime in its log, and gives each runtime its own hourly bucket:

"identity": { "audience": "agentgate:acme", "issuers": [{ "kid": "k1", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }], "required": false },
"budget": { "maxRequestsPerHour": 600, "maxRequestsPerHourPerRuntime": 200 }

The workspace profile exports ANTHROPIC_CUSTOM_HEADERS with the assertion as x-agentgate-runtime, which Claude Code and the Anthropic SDKs send on every call; other tools can set the same header. The gateway checks the signature, audience, lifetime, and claims, but cannot check the token binding (it never sees the workspace token), so the assertion attributes the request rather than authorising it: the gateway client token is still required, a present but invalid header is refused (401), and a missing header is refused only with "required": true. The header is stripped before the request reaches the provider. model.request log records then carry runtime (runtime ID, human, agent, team, jti), which the SIEM detections can key on.