Provider router
The provider router is an opt-in local proxy inside the broker. A coding agent points its API base URL at the broker; the router picks an upstream provider and model for each request, translates between the OpenAI Responses, OpenAI Chat Completions, and Anthropic Messages wire formats, fails over across an ordered list of targets, and records every request and physical attempt in a local ledger. It is how Codex can run on DeepSeek, OpenRouter, Kimi, Z.ai, or a local Ollama model, and how Claude Code can run on an OpenAI-compatible provider, without either agent knowing.
It is off until the owner turns it on. Nothing else in AgentNotify depends on it, and a broker with the router off makes no request to any model provider.
Endpoints
All routes live on the broker's existing loopback listener, under /router/v1:
| Method and path | Inbound wire | Typical client |
|---|---|---|
POST /router/v1/responses |
OpenAI Responses | Codex (wire_api = "responses") |
POST /router/v1/chat/completions |
OpenAI Chat Completions | OpenCode, Kilo, anything OpenAI-compatible |
POST /router/v1/messages |
Anthropic Messages | Claude Code (ANTHROPIC_BASE_URL) |
POST /router/v1/messages/count_tokens |
Anthropic | Claude Code; forwarded only to an Anthropic-wire upstream, otherwise 404 |
GET /router/v1/models |
OpenAI model list | lists every selectable provider/model, alias, and combo |
- Off: every
/routerrequest gets404 {"error":{"type":"router_disabled",...}}. - Authentication: a separate router key, not the broker's
/v1bearer token. It is accepted asx-agentnotify-router-key: <key>,Authorization: Bearer <key>, orx-api-key: <key>and compared in constant time. The key only grants spending through the router; it cannot read notifications or configuration. It is generated when the router is first enabled and can be regenerated. When it arrives inx-agentnotify-router-key, the client'sAuthorizationorx-api-keyis its own Anthropic credential, used only for its own models (see Claude Code's own models). - Loopback: the same
Hostcheck as the web interface (loopback names only), so a DNS-rebinding page cannot reach it. Browsers are refused: a request carrying anOriginheader gets403. - Body size: the broker's 64 KiB API limit does not apply. Router routes raise the per-request
limit to
RouterMaxRequestBodyBytes(default 32 MiB) because agent requests carry whole conversations and images.
Errors are written in the inbound wire's own error shape ({"error":{"message","type","code"}} for
OpenAI wires, {"type":"error","error":{"type","message"}} for Anthropic) so the agent displays them.
The code is one of a fixed set — router_disabled, unauthorized, forbidden,
host_not_loopback, payload_too_large, invalid_request, missing_model, unknown_model,
ambiguous_model, no_enabled_target, unsupported_previous_response_id, not_supported,
provider_key_unreadable, subscription_signed_out, rate_limited, payment_required, timeout,
connection_error, client_error, upstream_error, all_targets_unavailable — and never a
provider's own message. What the message may carry is the provider's own short error code, such as
Upstream returned HTTP 429 (usage_limit_reached): taken from error.code, error.type,
detail.code, or a top-level code/type, and only when it is an identifier of at most 64
characters ([A-Za-z0-9_.-]). It is also written to the broker log with the upstream and model, so a
failed request can be explained afterwards. The ledger keeps its fixed code. A native Anthropic
request is the exception: Anthropic's own error body and retry-after are returned to the agent
unchanged.
Configuration and storage
config.json holds only the switch, the router key, and limits:
RouterEnabled (default false), RouterKey, RouterMaxRequestBodyBytes, RouterLedgerRetentionDays
(default 30).
SQLite holds everything else, in tables created idempotently by RouterRepository.InitializeAsync:
router_upstreams—id,slug(unique,[a-z0-9][a-z0-9-]{0,31}, theproviderpart ofprovider/model),label,wire(openai_responses | openai_chat | anthropic_messages),base_url,encrypted_key(nullable: local servers and subscriptions need none),models(JSON array of up to 500 native model IDs the owner declared),enabled, timestamps,auth(api_key | codex_chatgpt | muse_code, defaultapi_key), andmodel_wires(nullable JSON object of model ID → wire, for a provider that serves some models over a different wire than its own). The last two columns are added to an older database in place. Keys are sealed with the broker'sISecretProtectorbefore storage, decrypted only while building an upstream request, and never returned, logged, or put in the ledger — the same rules as API accounts.router_routes—id,name(unique),kind(alias | combo),targets(JSON ordered array ofprovider/modelstrings),enabled, timestamps. An alias has exactly one target; a combo has 1–8 and fails over in order.router_settings— one row:default_route(aprovider/model, alias, or combo name, nullable).router_requests— one row per logical request:id,started_at,finished_at,inbound_wire,requested_model,route_kind(explicit | alias | combo | native | model_list | default),route_name, finalupstream_slug/model,stream,status(HTTP status returned to the client),outcome(ok | upstream_error | client_error | canceled | failed_over_exhausted), token counts (input,cached_input,output,reasoning, nullable),usage_status(reported | unreported),error_code(a short fixed code, never a provider message body).router_attempts— one row per physical upstream send:request_id,ordinal,upstream_slug,model,upstream_wire,status(HTTP status or null),error_code,duration_ms,bytes_streamed(whether any byte reached the client),started_at.
The ledger never stores prompts, responses, headers, keys, or provider error bodies. Rows older than
RouterLedgerRetentionDays are pruned when the broker starts and once a day.
Upstream presets
RouterPresetCatalog offers presets that fill everything except the key: slug, label, wire, base URL,
how the upstream authenticates, and which wire each model family uses. The owner can still enter any
base URL as a custom provider. base_url is the prefix the wire path is appended to (/responses,
/chat/completions, /messages):
| Preset | Kind | Wire | Base URL |
|---|---|---|---|
| ChatGPT plan (Codex) | subscription, unofficial | openai_responses |
https://chatgpt.com/backend-api/codex |
| OpenCode Go | subscription (API key) | openai_chat, per model |
https://opencode.ai/zen/go/v1 |
| Muse Code plan | subscription, unofficial | openai_responses |
https://api.meta.ai/v1 |
| OpenCode Zen | per token | openai_chat, per model |
https://opencode.ai/zen/v1 |
| Meta Model API (Muse Spark) | per token | openai_responses |
https://api.meta.ai/v1 |
| OpenAI | per token | openai_responses |
https://api.openai.com/v1 |
| Anthropic | per token | anthropic_messages |
https://api.anthropic.com/v1 |
| OpenRouter | per token | openai_chat |
https://openrouter.ai/api/v1 |
| DeepSeek | per token | openai_chat |
https://api.deepseek.com/v1 |
| Moonshot (Kimi) | per token | openai_chat |
https://api.moonshot.ai/v1 |
| Z.ai | per token | openai_chat |
https://api.z.ai/api/paas/v4 |
| SiliconFlow | per token | openai_chat |
https://api.siliconflow.com/v1 |
| Groq | per token | openai_chat |
https://api.groq.com/openai/v1 |
| Ollama | this computer | openai_chat |
http://127.0.0.1:11434/v1 |
| LM Studio | this computer | openai_chat |
http://127.0.0.1:1234/v1 |
Per-model wires. OpenCode Zen and Go serve several model families under one base URL and key, each
on its own wire: Claude, Qwen, and Union (and Go's MiniMax) on /messages; GPT, Grok, and Muse Spark on
/responses; everything else on /chat/completions. The preset's prefix rules turn the saved model
list into model_wires, and RouteResolver narrows each resolved target to its model's wire, so the
proxy, translator, and ledger see the wire actually used. Zen's Gemini models use Google's own wire,
which the router does not speak, so they are left out of its model list. A model typed as an explicit
provider/model that was never declared uses the upstream's own wire.
Finding a provider's models. POST /ui/api/router/models/fetch lists a provider's models so they
can be ticked rather than typed. It asks GET <base_url>/models with the key being entered, the key
OpenCode already holds, or — for a saved upstream, and only when the base URL is unchanged — the
stored key, under the same destination rule as routed traffic; nothing is saved. The ChatGPT plan has
no public list, so Codex's own models_cache.json (entries whose visibility is list) is read
instead. The OpenAI shape {"data":[{"id"}]}, a bare array, and {"models":[{"name"}]} are accepted,
responses are capped at 4 MiB and 500 models, and the reply carries each model's wire.
Reusing a key AgentNotify already has. A key saved under Live quota → API accounts for DeepSeek,
Moonshot, SiliconFlow, or OpenRouter can be chosen as a provider's key. The upstream then stores no key
of its own; its credential_ref is api_account:<id>, and each attempt opens that account's key, so
the key is entered and rotated in one place. OpenAI and Anthropic accounts there hold Admin keys, which
cannot send model requests, so they are not offered. A removed account fails the attempt with
provider_key_unreadable rather than sending no key. A typed key, or removing the key, replaces the
reference; an upstream has one credential source at a time.
Reusing a key OpenCode has. When OpenCode's auth.json holds a plain API key for the preset's
provider (OpenCode Go and Zen, OpenRouter, DeepSeek, OpenAI, Anthropic, Moonshot, Z.ai, Groq), the page
offers to use it. The broker reads it only when asked to fetch or save, seals it like a typed key, and
never returns it; the preset list says only whether one exists. OpenCode's OAuth sign-ins are not
reused.
Subscriptions
Two presets use a monthly plan the owner already pays for, through a sign-in another tool on this computer keeps. Both are unofficial: neither plan documents use from other applications. They are opt-in, labelled so on the page, and store no key in AgentNotify.
- ChatGPT plan (
codex_chatgpt). Each attempt reads theauth.jsonof the Codex account the upstream names (credential_refprofile:<directory>, one of the accounts listed above; the built-in$CODEX_HOMEor~/.codexwhen none), so each Codex account can be its own provider and a fallback chain can move from one plan to the other. It sends that account's access token withchatgpt-account-id,OpenAI-Beta: responses=experimental, andoriginator: codex_cli_rs. When the token is within five minutes of itsexpclaim, or the backend answers401, the file is read again (Codex may have renewed it), and otherwise the router renews it with Codex's own OAuth client and writes the new tokens back into Codex's file, leaving every other field alone, so Codex and the router keep sharing one sign-in. A request to this backend is adjusted first:storeis forced tofalse,instructionsis added empty when missing, andmax_output_tokens,max_tokens,temperature,top_p, andprevious_response_idare removed. The backend only answers as a stream (it refusesstream: falsewith a400), sostreamis forced on, and a client that asked for one response gets the stream collected into one. - Muse Code plan (
muse_code). The identity token in Muse Code's~/.config/muse/auth.json(access_token) is exchanged atPOST https://api.meta.ai/muse-code/keyfor a subscription Model API key, as Muse Code's CLI does; the key is kept in memory for 23 hours and used againsthttps://api.meta.ai/v1. Muse Code may keep its sign-in in the OS keychain instead of that file; only the file is read.
A missing or unrenewable sign-in fails the attempt with subscription_signed_out (and a warning in the
broker log that says what to run); in a fallback chain the next target is tried.
OpenCode Go is a subscription too, but authenticates with an ordinary key. Its service asks each
client to name itself and to send a stable per-conversation ID, so every upstream request carries
User-Agent: agentnotify-router/<version>, and a request to an opencode.ai host carries
x-opencode-session with the agent's own conversation ID (x-opencode-session,
x-claude-code-session-id, session_id, or conversation_id from the inbound request), or a
per-process ID when the agent sent none.
Destination rule. base_url must be absolute https, or http only when the host is a loopback
literal (127.0.0.1, ::1, localhost). No user info, query, or fragment. The upstream client
follows no redirects, sends no cookies, and ignores system proxies, so a key only ever reaches the
host it was saved for.
Route selection
RouteResolver is a pure function of one configuration snapshot (upstreams, routes, default) and the
requested model string. It returns an ordered list of concrete targets {upstream, nativeModel} plus
route_kind/route_name, or a fixed error. Precedence:
- Combo or alias by name.
combo/<name>or a bare<name>equal to an enabled route's name. - Explicit
provider/model. The part before the first/names an enabled upstream slug; the rest is the native model ID verbatim (soopenrouter/anthropic/claude-sonnet-4.5works). - The agent's own Anthropic model. On the Anthropic wire, when the client sent its own credential
(see Authentication), a bare
claude-…ID goes to Anthropic itself with that credential — route kindnative, upstreamanthropic. This comes before the declared-model rule, so a provider that happens to listclaude-opus-5does not take over Claude Code's own Opus; only a route the owner named that way (rule 1) does. - Declared model list. A bare model ID declared by exactly one enabled upstream goes there. If
two or more declare it, the request fails with
ambiguous_modelrather than picking one. - Default route. Otherwise the default route, when set, resolved by rules 1–2. A request that
carries no model at all also lands here; without a default it is
missing_model(400). - Otherwise
unknown_model(404).
Disabled upstreams are skipped inside a combo, and a combo with no enabled target fails with
no_enabled_target.
Smart switching
The Routing page's Smart switching card stores one strategy in router_settings.switch_strategy: off, ordered,
sticky, or round_robin. When enabled, whatever the rules above resolved to is expanded with the
same model at every other enabled provider that lists it, so a usage limit, an outage, or a refused
key can hand the request to the same model somewhere else with no route to set up:
- Same model means the IDs match ignoring case and any vendor path:
deepseek-v4-flashat DeepSeek,deepseek/deepseek-v4-flashat OpenRouter, andDeepSeek-V4-Flashelsewhere are one model. Nothing else is treated as equivalent; a different version is a different model. - Order of the added targets, cheapest kind first (
RouteResolver.CostTier): a subscription through another tool's sign-in (each ChatGPT account, Muse Code), then OpenCode Go's flat plan, then a server on this computer, then anything billed per token, which is always last. Within a kind, the order the providers were added. - A bare model name several providers list is no longer
ambiguous_model: it goes to all of them in that order, starting with the cheapest. - Routes still apply. A nickname or fallback chain resolves as before; switching appends the same-model fallbacks of every target after the chain's own.
- Strategies. Ordered begins from the configured first routed target every request. Sticky keeps the last routed target that completed successfully until it fails. Round robin rotates the routed starting target per model/route group, then retains ordinary failover order for that request.
- Claude Code is one-way. A credential-bearing native
claude-*request starts at Anthropic, then tries the equivalent Claude model at configured providers, then the optional cross-modelclaude_fallback_route. Native Anthropic is never appended to another request or exposed to another harness. Sticky mode remains on a routed fallback after native Claude is exhausted; the other modes retry native Claude after its bounded cooldown. Without a configured fallback target, Anthropic's own errors still pass through unchanged.
The Routing page lists every model more than one provider serves, with the base order it can use.
Every Codex account is a ChatGPT-plan provider
Codex accounts are one list, the one Insights and Live quota show. Once the owner has added the ChatGPT
plan at all, the broker keeps one ChatGPT-plan provider per signed-in Codex account
(RouterConfigService.SyncCodexAccountsAsync, run when the router page loads and after a plan is
added): a missing account gets a provider (chatgpt-second for ~/.codex-second, same models as the
first), and a provider that duplicates another's account is pointed at one nothing uses yet, keeping its
slug so routes and agent pickers that name it keep working. A signed-out account is left out. The page
therefore never asks which Codex account to use; with smart routing on, a GPT model moves from one plan
to the next when the first runs out.
Protocol translation
Translation goes through one internal representation, so three decoders and three encoders give every pair:
RouterRequest—model,systemtext, orderedmessages(roleuser | assistant | tool, parts: text, image (URL or base64 with media type), tool call{id, name, argumentsJson}, tool result{callId, text, isError}),tools({name, description, parametersSchema}),toolChoice(auto | none | required | {name}),maxOutputTokens,temperature,topP,stop,stream,reasoningEffort(optional hint), andparallelToolCalls.RouterStreamEvent—TextDelta,ReasoningDelta,ToolCallStart{index,id,name},ToolCallArgumentsDelta{index,json},ToolCallEnd{index},Usage{input,cachedInput,output,reasoning},Finish{reason: stop | length | tool_calls | content_filter | error}.
Per wire: a request decoder (inbound JSON → RouterRequest), a request encoder (RouterRequest →
upstream JSON), a stream parser (upstream SSE → events), a stream writer (events → inbound SSE), and a
non-streaming aggregator/writer pair.
Passthrough. When the inbound wire equals the upstream wire, the body is forwarded as-is except
that model is replaced by the native model ID. Streamed bytes are relayed unchanged while a tap
parses usage for the ledger. This keeps fields the IR does not model (Responses reasoning items with
encrypted_content, Anthropic thinking signatures, cache control) intact on a same-wire hop.
What translation drops or maps, deliberately:
- Responses input items:
message(rolesuser,assistant,system,developer— the last two joinsystem), contentinput_text/output_text/input_image,function_call,function_call_output,custom_tool_callandcustom_tool_call_output(mapped to a function tool call whose arguments are{"input": "<text>"}), andreasoning(dropped).instructionsbecomessystem. Tools of typefunctionmap directly; typecustom(Codexapply_patchfreeform) maps to a function with one required string parameterinput, and the response maps back to acustom_tool_callitem with that string asinput. Built-in tools (web_search,local_shell,image_generation, …) are dropped when translating, and noted in the ledger astools_dropped.storeis forced irrelevant (no state is kept);previous_response_idon a translated hop fails withunsupported_previous_response_id. - Anthropic
system(string or text blocks), mid-conversationsystem-role messages (Claude Code's environment block; joined tosystem, as the other decoders do with system and developer turns),text,image(base64 or URL),tool_use,tool_result(text or text blocks;is_error),thinking/redacted_thinking(dropped), any other block type — server tools, documents — (dropped, noted asblocks_dropped),tools[].input_schema,tool_choice(auto,any→ required,tool→ name,none),max_tokens,stop_sequences,temperature,top_p. - Chat Completions
messages(system/developer,userstring or parts,assistantwithtool_calls,tool),tools,tool_choice,max_tokens/max_completion_tokens,stop,stream_options.include_usage(always requested upstream so usage reaches the ledger),reasoning_contentdeltas (asReasoningDelta, emitted to the client only where its wire has a place for it). - Anthropic upstreams require
max_tokens; when the inbound request has none, the encoder uses 8192.
Stream framing written back to the client:
- Responses:
response.created,response.in_progress, then per output itemresponse.output_item.added,response.content_part.added,response.output_text.delta…,response.output_text.done,response.content_part.done,response.output_item.done; a function call streamsresponse.function_call_arguments.delta/.done; a custom tool call emits itscustom_tool_callitem onoutput_item.done; finallyresponse.completed(orresponse.incompleteforlength) withusage{input_tokens, input_tokens_details.cached_tokens, output_tokens, output_tokens_details.reasoning_tokens, total_tokens}. Each event carriessequence_number. IDs areresp_…,msg_…,fc_…, generated per request. - Anthropic:
message_start(withusage.input_tokenswhen known, else 0),content_block_start,content_block_delta(text_delta/input_json_delta),content_block_stop,message_delta(stop_reason:end_turn | max_tokens | tool_use | stop_sequence,usage.output_tokens),message_stop.pingis not required. - Chat Completions:
chat.completion.chunkobjects withdelta.content,delta.tool_calls,finish_reason, a final usage chunk when the client asked for it, thendata: [DONE].
Replayed conversation identifiers
A stateless Responses client replays its whole conversation in input on every turn, and each
replayed item carries the identifier assigned by whichever upstream produced it. Those identifiers
are scoped to one upstream — and, for a subscription backend, to one account and one rollout.
Routing is what makes that a hazard. A combo, a fallback, a smart switch, or simply choosing a different model in the agent's own menu sends the next turn elsewhere, and the replayed identifiers go with it.
One shape of this fails every time rather than occasionally. The Responses contract accepts only
letters, digits, underscores and dashes in an item identifier, but a ChatGPT-plan backend issues
reasoning identifiers of the form rs_<response>:rs_<item>. Once one of those is in an agent's
history, every later turn carries it, so the conversation fails from that point on — and keeps
failing after a model change, because the history travels with it.
On a Responses hop the router therefore strips an identifier the contract cannot accept, and
drops the whole item when it is a reasoning item, whose payload is bound to a response no other
upstream can resolve. An acceptable identifier is never touched, so an upstream that can still match
an item to its own earlier response continues to. A non-zero count is written to the broker log:
Router upstream 'chatgpt': dropped 2 replayed conversation item(s) carrying an identifier
the Responses API rejects. They were minted by a different upstream or rollout and cannot
be matched here.
Nothing is removed that would otherwise have worked — a request carrying such an identifier is rejected outright — and no other wire is affected, because a translated hop rebuilds the request from its decoded form and carries no foreign identifiers to begin with.
Failover
A combo's targets are tried in order for one logical request. A target is skipped while it is cooling
down. An attempt fails over to the next target only when no byte has been written to the client
and the failure is one of: connection error, timeout before response headers, HTTP 400 (the
provider rejected the request as it was rendered for it), 401/403 (the provider refuses its key
or sign-in), 402 (that target's account is out of credit), 404 (a model the provider lists but
does not serve), 408, 429, 500, 502, 503, 504, 529. Each of those is about the target,
not the request, so another target can still serve it.
400 is in that list because every target is sent its own rendering of the request: a different
wire, its own translation, and its own rules about identifiers, role order, and which fields it will
accept. One provider refusing what it was sent therefore says nothing about whether the next would
refuse what it will be sent — so stopping there handed the agent an error the chain still had
targets left to avoid. Unlike the others, a 400 does not cool the target down: it is about this one
request, and parking a healthy provider would divert every unrelated request away from it too.
Failing to render the request for a target — the translation cannot express it on that wire — ends
that attempt the same way, so a target speaking a wire that can carry it still gets its turn. A
request the router cannot read at all is rejected before any target is tried. Any other 4xx is the
request's fault: it is returned to the client
without trying another target and without cooling the target down. Once the first byte reaches the
client the attempt is committed; a later upstream failure ends the stream with the wire's error event
(response.failed, Anthropic error event, or a Chat error chunk) and is recorded, never retried.
Cooldown is in memory, per upstream slug + model: Retry-After (seconds or HTTP date, capped at
10 minutes) when present, otherwise 30 seconds after a 429/529 and 15 seconds after a connection
error or 5xx, 60 seconds after a 401, 403, or 404, and 5 minutes after a 402. When every target has failed, the client receives the last
failure. When every target is already cooling down, the request is answered at once without asking
any of them: 429 rate_limited if the soonest cooldown to end began with a 429/529, otherwise
503 all_targets_unavailable, with retry-after set to the seconds until it ends, so the agent waits
that long instead of retrying into the same wall. A native Anthropic request is never cooled down:
Anthropic's own answer, retry-after included, goes back to the agent.
Timeouts: 30 seconds to connect, 300 seconds to the response headers, and 300 seconds of silence
between streamed chunks. A client disconnect cancels the upstream request and records canceled.
Putting routed models in the agent's own picker
Typing a selector works, but the point is to pick a routed model from the menu the agent already has.
Each host exposes that differently, so AgentNotify writes each host's own mechanism. Connecting is a
button on the Router → Agents page, or agentnotify router connect <agent>.
Every account, not only the default one. The page lists each Codex and Claude Code account from
the list Live quota monitors (QuotaAccountDefinition.Monitored): the built-in ~/.codex and
~/.claude (IDs codex and claude_code, always present), discovered profiles such as
~/.codex-second (ID codex:home:second), and accounts added by hand (q_…), minus WSL profiles.
Each is connected in its own directory, and a Codex account other than the built-in one gets its own
generated catalogue (codex-model-catalog-<id>.json) because its shell-tool choice is its own. The CLI
takes the same IDs: agentnotify router connect codex:home:second.
Codex reads a model catalogue from a file named by its model_catalog_json setting. AgentNotify
generates that file — one entry per provider/model, per alias, and per combo/<name> — and adds a
[model_providers.agentnotify] block pointing at /router/v1 with the router key in
experimental_bearer_token, so Codex authenticates with no environment variable set. Every routed
model then appears in /model. For a model on a ChatGPT-plan upstream, the catalogue entry is Codex's own, copied from its
models_cache.json with only the slug, display name, and priority changed, so that model keeps Codex's
prompt, context window, reasoning levels, and shell tool exactly as without the router. Codex's picker
shows only the catalogue: it has no mode that adds to its own list, which is why the ChatGPT-plan
provider is how GPT models stay in it.
Codex also resolves several settings per model, and the same page
writes them: reasoning effort, the subagent model and its effort (default_subagent_model), the
review model, and which shell tool a routed model is offered.
That last one matters. Codex's shell_type chooses the tool the model must call; AgentNotify defaults
to shell_command, one ordinary function call that third-party models handle far more reliably than
the stateful unified_exec session tool. Codex's local shell type is deliberately not offered: it
is a built-in tool type rather than a function, and translation to another wire drops it, which would
leave the model unable to run anything.
Codex's catalogue entries must also carry instructions. Its own models get them from its backend, and a routed model has no such entry, so AgentNotify supplies its own short, plain preamble rather than copying anyone else's prompt.
Claude Code has both a curated picker and per-entry environment variables, so both are written.
Its modelPicker setting gains a row per routed selector, each declaring the known model it
behavesAs — without that Claude Code cannot tell a routed model's context window or capabilities and
says so on every start. Optionally those rows replace Anthropic's own lineup instead of following it; that switch is off unless
the owner turns it on.
Separately, ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS (the router key, as
x-agentnotify-router-key: <key>, after any header lines the owner already sends), and the model each
built-in entry resolves (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, …SONNET…, …HAIKU…, and
the background ANTHROPIC_SMALL_FAST_MODEL) are set in the env block of settings.json.
ANTHROPIC_AUTH_TOKEN is deliberately not set.
Claude Code's own models
Claude Code has one base URL for every model, so once it points at the router its built-in Opus,
Sonnet, and Haiku arrive there too. They must not run on some other provider under Claude's name, and
they should keep using the owner's Claude sign-in. So the router key travels in its own header, which
leaves Claude Code sending its own sign-in exactly as it would to Anthropic (Authorization: Bearer sk-ant-oat… for a Claude plan, x-api-key for an API key). A claude-… model that no route claims
is then sent to https://api.anthropic.com/v1/messages (or /messages/count_tokens) with that
credential, the body byte for byte, the inbound query string (?beta=true), and the client's own
anthropic-*, x-stainless-*, user-agent, x-app, and x-claude-code-session-id headers. The
credential goes to Anthropic and nowhere else: it is never sent to another upstream, stored, logged,
or put in the ledger. A client that sends the router key as its bearer has no credential of its own to
forward, so its claude-… models are ordinary unrouted names (default route or unknown_model).
Running sessions. Claude Code applies an env variable added to settings.json to a running
session at once, but keeps one that was removed until it restarts (verified with Claude Code 2.1.276).
Connecting therefore takes effect in open sessions, and disconnecting does not: they keep sending to
the router until restarted. Because their built-in models pass through to Anthropic, they keep
working; routed models keep working too while the router is on.
What writing those files is held to
- A copy first. Every change copies the file into AgentNotify's own directory first. The Agents page lists those copies with the reason each was taken, and restores any of them; a restore copies the current file too, so it can be stepped back.
- Only AgentNotify's own lines. Codex's
config.tomlis edited between marker comments, in two regions because TOML is positional — bare keys must precede the first table. Nothing else in the file is reordered or reformatted. A key the owner already set that the managed region is about to set is commented out rather than left in place, because TOML rejects a key assigned twice and Codex would refuse to start. Claude Code'ssettings.jsonis merged as JSON, touching only the keys listed above. - Their own settings come back. Disconnecting restores the values the file held before, not merely the absence of AgentNotify's lines, and un-comments what was commented out.
- Nothing for an agent that is not installed. No directory is created to make a host appear connected.
- The catalogue follows the router. Changing upstreams, routes, or the key rewrites a connected agent's generated catalogue and embedded key, and drops a subagent or review model that no longer resolves — a picker offering models the router refuses is worse than no picker.
Effort mapping
Claude Code sends its selected five-step effort as output_config.effort (low, medium, high,
xhigh, max); Codex sends its own low/medium/high as reasoning.effort or
reasoning_effort. Those five names mean the same thing on both scales, so the router normalizes
either wire onto them (RouterRequest.reasoningEffort) and maps once, after a route resolves to a
concrete target. Responses targets receive reasoning.effort; Chat targets receive
reasoning_effort. Native Anthropic requests remain byte-for-byte passthrough. Anthropic-compatible
aggregator hops keep every native field but replace or omit only output_config.effort when their
concrete model's mapping requires it. A same-wire hop whose mapped effort equals what the client
sent is not rewritten at all.
A mapping never silently changes a level the target can spell: the automatic tables are identity
wherever the target has the same name, and only levels above the target's top collapse onto it
(Claude's max becomes a four-level family's xhigh). A value outside the five names — OpenAI's
minimal, or a provider-specific word — is sent verbatim when the target supports it, and otherwise
falls back to the target's default (minimal counts as low). An unknown model omits effort
entirely.
One table per family. The Effort mapping page groups every routed model by family (DeepSeek,
GLM, Kimi, Qwen, MiniMax, Grok, Muse, OpenAI, Claude, or other) — a hundred models are ten cards.
Aggregators such as OpenRouter and OpenCode are classified from the model ID, not the aggregator
name. Each family card holds the exact accepted values, the target value for each of the five
levels, and an optional default used when the request carries no effort; the Per model tab
overrides any single provider/model the same way. Precedence: per-model override, family override,
automatic. Every map is bounded, monotonic, and may explicitly choose omit. Overrides live in
router_effort_mappings and router_effort_family_overrides; routes and combos need no copies
because the final target owns the capability.
Web interface and CLI
The Model router group in the navigation holds five pages:
| Page | What it does |
|---|---|
| Providers | The on/off switch; your providers, each with an on/off switch; and a gallery of presets grouped as subscriptions, pay per token, and this computer. Adding one is: pick it, paste a key (or reuse OpenCode's, or a subscription sign-in), tick models from the fetched list, save. Slug, wire, and base URL sit under Advanced. The base URLs and key regeneration are under a disclosure. |
| Routing | Optional: nicknames (an alias, one model) and fallback chains (a combo, tried in order), each picked from the providers' models; what an unknown model falls back to; and Smart switching — ordered, sticky, or round-robin across providers that serve the same model, plus Claude Code's cross-model fallback. The page says plainly that no route is needed to use a model. |
| Agents | Connect an agent so its own picker lists these models, choose its subagent/review/effort settings, disconnect, and restore a saved copy of its configuration |
| Activity | The request ledger with per-attempt detail, and totals by model |
| Effort mapping | One effort table per model family (Families tab), with per-model overrides under Per model; applies to Claude Code's and Codex's efforts alike |
The Agents page also shows copyable snippets for configuring a host by hand, for anyone who would rather AgentNotify did not touch their files.
Codex (~/.codex/config.toml):
model_provider = "agentnotify"
model = "combo/coding"
[model_providers.agentnotify]
name = "AgentNotify router"
base_url = "http://127.0.0.1:47821/router/v1"
env_key = "AGENTNOTIFY_ROUTER_KEY"
wire_api = "responses"
Claude Code:
export ANTHROPIC_BASE_URL=http://127.0.0.1:47821/router
export ANTHROPIC_CUSTOM_HEADERS="x-agentnotify-router-key: $(agentnotify router key)"
export ANTHROPIC_MODEL=combo/coding
agentnotify router status prints whether the router is on and its base URLs, and
agentnotify router key prints the router key from the local config (like agentnotify token); both
read the file directly. agentnotify router agents lists the agents and every selector they can be
pointed at, while agentnotify router connect <agent> [--model <selector>] and
agentnotify router disconnect <agent> ask the running broker to write or undo those files, since it
owns the key and the generated catalogue.
The web API under /ui/api/router follows the existing web-interface rules: loopback host check,
X-AgentNotify-UI: 1 on every change, and write-only secrets.
Relationship to Usage and Live quota
The router ledger is proxy-observed usage. It is kept separate from the log-derived Usage view and from Live quota, and the two are never added together: a request that went through the router is also written to the agent's own log. The Router page shows its own totals with that caveat.
Deliberate limits
- A route that is the default route cannot be deleted until the default is changed, and an upstream cannot be deleted while a route or the default still names it.
count_tokensis only forwarded when the resolved target is an Anthropic-wire upstream; there is no local tokenizer, and guessing a count would be worse than saying it is unavailable.- A canceled request is recorded with no status, since nothing was returned to the client.
Not implemented yet
- Policy routing (
policy/<id>) scored on quota, health, cost, and latency evidence. - Weighted, weighted-random, or least-used combo strategies; ordered, sticky, and round-robin smart switching exist.
- Pinning a Codex account pool; Gemini and Ollama-native wires.
- Connectors for the other hosts (OpenCode, Kilo, Cursor, Gemini CLI); only Codex and Claude Code have one, and each needs that host's own model-list mechanism rather than a generic file edit.
- Per-model context windows in the generated catalogue: every entry declares 200k, because the router does not yet know each upstream model's real window.
- Cost estimates on ledger rows and correlation with log-derived Usage records.
- Quota and spend thresholds raised as attention requests.