diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index 11ce81146..5fc1be62a 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -10,9 +10,9 @@ Use `modelProviders` to declare curated model lists per auth type that the `/mod > > Only the `/model` command exposes non-default auth types. Anthropic, Gemini, etc., must be defined via `modelProviders`. The `/auth` command lists Qwen OAuth, Alibaba Cloud Coding Plan, and API Key as the built-in authentication options. -> [!warning] +> [!note] > -> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. +> **Model uniqueness:** Models within the same `authType` are uniquely identified by the combination of `id` + `baseUrl`. This means you can define the same model ID (e.g., `"gpt-4o"`) multiple times under a single `authType` as long as each entry has a different `baseUrl` — for example, one pointing to OpenAI directly and another to a proxy endpoint. If two entries share both the same `id` and the same `baseUrl` (or both omit `baseUrl`), the first occurrence wins and subsequent duplicates are skipped with a warning. ## Configuration Examples by Auth Type diff --git a/packages/cli/src/auth/install/applyProviderInstallPlan.ts b/packages/cli/src/auth/install/applyProviderInstallPlan.ts index 03a9807d4..bea863f4f 100644 --- a/packages/cli/src/auth/install/applyProviderInstallPlan.ts +++ b/packages/cli/src/auth/install/applyProviderInstallPlan.ts @@ -18,6 +18,13 @@ import type { ProviderModelProvidersPatch, } from '../types.js'; +function isSameModelIdentity( + a: { id: string; baseUrl?: string }, + b: { id: string; baseUrl?: string }, +): boolean { + return a.id === b.id && (a.baseUrl ?? '') === (b.baseUrl ?? ''); +} + function applyModelProvidersPatch( existingModelProviders: ModelProvidersConfig, patch: ProviderModelProvidersPatch, @@ -33,7 +40,9 @@ function applyModelProvidersPatch( if (ownsModel) { return !ownsModel(model); } - return !patch.models.some((newModel) => newModel.id === model.id); + return !patch.models.some((newModel) => + isSameModelIdentity(newModel, model), + ); }); updatedModels = diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index f81348a3f..4e7323b6b 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -24,12 +24,15 @@ const DEFAULT_ENV_KEYS: Record = { }; /** - * Find model configuration from modelProviders by authType and modelId + * Find model configuration from modelProviders by authType and modelId. + * When multiple models share the same id (different baseUrls), returns the + * first match. Callers that need an exact match should also compare baseUrl. */ function findModelConfig( modelProviders: ModelProvidersConfig | undefined, authType: string, modelId: string | undefined, + baseUrl?: string, ): ProviderModelConfig | undefined { if (!modelProviders || !modelId) { return undefined; @@ -40,6 +43,9 @@ function findModelConfig( return undefined; } + if (baseUrl) { + return models.find((m) => m.id === modelId && m.baseUrl === baseUrl); + } return models.find((m) => m.id === modelId); } diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 383283d15..5593ab8f0 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -36,6 +36,45 @@ function formatModalities(modalities?: InputModalities): string { return `${t('text')} · ${parts.join(' · ')}`; } +/** + * Build a unique selection key for a model entry in the model dialog. + * When baseUrl is present, it's appended after a \0 separator to ensure + * entries with the same model id but different baseUrls get distinct keys. + */ +function buildModelSelectionKey( + authType: string, + modelId: string, + baseUrl?: string, +): string { + const base = `${authType}::${modelId}`; + return baseUrl ? `${base}\0${baseUrl}` : base; +} + +/** + * Parse a model selection key back into its components. + */ +function parseModelSelectionKey(key: string): { + authType: string; + modelId: string; + baseUrl?: string; +} { + const sep = '::'; + const idx = key.indexOf(sep); + if (idx < 0) return { authType: '', modelId: key }; + + const authType = key.slice(0, idx); + const rest = key.slice(idx + sep.length); + const nullIdx = rest.indexOf('\0'); + if (nullIdx >= 0) { + return { + authType, + modelId: rest.slice(0, nullIdx), + baseUrl: rest.slice(nullIdx + 1), + }; + } + return { authType, modelId: rest }; +} + interface ModelDialogProps { onClose: () => void; isFastModelMode?: boolean; @@ -209,9 +248,10 @@ export function ModelDialog({ () => availableModelEntries.map( ({ authType: t2, model, isRuntime, snapshotId }) => { - // Runtime models use snapshotId directly (format: $runtime|${authType}|${modelId}) const value = - isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + isRuntime && snapshotId + ? snapshotId + : buildModelSelectionKey(t2, model.id, model.baseUrl); const isQwenOAuth = t2 === AuthType.QWEN_OAUTH; @@ -272,10 +312,13 @@ export function ModelDialog({ const activeRuntimeSnapshot = isFastModelMode ? undefined // fast model is never a runtime model : config?.getActiveRuntimeModelSnapshot?.(); + const currentBaseUrl = config + ?.getModelsConfig() + .getGenerationConfig()?.baseUrl; const preferredKey = activeRuntimeSnapshot ? activeRuntimeSnapshot.id : authType - ? `${authType}::${preferredModelId}` + ? buildModelSelectionKey(authType, preferredModelId, currentBaseUrl) : ''; useKeypress( @@ -302,7 +345,10 @@ export function ModelDialog({ const key = highlightedValue ?? preferredKey; return availableModelEntries.find( ({ authType: t2, model, isRuntime, snapshotId }) => { - const v = isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + const v = + isRuntime && snapshotId + ? snapshotId + : buildModelSelectionKey(t2, model.id, model.baseUrl); return v === key; }, ); @@ -312,12 +358,13 @@ export function ModelDialog({ async (selected: string) => { setErrorMessage(null); - // Fast model mode: just save the model ID and close + // Fast model mode: save the model ID only (baseUrl is intentionally + // discarded — getFastModel resolves via the first registry match). if (isFastModelMode) { - // Extract model ID from selection key (format: "authType::modelId" or "$runtime|authType|modelId") let modelId: string; if (selected.includes('::')) { - modelId = selected.split('::').slice(1).join('::'); + const parsed = parseModelSelectionKey(selected); + modelId = parsed.modelId; } else if (selected.startsWith('$runtime|')) { const parts = selected.split('|'); modelId = parts[2] ?? selected; @@ -376,6 +423,7 @@ export function ModelDialog({ let selectedAuthType: AuthType; let modelId: string; + let selectedBaseUrl: string | undefined; if (isRuntime) { // For runtime models, extract authType from the snapshot ID // Format: $runtime|${authType}|${modelId} @@ -387,22 +435,19 @@ export function ModelDialog({ } modelId = selected; // Pass the full snapshot ID to switchModel } else { - const sep = '::'; - const idx = selected.indexOf(sep); - selectedAuthType = ( - idx >= 0 ? selected.slice(0, idx) : authType - ) as AuthType; - modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected; + const parsed = parseModelSelectionKey(selected); + selectedAuthType = (parsed.authType || authType) as AuthType; + modelId = parsed.modelId; + selectedBaseUrl = parsed.baseUrl; } - await config.switchModel( - selectedAuthType, - modelId, - selectedAuthType !== authType && - selectedAuthType === AuthType.QWEN_OAUTH + await config.switchModel(selectedAuthType, modelId, { + ...(selectedAuthType !== authType && + selectedAuthType === AuthType.QWEN_OAUTH ? { requireCachedCredentials: true } - : undefined, - ); + : {}), + baseUrl: selectedBaseUrl, + }); if (!isRuntime) { const event = new ModelSlashCommandEvent(modelId); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 52041246f..e800da9c4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1586,7 +1586,7 @@ export class Config { async switchModel( authType: AuthType, modelId: string, - options?: { requireCachedCredentials?: boolean }, + options?: { requireCachedCredentials?: boolean; baseUrl?: string }, ): Promise { await this.modelsConfig.switchModel(authType, modelId, options); this.notifyModelChangeListeners(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b19563a79..d24046033 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,7 @@ export { type ModelConfigSourcesInput, type ModelConfigValidationResult, ModelRegistry, + modelRegistryKey, type ModelGenerationConfig, ModelsConfig, type ModelsConfigOptions, diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 0a18d64e4..c98e65a47 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -15,7 +15,7 @@ export { type RuntimeModelSnapshot, } from './types.js'; -export { ModelRegistry } from './modelRegistry.js'; +export { ModelRegistry, modelRegistryKey } from './modelRegistry.js'; export { ModelsConfig, diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 9005dd52a..aa9fa5c5e 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { ModelRegistry, QWEN_OAUTH_MODELS } from './modelRegistry.js'; +import { + ModelRegistry, + QWEN_OAUTH_MODELS, + modelRegistryKey, +} from './modelRegistry.js'; import { AuthType } from '../core/contentGenerator.js'; import type { ModelProvidersConfig } from './types.js'; @@ -321,7 +325,7 @@ describe('ModelRegistry', () => { }); describe('duplicate model id handling', () => { - it('should skip duplicate model ids and use first registered config', () => { + it('should skip duplicate model ids (same id, no baseUrl) and use first registered config', () => { const registry = new ModelRegistry({ openai: [ { id: 'gpt-4', name: 'GPT-4 First', description: 'First config' }, @@ -339,6 +343,141 @@ describe('ModelRegistry', () => { expect(gpt4?.description).toBe('First config'); }); + it('should skip duplicate when both id and baseUrl match', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'First', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'Second', + baseUrl: 'https://api.openai.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(1); + expect(models[0].label).toBe('First'); + }); + + it('should allow same id with different baseUrls as distinct models', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + expect(models[0].label).toBe('GPT-4 Direct'); + expect(models[1].label).toBe('GPT-4 Proxy'); + }); + + it('should retrieve model by id and baseUrl precisely', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const direct = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + ); + expect(direct?.name).toBe('GPT-4 Direct'); + + const proxy = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + ); + expect(proxy?.name).toBe('GPT-4 Proxy'); + }); + + it('should return first match when getModel is called without baseUrl', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4'); + expect(model).toBeDefined(); + expect(model?.name).toBe('GPT-4 Direct'); + }); + + it('should handle hasModel with and without baseUrl', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + expect(registry.hasModel(AuthType.USE_OPENAI, 'gpt-4')).toBe(true); + expect( + registry.hasModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + ), + ).toBe(true); + expect( + registry.hasModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + ), + ).toBe(true); + expect( + registry.hasModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://unknown.example.com/v1', + ), + ).toBe(false); + }); + it('should handle multiple duplicate ids in same authType', () => { const registry = new ModelRegistry({ openai: [ @@ -498,6 +637,50 @@ describe('ModelRegistry', () => { expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined(); }); + it('should correctly reload same-id different-baseUrl models', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'Old Direct', + baseUrl: 'https://api.openai.com/v1', + }, + ], + }); + + registry.reloadModels({ + openai: [ + { + id: 'gpt-4', + name: 'New Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'New Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + )?.name, + ).toBe('New Direct'); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + )?.name, + ).toBe('New Proxy'); + }); + it('should handle reload with undefined config', () => { const registry = new ModelRegistry({ openai: [{ id: 'gpt-4', name: 'GPT-4' }], @@ -513,6 +696,57 @@ describe('ModelRegistry', () => { ); }); + it('should handle reload replacing same-id entries when baseUrls change', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 v1', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://old-proxy.example.com/v1', + }, + ], + }); + + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(2); + + registry.reloadModels({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 v1 updated', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 New Proxy', + baseUrl: 'https://new-proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://old-proxy.example.com/v1', + ), + ).toBeUndefined(); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://new-proxy.example.com/v1', + )?.name, + ).toBe('GPT-4 New Proxy'); + }); + it('should apply duplicate model id handling during reload', () => { const registry = new ModelRegistry(); @@ -529,5 +763,67 @@ describe('ModelRegistry', () => { 'Model A First', ); }); + + it('should preserve models with same id but different baseUrls during reload', () => { + const registry = new ModelRegistry(); + + registry.reloadModels({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + + const direct = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + ); + expect(direct?.name).toBe('GPT-4 Direct'); + + const proxy = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + ); + expect(proxy?.name).toBe('GPT-4 Proxy'); + }); + }); +}); + +describe('modelRegistryKey', () => { + it('should return id when no baseUrl is provided', () => { + expect(modelRegistryKey('gpt-4')).toBe('gpt-4'); + expect(modelRegistryKey('gpt-4', undefined)).toBe('gpt-4'); + expect(modelRegistryKey('gpt-4', '')).toBe('gpt-4'); + }); + + it('should return composite key when baseUrl is provided', () => { + const key = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + expect(key).toBe('gpt-4\0https://api.openai.com/v1'); + expect(key).not.toBe('gpt-4'); + }); + + it('should produce different keys for same id with different baseUrls', () => { + const key1 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + const key2 = modelRegistryKey('gpt-4', 'https://proxy.example.com/v1'); + expect(key1).not.toBe(key2); + }); + + it('should produce same key for identical id and baseUrl', () => { + const key1 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + const key2 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + expect(key1).toBe(key2); }); }); diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts index c2815fb32..28f177f7d 100644 --- a/packages/core/src/models/modelRegistry.ts +++ b/packages/core/src/models/modelRegistry.ts @@ -37,6 +37,15 @@ function validateAuthTypeKey(key: string): AuthType | undefined { return undefined; } +/** + * Build a composite registry key from model id and optional baseUrl. + * Two models with the same id but different baseUrls are distinct entries. + * When baseUrl is omitted/empty the key is just the id (backward compatible). + */ +export function modelRegistryKey(id: string, baseUrl?: string): string { + return baseUrl ? `${id}\0${baseUrl}` : id; +} + /** * Central registry for managing model configurations. * Models are organized by authType. @@ -85,7 +94,9 @@ export class ModelRegistry { /** * Register models for an authType. - * If multiple models have the same id, the first one takes precedence. + * Uniqueness is determined by the composite key (id + baseUrl). + * Two models with the same id but different baseUrls are treated as distinct. + * If multiple models share both id and baseUrl, the first one takes precedence. */ private registerAuthTypeModels( authType: AuthType, @@ -94,15 +105,15 @@ export class ModelRegistry { const modelMap = new Map(); for (const config of models) { - // Skip if a model with the same id is already registered (first one wins) - if (modelMap.has(config.id)) { + const key = modelRegistryKey(config.id, config.baseUrl); + if (modelMap.has(key)) { debugLogger.warn( - `Duplicate model id "${config.id}" for authType "${authType}". Using the first registered config.`, + `Duplicate model id "${config.id}"${config.baseUrl ? ` with baseUrl "${config.baseUrl}"` : ''} for authType "${authType}". Using the first registered config.`, ); continue; } const resolved = this.resolveModelConfig(config, authType); - modelMap.set(config.id, resolved); + modelMap.set(key, resolved); } this.modelsByAuthType.set(authType, modelMap); @@ -133,22 +144,41 @@ export class ModelRegistry { } /** - * Get model configuration by authType and modelId + * Get model configuration by authType and modelId. + * When baseUrl is provided, looks up by the exact composite key (id+baseUrl). + * When baseUrl is omitted, tries the plain id first (backward compatible), + * then scans all entries for the first match by model id. */ getModel( authType: AuthType, modelId: string, + baseUrl?: string, ): ResolvedModelConfig | undefined { const models = this.modelsByAuthType.get(authType); - return models?.get(modelId); + if (!models) return undefined; + + if (baseUrl) { + return models.get(modelRegistryKey(modelId, baseUrl)); + } + + // Try plain id key first (models registered without explicit baseUrl) + const plain = models.get(modelId); + if (plain) return plain; + + // Scan for the first entry with matching model id + for (const model of models.values()) { + if (model.id === modelId) return model; + } + return undefined; } /** - * Check if model exists for given authType + * Check if model exists for given authType. + * When baseUrl is provided, checks the exact composite key. + * When baseUrl is omitted, checks plain id and scans by model id. */ - hasModel(authType: AuthType, modelId: string): boolean { - const models = this.modelsByAuthType.get(authType); - return models?.has(modelId) ?? false; + hasModel(authType: AuthType, modelId: string, baseUrl?: string): boolean { + return this.getModel(authType, modelId, baseUrl) !== undefined; } /** diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index d34cc08c6..f82ae8a72 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -372,7 +372,7 @@ export class ModelsConfig { async switchModel( authType: AuthType, modelId: string, - options?: { requireCachedCredentials?: boolean }, + options?: { requireCachedCredentials?: boolean; baseUrl?: string }, ): Promise { // Check if this is a RuntimeModelSnapshot reference const runtimeModelSnapshotId = this.extractRuntimeModelSnapshotId(modelId); @@ -390,7 +390,11 @@ export class ModelsConfig { const isAuthTypeChange = authType !== this.currentAuthType; this.currentAuthType = authType; - const model = this.modelRegistry.getModel(authType, modelId); + const model = this.modelRegistry.getModel( + authType, + modelId, + options?.baseUrl, + ); if (!model) { throw new Error( `Model '${modelId}' not found for authType '${authType}'`, @@ -613,7 +617,7 @@ export class ModelsConfig { } // Check if model exists in registry - if so, don't create RuntimeModelSnapshot - if (this.modelRegistry.hasModel(currentAuthType, model)) { + if (this.modelRegistry.hasModel(currentAuthType, model, baseUrl)) { return; } @@ -826,14 +830,16 @@ export class ModelsConfig { return false; } - // Get previous and current model configs - const previousModel = this.modelRegistry.getModel( - authType, - previousModelId, - ); + // Get previous and current model configs. + // Use current baseUrl to disambiguate when multiple models share the same id. const currentModel = this.modelRegistry.getModel( authType, this._generationConfig.model || '', + this._generationConfig.baseUrl || undefined, + ); + const previousModel = this.modelRegistry.getModel( + authType, + previousModelId, ); // If either model is not in registry, require refresh to be safe @@ -874,57 +880,64 @@ export class ModelsConfig { // Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it), // so if modelId exists in registry, we should always use provider config. // This handles provider switching even within the same authType. - if (modelId && this.modelRegistry.hasModel(authType, modelId)) { - const resolved = this.modelRegistry.getModel(authType, modelId); - if (resolved) { - // When authType and modelId haven't changed (startup/restart scenario), - // the current apiKey was already correctly resolved by - // resolveCliGenerationConfig. Save it so we can restore it if - // applyResolvedModelDefaults clears it (i.e. process.env[envKey] is - // absent). For cross-provider switches (different modelId), we must - // NOT preserve the previous key — it may belong to a different - // service. Also detect hot-reload scenarios where the provider - // config changed in place (same modelId, different envKey/baseUrl) - // by comparing fields that applyResolvedModelDefaults sets. Use - // baseUrl source === 'modelProviders' as the "has been applied" - // signal — it covers both envKey and no-envKey models, and avoids - // false positives when startup baseUrl differs from registry - // default. (See #3417) - const hasBeenApplied = - this.generationConfigSources['baseUrl']?.kind === 'modelProviders'; - const isProviderChanged = - hasBeenApplied && - (this._generationConfig.apiKeyEnvKey !== resolved.envKey || - this._generationConfig.baseUrl !== resolved.baseUrl); - const isUnchanged = - previousAuthType === authType && - this._generationConfig.model === modelId && - !isProviderChanged; - const savedApiKey = isUnchanged - ? this._generationConfig.apiKey - : undefined; - const savedApiKeySource = isUnchanged - ? this.generationConfigSources['apiKey'] - ? { ...this.generationConfigSources['apiKey'] } - : undefined - : undefined; + // Prefer exact match (id+baseUrl) when the current baseUrl was set by a + // model provider switch; fall back to any model with the same id. + const providerBaseUrl = + this.generationConfigSources['baseUrl']?.kind === 'modelProviders' + ? this._generationConfig.baseUrl + : undefined; + const resolved = modelId + ? (this.modelRegistry.getModel(authType, modelId, providerBaseUrl) ?? + this.modelRegistry.getModel(authType, modelId)) + : undefined; + if (resolved) { + // When authType and modelId haven't changed (startup/restart scenario), + // the current apiKey was already correctly resolved by + // resolveCliGenerationConfig. Save it so we can restore it if + // applyResolvedModelDefaults clears it (i.e. process.env[envKey] is + // absent). For cross-provider switches (different modelId), we must + // NOT preserve the previous key — it may belong to a different + // service. Also detect hot-reload scenarios where the provider + // config changed in place (same modelId, different envKey/baseUrl) + // by comparing fields that applyResolvedModelDefaults sets. Use + // baseUrl source === 'modelProviders' as the "has been applied" + // signal — it covers both envKey and no-envKey models, and avoids + // false positives when startup baseUrl differs from registry + // default. (See #3417) + const hasBeenApplied = + this.generationConfigSources['baseUrl']?.kind === 'modelProviders'; + const isProviderChanged = + hasBeenApplied && + (this._generationConfig.apiKeyEnvKey !== resolved.envKey || + this._generationConfig.baseUrl !== resolved.baseUrl); + const isUnchanged = + previousAuthType === authType && + this._generationConfig.model === modelId && + !isProviderChanged; + const savedApiKey = isUnchanged + ? this._generationConfig.apiKey + : undefined; + const savedApiKeySource = isUnchanged + ? this.generationConfigSources['apiKey'] + ? { ...this.generationConfigSources['apiKey'] } + : undefined + : undefined; - this.applyResolvedModelDefaults(resolved); + this.applyResolvedModelDefaults(resolved); - // Restore the previously-resolved apiKey if applyResolvedModelDefaults - // cleared it (env var not found) and this is the same model. - if (isUnchanged && !this._generationConfig.apiKey && savedApiKey) { - this._generationConfig.apiKey = savedApiKey; - if (savedApiKeySource) { - this.generationConfigSources['apiKey'] = savedApiKeySource; - } + // Restore the previously-resolved apiKey if applyResolvedModelDefaults + // cleared it (env var not found) and this is the same model. + if (isUnchanged && !this._generationConfig.apiKey && savedApiKey) { + this._generationConfig.apiKey = savedApiKey; + if (savedApiKeySource) { + this.generationConfigSources['apiKey'] = savedApiKeySource; } - - this.strictModelProviderSelection = true; - // Clear active runtime model snapshot since we're now using a registry model - this.activeRuntimeModelSnapshotId = undefined; - return; } + + this.strictModelProviderSelection = true; + // Clear active runtime model snapshot since we're now using a registry model + this.activeRuntimeModelSnapshotId = undefined; + return; } // Step 2: Check if there are existing credentials from other sources (not modelProviders) @@ -1021,7 +1034,7 @@ export class ModelsConfig { } // Check if model exists in registry - if so, it's not a runtime model - if (this.modelRegistry.hasModel(currentAuthType, currentModel)) { + if (this.modelRegistry.hasModel(currentAuthType, currentModel, baseUrl)) { // Current is a registry model, clear any previous RuntimeModelSnapshot for this authType this.clearRuntimeModelSnapshotForAuthType(currentAuthType); return undefined;