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:
@@ -139,6 +139,39 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
test('deleting final profile clears provider env when active profile applied it', async () => {
|
||||
const {
|
||||
@@ -429,3 +625,82 @@ describe('deleteProviderProfile', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user