Skip to content

feat(model-picker): surface inactive provider profiles in /model (#1119 piece 2)#1164

Open
0xfandom wants to merge 1 commit into
Gitlawb:mainfrom
0xfandom:feat/1119-model-picker-cross-profile
Open

feat(model-picker): surface inactive provider profiles in /model (#1119 piece 2)#1164
0xfandom wants to merge 1 commit into
Gitlawb:mainfrom
0xfandom:feat/1119-model-picker-cross-profile

Conversation

@0xfandom
Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1146 (which lands the Codex/GPT suppression piece). This PR addresses the second piece @Vasanthdev2004 split out — making /model a single switcher across configured providerProfiles instead of forcing a /provider round-trip.

  • ModelOption gains an optional switchToProfileId. Existing options leave it unset and behave exactly as today.
  • getInactiveProviderProfileOptions() enumerates every configured profile that isn't the active one and emits a picker entry per model, labelled <model> · <profile.name> so the user can see they're changing providers, not just the model — addresses Vasanthdev's concern about confusing "change model only" with "change provider/base URL/API key".
  • Each entry's value is encoded with __switch_profile__:<id>:<model> so the picker's plain-string value channel stays the source of truth and same-named models under different base URLs (e.g. gpt-4o on multiple OpenAI-compatible endpoints) stay disambiguated. Decoded via the new parseSwitchProfileValue helper.
  • /model's handleSelect detects the prefix, calls setActiveProviderProfile(id) (the same call path /provider uses — applies env, persists active profile, refreshes startup file), then sets mainLoopModel to the bare model string. No new env-management surface.
  • Only surfaces inactive options when CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED is set, so users who haven't opted into the multi-profile workflow don't see the affordance.

With @suenot's 4-profile repro (Kimi active, GLM-5.1 / DeepSeek V4 Flash / MiniMax inactive), the picker now reads:

 1. Default (recommended)
 2. <Sonnet/Opus/Haiku 3P entries>
 3. kimi-k2.6 ✓                                Provider: Kimi
 4. glm-5.1 · GLM-5.1 (Z.AI)                   Switch to GLM-5.1 (Z.AI) (https://api.z.ai/api/anthropic)
 5. deepseek/deepseek-v4-flash:nitro · …       Switch to DeepSeek V4 Flash (OpenRouter) (https://openrouter.ai/api/v1)
 6. MiniMax-M2.5 · MiniMax (SambaNova)         Switch to MiniMax (SambaNova) (https://api.sambanova.ai)

Impact

  • user-facing impact: /model becomes the unified switcher requested in Feature: Show agentModels from settings.json in /model picker instead of hardcoded GPT/Codex models #1119 for multi-profile users. Users with a single profile see no behavior change (no inactive profiles → empty append).
  • developer/maintainer impact: one new field on ModelOption (optional, no downstream consumer requires it), two small pure helpers (parseSwitchProfileValue / encodeSwitchProfileValue), and one new handleSelect branch in the /model command. No change to ModelPicker's onSelect: (model, effort) shape — the profile id is carried through the value channel.

Testing

  • bun run build — green
  • focused tests: bun test src/utils/model/modelOptions.crossProfile.test.ts — 8/8 pass (round-trip encode/parse, : preservation for OpenRouter model strings, active-filter, multi-model explosion, env-applied vs not).
  • combined mock-leak guard: bun test src/utils/model/ src/commands/model/ src/utils/providerProfiles.test.ts — 165/165 pass.
  • bun run smoke — fails on this checkout with Cannot find package '@orama/orama'; reproduces on clean main HEAD 4d2de51 (pre-existing local env issue, unrelated to this change).

Notes

  • provider/model path tested: getModelOptions() 3P path (getAPIProvider() === 'openai', non-subscriber, profile env applied). The 1P path also appends inactiveProfileOptions so a 1P user with extra 3P profiles configured can still see them, mirroring how profileModelOptions is already appended in both branches.
  • The : separator inside the encoded value only splits on the FIRST colon — explicitly tested with deepseek/deepseek-v4-flash:nitro since OpenRouter model strings carry : segments.
  • Out of scope: surfacing agentModels from settings.json (different shape — those are subagent-routing entries with their own base_url/api_key; a separate UX decision about whether to expose subagent providers as first-class /model options).
  • Per the 2026-04-30 lesson on bun:test mock.module surface leaks: every mock.module(path, factory) call in the new test file spreads import * as actual from path and only overrides the symbols the test needs.

Refs #1119 — piece 2 of two.

When a user configures multiple providerProfiles (Kimi + Z.AI + OpenRouter
+ SambaNova in the Gitlawb#1119 repro, but the pattern fits any multi-provider
setup), switching the main session between them currently requires
round-tripping through /provider — /model only shows the active
profile's models.

Make /model the single switcher:

- ModelOption gains an optional `switchToProfileId`. Existing options
  leave it unset and behave exactly as today.
- `getInactiveProviderProfileOptions` enumerates every configured
  profile that isn't the active one and emits a picker entry per model,
  labelled `<model> · <profile.name>` so the user can see the choice
  changes providers, not just models.
- Each option's `value` is encoded with `__switch_profile__:<id>:<model>`
  so the picker's plain-string `value` channel stays the source of truth
  and same-named models under different base URLs (`gpt-4o` on multiple
  OpenAI-compatible endpoints) stay disambiguated.
- /model's handleSelect detects the prefix, calls
  `setActiveProviderProfile` (same path /provider uses — applies env,
  persists active profile, refreshes startup file), then sets
  `mainLoopModel` to the bare model string.

Only surfaces inactive options when `CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED`
is set, so users who haven't opted into the multi-profile workflow at all
don't see the affordance.

Tests cover round-trip encoding (including OpenRouter-style colon-bearing
model strings), the active-filter, the multi-model explosion, and that
`getModelOptions()` 3P path includes the inactive options only when the
profile env is applied. Combined invocation with the rest of
`src/utils/model/` + `src/commands/model/` + `src/utils/providerProfiles.test.ts`
runs clean to guard against mock-leak (per the 2026-04-30 lesson —
spreads `import * as actual` for every `mock.module` factory).

Refs Gitlawb#1119
@kevincodex1 kevincodex1 requested a review from jatmn May 14, 2026 14:13
Copy link
Copy Markdown
Collaborator

@gnanam1990 gnanam1990 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local: bun test src/utils/model/modelOptions.crossProfile.test.ts → 8/0 pass.

Endorsing @kevincodex1. The composite-value design is the right tradeoff — keeps the picker's value channel a plain string, and parseSwitchProfileValue correctly rejects malformed forms (<prefix>::foo, <prefix>:bar:). Gating on CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED so the inactive-profile entries only surface for users who've opted into the multi-profile workflow is a nice touch. setActiveProviderProfile() before setAppState ordering is correct.

Nit (non-blocking): the new logEvent('tengu_model_command_menu', { action: 'switch_profile' }) matches the existing 2 calls in this file, so it's consistent — but the whole tengu_* namespace is fork tech-debt that's worth a separate scrub across the codebase before/after the openlawb rename.

No other red flags. LGTM.

Copy link
Copy Markdown
Collaborator

@jatmn jatmn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [P2] Cross-profile /model switches skip the existing fast-mode cleanup
    src/commands/model/model.tsx:390
    The new parseSwitchProfileValue() branch returns immediately after activating the target profile, so it never reaches the fast-mode reconciliation that the normal /model path runs a few lines below. That means a first-party user can turn fast mode on, switch to an inactive provider profile from the picker, and keep fastMode latched even though the picker explicitly says switching away from the fast model turns it off. At best that leaves stale state until the user toggles /fast manually; at worst it silently re-enables fast mode when they come back to Anthropic later in the session. Please run the same fast-mode cleanup for switch-profile selections and cover that branch with a command-level test.

@Vasanthdev2004
Copy link
Copy Markdown
Collaborator

Blockers

  1. Cross-profile /model switches skip fast-mode cleanup — The new parseSwitchProfileValue() branch returns immediately after activating the target profile, never reaching the fast-mode reconciliation. A user can turn fast mode on, switch to an inactive provider profile, and keep fastMode latched even though the picker says switching away from the fast model turns it off.

Non-Blocking

  • tengu_* namespace is fork tech-debt worth scrubbing.

Looks Good

  • Makes /model a unified switcher across configured profiles
  • Composite-value design keeps picker's value channel a plain string
  • Gating on CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED so inactive entries only surface for multi-profile users
  • 8 tests passing for cross-profile options
  • 165 tests passing combined

Verdict: Changes Requested — fast-mode cleanup needs to run for switch-profile selections.

@gnanam1990
Copy link
Copy Markdown
Collaborator

Re-checked against the code: @jatmn's finding holds, and my earlier approval predates his review and is superseded by it. The new parseSwitchProfileValue() branch in model.tsx returns immediately after activating the target profile, so it never reaches the fast-mode reconciliation the normal /model path runs just below — a first-party user can enable fast mode, switch to an inactive profile from the picker, and leave fastMode latched (contradicting the picker's own "switching away turns it off" text). Running the same fast-mode cleanup on the switch-profile path, with a command-level test covering that branch, would close it out. Thanks for the contribution — looking forward to the update.

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.

5 participants