Skip to content

fix(model): /model listed one provider instead of every configured one - #772

Merged
ericleepi314 merged 6 commits into
mainfrom
worktree-model-provider-picker
Jul 31, 2026
Merged

fix(model): /model listed one provider instead of every configured one#772
ericleepi314 merged 6 commits into
mainfrom
worktree-model-provider-picker

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Summary

/model step 1 showed a single row — anthropic · 22 models — no matter how many providers were configured.

Root cause. model.options in ui-tui/src/gatewayClient.ts was a stub. It called the get_settings control, 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, ^d disconnect) 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.
  • Credential probing deliberately mirrors get_provider_config's literal lookup, 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 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 carries provider_mismatch so the client can re-drive the switch without pattern-matching error prose, and canonicalizes both sides so an alias-named session (--provider glm vs canonical zai) isn't mistaken for a cross-provider switch.

TUI (ui-tui/src/gatewayClient.ts, components/modelPicker.tsx)

  • model.options calls 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 named clawcodex.
  • model.save_key and model.disconnect were never wired and fell through to the adapter's default: case returning {}, so key entry always answered "failed to save key" and ^d silently did nothing.
  • Cross-provider selection routes through 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

^d deletes credentials, so its blast radius got the most scrutiny:

  • Only env-var names a provider owns. nvidia-nim lists DEEPSEEK_API_KEY among 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.
  • Never a name 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.
  • Contested names are reported, not deleted. siliconflow and siliconflow-cn both lead with SILICONFLOW_API_KEY; neither can claim it.
  • 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. Presence in the environment alone doesn't imply a shell export — set_secret mirrors config writes into the process — so the value is what separates them.
  • save_provider_key reads the existing block from the global tier only. Reading the merged view and writing globally would launder a repo-committable providers.*.base_url into 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 in ui-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 critic subagent 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_error guard 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 /bg spawning subprocesses unsandboxed under a sandbox hard-gate refusal) and is left as follow-up.

Also follow-up: save_provider_key has no active-provider guard, and the disconnect guard is per-session — single_session is stdio-only, so on the --http transport 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 nonexistent clawcodex model command.

Merge note

Merged origin/main (fusion models, #771). Two conflicts, both resolved by keeping both sides. Main made model.options fusion-aware — a fused session reports the base id in model and the fusion name separately, and the picker's current marker must follow the fusion name — so that rule is carried into both new paths and list_model_providers reports fusion alongside model. fusionModel.test.ts answered get_settings directly, 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 /model only in historical release notes, and docs/agent-server.md lists control subtypes illustratively (it already omits set_provider, compact, rewind).

Test plan

  • Python suite: 9266 passed, 3 skipped, 0 failed
  • ui-tui suite: 1605 passed, 8 failed — diffed identical to the pre-existing baseline on main
  • Live end-to-end against the real agent-server over stdio: 26 providers returned, ordered and marked correctly; active-provider disconnect refused

🤖 Generated with Claude Code

ericleepi314 and others added 6 commits July 30, 2026 23:54
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>
@github-actions

Copy link
Copy Markdown

Test Results

    1 files      1 suites   7m 46s ⏱️
9 269 tests 9 263 ✅ 6 💤 0 ❌
9 515 runs  9 509 ✅ 6 💤 0 ❌

Results for commit 74f50ed.

@ericleepi314
ericleepi314 merged commit b8ad243 into main Jul 31, 2026
3 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant