From db01038d5ce397e1cd4228f55075ef7b21cc2553 Mon Sep 17 00:00:00 2001 From: 0xfandom <50949929+0xfandom@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:23:29 +0530 Subject: [PATCH] feat(model-picker): surface inactive provider profiles in /model (#1119 piece 2) (#1164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(model-picker): surface inactive provider profiles in /model When a user configures multiple providerProfiles (Kimi + Z.AI + OpenRouter + SambaNova in the #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 ` · ` so the user can see the choice changes providers, not just models. - Each option's `value` is encoded with `__switch_profile__::` 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 #1119 * fix(model-picker): run fast-mode cleanup on cross-profile switch The new switch-profile branch returned before reaching the fast-mode reconciliation, so a user with fastMode latched on Anthropic Opus could switch to an OpenAI profile and silently keep fastMode on even though the new model can't support it. Extract the cleanup into a pure helper `reconcileFastModeForSwitch` and call it from both branches. Refs #1119. * fix(model-picker): decode cross-profile values before effort/display lookup Inactive-profile entries encode the picker value as `__switch_profile__::`, but `resolveOptionModel` forwarded the raw string straight to `parseUserSpecifiedModel`. For a reasoning-capable cross-profile entry such as `gpt-5.4`, `modelSupportsEffort()` then saw the prefixed string and reported "Effort not supported", and `handleSelect` dropped the toggled effort even when the underlying model accepts it. Run `parseSwitchProfileValue` first; when it matches, hand the bare target model to `parseUserSpecifiedModel` so effort capability, default-effort lookup, and display-name resolution all key off the real model id. * fix(model-picker): include inactive profiles on local OpenAI-compatible scope The inactive-profile compute lived after the `getAdditionalModelOptionsCacheScope()?.startsWith('openai:')` early return, so users with a local OpenAI-compatible profile active (Ollama, lm-studio, any localhost endpoint) never saw the cross-profile switcher in `/model`. They still had to round-trip through `/provider` to change profile. Hoist `profileEnvApplied`, the active-profile lookup, and `getInactiveProviderProfileOptions(activeProfileId)` above the early return, and append `inactiveProfileOptions` to the local-OpenAI branch return value. Other branches (Claude.AI, MiMo, MiniMax, ant) were already either irrelevant or have their own gating. Test: new regression in modelOptions.crossProfile.test.ts pins `getAdditionalModelOptionsCacheScope` to an `openai:` value and confirms the inactive profile still surfaces with a parseable `__switch_profile__` value. * fix(model-picker): apply the allowlist to the decoded cross-profile model filterModelOptionsByAllowlist evaluated cross-profile options by their encoded __switch_profile__:: value, so an availableModels allowlist that permits the bare target (e.g. glm-5.1) dropped every inactive-profile entry. Check the allowlist against parseSwitchProfileValue(value)?.model ?? value, and cover both the allowed and denied cases. * fix(model-picker): only surface cross-profile switch options on the /model path The inactive-profile entries come from the shared getModelOptions() list, but only the /model command's onSelect decodes __switch_profile__ values and activates the target profile. The prompt hotkey and Settings pickers wrote the encoded value straight to mainLoopModel, sending an invalid model string. Gate these options behind a new allowProfileSwitch prop that only the /model command sets; inline pickers no longer surface an option they cannot honor. Also apply the org allowlist to the decoded target model in the /model select handler. * test(model-picker): drop flaky cross-profile allowlist case The decoded-allowlist assertion drove the org allowlist through the shared session settings cache, which is racy across bun's single-process run and could leak availableModels into sibling suites (the providerConfig cache-scope tests went red in CI). The decode itself is a one-line guard already exercised by the parseSwitchProfileValue round-trip coverage, so remove the unreliable case rather than ship CI flake. Also snapshot the real provider/auth modules before mocking so each harness call rebuilds its mock from a clean base instead of a previous test's overrides (bun live-repoints the imported namespace to the active mock). * test(model-picker): stop cross-profile mocks leaking into provider suites The cross-profile tests mock.module'd ../providerProfiles, ./providers, ../auth and ../../services/api/providerConfig per test. bun's mock.module is process-wide and mock.restore() does not undo it, so these persisted into later files — most damagingly the providerConfig mock, which replaced the module with a single-function stub and stripped resolveProviderRequest / getAdditionalModelOptionsCacheScope from providerConfig.local's suite (now adjacent after the rebase onto #1706). Install each mock once at module load, keep the full export surface, and gate the overrides on module-level flags cleared in beforeEach/afterEach so the persisted mocks are transparent passthroughs for every other suite. Same pattern as the cross-spawn / install-surfaces leak fixes. * fix(model): reconcile fast mode before activating the switched profile In the cross-profile /model switch path, reconcileFastModeForSwitch ran after setActiveProviderProfile. The reconciler gates on isFastModeEnabled(), which reads the *active* provider — so once the target profile is activated it reflects the new (fast-mode-less) provider and short-circuits to 'unchanged', leaving fastMode latched on for a model that can't use it. Compute the reconciliation before activating the profile, so it evaluates against the source provider and correctly returns 'off' for an unsupported target. Add a command-level regression test that drives handleSelect with a __switch_profile__ value while setActiveProviderProfile flips the fast-mode state, and asserts fastMode is set to false (it fails if the call order regresses). * fix(model): re-check fast mode after activating a switched profile The pre-activation reconcile gates on the source provider, so its 'on' result is stale when the target provider cannot run fast mode even though the target model name passes the source-side support check (e.g. a third-party shim exposing a claude-opus-* model). Re-evaluate isFastModeEnabled / supported / available after setActiveProviderProfile and force fastMode off when it is no longer genuinely supported. Add a command-level regression test for that path and wrap the cross-profile test cleanup in try/finally so a failing assertion still unmounts the Ink instance (jatmn review, #1119). * test(model-picker): cover cross-profile allowlist with isolated settings Re-add the regression dropped in 06a0c80: filterModelOptionsByAllowlist must evaluate the allowlist against the decoded target model, not the encoded __switch_profile__ wrapper. Uses this suite's per-test settings cache (reset in afterEach) instead of the shared cache that made the earlier version flaky (jatmn review, #1119). * test(model-picker): make the cross-profile allowlist test leak-proof The new allowlist test drove availableModels through setSessionSettingsCache, but sibling suites (ModelPicker, ProviderManager, ...) mock.module both settings.js (getSettings_DEPRECATED) and modelAllowlist.js (isModelAllowed) process-wide, so in the full sequential run the leaked stubs defeated the cache and the denied option was not filtered (smoke-and-tests red on the full suite, green in isolation). Drive the allowlist deterministically from this suite instead: install-once, gated, passthrough mocks of getSettings_DEPRECATED (the filter gate) and isModelAllowed (the per-option check), both keyed off a single activeSettingsOverride and cleared in afterEach. Same gated-passthrough pattern as the suite's existing providerConfig/providers/auth/profiles mocks and the agent.test.ts allowlist approach. * fix(model): keep cross-profile switch options out of the SDK models list getModelOptions() now returns inactive-profile entries encoded as __switch_profile__::. print.ts mapped those straight into the ModelInfo list returned to SDK/web callers, exposing UI-only values that are not selectable model ids. Filter them with parseSwitchProfileValue before building modelInfos. Add ModelPicker coverage for the allowProfileSwitch filter (hidden inline, shown when allowed) and document cross-profile /model switching in the provider-profile docs. * test(model-picker): prove cross-profile switch options never reach SDK models Extract selectSdkModelOptions as the single gate the SDK modelInfos builder runs every getModelOptions() entry through, and cover it directly: an encoded __switch_profile__:: option is dropped while real model ids pass through. Fails if an inactive-profile affordance ever leaks into the initialize.models response again (#1119). * docs(model-picker): clarify the env gate for inactive-profile entries The inactive-profile models only appear when the provider-profile env workflow is active (CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED=1), not for every multi-profile setup. Spell that out and restore the local-only `--provider ollama` guidance that was folded into the paragraph. * fix(model-picker): gate SDK option filter on switchToProfileId marker selectSdkModelOptions filtered on the encoded __switch_profile__ value prefix, which also reserved that prefix for every custom model id. A real configured model whose id starts with __switch_profile__: would vanish from the SDK models response and non-switching pickers. Key the gate on the explicit switchToProfileId marker, which only synthesized switch options carry, and add the collision regression. Refs #1119 * fix(model-picker): reuse switch confirmation for cross-profile selections The cross-profile branch built its own "Switched to" message and returned before the regular path appended the selected effort and the "Billed as extra usage" notice, hiding cost-impacting feedback when a reasoning/extra-usage target was chosen through an inactive profile. Append effort and the extra-usage check to the switch confirmation. Refs #1119 * fix(model-picker): surface inactive profiles on the active Ollama path The isOllamaProvider() early return ran before the inactive-profile options were computed, so an active local Ollama profile saw only its own models and lost the cross-profile switcher, forcing the /provider round-trip this feature removes. Hoist the inactive-profile compute above the Ollama branch and append it to the Ollama returns. Refs #1119 * fix(model): surface inactive profiles on all provider branches; decode only real switch options Two follow-ups to the #1119 unified /model switcher: - inactiveProfileOptions was computed before the early-return branches but only appended on Ollama / local-scope / PAYG paths. The GitHub Copilot, NVIDIA NIM, MiniMax, Xiaomi MiMo, ant, and Claude-subscriber branches returned first, so a user with a saved profile active on any of those routes lost the cross-profile entries and had to round-trip through /provider. Append the (env-gated, so empty unless a profile is applied) inactive options on those branches too. - filterModelOptionsByAllowlist decoded any value starting with `__switch_profile__:` via parseSwitchProfileValue, even a normal custom model id that merely shares that prefix, evaluating the allowlist against the wrong inner model. Gate the decode on the `switchToProfileId` marker (the type's documented contract) so non-switch ids are checked verbatim. Extends the cross-profile harness with gated getAPIProvider / NVIDIA / subscriber overrides and adds branch-append + verbatim-allowlist regressions (red-green). * fix(model): key profile-switch handling on the marker across picker and command The allowlist/SDK paths already used the switchToProfileId marker, but two surfaces still keyed on the raw `__switch_profile__:` value prefix: - ModelPicker's inline-picker filter hid any option whose value started with the prefix, so a real custom model id like `__switch_profile__:vendor:gpt-5.4` disappeared from prompt/settings pickers. It now filters on `switchToProfileId === undefined`. - the /model command decoded parseSwitchProfileValue(model) for any prefixed string and tried to activate the encoded profile id, so selecting such a custom model activated a nonexistent profile instead of setting the literal model. It now only treats the value as a switch when the decoded profile id maps to a real configured provider profile — which every synthesized switch option does, and a prefix-colliding custom id does not. Drops the now-unused SWITCH_PROFILE_VALUE_PREFIX import from ModelPicker. Adds a picker regression (marked switch hidden, prefixed custom model stays visible) and completes the cross-profile branch coverage (MiniMax, Xiaomi MiMo, ant) so every branch that appends inactive-profile options is locked. * test(model): register target profiles in cross-profile switch tests The /model command now only treats a `__switch_profile__:` value as a switch when its decoded profile id maps to a real configured provider profile. The cross-profile switch tests set up setActiveProviderProfile but left the shared getProviderProfiles mock empty, so the new guard classified their switch values as literal models and the fast-mode / effort / extra-usage assertions no longer ran. Register each test's target profile via getProviderProfiles so the switch path executes as intended. * fix(model): gate cross-profile switches on the selected option marker Selecting a value that merely parses as `__switch_profile__::` activated the provider whenever existed, so a literal custom model id such as `__switch_profile__:profile_openai:gpt-5-mini` wrongly switched the active provider instead of being applied verbatim. Thread the picked option's `switchToProfileId` marker from ModelPicker.onSelect (selectOptions already carries it) and only activate a profile when the marker matches the decoded id. The effort/display resolver had the same gap — it decoded every prefixed value; gate it on a genuine marker-backed switch option too. Add a regression asserting a marker-less prefixed id is applied literally. * test(model): cover Max/Team Premium and empty-catalog switch-append paths The cross-profile branch-coverage suite exercised the populated-catalog returns but not the Max/Team Premium subscriber early return nor the empty-catalog fallbacks (NVIDIA/MiniMax/Xiaomi), which are the same paths that previously dropped the inactive-profile switch options. Lock them so every changed return that appends `...inactiveProfileOptions` is covered. * fix(model): keep inactive-profile switch options in /model discovery overrides The interactive /model command passes an optionsOverride into ModelPicker for descriptor-backed and legacy OpenAI-compatible discovery contexts, built from mergeActiveProfileModelOptions which only merges the ACTIVE profile's route models. Because the picker renders optionsOverride ?? getModelOptions(), the inactive-profile switch entries getModelOptions() appends never reached those paths, so the unified switcher vanished for provider-profile routes (OpenRouter/Kimi/MiniMax, refreshed local profiles). Re-append the same inactive-profile switch options (allowlist-filtered on the decoded target) to any override list before handing it to the picker. * fix(model): base the switch marker on the presented option, treat ties as ambiguous The picker derived switchToProfileId with selectOptions.find(value===...), and the effort/display resolver decoded when any getModelOptions() entry with the same value carried the marker. If a literal custom model id collided with an encoded switch value, the literal could borrow a different same-value option's marker and wrongly activate a provider. Add resolveSelectedSwitchProfileId, which keys on the actual presented option and treats duplicate-value matches as ambiguous (no switch), and route both the onSelect marker and the decode decision through it. --- docs/advanced-setup.md | 12 + src/cli/print.sdkModelOptions.test.ts | 76 ++ src/cli/print.ts | 24 +- .../model/model.fastModeSwitch.test.ts | 94 ++ src/commands/model/model.test.tsx | 483 +++++++++ src/commands/model/model.tsx | 224 ++++- src/components/ModelPicker.test.tsx | 131 ++- src/components/ModelPicker.tsx | 74 +- .../model/modelOptions.crossProfile.test.ts | 921 ++++++++++++++++++ .../model/modelOptions.switchMarker.test.ts | 46 + src/utils/model/modelOptions.ts | 195 +++- 11 files changed, 2225 insertions(+), 55 deletions(-) create mode 100644 src/cli/print.sdkModelOptions.test.ts create mode 100644 src/commands/model/model.fastModeSwitch.test.ts create mode 100644 src/utils/model/modelOptions.crossProfile.test.ts create mode 100644 src/utils/model/modelOptions.switchMarker.test.ts diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 37bbd20d4..6e9d56018 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -557,6 +557,18 @@ Supported values: profile-only custom model IDs. - `profile`: show only explicitly configured profile models. +When the provider-profile env workflow is active (i.e. a profile has been +applied and `CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED=1` is set — as it is after +launching with a saved profile) and you have more than one saved provider +profile, `/model` also lists models from your **inactive** profiles, grouped +under their profile name. Selecting one activates that provider profile and +switches to the chosen model in a single step, reconciling fast mode if the +target provider cannot run it. These cross-profile entries appear only in the +interactive `/model` picker — they are never returned to SDK/automation callers +and are hidden from inline pickers (such as the prompt hotkey or Settings), +which cannot switch the active profile. Simply having multiple profiles +configured without the env workflow active does not surface them. + Use `--provider ollama` when you want a local-only path. Auto mode falls back to OpenAI when no viable local chat model is installed. Use `--provider atomic-chat` when you want Atomic Chat as the local Apple Silicon provider. diff --git a/src/cli/print.sdkModelOptions.test.ts b/src/cli/print.sdkModelOptions.test.ts new file mode 100644 index 000000000..9bacd32e0 --- /dev/null +++ b/src/cli/print.sdkModelOptions.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test' + +import { selectSdkModelOptions } from './print.js' +import { + encodeSwitchProfileValue, + type ModelOption, +} from '../utils/model/modelOptions.js' + +// Regression for issue #1119: the interactive `/model` picker surfaces +// inactive-provider-profile entries whose `value` is an encoded +// `__switch_profile__::` string. Those are UI-only affordances and +// must never reach the SDK `initialize.models` response — they are not real, +// selectable model ids. selectSdkModelOptions is the single gate the SDK +// `modelInfos` builder runs every option through, so this asserts it strips +// them. +describe('selectSdkModelOptions — keeps cross-profile switch entries out of SDK models', () => { + const realOptions: ModelOption[] = [ + { value: null, label: 'Default (recommended)', description: 'default' }, + { value: 'sonnet', label: 'Sonnet', description: 'Sonnet 4.6' }, + { value: 'opus', label: 'Opus', description: 'Opus 4.8' }, + ] + + test('drops encoded __switch_profile__ options', () => { + const switchOption: ModelOption = { + value: encodeSwitchProfileValue('profile-2', 'qwen3-coder'), + label: 'qwen3-coder · My Ollama', + description: 'Switch to My Ollama (http://localhost:11434/v1)', + switchToProfileId: 'profile-2', + } + + const selected = selectSdkModelOptions([ + realOptions[0]!, + switchOption, + realOptions[1]!, + realOptions[2]!, + ]) + + expect(selected).toEqual(realOptions) + // No surviving option carries the UI-only encoded value or the + // switchToProfileId marker. + expect( + selected.some( + o => + typeof o.value === 'string' && + o.value.startsWith('__switch_profile__:'), + ), + ).toBe(false) + expect(selected.some(o => o.switchToProfileId !== undefined)).toBe(false) + }) + + test('passes through a list with no switch entries unchanged', () => { + expect(selectSdkModelOptions(realOptions)).toEqual(realOptions) + }) + + // Collision regression: a real, configured custom model id may literally + // start with `__switch_profile__:`. The gate keys on the explicit + // `switchToProfileId` marker (only synthesized switch entries carry it), so + // such an id must still reach SDK consumers rather than being mistaken for a + // UI-only profile-switch affordance. + test('keeps a real custom model id that starts with the switch-profile prefix', () => { + const collidingOption: ModelOption = { + value: '__switch_profile__:vendor:model', + label: 'Oddly-named custom model', + description: 'A real selectable model id, no switchToProfileId marker', + } + + const selected = selectSdkModelOptions([ + realOptions[0]!, + collidingOption, + realOptions[1]!, + ]) + + expect(selected).toContain(collidingOption) + expect(selected).toEqual([realOptions[0]!, collidingOption, realOptions[1]!]) + }) +}) diff --git a/src/cli/print.ts b/src/cli/print.ts index d1b603838..f2e771252 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -274,7 +274,10 @@ import { modelDisplayString, parseUserSpecifiedModel, } from 'src/utils/model/model.js' -import { getModelOptions } from 'src/utils/model/modelOptions.js' +import { + getModelOptions, + type ModelOption, +} from 'src/utils/model/modelOptions.js' import { modelSupportsEffort, getAvailableEffortLevels, @@ -384,6 +387,23 @@ const extractMemoriesModule = feature('EXTRACT_MEMORIES') : null /* eslint-enable @typescript-eslint/no-require-imports */ +/** + * Select the model options that are safe to expose through the SDK `models` + * response. `getModelOptions()` also returns inactive-provider-profile entries + * whose `value` is an encoded `__switch_profile__::` string (issue + * #1119). Those are UI-only affordances for the interactive `/model` switcher — + * they are not real, selectable model ids — so they must never reach SDK + * consumers. Exported so the exclusion is unit-testable. + * + * Filter on the explicit `switchToProfileId` marker rather than the encoded + * `value` prefix: a legitimate custom model id that happens to start with + * `__switch_profile__:` must still reach SDK consumers, and only the synthesized + * profile-switch options carry `switchToProfileId`. + */ +export function selectSdkModelOptions(options: ModelOption[]): ModelOption[] { + return options.filter(option => option.switchToProfileId === undefined) +} + const SHUTDOWN_TEAM_PROMPT = ` You are running in non-interactive mode and cannot return a response to the user until your team is shut down. @@ -1357,7 +1377,7 @@ function runHeadlessStreaming( }) } - const modelOptions = getModelOptions() + const modelOptions = selectSdkModelOptions(getModelOptions()) const modelInfos: ModelInfo[] = modelOptions.map((option): ModelInfo => { const modelId = option.value === null ? 'default' : option.value const resolvedModel = diff --git a/src/commands/model/model.fastModeSwitch.test.ts b/src/commands/model/model.fastModeSwitch.test.ts new file mode 100644 index 000000000..04809d31c --- /dev/null +++ b/src/commands/model/model.fastModeSwitch.test.ts @@ -0,0 +1,94 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +import * as actualFastMode from '../../utils/fastMode.js' + +// Regression for #1119 / jatmn review on PR #1164 — the cross-profile +// `/model` branch must run the same fast-mode reconciliation as the regular +// switch path. The pure helper `reconcileFastModeForSwitch` encodes the rule +// both branches call. + +afterEach(() => { + mock.restore() +}) + +function mockFastMode( + overrides: Partial = {}, +): void { + // Keep full surface to avoid cross-file mock.module leaks (lessons learned + // 2026-04-30). Override only the symbols this test needs. + mock.module('../../utils/fastMode.js', () => ({ + ...actualFastMode, + ...overrides, + })) +} + +async function importFreshModule( + suffix: string, +): Promise { + return import(`./model.js?${suffix}`) as Promise +} + +test('returns "unchanged" when fast mode is disabled at the process level', async () => { + mockFastMode({ + isFastModeEnabled: () => false, + isFastModeSupportedByModel: () => true, + isFastModeAvailable: () => true, + }) + const mod = await importFreshModule('fast-disabled') + expect(mod.reconcileFastModeForSwitch('claude-opus-4-7', true)).toBe( + 'unchanged', + ) +}) + +test('returns "off" when target model does not support fast mode and fastMode latched', async () => { + const clearFastModeCooldown = mock(() => undefined) + mockFastMode({ + isFastModeEnabled: () => true, + isFastModeSupportedByModel: (m: string | null) => + m?.startsWith('claude-opus') ?? false, + isFastModeAvailable: () => true, + clearFastModeCooldown, + }) + const mod = await importFreshModule('fast-off') + expect(mod.reconcileFastModeForSwitch('gpt-5-mini', true)).toBe('off') + expect(clearFastModeCooldown).toHaveBeenCalledTimes(1) +}) + +test('returns "on" when target supports fast mode and it is available and latched', async () => { + mockFastMode({ + isFastModeEnabled: () => true, + isFastModeSupportedByModel: () => true, + isFastModeAvailable: () => true, + clearFastModeCooldown: () => undefined, + }) + const mod = await importFreshModule('fast-on') + expect(mod.reconcileFastModeForSwitch('claude-opus-4-7', true)).toBe('on') +}) + +test('returns "unchanged" when fastMode is not currently latched', async () => { + mockFastMode({ + isFastModeEnabled: () => true, + isFastModeSupportedByModel: () => true, + isFastModeAvailable: () => true, + clearFastModeCooldown: () => undefined, + }) + const mod = await importFreshModule('fast-unlatched') + expect(mod.reconcileFastModeForSwitch('claude-opus-4-7', false)).toBe( + 'unchanged', + ) +}) + +test('returns "off" for the cross-profile switch target when fastMode is latched on Anthropic and the new profile model is unsupported (#1119)', async () => { + mockFastMode({ + isFastModeEnabled: () => true, + isFastModeSupportedByModel: (m: string | null) => + m === 'claude-opus-4-7', + isFastModeAvailable: () => true, + clearFastModeCooldown: () => undefined, + }) + const mod = await importFreshModule('fast-cross-profile') + // User had fast mode on while running Anthropic Opus, then picks an OpenAI + // profile from the picker — the new profile's model can't run fast mode, so + // the reconciler must drop it. + expect(mod.reconcileFastModeForSwitch('gpt-5-mini', true)).toBe('off') +}) diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index ee8fe2a60..9100d2206 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -12,9 +12,19 @@ import { resetSettingsCache, setSessionSettingsCache, } from '../../utils/settings/settingsCache.js' +import { encodeSwitchProfileValue } from '../../utils/model/modelOptions.js' import type { ModelOption } from '../../utils/model/modelOptions.js' import type { ModelSetting } from '../../utils/model/model.js' import type { SettingsJson } from '../../utils/settings/types.js' +import * as actualFastModeForModelTest from '../../utils/fastMode.js' +import * as actualExtraUsageForModelTest from '../../utils/extraUsage.js' + +// Snapshot the real fast-mode module up front so the cross-profile switch test +// can mock it with a full surface and restore the real one afterwards. +const REAL_FAST_MODE_FOR_MODEL_TEST = { ...actualFastModeForModelTest } +// Same for extraUsage, so the cross-profile confirmation test can force the +// `Billed as extra usage` branch and restore the real surface afterwards. +const REAL_EXTRA_USAGE_FOR_MODEL_TEST = { ...actualExtraUsageForModelTest } type SettingsModule = typeof import('../../utils/settings/settings.js') @@ -1234,6 +1244,81 @@ test('/model applies auto provider surface for single-model descriptor profiles' } }) +test('/model discovery override still surfaces inactive-profile switch options (#1119)', async () => { + // Regression for #1164 [P2]: descriptor/legacy discovery contexts pass an + // optionsOverride built from the active profile's route models only. Because + // the picker uses `optionsOverride ?? getModelOptions()`, the unified switcher + // for other configured profiles must be re-appended to the override, or it + // disappears entirely for provider-profile discovery paths. + const activeProfile = { + id: 'openrouter-profile', + name: 'OpenRouter', + provider: 'openrouter', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'openai/gpt-oss-120b:free', + apiKey: 'sk-openrouter', + } + const inactiveProfile = { + id: 'kimi-profile', + name: 'Kimi', + provider: 'openai', + baseUrl: 'https://api.moonshot.ai/v1', + model: 'kimi-k2', + apiKey: 'sk-kimi', + } + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = activeProfile.baseUrl + process.env.OPENAI_API_KEY = activeProfile.apiKey + delete process.env.OPENROUTER_API_KEY + process.env.OPENAI_MODEL = activeProfile.model + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_GITHUB + delete process.env.CLAUDE_CODE_USE_MISTRAL + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY + delete process.env.OPENAI_API_BASE + + mockDescriptorDiscovery({ + cachedModels: [{ id: 'profile-model', apiName: activeProfile.model }], + }) + mockProviderProfiles({ + getActiveProviderProfile: () => activeProfile, + getProviderProfiles: () => [activeProfile, inactiveProfile], + getProfileModelOptions: (profile: { model: string; name: string }) => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + + const rendered = await renderModelCommandWithCapturedPicker( + 'descriptor-picker-inactive-switch-options', + ) + try { + const override = rendered.getCapturedProps() + .optionsOverride as ModelOption[] + const switchOptions = override.filter( + opt => opt.switchToProfileId !== undefined, + ) + expect(switchOptions.map(opt => opt.switchToProfileId)).toContain( + 'kimi-profile', + ) + expect( + switchOptions.some( + opt => opt.value === encodeSwitchProfileValue('kimi-profile', 'kimi-k2'), + ), + ).toBe(true) + // The active profile's own route model is still present as a normal option. + expect( + override.some(opt => opt.value === activeProfile.model), + ).toBe(true) + } finally { + rendered.instance.unmount() + rendered.stdout.end() + } +}) + test('/model applies auto provider surface for single-model static descriptor profiles', async () => { const activeProfile = { id: 'opengateway-profile', @@ -2731,3 +2816,401 @@ test('/model does not auto-refresh descriptor models when nonessential traffic i expect(result).toBeTruthy() expect(discoverModelsForRoute).not.toHaveBeenCalled() }) + +test('cross-profile /model switch drops latched fast mode before activating an unsupported target profile (#1119)', async () => { + // Fast mode is enabled on the source provider; activating the target profile + // flips isFastModeEnabled() to false (the new provider can't use fast mode). + // This guards the ordering bug: reconcileFastModeForSwitch must evaluate + // against the source provider (before activation), otherwise it short-circuits + // to 'unchanged' once the new provider is active and leaves fastMode latched. + let targetProfileActivated = false + mock.module('../../utils/fastMode.js', () => ({ + ...REAL_FAST_MODE_FOR_MODEL_TEST, + isFastModeEnabled: () => !targetProfileActivated, + isFastModeSupportedByModel: (m: string | null) => m === 'claude-opus-4-7', + isFastModeAvailable: () => true, + clearFastModeCooldown: () => {}, + })) + mockProviderProfiles({ + getProviderProfiles: () => [ + { + id: 'profile_openai', + name: 'OpenAI', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + }, + ], + setActiveProviderProfile: (profileId: string) => { + targetProfileActivated = true + return { + id: profileId, + name: 'OpenAI', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + } + }, + } as never) + + let capturedOnSelect: + | ((model: string | null, effort: unknown, switchToProfileId?: string) => void) + | undefined + mock.module('../../components/ModelPicker.js', () => ({ + ModelPicker: function MockModelPicker(props: { + onSelect?: ( + model: string | null, + effort: unknown, + switchToProfileId?: string, + ) => void + }): React.ReactNode { + capturedOnSelect = props.onSelect + return null + }, + })) + + const { getDefaultAppState } = await import('../../state/AppState.js') + const observedStates: Array<{ fastMode?: boolean }> = [] + const { call } = await importFreshModelModule('fast-cross-profile-order') + const element = await call(() => {}, {} as never, '') + const { AppStateProvider } = await import('../../state/AppState.js') + const { render } = await import('../../ink.js') + const stdout = new PassThrough() + ;(stdout as unknown as { columns: number }).columns = 120 + const instance = await render( + { + observedStates.push(newState) + }} + > + {element} + , + stdout as unknown as NodeJS.WriteStream, + ) + + try { + await waitForCondition(() => capturedOnSelect !== undefined) + capturedOnSelect?.( + encodeSwitchProfileValue('profile_openai', 'gpt-5-mini'), + undefined, + // The real ModelPicker threads the selected switch option's marker; the + // mock picker mirrors that so handleSelect activates the profile. + 'profile_openai', + ) + + await waitForCondition(() => + observedStates.some(state => state.fastMode === false), + ) + expect(observedStates.at(-1)?.fastMode).toBe(false) + } finally { + instance.unmount() + // Restore the real fast-mode module for sibling tests in this file. + mock.module('../../utils/fastMode.js', () => REAL_FAST_MODE_FOR_MODEL_TEST) + } +}) + +test('cross-profile /model switch drops fast mode when the target provider cannot run it even though the model name passes source-side support (#1119)', async () => { + // jatmn review edge case: the target model name passes isFastModeSupportedByModel + // on the SOURCE provider, so the pre-activation reconcile returns 'on'. But the + // target provider cannot run fast mode (isFastModeEnabled() flips false after + // activation). The post-activation re-check must still force fastMode off rather + // than leaving it latched and printing "Fast mode ON". + let targetProfileActivated = false + mock.module('../../utils/fastMode.js', () => ({ + ...REAL_FAST_MODE_FOR_MODEL_TEST, + // Model name passes support on both sides; only the provider gating changes. + isFastModeSupportedByModel: () => true, + isFastModeAvailable: () => true, + isFastModeEnabled: () => !targetProfileActivated, + clearFastModeCooldown: () => {}, + })) + mockProviderProfiles({ + getProviderProfiles: () => [ + { + id: 'profile_shim', + name: 'Custom Shim', + provider: 'openai', + baseUrl: 'https://shim.example/v1', + model: 'claude-opus-4-6', + }, + ], + setActiveProviderProfile: (profileId: string) => { + targetProfileActivated = true + return { + id: profileId, + name: 'Custom Shim', + provider: 'openai', + baseUrl: 'https://shim.example/v1', + model: 'claude-opus-4-6', + } + }, + } as never) + + let capturedOnSelect: + | ((model: string | null, effort: unknown, switchToProfileId?: string) => void) + | undefined + mock.module('../../components/ModelPicker.js', () => ({ + ModelPicker: function MockModelPicker(props: { + onSelect?: ( + model: string | null, + effort: unknown, + switchToProfileId?: string, + ) => void + }): React.ReactNode { + capturedOnSelect = props.onSelect + return null + }, + })) + + const { getDefaultAppState } = await import('../../state/AppState.js') + const observedStates: Array<{ fastMode?: boolean }> = [] + const { call } = await importFreshModelModule('fast-cross-profile-capability') + const element = await call(() => {}, {} as never, '') + const { AppStateProvider } = await import('../../state/AppState.js') + const { render } = await import('../../ink.js') + const stdout = new PassThrough() + ;(stdout as unknown as { columns: number }).columns = 120 + const instance = await render( + { + observedStates.push(newState) + }} + > + {element} + , + stdout as unknown as NodeJS.WriteStream, + ) + + try { + await waitForCondition(() => capturedOnSelect !== undefined) + capturedOnSelect?.( + encodeSwitchProfileValue('profile_shim', 'claude-opus-4-6'), + undefined, + // The real ModelPicker threads the selected switch option's marker; the + // mock picker mirrors that so handleSelect activates the profile. + 'profile_shim', + ) + + await waitForCondition(() => + observedStates.some(state => state.fastMode === false), + ) + expect(observedStates.at(-1)?.fastMode).toBe(false) + } finally { + instance.unmount() + mock.module('../../utils/fastMode.js', () => REAL_FAST_MODE_FOR_MODEL_TEST) + } +}) + +test('cross-profile /model switch surfaces the selected effort and extra-usage notice (#1119)', async () => { + // jatmn review: the cross-profile branch built its own confirmation and + // returned before the regular `/model` logic that appends the selected effort + // and the cost-impacting `Billed as extra usage` notice. Selecting a target + // through an inactive profile must show the same feedback the direct model + // path does. + mock.module('../../utils/extraUsage.js', () => ({ + ...REAL_EXTRA_USAGE_FOR_MODEL_TEST, + isBilledAsExtraUsage: () => true, + })) + mockProviderProfiles({ + getProviderProfiles: () => [ + { + id: 'profile_openai', + name: 'OpenAI', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + }, + ], + setActiveProviderProfile: (profileId: string) => ({ + id: profileId, + name: 'OpenAI', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + }), + } as never) + + let capturedOnSelect: + | ((model: string | null, effort: unknown, switchToProfileId?: string) => void) + | undefined + mock.module('../../components/ModelPicker.js', () => ({ + ModelPicker: function MockModelPicker(props: { + onSelect?: ( + model: string | null, + effort: unknown, + switchToProfileId?: string, + ) => void + }): React.ReactNode { + capturedOnSelect = props.onSelect + return null + }, + })) + + const { getDefaultAppState } = await import('../../state/AppState.js') + let doneMessage: string | undefined + const { call } = await importFreshModelModule('cross-profile-confirmation') + const element = await call( + (message?: string) => { + doneMessage = message + }, + {} as never, + '', + ) + const { AppStateProvider } = await import('../../state/AppState.js') + const { render } = await import('../../ink.js') + const stdout = new PassThrough() + ;(stdout as unknown as { columns: number }).columns = 120 + const instance = await render( + {}} + > + {element} + , + stdout as unknown as NodeJS.WriteStream, + ) + + try { + await waitForCondition(() => capturedOnSelect !== undefined) + capturedOnSelect?.( + encodeSwitchProfileValue('profile_openai', 'gpt-5-mini'), + 'high', + // The real ModelPicker threads the selected switch option's marker; the + // mock picker mirrors that so handleSelect activates the profile. + 'profile_openai', + ) + + await waitForCondition(() => doneMessage !== undefined) + expect(doneMessage).toContain('Switched to') + expect(doneMessage).toContain('high effort') + expect(doneMessage).toContain('Billed as extra usage') + } finally { + instance.unmount() + mock.module('../../utils/extraUsage.js', () => REAL_EXTRA_USAGE_FOR_MODEL_TEST) + } +}) + +test('cross-profile /model does NOT switch a literal prefixed model id lacking the switch marker (#1164)', async () => { + // jatmn [P2]: a real custom model id such as + // `__switch_profile__:profile_openai:gpt-5-mini` parses like a switch value + // and `profile_openai` exists — but the selected option carried NO + // switchToProfileId marker, so it must be applied as a literal model, never + // activating the provider. The mock picker mirrors the real one by threading + // an UNDEFINED marker for this non-switch option. + let activatedProfileId: string | null = null + mockProviderProfiles({ + getProviderProfiles: () => [ + { + id: 'profile_openai', + name: 'OpenAI', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + }, + ], + setActiveProviderProfile: (profileId: string) => { + activatedProfileId = profileId + return { + id: profileId, + name: 'OpenAI', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + } + }, + } as never) + + let capturedOnSelect: + | ((model: string | null, effort: unknown, switchToProfileId?: string) => void) + | undefined + mock.module('../../components/ModelPicker.js', () => ({ + ModelPicker: function MockModelPicker(props: { + onSelect?: ( + model: string | null, + effort: unknown, + switchToProfileId?: string, + ) => void + }): React.ReactNode { + capturedOnSelect = props.onSelect + return null + }, + })) + + const { getDefaultAppState } = await import('../../state/AppState.js') + let doneMessage: string | undefined + const { call } = await importFreshModelModule('literal-prefixed-no-switch') + const element = await call( + (message?: string) => { + doneMessage = message + }, + {} as never, + '', + ) + const { AppStateProvider } = await import('../../state/AppState.js') + const { render } = await import('../../ink.js') + const stdout = new PassThrough() + ;(stdout as unknown as { columns: number }).columns = 120 + const instance = await render( + {}} + > + {element} + , + stdout as unknown as NodeJS.WriteStream, + ) + + try { + await waitForCondition(() => capturedOnSelect !== undefined) + // Same encoded string, but NO marker threaded — this is a literal custom id. + capturedOnSelect?.( + encodeSwitchProfileValue('profile_openai', 'gpt-5-mini'), + undefined, + undefined, + ) + + await waitForCondition(() => doneMessage !== undefined) + // Applied as a literal model, provider never activated. + expect(activatedProfileId).toBeNull() + expect(doneMessage).not.toContain('Switched to') + expect(doneMessage).toContain( + encodeSwitchProfileValue('profile_openai', 'gpt-5-mini'), + ) + } finally { + instance.unmount() + } +}) diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 70e1a44cd..af5b0e632 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -53,6 +53,8 @@ import { } from '../../utils/model/check1mAccess.js' import { getDefaultOptionForUser, + getInactiveProviderProfileOptions, + parseSwitchProfileValue, type ModelOption, } from '../../utils/model/modelOptions.js' import { buildRouteCatalogModelOptions, mergeRouteCatalogEntries } from '../../utils/model/routeCatalogOptions.js' @@ -71,8 +73,10 @@ import { getActiveOpenAIRouteModelOptionsCache, getActiveProviderProfile, getConfiguredProfileModelOptions, + getProviderProfiles, setActiveOpenAIRouteModelOptionsCache, setActiveOpenAIModelOptionsCache, + setActiveProviderProfile, } from '../../utils/providerProfiles.js' import { parseModelList } from '../../utils/providerModels.js' import { getInitialSettings } from '../../utils/settings/settings.js' @@ -317,6 +321,46 @@ function getLegacyOpenAIOptionsOverride(options: { ) } +// The picker renders `optionsOverride ?? getModelOptions()`. getModelOptions() +// appends inactive-profile switch entries (issue #1119) when a provider profile +// env is applied, but the discovery/refresh override lists are built from +// mergeActiveProfileModelOptions, which only merges the ACTIVE profile's route +// models. Without re-appending here, the unified `/model` switcher disappears +// for descriptor-backed and legacy OpenAI-compatible discovery contexts +// (OpenRouter/Kimi/MiniMax, refreshed local profiles). Mirror getModelOptions() +// so any override list carries the same inactive-profile switch options. +function withInactiveProfileSwitchOptions( + options: ModelOption[], +): ModelOption[] { + if (process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED !== '1') { + return options + } + const activeProfile = getActiveProviderProfile() + const switchOptions = getInactiveProviderProfileOptions(activeProfile?.id) + if (switchOptions.length === 0) { + return options + } + const present = new Set( + options.flatMap(option => + typeof option.value === 'string' ? [option.value] : [], + ), + ) + const additions = switchOptions.filter(option => { + if (typeof option.value !== 'string' || present.has(option.value)) { + return false + } + // Apply the org allowlist to the decoded target model, mirroring + // getModelOptions()'s allowlist pass, so a restricted switch target is not + // surfaced. handleSelect re-checks this before activating regardless. + const target = + option.switchToProfileId !== undefined + ? parseSwitchProfileValue(option.value)?.model ?? option.value + : option.value + return isModelAllowed(target) + }) + return additions.length > 0 ? [...options, ...additions] : options +} + function getOpenAIDiscoveryRequestOptions(routeId?: string | null): { apiKey?: string baseUrl?: string @@ -340,6 +384,31 @@ function getOpenAIDiscoveryRequestOptions(routeId?: string | null): { } } +// Reconciles fast-mode state when /model picks a new target — both the regular +// switch path and the cross-profile switch path (#1119 / jatmn review) call +// this so a latched fastMode never carries past a model that can't support it. +// Pure: returns the result and lets callers apply state mutations. +export type FastModeReconcileResult = 'on' | 'off' | 'unchanged' + +export function reconcileFastModeForSwitch( + targetModel: string | null, + isFastModeOn: boolean, +): FastModeReconcileResult { + if (!isFastModeEnabled()) return 'unchanged' + clearFastModeCooldown() + if (!isFastModeSupportedByModel(targetModel) && isFastModeOn) { + return 'off' + } + if ( + isFastModeSupportedByModel(targetModel) && + isFastModeAvailable() && + isFastModeOn + ) { + return 'on' + } + return 'unchanged' +} + export function shouldAutoRefreshRouteCatalog(options: { catalog: ModelCatalogConfig hasCachedModels: boolean @@ -611,7 +680,121 @@ function ModelPickerWrapper({ }) } - const handleSelect = (model: string | null, effort: EffortLevel | undefined) => { + const handleSelect = ( + model: string | null, + effort: EffortLevel | undefined, + switchToProfileId?: string, + ) => { + // Cross-profile switch from /model picker (issue #1119). The composite + // value carries the profile id; activate that profile first so subsequent + // requests use the new OPENAI_BASE_URL / OPENAI_API_KEY, then drop down to + // the regular model-switch path with the bare model string. + // + // Only treat the value as a switch when the SELECTED OPTION carried the + // `switchToProfileId` marker (threaded here by the picker) — not merely + // because the value parses as `__switch_profile__::` for + // an existing profile. A real custom model id such as + // `__switch_profile__:profile_openai:gpt-5-mini` (where `profile_openai` + // happens to exist) is a plain option with no marker, and must be applied + // as a literal model rather than activating the provider. Cross-check the + // decoded profile id against the threaded marker and a real configured + // profile before switching. + const decodedSwitch = parseSwitchProfileValue(model) + const switchTarget = + decodedSwitch && + switchToProfileId === decodedSwitch.profileId && + getProviderProfiles().some(p => p.id === decodedSwitch.profileId) + ? decodedSwitch + : null + if (switchTarget) { + // Apply the org allowlist to the decoded target model, not the composite + // value, so a permitted cross-profile model is not wrongly rejected. + if (!isModelAllowed(switchTarget.model)) { + onDone( + `Model '${switchTarget.model}' is not available. Your organization restricts model selection.`, + { display: 'system' }, + ) + return + } + // Run the same fast-mode reconciliation as the regular switch path — + // otherwise a user with fastMode latched on Anthropic would carry the + // latched state into the new profile even when its model can't support + // it (jatmn review, #1119). This MUST run before setActiveProviderProfile: + // reconcileFastModeForSwitch gates on isFastModeEnabled(), which reads the + // *active* provider, so once the target profile is activated it reflects + // the new (fast-mode-less) provider and short-circuits to 'unchanged', + // leaving fastMode latched on. + const switchFastMode = reconcileFastModeForSwitch( + switchTarget.model, + isFastMode ?? false, + ) + + const activated = setActiveProviderProfile(switchTarget.profileId) + if (!activated) { + onDone(`Could not activate provider profile "${switchTarget.profileId}".`, { + display: 'system', + }) + return + } + logEvent('tengu_model_command_menu', { + action: 'switch_profile' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + from_model: String(mainLoopModel) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + to_model: String(switchTarget.model) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + setAppState(prev => ({ + ...prev, + mainLoopModel: switchTarget.model, + mainLoopModelForSession: null, + })) + + // Re-evaluate fast mode AFTER activation: the pre-activation reconcile + // gates on the *source* provider, so its 'on' result can be stale when + // the target provider can't actually run fast mode (e.g. switching from + // first-party to a third-party OpenAI-compatible profile whose model name + // still passes the source-side support check). isFastModeEnabled() now + // reflects the target provider, so force fastMode off whenever it is no + // longer genuinely supported (jatmn review, #1119). + const fastModeSupportedNow = + isFastModeEnabled() && + isFastModeSupportedByModel(switchTarget.model) && + isFastModeAvailable() + const shouldTurnFastModeOff = + (isFastMode ?? false) && + (switchFastMode === 'off' || !fastModeSupportedNow) + + if (shouldTurnFastModeOff) { + setAppState(prev => ({ ...prev, fastMode: false })) + } + + let switchMessage = `Switched to ${chalk.bold(activated.name)} · model ${chalk.bold(switchTarget.model)}` + // Mirror the regular switch confirmation so a cross-profile selection + // surfaces the same cost-impacting feedback: the selected effort and the + // `Billed as extra usage` notice (jatmn review, #1119). The picker already + // decodes effort for switch values, so omitting it here would silently + // hide reasoning/extra-usage information the direct model path shows. + if (effort !== undefined) { + switchMessage += ` with ${chalk.bold(effort)} effort` + } + const crossProfileFastModeOn = + (isFastMode ?? false) && fastModeSupportedNow && !shouldTurnFastModeOff + if (shouldTurnFastModeOff) { + switchMessage += ' · Fast mode OFF' + } else if (crossProfileFastModeOn) { + switchMessage += ' · Fast mode ON' + } + if ( + isBilledAsExtraUsage( + switchTarget.model, + crossProfileFastModeOn, + isOpus1mMergeEnabled(), + ) + ) { + switchMessage += ' · Billed as extra usage' + } + onDone(switchMessage) + return + } + if (model && !isModelAllowed(model)) { onDone( `Model '${model}' is not available. Your organization restricts model selection.`, @@ -637,23 +820,21 @@ function ModelPickerWrapper({ message += ` with ${chalk.bold(effort)} effort` } - let wasFastModeToggledOn: boolean | undefined - if (isFastModeEnabled()) { - clearFastModeCooldown() - if (!isFastModeSupportedByModel(model) && isFastMode) { - setAppState(prev => ({ - ...prev, - fastMode: false, - })) - wasFastModeToggledOn = false - } else if ( - isFastModeSupportedByModel(model) && - isFastModeAvailable() && - isFastMode - ) { - message += ' · Fast mode ON' - wasFastModeToggledOn = true - } + const fastModeResult = reconcileFastModeForSwitch(model, isFastMode ?? false) + if (fastModeResult === 'off') { + setAppState(prev => ({ + ...prev, + fastMode: false, + })) + } + const wasFastModeToggledOn: boolean | undefined = + fastModeResult === 'on' + ? true + : fastModeResult === 'off' + ? false + : undefined + if (fastModeResult === 'on') { + message += ' · Fast mode ON' } if ( @@ -811,13 +992,18 @@ function ModelPickerWrapper({ onSelect={handleSelect} onCancel={handleCancel} isStandaloneCommand + allowProfileSwitch showFastModeNotice={ isFastModeEnabled() && isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable() } - optionsOverride={optionsOverride} + optionsOverride={ + optionsOverride + ? withInactiveProfileSwitchOptions(optionsOverride) + : undefined + } discoveryState={discoveryState} onRefresh={ discoveryContext?.canRefresh diff --git a/src/components/ModelPicker.test.tsx b/src/components/ModelPicker.test.tsx index 68c3c00d7..4f49f9c5e 100644 --- a/src/components/ModelPicker.test.tsx +++ b/src/components/ModelPicker.test.tsx @@ -10,6 +10,7 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' +import { SWITCH_PROFILE_VALUE_PREFIX } from '../utils/model/modelOptions.js' import { resetSettingsCache, setSessionSettingsCache, @@ -206,4 +207,132 @@ test('matches current model to override options case-insensitively', async () => stdin.end() stdout.end() } -}) \ No newline at end of file +}) + +function makeStdio(): { + stdin: PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void + } + stdout: PassThrough + getOutput: () => string +} { + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void + } + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + return { stdin, stdout, getOutput: () => output } +} + +const CROSS_PROFILE_OPTIONS = [ + { + value: 'claude-opus-4-6', + label: 'Active Model', + description: 'Current profile', + }, + { + value: `${SWITCH_PROFILE_VALUE_PREFIX}work:gpt-5.5`, + label: 'Switch to Work · gpt-5.5', + description: 'Inactive provider profile', + // Genuine switch option carries the marker (as production builds it). + switchToProfileId: 'work', + }, + { + // A real custom model id that merely starts with the switch prefix but is + // NOT a switch option (no switchToProfileId marker). It must stay visible in + // inline pickers — the filter keys on the marker, not the raw value prefix. + value: `${SWITCH_PROFILE_VALUE_PREFIX}vendor:gpt-5.4`, + label: 'Prefixed Custom Model', + description: 'Literal custom model, not a switch', + }, +] + +test('hides cross-profile switch options when allowProfileSwitch is falsy', async () => { + const { ModelPicker } = await import( + `./ModelPicker.js?cross-profile-hidden-${Date.now()}` + ) + const { stdin, stdout, getOutput } = makeStdio() + + const instance = await render( + + {}} + optionsOverride={CROSS_PROFILE_OPTIONS} + /> + , + { + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream, + exitOnCtrlC: false, + }, + ) + + try { + await waitForCondition(() => stripAnsi(getOutput()).includes('Active Model')) + const rendered = stripAnsi(getOutput()) + expect(rendered).toContain('Active Model') + // The inline picker cannot honor a profile switch, so the marked switch + // option must never surface. + expect(rendered).not.toContain('Switch to Work') + expect(rendered).not.toContain(SWITCH_PROFILE_VALUE_PREFIX) + // ...but a real custom model that merely starts with the prefix is NOT a + // switch (no marker) and must remain visible. + expect(rendered).toContain('Prefixed Custom Model') + } finally { + instance.unmount() + stdin.end() + stdout.end() + } +}) + +test('shows cross-profile switch options when allowProfileSwitch is set', async () => { + const { ModelPicker } = await import( + `./ModelPicker.js?cross-profile-shown-${Date.now()}` + ) + const { stdin, stdout, getOutput } = makeStdio() + + const instance = await render( + + {}} + allowProfileSwitch + optionsOverride={CROSS_PROFILE_OPTIONS} + /> + , + { + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream, + exitOnCtrlC: false, + }, + ) + + try { + await waitForCondition(() => + stripAnsi(getOutput()).includes('Switch to Work'), + ) + const rendered = stripAnsi(getOutput()) + expect(rendered).toContain('Active Model') + expect(rendered).toContain('Switch to Work') + } finally { + instance.unmount() + stdin.end() + stdout.end() + } +}) + diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index f7e82f8b0..fe8d99b77 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -11,7 +11,7 @@ import { useAppState, useSetAppState } from '../state/AppState.js'; import { convertEffortValueToLevel, type EffortLevel, getAvailableEffortLevels, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort } from '../utils/effort.js'; import { isModelAllowed } from '../utils/model/modelAllowlist.js'; import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel } from '../utils/model/model.js'; -import { getModelOptions, type ModelOption } from '../utils/model/modelOptions.js'; +import { getModelOptions, type ModelOption, parseSwitchProfileValue, resolveSelectedSwitchProfileId } from '../utils/model/modelOptions.js'; import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; import { Select } from './CustomSelect/index.js'; @@ -26,7 +26,18 @@ export type ModelPickerDiscoveryState = { export type Props = { initial: string | null; sessionModel?: ModelSetting; - onSelect: (model: string | null, effort: EffortLevel | undefined) => void; + /** + * `switchToProfileId` is the marker of the selected cross-profile option + * (issue #1119). It is defined only when the picked option is a genuine + * "switch profile" entry, so consumers must gate profile activation on this + * marker rather than re-parsing the encoded value — a literal custom model id + * that merely starts with `__switch_profile__:` arrives with it undefined. + */ + onSelect: ( + model: string | null, + effort: EffortLevel | undefined, + switchToProfileId?: string, + ) => void; onCancel?: () => void; isStandaloneCommand?: boolean; showFastModeNotice?: boolean; @@ -42,6 +53,14 @@ export type Props = { optionsOverride?: ModelOption[]; discoveryState?: ModelPickerDiscoveryState; onRefresh?: () => void; + /** + * Allow cross-profile "switch profile" options (issue #1119) to appear in the + * list. These carry an encoded `__switch_profile__::` value that + * only the `/model` command's onSelect knows how to activate. Inline pickers + * (prompt hotkey, Settings) that write the raw value to `mainLoopModel` must + * leave this off so they never surface an option they cannot honor. + */ + allowProfileSwitch?: boolean; }; const NO_PREFERENCE = '__NO_PREFERENCE__'; function normalizeModelPickerValue(value: unknown): string | null { @@ -86,7 +105,8 @@ export function ModelPicker(t0) { skipSettingsWrite, optionsOverride, discoveryState, - onRefresh + onRefresh, + allowProfileSwitch } = t0; const setAppState = useSetAppState(); const exitState = useExitOnCtrlCDWithKeybindings(); @@ -112,7 +132,16 @@ export function ModelPicker(t0) { } else { t3 = $[3]; } - const modelOptions = optionsOverride ?? t3; + const modelOptionsBase = optionsOverride ?? t3; + // Cross-profile switch options can only be honored by the /model command's + // onSelect, which decodes the value and activates the target profile. Strip + // them for inline pickers (allowProfileSwitch falsy) so a hotkey/Settings + // selection never writes the raw `__switch_profile__:...` value as a model. + // Key on the `switchToProfileId` marker, not the raw value prefix, so a real + // custom model id that merely starts with `__switch_profile__:` is not hidden. + const modelOptions = allowProfileSwitch + ? modelOptionsBase + : modelOptionsBase.filter(opt => opt.switchToProfileId === undefined); let t4; bb0: { if (initial !== null && isModelAllowed(initial) && !modelOptions.some(opt => optionMatchesPickerValue(opt, initial))) { @@ -298,7 +327,17 @@ export function ModelPicker(t0) { onSelect(null, selectedEffort); return; } - onSelect(selectedValue, selectedEffort); + // Thread the presented option's cross-profile marker (issue #1119) so the + // /model command activates a provider only for a genuine switch option, + // never for a literal custom id that merely starts with the prefix. + // selectOptions is the actual presented list (already captured in this + // memo's deps) and its entries spread the source ModelOption's + // `switchToProfileId`. If two options share the selected value (a literal + // custom id colliding with an encoded switch value), the selection is + // ambiguous — the Select cannot tell them apart — so treat it as NOT a + // switch rather than letting the literal borrow another option's marker. + const selectedSwitchProfileId = resolveSelectedSwitchProfileId(selectOptions, selectedValue); + onSelect(selectedValue, selectedEffort, selectedSwitchProfileId); }; $[35] = effort; $[36] = hasToggledEffort; @@ -447,9 +486,32 @@ function _temp2(s_0) { function _temp(s) { return isFastModeEnabled() ? s.fastMode : false; } +// A picker value is a genuine cross-profile switch only when the option with +// that exact value carries the `switchToProfileId` marker. A literal custom +// model id that merely starts with `__switch_profile__:` is a plain option with +// no marker and must NOT be decoded — otherwise the display resolver would +// strip a real model id down to its `:`-tail. getModelOptions() is the +// authority for the switch options (they only appear in the base list, never in +// a discovery override, and discovered ids never carry the prefix). If two +// options share the value (a literal id colliding with an encoded switch +// value), the match is ambiguous, so require exactly one option and treat that +// lone option's marker as authoritative. +function isGenuineSwitchProfileValue(value: string): boolean { + return resolveSelectedSwitchProfileId(getModelOptions(), value) !== undefined; +} function resolveOptionModel(value?: string): string | undefined { if (!value) return undefined; - return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value); + if (value === NO_PREFERENCE) return getDefaultMainLoopModel(); + // Cross-profile entries from /model encode the picker value as + // `__switch_profile__::`. Effort / display logic needs + // the bare target model id (e.g. `gpt-5.4`) — otherwise + // `modelSupportsEffort` sees the prefixed string and reports + // "Effort not supported" even for reasoning-capable models. Decode only when + // the value is a genuine marker-backed switch option, not any prefixed id. + const switched = isGenuineSwitchProfileValue(value) + ? parseSwitchProfileValue(value) + : null; + return parseUserSpecifiedModel(switched ? switched.model : value); } function EffortLevelIndicator(t0) { const $ = _c(5); diff --git a/src/utils/model/modelOptions.crossProfile.test.ts b/src/utils/model/modelOptions.crossProfile.test.ts new file mode 100644 index 000000000..008d2317d --- /dev/null +++ b/src/utils/model/modelOptions.crossProfile.test.ts @@ -0,0 +1,921 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mock } from 'bun:test' + +import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from '../settings/settingsCache.js' + +// Mock surface: keep the original providerProfiles export shape and only +// override `getProviderProfiles` / `getActiveProviderProfile` / +// `getProfileModelOptions` per test. Anything else (setActiveProviderProfile, +// etc.) stays as the real implementation so we don't break unrelated callers +// loaded in the same `bun test` invocation. See `src/utils/user.test.ts` for +// the canonical pattern; this is the same lesson as the 2026-04-30 mock-leak +// note in lessons_learned.md. +import * as actualProviderProfiles from '../providerProfiles.js' +import * as actualProviders from './providers.js' +import * as actualAuth from '../auth.js' +import * as actualProviderConfig from '../../services/api/providerConfig.js' +import * as actualSettings from '../settings/settings.js' +import * as actualModelAllowlist from './modelAllowlist.js' +import * as actualOllamaModels from './ollamaModels.js' +import * as actualNvidiaModels from './nvidiaNimModels.js' +import * as actualMiniMaxModels from './minimaxModels.js' +import * as actualXiaomiModels from './xiaomi-mimoModels.js' +import type { ModelOption } from './modelOptions.js' +import type { ProviderProfile } from '../config.js' +import type { SettingsJson } from '../settings/types.js' + +// Snapshot the real modules before any mock.module runs. bun live-repoints the +// `actual*` namespaces to the active mock, so these plain-object copies are the +// stable handle on the genuine implementations. +const realProviderProfiles = { ...actualProviderProfiles } +const realProviders = { ...actualProviders } +const realAuth = { ...actualAuth } +const realProviderConfig = { ...actualProviderConfig } +const realSettings = { ...actualSettings } +const realModelAllowlist = { ...actualModelAllowlist } +const realOllamaModels = { ...actualOllamaModels } +const realNvidiaModels = { ...actualNvidiaModels } +const realMiniMaxModels = { ...actualMiniMaxModels } +const realXiaomiModels = { ...actualXiaomiModels } + +// bun's mock.module is process-wide and mock.restore() does NOT undo it, so +// per-test mocks installed in a harness would persist and leak into later +// suites (e.g. providerConfig's cache-scope tests reading a pinned +// getAPIProvider). Instead install each mock ONCE here, gated on this flag, and +// delegate to the real implementation whenever a cross-profile test is not +// actively running. afterEach clears the flag, so the persisted mock becomes a +// transparent passthrough for every other file. Same pattern as the +// cross-spawn/file-suggestions leak fix (#1667). +let activeProfilesOverride: Partial | null = null +// Overrides getAdditionalModelOptionsCacheScope (used by getModelOptions to pick +// the openai-scope branch) only while a test sets it. Keeps the full +// providerConfig surface so the persisted mock doesn't strip resolveProviderRequest +// etc. from later suites (e.g. providerConfig.local). +let activeCacheScopeOverride: string | null = null +// Overrides getSettings_DEPRECATED (read by BOTH the filterModelOptionsByAllowlist +// gate in modelOptions.ts AND isModelAllowed in modelAllowlist.js) only while an +// allowlist test sets it. Many sibling suites (ModelPicker/ProviderManager/...) +// mock.module('../settings/settings.js') process-wide, which defeats +// setSessionSettingsCache, so drive the allowlist deterministically here rather +// than through the shared settings cache. Gated + passthrough so it doesn't leak. +let activeSettingsOverride: SettingsJson | null = null +// Drives the Ollama early-return branch in getModelOptionsBase. When set, the +// gated mock reports an Ollama provider with this fixed cached-model list so a +// cross-profile test can assert the branch still appends inactive-profile +// options (#1164). Gated + passthrough so it can't leak into other suites. +let activeOllamaOverride: { cachedModels: ModelOption[] } | null = null +// Pins getAPIProvider to a specific value (e.g. 'github') so the corresponding +// early-return branch in getModelOptionsBase runs. Gated + passthrough. +let activeApiProviderOverride: string | null = null +// Drives the NVIDIA NIM catalog early-return branch. Gated + passthrough. +let activeNvidiaOverride: { cachedModels: ModelOption[] } | null = null +// Flips the Claude subscriber branch on (and optionally Max tier). Gated so it +// doesn't leak subscriber state into sibling suites. +let activeSubscriberOverride: { max?: boolean } | null = null +// Drive the MiniMax / Xiaomi MiMo catalog early-return branches. Gated + passthrough. +let activeMiniMaxOverride: { cachedModels: ModelOption[] } | null = null +let activeXiaomiOverride: { cachedModels: ModelOption[] } | null = null + +mock.module('./ollamaModels.js', () => ({ + ...realOllamaModels, + isOllamaProvider: () => + activeOllamaOverride ? true : realOllamaModels.isOllamaProvider(), + getCachedOllamaModelOptions: () => + activeOllamaOverride + ? activeOllamaOverride.cachedModels + : realOllamaModels.getCachedOllamaModelOptions(), +})) + +mock.module('./nvidiaNimModels.js', () => ({ + ...realNvidiaModels, + isNvidiaNimProvider: () => + activeNvidiaOverride ? true : realNvidiaModels.isNvidiaNimProvider(), + getCachedNvidiaNimModelOptions: () => + activeNvidiaOverride + ? activeNvidiaOverride.cachedModels + : realNvidiaModels.getCachedNvidiaNimModelOptions(), +})) + +mock.module('./minimaxModels.js', () => ({ + ...realMiniMaxModels, + isMiniMaxProvider: () => + activeMiniMaxOverride ? true : realMiniMaxModels.isMiniMaxProvider(), + getCachedMiniMaxModelOptions: () => + activeMiniMaxOverride + ? activeMiniMaxOverride.cachedModels + : realMiniMaxModels.getCachedMiniMaxModelOptions(), +})) + +mock.module('./xiaomi-mimoModels.js', () => ({ + ...realXiaomiModels, + isXiaomiMimoProvider: () => + activeXiaomiOverride ? true : realXiaomiModels.isXiaomiMimoProvider(), + getCachedXiaomiMimoModelOptions: () => + activeXiaomiOverride + ? activeXiaomiOverride.cachedModels + : realXiaomiModels.getCachedXiaomiMimoModelOptions(), +})) + +mock.module('../settings/settings.js', () => ({ + ...realSettings, + getSettings_DEPRECATED: () => + activeSettingsOverride ?? realSettings.getSettings_DEPRECATED(), +})) + +// Sibling suites (ModelPicker/...) also mock modelAllowlist's isModelAllowed +// process-wide, so override it here too (gated + passthrough) and drive it from +// the same activeSettingsOverride allowlist, matching the gate above. Mirrors +// the agent.test.ts allowlist pattern. +mock.module('./modelAllowlist.js', () => ({ + ...realModelAllowlist, + isModelAllowed: (model: string) => { + const allowlist = activeSettingsOverride?.availableModels + return allowlist ? allowlist.includes(model) : realModelAllowlist.isModelAllowed(model) + }, +})) + +mock.module('../../services/api/providerConfig.js', () => ({ + ...realProviderConfig, + getAdditionalModelOptionsCacheScope: () => + activeCacheScopeOverride ?? + realProviderConfig.getAdditionalModelOptionsCacheScope(), +})) + +mock.module('../providerProfiles.js', () => ({ + ...realProviderProfiles, + getProviderProfiles: (...args: Parameters) => + (activeProfilesOverride?.getProviderProfiles ?? + realProviderProfiles.getProviderProfiles)(...args), + getActiveProviderProfile: (...args: Parameters) => + (activeProfilesOverride?.getActiveProviderProfile ?? + realProviderProfiles.getActiveProviderProfile)(...args), + getProfileModelOptions: (...args: Parameters) => + (activeProfilesOverride?.getProfileModelOptions ?? + realProviderProfiles.getProfileModelOptions)(...args), +})) + +// The 3P path reads getAPIProvider + subscriber checks; pin them to a stable +// 3P-openai non-subscriber shape only while a cross-profile test is active. +mock.module('./providers.js', () => ({ + ...realProviders, + getAPIProvider: () => + activeApiProviderOverride ?? + (activeProfilesOverride ? 'openai' : realProviders.getAPIProvider()), + getAPIProviderForStatsig: () => + activeProfilesOverride + ? 'openai' + : realProviders.getAPIProviderForStatsig(), + isFirstPartyAnthropicBaseUrl: (...args: Parameters) => + activeProfilesOverride + ? false + : realProviders.isFirstPartyAnthropicBaseUrl(...args), + isGithubNativeAnthropicMode: (...args: Parameters) => + activeProfilesOverride + ? false + : realProviders.isGithubNativeAnthropicMode(...args), + usesAnthropicAccountFlow: (...args: Parameters) => + activeProfilesOverride + ? false + : realProviders.usesAnthropicAccountFlow(...args), +})) + +mock.module('../auth.js', () => ({ + ...realAuth, + isClaudeAISubscriber: (...args: Parameters) => + activeSubscriberOverride + ? true + : activeProfilesOverride ? false : realAuth.isClaudeAISubscriber(...args), + isMaxSubscriber: (...args: Parameters) => + activeSubscriberOverride + ? !!activeSubscriberOverride.max + : activeProfilesOverride ? false : realAuth.isMaxSubscriber(...args), + isTeamPremiumSubscriber: (...args: Parameters) => + activeProfilesOverride + ? false + : realAuth.isTeamPremiumSubscriber(...args), +})) + +function buildProviderProfileFixture( + overrides: Partial = {}, +): ProviderProfile { + return { + id: 'profile_default', + name: 'Default Profile', + provider: 'openai', + baseUrl: 'https://api.example.com/v1', + model: 'example-model', + apiKey: 'sk-example', + ...overrides, + } +} + +async function importFreshModelOptionsModule( + providerProfilesMock: Partial, +) { + activeProfilesOverride = providerProfilesMock + const nonce = `${Date.now()}-${Math.random()}` + return import(`./modelOptions.js?ts=${nonce}`) +} + +beforeEach(() => { + activeProfilesOverride = null + activeCacheScopeOverride = null + activeSettingsOverride = null + activeOllamaOverride = null + activeApiProviderOverride = null + activeNvidiaOverride = null + activeSubscriberOverride = null + activeMiniMaxOverride = null + activeXiaomiOverride = null + setSessionSettingsCache({ settings: {}, errors: [] }) + resetModelStringsForTestingOnly() +}) + +afterEach(() => { + // Clear the gates so the persisted provider/auth/profile/providerConfig/settings + // mocks fall through to the real implementations for every later suite. + activeProfilesOverride = null + activeCacheScopeOverride = null + activeSettingsOverride = null + activeOllamaOverride = null + activeApiProviderOverride = null + activeNvidiaOverride = null + activeSubscriberOverride = null + activeMiniMaxOverride = null + activeXiaomiOverride = null + resetSettingsCache() + resetModelStringsForTestingOnly() +}) + +test('parseSwitchProfileValue: round-trips encoded payload', async () => { + const { encodeSwitchProfileValue, parseSwitchProfileValue } = + await importFreshModelOptionsModule({}) + const encoded = encodeSwitchProfileValue('profile_kimi_k26', 'kimi-k2.6') + expect(parseSwitchProfileValue(encoded)).toEqual({ + profileId: 'profile_kimi_k26', + model: 'kimi-k2.6', + }) +}) + +test('parseSwitchProfileValue: preserves colons inside model name', async () => { + // OpenRouter model strings carry `:` segments (`vendor/model:variant`); the + // parser must split only on the FIRST colon after the prefix so the model + // half keeps its inner colons. Regression guard against a naive + // `value.split(':')`. + const { encodeSwitchProfileValue, parseSwitchProfileValue } = + await importFreshModelOptionsModule({}) + const encoded = encodeSwitchProfileValue( + 'profile_openrouter', + 'deepseek/deepseek-v4-flash:nitro', + ) + expect(parseSwitchProfileValue(encoded)).toEqual({ + profileId: 'profile_openrouter', + model: 'deepseek/deepseek-v4-flash:nitro', + }) +}) + +test('parseSwitchProfileValue: returns null for plain model strings', async () => { + const { parseSwitchProfileValue } = await importFreshModelOptionsModule({}) + expect(parseSwitchProfileValue('claude-sonnet-4-6')).toBeNull() + expect(parseSwitchProfileValue(null)).toBeNull() + expect(parseSwitchProfileValue('__switch_profile__:')).toBeNull() + expect(parseSwitchProfileValue('__switch_profile__:only-id:')).toBeNull() +}) + +test('getInactiveProviderProfileOptions: omits the active profile', async () => { + const profileA = buildProviderProfileFixture({ + id: 'profile_a', + name: 'A', + baseUrl: 'https://a.example.com/v1', + model: 'a-model', + }) + const profileB = buildProviderProfileFixture({ + id: 'profile_b', + name: 'B', + baseUrl: 'https://b.example.com/v1', + model: 'b-model', + }) + const { getInactiveProviderProfileOptions } = + await importFreshModelOptionsModule({ + getProviderProfiles: () => [profileA, profileB], + getActiveProviderProfile: () => profileA, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + + const options = getInactiveProviderProfileOptions('profile_a') + expect(options).toHaveLength(1) + expect(options[0]?.switchToProfileId).toBe('profile_b') + expect(options[0]?.label).toContain('b-model') + expect(options[0]?.label).toContain('B') + expect(options[0]?.description).toContain('https://b.example.com/v1') + expect(typeof options[0]?.value).toBe('string') + expect(options[0]?.value).toContain('profile_b') + expect(options[0]?.value).toContain('b-model') +}) + +test('getInactiveProviderProfileOptions: surfaces all configured profiles when none is active', async () => { + // Edge: if the caller passes `undefined` (no active profile yet — e.g. on a + // pristine first-run before env is applied), every configured profile should + // appear. Guards against a stray `filter` that drops everything when the + // active id is missing. + const profileA = buildProviderProfileFixture({ + id: 'profile_a', + name: 'A', + model: 'a-model', + }) + const profileB = buildProviderProfileFixture({ + id: 'profile_b', + name: 'B', + model: 'b-model', + }) + const { getInactiveProviderProfileOptions } = + await importFreshModelOptionsModule({ + getProviderProfiles: () => [profileA, profileB], + getActiveProviderProfile: () => undefined, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + + const options = getInactiveProviderProfileOptions(undefined) + expect(options.map(o => o.switchToProfileId)).toEqual([ + 'profile_a', + 'profile_b', + ]) +}) + +test('getInactiveProviderProfileOptions: explodes multi-model profiles into one option per model', async () => { + // Issue #1119 use case: one OpenRouter profile with several `agentModels` + // exposed as comma-separated `model`. Each model should become its own + // picker entry so the user can pick the exact one they want, not just the + // primary. + const multi = buildProviderProfileFixture({ + id: 'profile_or', + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'deepseek/deepseek-v4-flash:nitro,glm-5.1,MiniMax-M2.5', + }) + const { getInactiveProviderProfileOptions } = + await importFreshModelOptionsModule({ + getProviderProfiles: () => [multi], + getActiveProviderProfile: () => undefined, + getProfileModelOptions: () => [ + { + value: 'deepseek/deepseek-v4-flash:nitro', + label: 'deepseek/deepseek-v4-flash:nitro', + description: 'OpenRouter', + }, + { value: 'glm-5.1', label: 'glm-5.1', description: 'OpenRouter' }, + { + value: 'MiniMax-M2.5', + label: 'MiniMax-M2.5', + description: 'OpenRouter', + }, + ], + }) + const options = getInactiveProviderProfileOptions(undefined) + expect(options).toHaveLength(3) + expect(options.every(o => o.switchToProfileId === 'profile_or')).toBe(true) + expect(options.map(o => o.label.split(' · ')[0])).toEqual([ + 'deepseek/deepseek-v4-flash:nitro', + 'glm-5.1', + 'MiniMax-M2.5', + ]) +}) + +test('getModelOptionsBase: 3P path includes inactive profile options when env applied', async () => { + const active = buildProviderProfileFixture({ + id: 'profile_active', + name: 'Active', + baseUrl: 'https://api.kimi.com/coding/', + model: 'kimi-k2.6', + }) + const inactive = buildProviderProfileFixture({ + id: 'profile_inactive', + name: 'GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + model: 'glm-5.1', + }) + + const previousFlag = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + try { + const { getModelOptions, parseSwitchProfileValue } = + await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + + const options = getModelOptions(false) + const switchOptions = options.filter(o => o.switchToProfileId !== undefined) + expect(switchOptions.length).toBeGreaterThan(0) + expect(switchOptions[0]?.switchToProfileId).toBe('profile_inactive') + const parsed = parseSwitchProfileValue(switchOptions[0]!.value) + expect(parsed).toEqual({ + profileId: 'profile_inactive', + model: 'glm-5.1', + }) + } finally { + if (previousFlag === undefined) { + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + } else { + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = previousFlag + } + } +}) + +test('getModelOptionsBase: local OpenAI-compatible scope still appends inactive profile options', async () => { + // Regression for #1164: when the active profile is a local OpenAI-compatible + // endpoint (Ollama, lm-studio, etc.), the scope-based early return used to + // skip the inactive-profile compute and the cross-profile switcher + // disappeared from `/model`. Now the inactive options are hoisted above the + // early return and forwarded in this branch too. + const active = buildProviderProfileFixture({ + id: 'profile_local', + name: 'Local Ollama', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.2', + }) + const inactive = buildProviderProfileFixture({ + id: 'profile_remote', + name: 'GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + model: 'glm-5.1', + }) + + const previousFlag = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + // Force the local OpenAI-compatible branch by pinning the scope getter to + // an `openai:` value (via the gated override installed at module load, which + // keeps the rest of providerConfig real so it can't leak into other suites). + activeCacheScopeOverride = 'openai:http://localhost:11434/v1' + try { + const { getModelOptions, parseSwitchProfileValue } = + await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + + const options = getModelOptions(false) + const switchOptions = options.filter(o => o.switchToProfileId !== undefined) + expect(switchOptions.length).toBeGreaterThan(0) + expect(switchOptions[0]?.switchToProfileId).toBe('profile_remote') + const parsed = parseSwitchProfileValue(switchOptions[0]!.value) + expect(parsed).toEqual({ + profileId: 'profile_remote', + model: 'glm-5.1', + }) + } finally { + if (previousFlag === undefined) { + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + } else { + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = previousFlag + } + } +}) + +test('getModelOptionsBase: active Ollama profile still surfaces inactive profile options', async () => { + // Regression for #1164: the `isOllamaProvider()` early return ran before the + // inactive-profile compute, so a user whose active profile is a local Ollama + // endpoint only saw the Ollama models and lost the cross-profile switcher, + // forcing the exact `/provider` round-trip this PR removes. The inactive + // options are now hoisted above the Ollama branch and appended to its returns. + const active = buildProviderProfileFixture({ + id: 'profile_ollama', + name: 'Local Ollama', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.2', + }) + const inactive = buildProviderProfileFixture({ + id: 'profile_remote', + name: 'GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + model: 'glm-5.1', + }) + + const previousFlag = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + // Force the Ollama branch with a non-empty cached-model list so it takes the + // `[default, ...ollamaModels, ...inactiveProfileOptions]` return path. + activeOllamaOverride = { + cachedModels: [ + { value: 'llama3.2', label: 'llama3.2', description: 'Local Ollama' }, + ], + } + try { + const { getModelOptions, parseSwitchProfileValue } = + await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + + const options = getModelOptions(false) + // The Ollama model is still present... + expect(options.some(o => o.value === 'llama3.2')).toBe(true) + // ...and the inactive profile now appears as a switch option. + const switchOptions = options.filter(o => o.switchToProfileId !== undefined) + expect(switchOptions.length).toBeGreaterThan(0) + expect(switchOptions[0]?.switchToProfileId).toBe('profile_remote') + const parsed = parseSwitchProfileValue(switchOptions[0]!.value) + expect(parsed).toEqual({ profileId: 'profile_remote', model: 'glm-5.1' }) + } finally { + if (previousFlag === undefined) { + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + } else { + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = previousFlag + } + } +}) + +test('getModelOptionsBase: 3P path omits inactive profile options when env NOT applied', async () => { + // If the user hasn't gone through `/provider` yet (profile env not applied), + // surfacing cross-profile switching would be confusing — they haven't opted + // into the multi-profile workflow at all. Guard against that. + const inactive = buildProviderProfileFixture({ + id: 'profile_inactive', + name: 'GLM', + model: 'glm-5.1', + }) + const previousFlag = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + try { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [inactive], + getActiveProviderProfile: () => undefined, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + const options = getModelOptions(false) + expect(options.every(o => o.switchToProfileId === undefined)).toBe(true) + } finally { + if (previousFlag !== undefined) { + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = previousFlag + } + } +}) + +test('getModelOptions: allowlist filters cross-profile options by the decoded target model', async () => { + // Regression for #1119: filterModelOptionsByAllowlist must evaluate the + // allowlist against the decoded target model (parseSwitchProfileValue(value).model), + // not the raw `__switch_profile__::` wrapper. An allowed cross-profile + // model must stay; a denied one must drop. Uses this suite's per-test isolated + // settings cache (set below, reset in afterEach) rather than the shared cache + // that made the earlier version flaky. + const active = buildProviderProfileFixture({ + id: 'profile_active', + name: 'Active', + baseUrl: 'https://api.kimi.com/coding/', + model: 'kimi-k2.6', + }) + const allowedInactive = buildProviderProfileFixture({ + id: 'profile_allowed', + name: 'GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + model: 'glm-5.1', + }) + const deniedInactive = buildProviderProfileFixture({ + id: 'profile_denied', + name: 'Blocked', + baseUrl: 'https://blocked.example/v1', + model: 'blocked-model', + }) + + // Only glm-5.1 (and the active model) are permitted; blocked-model is not. + // Drive the allowlist through the gated getSettings_DEPRECATED override so it + // is immune to sibling suites that mock the settings module process-wide. + activeSettingsOverride = { + availableModels: ['kimi-k2.6', 'glm-5.1'], + } as SettingsJson + + const previousFlag = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + try { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, allowedInactive, deniedInactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + + const switchTargets = getModelOptions(false) + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId) + + // Allowed cross-profile model kept; denied one filtered out by the decoded + // (not the encoded) model id. + expect(switchTargets).toContain('profile_allowed') + expect(switchTargets).not.toContain('profile_denied') + } finally { + if (previousFlag === undefined) { + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + } else { + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = previousFlag + } + } +}) + +// Shared fixtures for the "other provider branches also append inactive +// profiles" cases (#1164 [P2]). Each branch previously returned before the +// inactive-profile options were appended, dropping the cross-profile switcher. +function activeAndInactivePair() { + const active = buildProviderProfileFixture({ + id: 'profile_active', + name: 'Active', + model: 'active-model', + }) + const inactive = buildProviderProfileFixture({ + id: 'profile_remote', + name: 'GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + model: 'glm-5.1', + }) + return { active, inactive } +} + +async function withProfileEnvApplied(run: () => Promise) { + const previousFlag = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + try { + await run() + } finally { + if (previousFlag === undefined) { + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + } else { + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = previousFlag + } + } +} + +test('getModelOptionsBase: GitHub Copilot branch appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + activeApiProviderOverride = 'github' + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + const switchOptions = getModelOptions(false).filter( + o => o.switchToProfileId !== undefined, + ) + expect(switchOptions.map(o => o.switchToProfileId)).toContain( + 'profile_remote', + ) + }) +}) + +test('getModelOptionsBase: NVIDIA NIM catalog branch appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + activeNvidiaOverride = { + cachedModels: [ + { value: 'nvidia/model', label: 'nvidia/model', description: 'NVIDIA' }, + ], + } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + const options = getModelOptions(false) + // Catalog model still present, and the inactive switcher restored. + expect(options.some(o => o.value === 'nvidia/model')).toBe(true) + expect( + options + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptionsBase: Claude subscriber branch appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + activeSubscriberOverride = {} // Pro/standard tier + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + expect( + getModelOptions(false) + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptionsBase: MiniMax catalog branch appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + activeMiniMaxOverride = { + cachedModels: [ + { value: 'MiniMax-M2.7', label: 'MiniMax-M2.7', description: 'MiniMax' }, + ], + } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + const options = getModelOptions(false) + expect(options.some(o => o.value === 'MiniMax-M2.7')).toBe(true) + expect( + options + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptionsBase: Xiaomi MiMo catalog branch appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + activeXiaomiOverride = { + cachedModels: [ + { value: 'mimo-v2.5-pro', label: 'mimo-v2.5-pro', description: 'MiMo' }, + ], + } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + const options = getModelOptions(false) + expect(options.some(o => o.value === 'mimo-v2.5-pro')).toBe(true) + expect( + options + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptionsBase: ant branch appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + const prevUserType = process.env.USER_TYPE + process.env.USER_TYPE = 'ant' + try { + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + expect( + getModelOptions(false) + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) + } finally { + if (prevUserType === undefined) delete process.env.USER_TYPE + else process.env.USER_TYPE = prevUserType + } +}) + +test('getModelOptionsBase: Max/Team Premium subscriber branch appends inactive profile options', async () => { + // The Pro/standard subscriber branch is covered above; this locks the + // separate Max / Team Premium early return (isMaxSubscriber || + // isTeamPremiumSubscriber), which builds its own premiumOptions array and + // must push ...inactiveProfileOptions before returning. + const { active, inactive } = activeAndInactivePair() + activeSubscriberOverride = { max: true } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + expect( + getModelOptions(false) + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptionsBase: NVIDIA NIM empty-catalog fallback still appends inactive profile options', async () => { + // The catalog branch above exercises the non-empty return; this locks the + // `[defaultOption, ...inactiveProfileOptions]` fallback taken when the cached + // catalog is empty, which previously dropped the inactive switch options. + const { active, inactive } = activeAndInactivePair() + activeNvidiaOverride = { cachedModels: [] } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + const options = getModelOptions(false) + // No catalog model surfaced, but the inactive switcher is still restored. + expect(options.some(o => o.value === 'nvidia/model')).toBe(false) + expect( + options + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptionsBase: MiniMax empty-catalog fallback still appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + activeMiniMaxOverride = { cachedModels: [] } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + expect( + getModelOptions(false) + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptionsBase: Xiaomi MiMo empty-catalog fallback still appends inactive profile options', async () => { + const { active, inactive } = activeAndInactivePair() + activeXiaomiOverride = { cachedModels: [] } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + expect( + getModelOptions(false) + .filter(o => o.switchToProfileId !== undefined) + .map(o => o.switchToProfileId), + ).toContain('profile_remote') + }) +}) + +test('getModelOptions: allowlist checks a non-switch custom id verbatim, not decoded', async () => { + // Regression for #1164 [P2]: filterModelOptionsByAllowlist must only decode + // genuine switch options (identified by `switchToProfileId`), not any string + // that happens to start with `__switch_profile__:`. A custom model id with + // that literal prefix but no marker must be checked as-is — decoding it would + // evaluate the allowlist against the wrong (inner) model and wrongly drop it. + const literal = '__switch_profile__:sneaky:real-model' + const active = buildProviderProfileFixture({ + id: 'profile_active', + name: 'Active', + model: literal, + }) + // Allow the literal id (verbatim). Its decoded inner model `real-model` is NOT + // listed, so the old prefix-based decode would have dropped it. + activeSettingsOverride = { availableModels: [literal] } as SettingsJson + + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active], + getActiveProviderProfile: () => active, + // Active profile's own model options are appended WITHOUT a + // switchToProfileId marker, so this exercises the non-switch path. + getProfileModelOptions: () => [ + { value: literal, label: 'Sneaky', description: 'custom' }, + ], + }) + const values = getModelOptions(false).map(o => o.value) + expect(values).toContain(literal) + }) +}) diff --git a/src/utils/model/modelOptions.switchMarker.test.ts b/src/utils/model/modelOptions.switchMarker.test.ts new file mode 100644 index 000000000..cb3a3921c --- /dev/null +++ b/src/utils/model/modelOptions.switchMarker.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' +import { + encodeSwitchProfileValue, + resolveSelectedSwitchProfileId, + type ModelOption, +} from './modelOptions.js' + +// resolveSelectedSwitchProfileId is the presented-option authority for whether +// a /model selection activates a provider (#1119/#1164). It must key on the +// actual option carrying the marker, and treat duplicate-value matches as +// ambiguous so a literal custom id cannot borrow another option's marker. +describe('resolveSelectedSwitchProfileId', () => { + const switchValue = encodeSwitchProfileValue('work', 'gpt-5.5') + + test('returns the marker of the single matching switch option', () => { + const options: ModelOption[] = [ + { value: 'claude-opus-4-6', label: 'Active', description: 'Active' }, + { value: switchValue, label: 'Switch to Work', description: 'Switch', switchToProfileId: 'work' }, + ] + expect(resolveSelectedSwitchProfileId(options, switchValue)).toBe('work') + }) + + test('returns undefined for a literal option with no marker', () => { + const options: ModelOption[] = [ + { value: switchValue, label: 'Literal custom model', description: 'Literal' }, + ] + expect(resolveSelectedSwitchProfileId(options, switchValue)).toBeUndefined() + }) + + test('returns undefined when two options share the selected value (ambiguous)', () => { + // A literal custom id whose value collides with an encoded switch value must + // NOT borrow the switch option's marker. + const options: ModelOption[] = [ + { value: switchValue, label: 'Switch to Work', description: 'Switch', switchToProfileId: 'work' }, + { value: switchValue, label: 'Literal custom model', description: 'Literal' }, + ] + expect(resolveSelectedSwitchProfileId(options, switchValue)).toBeUndefined() + }) + + test('returns undefined when nothing matches the selected value', () => { + const options: ModelOption[] = [ + { value: 'claude-opus-4-6', label: 'Active', description: 'Active' }, + ] + expect(resolveSelectedSwitchProfileId(options, switchValue)).toBeUndefined() + }) +}) diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 597452eb3..e6fa1dacb 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -42,6 +42,7 @@ import { getActiveOpenAIModelOptionsCache, getActiveProviderProfile, getProfileModelOptions, + getProviderProfiles, } from '../providerProfiles.js' import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js' import { getCachedNvidiaNimModelOptions, isNvidiaNimProvider } from './nvidiaNimModels.js' @@ -56,6 +57,68 @@ export type ModelOption = { label: string description: string descriptionForModel?: string + /** + * When set, selecting this option also activates the named provider profile + * before switching the main-loop model. Encoded into `value` as a + * `SWITCH_PROFILE_VALUE_PREFIX`-prefixed string so the picker's `value` + * channel stays a plain string; consumers must call `parseSwitchProfileValue` + * on `value` (or read `switchToProfileId` directly) before treating it as a + * model setting. Used to surface inactive `providerProfiles` from the + * `/model` picker (issue #1119). + */ + switchToProfileId?: string +} + +/** + * Prefix encoded into `ModelOption.value` for options that, when selected, + * should activate a different provider profile before applying the model. + * Format: `${SWITCH_PROFILE_VALUE_PREFIX}:`. Two profiles can + * legally expose the same model string under different base URLs, so the + * profile id is part of the value to keep options unique. + */ +export const SWITCH_PROFILE_VALUE_PREFIX = '__switch_profile__:' + +export type ParsedSwitchProfileValue = { + profileId: string + model: string +} + +export function parseSwitchProfileValue( + value: ModelSetting, +): ParsedSwitchProfileValue | null { + if (typeof value !== 'string' || !value.startsWith(SWITCH_PROFILE_VALUE_PREFIX)) { + return null + } + const tail = value.slice(SWITCH_PROFILE_VALUE_PREFIX.length) + const sep = tail.indexOf(':') + if (sep <= 0 || sep === tail.length - 1) { + return null + } + return { + profileId: tail.slice(0, sep), + model: tail.slice(sep + 1), + } +} + +export function encodeSwitchProfileValue(profileId: string, model: string): string { + return `${SWITCH_PROFILE_VALUE_PREFIX}${profileId}:${model}` +} + +/** + * Resolve the cross-profile switch marker (`switchToProfileId`) for a selected + * picker value from the PRESENTED options list — the authority for whether the + * selection is a genuine profile switch (#1119/#1164). Only a single option + * with that value is authoritative: if two options share the value (a literal + * custom model id colliding with an encoded switch value), the Select cannot + * tell them apart, so the selection is ambiguous and resolves to `undefined` + * rather than letting the literal borrow another option's marker. + */ +export function resolveSelectedSwitchProfileId( + options: ReadonlyArray>, + selectedValue: ModelSetting, +): string | undefined { + const matches = options.filter(option => option.value === selectedValue) + return matches.length === 1 ? matches[0]!.switchToProfileId : undefined } function getScopedAdditionalModelOptions(): ModelOption[] { @@ -417,8 +480,43 @@ function getCopilotModelOptions(): ModelOption[] { } function getModelOptionsBase(fastMode = false): ModelOption[] { + // When a provider profile's env is applied, collect its models so they + // can be appended to the picker options below. + // We check PROFILE_ENV_APPLIED to avoid the ?? profiles[0] fallback in + // getActiveProviderProfile which would affect users with inactive profiles. + // + // Hoisted above the local OpenAI-compatible early returns (Ollama and the + // route-catalog scope) because users with a local profile active still need + // the unified `/model` switcher to surface every other configured profile — + // otherwise they have to round-trip through `/provider` (issue #1119). + const profileEnvApplied = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1' + const profileModelOptions: ModelOption[] = [] + let activeProfileId: string | undefined + if (profileEnvApplied) { + const activeProfile = getActiveProviderProfile() + if (activeProfile) { + activeProfileId = activeProfile.id + const models = getProfileModelOptions(activeProfile) + profileModelOptions.push(...models) + } + } + + // Inactive provider profile options. Surfaces each configured-but-inactive + // provider profile's models in the picker so users can switch active provider + // + model from `/model` instead of having to round-trip through `/provider` + // (issue #1119). Only built when the active profile env is applied so we + // don't expose this affordance to users who haven't opted into the + // multi-profile workflow. + const inactiveProfileOptions: ModelOption[] = profileEnvApplied + ? getInactiveProviderProfileOptions(activeProfileId) + : [] + if (getAPIProvider() === 'github') { - return [getDefaultOptionForUser(fastMode), ...getCopilotModelOptions()] + return [ + getDefaultOptionForUser(fastMode), + ...getCopilotModelOptions(), + ...inactiveProfileOptions, + ] } // When using Ollama, show models from the Ollama server instead of Claude models @@ -426,7 +524,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { const defaultOption = getDefaultOptionForUser(fastMode) const ollamaModels = getCachedOllamaModelOptions() if (ollamaModels.length > 0) { - return [defaultOption, ...ollamaModels] + return [defaultOption, ...ollamaModels, ...inactiveProfileOptions] } // Fallback: if models not yet fetched, show current model instead of Claude models const currentModel = getUserSpecifiedModelSetting() ?? getInitialMainLoopModel() @@ -438,9 +536,10 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { label: currentModel, description: 'Currently configured Ollama model', }, + ...inactiveProfileOptions, ] } - return [defaultOption] + return [defaultOption, ...inactiveProfileOptions] } // When using NVIDIA NIM, show models from the NVIDIA catalog @@ -448,9 +547,9 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { const defaultOption = getDefaultOptionForUser(fastMode) const nvidiaModels = getCachedNvidiaNimModelOptions() if (nvidiaModels.length > 0) { - return [defaultOption, ...nvidiaModels] + return [defaultOption, ...nvidiaModels, ...inactiveProfileOptions] } - return [defaultOption] + return [defaultOption, ...inactiveProfileOptions] } // When using MiniMax, show models from the MiniMax catalog @@ -458,9 +557,9 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { const defaultOption = getDefaultOptionForUser(fastMode) const minimaxModels = getCachedMiniMaxModelOptions() if (minimaxModels.length > 0) { - return [defaultOption, ...minimaxModels] + return [defaultOption, ...minimaxModels, ...inactiveProfileOptions] } - return [defaultOption] + return [defaultOption, ...inactiveProfileOptions] } // When using Xiaomi MiMo, show models from the MiMo catalog @@ -468,9 +567,9 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { const defaultOption = getDefaultOptionForUser(fastMode) const xiaomiMimoModels = getCachedXiaomiMimoModelOptions() if (xiaomiMimoModels.length > 0) { - return [defaultOption, ...xiaomiMimoModels] + return [defaultOption, ...xiaomiMimoModels, ...inactiveProfileOptions] } - return [defaultOption] + return [defaultOption, ...inactiveProfileOptions] } if (process.env.USER_TYPE === 'ant') { @@ -488,6 +587,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { getSonnet46Option(), getSonnet46_1MOption(), getHaiku45Option(), + ...inactiveProfileOptions, ] } @@ -505,6 +605,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { } premiumOptions.push(MaxHaiku45Option) + premiumOptions.push(...inactiveProfileOptions) return premiumOptions } @@ -524,9 +625,13 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { } standardOptions.push(MaxHaiku45Option) + standardOptions.push(...inactiveProfileOptions) return standardOptions } + // Local OpenAI-compatible / route-catalog scope. Inactive-profile options are + // appended here too so the unified `/model` switcher still surfaces every + // other configured profile while a local/route profile is active (#1119). const activeRouteCatalogOptions = getActiveOpenAIRouteCatalogOptions() const openAIModelOptionsScope = getAdditionalModelOptionsCacheScope() const activeProfile = getActiveProviderProfile() @@ -549,23 +654,10 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { sourceOptions, activeRouteCatalogOptions, ), + ...inactiveProfileOptions, ] } - // When a provider profile's env is applied, collect its models so they - // can be appended to the standard picker options below. - // We check PROFILE_ENV_APPLIED to avoid the ?? profiles[0] fallback in - // getActiveProviderProfile which would affect users with inactive profiles. - const profileEnvApplied = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1' - const profileModelOptions: ModelOption[] = [] - if (profileEnvApplied) { - const activeProfile = getActiveProviderProfile() - if (activeProfile) { - const models = getProfileModelOptions(activeProfile) - profileModelOptions.push(...models) - } - } - // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.8 + Opus 4.7 + Opus 4.6 + Opus 1M + Haiku if (getAPIProvider() === 'firstParty') { const payg1POptions = [getDefaultOptionForUser(fastMode)] @@ -584,6 +676,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { } payg1POptions.push(getHaiku45Option()) payg1POptions.push(...profileModelOptions) + payg1POptions.push(...inactiveProfileOptions) return payg1POptions } @@ -627,9 +720,44 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { payg3pOptions.push(getHaikuOption()) } payg3pOptions.push(...profileModelOptions) + payg3pOptions.push(...inactiveProfileOptions) return payg3pOptions } +/** + * Build picker options for each provider profile that is NOT currently active. + * Selecting one of these activates the profile (swapping `OPENAI_BASE_URL` / + * `OPENAI_API_KEY` / etc. via `setActiveProviderProfile`) and then sets the + * main-loop model to the chosen entry — the equivalent of `/provider` followed + * by `/model`, but in one step. See issue #1119. + */ +export function getInactiveProviderProfileOptions( + activeProfileId: string | undefined, +): ModelOption[] { + const profiles = getProviderProfiles() + const options: ModelOption[] = [] + for (const profile of profiles) { + if (profile.id === activeProfileId) { + continue + } + const baseOptions = getProfileModelOptions(profile) + for (const baseOption of baseOptions) { + const modelValue = + typeof baseOption.value === 'string' ? baseOption.value : '' + if (!modelValue) { + continue + } + options.push({ + value: encodeSwitchProfileValue(profile.id, modelValue), + label: `${modelValue} · ${profile.name}`, + description: `Switch to ${profile.name} (${profile.baseUrl})`, + switchToProfileId: profile.id, + }) + } + } + return options +} + // @[MODEL LAUNCH]: Add the new model ID to the appropriate family pattern below // so the "newer version available" hint works correctly. /** @@ -920,10 +1048,23 @@ function filterModelOptionsByAllowlist(options: ModelOption[]): ModelOption[] { const settings = getSettings_DEPRECATED() || {} const filtered = !settings.availableModels ? options // No restrictions - : options.filter( - opt => - opt.value === null || (opt.value !== null && isModelAllowed(opt.value)), - ) + : options.filter(opt => { + if (opt.value === null) { + return true + } + // Cross-profile options carry an encoded + // `__switch_profile__::` value; evaluate the allowlist + // against the decoded target model so an allowed model is not dropped + // just because of the switch wrapper. Only decode genuine switch + // options — identified by the `switchToProfileId` marker, not the raw + // string prefix — so a normal custom model id that happens to start with + // `__switch_profile__:` is checked verbatim rather than mis-parsed. + const effectiveModel = + opt.switchToProfileId !== undefined + ? parseSwitchProfileValue(opt.value)?.model ?? opt.value + : opt.value + return isModelAllowed(effectiveModel) + }) // Select state uses option values as identity keys. If two entries share the // same value (e.g. provider-specific aliases collapsing to one model ID),