diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index b04f4bcd68..7cb5a82464 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -20,6 +20,10 @@ const originalEnv = { OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, OPENAI_MODEL: process.env.OPENAI_MODEL, ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, } @@ -74,6 +78,14 @@ afterEach(() => { restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS) + restoreEnv( + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', + originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, + ) + restoreEnv( + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, + ) restoreEnv( 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', originalEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, @@ -159,6 +171,7 @@ test('opens the model picker without awaiting descriptor-backed route refresh', mock.module('../../utils/providerProfiles.js', () => ({ getActiveOpenAIModelOptionsCache: () => [], getActiveProviderProfile: () => null, + getProfileModelOptions: () => [], setActiveOpenAIModelOptionsCache: () => {}, })) @@ -210,6 +223,175 @@ test('shouldAutoRefreshRouteCatalog respects discovery refresh modes', async () ).toBe(false) }) +test('descriptor model options include active profile configured models', async () => { + const activeProfile = { + id: 'mistral-profile', + name: 'Mistral AI', + provider: 'mistral', + baseUrl: 'https://api.mistral.ai/v1', + model: 'devstral-latest, mistral-medium-latest', + apiKey: 'sk-mistral', + } + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id + + mock.module('../../utils/providerProfiles.js', () => ({ + getActiveOpenAIModelOptionsCache: () => [], + getActiveProviderProfile: () => activeProfile, + getProfileModelOptions: () => [ + { + value: 'devstral-latest', + label: 'devstral-latest', + description: 'Provider: Mistral AI', + }, + { + value: 'mistral-medium-latest', + label: 'mistral-medium-latest', + description: 'Provider: Mistral AI', + }, + ], + setActiveOpenAIModelOptionsCache: () => {}, + })) + + const { mergeActiveProfileModelOptions } = + await importFreshModelModule('descriptor-profile-model-merge') + + expect( + mergeActiveProfileModelOptions( + 'mistral', + [ + { + value: 'devstral-latest', + label: 'Devstral Latest', + description: 'Recommended · Provider: Mistral AI', + }, + ], + ), + ).toEqual([ + { + value: 'devstral-latest', + label: 'Devstral Latest', + description: 'Recommended · Provider: Mistral AI', + }, + { + value: 'mistral-medium-latest', + label: 'mistral-medium-latest', + description: 'Provider: Mistral AI', + }, + ]) +}) + +test('descriptor model options omit route defaults outside active profile models', async () => { + const activeProfile = { + id: 'mistral-profile', + name: 'Mistral AI', + provider: 'mistral', + baseUrl: 'https://api.mistral.ai/v1', + model: 'mistral-medium-latest, mistral-small-latest', + apiKey: 'sk-mistral', + } + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id + + mock.module('../../utils/providerProfiles.js', () => ({ + getActiveOpenAIModelOptionsCache: () => [], + getActiveProviderProfile: () => activeProfile, + getProfileModelOptions: () => [ + { + value: 'mistral-medium-latest', + label: 'mistral-medium-latest', + description: 'Provider: Mistral AI', + }, + { + value: 'mistral-small-latest', + label: 'mistral-small-latest', + description: 'Provider: Mistral AI', + }, + ], + setActiveOpenAIModelOptionsCache: () => {}, + })) + + const { mergeActiveProfileModelOptions } = + await importFreshModelModule('descriptor-profile-model-filter') + + expect( + mergeActiveProfileModelOptions( + 'mistral', + [ + { + value: 'devstral-latest', + label: 'Devstral Latest', + description: 'Recommended · Provider: Mistral AI', + }, + { + value: 'mistral-small-latest', + label: 'Mistral Small Latest', + description: 'Provider: Mistral AI', + }, + ], + ), + ).toEqual([ + { + value: 'mistral-medium-latest', + label: 'mistral-medium-latest', + description: 'Provider: Mistral AI', + }, + { + value: 'mistral-small-latest', + label: 'Mistral Small Latest', + description: 'Provider: Mistral AI', + }, + ]) +}) + +test('descriptor model options skip saved profile models for env-selected routes', async () => { + const savedProfile = { + id: 'mistral-profile', + name: 'Mistral AI', + provider: 'mistral', + baseUrl: 'https://api.mistral.ai/v1', + model: 'devstral-latest, mistral-medium-latest', + apiKey: 'sk-mistral', + } + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED + delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID + + mock.module('../../utils/providerProfiles.js', () => ({ + getActiveOpenAIModelOptionsCache: () => [], + getActiveProviderProfile: () => savedProfile, + getProfileModelOptions: () => [ + { + value: 'mistral-medium-latest', + label: 'mistral-medium-latest', + description: 'Provider: Mistral AI', + }, + ], + setActiveOpenAIModelOptionsCache: () => {}, + })) + + const { mergeActiveProfileModelOptions } = + await importFreshModelModule('descriptor-profile-model-env-skip') + + expect( + mergeActiveProfileModelOptions( + 'openrouter', + [ + { + value: 'openai/gpt-5-mini', + label: 'GPT-5 Mini', + description: 'Provider: OpenRouter', + }, + ], + ), + ).toEqual([ + { + value: 'openai/gpt-5-mini', + label: 'GPT-5 Mini', + description: 'Provider: OpenRouter', + }, + ]) +}) + test('/model refresh clears descriptor cache and reports updates', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' @@ -264,6 +446,7 @@ test('/model refresh clears descriptor cache and reports updates', async () => { mock.module('../../utils/providerProfiles.js', () => ({ getActiveOpenAIModelOptionsCache: () => [], getActiveProviderProfile: () => null, + getProfileModelOptions: () => [], setActiveOpenAIModelOptionsCache: () => {}, })) @@ -329,6 +512,7 @@ test('/model does not auto-refresh descriptor models when nonessential traffic i mock.module('../../utils/providerProfiles.js', () => ({ getActiveOpenAIModelOptionsCache: () => [], getActiveProviderProfile: () => null, + getProfileModelOptions: () => [], setActiveOpenAIModelOptionsCache: () => {}, })) diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 898910a462..00c123574d 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -21,7 +21,9 @@ import { getRouteDescriptor, resolveRouteCredentialValue, resolveActiveRouteIdFromEnv, + resolveRouteIdFromBaseUrl, } from '../../integrations/routeMetadata.js' +import { resolveProfileRoute } from '../../integrations/profileResolver.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, @@ -62,6 +64,7 @@ import { parseCustomHeadersEnv } from '../../utils/providerCustomHeaders.js' import { getActiveOpenAIModelOptionsCache, getActiveProviderProfile, + getProfileModelOptions, setActiveOpenAIModelOptionsCache, } from '../../utils/providerProfiles.js' @@ -108,6 +111,55 @@ function haveSameModelOptions(left: ModelOption[], right: ModelOption[]): boolea }) } +export function mergeActiveProfileModelOptions( + routeId: string, + routeOptions: ModelOption[], +): ModelOption[] { + const activeProfile = getActiveProviderProfile() + if (!activeProfile) { + return routeOptions + } + + const profileEnvApplied = + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1' && + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID === activeProfile.id + const activeProfileRouteId = + resolveRouteIdFromBaseUrl(activeProfile.baseUrl) ?? + resolveProfileRoute(activeProfile.provider).routeId + + if (!profileEnvApplied || activeProfileRouteId !== routeId) { + return routeOptions + } + + const profileOptions = getProfileModelOptions(activeProfile) + if (profileOptions.length === 0) { + return routeOptions + } + + const routeOptionsByValue = new Map( + routeOptions.flatMap(option => { + const value = + typeof option.value === 'string' ? option.value.trim().toLowerCase() : '' + return value ? [[value, option] as const] : [] + }), + ) + const merged: ModelOption[] = [] + const seen = new Set() + + for (const option of profileOptions) { + const value = typeof option.value === 'string' ? option.value.trim() : '' + const key = value.toLowerCase() + if (!value || seen.has(key)) { + continue + } + + seen.add(key) + merged.push(routeOptionsByValue.get(key) ?? option) + } + + return merged +} + function getActiveRouteId(): string | null { const activeProfile = getActiveProviderProfile() return resolveActiveRouteIdFromEnv(process.env, { @@ -185,15 +237,17 @@ async function loadDescriptorDiscoveryContext( return null } + const routeOptions = buildRouteCatalogModelOptions( + routeLabel, + staticEntries, + routeDefaultModel, + ) + return { kind: 'descriptor', autoRefresh: false, canRefresh, - optionsOverride: buildRouteCatalogModelOptions( - routeLabel, - staticEntries, - routeDefaultModel, - ), + optionsOverride: mergeActiveProfileModelOptions(routeId, routeOptions), routeId, routeDefaultModel, routeLabel, @@ -230,16 +284,18 @@ async function loadDescriptorDiscoveryContext( } } + const routeOptions = buildRouteCatalogModelOptions( + routeLabel, + mergedEntries, + routeDefaultModel, + ) + return { kind: 'descriptor', autoRefresh, canRefresh, discoveryState, - optionsOverride: buildRouteCatalogModelOptions( - routeLabel, - mergedEntries, - routeDefaultModel, - ), + optionsOverride: mergeActiveProfileModelOptions(routeId, routeOptions), routeId, routeDefaultModel, routeLabel, @@ -461,10 +517,13 @@ function ModelPickerWrapper({ }, ) - const nextOptions = buildRouteCatalogModelOptions( - discoveryContext.routeLabel, - result?.models ?? [], - discoveryContext.routeDefaultModel, + const nextOptions = mergeActiveProfileModelOptions( + discoveryContext.routeId, + buildRouteCatalogModelOptions( + discoveryContext.routeLabel, + result?.models ?? [], + discoveryContext.routeDefaultModel, + ), ) const changed = !haveSameModelOptions(optionsOverride ?? [], nextOptions) @@ -734,10 +793,13 @@ async function refreshModelsAndSummarize(): Promise { ...getOpenAIDiscoveryRequestOptions(discoveryContext.routeId), forceRefresh: true, }) - const nextOptions = buildRouteCatalogModelOptions( - discoveryContext.routeLabel, - result?.models ?? [], - discoveryContext.routeDefaultModel, + const nextOptions = mergeActiveProfileModelOptions( + discoveryContext.routeId, + buildRouteCatalogModelOptions( + discoveryContext.routeLabel, + result?.models ?? [], + discoveryContext.routeDefaultModel, + ), ) const changed = !haveSameModelOptions( discoveryContext.optionsOverride,