Feat/multi model provider support (#692)
* test: add tests for provider model env updates and multi-model profiles Add comprehensive tests covering: - OPENAI_MODEL/ANTHROPIC_MODEL env updates on provider activation - Cross-provider type switches (openai ↔ anthropic) clearing stale env - Multi-model profile activation using only the first model for env vars - Model options cache population from comma-separated model lists - getProfileModelOptions generating correct ModelOption arrays * feat: multi-model provider support and model auto-switch Support comma-separated model names in provider profiles (e.g. "glm-4.7, glm-4.7-flash"). The first model is used as default on activation; all models appear in the /model picker for easy switching. When switching active providers, the session model now automatically updates to the new provider's first model. The multi-model list is preserved across switches and /model selections. Changes: - Add parseModelList, getPrimaryModel, hasMultipleModels utilities with full test coverage (19 tests) - Use getPrimaryModel when applying profiles to process.env so only the primary model is set in OPENAI_MODEL/ANTHROPIC_MODEL - Update ProviderManager UI to hint at multi-model syntax and show model count in provider list summaries - Populate model options cache from multi-model profiles on activation so all models appear in /model picker regardless of base URL type - Guard persistActiveProviderProfileModel against overwriting comma-separated lists: models already in the profile are session selections, not profile edits - Set AppState.mainLoopModel to the actual model string on provider switch so Anthropic profiles use the configured model instead of falling back to the built-in default * fix: only show profile models when provider profile env is applied Guard the profile model picker options behind a PROFILE_ENV_APPLIED check. getActiveProviderProfile() has a ?? profiles[0] fallback that returns the first profile even when no profile is explicitly active, causing users with inactive profiles to lose all standard model options (Opus, Haiku, etc.) from the /model picker. * fix: show all model names for profiles with 3 or fewer models Instead of a summary format for multi-model profiles, display all model names when there are 3 or fewer. Only use the "+ N more" format for profiles with 4+ models. * fix: preserve standard model options in picker alongside profile models The previous implementation used an early return that replaced all standard picker options (Opus, Haiku, Sonnet for Anthropic; Codex/GPT models for OpenAI) with only the profile's custom models. Changes: - Collect profile models into a shared array instead of early returning - Append profile models to firstParty path (Opus + Haiku + Sonnet + custom) - Append profile models to PAYG 3P path (Codex + Sonnet + Opus + Haiku + custom) - Guard collection behind PROFILE_ENV_APPLIED to avoid ?? profiles[0] fallback Fixes review feedback: standard models are no longer hidden when a provider profile with custom models is active. Users see both the standard options and their profile's models. --------- Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
readCodexCredentialsAsync,
|
readCodexCredentialsAsync,
|
||||||
} from '../utils/codexCredentials.js'
|
} from '../utils/codexCredentials.js'
|
||||||
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
|
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
|
||||||
|
import { getPrimaryModel, hasMultipleModels, parseModelList } from '../utils/providerModels.js'
|
||||||
import {
|
import {
|
||||||
applySavedProfileToCurrentSession,
|
applySavedProfileToCurrentSession,
|
||||||
buildCodexOAuthProfileEnv,
|
buildCodexOAuthProfileEnv,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
import { Pane } from './design-system/Pane.js'
|
import { Pane } from './design-system/Pane.js'
|
||||||
import TextInput from './TextInput.js'
|
import TextInput from './TextInput.js'
|
||||||
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
|
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
|
||||||
|
import { useSetAppState } from '../state/AppState.js'
|
||||||
|
|
||||||
export type ProviderManagerResult = {
|
export type ProviderManagerResult = {
|
||||||
action: 'saved' | 'cancelled'
|
action: 'saved' | 'cancelled'
|
||||||
@@ -108,8 +110,8 @@ const FORM_STEPS: Array<{
|
|||||||
{
|
{
|
||||||
key: 'model',
|
key: 'model',
|
||||||
label: 'Default model',
|
label: 'Default model',
|
||||||
placeholder: 'e.g. llama3.1:8b',
|
placeholder: 'e.g. llama3.1:8b or glm-4.7, glm-4.7-flash',
|
||||||
helpText: 'Model name to use when this provider is active.',
|
helpText: 'Model name(s) to use. Separate multiple with commas; first is default.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'apiKey',
|
key: 'apiKey',
|
||||||
@@ -153,7 +155,12 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
|
|||||||
const keyInfo = profile.apiKey ? 'key set' : 'no key'
|
const keyInfo = profile.apiKey ? 'key set' : 'no key'
|
||||||
const providerKind =
|
const providerKind =
|
||||||
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
|
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
|
||||||
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
|
const models = parseModelList(profile.model)
|
||||||
|
const modelDisplay =
|
||||||
|
models.length <= 3
|
||||||
|
? models.join(', ')
|
||||||
|
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
|
||||||
|
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay} · ${keyInfo}${activeSuffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGithubCredentialSourceFromEnv(
|
function getGithubCredentialSourceFromEnv(
|
||||||
@@ -320,6 +327,7 @@ function CodexOAuthSetup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||||
|
const setAppState = useSetAppState()
|
||||||
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
|
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
|
||||||
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
|
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
|
||||||
@@ -573,6 +581,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
|
||||||
|
}))
|
||||||
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
return
|
return
|
||||||
@@ -585,6 +597,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the session model to the new provider's first model.
|
||||||
|
// persistActiveProviderProfileModel (called by onChangeAppState) will
|
||||||
|
// not overwrite the multi-model list because it checks if the model
|
||||||
|
// is already in the profile's comma-separated model list.
|
||||||
|
const newModel = getPrimaryModel(active.model)
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
mainLoopModel: newModel,
|
||||||
|
}))
|
||||||
|
|
||||||
providerLabel = active.name
|
providerLabel = active.name
|
||||||
const settingsOverrideError =
|
const settingsOverrideError =
|
||||||
clearStartupProviderOverrideFromUserSettings()
|
clearStartupProviderOverrideFromUserSettings()
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ import {
|
|||||||
} from './model.js'
|
} from './model.js'
|
||||||
import { has1mContext } from '../context.js'
|
import { has1mContext } from '../context.js'
|
||||||
import { getGlobalConfig } from '../config.js'
|
import { getGlobalConfig } from '../config.js'
|
||||||
import { getActiveOpenAIModelOptionsCache } from '../providerProfiles.js'
|
import {
|
||||||
|
getActiveOpenAIModelOptionsCache,
|
||||||
|
getActiveProviderProfile,
|
||||||
|
getProfileModelOptions,
|
||||||
|
} from '../providerProfiles.js'
|
||||||
import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js'
|
import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js'
|
||||||
import { getCachedNvidiaNimModelOptions, isNvidiaNimProvider } from './nvidiaNimModels.js'
|
import { getCachedNvidiaNimModelOptions, isNvidiaNimProvider } from './nvidiaNimModels.js'
|
||||||
import { getCachedMiniMaxModelOptions, isMiniMaxProvider } from './minimaxModels.js'
|
import { getCachedMiniMaxModelOptions, isMiniMaxProvider } from './minimaxModels.js'
|
||||||
@@ -476,6 +480,20 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.6 + Opus 1M + Haiku
|
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
|
||||||
if (getAPIProvider() === 'firstParty') {
|
if (getAPIProvider() === 'firstParty') {
|
||||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||||
@@ -491,6 +509,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
payg1POptions.push(getHaiku45Option())
|
payg1POptions.push(getHaiku45Option())
|
||||||
|
payg1POptions.push(...profileModelOptions)
|
||||||
return payg1POptions
|
return payg1POptions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +549,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||||||
} else {
|
} else {
|
||||||
payg3pOptions.push(getHaikuOption())
|
payg3pOptions.push(getHaikuOption())
|
||||||
}
|
}
|
||||||
|
payg3pOptions.push(...profileModelOptions)
|
||||||
return payg3pOptions
|
return payg3pOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
src/utils/providerModels.test.ts
Normal file
108
src/utils/providerModels.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPrimaryModel,
|
||||||
|
hasMultipleModels,
|
||||||
|
parseModelList,
|
||||||
|
} from './providerModels.ts'
|
||||||
|
|
||||||
|
// ── parseModelList ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseModelList', () => {
|
||||||
|
test('splits comma-separated models', () => {
|
||||||
|
expect(parseModelList('glm-4.7, glm-4.7-flash')).toEqual([
|
||||||
|
'glm-4.7',
|
||||||
|
'glm-4.7-flash',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns single model in an array', () => {
|
||||||
|
expect(parseModelList('llama3.1:8b')).toEqual(['llama3.1:8b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trims whitespace around each model', () => {
|
||||||
|
expect(parseModelList(' gpt-4o , gpt-4o-mini , o3-mini ')).toEqual([
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
'o3-mini',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters out empty entries from trailing commas', () => {
|
||||||
|
expect(parseModelList('gpt-4o,,gpt-4o-mini,')).toEqual([
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for empty string', () => {
|
||||||
|
expect(parseModelList('')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for whitespace-only string', () => {
|
||||||
|
expect(parseModelList(' ')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for comma-only string', () => {
|
||||||
|
expect(parseModelList(',,,')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles models with colons', () => {
|
||||||
|
expect(parseModelList('qwen2.5-coder:7b, llama3.1:8b')).toEqual([
|
||||||
|
'qwen2.5-coder:7b',
|
||||||
|
'llama3.1:8b',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getPrimaryModel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getPrimaryModel', () => {
|
||||||
|
test('returns first model from comma-separated list', () => {
|
||||||
|
expect(getPrimaryModel('glm-4.7, glm-4.7-flash')).toBe('glm-4.7')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the only model when single model is provided', () => {
|
||||||
|
expect(getPrimaryModel('llama3.1:8b')).toBe('llama3.1:8b')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the original string when input is empty', () => {
|
||||||
|
expect(getPrimaryModel('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns first model after trimming', () => {
|
||||||
|
expect(getPrimaryModel(' gpt-4o , gpt-4o-mini')).toBe('gpt-4o')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns first model when others are empty from trailing commas', () => {
|
||||||
|
expect(getPrimaryModel('claude-sonnet-4-6,,')).toBe('claude-sonnet-4-6')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── hasMultipleModels ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('hasMultipleModels', () => {
|
||||||
|
test('returns true when multiple models are present', () => {
|
||||||
|
expect(hasMultipleModels('glm-4.7, glm-4.7-flash')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for a single model', () => {
|
||||||
|
expect(hasMultipleModels('llama3.1:8b')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for empty string', () => {
|
||||||
|
expect(hasMultipleModels('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for whitespace-only string', () => {
|
||||||
|
expect(hasMultipleModels(' ')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false when extra commas produce no extra models', () => {
|
||||||
|
expect(hasMultipleModels('gpt-4o,,')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for three models', () => {
|
||||||
|
expect(hasMultipleModels('a, b, c')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
33
src/utils/providerModels.ts
Normal file
33
src/utils/providerModels.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for parsing comma-separated model names in provider profiles.
|
||||||
|
*
|
||||||
|
* Example: "glm-4.7, glm-4.7-flash" -> ["glm-4.7", "glm-4.7-flash"]
|
||||||
|
* Single model: "llama3.1:8b" -> ["llama3.1:8b"]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a comma-separated model field into an array of trimmed model names,
|
||||||
|
* filtering out any empty entries.
|
||||||
|
*/
|
||||||
|
export function parseModelList(modelField: string): string[] {
|
||||||
|
return modelField
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first (primary) model from a comma-separated model field.
|
||||||
|
* Falls back to the original string if parsing yields no results.
|
||||||
|
*/
|
||||||
|
export function getPrimaryModel(modelField: string): string {
|
||||||
|
const models = parseModelList(modelField)
|
||||||
|
return models.length > 0 ? models[0] : modelField
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the model field contains more than one model.
|
||||||
|
*/
|
||||||
|
export function hasMultipleModels(modelField: string): boolean {
|
||||||
|
return parseModelList(modelField).length > 1
|
||||||
|
}
|
||||||
@@ -139,6 +139,39 @@ describe('applyProviderProfileToProcessEnv', () => {
|
|||||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||||
expect(getFreshAPIProvider()).toBe('firstParty')
|
expect(getFreshAPIProvider()).toBe('firstParty')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('openai profile with multi-model string sets only first model in OPENAI_MODEL', async () => {
|
||||||
|
const { applyProviderProfileToProcessEnv } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
applyProviderProfileToProcessEnv(
|
||||||
|
buildProfile({
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'glm-4.7, glm-4.7-flash, glm-4.7-plus',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('glm-4.7')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('anthropic profile with multi-model string sets only first model in ANTHROPIC_MODEL', async () => {
|
||||||
|
const { applyProviderProfileToProcessEnv } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
applyProviderProfileToProcessEnv(
|
||||||
|
buildProfile({
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6, claude-opus-4-6',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('applyActiveProviderProfileFromConfig', () => {
|
describe('applyActiveProviderProfileFromConfig', () => {
|
||||||
@@ -361,6 +394,169 @@ describe('getProviderPresetDefaults', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('setActiveProviderProfile', () => {
|
||||||
|
test('sets OPENAI_MODEL env var when switching to an openai-type provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const openaiProfile = buildProfile({
|
||||||
|
id: 'openai_prof',
|
||||||
|
name: 'OpenAI Provider',
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [openaiProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = setActiveProviderProfile('openai_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('openai_prof')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'openai_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sets ANTHROPIC_MODEL env var when switching to an anthropic-type provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const anthropicProfile = buildProfile({
|
||||||
|
id: 'anthro_prof',
|
||||||
|
name: 'Anthropic Provider',
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [anthropicProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = setActiveProviderProfile('anthro_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('anthro_prof')
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_MODEL).toBeUndefined()
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'anthro_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clears openai model env and sets anthropic model env when switching from openai to anthropic provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const openaiProfile = buildProfile({
|
||||||
|
id: 'openai_prof',
|
||||||
|
name: 'OpenAI Provider',
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'sk-openai-key',
|
||||||
|
})
|
||||||
|
const anthropicProfile = buildProfile({
|
||||||
|
id: 'anthro_prof',
|
||||||
|
name: 'Anthropic Provider',
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
apiKey: 'sk-ant-key',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [openaiProfile, anthropicProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// First activate the openai profile
|
||||||
|
setActiveProviderProfile('openai_prof')
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
|
||||||
|
// Now switch to the anthropic profile
|
||||||
|
const result = setActiveProviderProfile('anthro_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('anthro_prof')
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_MODEL).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'anthro_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clears anthropic model env and sets openai model env when switching from anthropic to openai provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const anthropicProfile = buildProfile({
|
||||||
|
id: 'anthro_prof',
|
||||||
|
name: 'Anthropic Provider',
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
apiKey: 'sk-ant-key',
|
||||||
|
})
|
||||||
|
const openaiProfile = buildProfile({
|
||||||
|
id: 'openai_prof',
|
||||||
|
name: 'OpenAI Provider',
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'sk-openai-key',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [anthropicProfile, openaiProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// First activate the anthropic profile
|
||||||
|
setActiveProviderProfile('anthro_prof')
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
|
||||||
|
// Now switch to the openai profile
|
||||||
|
const result = setActiveProviderProfile('openai_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('openai_prof')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||||
|
// ANTHROPIC_MODEL is set to the profile model for all provider types
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('gpt-4o')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||||
|
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'openai_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for non-existent profile id', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const openaiProfile = buildProfile({ id: 'existing_prof' })
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [openaiProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = setActiveProviderProfile('nonexistent_prof')
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('deleteProviderProfile', () => {
|
describe('deleteProviderProfile', () => {
|
||||||
test('deleting final profile clears provider env when active profile applied it', async () => {
|
test('deleting final profile clears provider env when active profile applied it', async () => {
|
||||||
const {
|
const {
|
||||||
@@ -429,3 +625,82 @@ describe('deleteProviderProfile', () => {
|
|||||||
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getProfileModelOptions', () => {
|
||||||
|
test('generates options for multi-model profile', async () => {
|
||||||
|
const { getProfileModelOptions } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
const options = getProfileModelOptions(
|
||||||
|
buildProfile({
|
||||||
|
name: 'Test Provider',
|
||||||
|
model: 'glm-4.7, glm-4.7-flash, glm-4.7-plus',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ value: 'glm-4.7', label: 'glm-4.7', description: 'Provider: Test Provider' },
|
||||||
|
{ value: 'glm-4.7-flash', label: 'glm-4.7-flash', description: 'Provider: Test Provider' },
|
||||||
|
{ value: 'glm-4.7-plus', label: 'glm-4.7-plus', description: 'Provider: Test Provider' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns single option for single-model profile', async () => {
|
||||||
|
const { getProfileModelOptions } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
const options = getProfileModelOptions(
|
||||||
|
buildProfile({
|
||||||
|
name: 'Single Model',
|
||||||
|
model: 'llama3.1:8b',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ value: 'llama3.1:8b', label: 'llama3.1:8b', description: 'Provider: Single Model' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for empty model field', async () => {
|
||||||
|
const { getProfileModelOptions } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
const options = getProfileModelOptions(
|
||||||
|
buildProfile({
|
||||||
|
name: 'Empty',
|
||||||
|
model: '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(options).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setActiveProviderProfile model cache', () => {
|
||||||
|
test('populates model cache with all models from multi-model profile on activation', async () => {
|
||||||
|
const {
|
||||||
|
setActiveProviderProfile,
|
||||||
|
getActiveOpenAIModelOptionsCache,
|
||||||
|
} = await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
mockConfigState = {
|
||||||
|
...createMockConfigState(),
|
||||||
|
providerProfiles: [
|
||||||
|
buildProfile({
|
||||||
|
id: 'multi_provider',
|
||||||
|
name: 'Multi Provider',
|
||||||
|
model: 'glm-4.7, glm-4.7-flash, glm-4.7-plus',
|
||||||
|
baseUrl: 'https://api.example.com/v1',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveProviderProfile('multi_provider')
|
||||||
|
|
||||||
|
const cache = getActiveOpenAIModelOptionsCache()
|
||||||
|
const cacheValues = cache.map(opt => opt.value)
|
||||||
|
expect(cacheValues).toContain('glm-4.7')
|
||||||
|
expect(cacheValues).toContain('glm-4.7-flash')
|
||||||
|
expect(cacheValues).toContain('glm-4.7-plus')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type ProviderProfile,
|
type ProviderProfile,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
import type { ModelOption } from './model/modelOptions.js'
|
import type { ModelOption } from './model/modelOptions.js'
|
||||||
|
import { getPrimaryModel, parseModelList } from './providerModels.js'
|
||||||
|
|
||||||
export type ProviderPreset =
|
export type ProviderPreset =
|
||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
@@ -331,7 +332,7 @@ function isProcessEnvAlignedWithProfile(
|
|||||||
return (
|
return (
|
||||||
!hasProviderSelectionFlags(processEnv) &&
|
!hasProviderSelectionFlags(processEnv) &&
|
||||||
sameOptionalEnvValue(processEnv.ANTHROPIC_BASE_URL, profile.baseUrl) &&
|
sameOptionalEnvValue(processEnv.ANTHROPIC_BASE_URL, profile.baseUrl) &&
|
||||||
sameOptionalEnvValue(processEnv.ANTHROPIC_MODEL, profile.model) &&
|
sameOptionalEnvValue(processEnv.ANTHROPIC_MODEL, getPrimaryModel(profile.model)) &&
|
||||||
(!includeApiKey ||
|
(!includeApiKey ||
|
||||||
sameOptionalEnvValue(processEnv.ANTHROPIC_API_KEY, profile.apiKey))
|
sameOptionalEnvValue(processEnv.ANTHROPIC_API_KEY, profile.apiKey))
|
||||||
)
|
)
|
||||||
@@ -346,7 +347,7 @@ function isProcessEnvAlignedWithProfile(
|
|||||||
processEnv.CLAUDE_CODE_USE_VERTEX === undefined &&
|
processEnv.CLAUDE_CODE_USE_VERTEX === undefined &&
|
||||||
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
|
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_BASE_URL, profile.baseUrl) &&
|
sameOptionalEnvValue(processEnv.OPENAI_BASE_URL, profile.baseUrl) &&
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_MODEL, profile.model) &&
|
sameOptionalEnvValue(processEnv.OPENAI_MODEL, getPrimaryModel(profile.model)) &&
|
||||||
(!includeApiKey ||
|
(!includeApiKey ||
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey))
|
sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey))
|
||||||
)
|
)
|
||||||
@@ -397,7 +398,7 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
|
|||||||
process.env[PROFILE_ENV_APPLIED_FLAG] = '1'
|
process.env[PROFILE_ENV_APPLIED_FLAG] = '1'
|
||||||
process.env[PROFILE_ENV_APPLIED_ID] = profile.id
|
process.env[PROFILE_ENV_APPLIED_ID] = profile.id
|
||||||
|
|
||||||
process.env.ANTHROPIC_MODEL = profile.model
|
process.env.ANTHROPIC_MODEL = getPrimaryModel(profile.model)
|
||||||
if (profile.provider === 'anthropic') {
|
if (profile.provider === 'anthropic') {
|
||||||
process.env.ANTHROPIC_BASE_URL = profile.baseUrl
|
process.env.ANTHROPIC_BASE_URL = profile.baseUrl
|
||||||
|
|
||||||
@@ -416,7 +417,7 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
|
|||||||
|
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = profile.baseUrl
|
process.env.OPENAI_BASE_URL = profile.baseUrl
|
||||||
process.env.OPENAI_MODEL = profile.model
|
process.env.OPENAI_MODEL = getPrimaryModel(profile.model)
|
||||||
|
|
||||||
if (profile.apiKey) {
|
if (profile.apiKey) {
|
||||||
process.env.OPENAI_API_KEY = profile.apiKey
|
process.env.OPENAI_API_KEY = profile.apiKey
|
||||||
@@ -581,6 +582,16 @@ export function persistActiveProviderProfileModel(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the model is already part of the profile's model list, don't
|
||||||
|
// overwrite the field. This preserves comma-separated model lists like
|
||||||
|
// "glm-4.5, glm-4.7". Switching between models in the list is a
|
||||||
|
// session-level choice handled by mainLoopModelOverride, not a profile
|
||||||
|
// edit — the profile's model list should only change via explicit edit.
|
||||||
|
const existingModels = parseModelList(activeProfile.model)
|
||||||
|
if (existingModels.includes(nextModel)) {
|
||||||
|
return activeProfile
|
||||||
|
}
|
||||||
|
|
||||||
saveGlobalConfig(current => {
|
saveGlobalConfig(current => {
|
||||||
const currentProfiles = getProviderProfiles(current)
|
const currentProfiles = getProviderProfiles(current)
|
||||||
const profileIndex = currentProfiles.findIndex(
|
const profileIndex = currentProfiles.findIndex(
|
||||||
@@ -623,6 +634,23 @@ export function persistActiveProviderProfileModel(
|
|||||||
return resolvedProfile
|
return resolvedProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate model options from a provider profile's model field.
|
||||||
|
* Each comma-separated model becomes a separate option in the picker.
|
||||||
|
*/
|
||||||
|
export function getProfileModelOptions(profile: ProviderProfile): ModelOption[] {
|
||||||
|
const models = parseModelList(profile.model)
|
||||||
|
if (models.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.map(model => ({
|
||||||
|
value: model,
|
||||||
|
label: model,
|
||||||
|
description: `Provider: ${profile.name}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function setActiveProviderProfile(
|
export function setActiveProviderProfile(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
): ProviderProfile | null {
|
): ProviderProfile | null {
|
||||||
@@ -634,10 +662,20 @@ export function setActiveProviderProfile(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileModelOptions = getProfileModelOptions(activeProfile)
|
||||||
|
|
||||||
saveGlobalConfig(config => ({
|
saveGlobalConfig(config => ({
|
||||||
...config,
|
...config,
|
||||||
activeProviderProfileId: profileId,
|
activeProviderProfileId: profileId,
|
||||||
openaiAdditionalModelOptionsCache: getModelCacheByProfile(profileId, config),
|
openaiAdditionalModelOptionsCache: profileModelOptions.length > 0
|
||||||
|
? profileModelOptions
|
||||||
|
: getModelCacheByProfile(profileId, config),
|
||||||
|
openaiAdditionalModelOptionsCacheByProfile: {
|
||||||
|
...(config.openaiAdditionalModelOptionsCacheByProfile ?? {}),
|
||||||
|
[profileId]: profileModelOptions.length > 0
|
||||||
|
? profileModelOptions
|
||||||
|
: (config.openaiAdditionalModelOptionsCacheByProfile?.[profileId] ?? []),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
applyProviderProfileToProcessEnv(activeProfile)
|
applyProviderProfileToProcessEnv(activeProfile)
|
||||||
|
|||||||
Reference in New Issue
Block a user