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
scopesgate/v1read/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_claimbecomes the key id basis,user_claimthe user identity, andteam_claimthe organization used by per-team limits and attribution. - Scopes: a JWT scope list is authoritative. When
scopes_claimis 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 requiresreadorwrite, so configure a scope claim for JWT clients. - Grants:
allowed_models_claimandallowed_backends_claimread 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, orx5uin 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
kidcannot stampede the identity provider. - Rotation: a token with an unknown
kidtriggers at most one refresh perjwks_refresh_interval_secs. A rotated signing key is picked up on the next refresh.
Metrics:
continuum_jwt_auth_total{result}counts outcomes with a boundedresultlabel:accepted,expired,not_yet_valid,invalid_signature,wrong_audience,unsupported_algorithm,malformed,kid_not_found,jwks_unavailable, andmisconfigured.continuum_jwt_jwks_fetch_total{result}counts JWKS fetches withsuccess,timeout,http_error,too_large, andinvalid_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[].namevalues. 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'sallowed_backendsandallowed_modelspolicies. - Native Anthropic header: when no Bearer
AuthContextis present, a validx-api-keythat 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_backendsbehaves exactly as before. - Non-empty ⇒ the effective backend set is every backend whose
region_nameis in the list, matched exactly and case-sensitively. A backend with noregion_nameis region-unknown and never qualifies. - Both lists set ⇒ they intersect. A backend must satisfy
allowed_backendsand carry one of theallowed_regionstags. - Empty intersection ⇒ fail closed. The request is rejected with
403 Forbidden(permission_error, reported asmodel_not_permitted), and/v1/modelslists nothing. The router never falls back to every backend when a residency constraint cannot be satisfied. - Every dispatch path ⇒ the effective list replaces
allowed_backendsfor retries, fallback hops and hedges, the image, audio, embedding, rerank, and sparse endpoints,tokenize/detokenize, Responses and native Responses lifecycle calls, Anthropic Messages andcount_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/modelsapply the same effective list. - Cache isolation ⇒ two keys with different residency tags never share a response-cache entry, even when their
allowed_backendslists match. - Config validation ⇒ a tag in
allowed_regionsthat 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/messagesand/anthropic/v1/messages/count_tokensenforce the same model gate for bothAuthorization: Bearerand nativex-api-keyauthentication. - Models listing ⇒
/v1/models,/v1/models/extended, and/anthropic/v1/modelslist exactly the intersection ofallowed_modelswith the currently-published model names.GET /v1/models/{model}returns404 Not Foundfor 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_fileset, the windows are written every 30 seconds to<persistence_file>.budget.json(mode0600) 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_windowother than1d,7d, or30d, or amax_tokens_budgetof0, 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-idheader. - A client top-level
user_profile_idfield on every backend type. An operator value inrequest_extensions.body.defaultsorrequest_extensions.body.overridesis restored after the client value is removed. The native/anthropic/v1/messagesandcount_tokensingress, 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'sparams. - 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_idandx_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 beuprof_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(defaultfalse): Keeps the client's ownanthropic-user-profile-idheader 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:
- The client's header, when the key is a trusted gateway key and the value is well-formed.
- The key's
anthropic_user_profile_id. - The backend's static profile:
request_extensions.headers.anthropic-user-profile-idon a single-tenant backend. It must be a well-formed profile ID and is refused onbedrockbackends, which read the profile from the body; set the profile per key there instead. - 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 answers400 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_extensionsprofile, 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
blockingmode the Hub key authenticates the request and carries its profile. Inpermissivemode 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 as4111 111111111111) that pass the Luhn checksum. Non-standard short groupings (such as2-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,+/00country-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-definedcustom_entitiestake precedence overcredit_cardandphone. Any overlappingcredit_cardorphonespan is dropped even if it passes Luhn. - A
phonespan wholly contained inside a validcredit_cardspan (starting with a non-zero ISO/IEC 7812 issuer digit) is dropped sophone: blockdoes not block a credit card thatcredit_card: maskwould redact. - Digit runs starting with
0are never treated as credit cards; phone spans starting at0are 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).
- Korean identifiers (
- 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 incrementsguardrail_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 (aTransformverdict).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:
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:
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.200cloud 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.