Skip to content

Security & Admin

API Keys Section

API keys control client access to the router's endpoints. Keys can be configured through multiple sources.

Authentication Mode

The mode setting controls whether API authentication is required for API endpoints:

Mode Behavior
permissive (default) Allow requests without API key. Requests with valid API keys are authenticated.
blocking Only process requests that pass API key authentication. Unauthenticated requests receive 401.

When mode is blocking, every route mounted by the OpenAI-compatible API router requires a valid API key. If that key has a non-empty scopes list, the same middleware enforces the route class before the request reaches the handler. An absent or empty scopes list is unrestricted for backward compatibility. In permissive mode, missing or invalid keys still pass through and a valid narrowed key is not rejected on scope grounds.

Note: Admin, Files, and Metrics endpoints have separate authentication mechanisms and are not affected by this setting.

API route scope classes:

Required scope Routes
read GET /v1/models, GET /v1/models/extended, GET /v1/models/proxy, GET /v1/models/{model}, POST /v1/models/query, GET /v1/responses/{response_id}, POST /anthropic/v1/messages/count_tokens, POST /v1/messages/count_tokens, POST /tokenize, POST /v1/tokenize, POST /detokenize, POST /v1/detokenize, GET /anthropic/v1/models, GET /v1/vector_stores, GET /v1/vector_stores/{store_id}, GET /v1/vector_stores/{store_id}/files, GET /v1/vector_stores/{store_id}/files/{file_id}, POST /v1/vector_stores/{store_id}/search, GET /v1/batches, GET /v1/batches/{batch_id}, GET /v1/batches/{batch_id}/results
write POST /v1/models/refresh, POST /v1/completions, POST /v1/chat/completions, POST /v1/embeddings, POST /v1/rerank, POST /embed_sparse, POST /v1/images/generations, POST /v1/images/edits, POST /v1/images/variations, POST /v1/audio/transcriptions, POST /v1/audio/translations, POST /v1/audio/speech, POST /v1/responses, POST /v1/responses/compact, DELETE /v1/responses/{response_id}, POST /v1/responses/{response_id}/cancel, POST /v1/vector_stores, POST /v1/vector_stores/{store_id}, DELETE /v1/vector_stores/{store_id}, POST /v1/vector_stores/{store_id}/files, DELETE /v1/vector_stores/{store_id}/files/{file_id}, POST /anthropic/v1/messages, POST /v1/messages, POST /v1/batches, POST /v1/batches/{batch_id}/cancel, GET /v1/realtime, GET /realtime

write implies read, and admin implies both on these API routes. A key with only files satisfies neither class. Batch and realtime rows apply only in builds where those route features are compiled. Realtime routes require write even though they start as GET, because they upgrade to bidirectional inference sessions; POST /v1/models/refresh also requires write because it mutates the router's model catalog; POST /anthropic/v1/messages/count_tokens requires only read because it computes a count without dispatching inference, and POST /v1/vector_stores/{store_id}/search requires only read because a search does not mutate the store.

Section Configuration Properties:

Property Type Required Default Description
mode string No permissive Authentication mode: permissive or blocking
api_keys array No [] Inline API key definitions
api_keys_file string No - Path to external API keys file
api_keys:
  # Authentication mode: "permissive" (default) or "blocking"
  mode: permissive

  # Inline API key definitions
  api_keys:
    - key: "${API_KEY_1}"              # Environment variable substitution
      id: "key-production-1"           # Unique identifier
      user_id: "user-admin"            # Associated user
      organization_id: "org-main"      # Associated organization
      name: "Production Admin Key"     # Human-readable name
      scopes:                          # Permissions
        - read
        - write
        - files
        - admin
      rate_limit: 1000                 # Requests per minute (optional)
      enabled: true                    # Active status
      expires_at: "2025-12-31T23:59:59Z"  # Optional expiration (ISO 8601)

    - key: "${API_KEY_2}"
      id: "key-service-1"
      user_id: "service-bot"
      organization_id: "org-main"
      name: "Service Account"
      scopes: [read, write, files]
      rate_limit: 500
      enabled: true

  # External key file for better security
  api_keys_file: "/etc/continuum-router/api-keys.yaml"

Key Properties:

Property Type Required Description
key string Yes The API key value (supports ${ENV_VAR} substitution)
id string Yes Unique identifier for admin operations
user_id string Yes User associated with this key
organization_id string Yes Organization the user belongs to
name string No Human-readable name
description string No Notes about the key
scopes array No Permissions: read, write, files, admin. Empty/absent means unrestricted on /v1; non-empty is enforced on /v1 only in blocking mode.
rate_limit integer No Maximum requests per minute
enabled boolean No Active status (default: true)
expires_at string No ISO 8601 expiration timestamp
allowed_backends array No Per-key backend allow-list. Empty/absent ⇒ unrestricted. See below.
allowed_regions array No Per-key data-residency allow-list, matched against backends[].region_name. Empty/absent ⇒ unrestricted. See below.
allowed_models array No Per-key model allow-list. Empty/absent ⇒ unrestricted. See below.
prompt_logging_consent string No allowed or denied: what this key states about prompt capture. Absent follows prompt_logging.default_consent. See Where a key's consent comes from.

External Key File Format:

# /etc/continuum-router/api-keys.yaml
keys:
  - key: "sk-prod-xxxxxxxxxxxxxxxxxxxxx"
    id: "key-external-1"
    user_id: "external-user"
    organization_id: "external-org"
    scopes: [read, write, files]
    enabled: true

Security Features:

  • Key Masking: Full keys are never logged (displayed as sk-***last4)
  • Expiration Enforcement: Expired keys are automatically rejected
  • Hot Reload: Update keys without server restart
  • Audit Logging: All key management operations are logged
  • Scope Validation: In blocking mode, non-empty scopes gate /v1 read/write route classes
  • Constant-Time Validation: Prevents timing attacks
  • Max Key Limit: 10,000 keys maximum to prevent DoS

Admin API Endpoints (require admin authentication):

Endpoint Method Description
/admin/api-keys GET List all keys (masked)
/admin/api-keys/:id GET Get key details
/admin/api-keys POST Create new key
/admin/api-keys/:id PUT Update key properties
/admin/api-keys/:id DELETE Delete key
/admin/api-keys/:id/rotate POST Generate new key value
/admin/api-keys/:id/enable POST Enable key
/admin/api-keys/:id/disable POST Disable key

Inbound JWT/OIDC Validation

api_keys.jwt lets a client authenticate with a JWT issued by an identity provider instead of a static key. The section is off by default, and it is inert in a build compiled without the jwt-auth Cargo feature; such a build warns at load time and keeps static-key authentication unchanged.

The api_keys.jwt section is read at startup, so changing it takes effect after a restart, the same rule API-key material follows. A key rotation at the configured jwks_uri needs no restart: the cache picks it up on the next refresh.

A static key always wins. The middleware matches the presented credential against the key store first, and only a miss is examined as a JWT. A token whose unverified iss is not the configured issuer falls through to the existing behavior, so another provider's token is still treated as an unknown key. A token that names the configured issuer but cannot be verified is refused with 401 and the standard bad-key body. It is never admitted unverified.

Section Configuration Properties:

Property Type Required Default Description
enabled boolean No false Turn inbound JWT validation on
issuer string Yes when enabled - Exact iss value every accepted token must carry
audience string Yes when enabled - Audience the token's aud claim must contain
jwks_uri string One of jwks_uri or static_jwks - JWKS endpoint. Must be https, or http on a loopback host
static_jwks object One of jwks_uri or static_jwks - Inline JWK Set ({ "keys": [...] }), never refreshed
algorithms array No ["RS256", "ES256", "EdDSA"] Accepted asymmetric algorithms. none and HS* are load errors
clock_skew_secs integer No 60 Allowed clock skew for exp and nbf, from 0 to 300
jwks_cache_ttl_secs integer No 300 Seconds a fetched JWKS stays usable
jwks_refresh_interval_secs integer No 30 Minimum seconds between refreshes triggered by an unknown kid
jwks_timeout_secs integer No 5 Hard timeout of one JWKS fetch, from 1 to 30
jwks_max_bytes integer No 262144 Maximum accepted JWKS response body
subject_claim string No sub Claim carrying the subject. A token for this issuer without it is refused
user_claim string No - Claim carrying the user id. Absent falls back to the subject
team_claim string No - Claim carrying the team or organization id. Absent leaves it empty
scopes_claim string No - Claim carrying the scope list. Absent grants no scopes
allowed_models_claim string No - Claim carrying the allowed model list
allowed_backends_claim string No - Claim carrying the allowed backend list
api_keys:
  mode: blocking

  jwt:
    enabled: true
    issuer: "https://idp.example.com/"
    audience: "continuum-router"
    jwks_uri: "https://idp.example.com/.well-known/jwks.json"
    algorithms: ["RS256"]
    clock_skew_secs: 60
    scopes_claim: "scope"
    user_claim: "email"
    team_claim: "org_id"

Claim Mapping:

  • Identity: subject_claim becomes the key id basis, user_claim the user identity, and team_claim the organization used by per-team limits and attribution.
  • Scopes: a JWT scope list is authoritative. When scopes_claim is configured and the claim is absent or empty, the principal has no scopes and satisfies only routes that require no scope. Every mounted API route requires read or write, so configure a scope claim for JWT clients.
  • Grants: allowed_models_claim and allowed_backends_claim read string arrays. A malformed value refuses the token rather than widening the principal. An absent claim, or a present empty array, leaves the list empty, and an empty list means unrestricted; configure a claim only when the identity provider sets it on every token that needs the restriction.

Security Properties:

  • Asymmetric only: only the configured algorithms are accepted. alg: none, HMAC algorithms, and every algorithm outside the allow-list are refused, and a symmetric (kty: oct) JWK is never selected.
  • No token-selected keys: a token carrying jku, jwk, or x5u in its header is refused. Key material comes only from the configured JWKS.
  • Bounded inputs: the validator bounds token size, JWKS body size, JWKS key count, claim string lengths, list counts, and scope count.
  • Bounded fetch: a JWKS fetch has a hard timeout, follows no redirects, and fails closed. The cache has a TTL plus a rate-limited single-flight refresh, so an unknown kid cannot stampede the identity provider.
  • Rotation: a token with an unknown kid triggers at most one refresh per jwks_refresh_interval_secs. A rotated signing key is picked up on the next refresh.

Metrics:

  • continuum_jwt_auth_total{result} counts outcomes with a bounded result label: accepted, expired, not_yet_valid, invalid_signature, wrong_audience, unsupported_algorithm, malformed, kid_not_found, jwks_unavailable, and misconfigured.
  • continuum_jwt_jwks_fetch_total{result} counts JWKS fetches with success, timeout, http_error, too_large, and invalid_body.

Per-Key Backend Access Control

allowed_backends scopes a client key to a subset of configured backends. This is client-key → backend access control, separate from backends[].api_key (the upstream provider credential the router uses to call OpenAI, Anthropic, and the rest).

When the list is non-empty, requests authenticated with that key may only route to the named backends. A request for a model that only a disallowed backend can serve is rejected with 403 Forbidden and a permission_error body. An empty or absent list leaves the key unrestricted, which is the default.

Semantics:

  • Empty / absent ⇒ unrestricted (the key can route to any backend that serves the requested model).
  • Non-empty ⇒ allow-list of backends[].name values. Matching is exact and case-sensitive, the same as backend-name resolution elsewhere.
  • Unservable request ⇒ 403 Forbidden ({"error": {"type": "permission_error", ...}}). The 403 names the model so operators can tell it apart from a 404 ("model not found").
  • Cross-provider fallback is filtered by the same list, so a restricted key cannot escape its scope through a fallback hop.
api_keys:
  mode: blocking
  api_keys:
    - key: "${PARTNER_KEY}"
      id: "key-partner-1"
      user_id: "partner-acme"
      organization_id: "org-acme"
      scopes: [read, write]
      allowed_backends: [openai, anthropic]   # may only route to these backends

    - key: "${INTERNAL_KEY}"
      id: "key-internal-1"
      user_id: "team-ml"
      organization_id: "org-internal"
      scopes: [read, write]
      allowed_backends: [vllm-local]          # limited to the self-hosted backend

Mode interaction: the restriction applies in both blocking and permissive mode, but in permissive mode it only takes effect for callers that present a valid key. In permissive mode an authenticated key still attaches its policy (via a best-effort optional-auth step that never rejects), while requests with no key or an invalid key pass through unrestricted, preserving permissive mode's "anonymous welcome" behavior.

Models listing: /v1/models, /v1/models/extended, and /anthropic/v1/models are filtered when a restricted key is authenticated, so a key only advertises models served by at least one allowed backend. GET /v1/models/{model} returns 404 Not Found for a model the key cannot reach. Unauthenticated callers and unrestricted keys see the full list.

Config validation: at startup and on hot-reload, a name in allowed_backends that does not match any backends[].name produces a warning. It is not a hard error, so renaming a backend does not break the router before the operator updates the affected keys.

Admin API: allowed_backends round-trips through the create, update, get, and list endpoints. On create, an absent field defaults to unrestricted. On update, null (absent) leaves the list unchanged, an empty array clears all restrictions, and a non-empty array replaces the list. Runtime keys persist the field through the persistence file.

Anthropic API Headers

/anthropic/v1/messages, /anthropic/v1/messages/count_tokens, and /anthropic/v1/models apply the same per-key backend and model policies.

  • Bearer: Authorization: Bearer <key> supplies the key's allowed_backends and allowed_models policies.
  • Native Anthropic header: when no Bearer AuthContext is present, a valid x-api-key that matches a router client key supplies the same policies.
  • Permissive mode: an invalid or absent key continues without a per-key policy, matching the optional-auth behavior above.

Batch API: in control-plane builds, POST /v1/batches resolves its backend and applies allowed_backends before it fetches an input file, defers the request, acquires a batch slot, or contacts the provider's create endpoint. It resolves the backend again after admission and refuses the request if the backend name or provider identity changed during a configuration reload.

Per-Key Data-Residency Control

allowed_regions scopes a client key to backends whose operator-defined backends[].region_name tag is in the list. It is a data-residency control: the router compares two strings you wrote, and never infers a region from a URL, a provider account, or an AWS region identifier. A tag is 1 to 64 characters from [A-Za-z0-9._-]; an invalid value fails configuration load.

backends:
  - name: openai-eu
    type: openai
    region_name: eu                 # operator-defined residency tag
    api_key: "${OPENAI_EU_API_KEY}"
    models: [gpt-5.6-terra]

  - name: vllm-us
    type: vllm
    url: http://vllm-us.internal:8000
    region_name: us
    models: [qwen3]

api_keys:
  api_keys:
    - key: "${EU_TENANT_KEY}"
      id: "key-eu-tenant"
      user_id: "tenant-eu"
      organization_id: "org-main"
      allowed_regions: [eu]         # may only route to backends tagged eu

Semantics:

  • Empty / absent ⇒ unrestricted, and allowed_backends behaves exactly as before.
  • Non-empty ⇒ the effective backend set is every backend whose region_name is in the list, matched exactly and case-sensitively. A backend with no region_name is region-unknown and never qualifies.
  • Both lists set ⇒ they intersect. A backend must satisfy allowed_backends and carry one of the allowed_regions tags.
  • Empty intersection ⇒ fail closed. The request is rejected with 403 Forbidden (permission_error, reported as model_not_permitted), and /v1/models lists nothing. The router never falls back to every backend when a residency constraint cannot be satisfied.
  • Every dispatch path ⇒ the effective list replaces allowed_backends for retries, fallback hops and hedges, the image, audio, embedding, rerank, and sparse endpoints, tokenize/detokenize, Responses and native Responses lifecycle calls, Anthropic Messages and count_tokens, realtime, batch, fine-tuning, and control-plane budget eligibility. A hop cannot escape the region.
  • Listings ⇒ /v1/models, /v1/models/extended, /v1/models/{model}, and /anthropic/v1/models apply the same effective list.
  • Cache isolation ⇒ two keys with different residency tags never share a response-cache entry, even when their allowed_backends lists match.
  • Config validation ⇒ a tag in allowed_regions that no configured backend declares produces a startup warning only, so a typo does not stop the router. Such a key routes nothing, because the intersection is empty.

allowed_regions is authorable only in config.yaml or an external key file. The Admin key API and the WebUI cannot set or display the field, and the runtime persistence file is not an authoring surface. That file carries whatever the live key holds, which is always empty for a key created through the Admin API. Hub-synced keys do not carry the field, so a Hub-managed key is unrestricted by residency unless its local key entry sets one.

Per-Key Model Access Control

allowed_models scopes a client key to a subset of model names. Where allowed_backends controls where a key may route, allowed_models controls which models a key may name. Backend names churn as deployments scale up and down, but model names are stable, so model-level scoping is the durable control for multi-tenant routers. The two lists are independent and may both be set.

Semantics:

  • Empty / absent ⇒ unrestricted (the key may use any published model).
  • Non-empty ⇒ allow-list of model names. Primary names and aliases are separate names, so each is gated on its own. Matching is exact and case-sensitive.
  • Disallowed model ⇒ 403 Forbidden (permission_error). The gate runs before any fallback fall-through, so a restricted key cannot reach a fallback chain by requesting a model it may not use.
  • Anthropic Messages ⇒ /anthropic/v1/messages and /anthropic/v1/messages/count_tokens enforce the same model gate for both Authorization: Bearer and native x-api-key authentication.
  • Models listing ⇒ /v1/models, /v1/models/extended, and /anthropic/v1/models list exactly the intersection of allowed_models with the currently-published model names. GET /v1/models/{model} returns 404 Not Found for a model the key may not use.
api_keys:
  mode: blocking
  api_keys:
    - key: "${PARTNER_KEY}"
      id: "key-partner-1"
      user_id: "partner-acme"
      organization_id: "org-acme"
      scopes: [read, write]
      allowed_models: [gpt-5.6-terra, gpt-5.6-sol]   # may only use these models

Config validation: at startup and on hot-reload, a name in allowed_models that is not currently published produces a warning, not a hard error (publications churn as deployments scale).

Admin API: allowed_models round-trips through the create, update, get, and list endpoints with the same null / empty / non-empty update semantics as allowed_backends, and runtime keys persist it through the persistence file.

Batch API: POST /v1/batches intersects local allowed_models with the Hub tier's model_allowlist. An explicit empty Hub allow-list denies every model. The router checks every inline Anthropic request model and, for restricted OpenAI-style batches, streams the provider-owned JSONL input to check every request model before deferral or admission. A malformed or missing model fails closed for restricted access; unrestricted malformed Anthropic input remains available for provider-side validation.

ROUTER frontend mode: in the AppProxy ROUTER worker, per-key model visibility is the primary access-control dimension. Keys there are matched by SHA-256 hash of the presented bearer, may carry an expires_at and a per-node rate_limit (the cluster-wide ceiling is rate_limit × live_nodes), and may pin a source-IP allow-list. The router-side gate lives in appproxy::router::keys.

Per-Key Token Budget

max_tokens_budget caps the tokens (input plus output) a key may consume within a rolling budget_window of 1d, 7d, or 30d (default 30d). Once the window's usage meets the budget, the key's requests are refused with 429 Too Many Requests (rate_limit_error), in the Anthropic error envelope on the /anthropic mount. Unset, the default, sets no budget, and a key without one is never tracked.

api_keys:
  mode: blocking
  api_keys:
    - key: "${TEAM_KEY}"
      id: "key-team-1"
      max_tokens_budget: 5000000   # tokens per window
      budget_window: 7d

Semantics:

  • Usage is the token usage the router meters for the key at request completion, the same figure the per-key usage statistics and usage export record. Usage older than the window ages out in steps of 1/120 of the window.
  • Concurrency: admission reserves an estimate of the request's prompt (a quarter of the body length in tokens) until the response body ends, and refuses once recorded usage plus outstanding reservations meet the budget. Concurrent requests cannot all slip past a nearly spent budget; the overrun is bounded by the usage of the requests already in flight.
  • Reload: the budget and window are read from the live key on every request, so a hot reload applies to the next request. Changing the window keeps the recorded usage, which then ages out over the new window.
  • Restart: with api_keys.persistence_file set, the windows are written every 30 seconds to <persistence_file>.budget.json (mode 0600) and restored at startup, so a restart loses at most 30 seconds of usage. Without it the windows are in memory and a restart starts them afresh.
  • Replicas: the windows are per process. Behind a load balancer each replica enforces the budget against the traffic it serves.
  • Validation: a budget_window other than 1d, 7d, or 30d, or a max_tokens_budget of 0, is a load error.
  • Scope: budgets apply to keys from api_keys, api_keys_file, and the persistence file. The Admin API does not set a budget yet and keeps an existing one on update, and keys synced from Continuum Hub carry none; the Hub's tier limits govern those.

Data-Plane Budgets

The budgets: section adds rolling-window quotas on tokens and USD cost that do not need a Hub. max_tokens_budget is the explicit per-key override of its per_api_key scope: a key that sets it is governed by that budget and is not charged again by the fleet default. A key without it follows budgets.scopes.per_api_key, and the shared per_model and global scopes still apply to every key.

The budgets engine is a separate check from rate_limiting's tokens_per_minute and tokens_per_day, which are leaky-bucket rates rather than quotas. A request must pass the rate checks and every applicable budget window.

Only the caller's own per-key level is reported, in x-budget-api-key-* headers; the per-model and global levels are withheld because other tenants draw on them. The full configuration, refusal envelopes, storage modes, admin endpoints, and metrics are documented in Budgets.

Client Credential Headers

On the paths named here the router drops the credential-bearing headers on the inbound request before it builds the outbound request, so a caller's credentials are never relayed to the backend: the chat-completions paths (streaming, non-streaming, Unix socket, and every fallback hop, including a hop that switches provider), the Anthropic Messages path /anthropic/v1/messages over both HTTP and a Unix socket, /anthropic/v1/messages/count_tokens, the image generation and image variations paths, and the native Gemini multimodal embedding dispatch behind /v1/embeddings.

The stripped names are:

Header Why it is dropped
authorization The caller's router key or bearer token; the backend authenticates with its own credential
x-api-key Router-key equivalent on the inbound side, and the native Anthropic credential on the outbound side
api-key Azure OpenAI credential form
x-goog-api-key Gemini credential; the native path injects the configured key itself
cookie, set-cookie Session credentials
proxy-authorization Proxy credentials
x-auth-token, x-access-token Generic bearer-token carriers

A backend's credential comes only from its own configuration: api_key, the auth block (including auth.type: basic and the OAuth strategies), or a credential the Continuum Hub vault synced for that backend's provider. Matching is case-insensitive, and the strip is unconditional: it does not depend on whether the selected backend happens to have a credential, so a fallback hop cannot carry one provider's credential to another.

A backend configured without any credential therefore sends no Authorization header at all. Local engines that do not require auth accept that; a cloud provider answers 401, which is the correct signal that the backend entry is missing its api_key rather than a reason to relay the caller's key.

Non-credential headers still forward normally. user-agent, x-request-id, tracing headers, and any custom header your clients set reach the backend unchanged, so this is a deny list rather than a whitelist.

The typed Anthropic, Gemini, and Bedrock backend dispatch paths, the Responses API paths, the Realtime path, the chat-completions paths, the Anthropic-native inbound endpoints, the image endpoints, and the multimodal embedding dispatch all apply this deny list. The image variations path keeps its openai-* / x-* allow list on top of the deny list, and the image edits path forwards only openai-* and x-request-id.

Configuring upstream credentials

Because client Authorization headers are never forwarded upstream, every backend that requires upstream authentication must declare its own credential in the router configuration:

backends:
  - name: partner-openai
    type: openai
    url: "https://api.partner.example/v1"
    api_key: "${PARTNER_API_KEY}"

If an upstream backend returns 401 on every request, verify that api_key or auth is configured on that backend entry.

Per-Customer Attribution for Anthropic

Anthropic user profiles let an organization that serves many end customers attribute each request to one of them, so a safety restriction or ban applies to that profile instead of the whole organization. Behind the router, the organization is the operator's and the callers are its tenants. If a tenant could pick the profile, it could attribute its traffic, and its violations, to another tenant's profile. Claude in Amazon Bedrock and Google Cloud check only the ID format, so any well-formed ID would be accepted there.

The router therefore owns the attribution. A client never chooses it unless the operator marks its key as a trusted gateway.

What the router removes

On every outbound path and every backend type, the router drops:

  • The client's anthropic-user-profile-id header.
  • A client top-level user_profile_id field on every backend type. An operator value in request_extensions.body.defaults or request_extensions.body.overrides is restored after the client value is removed. The native /anthropic/v1/messages and count_tokens ingress, the chat-completions and Responses conversions, and an Anthropic Message Batch body are all covered; batch stripping applies both at the top level and inside each request's params.
  • Any client copy of x-continuum-resolved-user-profile-id, the router-internal header that carries the resolved value from the ingress to the dispatch.
  • The underscore spellings of both headers (anthropic_user_profile_id and x_continuum_resolved_user_profile_id, in any case). Some proxies and application servers read _ as -, so an upstream could otherwise take the underscore spelling as the attribution header.

Where the profile is sent

Backend Carrier
anthropic (Claude API, Claude Platform on AWS, Microsoft Foundry) anthropic-user-profile-id header
bedrock with the default mantle endpoint (Claude in Amazon Bedrock) Top-level user_profile_id body field
bedrock with endpoint_type: runtime or converse Not sent (the legacy InvokeModel and Converse integration does not support profiles)
continuumrouter anthropic-user-profile-id header, so a downstream router whose key for this router is a trusted gateway key can apply it. Every key's resolved profile ID is sent to every continuumrouter backend; configure one only for a router operated under the same Anthropic organization.
Any other type Not sent, unless the backend declares a static profile in request_extensions.headers

The same rules apply to count_tokens and to Message Batches created through /v1/batches, where Anthropic applies the header to every request in the batch.

Configuration

api_keys:
  mode: blocking
  api_keys:
    - key: "${TENANT_A_KEY}"
      id: "tenant-a"
      user_id: "tenant-a"
      organization_id: "acme"
      anthropic_user_profile_id: "uprof_011CZkZCu8hGbp5mYRQgUmz9"
    - key: "${GATEWAY_KEY}"
      id: "partner-gateway"
      user_id: "partner"
      organization_id: "partner"
      anthropic_user_profile_passthrough: true   # trusted: keep the caller's header
  • anthropic_user_profile_id: The profile this key's traffic is attributed to. It must be uprof_ followed by 1 to 128 ASCII letters, digits, _, or -; a malformed inline value is a configuration load error, and a malformed value from an external keys file is logged and never sent.
  • anthropic_user_profile_passthrough (default false): Keeps the client's own anthropic-user-profile-id header for this key. Enable it only when the key's single caller is itself a gateway that attributes its own end customers. A malformed or repeated client value is ignored and the key's own profile applies.

Precedence

For each request the router sends at most one value, chosen in this order:

  1. The client's header, when the key is a trusted gateway key and the value is well-formed.
  2. The key's anthropic_user_profile_id.
  3. The backend's static profile: request_extensions.headers.anthropic-user-profile-id on a single-tenant backend. It must be a well-formed profile ID and is refused on bedrock backends, which read the profile from the body; set the profile per key there instead.
  4. None. Anthropic counts the request as the organization's own usage.

A request with no recognized API key (permissive mode) gets only the backend's static profile, if any. A key synced from Continuum Hub counts as a recognized key in both modes (see Continuum Hub keys).

Admin API and caching

  • Admin API: Both fields round-trip through POST /admin/api-keys, PUT /admin/api-keys/{id}, and the get and list endpoints, and runtime keys persist them. On update, an absent field leaves the value unchanged and "" clears the profile. A malformed ID answers 400 Bad Request.
  • Request deduplication: The resolved profile is part of the deduplication key, so two identical requests attributed to different end customers never share one upstream generation.
  • Response cache: Unchanged. The cache is already scoped per API key, and a cache hit makes no upstream request, so there is nothing to attribute.
  • Router-originated calls: Health checks, Hub probes, the smart-routing classifier, and guardrail calls carry no resolved profile. They do carry a backend's static request_extensions profile, like any extension header.

Continuum Hub keys

A key synced from Continuum Hub can carry the same two settings, authored in the Hub: anthropic_user_profile_id and anthropic_user_profile_passthrough. The router applies them with the precedence above, exactly as for a locally configured key.

  • Capability: The router advertises anthropic_user_profile_v1. The Hub sends the two fields only to a router that advertises it and strips them for older routers, whose Hub keys send the backend's static profile, if any.
  • Auth modes: In blocking mode the Hub key authenticates the request and carries its profile. In permissive mode the Hub key's profile applies once policy enforcement matches the key. A locally configured key that matches the same token takes precedence.
  • Validation: A Hub-delivered profile ID that is not well-formed is never sent; the key falls back to the backend's static profile.
  • Persistence: With control_plane.policy.persist_key_table: true, both fields are kept in the key-table file, so a router restarted during a Hub outage restores them. Setting or clearing a profile rewrites the file immediately. A file that carries a profile cannot be restored by a router release that predates this feature; that router starts without a restored table and waits for the Hub.

Guardrails: PII Detection and Redaction

PII detection is one of several guardrail providers. For the full guardrails system (concepts, all eight providers, streaming gating, admin controls, metrics, and the threshold-tuning workflow), see the Guardrails guide.

The pii guardrail provider detects personally identifiable information and high-value secrets in request prompts and model responses, then either redacts them in place or blocks the request. It addresses OWASP LLM02 (Sensitive Information Disclosure). Unlike the classify-only providers, its primary action is to transform content: matched spans are replaced with placeholders and the sanitized text flows on.

Built-in scanners run locally with no external dependency:

  • Identifiers and credentials: Emails, US Social Security numbers (ssn), Korean resident registration numbers (kr_rrn), Korean foreigner registration numbers (kr_frn), Korean business registration numbers (kr_brn), AWS access-key IDs, PEM private-key blocks, and Bearer / sk- API keys.
  • Credit-card / PAN numbers (credit_card): Card-shaped digit runs (13 to 19 digits, standard 4-digit groups with a 1-to-4-digit final group, 4-6-5 and 4-6-4 groupings, or 2 to 3 groups of 4+ digits such as 4111 111111111111) that pass the Luhn checksum. Non-standard short groupings (such as 2-4-4-4, 3-4-4-4, 1-4-4-4-3, or eight pairs of digits) are ignored so adjacent dates or short numbers are not merged into false card spans.
  • Phone numbers (phone): Formatted domestic and international numbers, +/00 country-code numbers with 1- and 2-digit groups (e.g., +33 1 74 53 03 18, +7 495 301-77-50), Russian/Belgian/Swiss/Turkish national numbers, UK/Indian irregular groupings (01267-1-5398, 1800-87-6141), German slash area codes (030/12345678), and French or unseparated Chinese mobile numbers preceded by a phone label (Tél. : 01 42 68 53 00, Mobile: 13800138000). See PII detection and redaction for full pattern rules.
  • Word boundaries: Scanners use ASCII word boundaries ((?-u:\b)), so identifiers attached directly to Hangul or Han characters (e.g., 주민번호900101-1234567입니다, 메일kim@x.io입니다) are detected, whereas values attached to ASCII letters, digits, or _ are not.
  • Overlap precedence:
    • Korean identifiers (kr_rrn, kr_frn, kr_brn) and operator-defined custom_entities take precedence over credit_card and phone. Any overlapping credit_card or phone span is dropped even if it passes Luhn.
    • A phone span wholly contained inside a valid credit_card span (starting with a non-zero ISO/IEC 7812 issuer digit) is dropped so phone: block does not block a credit card that credit_card: mask would redact.
    • Digit runs starting with 0 are never treated as credit cards; phone spans starting at 0 are preserved unless a valid card begins at a later group. If a phone match starts on a short prefix token and extends into a valid network-formatted card number, the phone span is truncated before the card (and dropped if fewer than 7 digits remain), unless the combined prefix and card also form a complete phone number followed by short numbers (e.g., (03) 4111 1111 1111 1111).
  • External NER analyzer (optional): You can configure a Microsoft Presidio-compatible analyzer to merge NER-based PII spans with built-in detections. Under on_error: fail_open, if the external analyzer is unreachable, the router continues applying built-in scanners and increments guardrail_degraded_total{kind="external_unavailable"}.

Each detected entity type maps to an action:

  • mask (the default): replace the matched span with a placeholder, e.g. [REDACTED:EMAIL], and continue with the sanitized text (a Transform verdict).
  • block: block the request/response when this entity type is present.
  • allow: ignore this entity type (neither mask nor block).

Raw detected values are never written to logs; only entity types and counts are recorded for audit.

guardrails:
  enabled: true
  mode: enforce
  providers:
    - name: pii-redaction
      type: pii
      enabled: true
      # Run at both stages (the default): sanitize the prompt before it reaches
      # the backend and the response before it reaches the client.
      stages: [input, output]
      options:
        # Action for entity types not listed in `actions`. Default: mask.
        default_action: mask
        # Per-entity-type action overrides.
        actions:
          email: mask
          phone: mask
          ssn: block
          kr_rrn: block
          kr_frn: block
          kr_brn: mask
          credit_card: block
          aws_access_key: block
          private_key: block
          api_key: block
          employee_id: mask
        # Recommended placeholder template without angle brackets (Markdown and
        # HTML renderers can strip `<REDACTED:...>`). Default: "<REDACTED:{TYPE}>".
        placeholder_format: "[REDACTED:{TYPE}]"
        # Optional operator-defined custom regex entities (up to 64 entities,
        # 1 KiB per pattern). Validated on `config validate` and hot reload.
        custom_entities:
          - name: employee_id
            pattern: '(?-u:\b)EMP-\d{6}(?-u:\b)'
            context_words: ["employee", "사번"]
            context_window: 64
        # Optional external Presidio-compatible analyzer (built-in scanners
        # always run; external spans are merged in).
        # external:
        #   endpoint: "http://presidio-analyzer:3000/analyze"
        #   language: en
        #   entities: ["PERSON", "LOCATION", "IBAN_CODE"]
      on_error: fail_open

Options:

Property Type Default Description
default_action string mask Action for entity types not in actions: mask, block, or allow.
actions map {} Per-entity-type action overrides, keyed by entity type (email, phone, ssn, kr_rrn, kr_frn, kr_brn, credit_card, aws_access_key, private_key, api_key, or any custom_entities[].name).
placeholder_format string <REDACTED:{TYPE}> Template for masked spans; {TYPE} is replaced with the upper-cased entity type. Recommended: "[REDACTED:{TYPE}]" (no angle brackets, as Markdown renderers can drop <...>).
context_words map or array built-in Korean/English words Context words required within context_window bytes of an unhyphenated 13-digit kr_rrn or kr_frn candidate ({"kr_rrn": [...], "kr_frn": [...]} or a shared array).
kr_rrn_context_words array - Explicit context-word list override for unhyphenated 13-digit kr_rrn.
kr_frn_context_words array - Explicit context-word list override for unhyphenated 13-digit kr_frn.
context_window integer 64 Byte window on each side of an unhyphenated kr_rrn/kr_frn (or custom entity without its own context_window) searched for context words.
custom_entities array [] Operator-defined regex entities ({name, pattern, context_words?, context_window?}). At most 64 entries; each pattern at most 1,024 bytes. Validated on config validate and hot reload.
external.endpoint string - Presidio-compatible analyzer URL. Falls back to the provider's top-level endpoint. Span start/end offsets are read as Unicode code points, as Presidio reports them; an analyzer that reports UTF-8 byte or UTF-16 offsets is not compatible. A reversed span or one past the end of the text follows on_error: fail_closed fails the check, and fail_open masks the valid spans and increments guardrail_degraded_total{kind="external_invalid_span"}.
external.entities array [] Restrict the external analyzer to these entity types (empty = analyzer default).
external.language string en Language hint passed to the analyzer.

Korean identifier format & collision reference (public-specification format only; no checksum validation):

Entity / Identifier Format Behavior
kr_rrn (주민등록번호) YYMMDD-NNNNNNN or YYMMDD NNNNNNN (month 01-12, day 01-31, 7th digit 1-4); unhyphenated YYMMDDNNNNNNN with context word Detected as kr_rrn
kr_frn (외국인등록번호) YYMMDD-NNNNNNN or YYMMDD NNNNNNN (month 01-12, day 01-31, 7th digit 5-8); unhyphenated YYMMDDNNNNNNN with context word Detected as kr_frn
kr_brn (사업자등록번호) NNN-NN-NNNNN (3-2-5 digits) Detected as kr_brn
Corporate registration number (법인등록번호) NNNNNN-NNNNNNN (6-7 digits) Collides with kr_rrn when the first 6 digits are a plausible YYMMDD (01-12 month, 01-31 day) and the 7th digit is 1-4

The provider participates in both stages by default. When several entity types are present, a blocking entity wins over masking (most-severe-wins): the verdict is a block; otherwise, if anything was masked, a Transform carries the redacted text; otherwise the content is allowed.

WebUI Section

The optional webui section controls the embedded browser-based administration interface. The WebUI is compiled into the binary and served as static assets protected by admin authentication.

webui:
  enabled: true        # Enable or disable the WebUI (default: true)
  path_prefix: /webui  # URL path prefix (default: /webui)

Configuration Properties:

Property Type Default Description
enabled boolean true Enable or disable the WebUI
path_prefix string /webui URL path prefix. Must start with / and must not contain ...

When webui is omitted, the defaults apply: WebUI is enabled at /webui. To disable the WebUI:

webui:
  enabled: false

See Embedded WebUI for a full guide to using the browser interface.

Admin Section

The admin section configures the Admin REST API, including authentication and statistics collection.

Authentication

Admin authentication fails closed. With no admin.auth block, every Admin API request returns 401 Unauthorized; a block without method defaults to api_key. Use method: none only as an explicit opt-in for isolated development.

admin:
  auth:
    method: bearer             # none, bearer, basic, ip_whitelist, api_key
    bearer_token: "${ADMIN_TOKEN}"

method: api_key uses the API-key store shared with normal /v1 authentication and Admin key management. It accepts both Authorization: Bearer and X-API-Key, enforces required_scope (default admin), and observes key disable/rotate/expire/delete operations immediately. admin.auth is startup-owned and runtime configuration endpoints refuse changes to it. Edit the source configuration and restart the router instead.

See Admin REST API Reference for all authentication options.

Statistics Collection

The admin.stats subsection controls request metrics collection and persistence. Stats collection is enabled by default.

admin:
  stats:
    enabled: true                # Enable/disable collection (default: true)
    retention_window: 24h        # Ring-buffer retention for windowed queries (default: 24h)
    token_tracking: true         # Parse response bodies for token usage (default: true)
    persistence:
      enabled: true              # Enable stats persistence across restarts (default: true)
      path: ./data/stats.json    # File path for the snapshot (default: ./data/stats.json)
      snapshot_interval: 5m      # How often to write periodic snapshots (default: 5m)
      max_age: 7d                # Discard snapshots older than this on startup (default: 7d)

Configuration Properties:

Property Type Default Description
enabled boolean true Enable or disable statistics collection
retention_window string 24h Ring-buffer retention window for windowed queries
token_tracking boolean true Parse response bodies to extract token usage

Persistence Properties:

Property Type Default Description
persistence.enabled boolean true Enable stats persistence across restarts
persistence.path string ./data/stats.json File path for the persistence snapshot
persistence.snapshot_interval string 5m Interval between periodic snapshots
persistence.max_age string 7d Maximum age for restoring snapshots on startup

When persistence is enabled:

  • On startup, the router restores counters and ring-buffer records from the snapshot file. Uptime always resets to zero.
  • A background task writes snapshots atomically (temp file + rename) at the configured interval.
  • On graceful shutdown (SIGTERM/SIGINT), a final snapshot is saved.
  • Missing, corrupted, or stale snapshots are handled gracefully: the router starts with fresh counters and logs a warning.

Hot Reload: retention_window and token_tracking support immediate hot-reload. Persistence settings (path, snapshot_interval, max_age) require a restart.

Supported duration formats for retention_window, snapshot_interval, and max_age:

Format Example Meaning
Xs 30s 30 seconds
Xm 5m 5 minutes
Xh 1h 1 hour
Xd 7d 7 days

See Admin REST API Reference: Statistics APIs for the full endpoint documentation.

Shared Address Space Backends

The Admin API refuses a runtime backend whose URL resolves into a blocked range, including the shared address space 100.64.0.0/10 (RFC 6598). That range is also used by carrier-grade NAT and overlay networks such as Tailscale. An operator who runs a backend there opts in explicitly:

admin:
  allow_shared_address_space_backends: true

The default is false. The flag is startup-owned and read only from the source configuration, so an Admin API or WebUI change cannot grant itself the exemption. Edit the file and restart the router.

When enabled, only plain IPv4 addresses in 100.64.0.0/10 are permitted, for the backend base URL and its engine_stats.metrics_url. The following stay refused:

  • The 100.100.100.200 cloud metadata address inside the range.
  • IPv4-mapped (::ffff:100.64.0.1), NAT64 (64:ff9b::/96), and 6to4 (2002::/16) forms of the range.
  • Every other blocked range, such as private, loopback, link-local, and IPv6 unique-local addresses, including Tailscale's fd7a:115c:a1e0::/48.
  • OAuth endpoints, Converse image fetches, export sinks, and client-supplied file URLs, which keep the full blocklist.

ACP (Agent Communication Protocol) Section

The acp section configures the Agent Communication Protocol subsystem. ACP enables IDE and tool integrations to communicate with the router via JSON-RPC 2.0 over stdio. ACP is disabled by default.

To use ACP, run the router with --mode stdio.

acp:
  enabled: true

  transport:
    stdio:
      enabled: true

  agent:
    name: "Continuum Router"
    version: "1.0.0"
    description: "Local LLM inference agent"

  capabilities:
    load_session: true
    image: false
    audio: false
    embedded_context: false
    mcp: true

  default_model: "gpt-5.6-sol"
  system_prompt: "You are a helpful coding assistant."
  coding_agent_mode: true

  permissions:
    default_policy: ask_always
    auto_allow:
      - read
      - search
      - think
    always_ask:
      - edit
      - delete
      - execute

  sessions:
    max_concurrent: 10
    idle_timeout: "1h"
    storage: "memory"

  mcp:
    max_connections_per_session: 5
    allowed_servers: []
    server_spawn_timeout: "10s"

Top-Level Options

Property Type Default Description
enabled bool false Enable/disable the ACP subsystem
default_model string none Model used when a session/prompt request omits model. It must name a model one of the configured backends serves; with neither set, the prompt is rejected with -32602
system_prompt string none Inject a system prompt into all ACP requests
coding_agent_mode bool false Enable coding agent system prompt

session/prompt selects a backend that serves the resolved model, through the same seam the HTTP paths use: model-aware candidates, internal and disabled backends removed, health filtering, circuit admission, then the configured selection_strategy. Backends marked internal: true or enabled: false never receive ACP traffic. The ACP path deliberately does not apply the HTTP retry loop, model fallback chains, or the per-key backend allow-list; see ACP architecture for the full scope decision.

Transport Options

Property Type Default Description
transport.stdio.enabled bool true Enable stdio transport

Permission Options

Property Type Default Description
permissions.default_policy enum ask_always Default policy: ask_always, allow_read, allow_all
permissions.auto_allow list [read, search, think] Tool kinds auto-allowed without asking
permissions.always_ask list [edit, delete, execute] Tool kinds that always require permission

Session Options

Property Type Default Description
sessions.max_concurrent int 10 Maximum concurrent sessions
sessions.idle_timeout string "1h" Idle timeout before session cleanup
sessions.storage string "memory" Storage backend: memory or file
sessions.storage_path string none Path for file-based storage

MCP Bridge Options

Property Type Default Description
mcp.max_connections_per_session int 5 Max MCP connections per session
mcp.allowed_servers list [] Allowed server IDs (empty = all)
mcp.server_spawn_timeout string "10s" Timeout for spawning MCP server processes

See ACP Architecture for protocol details and ACP Usage Guide for practical examples.