fix(model): /model listed one provider instead of every configured one - #772
Merged
Conversation
get_settings reports only the provider a session is currently on, so anything built on it can show at most one row. provider_catalog walks PROVIDER_INFO — the merged registry of hand-written provider classes plus the data-driven OpenAI-compatible specs — and returns the rows the picker renders: auth state, model list, and the fields that drive its inline API-key stage. Credential probing mirrors get_provider_config's literal lookup rather than trying to be cleverer, because _do_set_provider resolves the same way; a row reported authenticated here but refused there would be a picker offering a provider you cannot select. exclusive_env_vars splits a provider's key env-vars into the ones it owns and the ones it merely borrows. The candidate list is a resolution preference, not ownership — nvidia-nim accepts DEEPSEEK_API_KEY — so ownership follows the primary candidate.
list_model_providers returns the catalog. save_provider_key persists a key where `clawcodex login` writes it, reading the existing block from the GLOBAL tier only — reading the merged view would launder a repo-committable providers.*.base_url into the user's global config. disconnect_provider clears stored credentials. set_model's cross-provider refusal now carries provider_mismatch so the client can re-drive the switch through set_provider without pattern matching error prose, and canonicalizes both sides so an alias-named session (--provider glm vs the canonical zai) is not mistaken for one. disconnect deletes only env-var names the provider exclusively owns, and never one the live session authenticates through: the active-provider guard compares slugs, so it would otherwise miss a session running on nvidia-nim losing the DEEPSEEK_API_KEY it borrows. A shell export behind a config key is sampled before deletion — delete_secret pops os.environ too, so reading it afterwards would report a clean disconnect the shell undoes on next launch.
model.options was a stub: it called get_settings, which describes only the
active provider, and synthesized a hardcoded single-element array from it.
The picker's multi-provider UI was fed one synthetic row, so the list read
"anthropic - 22 models" and nothing else. It now calls
list_model_providers, and falls back to the old synthesis only on a null
reply (old backend or timeout) — an explicit refusal is surfaced, since
papering over it would invent a provider named "clawcodex".
model.save_key and model.disconnect were never wired and fell through to
the default case returning {}, so inline key entry always reported "failed
to save key" and ^d silently did nothing.
Cross-provider selection re-drives through set_provider on the backend's
provider_mismatch signal, then re-applies the model; if that fails it
rolls back and says what actually stuck. A silent backend is not treated
as a known failure, since it may have applied the model.
…er-picker # Conflicts: # src/server/agent_server.py # ui-tui/src/gatewayClient.ts
model.options now asks list_model_providers — the picker lists every provider, not just the active one — so these answered a control the client no longer calls first and timed out. The fusion contract they cover is unchanged: the reply carries the base id in `model` and the fusion name separately, and the picker's current marker follows the fusion name. Adds coverage for the same rule on the get_settings fallback path, which an old backend still reaches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Results 1 files 1 suites 7m 46s ⏱️ Results for commit 74f50ed. |
Merged
3 tasks
ericleepi314
added a commit
that referenced
this pull request
Jul 31, 2026
…e against the live API (#773) * fix(openrouter): refresh the curated catalogue against the live API The OpenAI section still led with gpt-5 / gpt-4o / o1 while OpenRouter had moved on to the gpt-5.6 generation, and openai/o1-mini had been delisted upstream entirely — so the /model picker offered a row that fails at request time, the same "offers something you cannot select" shape as the picker bug in #772. Validating the rest against https://openrouter.ai/api/v1/models caught five more dead ids: anthropic/claude-3.5-sonnet, anthropic/claude-3.5-haiku, google/gemini-2.0-flash, meta-llama/llama-3.1-405b-instruct, deepseek/deepseek-v3.2-speciale and x-ai/grok-2. All are replaced with live successors; every id in the catalogue now resolves. Batch (:batch), image, audio and embedding variants stay out — none of them serves an interactive agent turn, and free-text ids are accepted regardless of this list. The list was also duplicated verbatim between PROVIDER_INFO["openrouter"] and OpenRouterProvider._curated_models(). Two lists for one set drift the moment someone edits one, and the halves feed different surfaces — the registry drives login and the picker, the other drives discovery's curation — so the provider now reads the registry. * feat(models): GPT-5.6 (Sol / Terra / Luna) Sol, Terra and Luna are durable capability tiers on one generation rather than a size ladder: Sol is the flagship, Terra balances capability against cost, Luna is the cheap high-volume tier, and gpt-5.6 is OpenAI's alias for Sol. Offered by the direct provider and by OpenRouter, which also carries a -pro variant of each tier. Each gets a real ModelConfig (1.05M context / 128K output). Without one they resolved through get_model_config's prefix fallback to the 272K catch-all, firing auto-compact about three quarters of a window early. Placement is load-bearing. The fallback matches on key.rsplit("-", 1)[0], so the bare gpt-5.6 alias has base "gpt" and would become the catch-all for EVERY unknown gpt id if it preceded gpt-5.5 — handing them a 1.05M window. Over-estimating overflows the context; under-estimating only compacts early, so the catch-all stays on the 272K entry and the alias is registered after it. The tier keys (base "gpt-5.6") go first, which is what lets an unlisted variant like gpt-5.6-sol-pro inherit the right window. default_model stays gpt-5.4 — the default is a cost-sensible tier, not the frontier, matching Anthropic defaulting to sonnet-4-6 while listing fable-5 first. SUBSCRIPTION_MODELS is untouched: which models the ChatGPT backend serves is a wire fact to observe, not assume. OpenAIProvider.get_available_models kept a second verbatim copy of the registry list, the same drift hazard as OpenRouter's; it now reads the registry, leaving the subscription branch its own set. * docs(changelog): GPT-5.6 + OpenRouter catalogue refresh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/modelstep 1 showed a single row —anthropic · 22 models— no matter how many providers were configured.Root cause.
model.optionsinui-tui/src/gatewayClient.tswas a stub. It called theget_settingscontrol, which describes only the provider the session is currently on, and synthesized a hardcoded one-element array from it. The 26-entry registry (PROVIDER_INFO— hand-written provider classes plus the data-driven OpenAI-compatible specs) was never enumerated, and no backend control exposed it. The row on screen was the active provider echoed back at itself, which is why its model count exactly matched anthropic's.The picker's multi-provider UI (
○/●/*marks, inline key entry,^ddisconnect) was ported whole but had only ever been fed that one synthetic row, so three paths behind it had never been reachable — and two of them were broken.Providers (
src/providers/catalog.py, new)provider_catalog()enumerates the real registry: active provider first, then providers that can authenticate, then the rest alphabetically.get_provider_config's literal lookup, because_do_set_providerresolves the same way. A row reported authenticated here but refused there would be a picker offering a provider you cannot select.exclusive_env_vars()splits a provider's key env-vars into owned vs borrowed. The candidate list is a resolution preference, not ownership.Agent server (
src/server/agent_server.py)list_model_providers,save_provider_key,disconnect_provider.set_model's cross-provider refusal now carriesprovider_mismatchso the client can re-drive the switch without pattern-matching error prose, and canonicalizes both sides so an alias-named session (--provider glmvs canonicalzai) isn't mistaken for a cross-provider switch.TUI (
ui-tui/src/gatewayClient.ts,components/modelPicker.tsx)model.optionscalls the new control; falls back to the old synthesis only on a null reply (old backend / timeout). An explicit refusal is surfaced — papering over it would invent a provider row namedclawcodex.model.save_keyandmodel.disconnectwere never wired and fell through to the adapter'sdefault:case returning{}, so key entry always answered "failed to save key" and^dsilently did nothing.set_provider, then re-applies the model; on failure it rolls back and reports which provider the session actually ended on.Safety of the disconnect path
^ddeletes credentials, so its blast radius got the most scrutiny:nvidia-nimlistsDEEPSEEK_API_KEYamong its candidates; treating that list as a delete-set would destroy a credential set for DeepSeek. Ownership follows the primary candidate — the mirror-image trap is that naive membership makes DeepSeek itself permanently undisconnectable, since nvidia-nim borrows its name.nvidia-nimlosing theDEEPSEEK_API_KEYit borrows.siliconflowandsiliconflow-cnboth lead withSILICONFLOW_API_KEY; neither can claim it.delete_secretpopsos.environtoo, so reading it afterwards would report a clean disconnect the shell undoes on next launch. Presence in the environment alone doesn't imply a shell export —set_secretmirrors config writes into the process — so the value is what separates them.save_provider_keyreads the existing block from the global tier only. Reading the merged view and writing globally would launder a repo-committableproviders.*.base_urlinto the user's global config, permanently and for every project, paired with the key they just typed.Test Coverage
New:
tests/server/test_model_provider_picker.py(36 tests) and 13 inui-tui/src/__tests__/gatewayClient.test.ts.Every fix is mutation-tested: the fix is reverted and the intended test confirmed to fail. That includes the shared-env delete-set, the ownership rule in both directions, the raw string compare, the merged-config read, the shell-export predicate, the live-session skip, the fallback gate, and the rollback.
Pre-Landing Review
Reviewed by the
criticsubagent over two rounds: 2 blocking + 5 major, then 2 residual findings, all fixed and independently re-verified against real config files. Final verdict APPROVE.Two fixes came from checking rather than accepting: the first ownership fix made DeepSeek undisconnectable, and the reviewer's proposed shell-detection predicate would have reported "your shell exports this" for every ordinary config key.
Known limitation
When no provider is configured the session fails to start, and the
init_errorguard short-circuits every control — including this one — so the picker can't yet be used to paste a first key. It now reports that reason instead of inventing a provider row. Widening that guard is security-adjacent (it stops/bgspawning subprocesses unsandboxed under a sandbox hard-gate refusal) and is left as follow-up.Also follow-up:
save_provider_keyhas no active-provider guard, and the disconnect guard is per-session —single_sessionis stdio-only, so on the--httptransport one session could disconnect another's provider. Unreachable from the TUI.Design Review
No visual changes beyond picker copy: the key-entry stage now names where the key is written (
providers.<slug>.api_key), the disconnect confirm screen lists the env vars it will clear, and the old fallback text pointed at a nonexistentclawcodex modelcommand.Merge note
Merged
origin/main(fusion models, #771). Two conflicts, both resolved by keeping both sides. Main mademodel.optionsfusion-aware — a fused session reports the base id inmodeland the fusion name separately, and the picker's current marker must follow the fusion name — so that rule is carried into both new paths andlist_model_providersreportsfusionalongsidemodel.fusionModel.test.tsansweredget_settingsdirectly, which the client no longer calls first; it now targets the new control, with added coverage for the same rule on the fallback path.TODOS
No TODO items completed in this PR.
Documentation
Documentation is current — no updates needed. Docs reference
/modelonly in historical release notes, anddocs/agent-server.mdlists control subtypes illustratively (it already omitsset_provider,compact,rewind).Test plan
main🤖 Generated with Claude Code