mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* 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 `<model> · <profile.name>` so the user can see the choice changes providers, not just models. - Each option's `value` is encoded with `__switch_profile__:<id>:<model>` so the picker's plain-string `value` channel stays the source of truth and same-named models under different base URLs (`gpt-4o` on multiple OpenAI-compatible endpoints) stay disambiguated. - /model's handleSelect detects the prefix, calls `setActiveProviderProfile` (same path /provider uses — applies env, persists active profile, refreshes startup file), then sets `mainLoopModel` to the bare model string. Only surfaces inactive options when `CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED` is set, so users who haven't opted into the multi-profile workflow at all don't see the affordance. Tests cover round-trip encoding (including OpenRouter-style colon-bearing model strings), the active-filter, the multi-model explosion, and that `getModelOptions()` 3P path includes the inactive options only when the profile env is applied. Combined invocation with the rest of `src/utils/model/` + `src/commands/model/` + `src/utils/providerProfiles.test.ts` runs clean to guard against mock-leak (per the 2026-04-30 lesson — spreads `import * as actual` for every `mock.module` factory). Refs #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__:<profileId>:<model>`, 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__:<id>:<model> 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__:<id>:<model>. 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__:<id>:<model> 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__:<profileId>:<model>` activated the provider whenever <profileId> 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.