Skip to main content

Routing

People and agents can have their own routes. Requests without one follow the org config and its conditional lanes.

A new node-written origin, or a new pairing of credentials with a base URL or custom_endpoints, needs providerkeys:manage (providers:write for API keys) as well as routing:write. This covers roots, groups and every branch, including nodes without a provider. A host injected from the node's own provider key needs no extra grant. Copying that URL into routing, or inheriting it for another node's credential, is node-written.

The comparison uses dispatch's key injection and inheritance. It distinguishes named pins, inline bags and unpinned credentials; headers add a separate keyed MAC. These MACs stay inside the save comparison. Provider keys come from authority; unavailable authority returns 503 provider_keys_authority_unavailable. Operators can retain existing grants. A new grant without permission returns 403 outbound_host_requires_providerkeys_manage and audits only the actor, code and route.

WhereHow
Org defaultRouting page, or GET / PUT /admin/routing-config
One personUsers → person → Routing (below)
Per requestx-anyray-config header

API-key traffic only; passthrough traffic (a subscription seat, or a caller's own provider key) keeps its own token and provider. It still runs the config's guardrails (pass-through traffic).

Routing reads mask inline provider credentials and customBody, including snake-case spellings, as {"$anyrayCredentialRef":"<64 lowercase hex HMAC>"}. inlineCredentials: {present, targets} reports presence and node count. Stored config and dispatch stay unchanged.

Guardrail checks are masked the same way: every field under a check's credentials except addresses and modes (resourceName, customHost, azureAuthMode, base URLs, domains, regions), plus a top-level apiKey, a webhook or log sink's headers, and every URL a check sends data to (webhookURL, logURL, jwksUri): an incoming-webhook URL is itself a credential. An address that embeds userinfo, a query or a fragment is masked whole. A reference stays on the check it came from, in the same guardrail field and spelling. Changing that check's plugin, any of its parameters or their key order is refused, and so is a reference placed anywhere the read did not put one. You can remove, reorder or disable (is_enabled) checks. New literal guardrail credentials are still accepted.

Echo references unchanged. They resolve from the authoritative config and bind the node's raw fields, spellings and types, except weight and request_timeout. Ancestors bind dispatch's propagated fields; group-local strategy and on_status_codes remain editable. You can reorder or remove targets and edit independent siblings. No routing save, even a security admin's, can change a reference's addressing. A target with its own credential and no provider_key_id still takes the fields it leaves unset, Base URL included, from the provider's default key, which only providerkeys:manage can change. New literal customBody values remain writable; new credentials must use Provider keys and provider_key_id. A save replies with the config as stored, read like a GET, so the reply saves back unchanged. If that read fails, the save still happened and the reply is 503 routing_config_saved_unreadable; reload before saving again.

Saves check schema and depth, refuse literal credentials, compare expectedRevision, resolve and bind references, check reload parity, then authorize outbound tuples using resolved credentials. Resolution and authorization share one stored snapshot, fenced by the write's revision. Referenced nodes and ancestors keep raw own fields; other subtrees normalize host spellings. An absent or empty root api_key may also have a reference so previous releases reject projected saves safely. When guardrail credentials are masked, the first unused root credential spelling holds one too.

Save refusalCode
New literal credentials400 inline_credentials_refused
A sealed value: a string starting rsec:v<n>:, or an anyray_sealed object400 routing_sealed_value_refused
Unknown or stale reference400 routing_credential_reference_invalid
Changed bound fields400 routing_credential_address_changed
Changed guardrail check under a reference400 guardrail_credential_check_changed
Conflicting aliases400 routing_field_alias_conflict
Config would change or disappear on reload400 routing_config_load_mismatch
Saved, but reading it back failed (reload)503 routing_config_saved_unreadable
No database, and the routing file can't be read or parsed (GET too)500 routing_config_unreadable

The private routing key is a durable random root in the shared database, with a data-dir file only when no database is configured. Rotating the admin token does not change revisions or references. If the key is unavailable, reads and saves return 503 routing_credential_key_unavailable without a revision; requests keep using cached routing. A read the store could not serve returns the cached copy with fromCache: true. Restore the shared spend database or writable ANYRAY_DATA_DIR, then reload.

Stored routing credentials can be sealed with a key derived from ANYRAY_ADMIN_TOKEN. One this token cannot open fails only its own target with 424, never another key, and /admin/health reports routingCredentials: unreadable. Replace that target's credential with a provider key.

SettingValue
anyray-default (sent by anyray-connect)Resolves to ANYRAY_DEFAULT_MODEL, default anthropic/claude-sonnet-5-5. With an org default config saved, when every target the request can reach (in that config, or in the person's or agent's own routing) serves that model, routing picks the provider and key as it would for the model id itself; Bedrock counts for a Claude model. Otherwise, and on a deployment with no default config, it goes to the model's provider. An alias you set for anyray-default always uses its own provider.
AliasesANYRAY_MODEL_ALIASES or PUT /admin/model-aliases
Retries502 / 503 / 504 and network errors: 2 attempts. 429: non-streaming only. retry block: { "attempts": 0 } opts out, max 5
ModeBehavior
singleOne target for every request.
loadbalanceSpread across targets by weight, sticky per conversation.
fallbackTry targets in order; advance on failure.
conditionalPick a target from request conditions (below).

Upstream timeouts​

Set in .env, then restart.

Env varDefaultBounds
ANYRAY_UPSTREAM_CONNECT_TIMEOUT_MS10000Connecting to the provider.
ANYRAY_UPSTREAM_HEADERS_TIMEOUT_MS60000First response byte of a streaming request.
ANYRAY_UPSTREAM_BODY_TIMEOUT_MS600000Gap between streamed chunks, and the wait for a non-streaming response's headers.

Conditional routing​

First matching conditions entry wins, else default; neither gives a 400.

{ "strategy": { "mode": "conditional",
"conditions": [ { "query": { "metadata.team": "research" }, "then": "frontier" } ],
"default": "economy" },
"targets": [ { "name": "frontier", "provider": "anthropic" },
{ "name": "economy", "provider": "openai" } ] }
Query partValues
Fieldsmetadata.<key>, params.<field>, url.pathname
Operators$eq, $ne, $gt / $gte / $lt / $lte, $in, $nin, $regex; combine with $and / $or

Regex limits​

$regex is admin config only: a client x-anyray-config carrying one gets a 400. Saving refuses a pattern over 128 characters, a backreference or lookbehind, a repeated group that itself repeats or alternates, and a pattern that could take too long to match. A pattern that starts with ^ can hold more repeats (^\d+-\d+$ saves, .*.*x does not). A $regex never matches a value over 256 characters, and a stored pattern that breaks these rules never matches.

A regexMatch or regexReplace guardrail rule has the same limits, and a client x-anyray-config may carry one. The flags are g, i and s, each at most once:

CheckRuleFlags
regexMatchthe pattern as written (/api/v1/ matches the slashes)a flags parameter: { "rule": "secret", "flags": "i" }
regexReplace/pattern/flags, or a bare pattern; redactText up to 1,024 charactersin the rule: /secret/gi (a flags parameter gets a 400)

Each rule is judged on a 100,000-character prompt, so one that slows on long text (a.*b, grok.*fast) gets a 400 on the request and is refused on save, while \d{3}-\d{2}-\d{4}, \d+ and sk-[A-Za-z0-9]{48} pass. A config may carry at most 32 regexMatch and regexReplace checks. The x-anyray-default-input-guardrails and x-anyray-default-output-guardrails headers count toward the 32 of the x-anyray-config sent with them, and get 32 of their own without one.

One request's regex checks share a fixed amount of matching work, across every fallback or load-balance target that reruns them and every config they come from. A request that would go past it gets a 446 and is not sent on, so text never goes through unchecked or unredacted. The same 446 stops a regexReplace whose redactText would grow the text by more than its own length plus 64 KiB.

A jsonSchema guardrail's pattern and patternProperties keys have the same limits, read with the u flag the validator uses, and share the same matching work. A schema can't use the url format (it can take too long on long text): use uri. A schema that could apply too many subschemas to one JSON value (allOf of two $refs to the next level, repeated), or is too large to load, gets a 400 and is refused on save. A recursive schema, such as a tree node whose children refer back to it, is fine. One request's jsonSchema checks also share a fixed amount of validation work.

What checks read​

RequestInput checks readOutput checks read
Chat completions, messagesThe latest turnThe answer's text
Responses (/v1/responses)The latest turnThe answer's output_text and refusals
CompletionspromptThe answer's text
EmbeddingsinputNothing to check
Token countsThe latest turnNothing to check
Image generations and editspromptRefused, as below

The latest turn is every message after the last assistant message, plus an assistant prefill that ends the request. That covers text blocks, tool results, text documents, search results, OpenAI tool messages, and a system message that follows the user's. On the Responses API it is what the client added after the model's last response (its reasoning, message and tool calls): tool outputs (function_call_output, shell stdout and stderr, and the rest) and new messages, plus the prompt template's variables. The system prompt, instructions and earlier turns are not read, except by a redaction (see Redaction). Partner guardrails get the same text, tool output included.

Any other body is one the checks cannot read: a file upload, audio, a batch, a request through the /v1/* proxy, or an image, a file or any answer that is not JSON. Under a guardrail with deny, or a check that can rewrite it, it gets a 446 and is not sent on. A GET or DELETE sends no body, so input checks have nothing to read on it. Checks that read only the request type, the metadata or a header (allowedRequestTypes, requiredMetadataKeys, jwt) run on every request, and a hook made only of them never refuses a body for being unreadable.

Input checks judge the request as the client sent it. Optimizing an allowed request never changes their result: a wordCount minimum the client's text meets still passes when the optimized text is shorter, and a maximum it exceeds still refuses the request, however short the optimized text would be.

On chat completions, completions, messages and the Responses API, the first target's input checks run before the optimizer. The optimizer sees only what they let through, and a refused request is never optimized. The first attempt reuses their result. If a check rewrote a value the target's override_params sets, that attempt runs the checks again. A fallback target runs its own input checks when the request reaches it.

Which guardrails run​

The guardrails that run are those of the config that routes the request. A request's x-anyray-config, its x-anyray-provider, or the signing person's or agent's own routing replaces the org config, guardrails included: the org config's guardrails do not run on that request. On a passthrough request, x-anyray-provider only names the provider the caller's key belongs to, so it replaces nothing (pass-through traffic).

In a config, a target's hooks and guardrails run in addition to the ones it inherits, never instead of them, so a fallback, load-balance or conditional target cannot skip a rule set above it.

When a check fails​

A check that throws fails its hook. With deny, or a check that can rewrite, the request gets a 446; otherwise the check is recorded as failed and the request goes on.

These checks can rewrite what they read: regexReplace, addPrefix, webhook, anyray.pii, azure.pii, bedrock.guard, pangea.pii, promptfoo.pii, patronus.pii, patronus.phi, f5-guardrails.scan, acuvity.scan, crowdstrike-aidr.guardChatCompletions and exa.online. Every other check only passes or fails.

A check that finds no text to read (an answer that only calls tools, an empty prompt) passes with no error, whatever failOnError says. An answer whose only text is a refusal, or whose text sits past its first choice, does not count as having no text. The checks do not read that text, so such an answer gets the same result it always did.

A check that returns an error (a partner it cannot reach, missing credentials) fails if the check sets failOnError: true. A guardrail with deny then blocks the request with a 446, and the check's error in hook_results reads the check returned an error. Without failOnError, the error is recorded and the request goes on.

Set failOnError on a deny guardrail you rely on

With failOnError, a partner outage blocks requests until the partner is back. Without it, a request can slip past: one that makes the partner call time out causes the error itself.

Under failOnError, a check that can rewrite blocks with a 446 when it returns an error, even in a mutator or a guardrail without deny. An async hook runs too late to block. Any other check in a hook without deny records the failure, and the request or answer goes on.

failOnError takes true or false. Any other value (the string "false", 1) gets a 400, in an x-anyray-config, in the default-guardrail headers and when an admin saves the routing config. A config saved before this check that holds such a value still loads, and the value counts as false.

A config saved before these limits still loads. Each rule runs on as much text as it can finish in time: about 100,000 characters for a rule saved under these limits, with the turn's text blocks counted together. Past that, when a text block is not a string (a token-id prompt, say), or when a jsonSchema check can't validate its JSON in time (nested too deeply, an array too long for uniqueItems, more JSON than the request's validation work allows, or a saved schema these limits now refuse), a guardrail with deny blocks the request with a 446. A regexReplace always does, and so does one that fails to run, so unchecked or unredacted text never goes through. A guardrail without deny records the check as failed.

The hook_results the gateway adds to a response list each hook's id, type, verdict, deny, transformed and skipped, and each check's id, verdict and an error with a fixed reason. They never include the text or JSON a check matched, or its rule.

Redaction​

A regexReplace redaction covers more than the latest turn: the system prompt and every message (on the Responses API, instructions and every input item), rewritten the same way every turn. A client sends its own unredacted copy of the history back each turn, so this keeps a secret out of later requests, and it keeps the bytes the provider caches identical turn to turn. Signed thinking blocks and tool-call inputs are left as they are; an OpenAI refusal is redacted like any text. A redacted answer loses the log probabilities of the text that changed. A redaction writes its result back to the places it read, an empty result included, and a failOnDetection one fails only on a match in the latest turn.

Redactions add up, across hooks and whether or not a hook is sequential. A regexReplace that read text another redaction has since changed runs again on the new text. A partner redaction is never called twice: when it lands on changed text, the regexReplace redactions run again on its result, and the request gets a 446 if one of them can't. An embeddings request is checked and redacted too, its inputs counted as one text (a card-number rule reaches about 106,000 characters across the batch).

For a request from a verified user, each gateway replica remembers where a rule matched each text (never the text), for that user only and until it goes unused for an hour, so a session pays for its history's regex work once. A long session that reaches a replica that has not seen it is checked in full there and can pass the rule's limit: that request gets a 446, but the texts that fit are remembered, so sending it again gets further. A request with no verified user is checked in full every time.

A partner redaction (anyray.pii, azure.pii, pangea.pii, promptfoo.pii, patronus.pii, patronus.phi, bedrock.guard, f5-guardrails.scan and acuvity.scan, with redaction on) covers the same places for a request from a verified user. The latest turn goes to the partner, and only it decides the check's verdict. Each replica remembers, for that user and until it goes unused for an hour, where the partner changed each text and what it put there, never the text itself. A hash or format-preserving replacement is kept as the partner returned it, which is what the provider gets anyway. History the replica has seen is rewritten the same way with no partner call. History it has not seen goes to the partner in separate calls, oldest first: for each check, at most 512 texts, 2,000,000 characters and 10 seconds a request, with 8 calls at a time.

Past those limits, or when the partner fails on the history, the check returns an error. With failOnError, the request gets a 446 and the next one picks up where it stopped, so a long session on a replica that has not seen it is refused until enough of its history is remembered. A 1,500-text session takes 3 requests when each partner call takes 120 ms, and 21 at 1.2 seconds for a partner that checks one text a call; after that, the session is not refused again on that replica while it stays in use. Without failOnError, history the partner did not check goes on as the client sent it. A request with no verified user has only its latest turn redacted: its history reaches the provider as the client sent it, and each redacted turn costs one prompt-cache miss when it becomes history. A crowdstrike-aidr check already sends the whole request and replaces it with the partner's answer.

Azure's PII service takes at most 5 texts of 5,120 characters a call, counting an emoji as one. A longer text is cut at its last newline before the limit, else its last space, else its last punctuation mark, else at the limit, and each piece is checked; an entity that spans a cut, such as a two-word name, can be missed. A text counts as checked only when every piece comes back. A latest turn of more than 512 pieces is not checked.

Azure checks​

An Azure check (azure.contentSafety, azure.pii, azure.shieldPrompt, azure.protectedMaterial) in a request's x-anyray-config or default-guardrails header runs on the caller's own API key or Entra client credentials. One that sets azureAuthMode to managed or workload gets a 400, because those modes use the gateway's own Azure identity. In the org routing config, managed works (workload identity is not supported by these checks). The gateway mints that identity's token for Azure AI services only and sends it only to an Azure AI services host (*.openai.azure.com, *.cognitiveservices.azure.com or *.services.ai.azure.com). A save whose check names another customHost, or a resourceName that moves the host off Azure, gets a 400, and the check refuses it with an error before fetching the token. The gateway verifies the TLS certificate of every Azure host, custom or not.

A bedrock.guard check reads awsAccessKeyId, awsSecretAccessKey, awsRegion and an optional awsSessionToken from its credentials, or awsAuthType: assumedRole with awsRoleArn and awsExternalId. A check saved with the older names accessKeyId, accessKeySecret and region works too. When a check has both, the aws name wins.

A caller's bedrock.guard check with awsAuthType: assumedRole gets a 400: it would assume the role with the gateway's own AWS identity. Send the caller's own AWS access key pair.

Streamed answers​

On chat completions, completions, messages and the Responses API, a streamed response is held until it ends and checked whole when an output guardrail has deny or an output check can rewrite the answer. A denial is the same 446 JSON a non-streamed one returns, and a redacted answer is sent as a stream carrying the redacted text. A held stream the gateway cannot read, or cannot rebuild after a redaction, gets a 446, and so does a Responses stream with an event whose text the checks do not read. A held stream past 64 MB gets a 446 too, and the gateway stops reading it. When a held stream ends before the checks get an answer to read, the 446 error type says why: upstream_stream_failed when the upstream broke it, held_stream_cut when the gateway stopped reading (the size cap, or a client that left). Other streams are sent as they arrive. A single stream event longer than 128 MB (as many characters, on a text stream), held or not, ends the stream before that event, the way a dropped upstream connection ends it, and the gateway stops reading it. The spend row of a refused answer carries what the provider billed for it, and the refusal adds no savings. When a fallback or retry then moves on, the row adds that cost, at the refused target's price, to the answer's.

Codex gets a guardrail denial and a held_stream_cut as a 400 with the same body, on an API key and on a subscription seat. Codex retries a 446 up to five times, and each retry is a new answer the provider bills and the gateway refuses again; a 400 it shows at once. An upstream_stream_failed stays a 446, so Codex retries a broken upstream. To tell a denial apart, the gateway reads at most 64 KB of a 446 body, for at most two seconds; a longer or slower body reaches Codex as sent.

Identity fields​

Identity fields read the verified key, not the x-anyray-metadata header:

FieldReads
metadata.user, metadata.userIdThe key's bound user
metadata.team, metadata.teamIdThe key's bound team
metadata.agent, metadata.agentIdThe service key's name, and its id
Identity lanes need per-person keys

On a shared org key or BYO pass-through credential, metadata.user comes from the header, so anyone can claim a user lane.

Other metadata.<key> fields are client-controlled. Team = SCIM-mapped team, else the enrollment team; a key with no team ignores a header team (SSO enrollment groupTeamMap / defaultTeam).

Guardrails on pass-through traffic​

A passthrough request keeps its own token and provider and is never routed through the config: a Claude Code, Codex or Copilot seat, or any request sent with x-anyray-auth-mode: passthrough and the caller's own key. It runs the config's guardrails and nothing else of it (no key, retry, cache or override): the hooks the org default config, or the signing agent's own routing, gives that request. A caller's x-anyray-config replaces the org's, guardrails included.

CheckOn pass-through
Input deny446; the request never reaches the upstream
Input redactionThe request is rewritten before it leaves, retries included
Output deny or redactionA successful answer is held until it ends and checked whole, as on API-key traffic, then sent, sent redacted, or refused with a 446. An upstream error is sent as it came
A body the checks cannot read446; on Copilot, also a request to a path that is not chat completions, Messages or Responses while an output deny or redaction applies

As on an API key, Codex gets a guardrail denial or a held_stream_cut as a 400.

When the config's router picks no target for a seat request (no condition matches and there is no default, or every load-balance weight is 0), the seat runs the guardrails set on that router and above it, and none of its targets'. With none there, the request goes as if no guardrail were set. A route that fails for another reason (a condition or the default names a target that doesn't exist, say) still gets a 446, as it fails on API-key traffic.

Every check runs, so a webhook, log or partner check in the config gets the request's text, as it does for API-key traffic. With no guardrails, pass-through traffic is forwarded with no added work.

On Claude Code and Codex, a caller's x-anyray-config that breaks a config rule gets the same 400 as on any other route. Copilot reads a caller's config only for its guardrails, so only one that carries guardrails gets that 400; one without them is ignored.

Checks that only record (a log hook, a guardrail without deny) run on the answer as they do for API-key traffic: a streamed answer is not held for them, and a failed one turns a JSON answer into a 246. They don't run on Codex's WebSocket.

Codex's WebSocket cannot hold an answer, so when an output deny or redaction applies, the gateway answers the upgrade with 426 and Codex sends the session over HTTPS instead. One added while a socket is open closes it once the turn in flight ends, and Codex reopens it over HTTPS. On the socket an input denial reaches Codex as a 400 error event, as over HTTPS. While the config has a deny or redaction anywhere, a socket frame that is neither a request nor Codex's interrupt of its own response is refused the same way, since the checks cannot read it.

Credentials on pass-through traffic​

A passthrough request is signed with the caller's own credential. From the provider's stored key it keeps the base URL and endpoint paths for every provider, plus these addressing fields:

ProviderTaken from the stored keyNever taken
bedrockRegion, the Claude over InvokeModel switchesAccess key pair, session token, role, external id, auth type
azure-openaiResource name, deployment id, API versionAPI key, AD token, auth mode, Entra, managed and workload identity fields
vertex-aiProject id, regionService account JSON
oracleRegion, compartmentAPI key, the IAM signing fields

Any other field on a stored key is never taken either.

On Bedrock the caller names its own credential: a Bedrock API key as the bearer with x-anyray-aws-auth-type: apiKey, or x-anyray-aws-access-key-id and x-anyray-aws-secret-access-key. A request with neither, or with x-anyray-aws-auth-type: assumedRole, gets a 400. The gateway would assume that role with its own AWS credentials.

On Azure, auth mode managed or workload gets a 400 on every route that reaches Azure OpenAI, because it uses the gateway's own Azure identity (Azure). The caller's API key, AD token or Entra client credentials work.

In pass-through, /v1/messages always uses the Anthropic seat lane or the request's custom host. The Bedrock and Azure rules apply to the other routes.

Pinning a target to a named provider key​

Any target may carry provider_key_id naming one of the provider's named keys (the id only, never a credential):

{ "strategy": { "mode": "conditional",
"conditions": [ { "query": { "metadata.team": "ml" }, "then": "ml-lane" } ],
"default": "org" },
"targets": [ { "name": "ml-lane", "provider": "openai", "provider_key_id": "team-ml" },
{ "name": "org", "provider": "openai" } ] }

Admin config only (ignored in a client x-anyray-config). Unknown ids are rejected on save; a later-deleted key fails pinned requests with 424, with no fallback.

Forwarded headers​

A target's forward_headers list, or the x-anyray-forward-headers request header (comma separated), names request headers to copy upstream. The value always comes from the request.

A forwarded header never replaces the provider credential (Authorization, x-api-key, api-key) or a provider key's Custom headers. When the request runs on a key the gateway holds, it also never replaces any other header the gateway sets for that key: OpenAI-Organization and OpenAI-Project from the target's openai_organization and openai_project, Oracle's CompartmentId, or the signed headers of a SigV4 or OCI request. The gateway drops that copy and logs only the header's name, at debug level.

Account headers never forward beside a key the gateway holds, even when the gateway did not set one: OpenAI-Organization, OpenAI-Project, x-goog-user-project, x-goog-project-id, CompartmentId, and the x-anyray-* account headers.

On litellm, truefoundry and anyray-upstream, two configured values are not sent: a custom header the gateway drops (such as Connection), and a custom_body field the caller replaces with its own body or override_params. Such a value stops counting as a credential the gateway holds only when it appears nowhere else in the request (body, headers or URL). A common value such as Connection: keep-alive usually matches the client's own headers, so it still counts.

Still forwardedWhy
content-type, openai-beta, anthropic-beta, anthropic-version, x-grok-conv-idThe gateway builds them from the caller's request
Other headers the gateway did not setThere is nothing to replace

passthrough traffic, and a request served on the caller's own credentials, forward as before: the caller's copy wins over everything except the credential and Custom headers.

On a key the gateway holds, a caller's account fields in x-anyray-config (openai_organization, openai_project, resource_name, vertex_project_id, oracle_compartment_id, and the Bedrock role's aws_role_arn and aws_external_id) and the matching x-anyray-* headers are ignored. Account headers inside the caller's custom_headers are dropped too. The admin's routing target or provider key decides, else the key's default. On Vertex, a request on any route naming another project in its path or a $userProject / userProject parameter gets a 400 on a key the gateway holds.

A caller's auth_passthrough in x-anyray-config is ignored. Only x-anyray-auth-mode: passthrough selects pass-through.

Routes for people and agents​

For API-key requests, precedence is x-anyray-config, then x-anyray-provider, then the signing person's or agent's route, then the org config. Own routes apply even when org routing is off. A delegating agent uses its own route, never the end user's.

A person route has a default, optional model rules, and optional providerKeys. Agents use their binding as the default and routingRules for model rules. Destinations are org, a single / loadbalance / fallback config, or subscription (people only). An exact model rule wins over a family rule; among families, the longest prefix wins. Otherwise the default applies. Model rules apply to JSON-bodied chat, messages, responses, and embeddings requests. Multipart uploads follow the route's default. Model matching normalizes provider prefixes and dated ids.

A key-signed request matching subscription uses the person's non-subscription default, else the org route, retaining their provider-key map. Pass-through traffic keeps its current provider and credential. /v1/me/models lists each destination's models only where the model rules send them there.

Agent bindings​

Bind a provider and key on the agent itself (Agents page or at mint time; shown read-only under From Agents on Routing). Precedence: request x-anyray-config → x-anyray-provider → own route → org config. A request x-anyray-provider (or a model alias) picks the provider over both, but its key still comes from routing when routing names one account for that provider: the agent binding, else the org config (a lane on the model counts on chat, messages and new Responses requests). Otherwise the agent's own key for that provider serves, else the default key. Or write a lane:

{ "strategy": { "mode": "conditional",
"conditions": [ { "query": { "metadata.agent": "release-bot" }, "then": "ci-lane" } ],
"default": "org" },
"targets": [ { "name": "ci-lane", "provider": "bedrock", "provider_key_id": "ci-account" },
{ "name": "org", "provider": "openai" } ] }

Only service keys match agent lanes.

Give an agent its own key for a provider​

  1. On the Routing page, under Agent keys, click Add.
  2. Pick the agent, the provider, and that agent's named key.
  3. Click Save. Add one row per agent and provider.

That agent's requests to that provider use its key, including simple model names such as a grok alias. Its other providers keep their default key:

AgentAgent keys rowgrok billsclaude bills
agent-ax-ai → xai-agent-axai-agent-aAnthropic default key
agent-bx-ai → xai-agent-bxai-agent-bAnthropic default key

A key picked in Targeted routing, Split routing or Agent routing still wins. If the key is deleted, the agent's requests to that provider fail with 424 instead of billing another account. API field: routingProviderKeys (reference).

An agent's key goes to every host the org config sends that provider's default key to. So adding, changing or removing a row needs providerkeys:manage (providers:write for API keys) as well as clientkeys:manage. Without it the save returns 403 agent_provider_keys_requires_providerkeys_manage and nothing changes.

Targeted routing on the Routing page​

Targeted routing adds lanes in front of the picked strategy, which becomes the default target (so conditional isn't in the picker).

MatchCondition it writesMatched against
agent namemetadata.agentThe verified service key's name
agent id (survives rename)metadata.agentIdThe verified service key's id
usermetadata.userThe verified key-bound user
teammetadata.teamThe verified key-bound team
modelparams.modelThe model named in the request
  • Values match whatever their letter case. A $regex on the model also tests the id in lowercase, so ^grok- catches Grok-4.7. To leave a model out of a $regex lane, add a $nin test (it ignores letter case); a lookahead can still pass on the id as sent. Any other $regex matches as written.
  • Name lanes stop matching on rename (traffic falls to the default); match on id instead. Revoked agents are flagged, not blocked.
  • Hand-written conditions, a third test, or a $regex other than a model family lane show read-only; use PUT /admin/routing-config.
  • A $regex lane lists every current priced model it matches in GET /v1/me/models.
Narrowing a lane with a second test

And also adds a second test on a different dimension (one query object = AND). Here Grok goes to each agent's own xAI key, everything else to a shared Bedrock account:

{ "strategy": { "mode": "conditional",
"conditions": [
{ "query": { "metadata.agentId": "svc_a", "params.model": "grok-4.6" },
"then": "agentId:svc_a+model:grok-4.6" },
{ "query": { "metadata.agentId": "svc_b", "params.model": "grok-4.6" },
"then": "agentId:svc_b+model:grok-4.6" }
],
"default": "default" },
"targets": [
{ "name": "agentId:svc_a+model:grok-4.6", "provider": "x-ai", "provider_key_id": "xai-agent-a" },
{ "name": "agentId:svc_b+model:grok-4.6", "provider": "x-ai", "provider_key_id": "xai-agent-b" },
{ "name": "default", "provider": "bedrock", "provider_key_id": "shared" } ] }

Split routing writes this in one step: pick one or more agents, the models, a provider and a key, then Apply (one lane per agent and model, on agent id, above broader lanes). Applying again re-points instead of duplicating. The agents stay picked, so the next provider is one more Apply.

To send every model of a provider, pick All xAI models (or All OpenAI models, All Anthropic models; one per provider that holds a key). That writes one lane per agent, which also catches models released later, and the page lists the current models it covers:

{ "query": { "metadata.agentId": "svc_a", "params.model": { "$regex": "^grok-" } },
"then": "agentId:svc_a+model~^grok-" }

A model picked by name for the same agent goes above its family lane, so grok-4.7 can bill another key while the other Grok models use the family lane's key.

Sending one person to their own provider​

Users → person → Routing → provider (and key) → Save writes the same user lane. The row names earlier lanes that could win, and why it is read-only when it is (routing off, no default provider, hand-written config). Agents use their Agents binding instead.

Saving safely from two places​

Send GET's revision back as expectedRevision; a stale one gets 409 with the winning revision.

Revision details
GET /admin/v1/routing → { "config": {...}, "revision": "a1b2c3d4e5f60718" }
PUT /admin/v1/routing { "config": {...}, "expectedRevision": "a1b2c3d4e5f60718" }

Without expectedRevision, the save retries concurrent changes up to three times, resolving references and checking permissions against the latest config each time. Repeated races return 409 with a retry message. The revision is an opaque keyed token: key order and no-op saves do not change it. The console reloads on 409 and asks you to reapply.

Without a database, a routing file that exists but can't be read or parsed gets no revision. GET and any save with expectedRevision return 500 routing_config_unreadable and leave the file as it is. Restore the file or remove it (a missing file means no routing config), then reload.