- Use
$repo-maintainerfor incoming-change adoption decisions and consequential maintenance that can cross modules, clients, operating systems, installers, providers, credentials, protocols, generated artifacts, or release surfaces. - Use its impact analysis and risk-proportional verification before calling such work complete. Skip it for factual replies and obviously isolated trivial edits.
These instructions apply when a user asks an agent to install this repository.
codex(the Codex CLI and desktop app),dsh(DeepSeek Harness),gemini(Gemini CLI),cursor(Cursor Agent plus Cursor App), andclaude(Claude Code through the router-owned launcher), andopenclaw(OpenClaw through a router-owned Responses provider) are supported targets. OpenCode is not aMODEL_ROUTER_TARGET: it is both a provider and one of the five published-into clients described under "Five clients, one publisher, one key each" below, whichcontrol client-setupwrites a custom provider into rather than installing a target for. A published-into client never gets its own service, state directory, or credential store.- Cursor is asymmetric: Cursor Agent uses the router's authenticated loopback Connect adapter, while retail Cursor App sends BYOK traffic through Cursor's servers and therefore needs an explicit stable public HTTPS tunnel to the router's separately keyed, app-only edge on port 4214. Never expose the main caller capability or the router port itself to the public Internet.
- A target is a client, not a router. One installation serves all of them:
one background service, one gateway, one set of provider credentials, one
provider selection, one set of ports.
MODEL_ROUTER_TARGETselects which client's configuration a command writes. It must never fork the state directory, the service, or the credential store — a user who installs two would otherwise be asked for every API key twice and would run two gateways against one set of provider quotas.ROUTER_PLANE_TARGETinsrc/paths.mjsnames that shared plane, and the environment aliases and the/healthservice name are keyed on it rather than on the client. - Installing more than one is normal and needs no special handling: run the install once per target. Whichever ones are already present are republished whenever the routable set changes, so the clients cannot drift apart.
Install Codex Router for the current user, preserve every unrelated Codex setting and ChatGPT authentication artifact, expose only the external providers the user wants, verify the integration, and leave the final Codex restart to the user.
- Read the host platform and check for Codex, Git, Node.js 22.19+, and
uvor Python 3.10+. On Windows, also verify that Windows PowerShell reportsFullLanguageand permitsAdd-Type; the process-tree safety boundary fails closed before mutation under Constrained Language, AppLocker, or WDAC. Read-only checks are allowed. Do not install a package manager or system runtime without the user's permission. - Use a stable checkout:
~/.local/share/codex-routeron macOS/Linux, or%LOCALAPPDATA%\codex-routeron Windows. Do not install the service from a temporary clone. - Never ask the user to paste OAuth tokens or API keys into chat, command arguments, logs, environment snippets, or tracked files.
- Determine which provider IDs the user requested:
anthropic-api,kimi-oauth,antigravity-oauth,kimi-api,kimi-api-cn,deepseek,grok-oauth,grok-api,qwen-plan,zai-coding,ollama-cloud,minimax-token-plan,meta,clinepass,venice,nousresearch, and/oropencode-go(shown to users as "opencode Go/Zen"; itsopencode-go-messages,opencode-go-responses,opencode-zen,opencode-zen-messages, andopencode-zen-responsesvariants share its stored key and are enabled and disabled with it automatically; never select or toggle them separately. Zen ships no preselected models — curate them per user withbin/curate-models opencode-zen; Claude lands on Messages and GPT/Grok/Muse on Responses), and/orcommandcode(shown to users as "Command Code"; itscommandcode-messagesvariant shares its stored key and is enabled and disabled with it automatically; never select or toggle it separately. Command Code uses its stored or environment API key; it has no router-managed CLI sign-in path. The catalog-only providersgroq,together,fireworks,cerebras,mistral,nvidia-nim,siliconflow,huggingface,gemini-api,github-copilot,chutes,orca, andvertexare also selectable, but they ship no preselected models: after the credential is stored, the user must runbin/curate-models PROVIDERin an interactive terminal to choose models. Vertex uses Application Default Credentials fromgcloud auth application-default loginplus./bin/control vertex set PROJECT_ID LOCATIONrather than an API key, and it is never selected bydefaultProviderIds(). If they did not specify and credentials already exist, useconfiguredrather than showing providers that cannot authenticate.openrouter,venice, andnousresearchalso ship live-reviewed checked-in presets, so their picker is not empty after the key is stored; anything else on their current account catalogs still has to be curated.veniceandnousresearchare ordinary API-key providers — Venice keys come from venice.ai/settings/api and Nous Portal keys from portal.nousresearch.com; neither has a router-managed CLI sign-in path, and Venice carries aplanNotebecause a free Venice account has no API entitlement at all. The anonymous providersopencode-freeandkilo-freeare also selectable (opencode-free-responsesis an internal, single-model protocol variant of the former and is never selected or curated separately), but they need no credential only for their documented free model subsets.kilo-freeandopencode-freeare catalog-only and needbin/curate-models PROVIDERafter selection.customis selectable on the same terms and is a container whose models each name their own endpoint, so enabling it asks for nothing and curating it is unnecessary. All three must be selected explicitly; never select one on the user's behalf just because it can authenticate without a key.kimi-apiandkimi-api-cnare two different Moonshot platforms, not a fallback pair: the global console at platform.moonshot.ai and the mainland one at platform.moonshot.cn have separate accounts, separate billing, and keys that each host rejects from the other. Ask which platform the user's key came from rather than defaulting, and never copy a stored key between the two. - For Kimi OAuth, reuse a valid
kimi loginsession. If login is needed, run the official CLI only in an interactive terminal. For API providers, invokebin/model-router codex provider-key PROVIDER setin a PTY so the hidden prompt receives the value directly; do not relay it through chat. GitHub Copilot requires a fine-grained PAT with the Copilot Requests permission; never read or copy the official Copilot CLI credential store. Command Command Code is API-key-only: invokebin/model-router codex provider-key commandcode setin a PTY so the hidden prompt receives the value directly. For Antigravity OAuth, never read or reuse the officialagy/IDE credential store and never use the vendor's OAuth client or identity. Require one coherent operator-owned Google OAuth client ID and secret pair: the operator must create a Google OAuth Desktop app client they own. The Google Cloud project behind that OAuth client must be allowlisted forcloudcode-pa.googleapis.com, a private Google API. Most projects are not allowlisted, and operators cannot enable this API themselves: it requires the producer-sideservicemanagement.services.bindpermission. Runbin/model-router codex providers login antigravity-oauth; the router first binds127.0.0.1on an OS-assigned port, then opens a loopback-only setup page where the client ID and matching secret are submitted without entering shell history. Open only the local URL through the OS browser command and redirect to Google inside that listener, so neither client value reaches argv or terminal logs. The coherent pair and resulting tokens are stored together in the router's owner-only credential file and used for refresh on macOS, Linux, and Windows; they never belong in a service environment. Sign-in does not call the private Antigravity service or enable the route. Run the explicitly quota-consumingproviders probe antigravity-oauth --live --yesnext; add--provision-projectonly when the operator separately authorizes creation of a Google Cloud project. The live probe will fail withSERVICE_DISABLEDif the OAuth client's project is not allowlisted. Provisioning still requires a successful, schema-valid bootstrap response that explicitly advertises the selected tier; auth errors, server errors, malformed responses, and missing tiers fail closed. The probe identifies itself truthfully as Codex Router, and only a successful proof makes the provider enableable. The Antigravity forwarder is not spawned or health-gated before that proof, so an unused provider port cannot fail the whole router. A passing probe records a generation-boundpending_activationthat every configured and publication reader rejects. Startup alone may boot its exact pending proof; only after the whole local stack is healthy does it atomically promote that generation active. A failed restart, an early process death, a credential replacement, or a disconnect leaves it nonpublishable. The probe restarts an installed service through that sequence, and a foreground operator must restart that process before enabling the provider. A v2 proof record with no activation metadata is not grandfathered: it was written by the unsafe pre-readiness path and must pass the explicit live probe again. If Google accepts only an impersonated vendor client, leave it disabled. If the project is not allowlisted, this provider cannot currently be used. A key does not mean every account may use the Provider API: the Go plan is refused with "Your Go plan doesn't include API access". GOAT, Pro, Max, Team, and Provider plans do have API access and meter against their own credits. Say so rather than re-running setup, which cannot change an entitlement. Never ask for the key in chat or place it in command arguments, logs, environment snippets, or tracked files. - Run read-only legacy detection. It is safe to pass
--migrate-knownwhen the detector identifies a repository-recognized older Codex Router: migration is scoped, snapshotted, and reversible. Never migrate, stop, delete, or replace an unknown router automatically. - On macOS/Linux, run
./install.sh --target codex --auto --providers IDS --migrate-knownfrom the stable checkout. On Windows, run./install.ps1 -Target codex -Auto -Providers IDS -MigrateKnown. Omit the migration flag when detection found nothing. Do not enable the smoke test unless the user agrees to a quota-consuming request. - Run
bin/model-router codex doctor(or./model-router.ps1 codex doctoron Windows). Core config, config privacy, catalog, caller capability, internal key, service, router health, and selected credentials must beOK. Unselected credentials may beWARN. - If a managed layer fails, use
model-router codex doctor --fix; add--migrate-knownonly for a recognized older installation. Repair rebuilds the Node and Python dependencies unconditionally, unlike a normal install or update, which skips whichever dependency step already matches its fingerprint. Force that rebuild by hand withbin/install --force-deps(./install.ps1 -CheckoutInstall -ForceDeps) when an environment looks corrupted rather than merely out of date. If repair still fails, createbin/support-bundleand report its path without uploading it. - Do not terminate Codex. Tell the user to fully quit it, reopen it, create a new task, and choose the new model.
Publish the router's routed models into DeepSeek Harness as one provider route, preserve every other section, route, comment, and credential in the harness's own documents, and leave the harness running — it hot-reloads, so there is nothing to restart and nothing to tell the user to quit.
- Steps 1-6 of the Codex procedure apply unchanged, except that Codex itself is
not a prerequisite: a harness-only machine needs Node 22.19+,
uvor Python 3.10+, and the harness. Do not runsrc/catalog.mjsthere — it asks the Codex CLI whether the session is signed in and refuses to publish when it cannot ask, which is a failure, not a fallback. - Run
./install.sh --target dsh --auto --providers IDS(macOS/Linux) or./install.ps1 -Target dsh -Auto -Providers IDS(Windows).--migrate-knownand--adopt-native-catalogare refused here: both act on Codex's own configuration, and the harness has no counterpart to either. - Run
bin/model-router dsh doctor. "Harness routing config", "Harness caller credential", "Harness settings privacy", and "Harness catalog freshness" must beOK, alongside the shared-plane checks. - Do not tell the user to restart the harness.
dsh-settings-filewatches the document and publishes external edits, so the route is live on the next request. Saying otherwise trains people to restart for nothing.
Publish the router's routed models into Gemini CLI by writing one marker block
in the environment file it already reads, preserve every other line in that
file, never open its settings.json for writing, and leave the user's next
gemini run to pick the change up.
- Steps 1-6 of the Codex procedure apply unchanged, except that Codex itself is
not a prerequisite: a Gemini-only machine needs Node 22.19+,
uvor Python 3.10+, and thegeminiCLI. Do not runsrc/catalog.mjsthere, for the same reason the harness does not. - Run
./install.sh --target gemini --auto --providers IDS(macOS/Linux) or./install.ps1 -Target gemini -Auto -Providers IDS(Windows).--migrate-knownand--adopt-native-catalogare refused here: both act on Codex's own configuration. - Run
bin/model-router gemini doctor. "Gemini routing config", "Gemini environment conflicts", "Gemini environment privacy", and "Gemini default model" must beOK, alongside the shared-plane checks. - Do not tell the user to quit anything. Gemini CLI reads its environment once,
at process start, so the next
geminiinvocation has the new values. Tell them instead to choose "Use Gemini API key" if the CLI asks how to authenticate — that is a one-time choice the CLI saves for itself, and the key it will use is this router's local caller capability, not a Google one.
Publish every selected, credentialed routed model to Cursor Agent and Cursor App without exposing the main router capability, preserve unrelated Cursor settings, and leave Cursor stopped so the user can reopen it cleanly.
- Steps 1-6 of the Codex procedure apply, except that Cursor App and/or the
official
cursor-agentbinary replace Codex as the client prerequisite. - Require an explicit stable public HTTPS origin for Cursor App. It must be a
user-owned named tunnel (or equivalent) forwarding only to
127.0.0.1:4214; never accept a temporary quick-tunnel URL and never point it at the main router port. Do not create DNS, tunnel, or cloud credentials without the user's authority. - Fully quit Cursor before writing its settings database. Run
./install.sh --target cursor --auto --providers IDS --cursor-public-url https://HOSTon macOS/Linux or./install.ps1 -Target cursor -Auto -Providers IDS -CursorPublicUrl https://HOSTon Windows. The manager refuses a live Cursor process because the app can overwrite external SQLite changes on exit. - Run
bin/model-router cursor doctor. The Cursor app routing config, Agent launcher, catalog freshness, caller capability, separate public-edge key, service, router health, and selected credentials must beOK. - Tell the user to run
cursor-router-agentfor Cursor Agent and to reopen Cursor App and choose acodex_router/readable_name__digest/effortmodel. The neutralized name avoids Cursor's built-in-substring BYOK rejection, and the suffix is how the user changes effort because Cursor gives ordinary user-added models no native parameter controls. The app override is global, so they should turn it off before returning to Cursor-managed models. - Cursor Agent text turns and its local read, shell, edit, and write loop are supported. The adapter sends typed controlled-exec messages back to the official client, which performs the operation under Cursor's own permission mode, then returns the typed result before the model resumes. Never execute those operations inside the router or bypass Cursor's permissions. Cursor MCP tools use a separate exec shape and stay unadvertised until it has the same official-client proof.
Publish every selected, credentialed routed model to Claude Code through a
router-owned claude-router launcher, preserve Claude's settings and login,
and keep every turn on the shared canonical Responses path.
- Require the official
claudeCLI. Never edit~/.claude/settings.json. - Run
./install.sh --target claude --auto --providers IDSon macOS/Linux or./install.ps1 -Target claude -Auto -Providers IDSon Windows. - The launcher supplies a secret-bearing loopback
ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN, and gateway model discovery only to its child process. It must not persist those values into Claude-owned files. It also pins Claude Code's agent and default-tier model names (CLAUDE_CODE_SUBAGENT_MODEL,ANTHROPIC_SMALL_FAST_MODEL, andANTHROPIC_DEFAULT_OPUS_MODEL/_SONNET_MODEL/_HAIKU_MODEL) to the same routed model the session runs: built-in agents and agents whose frontmatter pinsmodel: opusresolve through the tier aliases rather than throughCLAUDE_CODE_SUBAGENT_MODEL, and an unpinned alias falls back to a literal Anthropic id the router does not serve, which 404s every spawned agent of that type. A caller value that already names a routed id is preserved. - Model discovery publishes every routed slug as
codex_router/anthropic/ROUTER_SLUG. Theanthropicsegment is required: Claude Code filters gateway-discovered ids that do not containclaudeoranthropic. - The Anthropic Messages surface translates and re-enters
/v1/responses; it never reaches a provider directly. Tool use/results, images, token counting, SSE pings, and the model list are part of the compatibility boundary. - Run
bin/model-router claude doctor. Routing config, launcher, catalog freshness, caller capability, service, router health, and credentials must beOK. Then tell the user to runclaude-routerand use/model. - Anthropic officially supports Claude Code gateways for Claude models. Using non-Claude models through this compatibility surface is functional but not an Anthropic-supported product configuration; do not describe it otherwise.
Install OpenClaw when it is missing, publish every selected and credentialed
routed model under one router-owned codex-router provider, preserve all other
OpenClaw configuration, and leave the next agent run to read the new route.
- OpenClaw's current releases require Node 22.22.3+, 24.15+, 25.9+, or 26+;
Node 23 is unsupported. The target installer must refuse an unsupported
runtime before invoking npm. With npm 11.16+ or 12+, install the official
package as
npm install -g openclaw@latest --allow-scripts=openclaw; older npm 11 releases omit the scoped lifecycle flag they do not understand. - Run
./install.sh --target openclaw --auto --providers IDSon macOS/Linux or./install.ps1 -Target openclaw -Auto -Providers IDSon Windows. The Control Center Harness action calls the same install-if-missing and publish sequence. Do not start onboarding, a gateway, or an agent as a setup side effect. - Own exactly
models.providers.codex-router. Write it throughopenclaw config patch --stdin --replace-path models.providers.codex-router; never put the caller capability in argv, logs, or status output. Refuse a pre-existing provider with that id when no router publication marker proves ownership, or when its base URL is not a managed loopback caller URL. - Publish router slugs as
codex-router/SLUGoveropenai-responses, with each model's context window, input modalities, and exact supported reasoning efforts. If no OpenClaw default exists on the first publish, set the highest-priority route and record ownership. Preserve any existing default, stop owning a default the user changes, and remove it on uninstall only while it still equals the router-owned value. - The caller URL and API key are local capabilities and make both the
OpenClaw config and
openclaw-models.jsonprivate state. Caller-key rotation must republish OpenClaw inside the same transaction as other credentialed clients. Status, doctor, errors, and support material must redact the URL. - Run
bin/model-router openclaw doctor. OpenClaw routing config, CLI, config privacy, catalog freshness, caller capability, service, router health, and selected credentials must beOK. - AgentHarnessV2 is OpenClaw's native runtime-plugin boundary, not another
HTTP provider protocol. A normal Responses endpoint belongs in the provider
catalog and uses OpenClaw's embedded runtime when no native plugin claims
it. Do not register a harness plugin or describe this integration as a
native V2 harness. No restart is needed; tell the user to run
openclaw.
Gemini CLI speaks only the Gemini API, so it is the one client that cannot be
pointed at /v1/responses. Everything below exists so that fact costs one
translation layer and nothing else.
- One file, three keys, and no
settings.json. The router ownsGOOGLE_GEMINI_BASE_URL,GEMINI_API_KEY, andGEMINI_MODELinside a# BEGIN codex-router-geminiblock in~/.gemini/.env(GEMINI_HOMEinpaths.mjs, which honours the CLI's ownGEMINI_CLI_HOMEoverride). That is the whole integration:createContentGeneratorin@google/gemini-cli-corebuilds a plain@google/genaiclient from those variables, so nothing else has to be configured. Itssettings.jsonis JSONC carrying the user's own comments and is never opened for writing — aJSON.parse/stringifyround trip there deletes every one of them. Publishing twice is byte-identical, removing the block restores the document exactly, andtest/gemini-env.test.mjsasserts both against a document with somebody else's work around ours. - Refuse rather than guess.
dotenvlets the last assignment of a key in a file win, so aGEMINI_API_KEY=below our block silently decides the credential and nothing in the file says so.conflictingAssignments()finds any managed key assigned outside the block and the publish stops with the line named and the file untouched. Damaged markers — a missing end, a second begin — are refused the same way. Never add a "best effort" path there. - The document is private. It carries the caller key and the managed base
URL, so it is written 0600.
~/.geminiis created 0700 when absent and deliberately not re-moded when present: that directory is Gemini CLI's, andprotectPrivateFileon a directory would strip its execute bit and break the CLI outright. Status output reports the redacted URL, never the whole one. geminiis a leaf of the caller capability, likev1andpanel. The SDK appends/v1beta/models/{model}:{method}to whatever base URL it is given, so the secret has to sit in the path ahead of it. A new leaf must be added toredactCallerUrlat the same time it is added to the router, or the caller key reaches doctor output and support bundles in the clear.- The surface translates and re-enters; it never reaches a provider.
gemini-surface.mjsconverts a Gemini request into a Responses request, sends it through the router's own/v1/responsesover the loopback, and converts the answer back. That is what keeps the harness rule intact in spirit: tool-result ageing, the vision bridge, prompt-token substitution, upstream retry, model failover, and usage accounting all still sit on one request path. Do not give this surface its own upstream. - The loopback carries no credential. The key the CLI presents is this
router's caller capability, and the path it presented it on is already the
proof. Relaying it upstream would put a router secret on a hop that can be
substituted onto a provider — and leaving the header off is also what makes
callerBroughtNoUpstreamCredentialtrue, which is how a client with no ChatGPT session of its own reaches native models after the user explicitly authorizes the shared router plane once. - The default model is written, and that is deliberate. Gemini CLI's own
default is
gemini-2.5-pro, which this router does not route, so an install that left it alone would 404 on the user's first turn.GEMINI_MODELout-rankssettings.json'smodel.nameand is out-ranked by--model;--no-default-modelturns it off. This is the one place the Gemini integration deliberately departs from the harness's opt-in rule, because there the default is a convenience and here it is the difference between a working install and a broken one. embedContentis refused, not faked. The separate OpenAI-compatible/v1/embeddingssurface is model-gated and is not a Gemini translation contract. Gemini CLI callsembedContentonly frombaseLlmClient, never from the turn loop, so a named 501 is the honest answer and a fabricated vector would be the dishonest one.countTokensis estimated. There is no upstream to ask, and spending a real turn to answer a count would bill the user for a question they asked for free. A client that gets no number cannot decide whether to compact, so the same byte-ratio estimateresponse-usage.mjsuses for prompt accounting is the better failure.- The model list is served live, so it cannot drift.
/gemini/v1beta/modelsreads the catalog the router already publishes, which is why this integration has no copy to keep in step. The published default model is a snapshot and can drift;gemini-models.jsonrecords it, doctor compares it against the routable set, and it is the marker that decides whether the integration is installed. - A tool schema arrives as
parametersJsonSchema, notparameters.tools.jsin@google/gemini-cli-corewrites every built-in tool's schema into that field and validates incoming calls against the same one. Reading onlyparameters— the older Schema-proto spelling, which the type also permits — sent all ten tools upstream with no schema at all: the model invented argument names and the CLI rejected each call with "params must have required property 'file_path'". Every test passed while that was true, because a fixture written from the type definition spells it the other way. A declaration with neither field is sent as{type: "object", properties: {}}rather than withparametersomitted, because a chat-completions provider refuses a function whose parameters are absent. - Verify against the real CLI, not against the docs. Google's own
documentation does not describe this configuration; the contract was read
out of the installed
@google/genaiand@google/gemini-cli-corebundles (GOOGLE_AI_API_DEFAULT_VERSION,formatMap('{model}:streamGenerateContent ?alt=sse'),GOOGLE_API_KEY_HEADER,resolveModel's pass-through default) and then proved by drivinggemini -pat the surface. A change to the wire shape needs that same proof, not a plausible reading.
The router owns exactly two keys, in two documents that belong to the harness. Both are hot-reloaded by it, and its own Models page writes provider routes beside ours, so everything else in them is somebody else's work.
- One route, not a section. The router owns
llm-pi-ai.providers.codex-routerin$DSH_HOME/settings.yamlandCODEX_ROUTER_CALLER_KEYin$DSH_HOME/.credentials.yaml. It never reads, rewrites, or removes a sibling route, another adapter's section, or another credential. Publishing twice is byte-identical, and removing the route restores the document exactly — including the user's comments and blank lines.test/dsh-config-manager.test.mjsasserts both properties against a document that has work of somebody else's in every position the router writes near; do not weaken them. The credentials document comes in two shapes: current harness builds wrap the reference map in aversion/refsenvelope, older ones kept it at the document root. Both are written in place and neither is converted into the other, because the shape belongs to the harness build that reads the file.refspresent settles it;versionwithoutrefssettles it the other way, since that is a current harness on its first install — the case where guessing wrong is silent, because the harness resolvesapiKeyEnvunderrefsand a key one level too high 401s with no diagnostic.status()resolves the credential through that same decision, so it can never report one the harness cannot read, and a new reference takes its indentation from a sibling rather than from therefs:key's own column — a mixed-indent block is not YAML any parser reads back, and this file holds every adapter's key. - Refuse rather than guess.
src/yaml-structure.mjsis a fail-closed structural lexer for block-mapping YAML, not a general YAML parser. A document it cannot read plainly — a tab indent, a multi-document stream, a duplicate key, a sequence root, an unterminated flow collection, an anchored key, an inlineprovidersmapping — is refused with the file untouched and the line named. A refusal costs a command; a wrong guess rewrites a file whose only copy is on the user's disk. Never add a "best effort" path there. - Both documents are private. The settings document carries the managed base URL, which is a local caller capability, and the credentials document carries the key it references. Both are written 0600 under a 0700 directory, the same bound the harness itself holds them to, and status output reports the redacted URL exactly as the Codex manager does. Never print the complete managed base URL.
- Routed models are always published; native ones require authorization and
a session. Publish only the selected, credentialed, listed, non-hidden routed
models. An unregistered slug on the router's
/v1/responsesendpoint is treated as native GPT traffic needing a ChatGPT session, which a harness request does not carry — so a native model is advertised only while the user has explicitly authorized this shared local router plane andnativeSessionStatus().usablereports the session this machine is signed in with as spendable.nativeSessionAvailable()is the combined gate. Missing consent, an unreadable consent marker, sign-out, or expiry withholds the model. Publishing one the router cannot authorize offers a turn that 401s, which is the failure this gate exists to prevent; never widen it to presence alone, because an expired session is present. The vision-bridge engine candidates still exclude native models: that call site admits an engine on evidence the caller's session can spend it, and a substituted session is not the caller's. - The protocol is
openai-responses, because that is the only thing the router's caller endpoint serves, and every router capability — tool-result ageing, the vision bridge, prompt-token substitution, upstream retry, usage and throughput accounting — already sits on that routed path. Do not add a second upstream path or a chat-completions surface for the harness; the point of pointing it at the same endpoint Codex uses is that there is one request path to keep correct. The Gemini surface is not an exception to this: it speaks Gemini at the edge because its client can speak nothing else, and then re-enters this same endpoint over the loopback rather than reaching a provider of its own.models[].idis the router slug, never the gateway model id:/v1/responsesresolves it againstMODEL_BY_SLUG, and a gateway id falls through to the native path. - No
compaton the route. pi-ai types its reasoning-dispatch switches only onopenai-completionsand refuses a route-level switch anywhere else. Each model's request profile is applied on the router's own side of the hop, which is where that knowledge belongs. - A reasoning level pi-ai cannot name is dropped, not approximated. Its
level set is
off, minimal, low, medium, high, xhigh, max; the Codex ladder also spellsultra.unmappableEfforts()reports what was dropped so the omission is visible rather than silent. A model with no levels declaresreasoningEfforts: false— omitting the field would inherit whatever pi-ai's installed catalog says about a colliding id. - The default model is the user's. Taking over
agent-default-modelis opt-in (--set-default-model), snapshotted verbatim, and restored on uninstall — the same discipline the Codex login-free mode applies tomodelandmodel_provider. Never write it as a side effect of publishing. - Delegation is composition, not settings.
dsh-tool-subagentinstalls no settings section, so the router cannot configure the harness's subagent model and must not edit a preset it does not own. A child with no model of its own inherits the default model selection, which is already a routed model once the route is the default;./bin/model-router dsh subagent-presetprints the block to paste for a deployment that wants children on a different routed model. Codex'sbin/multi-agentstays Codex-only: it drivesmulti_agent_versionand the Codex agents directory, whose payloads are Codex's own encrypted format. - Drift is this integration's failure mode. The harness hot-reloads its
settings document, so anything else that writes it takes effect at once and
can leave the published route naming models the gateway no longer routes.
dsh-models.jsonin the router's own state directory records what the last publish wrote; doctor compares it against the routable set, and it is the marker that decides whether an integration is installed. Any code path that changes the routable set must republish throughrefreshTargetPickerIfInstalled(), which refreshes every installed client rather than only the active target.
The router's gateway is LiteLLM, so every install executes a large Python dependency tree. That tree is pinned and hashed rather than re-resolved.
requirements/python.txtis the lock: the full transitive closure ofPYTHON_REQUIREMENTSinsrc/install-plan.mjs, every distribution pinned and carrying its SHA256. Both installers install that file with--require-hashes, in both theiruvand theirpipbranch. Pinning only the two top-level packages left everything underneath them floating, which is how one machine's gateway came to differ from another's.- Never edit either
requirements/file by hand, and never add a package to an installer command line. Change the pin insrc/install-plan.mjsand runbin/lock-python, which rewritesrequirements/python.infromPYTHON_REQUIREMENTSand recompiles the lock. Commit both files together. - The lock must stay universal.
bin/lock-pythonpasses--universal --generate-hashes --python-version 3.10, which is what makes one file serve macOS, Linux, and Windows on CPython 3.10+ through environment markers. A lock regenerated without--universallooks fine and installs only on the machine that produced it;test/python-lock.test.mjsfails on that, on an unhashed entry, and on any disagreement withPYTHON_REQUIREMENTS. Do not weaken those tests to land a lock. - Check which wheels a litellm pin actually publishes before moving it.
1.95.0shippedmanylinuxandwin_amd64only, so macOS built it from the sdist withmaturinand a Rust toolchain — slow, and broken outright withoutcargo.1.96.0publishes macOS wheels (arm64 and x86_64) as well, so no supported platform builds from source today. If a macOS install is slow or failing, check forcargoand check the pin's wheel list; do not assume either state. - Hash verification covers the distributions, not the isolated build
environment pip and uv create for an sdist.
maturinis fetched unhashed during that build. Closing that gap needs a separate build-requirements lock; do not claim the current lock covers it. No supported platform builds from source at the current pin, which narrows the exposure but does not remove it — a pin without a wheel for someone's platform brings it back. - A pin can be a security floor, and moving it backwards reintroduces the
advisory it was raised for.
litellm==1.95.0requiredcryptography>=48.0.1,<49.0, so no patched cryptography could be resolved while it was held (GHSA-g6cj-pr64-35w5, fixed in 50.0.0). Dependabot reports the transitive package; the fix is almost always the direct pin above it. - Resolving is not booting. litellm's own metadata allows fastapi versions its
code cannot import (
get_flat_dependant, removed in 0.140), souv pip compilewill happily produce a lock whose gateway dies on startup. Any change to either Python pin has to be proven by starting the proxy and getting a live/health/liveliness, not by a successful resolve. - The lock is proven by installing it, not by reasoning about it.
.github/workflows/python-lock.ymlinstalls it for real on Linux and Windows through both resolvers, then asserts the pinned versions, thelitellm[proxy]extra, and a live/health/liveliness. It gets the command frominstall-plan.mjs python-install-command, which extracts the line frombin/installandinstall.ps1themselves — never write apip installline into CI, because a job that spells its own command can pass while the shipped installer fails. Its negative control must also keep failing: if an unhashed requirement ever installs, every other check in that job is meaningless. Do not add a resolver cache there; a cache hit can serve an already-unpacked wheel and skip the hash check the job exists to perform.
bin/stop unloads the background service. bin/start used to exec the
supervisor in the foreground instead, so the obvious stop; start pair was
asymmetric: it retired the managed service and left an unmanaged copy in its
place. The copy carried the calling shell's environment rather than the
installed one and died with the shell that started it.
That is how a live installation lost its proxy. A stop; start issued from a
zsh -lc that a desktop app had spawned produced a router with no
HTTP_PROXY and no NODE_USE_ENV_PROXY, because the shell had neither. Every
upstream was dialled directly, chatgpt.com timed out, and the router answered
502 with the message from src/transport-failure.mjs telling the operator to
set an opt-in that was already set -- in the LaunchAgent it had just unloaded.
The service definition still looked correct at every glance.
- Both verbs go through
src/service.mjs.bin/start, Windowscodex-router.ps1 start, and their corresponding stop paths manage the same background-service layer. Never add a lifecycle verb that manages the service on one side and bypasses it on the other. - The foreground supervisor stays reachable, never by accident.
bin/start --foregroundandcodex-router.ps1 start --foregroundare the explicit debugging paths. They enter throughsrc/foreground-start.mjs, which holds the shared service-operation lock for the supervisor's lifetime. That keeps caller-capability rotation/recovery from swapping generations underneath an unmanaged foreground router. Directsrc/start.mjsremains the OS-service payload; do not route the managed service through the lifetime lock. - A silent environment adopts the recorded proxy.
inheritedProxyEnvironment()insrc/proxy-environment.mjsreads the install manifest, andsrc/start.mjsapplies it toprocess.envbefore it reads anything or spawns a child, so the router and all three forwarders inherit it. This is the belt to the service definition's braces: it makes the foreground path, and any future path that execs the supervisor directly, reach upstreams exactly as the managed one does. - Silence is the only trigger.
proxyEnvironmentDeclaredalready treats a named proxy -- or anyNODE_USE_ENV_PROXY,0included -- as the operator speaking, and the restore defers to it. A deliberate unproxied run stays unproxied. Do not widen the trigger to "no proxy reachable" or similar inference; the manifest records a decision, not a guess. - Coverage.
test/proxy-environment.test.mjsholds the restore contract andtest/service-lifecycle.test.mjsholds the dispatch/ownership boundary: normal start reaches the managed service layer, Windows matches POSIX, and explicit foreground startup cannot boot while another service lifecycle operation owns the shared lock. The same file keeps the silent-environment proxy restore regression.
src/gateway-supervisor.mjs watches the LiteLLM child and replaces it when it
dies. It exists because the gateway is the one child of the service that is not
ours: a bug anywhere in that pinned Python tree can end the process rather than
the request, and issue #261 is exactly that — mapping an upstream 429 raised out
of LiteLLM's own request handler and the proxy exited 1. start.mjs raced every
child's exit, so one failed request killed the router and all three forwarders
and every client saw a bare "Connection error" naming nothing.
- Only the gateway is supervised. The forwarders and the router are ours; when one of them dies the service still exits and the OS supervisor rebuilds it. Do not extend the supervisor to them to "be consistent" — a crash in our own code is a bug report, and papering over it costs the incident.
- Supervision starts only after the gateway has been healthy once. A
gateway that never came up is a dependency or configuration failure, and
retrying it buries the message the operator needs. Startup failure is
unchanged: it throws out of
main()and takes the service down, which is whattest/startup-cleanup.test.mjsasserts. - Bounded, and bounded in a window. At most five restarts inside ten
minutes, backing off 1s, 2s, 4s, 8s, 16s, capped at 30s. The window is
load-bearing in both directions: a lifetime budget would eventually stop
restarting an install that crashes once a month, and no bound at all turns a
gateway that dies on every request into a spawn loop. Past the bound the
supervisor returns and the service exits exactly as it used to, so launchd's
KeepAlive, systemd'sRestart=always, and Task Scheduler get their clean restart.CODEX_ROUTER_GATEWAY_RESTARTS=0disables it entirely and restores the pre-#261 behaviour, which is what a crash investigation wants. - Never silent. The production LaunchAgent hard-sets
CODEX_ROUTER_QUIET, and a router that quietly resurrects a crashing gateway is indistinguishable from one that never failed. Every crash, every restart, and the decision to stop restarting are logged unconditionally. - A replacement that never becomes healthy is stopped, not left parked. Otherwise the loop waits on an exit that only an external kill can produce, and a hung gateway looks like a healthy one.
/healthnames the unreachable dependency. The unauthenticated leaf carriesdegraded: ["gateway"]— a closed set of three fixed local service names, never a URL, a credential, or the per-service payloads the protected leaf carries, andtest/routing.test.mjsasserts that boundary. It is what lets doctor report "serving but reports gateway unreachable" instead of "not ready", which sent operators looking for a dead service when the gateway was the thing that died.- The launcher is spawned through
spawnableCommand, like every other external command. The installer produceslitellm.exeon Windows, so the shipped path is untouched pass-through — butMODEL_ROUTER_LITELLM_BINandCODEX_ROUTER_LITELLM_BINare operator-set, and Node has refused to spawn a.cmd/.batwithout a shell since CVE-2024-27980. A batch launcher there used to end the service before it spawned anything, with an EINVAL naming neither the file nor the reason. Never reintroduce a barespawn(command, args)instart.mjs;test/gateway-restart.test.mjsguards the shape, because the behaviour itself cannot be exercised on POSIX. All three fields of the result are load-bearing,optionsincluded: for a batch shim it carrieswindowsVerbatimArguments, without which Node re-quotes a command line that is already escaped for cmd.exe. A call site that spreads onlycommandandargsis a Windows bug that POSIX CI cannot see — it was howdevinCliVersioncame to report "unknown" for an installed CLI. Note the one cost of the batch path: the service then holds thecmd.exehop rather than the gateway, so a signal reaches the hop and the real process is orphaned. That is strictly better than not starting at all, and it is another reason the installer produces an.exe. - Z.ai choice-bearing terminal usage is normalized before LiteLLM. LiteLLM
1.95/1.96 can discard authoritative usage when an OpenAI-compatible provider
puts
finish_reasonandusageon the same streaming chunk. Z.ai does that, sosrc/zai-cache-usage.mjsrewrites only that provider shape into the standard usage-only terminal chunk and preserves explicit cached-token details. Do not replace missing usage with estimates at this boundary and do not downgrade LiteLLM to escape the bug; the pin is also a security and wheel-availability floor.scripts/verify-zai-litellm-usage.mjsexercises the pinned LiteLLM bridge with synthetic authoritative usage on every Python lock job. - Z.ai Responses streams need a post-LiteLLM message-envelope repair.
Live GLM-5.3 traffic through LiteLLM 1.96 can finish a reasoning item and
then emit
response.output_text.deltafor the assistant message without the requiredresponse.output_item.added/response.content_part.addedenvelope. The same malformed stream can reuse reasoning'soutput_index=0for the message and close the message with areasoning_textcontent part.src/zai-responses-compat.mjsrepairs only that Z.ai event-stream shape after LiteLLM translation: valid streams remain byte-identical, native OpenAI traffic is never attached to the transform, and provider reasoning must never be copied into assistant-visible message content. A real Codex live probe is the regression oracle: noOutputTextDelta without active itemwarnings and the message occupies the next output index after reasoning. - LiteLLM custom-tool streaming uses a mixed lifecycle. LiteLLM 1.96
converts Responses
type: "custom"tools into Chat Completions functions whose one required string property iscontent. On the return stream it can already restoreresponse.output_item.added/doneas nativecustom_tool_callitems while still emitting legacyresponse.function_call_arguments.delta/doneevents whose JSON wrapper is{ "content": "..." }.NamespaceToolCallTransformmay decode that wrapper only when the source opening itself was already a native custom call; the router's own custom-function bridge keeps its{ "input": "..." }contract. Keep the streamed-input fingerprint check and fail closed when the delta, terminal arguments, or output-item close disagree. The focused namespace-relay test and Z.ai router fixture hold both sides of this boundary. The wrapper is a request, not a guarantee: models putcontentafter another key, answer{ "input": ... }or{}, or send raw patch text, and LiteLLM relays those rather than rejecting them. For a native custom call the relay therefore derives the input exactly as LiteLLM'sunwrap_custom_tool_argumentsdoes -- a stringcontentfrom a JSON object, otherwise the arguments verbatim -- and never a stricter reading; rejecting a shape LiteLLM accepted aborts a committed stream and Codex retries the identical turn until it fails. A present non-stringcontentstays unsupported, and the delta check is skipped only when the decoder emitted no input text for the client to contradict. - Do not answer a gateway crash by moving the litellm pin. The pin is a
security floor and a wheel-availability decision (see the lock section
above), any change to it has to be proven by booting the proxy rather than by
a successful resolve, and a router that survives its gateway is worth having
at every version. Coverage lives in
test/gateway-supervisor.test.mjs(the loop, the bound, the window, the backoff) andtest/gateway-restart.test.mjs(end to end: a stand-in gateway exits 1 mid-request, the service does not, and the router is still serving afterwards).
First distinguish a local model addition from a repository-wide model change. Prefer local curation when one user wants a model that an already registered provider advertises. Change the checked-in registry only when the user intends to ship tested support to every installer.
- Inspect the installed selection with
./bin/model-router codex providers list --json. Do not assume that a stored credential means the provider is intentionally visible. - If authentication is missing, use the provider's official OAuth CLI or run
./bin/model-router codex provider-key PROVIDER setin a PTY. Keep secrets out of chat, arguments, logs, environment snippets, and tracked files. - If the requested model is already checked into the registry tree under
config/(one vendor directory holding a<vendor>.jsonprovider file plus per-access-methodmodels.jsonfragments, e.g.config/kimi/kimi.jsonandconfig/kimi/oauth/models.json), run./bin/model-router codex providers enable PROVIDER. This preserves the other selected providers and refreshes the installed picker catalog. - If the provider is registered but the model is not checked in, run
./bin/curate-models PROVIDERin an interactive terminal. When the user gave exact IDs and the live catalog confirms them, the deterministic form is./bin/curate-models PROVIDER --models ID1,ID2 --apply. On Windows usenode .\src\curate-models.mjswith the same arguments. OrcaRouter also supports--free-only, which additively curates every live concrete OpenAI-compatible entry whose catalog price is zero, tags itisFree, and removes the movingorcarouter/freemeta-router if an older run curated it. It still requires an OrcaRouter API key for inference and never turns the provider on implicitly. - Local curation writes protected
user-models.jsonstate and survives router updates. Never edit the checked-inconfig/registry tree merely to satisfy one machine's request. The provider's own/v1/modelsendpoint alone decides which models exist. Interactive curation asks for each new model's context window, image support, and reasoning efforts (so the user can switch effort in the picker); the deterministic--modelsform takes conservative defaults,--efforts minimal,low,medium,high,xhighsets the effort ladder, and every stored value stays editable inuser-models.json. The context window is the exception to "conservative default": both forms store thecontext_lengththe provider's own catalog advertises for that model (modelContextLengthsinsrc/model-discovery.mjs), becauseautoCompactis derived from it and an understated window makes Codex compact a session that had the room. Only a model the catalog sizes in silence falls back to 131072. OpenCode Zen's anonymous catalog publishes ids and nothing else, so its free models are sized and laddered fromsrc/opencode-curation.mjs, which records OpenCode's own publishedlimitandreasoning_optionsfor each free id along with the sourcing. A documented window is stored only when the 0.85 auto-compact ratio still reserves that id's publishedlimit.output; otherwise the id keeps 131072 and its description says the window is unknown, because a window a full-length completion can overrun fails the turn outright. An id OpenCode documents nothing usable for keeps the stock "conservative default metadata" description, which is how a stored entry says every value in it is a default rather than an advertised capability. An explicit--effortsalways wins over a documented ladder. An entry curated before this landed keeps its stored window — an additive run never rewrites existing metadata — so repair it by editinguser-models.jsonor by--remove-ing and re-curating the model. An optionalavailabilityNuxstring on a model becomes the Codex "Introducing {model}" announcement (shown a limited number of times per slug, tracked by the Codex client itself); leave it unset unless the model is genuinely news to the operator. Curated models are not implicitly approved as native v2 subagent model overrides. - A curated model inherits a request profile from the provider's registry
models. The catalog-only resellers ship none, so curation also asks whether
the model rejects a forced
tool_choice(--request-profile auto-tool-choicein the deterministic form). Answer yes only for a model observed to answer HTTP 400 ontool_choice: "required"while still calling tools under"auto"— the restriction belongs to the upstream behind the reseller, not to the reseller, so it is set per model and never as a provider-wide default. When the upstream refuses the field in any form, including"auto"and"none", use--request-profile omit-tool-choiceinstead: it deletestool_choiceand keeps the tools, exceptnone, which also drops the tools so a prohibition cannot become the upstream default. Never widen it by changing whatsrc/compatibility-test.mjssends: the probe must keep sendingrequired, or it stops proving tool calling works for every other provider.dashscope-reasoningis the same kind of model-scoped observation for Alibaba Model Studio's OpenAI-compatible surfaces. It folds Codex's rung onto the family's documented ladder (Qwen3.8none/low/medium/xhigh, GLM-5.3low/high/max, DeepSeek V4.xnone/high/max, with the dated0731/0813snapshots keepinglow), writes the nested spelling on/responsesand the flat one on/chat/completions, maps Codex'sminimalonto DashScope'snone— Codex has no thinking-off rung of its own — and downgrades the forced tool choice the Qwen3.8 family refuses in thinking mode on both surfaces. - Run
./bin/model-router codex doctor. A livebin/test-modelrequest uses provider quota, so run it only with the user's approval. Finally, tell the user to fully quit and reopen Codex before checking the picker.
If the provider itself is unknown to the registry, stop treating the request as installation. It is repository development and requires the process below.
Switching a model on as a subagent (tray toggle, control subagents set) is
the operator's whole job; deciding whether that model under that provider
can hold the v2 child role is the router's. The same model answers differently
per provider — tool support, request profiles, and payload handling all vary —
so the unit of evidence is always the slug, never the model name.
- Enabling an unknown model hands it to a detached compatibility probe
(
src/subagent-verify.mjs): two live requests through the installed router proving streaming and a forced tool call. The proofs snapshot showscheckinguntil the verdict lands. A passing probe recordscandidate; it does not advertise v2. A worker that dies without a verdict records a failure, and a stalecheckingrecord is retryable. Explicit registry-v1 routes are settled decisions and are never re-opened by this local probe; registry-v2 routes need no compatibility probe. multi-agent-proofs.jsonis diagnostic application evidence only. Localcandidate, and legacyexperimental/proven, records cannot change the catalog'smulti_agent_version, managed agent definitions, or any client route.applySubagentProofsdeliberately returns the registry capabilities unchanged. An unreadable proofs file therefore authorizes and promotes nothing.- The compatibility probe is not the native collaboration proof. It does not exercise Codex's encrypted child payload relay, a marker-return spawn, or a same-thread follow-up. A successful ordinary chat/tool request must never be presented as evidence that the model can hold the native v2 child role.
- Only the exact checked-in registry route may assert v2. The route's slug,
provider, and upstream model must match an accepted artifact under
v2_agent/, and the accepted artifact andmultiAgentVersion: "v2"change land in the same pull request. CI enforces the implication in both directions for every post-workflow promotion. Six exact Kimi/Grok route identities certified before the artifact gate are grandfathered; changing any part of one identity loses that exception. control subagents verify [SLUG ...]re-researches explicitly (foreground, about two requests per unknown candidate); with no slugs it sweeps the enabled list. Select-all and mode changes never trigger probes. Provider, model, and family auto-policies are explicit standing consent for matching newly configured unknown routes; they still produce only candidates.- Machine-local evidence is exactly that. Never edit checked-in
config/because one machine's probe passed. Complete the redacted application, reproduce the two native child checks with a spendable account, and review the exact provider route before shipping a v2 claim to every installer.
- Run
./bin/discover-models PROVIDER; discovery is read-only. Confirm the model ID and capabilities against the provider's current official documentation. Never infer tools, images, context size, reasoning, or billing behavior from the model name. - Add the model declaratively to the vendor's registry fragment (the
config/<vendor>/<method>/models.jsonfile for its provider; a new provider also needs its definition inconfig/<vendor>/<vendor>.json) with uniqueslug,gatewayModel, and provider/upstream IDs; complete picker metadata; supported reasoning levels; input modalities; context/compaction limits; and the correct request profile. Uselisted: falsefor compatibility-only aliases. An optionalavailabilityNuxstring ships announcement copy that Codex renders as its "Introducing {model}" card the first few launches after the model appears; reserve it for a genuinely new flagship, because every installer will see it. Checked-in models that newly become routable (added by an update, or unlocked when the operator credentials and enables their provider) also announce automatically for seven days with copy assembled from their verified picker metadata (context window, effort ladder, image support) — tracked in the protectedannounced-models.jsonstate; the first catalog capture seeds that state silently, curatedavailabilityNuxcopy wins over the generated text, and locally curated user models never self-announce. In the CLI TUI this renders as a startup tip line; the full-screen prompt is instead driven by an optionalupgradeToobject ({ "model": "target/slug", "markdown": "..." }) on the model the operator currently runs: Codex renders the markdown as the entire "Codex just got an upgrade" modal (with{model_from}/{model_to}placeholders), and accepting switches the operator's default model to the target, so ship one only for a genuine successor. - A new provider also needs credential isolation, discovery metadata, selection/onboarding support, request translation, health behavior, and tests. Never place an API key or OAuth artifact in the registry. A new provider is not done until the whole checklist in "Ship a new provider to every installer" below passes.
- Set
multiAgentVersion: "v2"only after the model is proven through native Codex collaboration: tool calls work, encrypted subagent payload relay works without disclosure, a marker-return spawn succeeds, and a same-thread follow-up succeeds. Otherwise omit it and retain conservative v1 behavior. The registry is not the only way a route reaches v2. The operator's own selection promotes it —subagents mode selectedplussubagents set <slug> on, ormode all— and so does a completed local verification of all five checks recorded inmulti-agent-proofs.json. Selection is the ordinary path and the one the Control Center switch uses; the registry exists so nobody has to select a proven route by hand. None of this loosens the gate: an explicitoffbeats every mode, a hidden model is never promoted, a partial verification or a mismatched slug promotes nothing, the legacy diagnostic statuses promote nothing, and only the pull request that moves the registry entry may accept av2_agent/application. Readdocs/SUBAGENT-CERTIFICATION.mdin full before changingsrc/subagent-*.mjs,src/multi-agent-state.mjs,v2_agent/, or the Subagents column — it records which questions have already been answered at the cost of provider quota. - Remember that Codex advertises only a small priority-ordered subset of native
spawn-model overrides. Adjust priority intentionally and keep the desired
Kimi/Grok/GPT choices in that visible subset; do not crowd them out
accidentally when adding a model. The published catalog carries two
numberings for exactly this reason (
publishedPickerPrioritiesinsrc/catalog.mjs): a certified v2 route keeps its authored priority so it stays inside that window, while every v1 routed model is published in a band above the highest visible native priority so the picker shows vendor groups instead of interleaving routed entries among native GPT models (issue #544). Only the published entry is renumbered; failover, the vision bridge, and the other clients keep reading the registry value. - Add registry, catalog, routing/request-profile, and failure-path regression
tests. Run
npm run checkandnpm test. With explicit quota approval, run./bin/test-model 'provider/model' --live --yes, reinstall, fully restart Codex, and perform the native subagent probe before claiming support.
src/native-context-variants.mjs publishes a native GPT model under a second
slug carrying a different context window — gpt-5.6-sol-1m is the first. It is
not a new model and never becomes one: the entry is copied wholesale from the
capture, the router translates the slug back to its base on the way out, and
the only fields overridden are the slug, the display name, the description, and
the window/compaction pair.
- The window is read from the provider's current official documentation, the
same rule as any other model. Never raise one because a request happened to
be accepted, and never guess from the family name.
bin/doctorreports windows a provider has already disproved; that check does not authorize the opposite direction. - A variant ships hidden.
seedModelsHiddenapplies that default exactly once per slug, so it can never re-apply itself over an operator's choice — which is whymodel-picker.jsonrecordsseededalongsidehidden, and why every writer insrc/model-picker-state.mjsmust preserve it. A variant that costs more per turn than the model it shadows must never arrive switched on in an update. - Derive only from a base the capture actually shipped as
visibility: "list", and never in a login-free install: signed-out Codex surfaces display native slugs from a server-supplied allowlist, so a synthesized slug would consume an alias slot and then be invisible. - Every surface that enumerates the OpenAI group goes through
withNativeContextVariants— the catalog build, the tray probe, and the group's Show all / Hide all. A surface that readsnative-models.jsondirectly will silently omit variants. Published clients (src/routed-client-models.mjs, serving DeepSeek Harness and Gemini CLI) are deliberately not among them: they read the capture and do not apply the picker's hidden set to native models, so a variant would arrive switched on in a surface that has no switch. Publishing one there means fixing that first. - Cover the derivation, the slug translation on a live native turn, the
hidden-by-default seeding, and the survival of an explicit choice across a
rebuild.
test/native-context-variants.test.mjsis the existing shape.
A new provider is only complete when all of the following are true. Do not land a provider that satisfies routing but skips the tray, install, or usage surfaces.
- One-click install. The provider ID must work end to end with no manual
config edits: selectable through
install.sh --providers/install.ps1 -Providers, throughbin/model-router codex providers enable PROVIDER, and reported correctly bybin/model-router codex doctor. If the provider ships no preselected models, document it as catalog-only and make surebin/curate-modelshandles it. - Tray setup section. Every provider must appear in the macOS tray with a
working setup card driven by
src/provider-onboarding.mjsand the control commands the tray invokes:- API-key providers get the hidden credential path (tray →
control credential PROVIDERover stdin →saveApiCredential). The key must never transit chat, logs, or command arguments. - OAuth providers additionally get the OAuth section: an
OAUTH_CLISentry insrc/provider-onboarding.mjs(executable, npm package, login arguments) so the tray'sinstall-cli PROVIDERandlogin PROVIDERbuttons work, plus status, session-refresh, and reconnect-on-expiry wiring in the provider's OAuth status/session modules (followkimi-oauth-*/grok-oauth-*as the patterns). - Connecting is always one click. Any tray sign-in button installs the
official CLI when it is missing and then runs the login in the same
operation (
connectProviderin the tray), rather than stopping after the install and waiting for a second click. Label the button for everything it will do (Install & Sign In) so the single click stays honest. This is the house rule for every provider, OAuth or CLI-session: implement it without asking. - Add the provider icon under
apps/macos/ModelRouterTray/Resources/and record its source inPROVIDER-ICON-SOURCES.md.
- API-key providers get the hidden credential path (tray →
- Plan entitlement. When a provider's credential can authenticate an
account whose plan still may not call the API, set
planNoteon its registry entry.providers enable,doctor, and the tray all print it, so the requirement is visible where someone connects instead of arriving as a 403 inside Codex. Command Code is the case: every plan except Go is served through the Provider API, while Go remains CLI-only. - Usage, limits, and balance in the tray. Wire the provider's account
endpoint into
src/provider-account-usage.mjssoprovider-usage --jsonreturns real metrics:quotametrics (used/limit/remaining with reset time) for plan- or window-limited providers, andbalancemetrics (the remaining dollar or credit amount) for prepaid/pay-per-use providers. These feed the tray's "% left" display, usage cards, and low-remaining reminders, so a provider without them silently hides the user's spend. If the provider exposes no usage or balance API, the snapshot must degrade gracefully and the tray must say usage is unavailable rather than showing stale or empty numbers. Routed request/token accounting comes from the shared usage-events pipeline and needs no per-provider work beyond correct event recording.
The router can let a text-only model answer about a pasted image: the routed request path sends each image part to a vision-capable model the operator has already enabled and credentialed, and substitutes the returned transcript into the turn as text. Treat it as a router capability, never as a model capability.
-
It is on by default and off only when the operator says so (
bin/control vision-bridge off, protected state invision-bridge.json). Reading a pasted screenshot is the one thing people expect to work without finding a toggle, and everything it needs is already installed: an install with nothing to read images with resolves no engine and degrades exactly as it did before the bridge existed, so the default costs an unequipped machine nothing.- The line between "never configured" and "configured off" is structural,
not a sentinel: no state file means nobody has answered and the current
default applies; a readable file's
enabledis the operator's own answer and is taken verbatim forever. A storedfalsemust never be re-enabled by a change of default. A file that exists but this build cannot parse falls back to off, not to the default — somebody was here and we cannot tell what they chose, so it must not start spending quota. versionstays1on purpose. A bump would have to guess what an olderfalsemeant, and there is nothing to guess with; file presence already answers it, andvisionBridgeConfigured()has gated exactly that distinction since the bridge shipped. Do not add a migration that replaces that fact with an inference.- The installer writes no bridge state at all. It used to auto-enable once when a vision-capable provider happened to be selected, which made the file's presence mean "the installer ran" and left every other install needing a command nobody knew about. It now only reports. Never write bridge state from an install or update path; a routed image spends the engine provider's quota, so the only writers are the operator's own commands.
- Because it is on by default, a surface that would nag an unconfigured
install checks
visionBridgeConfigured()first.doctorwarns about "no resolvable engine" only for an operator who actually asked; for a default-on install it reportsok, since nothing was lost.
- The line between "never configured" and "configured off" is structural,
not a sentinel: no state file means nobody has answered and the current
default applies; a readable file's
-
The registry keeps declaring what each model itself reads.
inputModalitiesis never edited to addimagefor a bridge, andvisionBridgeaccepts onlyfalse, as a per-model opt-out. The registry loader rejectstrueso the file can never assert a capability the model lacks. -
The catalog advertises image input on a bridged model only while an engine actually resolves from the selected, credentialed, listed set. When the bridge is off, or the pinned engine disappears, the advertisement goes with it — Codex gates the paste on
input_modalities, so a stale advertisement would leave a paste that nothing can serve. Rebuild the catalog after every change and tell the user to fully quit and reopen Codex.resolveVisionEnginetakes that set as a function, never as an array, and rejects an array outright. Assembling it means probing every provider's credential synchronously — on macOS one/usr/bin/securityspawn per provider per keychain service, ~250ms with the event loop stopped — while two of the three answers (bridge off, engine pinned tolocal) never look at a candidate. On the request path that cost was paid per pasted image and blocked every other in-flight request. Deferring narrows nothing: what gets ranked is still exactly the selected, credentialed, listed set. A lazy list that skips the credential check is a security regression, not a speedup. -
A registry engine's call goes through the same gateway, credential, and request profile as any other routed turn. Do not add a second upstream path, a separate vision API key, or an external CLI dependency for a hosted engine. The one sanctioned exception is the local engine (
vision-bridge local): a vision model the operator runs themselves (Ollama, LM Studio, llama.cpp). It lives outside the registry, so the request path calls its/v1/chat/completionsendpoint directly with no credential, and it is used only when explicitly pinned — auto mode never routes images tolocalhost, since an unreachable server would fail every paste. The rule is about the address, not about that one engine: a keyless registry provider (local, and the loader guarantees keyless means a loopbackbaseUrl) is the same hazard wearing a registry slug, soresolveVisionEngineexcludes every loopback-served candidate from auto and admits it only by explicit pin. It stays listed in the picker, because choosing it carries the knowledge that the server has to be up. Auto mode is the only default an unattended machine gets, so it may only nominate an engine that is reachable without the operator having started something. This is what lets a text-only-only install enable the bridge with no paid vision model. Thevision-bridge setupcommand and probe target Ollama for auto-download because it is a managed daemon with a stable model registry — the only runtime where "install once and it keeps working" holds. llama.cpp and LM Studio remain first-class manual engines (the probe detects both, andvision-bridge local <model> <baseUrl>pins either); the installer never installs a runtime or pulls a multi-gigabyte model without explicit consent (setuprequires--yesbefore any download). A model download runs detached (src/vision-download.mjsstreams Ollama's/api/pulland records progress invision-download.json):pullreturns at once andpull-statusreports the percentage, because a synchronous multi-gigabyte pull freezes the tray and reads as a crash. The worker pins the model only after it is on disk, so a failed or interrupted download never repoints the bridge at a model that is not there. -
The second sanctioned exception is a native engine: a vision model from the operator's own signed-in ChatGPT plan, reached over the native path the router already owns (
NATIVE_BASE) with the caller's own session headers. It is permitted because it introduces nothing — no stored credential, no separate vision API key, no external CLI, no install step, nothing to download — and because it spends a plan the operator already pays for, on a backend the router already talks to on every native turn. That is the whole justification. These conditions are what keep it from widening into something else, and each one is load-bearing:- No new credential, ever. A native engine carries the caller's session
and nothing else: the fixed
FORWARD_HEADERSallowlist, copied from the request in hand and sent only to the hardcodedNATIVE_BASE. The router must never store, cache, mint, or read a credential for this path, and the gateway's internal key must never travel to that backend. An engine that would need a key the router does not already hold is not this exception. - Fail closed when there is no caller session. No session on the request
means no native engine: not a candidate, and a pin naming one does not
resolve. Never fall back to the gateway for a native slug — it holds no
credential for one — and never accept an on-disk capture as evidence that
the session is still good.
native-models.jsonandmerged-models.jsonare both reused deliberately when a fresh probe fails, so a sign-out leaves them naming an engine that can no longer be called. Signing out has to stop the engine resolving on the very next paste, not at the next catalog rebuild. Cached transcripts are part of this: a native transcript is keyed to the account that bought it, because a cache hit skips the call and with it every check that this session may still spend that model. - This is not a general bypass. It licenses one destination and one credential: the router's own native path, on the caller's own session. It is not a precedent that any hosted engine may skip the gateway once its credential story sounds tidy. Item 4 stands unchanged for every registry engine — a hosted engine that would bring a second upstream, a separate vision key, or an external dependency goes through the gateway or does not ship.
Known gap: plan quota and limits spent this way are still not surfaced. Every bridged read now records a usage event through the shared pipeline (model, provider, status, duration — no token counts, because the request path receives the transcript rather than the envelope) and logs one never-quieted line, so the operator can tell a vision call happened and against what. What is still missing is the other half of what "Ship a new provider to every installer" requires of everyone else: the ChatGPT plan's remaining quota does not move in the tray when a screenshot is transcribed, and no surface renders routed usage events at all. It is being closed separately. Do not read it as settled, do not weaken this section to accommodate it, and do not extend the exception to another engine while it is still open.
- No new credential, ever. A native engine carries the caller's session
and nothing else: the fixed
-
Substituted transcripts are untrusted user data. Keep them fenced and labelled as quoted image content, never log a transcript or a gateway error body, and keep the per-image failure path degrading to a stated failure rather than a failed turn. A stream that fails partway through is a failure, not a short transcript: deltas already in hand are discarded rather than returned, because a plausible truncated transcript is quoted downstream as though it were the whole image.
-
Evidence, not impressions. The instruction set asks for a transcript, a layout list, readable data values, and an explicit uncertainty list, so the downstream model quotes rather than guesses. Preserve the uncertainty section in any rewrite.
## Identificationis the one section where inference is allowed, and it exists because a pure transcription contract cannot answer "what is this?" — which is the most common thing anyone asks about a pasted image. Without it the reader described a photo it plainly recognized, and the routed model went looking on the internet, uploading the operator's screenshot to a public host on the way. Keep it separate from## Text, keep it required, and keep(unrecognized)as the answer when nothing is recognizable. Inference belongs in one labelled place, never spread through the sections that claim to be a reading.
-
The reader is asked what the operator wants to know, and asked again when that changes. Pinning the question to the image's own message kept the cache still and made an image's reading a snapshot of the first thing ever asked about it. The newest image follows the newest question instead. Three properties keep that affordable, and all three are load-bearing:
- Bought once per question, never per turn: a question already asked is served from the record, so Codex resending the conversation is free.
- Only the newest image follows the conversation. Older images keep the question they were read for, so a chat holding ten screenshots cannot turn one new question into ten new reads.
- The record accumulates, so an earlier answer survives a later question.
The question itself is the operator's words only: Codex's
<image …>wrapper, its# Files mentioned by the user:preamble, and its context blocks (<environment_context>,<recommended_plugins>) are bookkeeping, and sending them produced transcripts written about a filename.
-
Resolving an engine and reaching it are different questions. The reader is a short list — the operator's choice first, then the other credentialed, non-loopback vision models — and an image is offered to the next one when the first cannot be reached. A 401 from a lapsed session and a 503 from a provider outage both turned every paste into "could not be read" while the engine still resolved perfectly. What the fallback must not become:
- Silent. The evidence header names the engine that actually read the
image, and the per-turn log line records
fellBack. - A way around a pin. A pin that does not resolve is still an operator-visible problem, never a quiet switch to another model, and a pinned local engine never falls back onto a provider's quota nobody chose to spend.
- Expensive. The list is capped, the candidate set is still built at most once per turn, and a second engine is only ever called after the first has failed — so a working engine costs exactly what it did before. Another provider is tried before another attempt at a broken one: retries are spent only on the last engine in the list, because waiting out a retry ladder against a dead endpoint while a working engine sits behind it is how a fallback that works becomes a paste that takes half a minute.
- Silent. The evidence header names the engine that actually read the
image, and the per-turn log line records
-
A read that fails transiently is asked again — twice, at 250ms and 1s. The engine is a rate-limited account across a network, and losing an image for the whole turn to a 429 that would have cleared in a second is the opposite of what the bridge is for. What is not retried is equally deliberate: 4xx refusals buy the identical refusal, and a timeout is reported rather than retried because the per-attempt budget is already two minutes. A transport failure keeps the transport's own wording ("fetch failed"), which is how an operator learns their own loopback engine is down.
- A reading that came back incomplete says so in its own header. The
downstream model cannot otherwise tell "the image does not show that" from
"the transcript does not mention it", and it answers the first with
confidence either way.
## Datais optional by contract and its absence is not a bad read. The two causes are reported differently on purpose: a read cut off at the size cap left a large image genuinely unread, so it is the one case that invites a second look; missing sections mean the engine does not follow the format at all -- a small local model answering in prose -- and reading again returns the same shape, so an invitation there buys a loop rather than an answer. Never advertise a second look on every image.
- Every image the router carries is read, in both places Codex puts one:
parts of a user message, and the
outputof afunction_call_output. A text-only model that has just been handed a transcript still sees the file's path in the turn and callsview_imageon it, and that tool result holds the same bytes again. Missing it hands a raw data URL to a provider that rejects the whole conversation with an error naming no image (unknown variant image_url, expected text). Two consequences are load-bearing and must survive any rewrite:
- Say which file the transcript is of. A pasted image takes the path from
Codex's own
<image … path="…">wrapper, a tool result from theview_imagecall that asked for it. Without that link the model pays a tool turn plus a full resend of the conversation to open a file it has already been given — far more than the read cost. That wrapper is markup, not the operator's words, so it is stripped from the question sent to the engine. - A tool result inherits the question that led to it, so a
view_imageround trip lands on the transcript the paste already bought instead of buying a second one. It is also the only way a later question gets a freshly focused read, since the question pinned to an image is the one in its own message.
- An image's evidence is one record per image, not one transcript per
question. The question still decides whether a read has to be bought;
what gets injected is every reading the router holds for that image. Filing
one transcript per (image, question) and injecting only the matching one made
the evidence a snapshot of the first question ever asked, which a later
question could not add to. Keep the record append-only, keep the first
(general) reading undroppable when the cap bites, and keep the whole record
inside the budget a single transcript used to have. When one turn carries the
same image twice — the paste and the
view_imageresult — only the first slot prints the record; the rest point at it. That pointer is keyed on the image, never on matching transcript text: two different screenshots can read identically, and "the same image" has to be a fact about the bytes. - One image, one purchase. The transcript cache only knows about reads that
have finished, so concurrent requests — Codex sends them, and a subagent
runs beside its parent — all missed and all bought the same transcript. Reads
in flight are shared by image, effort, account, and question; waiters take the
first read's outcome including its failure, and the shared read is never tied
to one caller's
AbortSignal, or one client's cancellation would cost a live request an image. Reads within a turn run concurrently under a fixed cap: the operator waits for all of them before the routed turn starts, but the engine is somebody's rate-limited account and must not receive an album as a burst. - Never add a local model to
LOCAL_VISION_CATALOGwith anaccuracyclaim that was not measured. Runnode src/vision-benchmark.mjs, which scores a model against a checked-in image with known contents, and record the result inmeasured; anything unmeasured staysuntested. This is not bureaucracy:llavascores 0% andmoondream0% on text while sounding entirely plausible, so a reputation-based label would route users straight to a model that fabricates invoice numbers. The picker sorts on this field, so an unearned "accurate" puts a confident-wrong reader at the top of the list. - Which native models may read an image is one rule in one place
(
src/vision-engines.mjs), not a criterion each surface re-derives. The catalog build, the tray, and the request path each asked it separately once, and the three answers disagreed — the request path applied no auth gate at all. The rule is shared; only the evidence for the gate differs, because just one caller can afford to ask Codex directly (codexAuthStatus()spawns a process) and only the request path holds the caller's live session. Every call site names its evidence explicitly, and the coverage below fails when one of them stops. - The bridge lives on the routed request path only.
src/api-forwarder.mjssits downstream of the gateway — every routed model'sapi_basepoints at it — so Codex's traffic arrives already bridged and an image reaching that hop came from a client talking to the gateway directly. It replaces those parts with the same stated failure rather than reading them: an engine call from there would re-enter the gateway that is holding the request open. The substituted part must use the protocol's own text type (input_textfor Responses,textfor chat completions and Anthropic messages), or an image the provider rejects is merely traded for a text part it rejects. - Regression coverage lives in
test/vision-bridge.test.mjs,test/vision-bridge-state.test.mjs, the bridged-catalog case intest/catalog.test.mjs, the whole-path measurements intest/vision-bridge-e2e.test.mjs, and the router cases intest/routing.test.mjs. A change to engine ranking, caching, substitution, the native gate, or the advertisement rule needs a test there. The two properties worth stating as tests rather than prose: nothing image-shaped may survive into a forwarded body, and one image asked one question may be bought only once however many requests are in flight.
authMode: "anonymous" is not the same as keyless. A keyless provider is
loopback-only and serves from this machine; an anonymous provider sends the
operator's prompt to a fixed remote endpoint under a provider-controlled free
model policy. It must never declare a credential, keyless mode, or a base-URL
override, and the registry loader keeps its endpoint allowlisted.
Anonymous providers are configured but never defaulted: the credential
resolver may report them as ready, and an explicit --providers choice may
route a free model, but defaultProviderIds(), the no-argument setup path,
--providers configured, and ensure-configured must not add them when the
operator did not ask. Never check in a paid model ID or silently turn on an
anonymous endpoint during installation.
Which free IDs a reseller gateway serves is decided by anonymousModelAllowed
in src/model-registry.mjs, never by the registry fragment alone, and the
ANONYMOUS_ENDPOINTS table beside it is the reason a fragment edit cannot
point a credential-free provider at a model somebody would be billed for.
opencode-free and kilo-free each expose a large free subset picked out by a
naming rule that changes without notice, so neither ships that subset: discovery
filters the provider's live /models response and the user curates locally.
Z.ai revealed the OpenCode Go Ox Alpha preview as GLM-5.3-Flash. OpenCode Go
withdrew ox-alpha-free and now publishes glm-5.3-flash on Chat Completions.
The checked-in route is opencode-go/glm-5.3-flash; the old public slug
opencode-go/ox-alpha is a static migration alias so existing picker state and
callers move to the live route instead of reaching a withdrawn upstream ID. The
older curation slug opencode-go/ox-alpha-free is an alias to the same target.
OpenCode Go publishes a 1,000,000-token context and 131,072-token output limit
for the named route. Store the provider limit rather than the base model's
1,048,576 architectural maximum. The ordinary 0.85 compaction ratio is not
safe for this route in Codex: large live multimodal histories repeatedly
returned empty completions before that point. Compact conservatively at 400,000
while retaining the provider's advertised context as catalog metadata. This
does not bypass provider moderation; a rejected remote compaction remains a
provider limitation, not a router or stream crash.
No checked-in route preserves the preview under the Ox Alpha name. OpenCode
Free, OpenRouter, and Nous Research withdrew their preview ids. Direct
exact-route probes then rejected stealth/ox-alpha on Command Code as
model_unavailable on basic, streaming, forced-tool, stateless tool-result,
and compact requests. The available Venice account returned its HTTP 402
billing gate on all five surfaces before stealth-ox-alpha could be
wire-certified. Exact probes disable cooldown, response-verdict, and compaction
failover, so neither result can be a healthy alternate answering in disguise.
The repository therefore ships neither preset.
Command Code and Venice discovery still preserve the provider catalog for explicit operator curation. That is not compatibility certification: a local entry can fail when the catalog is stale or the account cannot reach inference. In particular, Venice curation retains the provider-advertised effort metadata; the repository does not replace it with a cross-provider inference for a route it could not execute.
The named GLM-5.3-Flash routes on OpenCode Go, OpenRouter, Z.ai API, and Z.ai
Coding did pass direct basic, streaming, forced-tool, stateless tool-result,
and compact probes. Their recorded effort ladder is low/high/max, and it is the
model's ladder rather than a generic reseller default. The model always
thinks, and its upstream refuses an off-ladder rung by name:
HTTP 400 — [1210] This model always engages in thinking and cannot be
disabled; please use low, high, or max
The ladder also collides with the effort clamp in src/catalog.mjs. Codex
gained the max variant in 0.143.0, so on anything older the catalog rewrites
this model's default down to xhigh — a rung every route refuses. The
legacy-named ox-alpha request profile in src/api-forwarder.mjs closes that
loop for the OpenCode Go, OpenRouter, and Command Code named routes: it clamps
whatever Codex sent onto the rungs the registry entry declares, so xhigh and
ultra land on max, while medium and minimal land on low. An absent effort stays absent
so the upstream default applies, and undocumented thinking is stripped. Z.ai
Coding uses its own glm-thinking profile. These named routes advertise a
1,000,000-token window, compact at the directly proved conservative 400,000
threshold, and preserve forced tool_choice: "required".
That 400,000 threshold belongs to the model, not to one reseller. Every
checked-in GLM-5.3-Flash route carries it, including commandcode/glm-5.3-flash
and the Ollama Cloud candidate, because the empty completions came from the
model's own large multimodal histories rather than from a provider's serving
stack, and each of these routes advertises the same 1M window over the same
upstream id. nousresearch/glm-5.3-flash was dropped for compacting at 943,000
against this rule; commandcode/glm-5.3-flash shipped at 900,000 for two weeks
because that is the Command Code house value for a 1M window — the entry was
written fresh in a bulk catalog pin, took the provider default, and no commit
message, comment, or research note ever argued for it. A per-provider exception
here is a claim about that provider's serving stack, so it needs its own
evidence in the entry or in this file; a provider's boilerplate ratio is not
that evidence.
commandcode/glm-5.3-flash needed that clamp for the same reason and shipped
without it. The profile chain in src/api-forwarder.mjs is keyed entirely on
requestProfile, so a route that declares none forwards reasoning_effort
verbatim — and this entry declares the model's low/high/max ladder, which
is exactly the ladder whose top rung a pre-0.143 Codex cannot spell. Command
Code documents no effort vocabulary of its own (which is why
commandcode/muse-spark-1.3 ships high alone), so the clamp is not a claim
about that reseller's serving stack: it only guarantees the router sends a rung
the entry itself advertises. Note that the clamp governs the Provider API path
only. The /alpha/generate plan fallback in src/commandcode-generate.mjs
builds its own schema-strict params and carries no effort at all, so on a
coding-plan account the three rungs in the picker reach nothing either way.
Other Command Code entries — glm-5.3, glm-5.2, the DeepSeek V4 routes, the
GPT-5.x routes, and the commandcode-messages Claude routes — publish max or
xhigh rungs with no clamp of their own and are in the same unproven position;
none of them has a measured Command Code effort vocabulary behind it.
ollama-cloud/glm-5.3-flash is checked in as candidate registry metadata with a
model-scoped request profile that clamps both flat and nested reasoning effort
onto the same low/high/max ladder. It must not be called certified until
the public slug passes the router-level exact-route suite for basic, streaming,
forced-tool, stateless tool-result, and compact requests with failover disabled.
ollama-cloud/glm-5.3 is also checked in as candidate registry metadata on the
same low/high/max ladder and the sibling ollama-cloud-glm-5-3 clamp profile,
advertising 1,000,000 context and an 880,000 conservative compact threshold
matching the existing Ollama Cloud GLM-5.2 policy. It requires its own run of
the router-level exact-route suite before it is called certified. That
threshold is not a provider-measured boundary. It is text-only: GLM-5.3's
multimodal variant is GLM-5.3-Flash, so the full-size route declares text
modality instead of inheriting Flash's image path.
Every GLM-5.3-Flash route therefore declares ["text", "image"], and the
exceptions were the mistake. Z.ai files this model under its vision-language
guides and gives its input modality as Video / Image / Text / File, documents
the image_url content block for it, and says it is fully available on the GLM
Coding Plan; OpenRouter's own catalog publishes ["text","image","video"] for
z-ai/glm-5.3-flash. Three routes nevertheless shipped text-only — the two
Z.ai ones and OpenRouter's — because each entry was written fresh when the
withdrawn Ox Alpha preset was replaced and took the conservative default rather
than the preset's measured modality set, with no note saying otherwise (#756).
A text-only declaration is not inert: bridgeVisionInput in src/router.mjs
reads exactly this field, so it spent a second model's quota transcribing every
pasted screenshot for a model that could read it directly, and the catalog told
Codex the route was text-only. Two things about the Coding Plan endpoint are
worth keeping straight, because they look like counter-evidence and are not.
Z.ai's Vision MCP Server is an addition for Coding Plan users, not a substitute
for a modality the endpoint lacks — its own page says a pasted image bypasses
it because the client "will by default transcode the image and call the model
interface directly". And the Uncheck Support Images line in the Cline and
tool-integration guides is written against glm-5.2, which is text-only; those
pages do not mention GLM-5.3-Flash at all. Z.ai publishes no modality table for
api/coding/paas/v4 in either direction, so the endpoint's acceptance of an
image is documented only at the model level. A route that claims a modality it
cannot serve trades a bridged read for a 400 on the whole turn, and it becomes
a bridge engine for other text-only models as well, so a future Flash route
on a new reseller is sourced from that reseller's own catalog rather than
inherited from this paragraph.
Console Go tokenizes independently of Codex and 400s when the prompt plus
completion does not fit any backend (Prompt too long … including the completion, later about 434983 tokens estimated against 262,144). That is
not quota and not a truncated tool-call repair. Do not classify it as
out_of_usage.
Do not compact below the unavoidable Desktop prefix. Live turns report
~88–108k cached input tokens from the tool list alone, so a compact threshold
under that floor fires after every skill read, kcr2 keeps a 1,024-byte source
excerpt, and the model re-reads the file in a loop. A route that measured a
completion reserve smaller than its advertised output publishes that reserve
as maxOutputTokens (OpenCode client limit.output) so a local
rendered + output > window check cannot refuse a prompt the hop would have
accepted. Do not copy a measured cap onto another provider's route without
that route's own evidence.
OpenCode's tokenizer can still count a thread above its advertised window when
Codex reports far less. Compact overflow may retry a larger-window model,
including a same-family OpenCode Go 1M route such as
opencode-go/glm-5.3-flash, without recording a provider cooldown. Compact
failures are translated to context_length_exceeded rather than echoing
LiteLLM's model-group wrapper. Ordinary turns still never swap on HTTP 400. If
nothing configured can hold the prompt, start a new Codex task. Do not copy
this hop onto turn failover.
Console Go also 400s when a single messages[N].content exceeds 2,500,000
characters. A live ImageGen function_call_output (1536×1024 PNG, 2.03 MiB,
2,707,238-character data URL) was stored by Codex, then the next turn failed
with messages[9].content exceeds maximum length of 2500000. The Chat
Completions image hoist keeps those bytes and still overflows. The OpenCode
hop replaces an oversized image payload with a labeled stub so the turn can
finish; it does not invent image bytes and does not copy this cap onto
OpenRouter or Cline. This is not context_length_exceeded and is not quota.
custom is a container, not a destination. It declares no baseUrl, no
credential, and no protocol; each of its models carries all three in an
endpoint block, and endpointForModel() is what every consumer asks instead
of reading provider.baseUrl. The loader refuses a container that declares any
of them, because two answers to "where does this go" have a silent winner.
- The endpoint descriptor is provider-shaped on purpose.
baseUrl,authMode,keyless, andcredentialmean exactly what they mean on a provider, soresolveProviderBaseUrland the whole credential chain accept one unchanged. Do not grow a parallel resolver: the moment the two implementations differ, one of them is the one nobody audited. - Identity is derived, never declared.
idis the model slug andkindis fixed, both injected at load; a fragment that set either could point one model's credential file and Keychain entry at another model's secret. The loader refuses a fragment that spells them. - Exactly one auth story per endpoint — anonymous, keyless, or a credential. Two would leave a silent winner; none would send an unauthenticated request to an address nobody vetted.
- The allowlist follows the address down. An
authMode: "anonymous"endpoint reaches a third party with no credential, so its address must appear inANONYMOUS_MODEL_ENDPOINTS, keyed by slug. Without that, adding a JSON file underconfig/custom/would be enough to send an operator's prompts to any HTTPS host on earth with nothing to authenticate them — which is the exact hazard the provider-level allowlist exists to prevent, one level down. Akeylessendpoint stays loopback-only for the same reason, and neither may declare abaseUrlEnv, because an environment override walks around whichever of the two rules applied. An endpoint that carries a credential needs no allowlist entry: the key is already the boundary. - Never defaulted.
defaultProviderIds()excludesper-modelalongsideanonymous. What the container holds is whatever somebody put in it, and at least one of those addresses is reached with no credential, so "enabling this sends prompts off-box" stays a choice a person made. - Nothing offers a key at the container level.
apiProvider()refuses it, the onboarding card is informational, andresolveProviderCredential()returns a persistent marker so selection, health, and the catalog still work. A key stored againstcustomwould be read by nothing. - Discovery refuses it. Discovery asks one endpoint what it serves, and a container is not an endpoint. Picking one of its models' addresses and reporting that as the provider's catalog would be worse than the refusal.
- Check in metadata you measured. A
custommodel ships with a verified context window, modality set, and effort ladder rather than the conservative defaultscurate-modelswould guess. An anonymous endpoint answers without a credential, so there is no excuse for inferring any of it.
Cursor is a client target, not a provider. The implementation was measured
against Cursor Agent 2026.08.25-3e8eec8 and Cursor App 3.16.17.
- Cursor Agent speaks Connect/protobuf.
CURSOR_API_ENDPOINTpoints the official binary at the router's caller-capability root. The adapter serves auth exchange, live routed model catalog/default,RunSSE, andBidiAppend, then re-enters/v1/responses.cursor-router-agentis the installed launcher and keeps the capability out of command arguments. - CLI tool execution stays in Cursor. The adapter maps read, bash, edit, and write calls onto Cursor's typed controlled-exec protocol, waits for the client's result, and resumes the model with that result. Cursor therefore remains the process that applies its permission mode and touches the local workspace; the router never executes a model-requested command or file mutation itself. The protocol is covered by wire-level tests and a live official-CLI proof. MCP declarations are not advertised because their separate exec shape has not received the same proof.
- Retail Cursor App is server-mediated. A loopback base URL is refused as
private-network access.
--target cursortherefore requires a stable public HTTPS origin whose tunnel forwards only to127.0.0.1:4214. The separate edge accepts only secret-bearing/v1/modelsand/v1/chat/completions, translates both Chat Completions and Responses-shaped bodies, and re-enters the canonical router path. The main router port stays loopback-only. - Cursor's override is global. Enabling it can also send Cursor-managed
model slugs to the custom edge. Routed models use collision-safe
codex_router/readable_name__digest/effortaliases because Cursor rejects a custom BYOK id containing a built-in model id before it reaches the edge. Cursor gives user-added models no stable native parameter metadata, so each supported reasoning effort is a separate picker row and the edge restores it asreasoning.effort. Turn the override off when returning to Cursor-managed models. - Cursor must be stopped for settings writes. The manager transactionally
updates the application-user JSON in
state.vscdb, preserves unrelated state, records its owned aliases, and reverses only its own changes. A live Cursor process may overwrite an external transaction on exit, so publish, republish, repair, and uninstall refuse while it is running.
Three findings from that work generalize to any CLI-backed provider, and cost real debugging to obtain:
- A CLI's
--stream-partial-outputmay not replace its message-level emitter. cursor-agent runs both, so a turn answering "391" emits twoassistantevents each reading "391"; concatenating every one of them streams "391391". Reconcile deltas against an accumulator rather than trusting that one emitter excludes the other. - Token usage came back camelCase (
inputTokens) from the live result while the shipped bundle's source spells it snake_case. Reading only the spelling the source suggests reported every real turn as zero usage, which the router records as a genuinely free turn. - Reading a vendor's bundled source narrows the guesswork but does not replace one real request. Every one of these survived a full green suite built on fixtures derived from that source.
local is a keyless provider: it serves from this machine, so there is no
credential to store, prompt for, or redact.
Local models are published as experimental, and the two roles are not
equally proven. Reading images is dependable: a local vision model transcribes
codes, numbers, and dates exactly, every run. Driving a Codex turn is not: the
same model has passed local-models agent-check and failed the identical check
minutes later. Do not quietly drop the label because a check happened to pass.
keyless: trueis only valid with a loopbackbaseUrland nocredentialblock; the loader rejects both violations. An unauthenticated provider pointed at the internet would send traffic off-box with no key.- Checked local models are published into the user-model overlay, the same
mechanism curated cloud models use. Do not add a second registry path for
them, and never write local models into the checked-in
config/tree -- they exist only on the machine that installed them. - A change to the checked set must rewrite both the Codex catalog and the
gateway route table (
refreshModelSettingsCatalog({ routes: true })). Writing one without the other is the drift doctor's "Catalog matches gateway routes" check exists to catch. - Checking, installing, and removing are three separate actions. Unchecking never deletes a download; removing requires explicit consent and unchecks the model so nothing stays selected once it is off disk.
- A local model advertises image input only when its family can actually read images -- the same standard the checked-in registry is held to.
- Codex drives every turn through tool calls, so a local model is publishable
only when Ollama reports the
toolscapability. Most vision models do not have it.local-models inspect <tag>reads the registry's chat template to answer that before a download, but a template mentioning.Toolsis necessary and not sufficient --qwen2.5-coder:7badvertises tools and still returns them as plain JSON text, which Codex cannot dispatch. Treat the flag as a filter and a real request as the proof. - New providers only reach a running router after the service restarts, since
the registry and gateway config load at startup. If the router starts
answering every request with
local_router_error, suspect a process still holding pre-change state rather than the new code.
- A model grants the capability, never a provider name.
/v1/embeddingsaccepts only a registered routed model whosesupportedEndpointsincludes/embeddings. Discovery metadata is untrusted and cannot add that field. - Endpoint-only models are not chat models. A model that omits its
provider's conversational endpoint must be
listed: false; otherwise the registry refuses it before the Codex picker can advertise a broken turn. - No chat adapter and no LiteLLM hop. The router rewrites the public slug to the gateway id and calls the internal API forwarder. The forwarder rewrites only that id to the upstream model and preserves the remaining embeddings JSON. Do not normalize the body as Chat Completions or Responses.
- The caller secret never leaves loopback. Query parameters on the capability URL are dropped, only a bounded request id may cross the internal hop, and the API forwarder replaces internal auth with the provider's own credential inside its established boundary. Both the router-to-forwarder and forwarder-to-provider hops refuse redirects so a 307/308 cannot replay the POST body onto another destination.
- Bound and cancel both directions; never retry. The public request and provider response default to 8 MiB limits, and client cancellation aborts both hops. A transport failure may occur after the provider billed the input, so automatic replay is not safe without provider-specific evidence.
- Every future endpoint is a new protocol review. This slice does not authorize completions, moderation, media, files, or batches. Each needs its own capability, wire contract, limits, cancellation, idempotency, and retry/stream-commit evidence.
devin-cli reuses the session devin auth login writes and spends that
account's ACU credits, the same shape as kimi-oauth and grok-oauth. What is
not the same is the transport, and that difference governs everything else
about it.
- There is no model API. Cognition documents a session API
(
api.devin.ai), not a chat API. The models answer only on Cascade —exa.api_server_pb.ApiServerServiceover Connect RPC at theapi_server_urlthe CLI stored. The schemas insrc/devin-proto.mjsare transcribed from the descriptor the shipped Devin client carries, which is the only published source for them. Treat every field number as evidence from one client version, not as a contract. Where that descriptor lives moved with the 3000.x series. The 2025.xdevinbinary embedded a descriptor set; 3000.10.31 is stripped of one, and the readable source is now the desktop client's generated protobuf-es field lists under@exa/chat-client(Devin.app/Contents/Resources/app/ node_modules/), which spell eachno:literally for the sameexa.api_server_pb,exa.codeium_common_pb, andexa.chat_pbmessages. Re-transcribe from whichever of the two the installed client actually ships, and record the version you read. - Unverified until someone with an account proves it. No maintainer has
run a live turn. The registry entry ships no models, the provider is
catalog-only, and nothing may claim support until
bin/devin-probe --live --toolspasses for a real account. Do not setmultiAgentVersion, do not check in model fragments, and do not describe this provider as working in README or release notes on the strength of the unit tests alone. - The unit tests prove translation, not the protocol.
protobuf-wire,devin-cli-turn,devin-cli-status, anddevin-connectcover the wire codec, the request mapping, the credential reader, and the envelope framing against fixtures. They cannot prove Cascade accepts the request. A green suite here is necessary and nowhere near sufficient. - The decoder must stay permissive and the credential reader strict.
Unknown protobuf fields are skipped, because the upstream adds them without
notice and a strict decoder would fail whole turns.
credentials.tomlis the opposite: it is read throughtoml-structure.mjs, so a duplicate key or a value the scanner cannot read plainly is refused rather than guessed at. - The router reads that file and never writes it. No code path may create,
move, copy, or delete another tool's credential file, and the token never
reaches a log, an argument, or an error message.
--no-discoverymust keep the file closed entirely. - Entitlement is the account's, not the registry's. Which models an
operator may run is decided server-side by
GetCascadeModelConfigsand team settings. Discovery asks; the registry never guesses. A model that appears for one account may be absent or refused for another. - Expect drift, and fail loudly when it happens. An unversioned transport
can change under a
devinupdate. When it does, the symptom is a Connectinvalid_argumenton every turn, not a subtle wrong answer — keep it that way rather than adding tolerant parsing that would mask a schema change. That is not hypothetical: it happened, and the shape of it is worth keeping. Devin 3000.x moved the CLI's model list fromGetCascadeModelConfigstoGetCliModelConfigs(#770). Both methods are still declared on the service —GetCascadeModelConfigsis the IDE's and the CLI no longer calls it — so a CLI-credentialed account was answeredinvalid_argumentrather thanunimplemented, and the router read that as its own encoding being wrong. It was not:bin/devin-probe's request-shape check passed in the same run, and re-reading every field the router writes against 3000.10.31 found all of them unchanged. A refused call whose encoding audits clean is evidence about the method, not about the bytes — check the method the installed CLI calls before touching a field number.test/devin-proto.test.mjspins the method names and those field numbers as literals, because a test that reads the constant it guards passes straight through a rename. Two rules make "loudly" mean something. First, a Connect error code must reach the router as the HTTP status the protocol assigns it: the sixteen-code table insrc/connect-stream-audit.mjsis the single source, imported by the client rather than restated, and a code that fell through to 502 would be read one layer up as a transient fault in the chain and sent again — which is precisely wrong forunimplemented, the answer to a service path or method name that drifted. Second, the client asks forconnect-accept-encoding: identityand refuses a frame that carries the compressed bit anyway (devin_compressed_frame). Compressed bytes are not protobuf, so decoding them produces an empty turn or an unactionable wire error; do not add decompression to this transport on the strength of a fixture, because no maintainer can test it against Cascade. - An operator who never curated a Devin model pays nothing for it. Unlike
the three forwarders that always run,
src/devin-cli-forwarder.mjsis spawned only whenMODELScontains adevin-climodel, so an unconfigured install starts no fourth child, binds no fourth port, and waits on no fourth health probe. The gate is deliberately the curated model and not the stored credential: a curated model is exactly what makeswriteLiteLlmConfig()emit aDEVIN_CLI_FORWARD_BASE_URLroute, and both are read from the sameMODELSarray on the same boot, so a live gateway route can never point at a port nothing bound. Gating oncredentials.tomlinstead would trade the forwarder's actionable 401 namingdevin auth loginfor a bare connection error. An unverified provider must stay free for the people not using it — apply the same rule to any future provider that needs its own forwarder.
- The config manager owns its marked root
openai_base_urlandmodel_catalog_jsonblock plus its markedmodel_providers.codex-routertable and, when the user has no concurrency preference, its marked[agents].max_concurrent_threads_per_sessiondefault. It may change the rootmodel_provideronly when the user explicitly enables either login-free mode or signed routing from a root-OpenAI configuration. Signed routing selects the dedicated, ChatGPT-authenticatedcodex-router-signedprovider; ordinary install, update, repair, and catalog refresh must never create or migrate that switch implicitly. Keep its state readable by the previous release and restore the prior provider exactly when it is disabled. Login-free mode may also select an enabled externalmodel; snapshot both previous values in protected router state and restore them exactly when the mode is disabled. - Preserve reasoning settings, profiles, projects, trust, MCP configuration,
features, and ChatGPT authentication. Preserve
modelandmodel_provideroutside those explicitly enabled routing modes. - A user-initiated macOS tray login-mode change may gracefully restart only the registered Codex desktop app. This does not authorize an installation task to quit Codex, and the tray must never force-terminate it.
- Do not kill unknown processes on ports 4200-4203, or on the Grok OAuth forwarder port 4208. The previous 4100-4103/4108 defaults remain valid only when explicitly supplied through the port environment variables.
- Do not print or read credential-file contents. Status commands report presence and source only.
- Treat the generated
/_codex-router/.../v1config path as sensitive local authentication. Never paste the complete managed base URL into chat or a public issue; use the redacted status or support-bundle output. - Do not delete retained keys, logs, backups, snapshots, or old state directories.
- Do not restart or quit the Codex App from the installation task.
An install made with --no-provider --no-discovery persists a discovery
kill-switch (discovery-mode.json, read through
src/discovery-mode.mjs discoveryDisabled(), overridable with
CODEX_ROUTER_NO_DISCOVERY=1|0). While it is set, the promise is absolute:
no provider credential file, macOS Keychain item, other CLI's OAuth or
session file, or Codex auth.json is read, no codex login status probe
runs against the real CODEX_HOME, and traffic gets a local
503 router_idle_no_provider instead of provider or native forwarding.
- Every new credential reader, sign-in probe, or session consumer must
consult
discoveryDisabled()before its first read or spawn and report "nothing found" rather than throwing. The guard belongs at the reader, not only at its current callers — call graphs move. - An explicitly written empty provider selection is a deliberate state, not
an error:
ensure-configuredreports it as idle, the doctor warns instead of failing, and installing or updating on top of it must keep working. - Never select a provider, re-enable discovery, or clear the marker on the user's behalf. Re-running setup without the flags is the only exit path, and it is the operator's to take.
- The account-aware
codex debug models(andmodels_cache.json, which is that same catalog written to disk) counts as an account read: the catalog capture and the doctor's staleness probe use onlydebug models --bundledwhile the switch is set.test/doctor-idle.test.mjsproves the bare form never spawns. - A corrupt
discovery-mode.jsondeliberately reads as discovery on — the opposite direction of the vision-bridge precedent, which fails toward off. There the risk is spending quota nobody approved; here the marker only ever exists on a machine that installed with--no-provider, where resuming reads finds no credentials to spend, while failing toward "off" on a credentialed install would silently blind every provider over one damaged file.test/discovery-mode.test.mjspins the choice.
src/codex-shim.mjs can put a wrapper named codex on the user's PATH so the
router is verified up before Codex starts. Installing a file that shadows a
command the user already has is a change only they may authorize.
- Never install it from
install.sh,install.ps1,doctor --fix, or any automatic repair. It ships behindmodel-router codex shim installonly. - Never write into a PATH directory outside the user's home directory. A shim
in
/usr/local/binchangescodexfor every account on the machine. - Never overwrite or delete a
codexthat does not carrySHIM_MARKER. Another wrapper there is somebody's deliberate setup, not debris. - Never edit shell startup files to put the shim on PATH. When no directory
ahead of Codex is writable, print the
export PATH=...line and stop. - Every failure path in the generated shim must still
execthe real Codex. A stopped router, a deleted checkout, and a gateway that never becomes healthy are all recoverable; acodexthat refuses to start is not. The wait is bounded byMODEL_ROUTER_SHIM_WAIT, andMODEL_ROUTER_SHIM=0bypasses the check.
test/codex-shim.test.mjs covers each of these. Do not weaken those tests to
land a change.
Follow mode ("Show tray: With Codex") decides when the tray is visible and, in
that mode, when the router runs at all. Codex ships both as a desktop app and as
an npm CLI, and only the app has a bundle identifier, so
NSRunningApplication alone is not an answer: a bundle-only check reported
"Codex is not running" for every terminal session, hid the menu bar item, and
stopped the router 30 seconds into the user's work.
Detection must cover both — bundle identifiers for the apps, and a process-table
scan for the CLI. Keep the scan in sysctl; it runs every five seconds for the
life of the session, and spawning pgrep on that cadence is a cost the check
does not justify. apps/macos/ModelRouterTray/Tests/HostProcessDetectionTests.swift
guards it.
apps/macos/ModelRouterTray/Resources/AppIcon.svg is the source and
AppIcon.icns beside it is the committed output of scripts/build-app-icon.sh.
Regenerate and commit both together after editing the SVG. Do not make
scripts/build-macos-tray-app.sh rasterize the icon: it would put sips and
iconutil on the critical path of every tray build for one asset that changes
almost never. Keep the SVG free of -- inside comments and of SVG filter
primitives — CoreSVG, which is what sips uses, rejects the first and silently
drops the second.
src/upstream-retry.mjs retries a native upstream request a bounded number of
times. One rule governs it, and breaking it corrupts responses rather than
merely failing them.
- A retry is legal only while nothing has been relayed. The loop lives
entirely before its callers touch their
ServerResponse, and thecanRetrypredicate (response.headersSent, checked again before every retry) is the backstop.copyResponseHeadersonly stages values withsetHeader, soheadersSentflips when Node flushes the head — on the first body write, or onend()for a bodyless upstream. Never move a retry aroundpipeResponse: an upstream that dies mid-stream has already delivered bytes, and replaying it appends a second response to a stream the client is reading.test/native-retry.test.mjsasserts the caller received the partial stream exactly once. - Only failures where an intermediary never obtained a response qualify: 502,
503, 504, Cloudflare's 520-524, and connect-level socket errors. Do not add
429 — it is rate limiting, its
Retry-Afteris relayed, and sleeping for the upstream's suggested delay is the hang the bound exists to prevent. Do not add 4xx, and do not add 500, where the origin ran and a repeat risks a second execution. - Keep the bound small. Codex retries roughly five times on its own and the
two loops multiply, so the router's share (2 retries, 250ms then 750ms) has
to keep the product a fast failure. A retry is also only started while the
request has been cheap so far — a five-second budget, because a 504 the edge
spent half a minute producing, or a connect timeout, must not be tripled.
CODEX_ROUTER_NATIVE_RETRIES,CODEX_ROUTER_NATIVE_RETRY_BACKOFF_MS, andCODEX_ROUTER_NATIVE_RETRY_BUDGET_MStune it;0disables it. - The request body must stay replayable: encode it into a Buffer once, above
the retry, so every attempt sends identical bytes under the identical
Content-Encoding. Never hand the loop a stream, and never re-runcompressedNativeBodyper attempt — headers and body would be free to disagree. - An abort stops everything at once, backoff included. Pass the caller's signal through to both the fetch and the wait.
- A silent retry is worse than no retry: it makes a flaky upstream look
healthy. The retry log line is never gated on
CODEX_ROUTER_QUIET, which a production LaunchAgent hard-sets, and the usage event carriesretriesso a turn the router rescued is distinguishable from one that never failed. Log the status or the transport error's own name and code — never a response body, and never the caller capability path.
src/model-failover.mjs decides when a turn whose provider reported it has no
usage left is rebuilt for a different model, and buildRoutedRequest in
src/router.mjs is what makes rebuilding it possible. The rules are narrow on
purpose; several of them exist because the obvious wider version is wrong.
- The same relayed-byte rule as upstream retries, for the same reason. The
failover branch lives before
pipeResponse, andnothingRelayed(response)is re-checked before every hop. Never move it aroundpipeResponse: a mid-stream swap grafts a second response onto a stream the client is reading, and duplicates any tool call the client has already executed. That second hazard is worse than the duplicated stream and has no equivalent in the retry path. - Only "your usage is gone" qualifies:
upstreamFailureKindreturningout_of_usage, a 402, or a 429 whoseRetry-Afterexceeds sixty seconds. Do not add 401 or 403 — a swap would hide the rejected credential that is the only thing worth telling the operator. Do not add 404 or 400, which are deterministic. Do not add 5xx:upstream-retry.mjsalready absorbs the transient shapes, and masking a provider outage costs an incident somebody would want to see. Do not lower the 429 threshold; trading a twenty-second wait for a cold prompt cache is a bad deal for the rest of the session. Entitlement failures are classified before quota ones and never swap, because "upgrade your plan" appears in both vocabularies and no other provider's quota makes a missing entitlement true. A local LiteLLM conversion of stored tool-call arguments (Failed to parse tool call arguments for tool … (Anthropic tool invoke)) is the same class of failure: it happens before any provider request, the argument body is echoed in the error and can match a quota phrase, and no other provider can make that history executable. Classify it before quota and never swap (#796). Claude Code excludes billing errors from its own fallback on the reasoning that they usually mean misconfiguration. That reasoning does not hold here: with thirty providers configured, an exhausted plan is a daily event and having somewhere else to go is the whole point of the install. - Never trade a quota error for a context error. A candidate is eligible
only when its
contextWindowcan holdestimateInputTokensof the bytes the turn was about to send. That estimate errs high by design, which is the safe direction. Falling from a 1M-context model onto a 262K one mid-session is a strictly worse turn than the one it replaced. - Never fail over inside the same provider family. Compare
canonicalProviderId: protocol variants share one credential and therefore one quota, so a sibling is guaranteed to fail the same way. Compaction is the one exception: a context-length 400 on/responses/compactmay retry a larger-window model, including a same-family sibling, without recording a cooldown. Ordinary turns still never swap on 400 and still never hop inside the family. See "OpenCode Go Messages hops must fit Console Go's own limits". - A cooldown is only ever a window the provider itself named. Derived from
Retry-After,cooldownUntil, or a wall-clock reset the provider stated in its own refusal body — Z.ai's Coding Plan sends "Your limit will reset at 2026-09-01 21:32:15" and no header, and without reading it an exhausted plan is re-attempted once per turn for the whole window. A bare stamp carries no zone, so it is resolved to the earliest instant any real UTC offset allows that has not already passed, and ignored outright when no offset can place it ahead. Waking early costs one refusal; waking late withholds a model the operator is paying for, and reading a zoneless stamp as local time does exactly that for anyone whose clock does not match the plan's. Never invented, capped at six hours, and cleared on that provider's next successful answer. A provider under cooldown is skipped before dispatch, which is the entire saving — so a cooldown that is wrong strands the operator's chosen model, and that is why nothing may record one from a guess.control failover resetand the doctor's report exist so a wrong one is visible and removable. - Bounded: at most two hops, a thirty-second budget for the whole sequence, abort-aware, stop on the first success. When nothing is eligible, return the failure the operator's own model gave — it is the one they can act on.
- Never silent, and never in the transcript. The log line is not gated on
CODEX_ROUTER_QUIET, which a production LaunchAgent hard-sets. Both attempts are metered and the serving row carriesfailoverFrom. Do not "helpfully" inject a notice into the stream: Codex replays assistant output as input, so a router-authored sentence comes back next turn as something the model believes it said. - The rebuild must start from the pristine payload.
buildRoutedRequestwrites to neitherpayloadnor the aged input, and this is load-bearing in two places.flattenNamespaceToolsonly recognizestype: "namespace"items, so a second pass over already-flattened tools returns an empty namespace map — plausible tools with no way to map the model's calls back. AndcarryReasoningThroughInputreplaces reasoning items in place, so a responses-native second pass would find them already gone. The input array is copied before it is rewritten — and copied only when it is an array, becauseinputis equally legal as a bare string and spreading one produces an array of single characters, which reaches the provider and still reads as a 200.test/router-timing-log.test.mjscaught exactly that. selectedConfiguredListedModels()is not cheap: it probes every provider's credential synchronously and spawns/usr/bin/securityper keychain service on macOS. Call it only once a failure or a cooldown is already known, never on the happy path.- Coverage lives in
test/model-failover.test.mjs(classifier, ranking, cooldown store) andtest/model-failover-router.test.mjs(end to end, including that the failed attempt's bytes never reach the client). A change to the trigger set, the ranking, or the cooldown rules needs a test there.
Not implemented: the native ChatGPT tier. Falling back to the signed-in
ChatGPT plan is deliberately absent. It is not a body swap but the other branch
entirely, and it crosses the routed/native boundary this file governs
elsewhere — encrypted_content rewriting, the compatibility relay, the
collaboration envelope. Those rules require live marker-return probes through
every installed routed agent before a change ships, so the tier cannot be added
from the test suite alone. Add it with those proofs or not at all.
A provider that finishes a tool call with unterminated or otherwise invalid
JSON arguments produces an item the client cannot execute. Codex stores it
anyway, replays it on the next turn, and every later request on that thread
then fails — locally on Anthropic/Messages routes, as a generic provider 400
on OpenAI-compatible ones (#797). LiteLLM's _attempt_json_repair only closes
unmatched brackets and correctly refuses a string that was never terminated;
closing it here would invent command bytes.
- Fail the completed call, never repair it. When a routed
function_call_arguments.done,output_item.done, or non-streamingoutput[]carries non-empty arguments thatJSON.parserejects, withhold the whole call (opening item and deltas included) and fail that attempt. Closing an unterminated string would invent command bytes. Empty arguments stay allowed (the call may still be streaming). Custom tool calls andpreserveRawArgumentscodec items keep their freeform text for the native hook. Duplicate keys still parse and are not this failure. If nothing has been relayed, retry once on the same path as an empty completion. After that retry, or if a byte already left, fail the turn locally so Codex cannot store the item. jsonArgumentsAreUnambiguousstill only gates rewriting. The namespace relay's#unsafeSseFramepass-through is not permission to store an unusable call. The refusal lives insrc/invalid-function-call.mjs, after the namespace transform (so restored names appear in the error) and before the empty-completion guard.- A stored invalid call is refused locally before any provider request.
Name the tool, call id, and input index. Do not echo the argument body.
Do not attribute the failure to the provider. Coverage lives in
test/invalid-function-call.test.mjs, the conversion cases intest/error-translation.test.mjsandtest/model-failover.test.mjs, and the router cases intest/model-failover-router.test.mjs.
Command Code sells one catalog behind two surfaces, and the documented one is
an entitlement rather than a credential. POST /provider/v1/chat/completions
and /provider/v1/messages are the published Provider API; an account below
the Provider plan signs in, mints a real key, runs the official CLI all day,
and is still answered
403 {"error":{"code":"upgrade_required","message":"Your Go plan doesn't include API access…"}}. POST /alpha/generate is the route the command-code
CLI itself uses for every turn it takes, and it is not plan-gated. Serving the
cheap plans means speaking that route.
- The fallback is a route change, never a provider split.
commandcodeandcommandcode-messagesstay one family with one credential and one catalog, exactly as the provider checklist requires. What changes is where the turn is sent, which is why this lives insrc/api-forwarder.mjsbeside the Copilot replay rather than in a forwarder of its own. - Only the entitlement refusal may move a turn.
isUpgradeRequired()insrc/commandcode-plan.mjsdemands a 403 and theupgrade_requiredcode (or its message). A timeout, a 500, or a rate limit says nothing about the plan, and reading one as a refusal would quietly move a paying Provider-plan account onto its coding-plan credits. Any other 403 is relayed with the provider's own message. - It is legal because nothing has been relayed yet. The refusal arrives before the first response byte reaches the caller, which is the same boundary the upstream-retry and model-failover rules draw. A fallback after a relayed byte would not be legal and is not attempted.
- The verdict is remembered per credential, not per process.
commandcode-plan.jsonstores a SHA-256 fingerprint of the key — never the key — so a new key after an upgrade re-probes, and a six-hour window re-checks a plan that changed under the same key. A success is written only when that window came due, so a healthy account does not rewrite state once per turn. - The envelope is reverse-engineered, so re-derive it rather than guess.
Command Code publishes no reference for
/alpha/generate. The shapes insrc/commandcode-generate.mjsandsrc/commandcode-stream.mjscame from the shipped bundle at$(npm root -g)/command-code/dist/cli.mjs(v1.14.1) and were confirmed against the live gateway. Three traps are load-bearing:configis schema-strict and every field is required,memoryis a string and not an object, andparams.messagesis the Vercel AI SDKModelMessage[]schema — not Anthropic blocks and not OpenAI tool messages. The response is newline-delimited JSON despite thetext/event-streamcontent type, its blocks interleave, and the trailingtool-callevent keys ontoolCallIdwhere every incremental event keys onid. - An empty
systemfield is not "no system prompt". It is a cue to splice in the Command Code agent's own preamble. Measured against the live gateway, the same one-line turn cost 92 prompt tokens with a system prompt and 7,637 without, and the model spent them being told it was Command Code with Command Code's tools. A turn carrying none gets a neutral one. - Billing differs even though the models do not. The Provider plan pays
as it goes; a coding plan spends plan credits against 5-hour and weekly
window caps and per-model allowances. That is what the
planNoteis for, and it is why the note stays on the registry entry now that the plan no longer blocks access outright.
LiteLLM 1.96's Chat Completions to Responses bridge copies the request's
instructions and whole tools array into both response.created and
response.in_progress (_default_response_created_event_data in
litellm/responses/litellm_completion_transformation/streaming_iterator.py);
its response.completed does not echo them. Measured against the pinned
LiteLLM offline, with no provider quota: a 300-tool list and a 16 KiB
instructions string make response.created and response.in_progress
409 KiB each, while response.completed stays at 716 bytes and carries
tools: []. A stream repair that meets a frame over any of its pre-commit
budgets releases the bytes raw and switches itself off for the rest of the
response, so a small budget disables the repair in exactly the sessions with
the most tools, and no mock gateway with a bare envelope shows it.
- The frame bound is 10 MiB, matching
MAX_SSE_FRAME_BYTESinsrc/namespace-relay.mjs.DeepseekToolMessageCompatTransformandTranslatedToolMessageCompatTransforminsrc/deepseek-tool-message-compat.mjsuse it, so the blank-message cleanup and direct DeepSeek's reasoning-bridge repair survive a large prelude. - Every per-frame budget moves with it. The strict JSON scan also counts object members and key code units per frame, and tool schemas are member-dense: with only the byte bound raised, the 8 KiB member budget still failed the same frame open. Both scan budgets are derived from the frame bound at the density the 256 KiB bound allowed. The 64 KiB candidate hold budget is deliberately unchanged, because the prelude is relayed as soon as it parses and is never held.
- A bound moves where failure happens, never what it does. An over-budget frame is still released byte-identical, after any capture held ahead of it, and the repair stays off for that response. Never truncate, re-serialize, or skip part of a frame to fit a budget. Accumulator storage grown past an ordinary event is released once its frame is taken, so one large prelude does not pin that capacity for the whole stream.
- The scan costs CPU in proportion to the frame, so keep it bulk. A pre-commit frame is decoded, uniqueness-scanned, and parsed synchronously on the router's event loop. Measured on the 409 KiB prelude that prompted this: 3-6 ms, scaling at roughly 7 ms per MiB to 53 ms at 7.5 MiB. The frame scanner reaches that by jumping between line feeds rather than walking every byte -- the per-byte loop it replaced cost 49 ms on an 8 MiB frame on its own. Raising a bound again, or adding another pre-commit parser to the routed path, needs a measurement at the new bound rather than only a passing test. Measure uncontended: on a loaded machine these numbers inflate by more than an order of magnitude and invite a fix for a cost that is not there.
- Fixtures for this path echo a large, dense tool list. "router keeps
DeepSeek message repairs behind a Desktop-sized prelude" in
test/routing.test.mjsasserts that its echoed prelude crosses both old budgets before it asserts either repair; the small-limit cases intest/deepseek-tool-message-compat.test.mjshold the fail-open semantics.
Only direct provider deepseek with upstream model deepseek-flash uses the
existing API forwarder's native /responses route, including compaction.
Legacy aliases and resellers retain Chat. LiteLLM 1.96's unknown-model fallback
removes upstream streaming and synthesizes SSE; setting its protocol alone is
insufficient. Keep credentials, transport, usage, cancellation and pre-byte
retry rules on the shared path.
- Replay native reasoning once as a
reasoning.contentarray ofreasoning_textparts; convert legacy summaries there, never into visible messages. Preserve typed image/tool outputs andinput_image.file_id, without uploading files. - Convert recovered
agent_messagehandoffs withagentMessagesAsUserMessageson ordinary and compaction requests: task text in an unsupported item type is not delivered. Flatten namespaces/deferred tools through the existing adapters; retain native top-levelapply_patchand bridge other custom tools. Restore exact namespace/name identities, including plain-name collisions. - Codex can flatten native and MCP function/custom declarations before sending
them to a routed provider. Restore namespaces only for live, directly exposed
tools identified by the request's canonical turn metadata. Rewrite declarations
only on routes that flatten tools. Responses-native routes that skip
flattening send the client's declarations unchanged, but build their response
lookup from the restored inventory so a returned flat call still reaches Codex
under its
{namespace, name}identity. Restore before app expansion and normal flattening so client schemas, custom formats and collaboration model constraints use the existing adapters. Never infer tools from name prefixes or create declarations from metadata alone; ambiguous and ordinary-name collisions remain unchanged. - GLM thinking, legacy DeepSeek thinking, Hy4 Preview (
hy4-reasoning) and Command Code's DeepSeek Flash Chat route carry reasoning through LiteLLM as assistantthinkingparts, restored by the forwarder toreasoning_content. An interleaved-thinking model that instead sees its past reasoning replayed as visible assistant text moves new thinking into the answer channel and loops on its last progress note (Hy4 on opencode Go, 12 September 2026: 2, 4, 5, 8, 16 copies per message). The rule belongs to the upstream model, not to the reseller or its request profile, sosrc/chat-reasoning.mjsalso keys it on the upstream family (DeepSeek, GLM-5.x, Kimi K3, MiniMax M3, Tencent Hy3/Hy4) for the Chat Completions resellers it lists. Add a family only with evidence the vendor expectsreasoning_contentback, and a reseller only after a live probe shows the route returns reasoning and accepts the echo-back; Anthropic-protocol variants never enter it. Do not special-case the carry instead. A Chat Completions route outside the contract drops the reasoning from the carry rather than replaying it asoutput_text: the visible-text replay is the loop trigger named above, and dropping asserts nothing about a vendor'sreasoning_contenthandling, so it needs none of the evidence a family entry does. That path was inert until #708 widened the reasoning-lifecycle repair to everyopenai-protocol provider and Codex began storing reasoning items for these turns (#755). Adding a family is still the better outcome where the evidence exists — dropping keeps the model coherent, but it does lose the thinking. This is a routed-path rule only, and it does not generalise: the native backend faces the opposite constraint, since it rejects a foreign reasoning item outright and never reads a reasoningsummary, so visible text can be the only replay that survives there. Weigh the two separately rather than making either the house style. Remove only successfully carried reasoning runs so plaintext cannot also become a user message. Do not mutate source items or change other native Responses routes. Keep this policy shared between hops without applying direct DeepSeek sampling parameters to resellers. Command Code's schema-strict/alpha/generatefallback remains separate.
Regression coverage lives in test/deepseek-responses-routing.test.mjs,
test/namespace-relay-custom.test.mjs, test/chat-reasoning.test.mjs and the
Chat replay cases in test/routing.test.mjs. For the optional offline proof,
set MODEL_ROUTER_TEST_LITELLM_PYTHON to a Python interpreter with the repository's
pinned LiteLLM and run node --test test/chat-reasoning.test.mjs. It uses only
loopback services and synthetic credentials, with duplicate negative controls.
See the provider's Responses contract.
Codex decides when to compact from the input_tokens each response reports, so
a provider that answers a large prompt with an explicit zero disables
compaction entirely and the session runs until the provider rejects the turn.
The router replaces that number on the way to Codex. The rules are narrow on
purpose.
- Only an explicit zero is replaced, and only on a routed response whose request the router measured as large. A missing usage block, a missing prompt field, and any positive count are all forwarded untouched, so a provider that reports correctly never sees this path and the substitution stops by itself the moment the upstream recovers. Do not widen the predicate into "the number looks wrong".
- The estimate errs high. Compaction sits below the provider's hard limit (900,000 of 1,048,576 for the affected models, a 14% margin), so an estimate that lands low still lets the turn die, while a high one only compacts sooner. Do not "improve" the ratio toward accuracy without re-checking that margin, and do not add a tokenizer dependency or download for it.
- Telemetry keeps what the provider said. The usage event records the
reported counts verbatim and adds
estimatedInputTokensbeside them; the log line names the substitution. Never fold the estimate intoinputTokens— a run of estimated turns is the evidence that the provider is still broken, and an overwritten field would read as a recovery. - The response body is otherwise byte-identical, including bytes that are not
valid UTF-8: the rewrite path forwards the original buffers and re-encodes
only the one
data:line it replaces, preserving framing and terminators. Do not reintroduce a decoded-text passthrough, which silently rewrites a malformed byte to U+FFFD. - If a provider is ever added that reports prompt tokens excluding cache hits, a fully cached turn could report a truthful zero. Substituting there is still right for compaction — cached tokens occupy the context window — but say so in that provider's registry work rather than discovering it from a surprised user.
- Regression coverage lives in
test/response-usage.test.mjsand theprompt-token estimatecases intest/routing.test.mjs. A change to the predicate, the ratio, or the telemetry needs a test there. - Actual image references may use a documented provider/model token bound instead of charging their base64 bytes as text. Direct DeepSeek Flash and its retired Flash aliases use the documented 1,024-token maximum per image (verified 10 September 2026). File references still contribute image tokens. Do not extend this bound to resellers or other models without evidence, or discount pasted image JSON, tool schemas, or unknown content shapes. The estimate must not mutate or upload the caller's image data.
Grok OAuth can reason for minutes with nothing to send, and every layer between
xAI and Codex has its own idle limit. The router's post-prologue stall guard
(CODEX_ROUTER_GROK_STREAM_STALL_MS, ten minutes) is the one meant to decide.
- Every transport hop outlasts the guard.
src/grok-stream-timeouts.mjssizes the router's Grok gateway pool (its headers and body bounds), the gateway's per-deploymentstream_timeoutand non-streamingtimeout, and the forwarder's xAI pool from that one value. Compaction is a hop too: it is not streamed, so its headers arrive only after the whole generation, and it uses the same pool and deployment bound as a turn. A new hop on the Grok path takes its bound from there. The shared Undici pool keeps its default for every other provider. - Codex's idle timer is fed a lifecycle event, never a comment. Codex
abandons a stream after five minutes without a parsed data event and sends
the whole turn again, which bills the provider twice; an SSE comment or a
WebSocket ping does not reset that timer.
src/responses-heartbeat.mjsrelaysresponse.in_progresscarrying only the response's id, model, and creation time -- only afterresponse.created, only at an event boundary, never after a terminal event, and as the last pipeline stage so no router transform parses it. It never carries text, reasoning, or a tool item: the rule against router-authored transcript content stands. - Only Grok OAuth routes get either. Other providers keep their stall bound and receive no heartbeat. Widening either needs the same proof: a router test that a silent stream survives, and one that another route is unchanged.
- Coverage lives in
test/grok-stream-timeouts.test.mjs,test/responses-heartbeat.test.mjs,test/fetch-transport.test.mjs, and the Grok cases intest/empty-completion-router.test.mjs.
Native models label every assistant message commentary or final_answer.
Codex folds commentary into its "Worked for ..." group, renders the final
answer below it, and finds a thread's answer with
json_extract(item_json, '$.phase') = 'final_answer'. Routed providers send no
label, so src/message-phase.mjs assigns one.
- The rule is the one native turns follow, read from item order. A message
that a tool call or another message follows is commentary; the last message
of a
response.completedorresponse.doneis the final answer, even when reasoning items follow it. Reasoning decides nothing: labelling that message commentary left the turn without an answer, and Codex's thread-history fallback reads only messages whose phase is null.response.failed,response.incomplete(a stream error to Codex), anderrorleave the last message unlabelled. Never infer the label from the text. - A provider's phase always wins. Only an absent or null phase is filled, so a Responses provider that already labels messages passes through unchanged.
- Hold one message frame, briefly. Only the message's
output_item.donewaits, until a tool call or message opens or the response settles. A reasoning item in between is held with it, deltas included, inside the 1 MiB bound; text deltas before the done frame stream live, and everything held is replayed in order. A failed, incomplete, errored, or[DONE]-terminated response, a clean end of stream without a terminal, invalid UTF-8, or an exhausted hold bound releases the original bytes unlabelled. An upstream error does not:pipeResponsedestroys the stage, a destroyed stream cannot push, and the held frames are lost with the stream -- as they are in the item-lifecycle normalizer and the other holding stages -- before the router ends the body withlocal_router_stream_failed. - It is metadata, not transcript, but Codex replays it. It adds no text and
costs no model tokens. Codex stores the label and sends it back with the
message on every later turn. Chat-translated routes drop it: LiteLLM
rebuilds chat history from role and content, Anthropic-protocol routes are
rebuilt the same way, and Antigravity builds its messages from text and tool
calls. Providers with
protocol: "openai-responses"receive it. Theiropenai/responses/<model>deployments pass input items through, as donormalizeRoutedInput,normalizeResponsesRequest,deepSeekResponsesInput, and WebSocket continuation state. That covers Meta, OpenCode, OpenCode free, GitHub Copilot, and DeepSeek Responses. OpenAI's Responses schema defines the field, and native passthrough keeps it. Local rollouts show OpenCode's Responses surface accepting it (Muse Spark emits it itself); the other built-in Responses providers are unverified. An operator-configured--adapter openai-responsesendpoint is an unknown validator, sosrc/api-forwarder.mjsomitsphasefrom its message input items withwithoutInputMessagePhase. A built-in provider shown to reject the field needs the same narrow input strip and a test, not a change to the label. - Routed streams only, after the item-lifecycle normalizer, so items are
already sequential. Coverage lives in
test/message-phase.test.mjs, the routed case intest/namespace-relay-routing.test.mjs, and the generic Responses input case intest/generic-routing.test.mjs.
Tencent Hy4 Preview carries a tool-call syntax of its own,
<tool_calls:NONCE><tool_call:NONCE>name<arg_key:NONCE>k</arg_key:NONCE><arg_value:NONCE>v</arg_value:NONCE>....
Serving stacks disagree about it: the agent-check tool probe recorded
commandcode/hy4-preview failing on exactly this markup while
opencode-go/hy4-preview passed the same probe minutes later, and the
opencode-go route then leaked it intermittently mid-session. Nothing reaches the
tool_calls array on a leaked turn, so LiteLLM's chat-completions -> Responses
bridge relays a reasoning item followed by an assistant message with empty
content and no function_call. Codex ends the turn there and writes
task_complete with last_agent_message: null: the client shows its
"Worked for ..." group and no answer at all, with no error anywhere.
src/leaked-tool-call-recovery.mjs parses that markup back into real
function_call items.
- Only the model's own calls. The transform relays a call the model wrote; it never authors one. A span that is unterminated, carries a mismatched nonce, names something that is not a tool name, or fails to parse for any other reason is relayed verbatim and recovers nothing. A stream without the markup is passed through byte-for-byte, and invalid UTF-8 disables rewriting for the rest of the stream.
- The nonce is read, never assumed.
6124c78eappears in every capture to date, on both routes, but it is taken from the opening tag and the closing tag must repeat it. Do not hardcode it. - One item contributes its calls once. The same span arrives on the delta
channel, in the
.donesnapshot, and in the stored item; recovery is keyed by output index so the call is emitted a single time. The summary and content channels are two renderings of one item's thinking, so they hold separate span streams and the fuller reading wins -- sharing one stream between them made the second channel look like a genuine extension of the first and recovered, and executed, the call twice. - A leaked argument value is text. It is read as JSON only when its text is
exactly its own JSON form, so a declared
20000ortruesurvives while a shell command, a path, or0755stays the string the model wrote. - Hy4 routes only, before the namespace transform, so a recovered
flattened
mcp__server__toolcall is restored like any other, the empty- completion guard sees content, and the phase labeller reads the blank message as commentary instead of a final answer. Native streams gain no stage, and neither does any other routed family: this is Hy4's own syntax, and scanning every routed provider's text for it would turn prose that merely quotes the markup -- a diff, a web page, this file -- into executed tool calls.usesHy4NonceMarkupis the gate (usesLeakedToolCallRecoveryis its name at this call site); widening it pasthy4-previewreopens that injection channel. The reasoning-tag stripper reads the same gate for the markup's reasoning delimiter,</think:NONCE>(#654): it strips the suffixed spelling and treats an orphan close -- one whose opening tag never arrived -- as the end of leaked reasoning, dropping the text in front of it. That reading is destructive, so it stays behind the gate and behind the suffix; a bare</think>keeps its prefix on every route. Coverage lives intest/leaked-tool-call-recovery.test.mjs,test/reasoning-tag-stripper.test.mjsand the leaked-channel case intest/namespace-relay-routing.test.mjs. - A span is scanned once, not re-scanned per delta. The capture is held
unjoined with a closing-tag overlap because
_transformis synchronous: re-scanning one growing string re-flattens the rope every delta, and a 1.25 MB unterminated span blocked the event loop for 21.5 s against 0.8 s for the same bytes with no span open. The capture bound is 4 MiB.
LiteLLM 1.96's Chat Completions to Responses bridge opens the assistant message
first, then streams response.reasoning_summary_text.delta under a fresh
hashed rs_… id per delta (or the message's id), with no reasoning
output_item.added or reasoning_summary_part.added and on the message's
output_index. Codex drops deltas that belong to no open item, so reasoning
never rendered and no reasoning item was saved to the thread. That held for
every Chat Completions route (measured on commandcode/hy4-preview and
opencode-go/deepseek-v4.1-flash), not only Grok.
- One repair, scoped by protocol.
reasoningSummaryCompatTransforminsrc/grok-reasoning-summary-compat.mjsattaches the lifecycle repair to every provider whoseprotocolis Chat Completions (openai, the default) or Anthropic Messages (anthropic). LiteLLM still setsuse_chat_completions_api: truefor Anthropic routes, soopencode-go-messagesandcommandcode-messagesarrive as the same message-first hashed summary stream. Directdeepseekis excluded becauseDeepseekToolMessageCompatTransformalready repairs its bridge, andopenai-responsesproviders skip this bridge. Widening it to another protocol needs a captured stream from that protocol first. LiteLLM can also close the assistant message withcontent_part.donereasoning_textbeforeoutput_text.done. That close is thinking leaking onto the message part, not the end of the answer: rewriting it tooutput_textwhile text is still arriving truncates the visible reply (a live identity answer stopped mid-sentence). Drop the premature close and only rewrite one that follows a grownoutput_text.done. The drop must still apply when noreasoning_summary_text.deltahas opened the repair — a live ImageGen turn streamed the prefix, closed asreasoning_text, thenresponse.completedwith 21 tokens, and Codex stored that cut asfinal_answer. Hold the prefix untiloutput_text.donewhose text grew after the close; a done snapshot that is still the leaked prefix (the ImageGen turnaround that stopped at(no reference) is truncated thinking too. LiteLLM 1.96's finish sequence also emits that done snapshot before thereasoning_textclose, which storedThe skill is loaded. This is a single concept-sheet generation: a GTA-style AAAasfinal_answer. Hold the done event until the part close; if its text is the thinking or a prefix of it, withhold so empty-completion retries. A held done that LiteLLM then closes asoutput_textis still truncated when the snapshot is a mid-clause cut (I'll use the image generationafter the 14:12 empty-completion retry). Punctuated answers stay answers. A single token with no whitespace (CODEX_ROUTER_STREAM_OK) is a finished marker, not a mid-clause cut. If the stream completes without a grown done, withhold the message so empty-completion retries or fails rather than succeeding. - Grok's gateway-error wording stays on Grok OAuth. Only
grok-oauthreplaces an untyped LiteLLM error envelope with the fixed local error. Other routes relay that envelope byte-identical, after closing the reasoning item asincompleteand releasing any message the repair was holding. - The pre-commit frame bound is 10 MiB, like the namespace relay's.
LiteLLM echoes the request's
instructionsand fulltoolsarray inresponse.created, and a Codex Desktop tool list exceeds 256 KiB. A smaller bound releases that frame raw and disables the repair for the whole stream, which no mock gateway with a tiny prelude reproduces. Any fixture for this path must echo a Desktop-sized tool list. - Canonical streams pass byte-identical. Coverage lives in
test/grok-reasoning-summary-compat.test.mjsand the Grok and Desktop-sized Chat Completions router cases intest/routing.test.mjs. The regression oracle is a live Codex turn whose rollout records areasoningitem.
- A normal
/responsessmoke test does not cover Codex collaboration. Current model-generated subagent tasks and messages can arrive as nativeencrypted_content, with visible text ending atPayload:. External models cannot read that payload directly. - The compatibility relay must remain signed-in-only and fail closed. Send its
native request with
stream: true, accept SSE by body framing as well as content type, recognize paddedgAAAA...=ciphertext, and treat non-Fernetencrypted_contentfrom an external parent as plaintext. - The same rule applies in reverse, and it is not conditional on the envelope.
A routed subagent cannot mint an OpenAI token, so Codex stores its readable
handoff under
agent_message.content[].encrypted_contentwhatever the surroundingMessage Type:rendering looks like. Before forwarding to a native Responses endpoint —/responsesand/responses/compactalike — rewrite every non-Fernetencrypted_contentpart of anagent_messagetoinput_text; that schema accepts onlyinput_text,input_image, andencrypted_content, sooutput_textis not a fallback. Classify on the ciphertext format (thegAAAAAFernet prefix over base64url with no whitespace), never on whether the plaintext looks readable, and forward a value that passes byte-identical. Do not gate this on a router-written sentinel: the router never authors these items, and a marker would strand the already-broken conversations this recovers. - The native endpoint also checks an optional item
idagainst the prefix it mints for that item type:fcfor function calls,ctcfor custom tool calls,msgfor messages. Codex saves and replays the IDs routed providers minted (call_...,tool_...,chatcmpl-...), so a conversation moved back to a native model fails with "Expected an ID that begins with 'fc'" once one is in its history.normalizeNativeInputomits only a stringidwithout that prefix, on/responsesand/responses/compactalike.call_idstill pairs each call with its result, a native-only history is forwarded unchanged, and routed requests keep their IDs. Thenative replay omits incompatible item IDscase intest/routing.test.mjsholds both sides. The custom→function bridge is the other direction: Console Go requiresfcon function-shaped items, and a rewrittencustom_tool_call_outputstill carriedctco_…(#780).bridgeCustomToolsomits a non-fcstringidon the rewritten call and output;call_idstill pairs them. A native-mintedfc…id is kept. Do not mint a substitute id. - Never log relay response bodies, decrypted task text, or exception messages that can echo either. Regressions require fragmented/mislabeled SSE tests and real marker-return probes through every installed routed agent plus a same-thread follow-up.
- A test that isolates the state directory must isolate
CODEX_HOMEwith it.MODEL_ROUTER_STATE_DIRandCODEX_ROUTER_STATE_DIRdo not redirectCODEX_AGENTS_DIR, which is$CODEX_HOME/agents, andsrc/catalog.mjsprunes that directory to the exact registry-v2 routes the state it just read leaves enabled and visible. Point the state at a scratch directory while inheriting the real home and the run deletes the operator's own routed agent definitions — every model selected in the real state can disappear from that scratch publication — whilemulti-agent-settings.jsonandmulti-agent-proofs.jsonlive in the scratch state. The operator sees subagents reset after an unrelated command.test/state-owner.test.mjspins this: no test file may spawn the catalog without settingCODEX_HOME.
dsh-config-manager.mjs publishes routed models into a harness that is already
there. On a machine without one that assumption is a manual npm install -g the
user has to find in the docs, so src/dsh-install.mjs owns the other half.
control harness setupinstalls@deepseek-ai/dshglobally ifdshis absent, then publishes.control harness statusreports without touching anything. The tray's Settings row drives the same command.- Global, not
npx. The harness's own README documentsnpx @deepseek-ai/dsh web, which refetches per run and leaves nodshbehind — and an npx process is invisible topresence-state.mjs, which has to be able to see the client to keep the router up for it. - Never folded into
apply,enable, or a repair path. It installs a third-party package over the network; that must be something a user asked for in as many words, not a consequence of something else. - Node is checked before npm is reached. The package declares no
engines, so a stale runtime otherwise fails at first boot with a syntax error from insidenode_modules. Compare major and minor numerically —22.9sorts above22.19as a string. - Install then publish, with no rollback between them. A publish that fails leaves an installed harness, which is where a retry wants to start, and the publish is idempotent so the retry is a re-run of the same call.
npm-global-install.mjsholds the npm mechanics for both this and the provider CLIs. One copy, because the details that took a debugging session to get right — the PATH a spawn inherits, where npm drops binaries per platform, which line of npm's output is worth showing — are exactly what drifts.- Native GPT models are published only while
codex-native-session.mjsreports both explicit shared-plane authorization and a usable session: they need a ChatGPT session, and a harness request carries none of its own. Onechatgpt-session enableapplies to every local client for this OS user; they are withheld again the moment authorization is revoked or the session is missing or expired. The count the button reports is the routable set, not the picker.
src/dsh-web.mjs starts and finds the browser UI, so the tray's button can be
Open site once there is a site to open.
- Adopt, never collide. The harness binds a fixed port rather than picking a
free one, so a second launch exits with
EADDRINUSEand takes the click with it.startDshWebprobes first and returnsstartedNow: falsewhen something already answers. - Stop only what this router started, the same rule
ollama-runtime.mjsfollows. PID plus process start identity are persisted together and both must match, because PIDs are reused;src/process-identity.mjsholds that check for both callers. - The probe asks whether the port answers, not what is behind it. A 404 from the harness's own router is a running harness, and fingerprinting somebody else's HTML to be surer would be worse than the ambiguity.
- The port is a setting (
MODEL_ROUTER_DSH_WEB_PORT), not a constant.dsh web --portexists, and a user who moved theirs must not be sent to a dead URL. - Setup does not start the UI. It already installs a package and writes another program's configuration; adding a server launch makes one click three consequential things, and the last is the one the user can do themselves a moment later. Starting is its own button, so a republish never puts a browser window on screen that nobody asked for.
control --jsonmust carry the web-aware snapshot. It is what the tray polls, and the cheap synchronous variant reports nowebat all, which reads as "stopped" and offers to start a harness that is already serving.- Stopping and disconnecting are different questions, and the row asks whichever one currently costs something. While the harness is resident it holds a Node process and its plugin tree in memory -- ~184 MB measured -- so the secondary action is Turn off, which stops the process and leaves the route published. Once nothing is running, the only thing left to undo is the integration, so it becomes Disconnect. A harness this router did not start is never signalled; the row says where it came from instead.
- Turning a client off is not a reason to tear the plane down.
bin/disableremoves the service only onceinstalledTargets()is empty; disabling the harness while Codex is still published used to uninstall the LaunchAgent and stop Codex working too.control harness disconnectis the tray's path and never touches the service at all. - The default model is the user's. Restore only over a default this router
wrote — the harness's own Models page writes the same key, and a snapshot
taken before their choice is not a licence to undo it. With no snapshot but a
router-owned default, remove the key rather than leave the harness pointed at
a provider the same uninstall just deleted. All three cases are covered in
test/dsh-config-manager.test.mjs.
opencode, pi, omp, Command Code, and Hermes Agent all offer the same thing: a
user-owned configuration document with a mapping of custom providers in it.
That is why they share src/routed-harness-*.mjs rather than getting five
near-identical managers. routed-harness-catalog.mjs is the part that
genuinely differs and it is data; routed-harness-document.mjs edits one key
of somebody else's file; routed-harness-manager.mjs is the publisher.
- The wire is one the router already serves, never a third one. The caller
endpoint answers
/v1/responsesand, behind the/anthropicleaf, the Anthropic Messages API. Nothing else — there is no/chat/completions. So opencode, pi, and omp declare a Responses provider, while Command Code and Hermes take the Anthropic surface withclaude-model-id.mjsids. Command Code's BYOK wire is Chat Completions or Anthropic Messages and Hermes'scodex_responsesis its xAI path, not a generic Responses client; picking the "OpenAI-compatible" option for either would have published a provider that 404s on its first turn.test/routed-harness.test.mjsasserts no adapter ever emitsopenai-completions. - The capability is the URL, not the key. The caller secret is a path
segment, so a bearer is redundant. Where a client treats a keyless provider
as first-class it is declared keyless (
auth: nonefor omp,apiKey: falsefor Command Code, omitted for Hermes); where a client hides keyless models from its own picker — pi loads them and leaves them unselectable — the same secret is repeated in the field it reads. That difference is not cosmetic: it decides whether the models show up at all. Every published document is written 0600 either way, because the URL is the capability. - These are not
MODEL_ROUTER_TARGETvalues. Nothing installs as opencode. They are published into bycontrol client-setup <id>and removed bycontrol client-disconnect <id>, and neither touches the service.installedTargets()still counts them, sobin/disablewill not retire the shared plane while one of them is pointed at it. - A
codex-routerprovider we did not write is never replaced or removed. Ownership is decided by the base URL, not by the key name: a second checkout, an older build, or a hand-written proxy can legitimately hold that name. Install and disconnect both refuse rather than guess. - YAML is spliced, JSON is round-tripped, and neither is reformatted.
ompand Hermes are edited by line range throughyaml-structure.mjs, which is what preserves comments and hand-formatting. JSON documents go throughJSON.parse, which cannot preserve a//comment — so a document that is not plain JSON is refused with an explanation, and anopencode.jsoncbesideopencode.jsonblocks publication rather than being rewritten or ignored. - The default model is the user's. opencode is the only one of the five whose default key is a plain string in the same document; the others keep theirs in a second file or behind a mapping whose schema changes shape on first use, and guessing wrong there costs a user their configured model for no gain. Even there it is claimed only over a value this router wrote, or when nothing is set, and removed on disconnect only while it is still ours.
- A failed marker write rolls the document back. A client pointed at a route the router has no record of is the one state neither disconnect nor drift detection can reason about.
- Rotation covers all five. The secret is a path segment of every published
base URL, so
caller-key.mjsrefreshes each of them andinstalledTargetsFromStatusrefuses to rotate across one whose managed state is partial. - Installing the client CLI is still the explicit action it is for the
harness. Only
client-setupinstalls, and only from a package registry. Hermes ships acurl | bashinstaller and omp (can1357/oh-my-pi, whose npm package runs on Bun) installs from a script, Homebrew, or Bun; this router does not run remote installers on somebody's behalf, so those rows report the CLI as missing and link to the official instructions. Do not point the omp row at@oh-labs/oh-omp: that fork installsoh-ompand reads~/.oh-omp, not the~/.omptheompcommand reads. - A client too old to read the document is updated, not published past.
Command Code first reads
providers.jsonin 1.30.0, sominimumVersionon its catalog entry makes setup update an older CLI and makes status and doctor report one. A version the CLI will not report is unknown, not outdated. - Staying current is its own action, and it runs the client's own updater.
@latestinnpmPackageonly decides what a first install fetches;installRoutedHarnessdeliberately leaves a CLI that is already there alone, because bumping somebody's global coding agent must not be a consequence of republishing a model list.control client-update <id>|--all, the Harness row's Update button, andupdateRoutedHarnessare the paths that do it. Each prefers the client's own updater (opencode upgrade,pi update --self,command-code update,hermes update --yes) overnpm install -g: a CLI installed by Homebrew or acurl | shscript is not an npm package, and reinstalling it as one leaves two copies whose winner is PATH order — which shows up as a row reporting the new version while the shell keeps running the old one. npm is the fallback only for a client that publishes a package and ships no updater.--allskips a client that is not installed (update what I have, not install five agents I never asked for) and reports per client rather than stopping at the first failure. A client that reports no version before and after is never called "updated". - Check the package name is still the maintained one. pi moved publishers:
@mariozechner/pi-coding-agentstopped at 0.73.1 and the live line is@earendil-works/pi-coding-agent. An abandoned package still installs and still answerspi --version, so nothing in this repository would have reported it as wrong — only reading the client's own install docs does. - Prove a publication against the real client, not against its docs. The
unit suite passed while opencode 1.18 rejected every published model, because
its schema requires
limit.outputwheneverlimitis present. opencode now getslimit.inputat the router'sautoCompactandlimit.outputas the headroom above it, or nolimitwhen there is no threshold. A change to any adapter needs the same check: publish into a scratch document, then have the installed client list the models from it and take one real turn (a free route costs nothing). Listing alone missed that OpenCode's post-completionpinghad become a trailing gateway error: Codex ignores bytes after a terminal event, and opencode and pi do not. - Devin CLI and T3 Code are deliberately absent. Devin CLI's config selects
from Cognition-hosted models and has no custom base URL, so routed models
cannot be published into it; the
devin-cliprovider is the other direction and already exists. T3 Code drives official CLIs rather than talking to models itself, so it inherits whatever routed client it drives — seedocs/COMPATIBLE-APPS.md. Neither gets a Harness row, because a row that cannot publish is a row that lies.
Native traffic is authorized by the caller's session: nativeHeaders copies
authorization and chatgpt-account-id off the incoming request, and Codex
attaches both. A harness turn attaches neither, so native models advertised to
it were models it could never spend.
src/codex-native-session.mjs closes that by falling back to the session this
machine is already signed in with, in $CODEX_HOME/auth.json, only after the
user authorizes that use once. native-session-consent.json is an owner-only
marker carrying no credential and belongs to the shared router plane: asking
the same OS user to sign in or authorize once per harness buys nothing.
-
Consent fails closed. A missing, malformed, or unrecognized marker means off.
chatgpt-session enablerefuses untilcodex loginhas produced a usable session, then republishes every installed client;disableremoves the marker and republishes them again without signing Codex out. TheCODEX_ROUTER_NATIVE_SESSION_FALLBACK=1environment override is the explicit headless opt-in, while0is an emergency off switch. No other value is consent. -
Fallback, never override. Injection happens only when the request carried no upstream credential. Codex always carries one, so a Codex turn is byte-identical to before — verified by relaying a deliberately invalid token and getting that token's own 401 back rather than a success.
-
"No credential" is not "no header". The harness authenticates to this router with the router's own caller key, sent as a bearer token, because a provider route has nowhere else to put one. Testing
!headers.authorizationtherefore never fired for a real harness turn: the caller key went upstream and every turn came back "API key is invalid". Compare the presented bearer token againstCALLER_KEYandINTERNAL_KEYand treat a match as no upstream credential. When there is nothing to substitute, delete the header rather than forward it — a router secret must never leave the machine. -
Test the shape the client actually sends. A curl with no
Authorizationheader at all passes the naive guard and proves nothing. -
The native endpoint accepts a narrower request than the public Responses API.
storemust befalse,streammust betrue, and ten parameters a generic OpenAI client sends are rejected one at a time as bare 400s:temperature,top_p,presence_penalty,frequency_penalty,max_tokens,max_output_tokens,metadata,seed,user,truncation. Codex complies already, so the payload is normalized only for a caller whose session was substituted — a Codex turn is never rewritten.reasoning,tool_choice,parallel_tool_calls, andinstructionsare accepted and must survive; the strip is a denylist for that reason, not a whitelist. Measure any change to that list against the live endpoint rather than guessing. -
Publishable exactly while spendable.
dshRoutedModels()includes native models only whilenativeSessionAvailable()is true, so the harness is never offered a model that would 401.visibility: "hide"entries stay unpublished: they are Codex's own internals, a watermarked build and the auto-review model. -
The credential never leaves the process. It is not logged, not returned by a status call, and not put in an error message.
nativeSessionStatus()reports presence, usability, and age —test/codex-native-session.test.mjsasserts the serialized status contains neither the token nor the account id. -
It widens the caller key. With sharing authorized, anything holding that local key spends the ChatGPT subscription and not only the API-key providers. That is a deliberate, user-made tradeoff recorded once for the shared plane;
chatgpt-session disablerevokes it everywhere and the clients silently drop back to routed models only. -
The access token lives about ten days, and Codex renews it only when Codex is used. A harness-only stretch longer than that would otherwise leave the router sending a dead token.
nativeSessionHeaders()reads theexpclaim and declines two minutes early, so an expired session withholds the headers anddshRoutedModels()stops publishing native models — the picker loses the eight rather than serving certain 401s. -
Codex refreshes its own credential; this router never does. Reproducing that OAuth exchange would mean guessing an unpublished client identity and, if refresh tokens rotate, either rewriting Codex's own file or invalidating the login this router was asked not to disturb.
refreshViaCodex()runscodex login statusinstead — best effort, single-flight, at most once every five minutes — and lets Codex decide. If nothing renews, the session simply reads as expired. -
doctorreports it as its own line, because "open Codex once" is the fix and nothing else would say so.
native-redirect.mjs is deliberately all-or-nothing, and its own comment says
why: native turns "carry no reliable marker separating background work from a
deliberately picked GPT model". Codex's automatic approval reviewer is the
exception. It arrives under its own hidden slug, codex-auto-review, so it can
be singled out without ever touching a GPT model the operator chose.
That matters because the reviewer's quota is not the session's. With Use Router with ChatGPT on, an external model can answer the turn while every
Approve for me still spends ChatGPT quota, so an exhausted plan leaves a
session that reasons and proposes commands but cannot run the ones that need
review (#787). src/auto-review-fallback.mjs lets the operator name a routed
model for exactly those reviews.
- Only a refusal the reviewer could not run may engage it. The verdict
comes from
classifyRoutedFailure, the routed path's own classifier, which already puts an entitlement refusal ahead of a quota one, ignores every 5xx and deterministic 4xx, and reads a reset time only where the upstream stated one. Do not grow a second classifier here; two definitions of "out of quota" is one more than this repository can keep correct. - A
denyis a decision, not a failure. It arrives as HTTP 200, so it can never reach the classifier — but say it in a test anyway, because asking a second model to re-review a command the first refused is the one outcome the issue names as unacceptable. The same holds for any answer at all: the first native answer clears the window, the ruleclearProviderCooldownfollows. - The window is the upstream's, capped at six hours, like a provider cooldown. A refusal that named nothing gets sixty seconds — enough that a burst of approvals in one minute does not each pay for the same rejection, short enough that a quota which returns is noticed almost at once. Never invent a longer one.
- The first failing review is not rescued. The refusal is relayed exactly
as ChatGPT wrote it and the window is recorded; the next approval goes to
the configured reviewer. Rescuing the failing turn would mean rebuilding a
native request as a routed one mid-flight, which is the native/routed
crossing the "Not implemented: the native ChatGPT tier" note above refuses
to make from the test suite alone. The pre-flight redirect reuses the
crossing
native-redirectalready ships, at the one point it is safe. - Never silent. The log line is not gated on
CODEX_ROUTER_QUIET, the usage row carriesautoReviewFallback, andcontrol auto-review-fallback statusreports the configured reviewer, the window, and which reviewer the next approval will actually use. - Coverage lives in
test/auto-review-fallback.test.mjs(state, classifier boundary, window arithmetic) and the two#787cases intest/routing.test.mjs(end to end, including that adenyis never retried through another model). Both router cases fail without the hooks.
handleResponses treats a model it has no route for as native GPT traffic and
forwards it to chatgpt.com. That is correct only for native slugs, and no native
slug contains a /: not the captured account catalog, not the context variants
in src/native-context-variants.mjs, not the native-alias keys or the
native-redirect sources. Every routed slug is provider/model.
So a slug containing / that resolves to no route — exact slug, migration or
curation alias, or native alias — is refused locally with HTTP 400,
invalid_request_error, code unrouted_model (src/unrouted-model.mjs),
before native redirect or native passthrough can take it. Issue #689 is why: a
user model added to user-models.json after the service started reached the
Codex picker (the catalog is rebuilt in another process) but not the live
router's MODEL_BY_SLUG, went to ChatGPT, and came back as "The 'vendor/model'
model is not supported when using Codex with a ChatGPT account" — which reads
as an OpenAI restriction and sent the user's prompt to OpenAI besides.
- Keep the check ahead of
readNativeRedirect(). The redirect exists for Codex's background sessions on native slugs; it must not quietly serve a mistyped or unloaded routed model with some other model. - The message names the slug, whether its prefix is a registered and enabled
provider, and the reason
mergeUserModelsskipped a user model with that slug (USER_MODELS_SKIPPED), and points atbin/control service restartanduser-models.json. It never carries a credential, caller key, base URL, or path. It does not probe credentials: that spawns keychain lookups. - Client surfaces already resolve their own prefixes before re-entering this
path — Claude strips
codex_router/anthropic/, Cursor maps its neutral ids to the slug, Gemini, DeepSeek Harness, and OpenClaw send the router slug — and the Responses WebSocket re-enters over HTTP, so this one check covers them. - If a native namespace with a
/ever appears, narrow the rule to prefixes that are not that namespace; do not drop it.
The tray's presence setting can tie the router to the Codex and ChatGPT desktop
apps, stopping it 30 seconds after both close. That is only safe for a client
the tray can actually see. NSRunningApplication enumerates app bundles, so it
sees the desktop apps and nothing else — a codex TUI in a terminal and a dsh
harness turn both register nothing at all. Neither can be started on demand
either: a turn that finds 127.0.0.1:4202 closed fails immediately, while the
five-process stack behind that port takes up to 300 seconds to warm, so lazy
start does not exist at request latency. The port has to already be open.
effectivePresenceMode()insrc/presence-state.mjsis what the tray anddoctoract on. It reportsalwayswheneverdsh-models.jsonexists orcodexresolves on PATH, whatever the stored mode says. Read it, neverreadPresenceMode(), anywhere a service gets stopped.- Detection errs toward finding a client. A false positive costs a dormant toggle; a false negative costs somebody their next request.
- The stored mode is overridden, never rewritten. Removing the harness route or the CLI hands the user's own choice back on the next read.
- The router owns the rule and the tray consumes it:
control --jsoncarries apresenceblock, and the tray readspresence.effectiveModerather than re-deriving anything from target flags, which is where the two would drift. The field is optional in the Swift decoder, so a tray keeps working against a router that predates it. test/presence-state.test.mjscovers both signals, the override, the round trip, and the fact that always-on is left alone. A change to the gate needs a test there.
- Anything a skill, tool, or agent produces that is not source — rendered
video, images, audio, benchmark dumps, one-off reports — belongs in
generated/at the repository root. That directory is gitignored, so the working tree stays clean and nothing large lands in a commit by accident. - Do not add per-extension ignore rules (
*.mp4,*.png) for this. They also hide checked-in assets such as tray icons and documentation screenshots. - Files that are meant to ship — icons, fixtures, docs assets — go in their
real home under version control, not in
generated/.