Last updated

Codex Router Changelog — Version History

This page tracks every release of Codex Router and is generated directly from the repository’s CHANGELOG.md. The current release line is 0.6.0.

How updates work

Codex Router updates through the managed checkout: run ./bin/model-router codex update (or ./codex-router.ps1 codex update on Windows). Updates require the recognized GitHub origin and a checkout with no edits to tracked files — untracked files never block an update, and --force (./bin/update --force, ./bin/rollback --force) discards tracked edits if you need it. The previous revision is kept as a local rollback ref. After any update, run ./bin/model-router codex doctor --fix so the generated config and service match the source revision, then fully quit and reopen Codex so it reloads the merged model catalog.

The changelog below is the complete release history. If you are looking for what changed in a specific version, use your browser’s find-in-page (Control+F / Command+F) to jump to that version heading.


Unreleased

  • An apostrophe in a harness config no longer moves the router’s route into somebody else’s value. yaml-structure.mjs treated every ' and " as a quoting indicator, but YAML only gives a quote that meaning where a node can begin: note: don't edit is a plain scalar, not an unterminated quoted one. A single apostrophe therefore swallowed the rest of the document. When nothing later matched it, the scan refused a perfectly ordinary file and the router could not publish at all; when a later line happened to carry a matching quote, the scan finished quietly having hidden every key in between, and the splice wrote codex-router: inside a block scalar or a list item. The harness then saw no route and the user’s value grew four lines of YAML. A quote now opens a scalar only at the start of a value, after a flow collection’s [, {, , or :, and after a block sequence’s - . Across 27,045 generated documents that PyYAML accepts, 6,956 refusals, 184 unparseable outputs and 178 misplaced routes all go to zero. Affects DeepSeek Harness settings.yaml and .credentials.yaml, omp models.yml, Hermes Agent config.yaml, and caller-capability refreshes into all three.

  • The Devin CLI model list asks for the method Devin 3000.x actually serves. devin-cli called GetCascadeModelConfigs, which is the IDE’s method; the CLI moved to GetCliModelConfigs, so a CLI-credentialed account was answered Connect invalid_argument on every bin/devin-probe and bin/curate-models devin-cli run (#770). Both methods are still declared on the service, which is why the refusal did not arrive as unimplemented. Re-reading every field the router writes against Devin 3000.10.31 found the request shape unchanged, so the method name was the whole drift. The probe now says so when an invalid_argument lands behind a clean request-shape check, and the unused MODEL_INFO table — whose field numbers had also gone stale — is gone. The provider remains unverified: no maintainer has run a live turn.

  • start and restart say the scheduled task is missing instead of relaying schtasks. On Windows both verbs went straight to schtasks /Change /TN "Codex Router" /ENABLE, so with no task registered the operator got schtasks.exe’s own error, naming neither the task nor anything to do about it (#760). stop had guarded on the task’s existence since it shipped; these two now do too, and name service.mjs install and doctor --fix. Nothing is mutated on the way out, and a start that cannot start no longer prints {"state":"running"}.

  • OpenCode Go Messages no longer 400s a generated ImageGen PNG. Console Go rejects a single messages[N].content over 2,500,000 characters. A live Union Alpha follow-up after image_gen carried a 2.03 MiB 1536×1024 PNG as a 2,707,238-character data URL and died before final_answer. The hop now replaces that oversized image with a labeled stub; Codex still has the file. Compact stays 180,000 and the completion cap stays 32,768.

  • Union Alpha publishes the measured 32,768 completion reserve. OpenCode and Console Go treat an omitted max_tokens as the advertised 131,072 output. A Desktop-sized first turn (~140k rendered) plus that reserve exceeds the 262,144 window and comes back as context_length_exceeded / “tokenizer/template mismatch, not high demand” even though the hop would have fitted the same prompt at 32,768. The Messages route now always sends 32,768 and the catalog / OpenCode limit.output advertise that cap. Compact stays 180,000. Rebuild the catalog; a thread already past OpenCode’s tokenizer still needs a new task.

  • An unfinished Union Alpha prefix closed as output_text is still truncated. The 14:12 ImageGen retry stored I'll use the image generation as final_answer after empty-completion already withheld the first attempt. LiteLLM had closed that fragment as a real output_text part, so the thinking-match withhold never fired. A held done snapshot that is still a mid-clause cut is withheld; punctuated answers stay answers.

  • Automatic approval reviews can fall back to a routed model when ChatGPT quota runs out. Codex runs Approve for me on its own hidden native model, so with Use Router with ChatGPT on, an exhausted plan left a routed session proposing commands it could not execute (#787). ./bin/control auto-review-fallback set <provider/model> names a reviewer for exactly those turns. It engages only after the native reviewer has itself refused for quota, and only for the window that refusal named — a denial, a policy rejection, a 5xx, and anything ambiguous all stay native, and a deny is never retried through another model. The first native answer afterwards ends the window. The main agent’s model is unaffected either way.

  • subagents explain <model> says why a route cannot be delegated to. The answer lived in three places that never met — selection in subagents status, promotion in the published catalog, and the agent definition on disk — so the only way to find out was to spawn one and read codex exited 1 (#804). The new command names the first blocker and the command that fixes it, distinguishes a typo from an uncurated model from a native slug, and says whether a route’s v2 claim comes from the registry, a local five-check run, or the operator’s own selection. Read-only and quota-free.

  • A configured subagent effort no longer reads as a drifted agent definition. syncRoutedCodexAgents wrote model_reasoning_effort into the definition and routedCodexAgentStatus computed the expected contents without it, so every model with a subagent effort set was reported stale forever: doctor flagged drift, --fix republished identical bytes, and the next check flagged it again.

  • Playwright is 1.63.0 in both the router tests and the Control Center. Dependabot #758 only bumped the root pin. The Control Center lock stays in step so renderer tests and docs screenshots use the same browser.

  • LiteLLM’s finish sequence no longer stores a leaked Union Alpha prefix. Holding until output_text.done assumed that event arrived after the reasoning_text close. LiteLLM 1.96 emits the done snapshot first, then closes the part as thinking, which stored The skill is loaded. This is a single concept-sheet generation: a GTA-style AAA as final_answer. The done snapshot is held until that close; the same text (or a prefix of the thinking) is withheld so empty-completion can retry. A distinct answer still completes.

  • A truncated exec_command JSON call is retried instead of a 502. Union Alpha can finish a tool call with an unterminated string. The router still refuses to store that item, but if Codex has not seen a byte yet it retries once like an empty completion instead of answering unexpected status 502.

  • A leaked Union Alpha prefix is still truncated when LiteLLM also sends output_text.done. Holding until that event was not enough: the live ImageGen turnaround closed as reasoning_text and then output_text.done for the same 29-token sentence ending (no reference. The snapshot is withheld so empty-completion can retry instead of storing it as final_answer.

  • Union Alpha no longer compact-loops on a Codex Desktop tool list. Compact at 80,000 sat below ~88–108k of cached tool-schema tokens, so every ImageGen skill read compacted, the checkpoint kept a 1k excerpt, and the model re-read the file. Compact is 180,000, above that floor. The 32,768 max_tokens cap and compact overflow hop stay. Rebuild the catalog and start a new Codex task; a thread already in the compact loop will keep looping.

  • Union Alpha no longer stores a 21-token mid-sentence final_answer after a premature reasoning_text close. The earlier drop only fired when a reasoning-summary delta had already opened the repair. Live ImageGen turns streamed I'll use the ImageGen skill… so with no summary first, so the close still ended the message and Codex marked the turn complete. The repair now holds that prefix until output_text.done; if the stream completes without it, empty-completion retries or fails instead of succeeding with 21 tokens.

  • Compact overflow on Union Alpha can retry a larger-window model. OpenCode estimated about 434,983 tokens against Union Alpha’s 262,144 card, so compacting the same 262k route cannot save that thread. Compact failures are translated to context_length_exceeded instead of LiteLLM’s model-group wrapper. Compact may retry a larger-window model, including a same-family OpenCode Go 1M route, without recording a cooldown. Ordinary turns still never swap on HTTP 400. If every configured window is still too small, start a new Codex task.

  • Union Alpha on OpenCode Go Messages caps the Messages completion budget. Console Go 400s a prompt-plus-completion that does not fit every available backend. The Go Messages route keeps the advertised 262,144 window, caps Messages max_tokens at 32,768, and translates that 400 as a context-window error rather than a generic rejection. The shipped slug is opencode-go-messages/union-alpha.

  • Union Alpha is now a checked-in OpenRouter route. OpenRouter publishes this stealth preview as stealth/union-alpha (262,144 context, 131,072 output, text and image input, currently free). The shipped slug is openrouter/union-alpha. OpenRouter does not advertise a reasoning-effort ladder, so the stored rung is the conservative single high. Its endpoint record accepts tool_choice auto only (required and none are false), so the route uses auto-tool-choice. Cline the IDE can already pick this id through OpenRouter; ClinePass and Command Code do not list it. Rebuild the catalog and fully quit and reopen Codex.

  • A completed function_call with invalid JSON arguments is no longer stored. Relaying that item left Codex unable to execute it and poisoned every later turn on the thread (#797). The router now withholds the completing snapshot and fails the turn instead of inventing a closing quote. Empty arguments, custom tools, and native-hook raw patches are unchanged.

  • A local tool-argument conversion failure is no longer a provider rejection. LiteLLM raises that parse while building Anthropic tool_use.input from stored history, before any provider request. The error now names the stored call and does not fail over, even when the argument body matches a quota phrase (#796).

  • OpenCode Go Messages no longer 400s Codex hosted/custom leftovers or 502s thinking-only streams. Anthropic Messages (Union Alpha and every other protocol: "anthropic" route) now keeps only named functions with object schemas, and LiteLLM’s Chat Completions reasoning-summary repair attaches to those routes because they still set use_chat_completions_api: true. A reasoning_text close is thinking, not an answer, so the empty-completion guard can still retry; a reasoning_text close that arrives before output_text.done also no longer ends the visible message, which had truncated Union Alpha replies mid-sentence (Union Alpha (). Qwen on this route additionally omits tool_choice entirely (omit-tool-choice); MiniMax still accepts the field. Rebuild the catalog and fully quit and reopen Codex. Union Alpha is opencode-go-messages/union-alpha.

  • OpenCode Zen now has Messages and Responses protocol variants. Claude curated under opencode-zen lands on opencode-zen-messages; GPT, Grok, and Muse land on opencode-zen-responses; Gemini is refused. The variants share Go’s key and selection toggle but keep Zen’s separately billed cooldown scope. Re-curate with bin/curate-models opencode-zen to move existing Chat entries onto the matching wire.

  • Google Cloud Vertex AI is a catalog-only provider. It authenticates with Application Default Credentials from gcloud auth application-default login (never a stored API key or a silent gcloud auth login user token), honors --no-discovery, and lists Model Garden models for bin/curate-models vertex. A discovered id is not routable until it is curated onto a reviewed adapter.

  • Direct Meta Muse Spark 1.3 Contributor no longer fails on recursive Codex tool schemas. Meta’s direct Responses endpoint answers a self-referencing tool schema with HTTP 400 Recursive JSON schemas are not currently supported before inference, losing the whole turn downstream of the gateway. The verified meta/muse-spark-1.3-contributor route now breaks only the cycle-closing reference edge through the existing toolSchemaRecursion: "flatten" repair, preserving definitions, acyclic references, and sibling constraints. Sibling Meta routes keep their payloads until their own endpoint proves the same restriction (#792).

  • Union Alpha is now a checked-in OpenCode Go route. OpenCode’s live Go catalog and docs publish this stealth model as union-alpha on the Messages API (https://opencode.ai/zen/go/v1/messages), currently free for a limited time. The shipped slug is opencode-go-messages/union-alpha. Window (262,144) and output (131,072) come from OpenCode’s models.dev record; Codex compact reserves that output budget. OpenCode documents reasoning but publishes an empty effort list, so the stored ladder is the conservative single high. The maker is unnamed, so the Control Center uses the stealth mark rather than guessing a vendor. Omen Alpha is still in the live catalog but deprecated in that same record and is not checked in. Rebuild the catalog and fully quit and reopen Codex to see the new picker row.

  • A .env that mixes CRLF and LF keeps both. The Gemini integration owns a marker-delimited block in $GEMINI_HOME/.env and treats every other byte as the user’s, but it detected one line ending for the whole document and rejoined every line on it. A file touched by two editors came back with the router’s ending on lines the router never wrote — and came back that way again after the block was removed, so the rewrite outlived the integration. Each line now keeps the terminator it arrived with, and the block is rendered with the document’s prevailing one. A file that ends without a newline still gains exactly one, because the block has to start on its own line; that is the only difference a publish and removal now leaves behind.

  • Command Code’s DeepSeek V4.1 Flash route advertises image input. The checked-in entry was text-only until Command Code documented the capability at the Provider API: the model page lists Image input, and the Provider FAQ says message content is text and images with no per-model pre-gate. Matching the other V4.1 Flash routes, commandcode/deepseek-v4.1-flash now declares ["text", "image"] so pasted screenshots go to the model instead of the vision bridge (#783). Rebuild the catalog and fully quit and reopen Codex before pasting an image.

  • Doctor no longer fails “Enabled providers: none” for a generic-only install. Registry selection and generic providers are two lists: an empty enabled-providers.json with a credentialed Poe (or other generic) route still serves traffic, but doctor only named the registry file and exited 1 while the generic row said OK (#774). Enabled generics now count.

  • ainetcafe added as an API-key provider. ainetcafe/kimi-k3 routes to ainetcafe’s OpenAI-compatible endpoint (https://microquickjs.com/v1, key AINETCAFE_API_KEY), which serves Kimi K3 from its own cluster. It reuses the kimi-k3 request profile (top-level reasoning_effort, no thinking), declares the default 256K context and text + image input, and stays conservative on original-detail images like the other K3 relays.

  • The Windows Control Center no longer flashes a console on every refresh. Ordinary control.mjs invocations re-exec through the process tree with stdio: "inherit", which is required so credential stdin and live output survive. That used to map every inherit to windowsHide: false, so a parent with no console of its own — the packaged Electron tray — made Windows allocate a new visible Windows Terminal on every snapshot (#775, #731). Process-tree now treats inherit without a TTY as background work: three fresh pipes, live stdin/stdout/stderr relay, and CREATE_NO_WINDOW (#744). A real terminal still inherits a console. Switching the re-exec to capture would hide the window the wrong way: capture ignores stdin, which is how Control Center writes provider keys.

  • Muse Spark 1.3 Free no longer 400s on follow-up turns. OpenCode Zen’s anonymous Responses route is a Console proxy, so Meta-issued reasoning encrypted_content is bound to Console’s caller, not this router. Replaying it came back as HTTP 400 “reasoning encrypted_content was not issued to this caller”. The exact Muse Contributor Free Responses gate now drops that continuation token (keeping any summary text) and stops asking for it on include. Paid Zen/Go keep a stable key and are unchanged.

  • DashScope’s documented thinking ladder now reaches the model, so a curated DashScope route can offer more than one rung. Model Studio documents reasoning.effort with a ladder per upstream family, but the router had no profile on a generic provider’s boundary: the nested reasoning.effort Codex sends is ignored by /compatible-mode/v1/chat/completions, which reads the flat reasoning_effort, so every rung produced the same turn and curation could only publish the conservative single high. The new curatable dashscope-reasoning profile folds the requested rung onto the family’s documented ladder (Qwen3.8 none/low/medium/xhigh, GLM-5.3 low/high/max, DeepSeek V4.x none/high/max, with the dated 0731/0813 snapshots keeping low), writes whichever spelling the surface reads, maps Codex’s minimal onto DashScope’s none because Codex ships no thinking-off rung, and downgrades the forced tool_choice the Qwen3.8 family refuses in thinking mode. Measured end to end through the router: minimal returns zero reasoning tokens on Qwen3.8 and DeepSeek V4.1 Flash, and bin/test-model --live --yes passes 5/5 on all five curated DashScope models (#785).

  • OpenCode Go no longer 400s a follow-up after apply_patch. The custom→function bridge rewrote custom_tool_call_output to function_call_output and kept the item id (ctco_…). Console Go requires function-shaped ids to begin with fc, so the next turn failed with that id at the same index and stayed broken for the rest of the thread (#780). The bridge now omits a non-fc string id on the rewritten call and output; call_id still pairs them. A native-minted fc… id is kept.

  • A multiline string in config.toml is no longer edited as if it were settings. The root-level helpers found assignments by matching lines, so a line inside a multiline string — prose in instructions, a documented example — counted as one whenever it was shaped like model = .... Because it came first, it was the line that got rewritten, deleted when a value moved, and read back as the current setting. Switching the model edited the user’s prose and left the real model untouched; enabling the router deleted a line out of the middle of their text; and the router journalled the prose as the previous value, so turning it off restored that. All of it silent, and the setting the user asked for never changed. These helpers now locate the assignment through scanTomlDocument — the fail-closed structural lexer this file already uses for table boundaries, which exists to tell a real assignment from text that looks like one. String values are decoded by the lexer, including trailing comments. Root reads and writes scan the same root section, even when a later table contains an invalid escape. Catalog removal, concurrency detection, and managed-marker insertion also respect structure. Multiline root settings are refused rather than partially removed, and login-free in-place changes retain strict whole-document validation. Legacy malformed root sections retain their best-effort line matching so Windows prototype installations can still be disabled; prose preservation is only guaranteed when that root section can be scanned.

  • Routed models can be published ahead of the native GPT picker entries. Codex renders its picker by priority, and routed models always landed in a band after the highest visible native entry, so an operator whose everyday models are external could not put them first. model-picker.json now carries an order (native-first, the unchanged default, or routed-first), set with ./bin/model-router codex picker-order. Under routed-first every routed model — certified v2 spawn routes included, so none interleaves with the shifted natives — publishes at 1..N in the existing vendor-group order and the natives move after them. Visibility writers preserve the choice; an older file or an unrecognized value keeps the default.

  • Generic Ollama providers are curated at the model’s served context length and modalities instead of the conservative default. Ollama’s OpenAI-compatible /v1/models lists ids only, so a curated Ollama model carried the 131072-token guess (#266) and text-only input even when the server runs it at 1M with vision. Generic discovery now asks the same origin’s /api/show for each listed model when the provider is an OpenAI-chat endpoint rooted at /v1, proves the answer is Ollama-shaped, and fills in only the fields the list left blank. The probe is bounded like the catalog fetch, stops on a missing route or after three leading refusals, and skips a model the server cannot describe. Curation stores the advertised window and image input; discovery reports them as contextLengths and the new inputModalities map, so a documented or default modality never masquerades as a served one.

  • GLM-5.3-Flash on Command Code no longer sends an effort rung the model refuses by name. commandcode/glm-5.3-flash declares the model’s low/high/max ladder and carried no requestProfile, and the profile chain in src/api-forwarder.mjs is keyed entirely on that field — so the effort Codex sent went upstream verbatim. Codex older than 0.143 has no max in its effort enum, so clampModelEfforts rewrites this route’s default down to xhigh, which is the rung GLM-5.3-Flash answers with 400 — [1210] This model always engages in thinking and cannot be disabled; please use low, high, or max. The route now carries the same ox-alpha clamp the OpenCode Go and OpenRouter Flash routes use, so xhigh/ultra land on max and medium/minimal on low, an absent effort stays absent, and no rung the entry does not advertise can leave the router. This asserts nothing about Command Code’s own validation, which the provider does not document; the plan fallback at /alpha/generate carries no effort at all and is unchanged. compHash is bumped, so rebuild the catalog and fully quit and reopen Codex.

  • GLM-5.3-Flash on Command Code now compacts at 400K like every other route for that model. commandcode/glm-5.3-flash shipped with autoCompact: 900000 — the Command Code house value for a 1M window, carried by two dozen of that provider’s entries — while the five other checked-in GLM-5.3-Flash routes compact at 400,000. That threshold is a property of the model: large live multimodal Flash histories repeatedly returned empty completions before the advertised limit, which is why nousresearch/glm-5.3-flash was dropped rather than shipped at 943K. The Command Code entry was written fresh in a bulk catalog pin and took the provider default; no commit message, comment, or research note argued for 900K, and the earlier incarnation of the same file carried 400,000. Codex therefore ran this route 500,000 tokens past the point where the model has been seen to go blank. The route is also now named in the test/glm-5.3-flash.test.mjs inventory — its absence there is what let the outlier live — and that inventory is now derived from the registry, so the next Flash route cannot be omitted silently. compHash is bumped, so rebuild the catalog and fully quit and reopen Codex to pick up the new threshold.

  • A failed script install no longer leaves POSIX users stuck on the rollback revision. After setup fails, the checkout is detached at the previous commit by design so the service is not left on half-applied code. Windows and ./bin/update already switched that state back to main before the next fetch; install.sh refused instead because it was not on main. A Homebrew user who then installed from the script could stay on refs/codex-router/rollback and keep serving the tree that predates x-opencode-session (#761). A retry now restores main the same way.

0.6.0

  • Thinking models on Chat resellers outside the native-reasoning contract no longer see their own past reasoning replayed as visible prose. #708 widened the reasoning-lifecycle repair from grok-oauth to every openai-protocol provider, which is what finally let reasoning reach Codex on these routes — and as a side effect made Codex store a reasoning item for those turns. Most Command Code thinking models match no entry in the native-reasoning family table (commandcode/qwen3.8-flash resolves to upstream Qwen/Qwen3.8-Flash), and for those the carry turned the stored reasoning into output_text on the next turn. A model that reads its own thinking as prose it once said moves new thinking into the answer channel and loops on its last progress note — the documented 2, 4, 5, 8, 16 copies per message (#755). Those routes now drop the reasoning from the carry instead of converting it, which asserts nothing about any vendor’s reasoning_content handling; routes inside the contract still carry theirs as thinking parts, and native Responses providers are untouched. Before #708 this was inert on these routes, because no reasoning item was stored to carry.

  • A slow first start no longer uninstalls the service the installer just installed. On a clean machine the install wrote its launchers and registered its service correctly, then a cold-starting LiteLLM gateway with a large model set overran the 300-second health wait. The installer treated that as a failed install and rolled back — service.mjs uninstall deletes the service and unlinks both launchers — so the operator was left with "installed":true in the log, no start-codex-router.cmd on disk, no scheduled task, and a bare fetch failed naming nothing (#760). The earlier guard for this only covered a reinstall over an already-working router; a first install had no prior state to compare against and was torn out anyway. service.mjs now exits 75 (EX_TEMPFAIL) when the service is installed and running but health has not answered yet, and both installers leave the service and the client config exactly as installed for that case, printing what to check instead. A crash loop or a dead launcher is still a rollback: those are broken rather than slow, and the readiness layer tags only the retryable timeout.

  • GLM-5.3-Flash reads pasted images itself on Z.ai and OpenRouter instead of paying another model to describe them. zai-coding/glm-5.3-flash, zai-api/glm-5.3-flash, and openrouter/glm-5.3-flash declared inputModalities: ["text"], so bridgeVisionInput sent every image in the turn to a vision engine and substituted a transcript — spending a second provider’s quota, and losing whatever a transcript cannot carry — for a model Z.ai documents as natively multimodal (#756). Z.ai gives the model’s input modality as Video / Image / Text / File, documents its image_url content block, 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. All three entries now declare ["text", "image"]. The text-only values were never a measurement: each entry was written fresh when the withdrawn Ox Alpha preset was removed and took the conservative default, while the preset it replaced had carried image input from three provider catalogs. The full-size GLM-5.3 routes stay text-only, which is the same fact rather than an inconsistency — Flash is the multimodal member of that family — and a test now holds both halves. compHash is bumped on each changed entry, so rebuild the catalog and fully quit and reopen Codex before pasting an image.

  • Hy4’s nonce-suffixed reasoning delimiters no longer leak the model’s planning into the answer. Hy4 Preview writes its own markup with a per-message nonce (</think:6124c78e>, the family src/leaked-tool-call-recovery.mjs already parses tool calls out of). A serving stack that consumes the opening tag but relays the close left the model’s internal prose in output_text with only an orphan close behind it, and the reasoning-tag stripper’s exact </think> grammar could not see it, so the chain-of-thought was shown as the answer and replayed into later turns (#654, commandcode/hy4-preview). The stripper now reads the suffix, and an orphan nonce close — one whose opening tag never appeared — ends the leaked reasoning and takes the text in front of it. Both are gated to Hy4 routes by the same usesHy4NonceMarkup check the tool-call recovery uses: a bare </think> keeps its prefix everywhere, since that spelling can appear in an answer about reasoning tags. Tool-call markup spans are still relayed verbatim; only an orphan close of one is read as a terminator. Bytes the delta channel has already emitted cannot be retracted, so a leak split across deltas can still flash on screen; output_text.done and the stored message item — what is replayed into the next turn — are cleaned either way.

  • Native replay no longer sends both a full reasoning item and its duplicate reference. A request can contain an inline reasoning item followed by an item_reference with the same rs_ id. Native input normalization now keeps the full item and removes only that redundant reference after all existing reasoning cleanup has run. A null, empty, or non-string encrypted_content value is not treated as evidence that the item was never stored: direct- credential callers retain the existing stored-namespace fallback, and a bare reference with no surviving inline item remains untouched.

  • Locally curated Moonshot models with toolSchemaRecursion: "flatten" preserve recoverable types when breaking recursive tool references. No shipped Kimi model enables flattening; stock Kimi and Meta payloads are unchanged. Explicit types and type-implying siblings take precedence over the referenced definition. Pure reference aliases are followed safely. Untyped recursive unions and reference-only rings still cannot supply a target type and remain permissive; this is not a general fix for Moonshot’s missing type in anyOf properties error. The route behind #726 has not been established, so that issue is not claimed resolved by this change.

  • A resold image cannot be charged as prose in the prompt-token estimate. maxImageTokensForRoute bounded only the three documented direct DeepSeek Flash models, so a route such as openrouter/deepseek-v4.1-flash had no bound at all and the estimator added the image’s base64 to the prompt tokens — 819k tokens for one 2.7 MB screenshot, which is most of a 1M window and enough to trigger client auto-compaction on its own. Routes without a documented bound now take a conservative 4096-token default; the three DeepSeek Flash models keep their documented 1024.

  • Routed coding clients can be kept current from the Harness page. control client-update <id> and control client-update --all, plus an Update button on each row and Update all in the header, move opencode, pi, Command Code, and Hermes Agent to their latest release. Each runs that client’s own updater (opencode upgrade, pi update --self, command-code update, hermes update --yes) rather than npm install -g, so a CLI installed by Homebrew or a curl | sh script is updated in place instead of gaining a second npm copy that may win or lose on PATH. omp ships neither a package this router installs nor a self-update subcommand, so its row prints the project’s own three installs. Updating stays a separate, explicit action: publishing a model list never changes a client’s version. --all skips clients that are not installed and reports each one instead of stopping at the first failure.

  • pi is installed from its maintained package. The pi coding agent moved from @mariozechner/pi-coding-agent (last published at 0.73.1) to @earendil-works/pi-coding-agent. Setup installed the abandoned name, which still installs and still answers pi --version, so the stale agent looked healthy. pi’s own --ignore-scripts install flag is used as well.

  • Muse Spark Responses turns no longer end in a gateway error. OpenCode Go and Zen send event: ping ({"type":"ping","cost":"0"}) after every response.completed. The API forwarder treated it as data after the terminal event and appended invalid_responses_stream, which LiteLLM re-raised as Response API in-stream error on every completed opencode-go-responses and opencode-free-responses turn. Codex ignores bytes after a terminal event, so it went unnoticed; opencode and pi validate them and failed every turn. A keep-alive or SSE comment after the terminal event is now dropped; real data after it is still refused.

  • Thinking models behind resellers now get their reasoning replayed the way their vendors require. The replay contract was keyed on request profiles, so zai-coding/glm-5.3 replayed reasoning as reasoning_content while the same GLM on opencode Go, OpenRouter, or Command Code — and Kimi K3, Hy3, MiniMax M3, DeepSeek there — had it replayed as visible assistant text, the shape that made Hy4 loop on its own progress notes. The contract is now keyed on the upstream model family for Chat Completions resellers: DeepSeek (the API 400s without it), GLM-5.x (Z.ai requires the history replayed with preserved thinking), Kimi K3 (Moonshot requires it in tool loops), MiniMax M3 (interleaved thinking), and Tencent Hy3/Hy4. Verified live on every reachable route on opencode Go, OpenRouter, and Command Code; Anthropic protocol variants and resellers that were not probed keep their existing channel.

  • Hy4 Preview no longer loops on its own progress notes mid-turn. Its reasoning is now replayed to it the way DeepSeek’s and GLM’s already were — as reasoning_content on the assistant turn that produced it — instead of as visible assistant text. Replayed as prose, the model read its own past thinking as something it had said aloud, moved new thinking into the answer channel (reasoning tokens went from 174 to 0 in one step), and from there repeated its last note 2, 4, 5, 8, then 16 times per message while Codex showed “Reconnecting”. Applies to every route on the hy4-reasoning profile (opencode Go, OpenRouter, NanoGPT, Nous); Command Code’s own Hy4 shim and Cline are unchanged.

  • A routed turn no longer dies silently when the model writes its tool calls as text. Tencent Hy4 Preview has a tool-call syntax of its own (<tool_calls:NONCE>...). When a serving stack fails to parse it, the calls arrive as ordinary text on the reasoning channel, nothing reaches the tool_calls array, and the turn ends on an assistant message with no content and no function_call. Codex reads that as the end of the turn and writes task_complete with last_agent_message: null, so the user is left with the “Worked for …” group and no answer at all — in the capture this was found in, four minutes of work vanished mid-investigation. The router now parses the leaked markup back into real function_call items, strips it from the text it was buried in, and emits the calls before the turn closes, so the model’s own work continues. Only the model’s calls are recovered: a malformed, unterminated or nonce-mismatched span is relayed verbatim, a stream without the markup is passed through byte-for-byte, and native turns gain no stage.

  • Reasoning from Chat Completions models now shows in Codex. LiteLLM’s Chat Completions to Responses bridge opens the assistant message first and streams the model’s reasoning under a fresh hashed item id per delta, with no reasoning item around it. Codex drops deltas that belong to no open item, so routed models such as Hy4 Preview (Command Code), DeepSeek V4.1 Flash on opencode Go, and every other Chat Completions route showed no reasoning at all, and none was saved to the thread. The repair that already rebuilt one reasoning item for Grok OAuth now runs for every Chat Completions route. Direct DeepSeek keeps its own bridge repair, Responses and Messages providers are untouched, canonical streams pass byte-identical, and the Grok-specific gateway-error wording stays on Grok OAuth. Its first-frame bound also rises from 256 KiB to 10 MiB: LiteLLM echoes the whole Codex tool list in response.created, and in Codex Desktop that frame used to switch the repair off for the entire stream.

  • Blank tool-call messages and direct DeepSeek reasoning are repaired behind large tool lists. LiteLLM echoes the request’s instructions and whole tool list in response.created and response.in_progress. The stream repairs that remove LiteLLM’s blank assistant message before a tool call (every Chat Completions and Messages route) and rebuild direct DeepSeek’s reasoning item gave up on any frame over 256 KiB or with more than 8,192 JSON members, so a session whose tool list crossed either had both repairs switched off from its first event. Mock gateways send a bare envelope and never showed it. The frame bound now matches the namespace relay’s 10 MiB, the JSON scan budgets grow with it, and malformed, ambiguous, or over-budget streams still pass through byte-identical.

  • Kimi no longer rejects a tool schema whose union leaf declares no type. A nullable field written the ordinary way — {"anyOf":[{"type":"string"},{"type":"null"}]} — carries no type of its own, and Moonshot’s validator refuses the whole request with HTTP 400 tools.function.parameters missing type in anyOf properties, losing the turn on every Kimi model (#641). Nothing in the pipeline supplied one: normalizeSchemaLiterals only removes literals that contradict a type a node already declares. The Moonshot compatibility pass now declares a type where the node already implies one — from properties/required, items, a single-typed enum/const, or a union whose branches all agree — alongside the existing $ref inlining. A node that implies nothing (a bare {}, a lone not, a mixed enum) is left open, because narrowing a schema the client meant to leave open is worse than the rejection. Every other provider keeps the exact wire payload it has today.

  • A streamed answer no longer loses the whitespace it starts with. The inline-reasoning stripper trimmed the leading whitespace of every message it streamed, but stripThinkTags — the same module’s whole-string form, used for the output_text.done snapshot and the stored message item — trims only when it actually removed a tag, and returns an untagged message by identity. So an ordinary answer opening with a newline (a fenced code block, a leading blank line) reached the client without it, while the terminal snapshot beside it kept it: the rendered answer and the stored one disagreed on a route that had leaked no reasoning at all. A first delta made only of whitespace was dropped from the stream outright. The stripper now holds that whitespace until the first visible character, by which point it knows whether a tag was removed, and emits or drops it to match. The hold is bounded: nothing is kept once a tag has been removed, only the new delta is scanned rather than the accumulation, and past 8 KiB of unbroken whitespace the stripper emits what it holds instead of growing further. The observed leak shapes are unchanged — reasoning arrives before the answer, so the removal is always known in time.

  • opencode Zen’s quota headers no longer overwrite the Go plan’s. Both plans share one credential and one selection toggle, but Zen bills at its own endpoint, which is why cooldownScope keeps Zen’s identity where canonicalProviderId folds it into Go. The forwarder harvested the observed rate-limit headers under the canonical id instead, so every Zen response overwrote the Go plan’s entry in rate-limits.json and every Go response overwrote Zen’s — one key holding whichever plan answered last, under the name of the other. Zen’s window could not be read back either: every cooldown-scope consumer looks it up as opencode-zen, an id the file never held. The snapshot is now keyed by cooldown scope, the same identity the cooldown store beside it already uses, so the two cannot drift apart again (#575). Every other provider and variant keeps the key it has today, and a stale entry is replaced by the next response that carries headers.

  • A routed model the router has not loaded now fails locally, not at ChatGPT. A model added to or renamed in user-models.json shows up in the Codex picker as soon as the catalog is rebuilt, but the running router reads its routes only at startup. Until the service restarted, the router forwarded that slug to ChatGPT like a native model, and the turn failed with “The ‘unorouter/gpt-6-astra’ model is not supported when using Codex with a ChatGPT account”. That read as an OpenAI restriction, and the prompt went to OpenAI besides. A user model the registry skipped as invalid failed the same way (#689). No native slug contains a /, so the router now answers a provider/model slug it has no route for with HTTP 400 unrouted_model before the native redirect or passthrough can take it. The message names the slug, says whether its provider is registered and enabled, gives the reason a user model with that slug was skipped, and says to restart with bin/control service restart. Native slugs, native aliases, the native redirect, and routed slugs behave as before. doctor now warns about each skipped user model that has no route.

  • Control Center loads Codex account usage again when both account reads answer. The conflict resolution that merged #662 onto #648 renamed the shared normalizer to partialUsage but left the both-answered call site on the removed usageFromReplies, so every healthy poll threw a ReferenceError and Control Center painted “Some router data could not load” over the snapshot. The test fake answered synchronously from inside the probe’s guarded stdin write, whose catch swallowed the error; it now has a deferred mode that answers on a later tick like a real pipe, and a both-answered test that fails without the fix.

  • Routed models’ turns now render like native ones in Codex. Native models label each assistant message commentary (a progress note before more tool calls) or final_answer, and Codex folds commentary into “Worked for …” and shows the final answer below it. Routed providers never send the label, so every progress note rendered as a standalone answer. The router now labels routed messages from the stream’s item order: a message a tool call or another message follows is commentary, and the last message of a completed response is the final answer, even when only reasoning follows it. A phase the provider sent always wins, text still streams live, and failed, incomplete, or unterminated responses are relayed unlabelled; a stream the upstream breaks off loses the held message frame along with the rest of the turn. The label costs no model tokens, but Codex sends it back on later turns. Chat-translated routes drop it from history. Responses-surface providers (Meta, OpenCode, OpenCode free, GitHub Copilot, DeepSeek Responses) receive it, as OpenAI’s Responses schema allows. Endpoints added with --adapter openai-responses have it removed before the request leaves the router, since nothing shows that their validators accept it.

  • apply_patch calls no longer abort routed turns mid-stream when a model skips LiteLLM’s wrapper. LiteLLM sends native custom tools such as apply_patch to Chat Completions providers as a function with one content string, and relays whatever arguments come back. Models do not always comply: they put content after another key, answer {"input": ...} or {}, or send the raw patch. The router accepted only a leading {"content": "..."} and aborted the already-streaming response, and Codex retried the identical turn until it failed (“stream closed before response.completed”). The router log showed “invalid custom tool arguments done” or “incomplete custom tool argument delta sequence” on DeepSeek V4.1 Flash, DeepSeek V4 Flash, GLM-5.3, and Grok 4.5. The relay now derives the input exactly as LiteLLM does, so Codex receives the same call LiteLLM produced and a malformed patch comes back to the model as an ordinary tool error. A non-string content, a completed item that disagrees, and streamed text contradicted by the final input still fail closed. Reproduced offline against pinned LiteLLM 1.96.0.

  • Switching a conversation back to OpenAI no longer fails on routed item IDs. Routed providers mint their own item IDs (call_..., tool_..., chatcmpl-...), Codex saves them, and OpenAI rejects them on replay with “Expected an ID that begins with ‘fc’”. Before sending saved history to OpenAI, the router now omits an optional id that lacks the native prefix for its item type: fc for function calls, ctc for custom tool calls such as apply_patch, and msg for messages. It preserves call_id, matching results, native IDs, and requests to external providers, so a native-only history is unchanged. Tests cover continuing and compacting a conversation, sessions supplied by the caller or the router, and replaying normalized history. Based on #664 by @webhype.

  • Tok/s counts reasoning tokens exactly when they were generated inside the timed window. The Sep 5 change subtracted reasoning_tokens from the numerator on every route, but the first-token clock already started on the first reasoning delta, so on the OpenAI, Grok, and Command Code routes the reasoning time stayed in the denominator while its tokens left the numerator. A fit of generation time against visible and reasoning tokens over the local usage log put the cost of a reasoning token at roughly the cost of a visible one on those routes (gpt-5.6-sol 21 vs 25 ms, grok-4.6 18 vs 16 ms), proving the thinking ran inside the window; the meter read gpt-5.6-luna at 21 tok/s against about 80 measured, and grok-4.5 at 16 against about 67. Only Muse Spark on the OpenCode Responses route thinks in silence before its first token (0.02 ms per reasoning token in the same fit), which is the route the subtraction had been measured on. The stream transform now records reasoningStreamed — whether any reasoning delta (Responses summary or text deltas, chat reasoning_content / reasoning) was relayed — and reasoning deltas and chat tool-call deltas start the first-token clock like visible text does. aggregateProviderUsage and the Control Center per-event rate keep reasoning tokens when the marker is true or absent (rows written before it existed), and subtract them only when it is false. A reasoning count larger than the output count proves a provider reports visible tokens only (Command Code’s DeepSeek V4 Pro: 62 of 146 rows, e.g. 150 output against 499 reasoning), so the inclusive total is rebuilt first instead of clamping the sample to zero and silently dropping it. Provider totals and billing are unchanged.

  • Meta API routes for Muse Spark 1.3 and its Contributor tier. meta/muse-spark-1.3 and meta/muse-spark-1.3-contributor mirror the Muse Spark 1.2 Meta routes: 1M context compacting at 900K, text and image input, the minimal-to-xhigh ladder defaulting to high, reasoning summaries, and auto-tool-choice. Meta’s model page documents both ids, the window, and image input; the Contributor tier is cheaper because Meta may use its traffic to improve its products.

  • Command Code route for Muse Spark 1.3. commandcode/muse-spark-1.3 follows commandcode/muse-spark-1.2: 1M context compacting at 900K, text and image input, auto-tool-choice, and only the high effort, because Command Code does not document effort values. Command Code also lists the Contributor tier, but no Command Code Contributor route is checked in for either version; it remains available through bin/curate-models commandcode.

  • OpenRouter route for DeepSeek V4.1 Flash. openrouter/deepseek-v4.1-flash takes OpenRouter’s catalog values (1,048,576 context, text and image input, low/high/max) and compacts at 900K to keep DeepSeek’s 128K max-effort completion. It carries auto-tool-choice because DeepSeek rejects forced tool choices in thinking mode. Live verification has not been run; see docs/research/deepseek-v4-1-flash-2026-09-11.md.

  • Preserve tool calls after large fragmented response preludes. Allow one unfinished initial event within the existing 10 MiB bound and match the namespace relay’s limit, so later MCP calls retain their client identities. Prelude timeouts, empty-completion checks and retry safety remain intact.

  • DeepSeek empty-completion guard allows large reasoning after liveness release. Issue #684: Direct DeepSeek V4.1 Flash MCP turns with large reasoning deltas no longer hit the empty-completion byte limit prematurely. After liveness is established (by initial reasoning or content), the guard uses a 10MB limit for incomplete SSE blocks instead of the 1MB pre-liveness limit. This accommodates legitimate large reasoning events delivered in small network chunks while still protecting against unbounded/malformed streams. Fixes #684.

  • DeepSeek V4.1 Flash is available on four providers, alongside V4. DeepSeek released V4.1 Flash on 2026-09-10. New routes: deepseek/deepseek-v4.1-flash (1M window, image input, thinking with low/high/max), opencode-go/deepseek-v4.1-flash (OpenCode’s renamed id; the launch-day deepseek-flash id is deprecated and not routed), nousresearch/deepseek-v4.1-flash (sized to the Portal’s served 262,144 window), and commandcode/deepseek-v4.1-flash (text-only until image input is verified at the Provider API). Every V4 route stays listed. On the DeepSeek API the V4 Flash ids are now served by V4.1 Flash, and from 2026-09-14 04:00 UTC deepseek-v4-pro requests are served by V4.1 Flash too. Ollama Cloud and ClinePass do not offer V4.1 Flash yet. Evidence is in docs/research/deepseek-v4-1-flash-2026-09-11.md.

  • Routed workers expose read-only request progress. bin/control activity [thread-id] reports active requests and bounded recent outcomes behind the caller capability, including observed stream events and cancellation causes. Quiet polling never ends a request, and the shipped agent guidance separates polling intervals from worker deadlines.

  • Grok repairs require a successful response terminal. Failed, incomplete, and truncated streams return a terminal error without releasing withheld client actions or private final answers. Reasoning remains live.

  • Grok gateway stream errors reach Codex as terminal failures. Untyped gateway errors are normalized without exposing upstream diagnostics or appending empty message closes, and request activity records the failure.

  • Grok compaction keeps the Grok transport bounds, and a failed Grok turn stays a failure when the client leaves. A Grok OAuth compaction now uses the same long-idle router pool and gateway timeout as a streamed turn, so a long summary is no longer cut off by the 300-second Undici or 600-second gateway defaults. A Grok turn that already delivered a terminal error is metered and reported in /activity as a failure even if the client then closes the still-open stream, which the WebSocket edge does five seconds after a failure; it used to read as a user cancellation.

  • Grok OAuth streams survive long reasoning pauses end to end. After the prologue is released, a Grok OAuth turn uses a ten-minute stall bound (CODEX_ROUTER_GROK_STREAM_STALL_MS, positive milliseconds; invalid or timer-unsafe values keep the default) instead of the 30-second prelude. Every hop on that path now outlasts the bound: the router’s gateway pool and the forwarder’s xAI pool used to end a silent stream after five minutes with UND_ERR_BODY_TIMEOUT, and the gateway after ten. While the stream is silent the router also relays a response.in_progress heartbeat (CODEX_ROUTER_GROK_HEARTBEAT_MS, default one minute), because Codex abandons a stream after five minutes without a data event and sends the whole turn again, billing it a second time. Other providers keep their existing bounds and receive no heartbeat. Visible streams are never replayed.

  • A failure an upstream states before any content reaches the client at once. The empty-completion guard held a pre-content error, response.failed, or response.incomplete until its prelude limit and then reported a second, invented failure. It now releases the upstream’s own verdict immediately and never retries it. On the WebSocket edge, a stream that stays open after a failure is drained for at most five seconds, so the client’s next request on that socket is not queued behind it.

  • A Grok attempt is sealed by its first terminal event. A tool call, text, or second terminal arriving after response.completed can no longer add a client action or change the outcome, and the forwarder stops reading there. An optional progress-only retry that fails, is incomplete, or ends without a terminal keeps the first, already-successful answer instead of replacing it with an error. Provider-reported counts from a rejected attempt are logged.

  • Fast is never sent where it was not offered. A routed body keeps service_tier only when the route serving it advertises that tier, so a priority request on grok-oauth/grok-4.6 no longer reaches a failover candidate or a compaction for another route, while a curated model that advertises a tier still receives it. A usage row covering two attempts no longer carries one attempt’s tier.

  • Request activity is accurate on every path. The routed transport retry and failover candidates count as observed attempts, a candidate waiting for headers is attributed to itself, and a response.completed carrying a failed or incomplete status settles as failed with terminalStatus. Client disconnects on native image/search and embeddings requests settle as canceled; a canceled native stream used to record completed with status 200. codex-router.ps1 activity is the Windows entry point. Grok-only diagnostics no longer follow a turn onto another provider, and run reports total billed tokens for collapsed retries.

  • Structured patch paths that a native header trim would change are refused, including trailing U+0085. The CI test job and npm test now fail a hung test instead of holding a runner for six hours.

  • The ChatGPT Web provider is removed: using it risked an OpenAI account ban. chatgpt-web routed Codex turns into an unofficial browser automation of chatgpt.com, driven through a separately installed launcher on loopback port 17841. Automating a ChatGPT account that way is outside OpenAI’s terms of service, and enforcement falls on the signed-in account: a suspension or permanent ban costs the operator their ChatGPT subscription and their Codex access with it. That is not a risk this router should carry behind a warning, so the integration is gone rather than deprecated. Removed the provider definition, its seven curation routes, the launcher metadata and catalog handling in curation and discovery, the user-model slug rule, and the setup documentation. The direct Responses contract existed only to serve it and goes with it: src/direct-responses-provider.mjs, the router’s directResponses dispatch and error passthrough, the failover and vision-bridge exclusions, the registry validation, and the client publication filter. No other provider declared directResponses, codexOnly, or explicitSelection, so every route now takes the ordinary routed path. Anyone who curated chatgpt-web/* rows should stop using them and delete them with bin/curate-models chatgpt-web --remove <slug> before updating; after the update those entries reference a provider that no longer exists and are skipped at load with a Skipped user model: ... references unknown provider chatgpt-web warning, so nothing breaks, but the stale rows stay in user-models.json until removed.

  • A new native model no longer stays invisible after a Codex upgrade. The account catalog endpoint gates its model list on client_version, but the router replayed the ETag it had cached under the previous version. The server answered 304, and the router then restamped that pre-upgrade body with the new version — so cacheIsFresh passed forever, the model fingerprint never moved, drift never fired, and GPT-6-Astra never reached the picker while the router was installed (issue #645). A client_version change now sends an unconditional request, and a 304 answering an unconditional request is treated as a failure rather than blessing the stale body. Revalidation within one client_version is unchanged.

  • An outdated codex on PATH can no longer strip a model out of Codex’s own cache. The same endpoint gates its list on client_version — measured live, 0.150.0 is not offered gpt-6-astra while 0.153.4 is. When the Codex the router resolves is older than the client that last wrote models_cache.json, the refresh now declines to write (stale-client) instead of replacing the richer list with its own poorer one.

  • A missing native capture now counts as catalog drift. With native-models.json absent, nativeCatalogDriftDetected() returned “nothing to compare” and the startup reconciliation never republished, stranding the picker on whatever was last written even as the account gained models. A missing capture alongside a valid account cache is maximal drift, and republishing re-captures from that cache.

  • Native collaboration relay auth and quota failures preserve their semantics. When the native Codex relay needed to open a routed subagent payload receives HTTP 429, the router now preserves that status instead of rewriting it to 502. The exact account-and-ciphertext refusal is remembered for a short, bounded interval so immediate client retries fail locally without spending another native relay request; other accounts and payloads remain isolated. A native 401 is also preserved with a sanitized local error, allowing Codex’s own ChatGPT authentication recovery to refresh the session and retry without exposing the upstream response body.

  • Five more coding clients can be published to from the Harness page. opencode, pi, omp (oh-my-pi), Command Code, and Hermes Agent each keep their providers in a configuration document the user also owns, so one shared publisher (src/routed-harness-*.mjs) writes the single codex-router provider key each of them reads and leaves every other byte alone. Set up installs the client’s CLI where this router can (updating a Command Code older than 1.30.0, the first release that reads providers.json) and publishes the whole routed catalog in one action; omp and Hermes install from their own instructions first; control client-setup <id> and control client-disconnect <id> are the same thing from a terminal. Clients that speak the Responses API reach the authenticated loopback /v1 path with the router’s own slugs; Command Code and Hermes, which have no Responses client, reach the same Anthropic Messages surface Claude Code uses with codex_router/anthropic/<slug> ids. Enabling a provider, storing a key, or curating a model republishes all five alongside the existing clients, and a caller-capability rotation refreshes them. YAML documents are spliced by line range so comments and sibling providers survive; a JSON document the router cannot round-trip is refused rather than reformatted; a codex-router provider whose base URL this router did not issue is never replaced or removed; and opencode’s default model is claimed only when the user has not chosen one. Devin CLI and T3 Code are deliberately not rows: Devin CLI has no custom base URL, and T3 Code drives whichever official CLI is already routed.

  • Command Code forced tool choices now use the same bounded alias as the tool definition. The 64-character compatibility added in #643 shortened provider-facing tool names but left an object ool_choice at the client’s original spelling, so a forced long tool could still be rejected as unknown. Forced choices for both Command Code variants now pass through the same reversible namespace alias map as the advertised tools.

  • Router-injected subagent interrupts now keep unique call IDs across turns. Streamed collaboration cleanup previously numbered injected interrupt_agent calls from call_router_interrupt_1 inside each request-scoped transform, so a later turn could reuse an ID still present in Codex conversation history. Stream and non-stream injection now share a UUID-backed call-ID generator, preserving call/output pairing across long multi-turn agent sessions.

  • OpenCode Go Muse Responses routes can continue after a completed web search. Live replay probes for Muse Spark 1.2 and 1.3 Contributor confirmed that the Responses upstream accepts completed web_search_call history even though neither route advertises a new search tool. Both exact routes now declare supportsSearchHistory: true, so follow-up and compact turns preserve that verified history instead of failing locally with model_search_not_supported (issue #639).

  • Startup now repairs drifted routed-agent definitions. The post-health native-catalog reconciliation also compares Codex Router’s managed agent files with the current routed-model, visibility, and subagent settings. A missing, stale, unprotected, or extra managed definition triggers the same locked picker republish even when native model metadata itself is unchanged. Foreign/unreadable Codex transport state remains write-free.

  • The dashboard 24H chart now covers the exact rolling 24-hour window. The hourly rollup added in #644 aligned bars to clock hours but began at the next whole hour after now - 24h, dropping up to almost one hour of valid traffic. The router and legacy renderer fallback now retain both partial edge hours while filtering events to the exact half-open [now - 24h, now) window.

  • Command Code no longer rejects a routed turn over a long tool name or a recursive schema. A Codex turn carrying a client tool such as mcp__openai_api_key_local_confirmation__confirm_openai_api_key_local_destination (80 characters) was refused before generation with HTTP 400: `name` must be at most 64 characters, got 80, and the turn behind it then hit Recursive JSON schemas are not currently supported (issue #626). chatProviderToolSurface() now sends both commandcode and commandcode-messages through the router’s existing bounded alias route at 64 characters, and both variants join the non-recursive schema repair. The aliases stay deterministic and reversible, so a call the model makes under the bounded spelling is restored to the client’s own tool identity. Every other non-Groq provider keeps its tool surface byte for byte.

  • The macOS tray no longer spawns a Node process every second to read health. refreshActivity() polls health once a second and ran bin/control health --json each time, which boots Node and control.mjs’s whole module graph to make one loopback GET: about a second of CPU per call, so an idle tray pegged a core for as long as it ran (measured 93.6% CPU, 1450 ms wall and 1000 ms CPU per poll, against 1.4 ms for the same GET over plain HTTP). The tray now reads the protected health leaf directly with URLSession, behind the same caller key and the same 3 s timeout. It decodes every HTTP response, so a 503 still carries the degraded list and service rows; transport failures throw and are recorded exactly as a failed control health was. control-health.mjs remains the contract for the CLI and the Control Center. The bounded one-second polls during MLX install, runtime update, and vision-bridge pull still shell out and are unchanged.

  • Tok/s meter now excludes reasoning tokens and hides during generation. observedTokensPerSecond used full outputTokens while TTFT waited for the first visible token. Providers often include reasoning_tokens (silent thinking) in output_tokens, inflating reported speed (~+24% measured on Muse Spark free: 502 output with 98 reasoning → 163.6 tok/s vs ~131.6 when reasoning excluded). normalizeTokenUsage now extracts reasoningTokens from output_tokens_details.reasoning_tokens / completion_tokens_details.reasoning_tokens / reasoning_tokens when present. aggregateProviderUsage and Control Center tokensPerSecondFromEvent subtract reasoning from the tok/s numerator to match industry TTFT on first visible token. Provider totals and billing still count full output. Panel and tray status chips now hide the measured median while generating, showing only ”— tok/s” and “Appears after a metered reply” during active turns. Historical events without reasoningTokens are unchanged. Also fixes chat first-token detection: chat.completion.chunk often has no type field, so checking type before delta meant chat TTFT never fired and tray speed stayed null.

  • Startup prunes enabled-providers.json entries that cannot authenticate. Unknown ids (version skew) and recognised providers without a credential were left in the selection file forever, so the dispatcher still treated them as enabled and answered provider_api_key_missing. Startup now rewrites the file to the configured subset (and deletes a file that named only unknown ids, preserving the no-file show-all fallback). Idempotent; discovery-disabled and show-all modes are left alone.

  • macOS KeepAlive no longer uses launchd Adaptive, which starved startup. Adaptive only boosts on XPC transactions; the router is localhost HTTP, so the job stayed at Background. Node OAuth/API forwarders then missed the 30s health budget, KeepAlive restart-looped, and tray Update / doctor —fix waited 300s on /health and rolled the checkout back. The LaunchAgent now renders ProcessType=Standard. Do not revert to Background (LiteLLM starved to ~4% CPU in fb40f8c).

  • Grok OAuth post-tool turns no longer stay silent while their repair is certified. The forwarder now opens a streamed Chat Completions response as soon as xAI returns its headers and relays reasoning immediately, while still withholding the short visible sentence that may be a progress-only stop. A certified retry supplies the final answer or tool call; an invalid repair ends the already-open stream with one terminal error and never emits [DONE]. Retry and failure logs now include per-attempt header, first-event, and total timings plus the safe x-grok-req-id, so upstream silence can be distinguished from router-side staging without logging prompt or output.

  • Routed reasoning models no longer leak <think> chains into the visible answer. Qwen (and other reasoning models bridged through LiteLLM’s chat-completions path) sometimes emit their chain-of-thought inline in the content channel as <think>...</think> instead of on the reasoning channel, so LiteLLM relays it as output_text and the answer renders behind the model’s reasoning — or behind a bare </think> when the open tag is consumed upstream but the close is not. A new ReasoningTagStripper egress transform (src/reasoning-tag-stripper.mjs) removes <think>...</think> spans and orphan tags from the message text across the streamed output_text.deltas (buffering a tag split across deltas), the terminal output_text.done, and the stored message item. It covers the reasoning-delimiter family the model varies to (think/thinking/reason/reasoning), leaves the structured reasoning channel and every non-message item untouched, and passes clean answers through unchanged. Routed providers only.

  • Routed turns that mix assistant text and a tool call no longer render twice. LiteLLM’s chat-completions-to-Responses bridge (use_chat_completions_api) could leave the assistant message item open across a function_call item and emit its output_item.done late, so Codex committed the streamed text once while it was live and again when the delayed close arrived — the same sentence appeared twice, with the tool call after it. It surfaced intermittently on qwen-plan turns that both speak and call a tool. A new ItemLifecycleNormalizer egress transform (src/item-lifecycle-normalizer.mjs), added last in the routed-provider response pipeline, holds events for a newly-opened output item until the currently-open item closes, restoring the Responses contract that every item is done before the next output_item.added. It reorders only — no event body is added, dropped, or rewritten — and engages only when the upstream actually interleaves; clean streams and native OpenAI streams pass through unchanged.

  • A Grok OAuth outage now terminates streamed Codex turns instead of leaving a stale Working badge. After the local gateway has exhausted its bounded retries, a final Grok 5xx on an explicitly streamed Responses request is returned as one terminal SSE error event. Non-streaming Grok calls, actionable 4xx failures, and every other provider retain their HTTP status and JSON body, while usage metering still records Grok’s real failure status.

  • Empty tools: [] is now stripped for all API-forwarder routes, not only qwen38-community. Strict upstreams (vLLM >=0.20 Pydantic) refuse an empty tools array. Codex sends tools: [] on compaction and plain chat, so without this strip every compaction against strict providers 400s. The repair was previously applied only to the qwen38-community profile; it is now applied to all routes so compaction and plain chat work against any strict provider. Dangling tool_choice is dropped only after stripping an empty tools: [] array (not when tools was never present). The qwen38-community profile additionally drops tool_choice when tools is absent, as that endpoint refuses “When using tool_choice, tools must be set”. Real non-empty tool arrays and their tool_choice are forwarded unchanged. (Fixes #588)

  • Grok OAuth reasoning summaries now appear in Codex while the model is thinking. LiteLLM can open an empty assistant message before Grok’s first reasoning delta, then assign a different item id to every delta and return the terminal reasoning item in Chat Completions shape. Codex discarded that orphaned lifecycle and showed no progress until answer text arrived. The router now repairs only Grok OAuth event streams into one canonical Responses reasoning item, preserves valid streams, and keeps the following message and tool output indexes consistent.

  • A Retry-After expressed as a date is now honored. RFC 9110 allows Retry-After to carry either delay-seconds or an HTTP-date. The router read the header as a bare number, so a dated value became NaN and every consumer discarded it: a burst 429 that named its own window did not qualify for failover, no provider cooldown was recorded, and the translated error lost its “retry in about Ns” hint. The turn therefore died on a provider that had said exactly when it would be back, and each later turn paid the same refusal again for the length of the window. The header now goes through the same resetAt parser the x-ratelimit-* snapshot already used, which reads delay-seconds, an HTTP-date, and a Go-style duration alike, and api-forwarder’s private copy of that conversion is gone in favor of the shared one, so there is one place the header is read.

  • A 429 that named no wait no longer advises retrying “in about 0s”. headers.get answers null for a header that was never sent and Number(null) is 0 — a finite value, so the rate-limit message quoted a zero-second wait as though the provider had asked for one. Absence and an unparseable value now read as no window at all, while a delay of 0 (which RFC 9110 permits) and a date that has already passed are kept as the zero they are, because a provider saying “now” is not a provider saying nothing. Only a positive wait is worth wording, so both zeroes reach the operator as “Wait a bit and retry.”

  • Pin Gemini 3.8 Flash, Muse Spark 1.3, Claude Fable 5.1, and additional models across providers. Confirmed routes 2026-09-03: Gemini 3.8 Flash on OpenRouter, Command Code, Nous Research, and Venice; Muse Spark 1.3 and 1.3 Contributor on OpenRouter, Nous Research, and opencode Go Responses; Claude Fable 5.1 on OpenRouter, Command Code Messages, Nous Research, and Venice; Command Code Qwen3.8 Max 0902 and GLM-5.3-Flash; opencode Go Messages Qwen3.8 Flash. No new multiAgentVersion: "v2" stamps. Updated Command Code curation allowlists for the new Chat and Messages ids.

  • OpenCode Free Responses now lists Muse Spark 1.3 Contributor Free. The anonymous Zen Responses route now includes both muse-spark-1.2-contributor-free and muse-spark-1.3-contributor-free. Same documented exception: no API key needed, but the free catalog can vanish and Meta may train on these turns.

  • Qwen Plan collaboration calls are now restored to namespaced shape. Qwen Plan (Alibaba Model Studio Token Plan) returns Codex v1 collaboration calls as pre-flattened names (multi_agent_v1__spawn_agent with no namespace), which Codex Desktop rejects. The Responses adapter now rebuilds the namespace from flattened tool names that came from actual type: "namespace" entries or from known collaboration namespaces (multi_agent_v1, collaboration), so Desktop receives spawn_agent in the multi_agent_v1 namespace instead. MCP-style names like mcp__node_repl__js are left unchanged unless they came from a real namespace tool, avoiding false restoration of tools that happen to contain __ (#568).

  • An exhausted opencode Go plan no longer withdraws opencode Zen, or the reverse. Provider cooldowns were keyed by the canonical provider id, which is the right identity for a protocol variant — opencode’s Messages and Responses routes are one subscription behind two wire formats, so one being empty means all of them are. Zen is the exception the code already names in cooldownScope: it shares Go’s credential and selection toggle but is billed separately at its own endpoint. Filing both windows under the parent meant a closed Go plan silently answered a Zen turn from a different provider (reason=cooled_until_...), and an exhausted Zen balance withdrew the whole Go subscription — in both directions a paid route taken away for a window its provider never named for it. Windows are now keyed by cooldown scope wherever they are recorded, read, cleared, or ranked — including the vision bridge’s engine selection, which read a window its own writer files under a different key — and api-forwarder passes the provider id through unresolved so the scope is decided in one place. Protocol variants still share a window, which is what the existing family test holds.

  • The Windows Control Center no longer flashes PowerShell windows. A console process spawned by a parent that has no console of its own — the Electron Control Center and the tray — gets its own window unless windowsHide is set, and four background helpers were missing it. The one every private write reaches meant a burst of visible windows on each status refresh, which is why it showed up on every message sent (#565). The private-file ACL writer and its verifier, scheduled-task registration, and the tray’s task-state poll now all hide. Interactive prompts are untouched: they inherit a console the operator is already looking at, and hiding it would ask for input through a window nobody can see. A source-level test holds the line, since the failure is invisible off Windows.

  • A failed Windows scheduled-task launch now explains itself. Node reports a module it cannot read exactly as it reports one that is not there — Cannot find module with MODULE_NOT_FOUND — so an install whose checkout the task’s own token cannot read looked like a missing src\start.mjs that the operator could open in front of them (#548). Readiness failure now checks whether the path the loader named exists: if it does, it reports a permission or token problem and how to confirm it, and if it does not, it reports an incomplete checkout. When the log carries no such evidence the original wording is kept rather than a cause being invented. This improves the diagnosis only; the underlying ACL condition is not yet reproduced.

  • The Codex picker now shows vendor groups. Codex sorts its model picker by each entry’s priority, never by catalog order, and routed models reused the same low integers as native GPT entries, so DeepSeek and Grok routes interleaved with GPT models and the vendor grouping the catalog always carried never reached the screen (#544). Routed models that are not certified v2 spawn routes are now published in a band above the highest visible native priority, in vendor-group order. A certified v2 route keeps its authored priority, because that value is what keeps it inside Codex’s small spawn-model override window; renumbering it would crowd it out. Only the published entry changes: failover ranking, the vision bridge, and every other client keep reading the registry’s authored value.

  • Grok OAuth no longer replays a known progress-only sentence after an aborted follow-up. The first affected turn remains fully live. Once a conversation has actually produced the progress-only shape, later user-message turns buffer only a short visible prefix while leaving the response head and preceding reasoning live. A real tool call or longer answer releases that prefix immediately; an upstream abort reports its terminal stream error without first committing the same status sentence to Codex again. Evidence is retained in a bounded in-memory conversation set.

  • Request bodies are decompressed off the event loop, and an oversize zstd frame is refused from its header. Codex zstd-compresses every request, so the router inflated a buffer that grows with the conversation on the thread serving every other request, through the one-shot synchronous decoder. That path is the one implicated in an intermittent native abort on Windows (#465: exit 0xC0000409 with no JavaScript frame, always late in a long session, always right after a successful turn), which took every listener down with it. The router now reads the size a Zstandard frame declares and answers 413 before any native decoder runs when it exceeds the decode cap, and inflates everything else through the asynchronous decoder under the same cap. The crash has not been reproduced outside Windows, so this is defensive hardening rather than a confirmed fix; the issue stays open for a crash dump.

  • Antigravity OAuth now uses an operator-owned, fail-closed sign-in. The router requires one matching Google Desktop-app client ID/secret pair, collects it through an ephemeral IPv4 loopback listener, and stores the pair privately with the refresh session on macOS, Linux, and Windows. It no longer borrows the official agy/IDE credential or identity, copies a secret into service definitions, or provisions a project during sign-in. The route stays disabled until a separately confirmed, quota-consuming live probe succeeds with the truthful codex-router identity; incompatible legacy records are preserved until the operator explicitly disconnects them. Its forwarder is not spawned or health-gated before proof. A passing probe is persisted as a generation-bound pending activation that remains unpublishable until the restarted local stack is fully healthy; failed startup, process death, credential replacement, and disconnect cannot promote it. Thus an unused provider port cannot take down the router or leave an unready route exposed. Pre-activation v2 proof records are deliberately unverified and require a fresh explicit probe rather than being grandfathered past this readiness gate. An authorization-code invalid_client rejection also tombstones only the exact credential-and-proof snapshot submitted with that same client pair; concurrent replacements and unrelated attempted clients remain untouched. The Control Center gives the live request, restart readiness, exact-generation confirmation, and client publication one shared ten-minute deadline, with a separate one-minute command-runner cleanup margin, so a wedged command tree can be terminated without shortening the cooperative activation budget. On Windows, bounded children start suspended in a kill-on-close Job Object, get only dedicated standard handles through an explicit inheritance allowlist, and remain attached to the console for interactive operations; restricted PowerShell hosts fail before launching the mutation.

  • Support bundles now always omit historical logs. A log may contain a credential that was later rotated or deleted, so current-secret discovery cannot prove any historical tail safe. Bundles retain redacted generated diagnostics and log-file metadata without copying arbitrary log contents; the former --include-logs switch remains accepted as a deprecated no-op.

  • OpenClaw is now a one-click routed client. The Harness page can install the official openclaw@latest package when it is missing and publish every selected router model through a private codex-router Responses provider. The integration preserves unrelated OpenClaw config and user-selected defaults, participates in shared catalog refresh and caller-key rotation, and is available as --target openclaw on POSIX and Windows.

  • Muse Spark 1.2 Contributor no longer fails on recursive Codex tool schemas through OpenCode Go. Console Go rejects the whole Responses turn before inference when this model receives a recursive local JSON-Schema reference. The router now breaks only cycle-closing reference edges for the paid Muse route, preserving definitions, acyclic references, and sibling constraints. Other Console Go Responses models retain their existing schema payloads until they demonstrate the same restriction.

  • OpenCode Console Go chat models no longer reject ambient hosted-search options. Codex can attach web_search_options even when the selected OpenCode Go model does not advertise hosted search; Console Go forwards the unknown field to its strict Chat Completions backend, which rejects the entire turn with HTTP 400. The router now removes only that unsupported option for the opencode-go provider on ordinary and compaction requests, with the API forwarder enforcing the same boundary. Other search payloads and providers that accept the option remain unchanged.

  • Strict generic providers no longer receive unadvertised hosted-search extensions. Codex may attach web_search, web_search_preview, web_search_options, and search-only include entries even when the chosen routed model does not advertise search. The router now removes only those hosted-search artifacts at its managed Responses boundary for unsupported runtime-generic routes, while preserving ordinary functions (including one named web_search), capable models, and direct gateway traffic. An explicit request that can no longer satisfy tool_choice fails locally with a named 400 instead of reaching a strict upstream as an ambiguous request. Failover also preserves the source model’s hosted-versus-standalone search mode for search-capable turns and refuses to guess after search history exists.

  • Reinstalling over a state directory owned by another checkout no longer deadlocks. The installers recorded the state directory’s new owner only after the background service reported healthy, while the service itself refuses to boot for as long as the record still names another checkout (foreign_state_owner): an install that followed one from a different checkout crash-looped for the full 300-second readiness budget, rolled back, and repeated on every retry, and deleting the whole state directory — every stored provider key with it — was the only escape. The record now precedes the service step it describes, so the service boots against its own ownership. The ownership override the installers run under is scoped to that full, ownership-transferring install on both platforms: a prepare-only run meets the guard like any other writer (and the Windows installer restores the caller’s environment when it finishes). Linux readiness also fails fast once the service manager’s restart counter shows a crash loop, instead of waiting out the whole budget, and names the journal and the router log in the error; the counter query itself is killed inside a bounded slice of the remaining budget, so a blocked systemctl cannot stretch the wait, and a health answer that lands as the threshold is crossed still wins over the crash-loop verdict.

  • OpenCode Go Kimi K2.7 Code now accepts current Codex tool schemas. Its Moonshot-backed validator receives the same bounded decorated-$defs repair as the first-party Kimi routes. The compatibility gate names only this observed Console Go model, so unrelated OpenCode Go routes keep their tool payloads unchanged.

  • Translated tool streams no longer expose bridge-only assistant turns. Bounded, fail-open normalization removes only empty assistant envelopes that LiteLLM’s Chat Completions and Anthropic Messages bridges corroborate with a completed tool lifecycle, then compacts the affected output indexes. Real text, reasoning, refusals, malformed or ambiguous streams, and native Responses routes remain untouched. Non-streaming bodies receive equivalent bounded, fail-open handling under an exact terminal-output proof. Direct DeepSeek keeps its narrower provider-specific proof: the historical no-prelude bridge drops its corroborated private-reasoning blank, while the current bridge reconstructs candidate-attached reasoning under the terminal reasoning ID and removes only the separately corroborated blank output.

  • Live model certification now proves the requested route. Compatibility and smoke probes disable router failover, so a healthy alternate can no longer certify a broken preset. Direct probes retained GLM-5.3-Flash on OpenCode Go, OpenRouter, and Z.ai Coding; the unproved Command Code, Nous Research, and Venice Flash presets were withdrawn. Command Code’s Ox Alpha id returned model_unavailable on every exact surface, while the available Venice account was billing-blocked before its Ox route could be certified, so neither Ox preset ships. Provider discovery remains available for explicit per-machine curation without presenting catalog presence as wire proof. Curated models now inherit a request profile only when every checked-in route in that provider family has the same non-empty profile, so a model-specific repair such as OpenRouter Flash’s ox-alpha profile cannot leak onto an unrelated model selected from the same catalog.

  • Router retention and concurrency now stay bounded without crossing native accounts. Request bodies and buffered upstream errors have explicit byte ceilings, active turns retain truthful accounting after tray records expire, and a separate 24-hour execution deadline protects abandoned work. Encrypted collaboration relays coalesce only within the same resolved native account; one canceled waiter cannot stop another, while the shared read is aborted when every waiter leaves. Aggregate limits and cache counters are visible only through caller-authenticated health.

  • Qwen3.8 Flash and GLM-5.3 full are now pinned on listed providers. Live catalogs confirmed 2026-08-27: OpenRouter qwen/qwen3.8-flash and z-ai/glm-5.3; Command Code Qwen/Qwen3.8-Flash and zai-org/GLM-5.3; QwenCloud/DashScope qwen3.8-flash; Nous Research qwen/qwen3.8-flash; Venice z-ai-glm-5-3; Z.ai API glm-5.3-flash. GLM-5.3-Flash was already shipped in #466; this adds the full GLM-5.3 on OpenRouter, Command Code, and Venice, plus Qwen3.8 Flash on OpenRouter, Command Code, Nous Research, and Qwen Plan, plus GLM-5.3-Flash on zai-api.

  • OpenCode Go’s Ox Alpha preview has graduated to GLM-5.3-Flash. The authenticated catalog now publishes glm-5.3-flash and reports the old ox-alpha-free ID unavailable, matching OpenCode’s current Chat Completions table and Z.ai’s reveal. The picker now exposes opencode-go/glm-5.3-flash as the named, metered 1M-context multimodal model; existing opencode-go/ox-alpha and locally curated opencode-go/ox-alpha-free selections migrate through static aliases, and the preview’s measured low/high/max effort normalization remains attached to the named route. Codex now compacts this route conservatively at 400K after large live multimodal histories returned empty completions before the generic 85% point of its advertised 1M window.

  • OpenRouter now ships Grok 4.6 as a listed route. The checked-in pin is openrouter/grok-4.6, upstream id x-ai/grok-4.6, 500,000 context with auto-compact at 440,000, text+image input, and the low/medium/high reasoning ladder (matching Command Code, not Nous Research’s xhigh-ladder route).

  • README: Ox Alpha availability updated. No checked-in Ox Alpha preset remains. The withdrawn OpenCode Free, OpenRouter, and Nous routes are gone; Command Code directly reported its id unavailable, and Venice could not be wire-certified through the available account’s billing gate. OpenCode Go, OpenRouter, and Z.ai Coding retain their direct-proven named GLM-5.3-Flash routes.

0.5.0

  • Grok 4.6 ships on opencode Go. OpenCode’s current Go list and endpoint table publish grok-4.6 on /zen/go/v1/responses (not chat, not messages). The checked-in route is opencode-go-responses/grok-4.6, 500,000 context with auto-compact at 440,000, text+image, and the low/medium/high/xhigh ladder models.dev publishes for this id, defaulting to high. No Free twin exists. Grok 4.5 stays on the same Responses variant.

  • Nous Research now lists ~27 new models including Hermes 4, free portal routes, and coding flagships. The checked-in registry adds nousresearch/hermes-4-405b and nousresearch/hermes-4-70b, six free-tier routes (longcat-2.0-free, laguna-s-2.1-free, laguna-xs-2.1-free, step-3.7-flash-free, hy3-free, solar-pro4-free) tagged Free in the Models page, plus coding flagships (DeepSeek V4, Kimi K3, Qwen 3.8/3.7 Max, GLM-5.3/5.2, MiniMax M3, Claude Opus/Sonnet/Fable 5, GPT-5.6 Terra, Gemini 3.7 Flash, Grok 4.6, and others). 28 listed models total including the existing ox-alpha. Routing still requires a Nous Portal API key (the :free ids are billed through the portal credential, not anonymous like OpenCode Free). This is not the full 372-model catalog.

  • Subagent selection is honoured again. applyMultiAgentSettings only ever demoted: it read disabled and hidden and nothing else, so the three modes documented in .claude/skills/codex-subagents/SKILL.mdproven, selected, all — were inert and every selection an operator had made was silently discarded. An install running mode: selected with twenty routes enabled had four spawnable and one agent definition on disk, while subagents status cheerfully reported the twenty. The modes work as documented again: an explicit off still beats every mode, a hidden model is never promoted, and only an explicit choice promotes — a machine-local probe still promotes nothing on its own. On upgrade this changes what Codex is offered: an install sitting on mode: all, or on selected with a stale enabled list, will advertise those routes as subagents again, which is what the setting always said it would do. mode all remains “every non-hidden model, regardless of whether it works” — verify a route with the agent check before relying on it (PR #439).

  • Running the test suite no longer clears the operator’s subagent definitions. Four tests in test/control.test.mjs pointed MODEL_ROUTER_STATE_DIR at a temporary directory but left CODEX_HOME alone. subagents set republishes the catalog, and the agent definitions it writes are keyed off CODEX_HOME, so every npm test emptied ~/.codex/agents on the machine running it — seventeen definitions before, none after, restored by the next publish, with a doctor FAIL as the only trace. The publish that clears them also says so now, rather than emptying the directory in silence (PR #439).

  • A provider reachable only through the proxy no longer reads as a broken one. The Control Center is launched by the desktop session, so it inherits HTTP_PROXY from the login environment but nothing telling Node it may use it — the address and the permission to use it are separate answers. A discovery child then dialled the provider directly and its connect timeout was reported as the provider failing, which is how a reachable Venice catalog came back as fetch failed. Children spawned by the app now read the opt-in the install manifest recorded, and only for the install that recorded it; only the opt-in is restored, never an address (PR #439).

  • The Models page is one list. Two sections both changed what ended up in the picker, six words described three concepts, and a row jumped to a different group the moment its switch was flipped. Provider accounts collapse into a connections strip, one Add models dialog searches every connected catalog, and row order no longer depends on the switches. The Subagents column stopped offering a compatibility test that could not enable anything — turning the switch on now selects the route and the router publishes it, one click from off to spawnable. docs/SUBAGENT-CERTIFICATION.md records what the five-check certification can and cannot establish, including that checks 3-5 cannot complete while Codex is signed in with a ChatGPT account, so the next reader does not spend provider quota re-learning it (PR #439).

  • Windows startup now fails fast when the scheduled task is dead instead of polling health for its whole budget. Task Scheduler can keep a stale instance entry (or a Running state) after the launcher tree behind it has died, so service start used to spend its entire readiness timeout waiting for a router that nothing would ever start (issue #384, PR #387). Readiness now reads the task from two places — the COM instance enumeration, and a direct scan for a live process whose command line references the generated launcher — and once both stop reporting a live launch for longer than a short grace, fails with the task’s own LastTaskResult instead of a generic timeout. A task-state query failure is inconclusive and never fails the wait by itself, so a restricted shell still receives the full health budget rather than a false failure. The health answer itself is normalized to one contract before the guard trusts it — healthy only when the router actually answered, whether the probe resolves or rejects — and a Windows interpreter probe that merely times out under process-launch contention, which is not evidence of a broken virtual environment, is retried once with a wider bound before install reports a condition that was transient all along.

  • ChatGPT subscription access for non-Codex clients is now an explicit, one-time local authorization. A discovered Codex auth.json no longer silently lets DeepSeek Harness, Gemini CLI, or another caller-key client spend the account. After codex login, the user runs model-router codex chatgpt-session enable once; an owner-only, credential-free marker applies that decision to every client on the shared loopback router plane. Disabling it revokes native GPT publication everywhere without signing Codex out. A missing or malformed authorization and a missing or expired session both fail closed. External model routes and Codex’s own session pass-through are unchanged.

  • Ox Alpha initially shipped on six routes. The stealth 1M-context reasoning model was checked in for opencode-free (no key), opencode-go, openrouter, commandcode, nousresearch, and venice, each under the upstream id that provider’s own live catalog publishes. All six advertise 1,048,576 tokens with 131,072 of output, text+image input, and a low/high/max effort ladder defaulting to max — the model always thinks, and its upstream refuses any other rung by name (“please use low, high, or max”). Venice’s catalog advertises a fourth rung the model rejects; the model wins, and the disagreement is written down rather than silently resolved. A new ox-alpha request profile clamps the requested effort onto those three rungs, which is load-bearing rather than defensive: a Codex older than 0.143 has no max in its enum, so the catalog sends the clamped xhigh and every turn would otherwise 400. Only the credential-free route carries curated announcement copy; the other five used the automatic announcement once their provider was credentialed.

  • Venice and Nous Research (Hermes) are new API-key providers. Both are selectable through install.sh --providers, providers enable, and the tray, both ship a provider mark, and both are covered by doctor. Venice carries a plan note because a free Venice account has no API entitlement at all — API access needs a Pro subscription, a funded USD balance, or staked VVV that grants VCU.

  • The tray and control center show what these subscriptions have left. Venice’s api_keys/rate_limits route is wired into provider-usage, so the tray reports all three pools that can fund a request (USD, VCU, and the daily DIEM allowance) plus the API tier, instead of showing a zero to someone whose VCU balance is full. OpenRouter now reports its per-key spend cap from /api/v1/key, adding the account-wide credit pool when the stored key is a management key and keeping the per-key numbers when it is not. Nous Portal publishes no credits route at all, so it degrades to its subscription page and observed router traffic rather than inventing a number.

  • Private state files are hardened with a canonical owner-only ACL on Windows. The previous Windows path asked GetAccessControl about the file’s existing DACL and then edited it with SetAccessRuleProtection and RemoveAccessRuleSpecific, so a file whose DACL was already non-canonical could make it throw (or silently keep foreign ACEs), the exact drift an install or doctor --fix is meant to repair. Windows hardening now builds a fresh, empty FileSecurity, replaces the DACL outright with a single current-identity FullControl Allow rule and inheritance cleared, and skips persisting owner/group so it needs only WRITE_DAC and cannot fail where an icacls /inheritance:r /grant:r path would have succeeded. A hardening failure now exits non-zero with the PowerShell diagnosis on stderr instead of masquerading as success.

  • MiniMax M3 no longer shows its chain of thought as assistant text. The Token Plan route asked for adaptive thinking but not reasoning_split, so MiniMax embedded the reasoning in content as literal <think>...</think> markup, which every client that does not know the vendor format rendered as ordinary output. Requesting the split moves it to reasoning_content, which this router already relays as reasoning. Verified against api.minimax.io: the same prompt leaks <think> into content without the flag and carries a populated reasoning_content with it. Reported in #333 by @moryk87.

  • The harness caller key is written where a current harness reads it. DeepSeek Harness moved .credentials.yaml to a version/refs envelope around the reference map, and the router only knew the flat root map the older builds used — so on a current harness it wrote CODEX_ROUTER_CALLER_KEY one level too high, the harness resolved the route’s apiKeyEnv under refs, found nothing, and every turn came back 401 with no diagnostic anywhere (reported in #351 by @jepgambardella). Both shapes are now written in place, and neither is converted into the other: refs present settles it, version without refs settles it the other way — that is a current harness on its first install, the case where guessing wrong is silent — and a document with no references at all adopts the current shape. A new reference follows the indentation the envelope already uses for its siblings rather than assuming two spaces, since a mixed-indent block is not YAML any parser reads back and the file holds every adapter’s key, not only ours. Removing the reference prunes a refs: it emptied, exactly as removing the route prunes an emptied providers:, so uninstall restores the document byte for byte. status resolves the credential through the same decision the writer makes, so it can no longer report one the harness cannot read. A reference an older build left at the root of an enveloped document is moved rather than copied, so uninstall no longer leaves a second copy of the caller key behind. Anything that is not a reference map in either shape — a nested mapping at the root, a nested mapping inside refs, an inline refs — is still refused with the file untouched.

  • The Grok OAuth forwarder is health-checked, and a dependency the router did not name is no longer reported as unknown. /health probed the Kimi OAuth forwarder, the API forwarder, and the gateway, but never the Grok OAuth forwarder on its own port — so an operator routing through Grok OAuth got no signal for it at all and the router could answer ok with that forwarder dead (issue #366). It is now probed like the others, gated on the grok-oauth provider actually being selected so an unused port is never dialled, named as grokOauth in degraded when it is down, and carried through the redacted health projection into the tray and the Control Center, which both render it beside the other forwarders. Separately, both surfaces fell through to “Unknown / Waiting for health report” whenever a service key was simply absent from the payload; a router that reported ok has already probed every dependency it knows about, so an id missing from degraded now renders Ready and a healthy install stops looking like it never answered.

  • Curated OpenCode Zen free models now ship the effort ladder and context window OpenCode publishes for them, and say which values are still guesses. Zen’s anonymous /models endpoint returns ids and nothing else, and the deterministic curation path is never interactive, so every free model landed in the picker with a generic 131,072-token window and a single high effort no matter what the route actually supported (#352). src/opencode-curation.mjs now carries OpenCode’s own published limit and reasoning_options per free id — adding real windows for nemotron-3-ultra-free (1,000,000) and laguna-s-2.1-free (256,000) alongside the two that already had them, and real effort ladders for muse-spark-1.2-contributor-free, x-preview-f-free, laguna-s-2.1-free, deepseek-v4-flash-free, and hy3-free. A window is declared only when curation’s 0.85 auto-compact ratio still reserves that id’s published output limit, so compaction fires before a completion can overrun the window the entry just declared; deepseek-v4-flash-free, hy3-free, mimo-v2.5-free, and nemotron-3.5-lightning-free therefore keep the conservative default rather than a number a full-length answer could walk off the end of. Each entry’s description now names the provenance of every capability it carries and the ones that stayed unknown, so a reader can tell a documented value from a default without leaving the file. Hand-tuned entries are still never rewritten: the upgrade path touches only an entry still holding the untouched generic sizing pair and the untouched single-high ladder, and an explicit --efforts always wins.

  • Kimi OAuth sessions survive the Codex App connector pack. Moonshot accepts a tool-schema $ref only when it points into #/$defs/ and rejects the whole request — not the one tool — over anything else, so the sibling-property pointers connector tools ship (Wego _flights_search points inboundTotalDurationRange at its own sibling priceRange) turned every kimi-oauth App session into an HTTP 400 on its first message (issue #353). The relay now inlines those pointers for the kimi route: the node is replaced by the schema the client itself pointed at, with any constraint declared beside the $ref still winning, and #/$defs/ pointers left exactly as they are. A pointer that cannot be resolved is left alone rather than guessed at, a cyclic schema stops at the edge that would close the cycle, and an expansion that outgrows its byte budget falls back to the original schema rather than shipping a duplicated one. Every other provider keeps the payload it gets today — the inlining is scoped to the provider the rejection was reproduced on.

  • The macOS tray can load a provider’s current model list and curate from it. A new Provider catalogs panel asks any configured provider that supports live discovery for the models it serves right now, marks the ones already routed, and adds a selection through the same curate-models.mjs path the desktop app uses — so the tray no longer has to hand people back to a terminal to pick up a model their provider shipped after install. The first open is answered from the router’s stored list, so it costs no round trip and works offline; only Reload re-asks upstream. Model ids coming back from a provider are held to the same slug rule the Electron app enforces before any of them reaches curation, and every discovery or curation run is bounded by the Electron timeouts, so a provider that accepts the connection and never answers can no longer wedge the panel’s buttons for the rest of the session. The panel is translated in all six tray languages.

  • An update no longer empties the picker on a pre-allowlist install. The routed picker became an allowlist in v0.4.0-beta.4; on an install written by an older build, every routed model was absent from seeded rather than recorded as visible, so the first catalog rebuild read “on, but never written down” as “never decided” and applied the opt-in default to models that were plainly already showing (issue #338). The catalog build now migrates that file once, before the default runs: a routed slug in neither hidden nor seeded is recorded visible, so the picker the operator was looking at survives the update. Models switched off on purpose stay off, and a fresh install still opts in model by model.

    If an update already hid your models, the migration cannot tell them apart from a deliberate hide — both now sit in hidden and seeded — and deliberately does not guess. Restore a provider’s models with the picker command, which republishes the catalog on the way out:

    ./bin/control picker provider opencode-go show

    Substitute your provider ID (./bin/model-router codex providers list --json lists them), repeat per provider, then fully quit and reopen Codex.

  • Guided setup asks which models go in the picker. ./bin/setup --guided now has a model step between choosing providers and connecting credentials. It starts from the picker the machine already has — nothing on a first install, the operator’s existing selection on a re-run — so enabling a provider still offers its models rather than choosing them, and pressing Enter through the step never changes what is already there. The selection is written after the “Proceed?” confirmation, so a cancelled setup and a --selection-only run both leave model-picker.json untouched, and it records hidden/seeded rather than an allowlist on a pre-allowlist file, because one screen of models cannot answer for the providers it never showed.

  • Concurrent Codex turns no longer stall /health and the next request. Loopback liveliness probes use a separate dispatcher so they cannot queue behind long-lived SSE sockets, and /health serves a recent probe result immediately while a slow LiteLLM liveliness check refreshes in the background. The snapshot is still bounded: once it is older than 15s the next /health waits for a live probe, so a tray-less doctor cannot inherit an hours-old “reachable”. Probe GETs use undici’s own fetch with that extra Agent (Node’s builtin fetch rejects an npm-undici dispatcher) and the same NODE_USE_ENV_PROXY selection as routed traffic. The process-wide HTTP/1.1 pool stays unbounded — a numeric connections cap would queue later turns across every installed client. Disconnect handlers are registered before the JSON.parse yield so a canceled turn cannot start an upstream fetch. The tray was reporting Starting and Codex “waiting for network” with two or three in-flight turns even though the router was still generating.

  • Homebrew installs can reach every command again. The formula’s PATH shim exec’d bin/model-router codex, whose fixed whitelist has no entry for curate-models, discover-models, refresh-catalog, test-model, support-bundle, or control — so a packaged user had no way to add a custom provider’s models, and a bare codex-router or codex-router --help printed model-router’s usage instead of the packaged command list. The shim now dispatches through bin/codex-router, which exists for exactly this case. post_install gets its own private entry point, because bin/codex-router refuses install by design. Reported in #334.

  • GLM-5.3 on opencode Go serves its real 1M context. The entry still carried 200,000/180,000 — the GLM-5.1 lineage default that #244 established is wrong for 5.3, where a live probe accepted 990,020 prompt tokens. Both Z.ai GLM-5.3 entries were corrected then; this opencode Go entry was missed, so a session was compacting at 180K against a model that serves a million. Its GLM-5.1 and GLM-5.2 siblings on the same gateway already declare 1,048,576, so 1,000,000/900,000 stays conservative against the relay. Standalone web search stays off for this route until the opencode Go relay is verified to preserve tool/function-call history.

  • External-model compaction now carries evidence-backed kcr2 checkpoints. The router assigns stable U/C/R/A source IDs before tool results are aged, accepts only a bounded structured source selection from the summarizing model, and derives the trusted section from redacted original excerpts and machine-readable tool outcomes. Model prose remains explicitly unverified; missing, fabricated, or misclassified references are rejected; unresolved unknowns survive repeated compaction; and replay tells the next model to re-read mutable state before changing it. New checkpoints are capped at 96 KiB after final JSON serialization, with a 32 KiB recent tail. Existing source IDs and counters are rejected unless they are positive safe integers; the source catalog sent to the model has its own 96 KiB JSON limit, and only IDs actually present in that catalog can be selected. Wrapped provider output is accepted only when it contains exactly one contract-valid JSON object and is no larger than 256 KiB. The latest two user messages are reserved in both the source catalog and recent tail. The v1 compact endpoint now replays at most those two complete ordinary user messages before the checkpoint instead of carrying every short historical instruction that fits its character budget; a message that does not fit is never replayed as an unmarked fragment. Responses reasoning output is now treated only as a draft: KCR2 parses the final message instead of concatenating both channels and mistaking their separate JSON objects for an ambiguous answer. Existing kcr1 payloads and old v1 plain-summary messages still replay but are labeled UNVERIFIED_LEGACY_SUMMARY. Checkpoint excerpts reuse the managed caller-URL redactor and remove recognized GitHub token prefixes before they reach either the source catalog or serialized checkpoint. Native OpenAI requests still forward their encrypted compaction bytes unchanged.

  • Grok 4.6 can select Codex’s native image viewer. xAI stopped without a function call when the tool was named view_image, even when selection was required. The Grok OAuth boundary now presents that tool as inspect_image and restores returned calls to view_image, without colliding with a real client tool of the same alias name. This complements the existing image-result transport fix: Grok can now both invoke the viewer and receive its returned pixels.

  • Grok OAuth keeps image-bearing tool results multimodal. The Chat Completions-to-Responses hop JSON-stringified every non-string tool result, so Codex could complete view_image while Grok received JSON and base64 text instead of pixels. Structured image output now remains input_image, preserves detail and mixed-part order, and recovers common image MIME types from generic octet-stream data URLs. Text-only and other structured tool results keep their previous behavior.

  • A Codex Stop hook continues grok-oauth mid-task stops once. After a tool result, a short status sentence with no follow-up tool call used to hand control back. hooks/codex-stop-grok-oauth.mjs is a user-level Stop hook: it blocks only when model is a grok-oauth/* slug, the transcript shows a tool result then a short status, and stop_hook_active is false. Native GPT slugs fall through with { continue: true }. Cap is one automatic continue. CODEX_GROK_OAUTH_STOP_HOOK=0 disables it.

  • The routed-model skill says a text-only turn ends the task. Custom models often emit a status sentence after a tool result and call nothing; Codex then hands control back. The codex-router skill now states that contract and tells the model to call the next tool in the same turn when work remains. This is instruction, not a protocol fix — the Grok OAuth after-tool retry still covers a model that ignores it.

  • Grok OAuth no longer accepts uncertified prose after a tool result as a successful completion. The progress-only retry used to classify only on visible-text length and output tokens. After a successful tool, a cheap status sentence (“The figures are ready.”, 95 tokens) never retried, and a reasoning-heavy one-liner was nudged with “if you are already done, stop” — so the model restated the status and the turn looked finished. The last non-system message being a tool result is now the signal, independent of language, phrasing, or answer length: a no-tool turn is held and retried once. The repair must call exactly one function: either a client tool or the router’s private final-answer tool. The private tool is converted back to ordinary assistant text and never reaches Codex. An empty, progress-only, or failed repair becomes an explicit 502 instead of a clean stop, so Codex cannot record it as a silent success. A one-line verdict after a user message still gets the no-tool branch first, so a finished Q&A cannot be talked into a call the client would run.

  • Grok progress-repair usage separates context from billed spend. Codex now receives the selected repair attempt’s prompt count instead of the sum of both attempts, preventing a roughly 150k context from appearing as 300k. The aggregate provider cost is retained separately as billed input/output tokens in the local usage ledger.

  • Grok and DeepSeek advertise Codex reasoning summaries. The catalog now opts the official Grok and DeepSeek thinking models into supports_reasoning_summaries, so Codex can show their thinking while a turn is in flight and collapse it afterwards — the same surface native GPT uses. Grok OAuth was dropping xAI’s reasoning_summary_text / reasoning_text deltas on the Chat Completions hop; those now land as reasoning_content so LiteLLM can put them back on the Responses reasoning channel. DeepSeek already emitted reasoning_content; it only needed the catalog flag. deepseek-chat stays off because it is the non-thinking alias.

  • Grok OAuth retries a progress-only stop once on the user-message path, without holding the first byte. Attempt 1 streams live. If the client offered tools and the turn ends with short visible text, no tool calls, and enough output tokens to be reasoning-heavy, the forwarder retries once with a trailing user nudge and appends only the retry’s tool-call deltas plus finish_reason: "tool_calls" onto the same open stream. The first answer is kept when the retry also has no tools. This paragraph describes turns following a user message; post-tool turns use the stricter certified repair above. Both attempts on this older path are summed into usage with progress_only_retried: true; that marker is what acceptedInputTokens excludes, not a bare transport retries count. The retry log is not gated on MODEL_ROUTER_QUIET. Set CODEX_ROUTER_GROK_PROGRESS_ONLY_RETRY=0 to pay once and see the raw first attempt. dispatchSseBlock now catches only JSON.parse.

    The trigger is a shape and cannot be anything else: a finished task answered in one line — “Yes, that is correct.” after 1,500 reasoning tokens — is indistinguishable from a turn that stopped early, so it is retried too. The nudge therefore offers the no-tool branch first (“if that already completed the task, restate the final answer and call no tool”), which routes the finished case into keep-first. An imperative nudge makes such a turn invent a tool call, and the forwarder would graft it onto the answer for the client to run. A false positive now costs one round trip, not a wrong action.

  • The Devin CLI probe no longer reports “unknown” for a Devin CLI that is installed and working. devinCliVersion was the one call site out of twenty that took command and args from spawnableCommand and threw away the third field. For a Windows .cmd shim — which is what npm installs — that field carries windowsVerbatimArguments, and without it Node re-quotes a command line that has already been escaped for cmd.exe. The version came back empty and the probe printed unknown, which reads as “you do not have the CLI” to the one person running a probe written specifically to stop that misdiagnosis. The probe’s own convention — every outside edge injectable — now covers this edge too, so the options, the cmd.exe hop, and the POSIX pass-through are all asserted on every platform rather than only on Windows.

  • The Windows command-line escaping is now pinned against the hazards that could not previously be caught off Windows. spawnableCommand builds one cmd.exe command line, and until now the only proof it was armed correctly was an end-to-end test that runs a real shim and therefore skips everywhere else. A pipe, a redirect, a !, and a trailing backslash before a closing quote — the four that would end the quoted span or start a second command if the escaping were wrong — are now asserted in rendered form on every platform, and added to the set the Windows job runs for real. No behaviour changed: the escaping already matched cross-spawn character for character.

  • Running the test suite no longer resets your subagents. test/state-owner.test.mjs ran the real src/catalog.mjs against a scratch state directory while inheriting the developer’s own CODEX_HOME. No state-directory override redirects $CODEX_HOME/agents, so the catalog read an empty state — no proofs, no selection, no picker — and pruned the real agents directory to the handful of models the shipped registry promotes on its own, deleting the definition of every model this machine had promoted through a local capability probe. The settings naming those models live in the state directory and survived, so subagents status kept reporting them as enabled while Codex had nothing left to spawn: subagents that appeared to reset themselves after an unrelated command. The test now isolates the home with the state, and a guard in the same file fails if any test spawns the catalog without doing so. If your routed agents are already missing, one catalog refresh from the owning checkout restores them.

  • A forwarder that cannot bind its port now says so. The four forwarders the service starts — kimi-oauth, api-forwarder, grok-oauth, and devin-cli — called listen with no 'error' handler, so a port already in use killed the process with Node’s unhandled-'error' crash dump: throw er and a libuv stack, in a log the four of them share, naming neither the forwarder nor the port. Startup then reported only that some forwarder had exited before becoming healthy. Each one now reports the bind failure the way the router already did since #171 — one line naming itself, the address, and the reason — and exits with the router’s own listen-failure codes (98 for EADDRINUSE, 97 for EACCES, 96 otherwise), so one line in the service log classifies the death for a supervisor and a human alike.

  • GPT-5.6 Sol can now run at the 1M context window OpenAI documents for it. The catalog Codex ships declares 272,000 tokens against a documented 1,050,000, and that figure has already moved twice (openai/codex#31860, #32806). Editing model_context_window and model_auto_compact_token_limit in config.toml answers this for a whole machine; the picker now answers it per task. GPT-5.6-Sol (1M context) (gpt-5.6-sol-1m) is the same upstream model published under a second slug with a 1,000,000-token window and compaction starting at 900,000 — instructions, reasoning ladder, image input, and subagent behavior are copied from gpt-5.6-sol, and the router rewrites the slug back to its base before the turn leaves, so OpenAI only ever sees the model it published. It ships switched off, because a turn resends the whole conversation and a request above 272,000 input tokens is billed at a higher rate in full: a model that costs more than the one it shadows has to be chosen, not discovered after the bill. Switch it on under OpenAI in the Settings model list, or with ./bin/control picker set gpt-5.6-sol-1m show. That answer is remembered — later catalog rebuilds never re-apply the default to a model already decided, in either direction — and a login-free install does not get the entry at all, because its native slugs come from a server-supplied allowlist.

  • Gemini CLI is a target. It speaks only the Gemini API and Google ships no bring-your-own-provider setting, so pointing it at this router used to be impossible — the endpoint it wants does not exist anywhere in the codebase. It does, however, read its endpoint, its credential, and its default model from the environment, and createContentGenerator builds a plain @google/genai client from them. So the router now serves /_codex-router/<key>/gemini/v1beta/models/{model}:{method} and writes one marker block into ~/.gemini/.env. ./install.sh --target gemini or ./bin/model-router gemini enable sets it up; the next gemini run has the routed models, with nothing to restart. The surface reaches no provider of its own. It translates the turn into a Responses request and sends it through the router’s existing /v1/responses over the loopback, so tool-result ageing, the vision bridge, prompt-token substitution, upstream retry, model failover, and usage accounting all still sit on one request path rather than two that would drift. Tools, system instructions, inline images, streaming deltas, reasoning summaries, tool calls and their results, usage counts, and finish reasons all cross in both directions. A stream the upstream drops still ends with a finish reason, because the SDK waits for one before it considers a turn over. settings.json is never opened for writing: it is JSONC carrying the user’s own comments, and this integration does not need it. The .env block is the only thing written, it is 0600 because it holds the caller key, publishing twice is byte-identical, and removing it restores the file exactly. A managed key assigned outside the block stops the publish with the line named rather than being silently overwritten — dotenv lets the last assignment win, so a duplicate would quietly decide which endpoint is in force and nothing in the file would say so. The default model is written, unlike the harness integration’s opt-in equivalent, because Gemini CLI’s own default is a Gemini model this router does not route: an install that left it alone would 404 on the first turn. --model still outranks it and --no-default-model omits it. Embeddings are refused with a named 501 rather than faked, and countTokens is estimated rather than answered by spending a real turn upstream. None of this is documented by Google. The contract was read out of the installed @google/genai and @google/gemini-cli-core bundles and then proved by driving the real gemini -p at a real provider: a routed turn came back through the CLI verbatim, and a tool-calling turn completed the whole loop — ten tool schemas out, a tool call in, its result back out, and the model’s answer in. That live run is what caught the one bug the unit tests could not: a Gemini tool declares its schema as parametersJsonSchema, not parameters, so the first cut sent every tool upstream with no schema at all and the CLI rejected each call the model made with “params must have required property ‘file_path’”.

  • The rule for which models may be published to a client that carries no ChatGPT session of its own now lives in src/routed-client-models.mjs instead of inside the harness manager. It was always a general rule; a second client wanting it verbatim is what made a second copy the wrong answer.

  • A subagent that had been proven once could never be un-proven, however badly it behaved afterwards. The observer that settles a locally verified subagent gated itself on awaitingSpawnProof, which is true only while a slug sits in the experimental window — so the instant turn one promoted a model, the router stopped watching it. A hard 400/422 on turn two was discarded with everything else, and the only thing that could re-examine the slug was a hand-run control subagents verify (#257). Two changes, both about the gate rather than the thresholds. proven is now revocable: the same structural rejection that would have blocked promotion takes it back afterwards, without needing to repeat, because nothing makes a 400 weaker after a 200 than before it and the transient statuses that prove nothing (429, 5xx, disconnects) were already excluded. Registry-v2 models are untouched — their claim is the shipped native collaboration proof, not one machine’s traffic — and re-promotion stays manual, since that is the direction that spends quota. And a child that answers turn after turn without converging is now demotable at all: it emits nothing but 200s, so no status-shaped branch could ever see it, and the evidence instead is how much of its own budget one spawn burns while still going. src/subagent-turns.mjs accounts each spawn separately by thread-id and adds up the new input tokens it produces — every child turn resends the whole conversation, so growth in the prompt count is what the child newly made, and a compaction makes the count fall so everything after it is work being done twice. The ceiling is twice the larger of the model’s declared autoCompact budget and the largest prompt the spawn has actually had accepted: one budget is a large but legitimate task, and it is compacting again without ever finishing that names the runaway, which is the same pathology context-window-drift.mjs and #266 already describe. No round number was invented — autoCompact is per model and comes from the provider’s own published window, and a model that declares none is counted but never condemned. Measuring against the spawn’s own observed peak as well as the declaration makes a false demotion impossible rather than merely unlikely: an uncompacted spawn’s total is exactly half its own ceiling however long its task runs, so neither a model whose declared budget sits far below its window nor a single oversized tool result can condemn anything. Only prompt counts the provider actually reported move the total, so substituted estimates and retry-doubled counts cannot manufacture a demotion. Both paths log unconditionally and record the turn and token counts in the proofs file, so control subagents status and the tray say what happened rather than a picker entry quietly disappearing.

  • A passing Devin CLI probe now means something. The provider’s Cascade transport was transcribed from a shipped binary and has never met Cognition’s backend, so #270 asks a volunteer with an account to settle it. The probe that ask points at printed one line per stage, and every one of those lines could pass while the thing it was meant to prove had failed: an empty model list read as OK: account advertises 0 model(s), a stream that decoded to nothing read as OK: streamed 0 character(s), and a tool call that arrived under a field number this build does not know was skipped in silence and reported as a model that chose not to call a tool — the one failure that decides whether Codex can drive the provider at all. The probe now audits the raw bytes alongside the schema and prints a PASS/FAIL line per assumption with the observed value on each failure, so a run that reports success has confirmed each one separately: request encoding, model list decoding, envelope framing, the compression flag, the end-of-stream terminator, stop reason, usage, tool-call ids and arguments, and a replay of the captured bytes through the client a routed turn actually runs. Where a tool call goes missing, three distinct lines separate a renumbered field from a model that declined. The live turn is now capped at 64 output tokens and 90 seconds, --live remains the only flag that spends anything, a mistyped flag fails the run instead of quietly downgrading it, and the output folds $HOME to ~ and carries no token, because it is written to be pasted into a public issue. docs/DEVIN-CLI-PROBE.md is the tester’s copy of all of it.

  • The Devin CLI transport now reports a refusal as a refusal. Its Connect client carried ten of the protocol’s sixteen error codes, and the six it did not — canceled, already_exists, aborted, out_of_range, data_loss, and unimplemented — fell through to 502. Every layer above reads a 5xx as a bad moment in the chain rather than an answer: the vision bridge retries it, and Codex spends its own reconnects on it. unimplemented is what Cascade answers when the service path or method name has drifted from the binary these schemas were transcribed from, so the one code that can never succeed was the one dressed as worth another try. The client now imports the full table from src/connect-stream-audit.mjs instead of restating half of it, so unimplemented arrives as 501, already_exists and aborted as 409, out_of_range as 400, and canceled as 499 — none of them retryable — on both the HTTP failure path and the end-of-stream terminator.

  • A compressed Connect frame is no longer a silently empty answer. Each Connect envelope has a flags byte whose low bit marks the message compressed, and the client ignored it: the frame went to the protobuf decoder, which is not being handed protobuf, and the turn ended with no text and no tool calls or with a wire-type error naming nothing anyone could act on. The client now asks for connect-accept-encoding: identity on both call shapes and, if a frame arrives compressed regardless, fails with a named devin_compressed_frame (501) instead of guessing — including on the end-of-stream terminator, where a compressed frame would otherwise have read as the empty {} that means the turn succeeded. Decompression is deliberately not implemented: no maintainer can reach Cascade to test it, and a compliant server has no reason to compress once it has been told identity.

  • Grok OAuth no longer loses late or custom tool calls. The forwarder now accepts function_call and custom_tool_call items that first appear in response.output_item.done, restores final arguments when argument deltas are absent, joins repeated SSE data: fields, and consumes the final event even when the upstream omits its trailing blank line. Streaming remains live while the upstream turn is running.

  • Curated models were filed at 131072 tokens however big they actually were, and the million-token ones compacted on every turn. Curation stored one conservative window for every model it added, so a model OpenRouter advertises at 1,050,000 was told to auto-compact at 110,000 — eight times below its real capacity. That is not a cosmetic understatement: when a provider answers with prompt_tokens: 0 the router substitutes an estimate of the prompt it just sent, and that estimate errs high on purpose, so against a threshold this low it landed above the compaction limit turn after turn. The session summarized itself, lost its working state, redid the same opening work, and summarized again without ever finishing (#266). The provider’s catalog already carries the answer, so discovery now reads it: context_length, the figure the serving endpoint reports under top_provider, context_window, or Copilot’s capabilities.limits.max_context_window_tokens, taking the smallest of the ones present because those are limits at different scopes and only the narrowest is the one the request path can rely on. Both curation forms store it, and autoCompact follows from it; the interactive prompt offers it as the default rather than making the user retype a number the provider already published. A model the catalog sizes in silence still falls back to 131072, and an entry curated earlier keeps what it was given — an additive run never rewrites metadata a user may have tuned by hand, so repair it in user-models.json or --remove and curate it again.

  • The substituted prompt-token estimate charged the session for reasoning no model ever reads. When a provider answers with prompt_tokens: 0 the router substitutes an estimate of the prompt it just sent, dividing the serialized request body by 3.3 bytes per token. That divisor is calibrated against text a model reads, and the body is not: most of a Codex turn is encrypted_content, the sealed chain of thought carried on every reasoning item. The gateway’s Responses-to-chat bridge drops reasoning items outright and no routed provider can decrypt another vendor’s token, so those bytes buy zero prompt tokens — but they were counted, and there can be a lot of them. The router already sheds some: a reasoning item that carries summary text and sits immediately before the turn it belongs to is rewritten into assistant text, ciphertext and all. An item with an empty summary, which is what a provider returns when it has none to give, is forwarded whole. Measured through the router itself on a twelve-turn tool loop: with summaries no ciphertext reaches the gateway at all, and without them every blob does and they are 64% of the body the router sends. Charging that 64% at 3.3 bytes per token is where the field reports of 3.9x–4.7x come from, and an estimate that high clears autoCompact on a window the session is nowhere near, so it compacted on every turn the provider reported as zero (#266). The estimate now discounts encrypted_content and counts everything else. The subtraction is deliberately one-sided: an unrecognized field is still counted, so a body shape nobody anticipated errs high rather than estimating near zero, and JSON escaping, structural scaffolding, and base64 image data all stay on the bill for the same reason. The clamp to the declared context window is unchanged, but it now means something. It used to fire on conversations at a quarter of the limit, and since autoCompact is 85% of the window a clamped estimate compacts by construction. Counting only model-visible bytes puts a floor under it: the estimate can only reach the window if the visible text does, so a clamped estimate now means the conversation really is between 82.5% and 100% of the limit, where compacting is the right answer.

  • A subagent on a thinking model poisoned the conversation that spawned it. Every request after the child finished came back as a 400 reading “The reasoning_content in the thinking mode must be passed back to the API”, seen on DeepSeek V4 Flash through the opencode Go subscription. LiteLLM’s Responses-to-chat translation drops reasoning input items outright, and the carry that compensates for that only recognised a tool loop — reasoning sitting immediately before a function_call. A subagent ends in prose, so the reasoning behind its final answer was thrown away and the provider was asked to continue a thinking turn it had never been shown. The carry now covers every assistant turn: prose answers and custom tool calls as well as function calls, and the whole run of reasoning items a turn emits rather than only the last of them. It merges into the assistant message instead of inserting a second one, because two assistant turns back to back are their own rejection on the same providers. “Compact old tool results” was reported alongside this and is not involved — the aging pass only ever rewrites the output of a tool result, and now has a test proving the reasoning and assistant turns around it come through by reference. A subagent is not required to reach this: an ordinary follow-up after any thinking-mode answer fails the same way on a build that predates the fix, and that plainest path is pinned by its own test.

  • A single bad upstream response could take the whole router down. LiteLLM 1.96.0 raises out of its own request handler while mapping an upstream 429 — opencode Zen’s exhausted free tier is one reliable way to reach it — and the gateway process ends with exit code 1. The service raced every child’s exit, so that one failed request also killed the router and all three forwarders, and from then on every client got a bare Connection error naming nothing (#261). The gateway is now supervised: it is restarted in place, with a doubling backoff and at most five restarts inside ten minutes, while the router keeps listening — so a crash costs one stalled request instead of the session, and the next one is answered by a live gateway. Every crash, every restart, and the decision to stop restarting are logged unconditionally, and when the bound is exhausted the service exits exactly as before so the OS supervisor performs a clean restart. Supervision starts only once the gateway has been healthy: a gateway that never came up is still a startup failure, not a retry loop. /health now names which local service is unreachable, so doctor reports “serving but reports gateway unreachable” instead of “not ready”, and CODEX_ROUTER_GATEWAY_RESTARTS=0 restores the old behaviour for an investigation that wants the process to die where it died. The pinned litellm version is unchanged: a router that survives its gateway is worth having whichever version is installed.

    Startup also stopped refusing a Windows batch launcher. Node has declined to spawn a .cmd/.bat without a shell since CVE-2024-27980, so pointing MODEL_ROUTER_LITELLM_BIN at a batch wrapper ended the service before it spawned anything, with an EINVAL naming neither the file nor the reason. The launcher now goes through the same spawnableCommand helper every other external command in the repository uses. The shipped installer produces litellm.exe, so a normal Windows install is unaffected.

  • Command Code was unusable on every plan but one, which is not the plan most of its customers buy. The provider only ever spoke /provider/v1, and that surface is an entitlement rather than a credential: a $1 Go account signs in, mints a real key, runs the official CLI all day, and is still answered 403 upgrade_required — “Your Go plan doesn’t include API access”. The router’s only response was a plan note explaining why nothing worked. The command-code CLI itself does not use that surface; every turn it takes goes to /alpha/generate, which is not plan-gated. The forwarder now answers the entitlement refusal by moving the turn there, so Go, GOAT, Pro, and Max are served with the same key, the same catalog, and no upgrade. Both protocols are covered: the chat-completions catalog and the Messages variant that carries the Claude models. The refusal is remembered against a fingerprint of the credential — never the key — so it is bought once rather than once per turn, re-probed when the key changes, and re-checked every six hours in case the plan did. Only a real upgrade_required may move a turn; a timeout, a 500, or any other 403 is relayed with the provider’s own message, because reading one of those as a refusal would quietly move a paying Provider-plan account onto its coding-plan credits. The fallback happens before the first relayed byte, the same boundary the upstream-retry and model-failover rules draw.

    That route carries the CLI’s own envelope, not an OpenAI or Anthropic body, so both directions are translated: a schema-strict config block where every field is required and memory is a string rather than an object, messages in the Vercel AI SDK ModelMessage schema, snake_case input_schema tools, and a newline-delimited JSON response — despite its text/event-stream content type — whose blocks interleave and whose trailing tool-call event keys on toolCallId where every incremental event keys on id. Command Code publishes no reference for any of it; the shapes were derived from the shipped CLI bundle (v1.14.1) and confirmed against the live gateway.

    One measurement changed the design. An empty system field is not “no system prompt” to that route — it is a cue to splice in the Command Code agent’s own preamble. The same one-line turn cost 92 prompt tokens with a system prompt and 7,637 without, spent telling the model it was a different product with different tools. A turn carrying no system prompt of its own now gets a neutral one instead of the agent’s.

  • The tray showed Command Code’s spending windows but not what was left to spend. The billing route it already polls reports the credit pool beside the 5-hour and weekly caps, and a coding plan runs out of the first long before it stops hitting the second. Plan, purchased, and free credits now surface as a balance metric, and the plan’s own low-credit threshold marks it unavailable.

  • Retained tool results had no way to be seen and no way to be cleared. Tool-result compaction parks the exact original bytes of a result it rewrote in <state dir>/retained-tool-results. That store is bounded and fails safe — at its cap it stops accepting new results and eligible results pass through uncompacted — but it has no eviction and no TTL, so the only way to empty it was rm -rf, and the first time most operators would learn it existed was while hunting disk usage. It also matters more than its byte count: tool results carry file contents, command output, and API responses, and this is the one place the router keeps model-visible content on disk rather than the counts and bytes its telemetry is limited to.

    ./bin/doctor now reports the store on every run — file count, total size, and the age of the oldest entry — and reports it whether or not the directory exists, because “nothing retained” is the answer most installs should see and seeing it is what makes the directory discoverable at all. A store parked at its cap is reported as a warning rather than as healthy, since that state is permanent until somebody empties it.

    ./bin/control tool-result-aging purge empties it. It is a report by default: without --yes it prints what it would remove and removes nothing, and --dry-run says the same thing explicitly and outranks --yes so a wrapper that always consents can still preview. Deletion is confined to the store by construction rather than by intent — only names retention itself produces, only entries whose parent resolves to that one directory, no recursion, and no symlink is followed or removed. Anything else that ends up in there is left in place and named. The directory itself is kept: emptying it is the whole job, and removing it under a concurrent write buys nothing.

  • Devin’s models are reachable from the session its CLI already stored, and this adds the provider that reaches them — untested against a real account. devin auth login writes a persistent token to credentials.toml, so devin-cli reuses it exactly as kimi-oauth and grok-oauth reuse theirs. The transport is the part with no precedent here: Cognition publishes a session API, not a chat API, and the models answer only on Cascade — exa.api_server_pb.ApiServerService over Connect RPC — so this ships a small protobuf wire codec, the message subset transcribed from the descriptor set embedded in the shipped devin binary, a Connect streaming client, and a forwarder translating OpenAI Chat Completions into a GetChatMessage turn and its deltas back. Reasoning and tool calls are mapped; images ride only on the current turn, because replaying an older one fails the whole request. Thirty-seven tests cover the codec against hand-computed bytes, the request mapping, the credential reader, and envelope framing including a split frame and an error carried in the end-of-stream terminator. None of that proves Cascade accepts the request: no maintainer holds a Devin account, so the provider ships catalog-only with no checked-in models and is documented as unverified. bin/devin-probe is the way to find out — it checks the credential and lists the account’s models for free, and --live --tools spends one turn to prove a streamed answer and a forced tool call.

    Nobody who has not asked for Devin pays anything for it being here. The forwarder is spawned only when the registry actually holds a devin-cli model, so an install that never ran bin/curate-models devin-cli starts no fourth child, binds no fourth port, and waits on no fourth health probe — startup is byte-for-byte the work it was before. The gate is the curated model rather than the stored credential on purpose: a curated model is precisely what puts a DEVIN_CLI_FORWARD_BASE_URL route in the generated gateway config, and the route and the listener are decided from the same model list on the same boot, so a live route can never point at a port nothing is listening on. Gating on credentials.toml would have been the wrong trade — someone who curated a model but has not run devin auth login gets a 401 naming that command, which a missing forwarder would have turned into a bare connection error. When Devin is routed, everything is as before: the forwarder is health-waited alongside the other three, an unbindable port still aborts startup naming the forwarder, and a forwarder that dies still ends the service so the OS supervisor rebuilds it.

  • A retained tool result kept forever was an archive nobody chose. The store had a cap but no lifetime, so bytes retained today were still on disk a year from now, and a store that reached 512 files or 512 MiB stopped retaining anything new permanently — until somebody noticed and emptied it by hand. Retained originals now expire after 7 days.

    The number is derived rather than round. Nothing ever reads those bytes back into a turn: the receipt tells the model to repeat the tool call, so a retained original’s only reader is the operator, forensically, and only while the session that produced it still matters. The caps say the same thing about intent — 512 files and 512 MiB against a 32 KiB compaction floor is a working set of a few long sessions, not a history. And a week is already this repository’s horizon for “recent enough to still act on”, in the catalog’s announce window and the vision host’s size cache alike.

    Nothing sweeps on a timer and nothing is added to startup. Entries expire when the store is next written to — the way the cooldown store is trimmed on its next write, and the way a provider cooldown reads as gone long before anything deletes it. ./bin/doctor therefore reports what has already aged out rather than what has been removed, and ./bin/control tool-result-aging purge --expired runs that sweep by hand for an install where compaction is off and nothing is going to write again. It carries the same --yes consent, the same --dry-run, and the same containment as a full purge, and it never removes the key that binds the store’s names to the install — expiring that would orphan the entries the TTL just decided to keep.

    ./bin/control tool-result-aging ttl <days|off|default> sets the lifetime. off is a real answer, kept verbatim: an operator who wants the archive keeps it, and no later default overwrites that. A state file written before the TTL existed never answered the question, so it reads as the default rather than as “keep them forever”. The CODEX_ROUTER_TOOL_RESULT_AGING=0 kill switch does not disable expiry — it stops the router rewriting request context, and expiry is disk hygiene for bytes that are already written.

  • Every turn against a Meta model failed on the web search tool. Meta’s Responses surface answered each one with a 400 reading “tools[].search_content_types is only supported for web_search_preview tools”, so Muse Spark 1.1, 1.2, and 1.2 Contributor were unusable rather than degraded — the tool is declared on the turn whenever web search is enabled, so this had nothing to do with whether the model actually searched. Codex sends the current spelling of that tool (type: "web_search", carrying search_content_types beside external_web_access, filters, and user_location); Meta validates it against the legacy web_search_preview schema, which is the one place it accepts the field. The forwarder now drops search_content_types from a Meta request, and nothing else: the search tool itself still reaches the model with the rest of its settings, and a caller that sends Meta a real web_search_preview tool keeps the field on it. Scoped to Meta on purpose — OpenAI documents search_content_types on web_search and not on web_search_preview, the reverse of what this endpoint enforces, so the other Responses-native providers keep a parameter the current spec grants them. (#286)

  • The free Qwen3.8 endpoint refused any conversation whose system message arrived late or twice. Its chat template answers those with a 400 reading “System message must be at the beginning”, and a real Codex session reaches that shape routinely — a second system message, or one appended after the conversation is already under way. Probing the live endpoint pinned the rule to at most one system message sitting ahead of the first user, assistant, or tool turn; the developer role is outside it entirely, so [developer, system, user] is accepted and “the beginning” means before the first turn rather than index 0. The request profile now coalesces the system messages into one and places it ahead of the first turn, handling both plain string content and content-parts arrays, and leaves developer messages exactly where they are. A conversation the rule already allows is forwarded unchanged. Hoisting is a compatibility repair with a real cost — instructions the caller placed mid-conversation are read as opening context instead — accepted only because the alternative from this endpoint is no answer at all.

  • Every compaction against the free Qwen3.8 endpoint failed on an empty tool list. Compaction disables tool use by sending tools: [], which every other forwarder reads as “no tools” — this endpoint’s vLLM build answers it with a 400 saying the array must not be empty and the field should be omitted instead, and answers the tool choice sent alongside it with a second 400 saying tools must be set. The model carries a 262K window that auto-compacts at 230K, so the failure was not an edge case: it was every long session. The request profile now omits an empty list and then drops the tool choice that strip leaves with nothing to choose from. The repair sits at the last hop before this one endpoint, because an empty tool list stays the correct way to disable tools everywhere else, and a real tool list still forwards untouched.

    The entry is also renamed to Qwen3.8-27-free-victor, crediting victor, who publishes the endpoint, in the name the picker shows rather than only in the endpoint note.

  • A turn whose provider has run out of usage now continues on another model. An install with thirty providers configured runs out of one of them most days: a coding-plan window closes, a weekly quota lands, a balance empties. The router named that failure clearly and then stopped — Codex has nothing to do with a billing error, so a session mid-task simply ended, subagents included, while every other model the operator could reach sat unused. The turn is now rebuilt for the next eligible model and sent again, and the client sees one clean answer.

    What qualifies is deliberately narrow: an exhausted balance or plan limit, a 402, or a 429 whose Retry-After is longer than a minute. A rejected credential, an unknown model, a malformed request, and every 5xx keep exactly the error they returned before — swapping models to dodge a bad key would hide the one fact that fixes it, and a short rate limit is cheaper to wait out than a cold prompt cache is to pay for. Free models are tried before paid ones, then the rest in the registry’s own preference order; a model on your own machine is never chosen automatically, because the runtime might not be running. A candidate whose context window cannot hold the conversation is skipped, so a quota failure is never traded for a context-window rejection.

    When a provider says when it will be back, that window is believed: the next turn skips it outright instead of buying the same rejection again, and it is used again by itself once the window passes or the next time it answers. A reset time is never invented, only ever read from the provider, and it is capped at six hours so a malformed one cannot strand a model.

    Nothing about the swap is silent, and nothing is injected into the transcript. The router logs it (never gated on CODEX_ROUTER_QUIET), the tray Island names the model actually answering, and the usage event carries failoverFrom so a rescued turn stays distinguishable from one that never failed. Compaction gets the same treatment, because a compaction that fails ends a session just as hard. ./bin/control failover status|on|off|chain <slugs>|auto|reset; the doctor reports any provider currently being held off and when it clears.

  • The GLM-5.3 1M entry routed to a model code Z.ai does not serve. Shipping glm-5.3[1m] took the vendor’s documented 1M suffix at its word, and the suffix answers 1214 on both the OpenAI-compatible and Anthropic endpoints — every request through that entry failed, while plain glm-5.3 on the same endpoint and credential succeeded. The entry is gone rather than repaired, because there is nothing behind it to repair. The window it was invented to reach turned out to be served on the plain entry all along: a 990,020-token prompt was accepted, so zai-coding/glm-5.3 declares 1M and its picker description says so instead of directing readers to an entry that no longer exists. A config.toml still naming the removed slug has nothing to route to and fails at the native target; reselect the plain GLM-5.3 entry.

  • A custom provider whose models each name their own endpoint. Every other provider owns one address, which is why “route this one model from that one host” has always meant inventing a whole provider for it. custom owns none: each of its models carries its own baseUrl, its own auth, and its own metadata, so one picker entry can hold a free community endpoint, a self-hosted server, and a paid API with a key, at once. Enabling it asks for nothing and it is never in the default set, because what it holds is whatever somebody put in it.

    Its first model is the free community Hugging Face Inference Endpoint for Qwen/Qwen3.8-27B: no API key, 262K context, image input, tool calling, and a thinking budget the effort picker dials. Every capability was measured against the live endpoint rather than read off its model card — including the one divergence, that its vLLM build validates reasoning_effort against a literal set omitting the Codex ladder’s ultra, so the request profile folds exactly that rung onto max and leaves every other tier alone. It is shared, rate limited per IP, and its owner says it will be retired once launch interest fades: a model to try, not one to depend on.

    The security rule follows the address down rather than staying at the provider. A custom endpoint reached with no credential must have that address allowlisted in code, exactly as an anonymous provider’s is — otherwise adding a JSON file under config/custom/ would be enough to send prompts to any host on the internet with nothing to authenticate them. A keyless endpoint stays loopback-only, neither may declare an environment override that would walk around those two rules, and an endpoint’s identity is derived from its model so one model’s credential file can never be pointed at another model’s secret.

  • The GLM-5.3 1M entry routed to a model code Z.ai does not serve. Shipping glm-5.3[1m] took the vendor’s documented 1M suffix at its word, and the suffix answers 1214 on both the OpenAI-compatible and Anthropic endpoints — every request through that entry failed, while plain glm-5.3 on the same endpoint and credential succeeded. The entry is gone rather than repaired, because there is nothing behind it to repair. The window it was invented to reach turned out to be served on the plain entry all along: a 990,020-token prompt was accepted, so zai-coding/glm-5.3 declares 1M and its picker description says so instead of directing readers to an entry that no longer exists. A config.toml still naming the removed slug has nothing to route to and fails at the native target; reselect the plain GLM-5.3 entry.

  • A free Qwen3.8-27B provider that needs no account. qwen38-free routes the community Hugging Face Inference Endpoint for Qwen/Qwen3.8-27B: no API key, 262K context, image input, tool calling, and a thinking budget the effort picker dials. It joins opencode-free and kilo-free as an anonymous provider, so it is never selected on anyone’s behalf and never defaulted — ./bin/model-router codex providers enable qwen38-free is the whole setup. Unlike those two it is not catalog-only: a single-model endpoint has no naming rule to filter by, so its one documented free ID lives in code beside the endpoint allowlist and the model ships with metadata verified against the live endpoint rather than guessed by curate-models. The endpoint validates reasoning_effort against a literal set that omits the Codex ladder’s ultra, so its request profile folds exactly that rung onto max and leaves every other tier — and forced tool choices, which it answers correctly — alone. It is shared, rate limited per IP, and its owner says it will be retired once launch interest fades: a model to try, not one to depend on.

  • The panel’s local-model view surfaces LM Studio. LM Studio arrived as a provider with exactly one door: ./bin/curate-models lmstudio in an interactive terminal, while the panel’s Local LLMs section read only ollama list — so models loaded in LM Studio were invisible there and uncountable in its summary. The snapshot now carries an LM Studio section read from the server’s own /v1/models endpoint, the panel lists what it serves with checkboxes, and checking one publishes it through the same user-model overlay the terminal writes, so neither door can strand the other’s entries. A stopped server reads “not running” instead of the section vanishing, and a curated model the server no longer serves stays visible as such rather than lingering in the picker with no way to see why.

0.4.0-beta.4

  • A command that opens the browser panel. The panel shipped with no way to reach it: its URL carries the caller capability, and nothing printed one, so “nothing to install” still meant “and no way in”. codex-router.ps1 panel (bin/panel) opens it in the default browser. It reports the router being down instead of opening a page that would load empty, and prints the address redacted, because AGENTS.md treats the capability path as local authentication; --print is the deliberate exception and says what it is handing over.

  • Caller-key redaction covered only /v1. redactCallerUrl is what keeps the capability out of support bundles, doctor output, and error messages, and it matched the API path alone — so a panel URL, the identical secret in the identical position, travelled through every one of those surfaces verbatim. It now covers each leaf the capability guards.

  • The companion no longer requires a Rust toolchain. Building it meant installing cargo, the heaviest prerequisite in the project, asked of someone who only wanted to see the panel; without it the install step failed and the machine ended up with no companion at all. tray install now falls back to the Electron shell, which needs only the Node the router install already required, and codex-router.ps1 companion selects it explicitly. scripts/build-electron-companion.ps1 and its shell counterpart verify the runtime is actually present: npm 11 blocks install scripts by default and electron downloads its runtime from one, so npm ci exits 0 having fetched the package but not the binary, and the failure surfaces much later as an app that never starts.

  • Every single-argument Windows subcommand was unreachable. PowerShell enumerates a statement’s output into an assignment, so $Arguments = if (...) { @(...) } collapsed a one-element array to the element itself; $Arguments[0] then indexed a String and returned its first character. codex-router.ps1 tray status died on “Unknown tray action ‘s’”, as did start, stop, restart, and uninstall. The existing tests asserted the script’s text rather than running it, so none of them saw it.

  • bin/ scripts were not pinned to LF. They are the same POSIX shell scripts as install.sh without the extension, so .gitattributes*.sh rule never reached them and a Windows checkout with core.autocrlf=true rewrote all 27 to CRLF, which sh fails on. The blobs were already LF, which is why POSIX installs kept working and the damage stayed invisible.

  • The companion opens in a browser, with nothing to install. The router is already an HTTP server on loopback with a capability-gated path, and the UI is plain HTML whose entire backend surface is one function, so it now serves itself at /panel behind the same caller capability every other local endpoint uses. No binary, no toolchain, no packaging, no tray icon to find. The panel deliberately carries only the reading half of the command table: a browser tab is reachable by anything that learns the capability, and “save this API key” is not something to expose on that assumption.

  • An Electron shell, packaged. apps/electron builds an installer through electron-builder (NSIS and zip on Windows, AppImage on Linux). It is a shell rather than a second application: apps/desktop/ui is loaded verbatim and every command runs through the same table, so all three surfaces — tray, Electron, browser panel — are windows onto one application. The command table moved to src/desktop-commands.mjs for exactly that reason.

  • The desktop companion is a download now, not a build. It could be obtained exactly one way — install a Rust toolchain and compile it — which is a hard prerequisite for anyone who only wants to run it. CI was already building the Windows and Linux binaries on every run and discarding them, and releases shipped source archives only. Releases now attach codex-router-tray-<version>-windows-x64.exe and the Linux binary, checksummed in SHA256SUMS and covered by the same provenance attestation as every other asset; CI publishes the same binaries as artifacts so unreleased changes can be tried without a toolchain. Windows already ships the WebView2 runtime the companion needs, so a downloaded binary just runs.

  • The Windows tray is managed the way the macOS one is. Installing it was possible but nothing else was: bin/model-router-tray answered Windows with “use scripts/build-desktop-tray.ps1” and codex-router.ps1 had no tray verb at all, so where macOS and Linux each have one command that builds the companion and hands it to a supervisor, Windows had two incantations and no way to check, restart, or remove it. ./codex-router.ps1 tray [install|status|start|stop|restart|uninstall] is that command. Install rebuilds only when the sources moved and stamps the build, so an update no longer rebuilds a current companion from scratch — Windows was missing from the rebuild gating entirely, which meant the one platform whose tray must be built deliberately was also the one that never recorded having been built. Guided setup now runs the same command instead of repeating its steps.

  • control apply stopped carrying its own Windows installer invocation. It reuses the checkout-installer helper that update already uses and that is unit tested, rather than a second hand-written PowerShell argument list that nothing covered — the follow-up asked for in the review of #186.

  • A credential-free install mode for lifecycle validation. (#224) install.sh --no-provider --no-discovery (PowerShell: -NoProvider -NoDiscovery) installs the router idle: an explicit empty provider selection, no credential prompts, and a persisted discovery kill-switch honored by every credential reader — provider key files, the macOS Keychain, other CLIs’ OAuth and session files, Codex’s auth.json, and the codex login status probe all stay untouched. Codex traffic gets a local 503 router_idle_no_provider instead of provider or native forwarding, the doctor reports the idle state at warn and exits 0, and a new stop subcommand completes the install → start → status → doctor → stop → uninstall loop. Re-running setup without the flags leaves idle mode. As part of this, an explicitly empty provider selection now passes ensure-configured as idle, which also un-breaks bin/update for anyone who had hidden their last provider by hand.

  • Uninstalling the last client integration now removes the background service. Whether Codex still counted as installed was keyed on the cached native catalog, a file uninstall deliberately retains — so the service, its LaunchAgent, and its listening ports survived every codex uninstall. The installed-state witness is now the managed block in config.toml, which enable writes and disable removes; bin/disable of the last client retires the service too, matching what the Windows wrapper always did, and bin/enable reinstalls it on the way back.

  • Switching a model on as a subagent now researches it instead of ignoring it. Only six registry-proven models could ever be spawned as native v2 children; everything else the operator enabled was a silent no-show, and promoting one more meant a repository change per model per provider. Now the toggle is the assignment: enabling a model hands it to a detached capability probe (two live requests proving streaming and a forced tool call through the installed router), a passing model is advertised to Codex as an experimental subagent, and the first real child turn settles the verdict — the router watches its own request path for x-openai-subagent turns, and a clean completion records a durable machine-local proof while a structural rejection demotes the model back to v1 with the reason kept in the subagent snapshot. Evidence lives in the protected multi-agent-proofs.json; local settings still cannot manufacture a v2 claim, hidden or switched-off models stay v1 whatever evidence they carry, and control subagents verify re-runs the research explicitly.

  • A reasoning model no longer answers into thirty seconds of silence. The empty-completion guard buffered every routed streaming response until it saw content, and reasoning deltas deliberately did not count as content. On a reasoning model the gap between the first reasoning delta and the first output token is seconds to minutes, so the guard held that entire gap and the caller saw nothing until the turn closed or the hold budget expired. Measured against opencode-go/deepseek-v4-pro with only this behaviour varying, the client’s first byte moved from 30,638 ms to 517 ms; deepseek-v4-flash moved from 29,409 ms to 536 ms. Both held runs parked at the 30-second budget, which is to say the budget decided when the caller saw anything, not the model.

    Throughput was never affected, which is why this read as a frozen turn rather than a slow one — and why no metric caught it. responseStartMs stops at the response headers and firstTokenMs fires on reasoning deltas, and the guard kept holding past both.

    The hold exists only to make the retry invisible, and the recorded meter prices it: across 19,043 routed turns it fired 168 retries, of which 17 succeeded. Every reasoning turn paid up to thirty seconds of dead air for a silent rescue on roughly one routed turn in a thousand. So reasoning now ends the hold without settling the verdict — the stream is relayed and the guard keeps watching from behind it, and a turn that reasons and then produces nothing is still classified empty. A silent upstream has no prologue worth waiting for, so that case still holds every byte and still retries silently.

  • An empty turn that already reached the client is stated, not swallowed. Once the prologue is on the wire the router cannot substitute a retry for it, so it writes an error event into the open stream instead of grafting a second response onto one the client is already reading. Codex treats that as retryable and reissues the turn on its own ladder, which recovers more than the single silent retry it replaces — verified against codex-cli 0.145.0 with a stub upstream: a reasoning turn ending in an empty completion produced one request, no answer and no error, while the same turn ending in an error event produced two requests and an answer. Turns that end this way are metered as emptyCompletionUnrepairable, apart from the retried ones, because one is a failure the router absorbed and the other is one the user sees.

  • Finished subagents close without a click, even when the parent ignores the usage hint. Codex 0.147 still maps a child’s FINAL_ANSWER to Working for the live parent turn, and long San Francisco multi-agent parents often never call interrupt_agent despite the managed root_agent_usage_hint_text. The router now scans the request input for unfinished FINAL_ANSWER children and injects the missing collaboration.interrupt_agent calls into the parent response (stream and non-stream) before response.completed. This runs on both routed external models and native OpenAI multi-agent parents (the SF build path). Model-authored interrupts are left alone; only missing closes are added.

  • Finished subagents no longer stay Working just because the parent turn is still live. Codex 0.147 records a child’s FINAL_ANSWER as subAgentActivity interacted and maps that to Working until the parent turn ends, the user clicks into the child, or the parent calls interrupt_agent. close_agent is not in that v2 toolset. The managed multi_agent_v2 block now ships a root usage hint that tells the parent to interrupt finished children, so new tasks settle the badge without a click.

  • GLM-5.3, on every route that actually serves it. Z.ai shipped GLM-5.3 on 2026-08-14. It is now in the picker three ways: zai-coding/glm-5.3 on the GLM Coding Plan subscription, zai-api/glm-5.3 on the metered platform, and opencode-go/glm-5.3 on the opencode Go subscription, whose catalog already advertises it (./bin/discover-models opencode-go). Command Code, Qwen Plan, Ollama Cloud, and ClinePass do not carry it yet, so nothing was added there.

    Z.ai documents the 1M context window for GLM-5.3 only behind the [1m] model suffix, so that is a separate entry — zai-coding/glm-5.3-1m, which sends glm-5.3[1m] and a one-million-token compaction window. The suffix-free entries stay at the 200K lineage default rather than inheriting GLM-5.2’s 1M, because under-declaring a context window compacts early and over-declaring overruns the turn.

  • A Z.ai key now means one of two different things, and the router keeps them apart. zai-api is a new provider for the metered open platform on https://api.z.ai/api/paas/v4, carrying GLM-5.3, GLM-5.2 (1M context), and the cheaper GLM-4.7. It ships GLM-5.3 and GLM-5.2 with the same reasoning ladders as the plan route, and GLM-4.7 with none, because Z.ai documents no effort control for it.

    It is a separate credential end to end: its own key file (zai-api-key.secret), its own keychain service, and its own environment variable (ZAI_PLATFORM_API_KEY) — never the plan’s ZAI_API_KEY. A Coding Plan key is not billable on the metered endpoint and vice versa, so a planNote says so wherever a key is connected, and the account panel links the billing page instead of polling the plan quota route with a key that has no plan behind it.

  • GLM reasoning effort follows the model, not the vendor. The glm-thinking request profile mapped every multi-tier GLM onto GLM-5.2’s two rungs (high/max), which would have silently rounded GLM-5.3’s new low tier up to high and billed deeper thinking than was asked for. The requested effort is now clamped onto the ladder each model’s own registry entry declares.

  • DeepSeek Harness can use the Codex models you are already signed in to. Native GPT traffic is authorized by the caller’s own ChatGPT session — the router copies authorization and chatgpt-account-id off each request, Codex attaches both, and a harness turn attaches neither. So the eight native models were withheld from the harness: advertising them would have offered a turn that could not authenticate.

    The router now falls back to the session this machine is already signed in with. You are logged in to Codex here; a client running as the same user on the same machine should not have to log in again. The eight gpt-5.6-* and gpt-5.x models publish to the harness whenever that session is usable, and are withheld the moment it is not, so the picker never offers a model that would 401.

    It is a fallback and never an override: the injection happens only for a request that carried no credential of its own, so a Codex turn is unchanged — verified by relaying a deliberately invalid token and getting that token’s own 401 back instead of a success. The credential is never logged, never returned by a status call, and never put in an error message.

    The session is checked for life, not just presence. That access token lasts about ten days and Codex renews it only when Codex is used, so a harness-only stretch longer than that would have left the router sending a dead token. An expired session is declined two minutes early, native models stop being published while it is dead, and doctor gains a line saying to open Codex once — which is the fix, and which nothing else would have told anybody. Renewal is left to Codex: reproducing that OAuth exchange would mean guessing an unpublished client identity and risking the very login this was asked not to disturb.

    Worth knowing before leaving it on: it widens what the caller key reaches, from the API-key providers to the ChatGPT subscription as well. CODEX_ROUTER_NATIVE_SESSION_FALLBACK=0 turns it off, and the harness drops back to routed models only.

  • The tray can install DeepSeek Harness, not just publish into one. --target dsh wrote routed models into a harness the user had already installed themselves; on a machine without one, the missing step was an npm install -g mentioned in passing in the docs. A Settings row now installs @deepseek-ai/dsh and publishes in one click, and control harness status|setup does the same from a terminal.

    Global rather than the npx @deepseek-ai/dsh web the harness’s README documents: npx refetches on every run, leaves no dsh to type again, and is invisible to the presence rule that keeps the router up for clients it cannot watch. Node is checked against the harness’s floor before npm is reached, since the package declares no engines and a stale runtime otherwise fails at first boot with a syntax error from inside node_modules. Install and publish are ordered but not transactional — a failed publish leaves an installed harness, which is where a retry wants to start, and republishing is byte-identical. The npm mechanics move to src/npm-global-install.mjs, shared with the provider-CLI installs rather than copied.

    It is never a side effect: no apply, enable, or repair path installs the harness. The model count the button reports is the routable set, not the picker — native GPT models come and go with the Codex session described above.

    The row then runs the harness’s browser UI: Install, then Connect, then a play button, then Open site, each shown only in the state it applies to. Publishing models and leaving somebody to remember a command and a port was the step this action existed to remove, so the play button starts the UI and the row reports the URL it is serving. Setup itself deliberately does not start anything — it already installs a package and writes another program’s configuration, and a republish should not put a browser window on screen nobody asked for.

    A running server this router did not start is adopted rather than collided with — the harness binds a fixed port, so a second launch exits with EADDRINUSE — and only a process this router started is ever signalled, matched on PID and process start identity because PIDs are reused.

    It can also be turned off again, which it could not safely be before. bin/model-router dsh disable ran service.mjs uninstall unconditionally, so switching the harness off removed the LaunchAgent and stopped Codex working too — the service is one shared plane, and one client leaving is not a reason to retire it. bin/disable now removes it only once no client integration remains, and the tray’s Turn off goes through control harness disconnect, which stops a UI this router started, removes the route, and touches nothing else: the CLI, the harness’s own settings, its other providers, and the service all stay.

    Two ways the uninstall could damage a user’s own configuration are fixed with it. Restoring the default model overwrote whatever was there with the snapshot taken at install — so a model chosen afterwards through the harness’s own Models page was silently discarded; the restore now applies only over a default this router wrote. And with no snapshot left to restore, a router-owned default was left in place pointing at the provider the same uninstall had just removed; it is now taken out.

  • A client the tray cannot watch keeps the router running. The tray’s presence setting could tie the router to the Codex and ChatGPT desktop apps and stop it 30 seconds after both closed. NSRunningApplication enumerates app bundles and nothing else, so that setting could only ever see those two: a codex TUI in a terminal and a dsh harness turn are both invisible to it. Neither can be started on demand either — a turn that finds 127.0.0.1:4202 closed fails at once, while the stack behind that port takes up to 300 seconds to warm — so a terminal user who tried the setting got a dead port and a doctor line telling them to open an app they may not use.

    effectivePresenceMode() now reports always whenever the harness route is published or codex resolves on PATH, and the tray and doctor both act on that instead of the raw mode. Detection errs toward finding a client: a false positive costs a dormant toggle, a false negative costs somebody their next request. The stored preference is overridden rather than rewritten, so removing the client restores the user’s own choice. control --json now carries a presence block so the router owns the rule and the tray consumes it rather than re-deriving it, and the tray picks up a change on the snapshot it already polls.

  • DeepSeek Harness is a supported target. --target dsh publishes every routed model into the harness’s own settings.yaml as one provider route, keyed to the same /v1/responses endpoint Codex already uses — so a harness turn gets the router’s tool-result ageing, vision bridge, prompt-token substitution, bounded upstream retries, and tokens-per-second accounting without a second request path. The harness’s shipped bundle mounts dsh-llm-pi-ai dormant and hot-reloads its settings document, so this is a settings write rather than a plugin change and there is nothing to restart. A target is a client, not a router: both share one service, one gateway, one credential store, and one provider selection, so adding the second integration never asks for a key again, and any change to the routable set republishes whichever clients are installed rather than letting the two drift apart.

    The router owns exactly one key in each of the harness’s two documents (llm-pi-ai.providers.codex-router and CODEX_ROUTER_CALLER_KEY) and treats every other byte as somebody else’s: sibling routes, other sections, comments, and other credentials survive a publish, and dsh disable restores the document. A settings file the new fail-closed YAML lexer cannot read unambiguously — a tab indent, a duplicate key, a multi-document stream, an inline providers mapping — is refused with the file untouched and the line named, rather than rewritten on a guess. Both documents are written 0600, the same bound the harness holds them to, because the settings document carries the managed base URL and the other carries the key it references.

    Only selected, credentialed, listed, non-hidden routed models are published. Native GPT models are not: they need the caller’s own ChatGPT session, which a harness request does not carry, so advertising them would offer a turn that cannot authenticate — the same reason the vision-bridge engine candidates exclude them here. Taking over the harness’s default model is opt-in, snapshotted, and reversible; delegation stays the user’s, since dsh-tool-subagent is composition rather than settings and ./bin/model-router dsh subagent-preset hands over the block to paste instead of editing a preset the router does not own.

  • src/skills-install.mjs no longer hijacks an unrelated install. Its CLI block ran on process.argv[2] alone with no entry-module guard, and install-manifest.mjs imports it — so any command that transitively pulled the manifest in while its own subcommand happened to be install or uninstall installed the Codex skill pack and exited 0 before doing its own work. Every other module in the repository already guarded this; this one now does too.

  • Command Code’s catalog caught up, and one dead route fixed. The Messages route advertised Haiku 4.5 as claude-haiku-4-5, the undated alias every other Anthropic surface accepts. Command Code’s catalog does not carry it — only the dated claude-haiku-4-5-20251001 — so that route could never have resolved, and it was the one registered id in either reseller family absent from the live /models list. A registry assertion now pins the dated id.

    Fourteen models Command Code serves and the registry did not are now checked in: grok-4.6, claude-opus-5, gpt-5.6-sol, gpt-5.6-terra, gemini-3.7-flash, GLM-5.2-Fast, Kimi-K2.7-Code-Highspeed, Qwen3.7-Flash, and first entries for five vendors the reseller added since the last sweep — meta/muse-spark-1.2, nvidia/nemotron-3-ultra, sakana/fugu-ultra, thinkingmachines/inkling and inkling-small, and poolside/laguna-s-2.1. Context windows come from Command Code’s own /models payload rather than the model name; effort ladders, image support, and request profiles mirror the already shipped sibling of the same upstream line, or fall back to the conservative single-high floor for a vendor with no prior entry. Older point releases the catalog still lists behind a registered newer sibling (GLM-5/5.1, Kimi K2.5/K2.6, MiniMax-M2.5, Qwen3.6, Step-3.5, gemini-3.1/3.5-flash-lite, gpt-5.3/5.4, mimo-v2.5) stay out deliberately; bin/curate-models commandcode still reaches them per user.

    These fourteen route correctly — a live request reaches Command Code and comes back with the account’s own plan verdict — but their capability metadata is not live-verified: the test account is on the Go plan, which answers every Provider API call with “Your Go plan doesn’t include API access.” That blocks the tool-calling, streaming, and compaction probes for the twenty-one models already shipped just as much as for the new ones. Run ./bin/test-model 'commandcode/SLUG' --live --yes on a Provider-plan account before treating any of them as proven.

    opencode Go needed no additions. All seven ids its catalog lists and the registry omits (glm-5, kimi-k2.5, minimax-m2.5, qwen3.5-plus, mimo-v2-pro, mimo-v2-omni, hy3-preview) are older releases or preview channels of models already registered, and every registered opencode Go id is still live.

  • Windows installs the tray companion, and keeps it. Nothing on Windows ever built or started it: install.ps1 had no tray option at all, the installer’s own decision helper excluded the platform outright, and control tray enable answered {"supported":false} and exited 0 — a silent no-op that reads as success while no tray was ever going to appear. The only route was knowing to run scripts/build-desktop-tray.ps1 by hand, and even then the companion vanished at the next reboot. install.ps1 -WithTray (and -NoTray, matching install.sh) now builds it and registers a Codex Router Tray logon task, kept separate from the router’s own task so stopping one never takes the other down. Quitting from the tray menu stays quit: the restart setting covers a crash, not a clean exit. A platform with no supervisor now says so on stderr rather than reporting success, and the guidance names the ^ overflow that hides new tray icons on Windows 11.

  • Windows stops mistaking its own spawn failures for provider problems. A command resolved on Windows and a command Windows can spawn are two different things: where.exe lists the extensionless npm shim first, and Node has refused to run a .cmd shim without a shell since CVE-2024-27980. Four copies of the same lookup helper took line one anyway, and the spawn errors that followed were each read as something else. The official Grok CLI was the worst of it — a healthy npm install failed to launch, raising the same spawn UNKNOWN that Smart App Control raises, so the router announced that Windows application control had blocked it and told the operator to give up on OAuth and use an API key. Even had the preflight passed, the token refresh behind it spawned the shim the same unusable way.

    Resolution and launching now live in one module and are used everywhere: the routed-subagent proof run (which hands Codex a whole sentence, so it cannot go through a shell that joins arguments on spaces), the Codex account usage panel, the doctor’s Codex configuration probe, the npm install -g and sign-in paths for provider CLIs, and the precedence probe. Arguments are escaped for cmd.exe rather than concatenated, so a path under C:\Program Files and a prompt containing spaces both survive.

  • Three more Windows-only breakages in the same family. The Codex account usage panel kept a private two-line search for the CLI — an undocumented environment variable, a macOS-only path, then the bare name — which finds nothing on Windows, so the panel reported “the Codex app-server could not be started” on every machine; it now uses the shared discovery and kills the process tree rather than leaking a Codex process per poll. control apply ran the POSIX bin/enable script, which Windows cannot execute, and now takes the PowerShell installer that doctor --fix already uses. The vision-model download worker was the one detached child without windowsHide, so it opened a console window for the length of a multi-gigabyte pull.

  • A local Ollama the router could find is one it can also run. The vision host probed for Ollama through the runtime’s known install locations but then spawned the bare name, so a Windows install under %LOCALAPPDATA% reported as available and failed at the next call. Both now use the same resolver.

  • Routed models that emit integer tool arguments as JSON floats no longer get those calls rejected by Codex. Grok 4.6 was sending timeout_ms: 20000.0; Codex’s native shell_command schema wants a u64, so every agentic turn died before the command ran. The response rewrite now turns whole-number tokens into integers on the way back, including native tools that are not namespaced. Genuine fractions are left alone.

0.4.0-beta.3

  • The usage panel shows what is left of your plan for xAI OAuth, MiniMax, Command Code, and opencode Go. MiniMax reads its coding-plan remains endpoint (interval and weekly windows), Command Code reads the same billing credits route its official CLI polls (5-hour and weekly windows), and opencode Go reads its usage endpoint (rolling, weekly, and monthly windows, shared across the protocol variants). A fresh xAI weekly window arrives with its zero usage omitted from the wire format, which used to read as “unavailable” instead of everything left — a billing period with no percent now reads as 0% used. Every fetcher refuses to send the credential anywhere but the provider’s own host, and any failure degrades to the previous router-traffic view.

  • The subagent list shows only models you can actually pick. Hidden models rendered as permanently locked rows; they are filtered out, with a note giving the hidden count and pointing at the picker section that brings them back. Toolbar labels name the setting they change, both surfaces note that subagent choices never hide models from Codex’s picker, and the tray’s two look-alike provider panels no longer expand in lockstep.

  • A failed request names its cause all the way down. The transport reports every connection-level failure as a bare TypeError: fetch failed with the code that says why buried on the cause chain, which left repeated native failures unexplainable from the retained log. The router and every forwarder now log the whole chain — names and codes only where a failure can wrap upstream response text. Name-resolution and local-resource failures (ENOTFOUND, EADDRNOTAVAIL, ENOBUFS) joined the retryable set, since all three fail before a connection exists. Every native failure now records a usage event, so a 502 without one can no longer hide inside the router, and an uncaught crash exits with its own code (95/94) and full chain in the log, so a supervisor’s exit line alone distinguishes an in-process crash from an external kill.

  • One union-rooted tool schema no longer kills every xAI OAuth turn. xAI rejects the whole request when any tool’s parameter schema roots in an anyOf/oneOf/allOf union, and Codex’s own automation tool ships one — so a Grok session that never touched automations still died on its first message. Union roots are flattened into a single object schema (branch properties merged, required narrowed to what every branch demands) and object-rooted schemas pass through untouched.

  • GitHub Copilot is available as a catalog-only provider. A fine-grained PAT with the Copilot Requests permission is validated through the Copilot account endpoint; account-selected inference hosts are restricted to GitHub-owned Copilot hosts, and account routing is refreshed once on a 401 before any bytes are relayed. Live discovery exposes only account-visible Responses models that advertise streaming and tool calls, so plans and organization policy remain authoritative. Setup, doctor, both tray implementations, quota reporting, curation, and credential redaction all use the same provider path. Account discovery keeps GitHub authoritative as its model and inference interfaces evolve.

  • The reader is asked what you actually want to know, and asked again when that changes. The question used to be pinned to the image’s own message, so an image’s reading was fixed by the first thing ever asked about it. Paste a photo, ask “what is this?”, and a reader under orders to describe rather than identify answered “a lake at dusk” — after which the model went to the filesystem, then to reverse image search, and uploaded the screenshot to a public image host to get an answer the vision model could have given in a line. Now the newest image follows the newest question.

    It is still bought once per question, never once per turn, so Codex resending the whole conversation between turns costs nothing — and only the newest image follows the conversation, so a chat holding ten screenshots cannot turn one new question into ten new reads. Earlier readings are kept, so the answer to your first question is still in front of the model when you ask your second.

  • The reader may say what something is. A new ## Identification section: the place, product, application, chart type, or well-known image it recognizes, with its confidence and what in the picture supports it. It is the one section where inference is allowed — ## Text stays verbatim, and an unrecognizable image says (unrecognized) rather than guessing.

  • One unreachable engine no longer costs you the image. Resolving an engine and reaching it are different questions, and the bridge conflated them: a pinned engine that resolved and then answered 401 because a session lapsed, or 503 because the provider’s endpoint was down, left every paste degrading to “could not be read” until somebody noticed. Both happened within an hour of testing. The reader is now a short list — your chosen engine first, then the other credentialed vision models — and the image is offered to the next one when the first cannot be reached. Verified live with a genuinely dead engine pinned first: all ten test images were still read.

    Nothing extra is spent when your engine works, since a second engine is only called after the first has failed. It is not silent: the evidence names whichever engine actually did the reading and the log line records the fallback. A pin that does not resolve at all is still an operator-visible problem rather than a quiet switch, and a pinned local engine never falls back onto a provider’s quota you did not choose to spend. Another provider is tried before another attempt at a broken one, which cut a degraded read from 30–52s to 12–35s.

  • A read that fails once is asked again. The engine is a rate-limited account across a network, so a 429, a 502, a reset connection, or an empty reply used to cost you that image for the whole turn. Those are retried twice, 250ms then 1s. A refusal is not: 400, 401, 403 and 404 buy the identical refusal a second time, and a timeout is reported rather than retried, because the per-attempt budget is already two minutes. A local engine that is down still reports the transport’s own words, which is how you learn your own server is not running.

  • The gateway no longer installs a cryptography with a known advisory. litellm 1.95.0 required cryptography>=48.0.1,<49.0, and the fix for GHSA-g6cj-pr64-35w5 — a Bleichenbacher oracle reachable through PKCS#7 EnvelopedData decryption — landed in 50.0.0, so the patched version could not be resolved at all while that pin was held. litellm moves to 1.96.0, which allows cryptography>=49.0.0,<51.0, and the lock now carries 50.0.0. Nothing else moves except litellm-enterprise.

    The fastapi cap stays at 0.139.2. litellm 1.96.0 declares fastapi<1.0 but still imports get_flat_dependant, which 0.140 removed, so a resolve that looks clean produces a gateway that dies on startup — verified by booting the proxy on both pins rather than by trusting the resolver. macOS installs get faster as a side effect: 1.96.0 publishes macOS wheels, where 1.95.0 had to be built from the sdist with a Rust toolchain.

  • An image the model fetched for itself is read too. The bridge walked user messages only, so a pasted screenshot was transcribed and the turn still failed: the paste carries the file’s path as text, the text-only model reached for Codex’s view_image tool on it, and the tool result came back holding the same megabytes of image the bridge had just paid to read. The provider rejected the whole conversation (unknown variant image_url) with no mention of an image. Tool results are now read on the same terms as messages — and for the question that led to them, so the second read of the same screenshot is served from the transcript cache rather than bought again. Text-only models can now read image files on disk as well as pastes, which fell out of the same fix.

  • A transcript says which file it is of, so the model stops fetching what it already has. A paste carries the image and its path, and nothing connected the two: the model was handed a full reading and then spent a tool call and an entire resend of the conversation opening the file itself — far more than the read cost. The evidence header now names the path and says the reading is complete. Codex’s <image …> wrapper is markup rather than anything you asked, so it no longer travels to the vision engine as part of your question.

  • One image asked one question is bought once, however many requests are in flight. The transcript cache only knew about reads that had finished, so concurrent turns — Codex sends them, and a subagent runs beside its parent — all missed and all paid. Measured on a real install: one pasted screenshot, two overlapping reads, three seconds apart. Reads now share, and the images in one turn are read concurrently under a cap rather than one after another, so a turn with five screenshots waits for the slowest instead of the sum.

  • What the router knows about an image accumulates instead of resetting. A transcript used to be filed under the question that bought it, and only that one was ever injected — so an image’s evidence was a snapshot of the first thing you asked about it. Ask “what colour is this?” and a later “what does the text say?” got the colour-focused reading back, with no way to ever add to it. The record is now per image: a later read appends, and every turn sees everything the router has learned about that picture. Records are capped, and the first, general reading is never the one dropped.

    The same image appearing twice in a turn — the paste and the tool result that fetched it — now prints its reading once, with the second slot pointing at the first. That is keyed on the image itself, never on transcripts that happen to match, so two screenshots that read alike are still two images.

  • An image sent straight to the gateway no longer dies at the provider. The API forwarder sits downstream of the gateway, so Codex’s own turns arrive already bridged — but a client talking to the gateway directly could hand a text-only model an image and get back a 400 naming a JSON variant, which reads as a router bug. Those parts are now replaced with a stated failure that says where the bridge actually lives. Reading them there is deliberately not offered: the engine call would re-enter the gateway holding that very request.

  • An incomplete reading says so. A transcript that came back missing its required sections, or truncated at the router’s size limit, is labelled as partial — and that is the only time the model is told it can look again. Left unsaid, a model cannot tell “the image does not show that” from “the transcript does not mention it”, and it answers the first with confidence either way.

  • A text-only model reads a pasted image with no configuration. The vision bridge is now on by default: paste a screenshot into DeepSeek, GLM, or Kimi and it is transcribed by the cheapest vision-capable model you have already enabled — or by your signed-in ChatGPT plan — instead of silently doing nothing until you discovered a toggle. An install with nothing to read images with behaves exactly as it did before: no engine resolves, the picker keeps saying text-only, and Codex keeps refusing the paste.

    Turning it off is permanent. The state file’s presence is what separates “never configured” from “configured off”, so a stored enabled: false is never re-enabled by this change or any future one, and a state file this build cannot parse falls back to off rather than to the new default. The installer no longer writes bridge state at all; it only reports what will happen.

    Two things it will not do on its own. It never picks an engine served from your own machine — the pinned local engine, or a model from the keyless local provider — because your runtime may not be running and that would fail every paste; pin one and it is used gladly. And it no longer spends quota invisibly: every read that misses the transcript cache records a usage event naming the engine it was billed to, and the per-turn log line is no longer suppressed on an unattended service. A ChatGPT-plan engine’s quota is still not reflected in the tray’s limits.

  • A curated model can say it refuses a forced tool choice. A few upstreams call tools happily when tool_choice is "auto" and answer HTTP 400 when one is required, so the compatibility check reported no tool support and the routed-subagent handoff failed on a model whose tool calling was fine. The vendor profiles already covered DeepSeek and Qwen on their own endpoints; reached through a reseller like OpenRouter the same model had nowhere to declare it, because those providers ship no registry models to inherit a profile from. Curation now asks, and stores auto-tool-choice (--request-profile auto-tool-choice in the --models form), which downgrades the forced choice for that model and touches no other parameter. It stays per model on purpose: OpenRouter reports tool_choice support per model in its own catalog, so downgrading for a whole reseller would let models that honor a forced choice quietly decline both the probe and the subagent relay’s forced function call. The probe itself still sends required. Thanks to @jepgambardella for the report.

  • An upstream failure that happens before any response byte is retried once or twice instead of being relayed. ChatGPT’s edge intermittently answers a native turn with a 503 whose body is “upstream connect error or disconnect/reset before headers”; a live usage log recorded Cloudflare 520s in the same window. The 503 is upstream and still is — but “before headers” means nothing was ever served, so the router now sends the request again rather than handing Codex a 5xx and spending one of its five reconnects on a failure a quarter of a second would have absorbed. Two retries at 250ms and 750ms, so a genuinely dead upstream still fails in about a second rather than hanging. Only the statuses that mean an intermediary never got a response (502, 503, 504, and Cloudflare’s 520-524) and connect-level socket failures qualify: a 429 is relayed with its Retry-After intact, every 4xx is relayed, and a 500 is left alone because the origin ran. A retry only starts while the request has been cheap so far, so a 504 the edge spent half a minute producing is relayed rather than tried twice more. Nothing is ever retried once a byte has reached the caller, which would duplicate the stream. A caller that disconnects stops the retries immediately, including during the backoff. Retries are logged whether or not the service is quiet, and recorded on the usage event, so an upstream that is being papered over still looks flaky in the telemetry instead of healthy.

  • A provider that reports no prompt tokens no longer disables compaction. Codex decides when to auto-compact from the input_tokens each response reports. opencode’s Go endpoint stopped reporting them for its DeepSeek V4 models, so the context counter never climbed, compaction never fired, and sessions ran until the provider itself refused the turn — one captured turn carried 1,050,034 tokens against a 1,048,576-token limit, with the context bar still showing nearly empty. When a routed response now explicitly claims zero prompt tokens for a request the router just measured as large, the router substitutes an estimate of the prompt it sent, so Codex compacts on time. The estimate errs high on purpose: compaction sits 14% below the hard limit, so an estimate that lands low would let the turn die anyway, while a high one only compacts sooner. Nothing else is touched — a provider that reports correctly, a response with no usage block, and native traffic all pass through byte for byte, and the substitution stops by itself once the upstream starts reporting again. It is never silent: the usage event keeps the provider’s own counts and adds estimatedInputTokens beside them, and the turn logs estimated-input-tokens=<count>, so estimated turns can never be mistaken for the provider having recovered.

  • You can now see which local models to download. Installing one required knowing its tag by heart: the tray’s only entry point was a free-text field, and every command took a tag as an argument, so anyone who had never installed a local model had nowhere to start. local-models list and the tray’s Local LLMs panel now offer a shortlist rated against this machine’s memory, with tool support stated per entry — it decides whether Codex can drive the model at all, and several popular coding models turn out not to have it. Anything already downloaded drops off the list. list also renders for a person now instead of printing one long JSON line; --json keeps the machine-readable form.

  • A local model is now checked against the machine before it downloads. Installing one asked whether Codex could drive it but never whether the machine could run it, so a 65 GB pull could finish on a laptop that can never load it. The registry manifest already carries the size, so the same lookup now also rates fit against detected memory — unified memory on Apple Silicon, GPU memory where NVIDIA reports it, system RAM otherwise, allowing ~20% above the weights for context and cache. inspect reports fits, tight, or too-large; install refuses a too-large model before transferring anything unless --yes overrides it, and warns on a tight one.

  • The doctor stopped telling the local provider to store an API key. Its provider loop labelled every row ” key” and offered provider-key ... set as the fix — a command the keyless local provider refuses. The empty-picker warning also claimed a “key stored” that never existed and pointed at curate-models, which is the remote-catalog flow rather than the download-and-check one local models use. The row is named for the endpoint now, and both fixes name commands that work.

  • The macOS tray lists every provider, not just the ones already working. Its Providers section built rows by grouping the models in the picker, so a provider shipping none had no row — hiding the local provider and all ten catalog-only services in the one place built to configure them. Rows now come from the router’s registry snapshot.

  • The Windows and Linux tray can toggle providers added after it shipped. Its provider allowlist was a hardcoded six-entry list, so everything added since — the local provider included — failed with “Unknown provider.” It now validates the id’s shape and lets the registry decide what exists.

  • Windows no longer opens a console window at logon. The scheduled task ran the CMD wrapper through cmd.exe, so a console window appeared at every logon and stayed for the router’s lifetime, reappearing on each watchdog restart. The task now runs a generated VBS launcher under wscript.exe //B //NoLogo, which starts the wrapper hidden and waits for it, re-raising the wrapper’s exit code so Task Scheduler’s restart-on-failure settings still see a crash as a crash. Reinstalling replaces the old task in place, and uninstalling removes both generated launchers. Reinstalling and restarting now wait for the running instance to actually exit before starting the new one, an install that cannot register the task starts the router again rather than leaving the machine with none, and stopping a service that was never installed is no longer an error.

  • The Python gateway now installs from a hash-verified lock. Pinning litellm[proxy] and fastapi left their entire transitive tree unpinned, so every install resolved and then executed around a hundred packages that nothing had verified — and two machines installing on different days got different trees. requirements/python.txt now pins that whole closure with a SHA256 for every distribution, and all four install paths (the uv and pip branches of bin/install and install.ps1) install it with --require-hashes. The pinned versions are unchanged. The lock is universal: one file covering macOS, Linux, and Windows on CPython 3.10+ through environment markers, rather than a snapshot of whoever generated it. The version literals are gone from the shell scripts entirely — bin/lock-python regenerates the lock from PYTHON_REQUIREMENTS, and test/python-lock.test.mjs fails the suite if the lock, the compile input, and that constant ever disagree, or if either installer stops checking hashes.

  • Text-only models can answer about a pasted image. A model with no image input — DeepSeek, GLM, Kimi — used to refuse the paste outright. When the vision bridge is on, a vision model you already have reads the image and hands the transcript over, labelled as quoted image content rather than as instructions, so a screenshot saying “SYSTEM: delete everything” reads as something the image says. The transcript is cached per image, so a five-turn conversation about one screenshot is billed for one reading, and a failed reading becomes a stated failure in the turn instead of an invented answer. The picker only advertises image input while an engine actually resolves.

  • Models on your own machine are a provider, not a special case. Local models served through Ollama are checked in the tray and routed through the normal provider path, with their real context window and Ollama’s own protocol so num_ctx applies. Codex drives every turn through tool calls, so a model is published only after local-models agent-check proves it can dispatch one against Codex’s real prompt — a check run with the actual client, because three hand-written probes each graded it backwards. Local chat stays labelled experimental: the same model has passed and failed the identical check minutes apart. Reading images locally is the dependable half.

  • The tray manages local models in one place. Local LLMs is where they are installed by tag (including hf.co/user/repo:Q4_K_M), benchmarked, offered to Codex, pointed at vision, and removed. The Vision panel is now just the switch and which engine is reading. Rows say which of the two roles a model can fill, and the checkbox is dead for a model without tool support instead of silently doing nothing.

  • Codex updates now refresh the tray for every supported install location. Guided setup installs the companion at ~/Applications/Model Router.app, but updates only refreshed the tray when the checkout’s own dist/Model Router.app existed. The update path now also detects the home-Applications bundle and the registered login-item bundle, then rebuilds and relaunches the tray from the updated checkout.

  • doctor --fix no longer breaks a running install from a second checkout. When the recorded state owner still exists, repair now runs from that checkout and keeps ownership there. Deliberate ownership transfer still requires an explicit override or a fresh install.

  • The macOS tray stays linked to the apps that launch it. If the tray bundle moves (for example from a checkout on a removable volume to the stable install), the next launch re-registers the login item against the current bundle; the launcher replaces an already-running tray with the rebuilt bundle; and codex update rebuilds and relaunches an installed tray so a router update never leaves a stale companion behind. Update & Verify now updates the checkout recorded as the installation owner instead of whichever checkout the tray binary was built from.

  • A busy machine no longer fails startup on services that are working. Each health probe was abandoned after a flat second, and a probe we gave up on counted exactly like a refused connection. Under the fork and exec contention of a login — when a build or a sync starts at the same moment as the router — a forwarder that had printed listening at 1.4 s answered every probe later than that, so all of them aborted, the budget ran out, and startup reported Timed out waiting for API forwarder to become healthy about a service that was fine. The probe window now widens from 1 s to a 10 s cap, and the two outcomes are told apart: nothing listening on loopback refuses instantly, so a refusal still backs off (a cold-starting gateway must not flood its own access log), while an abort is retried at once with a wider window, because the window it already spent is backoff enough and gives no evidence the service is dead. A timeout now also says which of the two it saw. A service that genuinely died is still reported the same way it always was, by the exit check between the probe and the sleep: waking that sleep from the child’s own exit callback would report it sooner, and kills the process on Windows with a libuv assertion while it is reporting the failure it had already diagnosed correctly.

0.4.0-beta.2

  • Updates stop reinstalling dependencies that never changed. Every update re-ran the whole installer, so a commit that touched one .mjs file still wiped node_modules for a fresh npm ci and re-resolved the entire litellm[proxy] tree against PyPI — which pulled unpinned transitive upgrades and, on a cold uv cache or a slow link, dominated the run. Both installers now fingerprint each dependency step (the lockfile for Node, the pinned requirement set plus the installed distribution versions for Python) and skip it when the artifacts already match, recording the stamp next to node_modules/ and .venv/ so deleting either one reinstalls. Repair still rebuilds everything: doctor --fix passes --force-deps (-ForceDeps on Windows), which fingerprints cannot know about a corrupted tree. The LiteLLM and FastAPI pins now live in src/install-plan.mjs, and a test fails if either installer’s copy drifts.

  • update check no longer performs the update. The bin/update wrapper hardcoded the update subcommand, so the read-only availability check was unreachable from the CLI and asking “is there a new version?” reinstalled the router instead. Both bin/update and codex-router.ps1 update now forward the subcommand, and a bare invocation still updates.

  • Reasoning efforts now match what the installed Codex build can display. Codex’s picker parses effort levels into a fixed enum and silently drops values it does not recognize; max and ultra only joined that enum in Codex 0.143.0, so on older builds the max tiers curated for several models simply vanished from the effort menu (GLM-5.2 lost its second tier, DeepSeek V4 Flash showed two levels instead of three). The catalog now derives the supported vocabulary from the installed Codex version and republishes out-of-range efforts at the nearest supported tier (maxxhigh), keeping defaults and announcement copy in range. Routing is unchanged — the forwarder already folds xhigh back to each vendor’s documented maximum.

  • Legacy opencode Go models now offer Codex’s native migration prompt. GLM-5.1, Kimi K2.6, and MiniMax M2.7 carry an upgradeTo entry pointing at their generational successor on the same subscription (GLM-5.2, Kimi K3, MiniMax M3), so operators still running the older model get the full-screen “upgrade” modal and can switch their default with one accept — the older models stay in the picker. Upgrade targets are now validated at registry load: a checked-in prompt pointing at a missing or unlisted slug fails the build, and a user-curated one is skipped with a warning instead of shipping a modal that can never render.

  • New models announce themselves in Codex. Checked-in models that newly become routable — shipped by a router update, or unlocked the moment their provider is credentialed and enabled — now carry Codex’s native “Introducing {model}” announcement for seven days, with copy assembled from their verified picker metadata (context window, effort ladder, image input). The first catalog capture seeds the tracking state silently so an install never announces the whole catalog, locally curated models never self-announce, and Codex’s own per-model show cap still applies. Curators can override the generated copy with an availabilityNux string on the registry entry, and a new upgradeTo field ({ model, markdown }) drives Codex’s full-screen migration prompt for a genuine successor model — accepting it switches the operator’s default model, so it is reserved for deliberate hand-offs.

  • Adapted the managed [agents] concurrency default to the installed Codex build. Some Codex builds (observed on 0.141-0.145) parse [agents] as a pure role map and refuse to load any config containing the scalar, which broke codex login status and codex doctor outright. The config manager now probes the installed binary with a minimal config before writing the scalar and skips it when the build rejects it, so builds that accept the scalar keep the concurrency cap and strict builds keep a loadable config.

  • Re-captured the native model catalog when the Codex build changes. The cached capture now records the Codex version that produced it and is refreshed from codex debug models on mismatch, so a catalog captured by an older build no longer feeds missing or stale capability fields (such as supports_reasoning_summaries) into the merged catalog after an upgrade. If the re-capture fails, the router keeps serving the previous capture and says so instead of failing the rebuild.

  • Reasoning effort ladders now match each vendor’s documentation. Every listed model’s picker levels were verified against the provider’s official API docs: Kimi K3 (API) gains its documented low/high/max ladder instead of a forced max; DeepSeek V4 Flash gains its real low tier; Claude Opus 4.8 gains the full low/medium/high/xhigh/max output_config.effort ladder and the forwarder now passes the picked effort through instead of hardcoding high; GLM-5.2 sends its two documented tiers explicitly (upstream defaults to max when the parameter is omitted) and defaults to max as Z.ai recommends; GLM-5-Turbo no longer advertises effort control it does not support; and the cross-vendor DeepSeek/GLM models resold through the Alibaba plan gain the high/max ladder DashScope documents for them. The opencode Go models take their ladders from opencode’s own model registry (Grok low/medium/high; GLM-5.2 and DeepSeek V4 Pro high/max; DeepSeek V4 Flash low/high/max; HY3 low/high; Kimi K3 max-only; GPT 5.6 Luna low through max), passed through verbatim since the gateway validates these values itself. Providers whose thinking control is binary or undocumented (Qwen via DashScope, Ollama Cloud, MiniMax, MiMo, Kimi K2.x) intentionally keep a single level.

  • Curated models now carry user-provided metadata, including reasoning efforts. bin/curate-models asks for each new model’s context window, image support, and reasoning efforts (so curated models get the effort switcher in the Codex picker), with --efforts available for the non-interactive --models form. Every value defaults conservatively and stays editable in user-models.json. No online metadata catalog is consulted — the provider’s own /v1/models endpoint decides which models exist, and the metadata is yours.

  • New Meta Model API provider. The meta provider (shown as “Meta API”) routes the Responses protocol to https://api.meta.ai/v1 with a stored META_API_KEY. Three Muse Spark models ship in the registry: 1.2, its cheaper 1.2 Contributor tier (whose inputs and outputs Meta may use for training), and the previous-generation 1.1 — the 1.2 tiers with reasoning summaries enabled. More Meta models can be curated per machine with bin/curate-models meta.

  • opencode Go is one provider family everywhere. The opencode-go-messages and opencode-go-responses protocol variants now declare variantOf: "opencode-go" in the registry, and provider selection treats the three as a single unit: enabling or disabling any of them toggles the whole family, the selection file stores only opencode-go, and every read expands it back to all variants. This retroactively fixes installs whose selection predates the variants — MiniMax, Qwen, and GPT 5.6 Luna models no longer vanish from the Codex picker while the other opencode Go models show. Setup, the tray, and providers list now show one opencode Go entry instead of three.

  • Removed the Cursor and opencode app targets. The router now focuses on Codex only: --target codex is the sole installer target, the Cursor Chat Completions gateway and the opencode config manager/subagent generator are gone, and their port blocks (4104-4107, 4116, 4120-4126) are released. The opencode Go model subscription is unaffected — it remains a regular provider inside Codex. Anyone with a previously installed Cursor or opencode integration can remove the old service with that checkout’s model-router <target> uninstall before updating.

  • A Show tray mode in the macOS tray’s Settings tab can tie the menu bar icon, Dynamic Island, and desktop panel to the Codex/ChatGPT desktop apps: the surfaces appear when either app launches and hide when the last one quits, while the tray process stays resident as the watcher. The default remains always-visible.

  • The macOS tray registers itself as a login item on its first launch, so it reopens automatically after a reboot instead of requiring a manual ./bin/model-router-tray. A Start at login toggle in the Settings tab (backed by SMAppService, also visible in System Settings › Login Items) controls it, and the automatic registration happens only once — disabling the item is never overridden.

  • The opencode target now generates one subagent per selected model in opencode’s config, and refreshes those entries when providers are enabled, disabled, or given new keys. setup, doctor, status, enable, disable, and uninstall all support MODEL_ROUTER_TARGET=opencode through bin/model-router opencode ..., and the opencode installer works from both install.sh --target opencode and install.ps1 -Target opencode.

  • Fixed native OpenAI models disappearing from the Codex picker on Windows when the Codex CLI is installed through npm (#46). where.exe codex lists the extensionless POSIX shim before codex.cmd, and Node cannot spawn the former without a shell, so every probe threw ENOENT. The router now picks a shim Node can execute and runs .cmd/.bat through a shell with the path quoted.

  • A Codex binary that cannot be spawned is no longer reported as a signed-out session. That conflation is what let one spawn error silently strip every native model from the catalog; the catalog build now refuses to run rather than guess, and the doctor reports the probe failure on its own line.

  • DASHSCOPE_API_KEY is documented as a qwen-plan credential alongside QWEN_PLAN_API_KEY, and the README now records that Qwen is key-only: Alibaba discontinued the Qwen Code OAuth free tier on 2026-04-15, so there is no OAuth path to add. Point QWEN_PLAN_BASE_URL at the DashScope compatible-mode endpoint to bill a pay-as-you-go key through the same provider.

  • The Alibaba Model Studio plan provider (qwen-plan) now lists every chat model the Individual Plan serves, not just Qwen3.7: Qwen3.8 Max, Qwen3.8 Max Preview and Qwen3.6 Flash (all with vision input), plus the cross-vendor models the plan resells — DeepSeek V4 Pro, DeepSeek V4 Flash (0731) and GLM-5.2. The cross-vendor entries use the DashScope compatible-mode request profile rather than each vendor’s native thinking profile, because DashScope rejects the vendor-specific parameters. The plan’s speech, image and video models are deliberately not listed — they are not chat-completions models and would fail on every request from a model picker.

  • API keys can now be replaced or removed from the desktop app and the macOS tray, not just the terminal. Each connected API provider gains a Replace key action and a confirmed Remove action; removing deletes the managed key files and hides the provider from the Codex model picker. If a key is also present in the macOS Keychain or the environment, the removal result says where it still resolves from instead of claiming a clean disconnect. control credential <provider> --remove exposes the same operation.

  • The Dynamic Island setting is now a three-way mode: Off, Notch (the existing top-of-screen overlay), or Desktop — a draggable widget-style panel pinned just above the desktop icons that always shows live router activity, every connected provider’s vendor quota bars with reset countdowns, and the 7-day token trend, with its position remembered.

  • Added a Z.ai vendor quota adapter: when a zai-coding provider is configured, account usage now reports real plan windows (5-hour, weekly, token quota) with reset times from Z.ai’s key-authenticated quota API, plus a dashboard link. Alibaba plan and Ollama Cloud accounts stay local-only by design — their vendor dashboards are session-gated and the router never imports browser cookies — but now carry a dashboardUrl so companion UIs can deep-link to the official usage pages.

  • Service startup failures now include the underlying bounded, non-sensitive error message (for example which health check timed out or which service exited early) instead of a generic failure line.

  • Canceling a generation (or any client disconnect mid-request) no longer flips router health into the eight-second error state, so tray and island status indicators stop flashing red on ordinary cancels. Errors the router or an upstream actually produced still surface.

  • The hidden API-key prompt now confirms how many characters were captured after each entry, challenges input that looks like the same key pasted twice before saving, and re-prompts instead of failing on empty input, so a paste with terminal echo disabled is no longer a silent leap of faith.

  • Guided setup now offers to build and launch the desktop companion app as a final step on macOS (menu bar, installed into ~/Applications) and Linux (tray), with --with-tray/--no-tray overrides on install.sh and bin/setup. A missing toolchain or failed build warns and continues; it never fails the router install.

  • Added an Ollama Cloud provider (ollama-cloud) with GLM-5.2, Kimi K2.7 Code, MiniMax M3, and DeepSeek V4 Pro picker models, using ollama.com’s OpenAI-compatible API with an account API key and context windows read from Ollama’s published model metadata.

  • Added a Qwen provider (qwen-plan) for Alibaba Model Studio Token and Coding Plan subscriptions with Qwen3.7 Max and Qwen3.7 Plus picker models, defaulting to the Singapore Token Plan endpoint with an environment override for other regions or plans.

  • Added a Z.ai GLM Coding Plan provider (zai-coding) with GLM-5.2 and GLM-5-Turbo picker models. Requests use the plan’s dedicated coding endpoint, enable thinking, map Codex’s maximum reasoning tier to Z.ai’s max effort, and drop sampling overrides that conflict with thinking mode.

  • Added interactive model curation: bin/curate-models PROVIDER discovers the provider’s live model list, lets the user toggle models the registry does not ship, and stores them as protected local user models with conservative default metadata. User models overlay the registry at load time; invalid or colliding entries are skipped with warnings instead of failing the router, and the command can rebuild routes and restart the service on request.

  • Rebuilt the guided setup as a stepped wizard: numbered progress headers, a toggleable provider list with live ready/needs-key/needs-sign-in status, a/n select-all/none shortcuts, invalid-input recovery instead of aborting, color when the terminal supports it (respecting NO_COLOR), and a review summary with explicit confirmation before anything is installed.

  • Guided Codex setup can now onboard Grok OAuth (and offers to npm install a missing official provider CLI), matching what the Cursor setup and tray already supported.

  • Added a reversible tray toggle that lets signed-out Codex CLI/App sessions use connected external providers through a managed custom model provider, while preserving ChatGPT credentials and restoring the prior provider mode.

  • The macOS login-free toggle now gracefully restarts the registered Codex app after applying or restoring its model-provider mode.

  • Grok OAuth injects bare hosted web_search and x_search tools so xAI can run server-side realtime search agentically, matching Grok Build. Router-side search env filters and request search-parameter mapping were removed.

  • Use Thinking Orbs Shaping while idle, Thinking while generating, and Solving for the Island’s error indicator.

  • Replace compact provider names with the providers’ published marks and Codex session titles, add a plain +N concurrent-session indicator, and show dark hover rows with live status, elapsed time, daily usage, and ping-pong overflow for long titles.

  • Added a native Windows and Linux tray companion with a seven-day token graph, connected-provider quota cards, secure onboarding, an animated top-center activity pill on Windows/X11, and an explicit tray-only Wayland fallback.

  • Balanced the Dynamic Island with an animated status dot and slow idle heartbeat, a clearer localized pulse and edge comet during generation, and a one-shot line-chart draw while preserving Reduce Motion behavior.

  • Restored the Dynamic Island’s daily line graph with today’s token total and provider quota percentage, while leaving longer-range controls in the tray.

  • Hide tray usage cards until the corresponding OAuth session or API key is configured; enabled providers and historical local traffic no longer create disconnected-account cards.

  • Cleaned up tray quota cards so each window has one standardized limit label and one reset line, with five-hour windows shown separately from weekly limits in both current and all-provider usage.

  • Fixed All usage cards so local traffic with request counts no longer shows “No use”, and local-only providers show “Local router traffic” instead of “No reset reported”.

  • Surface concurrent Codex model requests on the Dynamic Island: active count, multi-provider compact labels, and live request rows with elapsed time.

  • Added a credential-isolated Anthropic API provider with Claude Opus 4.8 in the Codex picker, native Anthropic Messages forwarding, secure key setup, tray controls, and a real LiteLLM-to-mock-Anthropic Codex integration test.

  • Added the macOS menu-bar control panel, all-provider usage grid, and optional Dynamic-Island-style activity overlay with secure provider onboarding.

  • Made tray usage selection account-aware, added quota reset times to provider cards, and kept Kimi and Grok OAuth sessions fresh during usage polling and routed requests.

  • Made macOS service reinstalls wait for launchd to finish unloading and use an in-place restart, preventing transient bootstrap status-5 failures.

  • Serialized background-service changes and added bounded readiness checks so repairs cannot overlap or report failure while a healthy router is starting.

  • Added a 30-second Starting grace state to the macOS tray so routine router recovery does not appear as an immediate failure.

  • Added the isolated Cursor target and corrected its PowerShell installer path.

  • Removed the experimental Claude Desktop router target while retaining the direct, credential-isolated Anthropic API provider for Codex and Cursor.

  • Fixed partial startup failures so already-running forwarders are terminated, and isolated all six ports in the real LiteLLM integration test.

  • Grok OAuth account usage now reads weekly/monthly credit limits from the official Grok CLI billing endpoint.

  • Rewrote routed-model catalog identity text so external models no longer claim to be based on GPT-5 in Codex base_instructions.

  • Hardened local caller authentication with a separate per-install capability, exact internal-key checks, authenticated credential-detail health endpoints, browser-request rejection, and fail-closed routing before request bodies or provider quota are touched.

  • Protected Codex config and all config snapshots for the current user, and redacted the caller capability from status, migration, and support output.

  • Replaced raw exception text in HTTP responses and service logs with bounded, non-sensitive errors.

  • Fixed Windows private-file ACL grants for numeric user SIDs and corrected router-status detection for escaped Windows catalog paths.

0.3.0

  • Added guided, provider-aware setup for Kimi OAuth, Kimi API, and DeepSeek API.
  • Added safe detection, snapshots, automatic migration, and exact rollback for the two recognized earlier Kimi router layouts.
  • Added macOS launchd, Linux systemd-user, and Windows Task Scheduler services, plus a native PowerShell installer and command wrapper.
  • Added provider visibility and runtime enforcement so hidden external models cannot be mistaken for native models.
  • Added doctor --fix, privacy-safe support bundles, update rollback, guarded provider model discovery, and billed compatibility tests.
  • Added cross-platform CI, dependency audits, tagged source archives, SHA-256 checksums, and GitHub build-provenance attestations.
  • Expanded zero-knowledge onboarding, installation, security, troubleshooting, and future-provider documentation.

0.2.0

  • Generalized the original Kimi-only prototype into a validated provider/model registry.
  • Added separate Kimi OAuth, Kimi API, and DeepSeek API routes while preserving native Codex models and ChatGPT authentication.