fix(core): use id+baseUrl composite key for model identity

Custom provider installs previously used model id alone to determine
ownership, causing the second install to remove the first backend's
model entry when both expose the same model id (e.g. gpt-4o) with
different baseUrls. Use id+baseUrl as the composite identity key
throughout the model registry, ModelDialog, and modelsConfig to
prevent cross-provider model collisions.
This commit is contained in:
pomelo-nwu 2026-05-07 22:44:49 +08:00
parent 679175c3ab
commit 4c4ebb81ca
10 changed files with 495 additions and 95 deletions

View file

@ -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

View file

@ -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 =

View file

@ -24,12 +24,15 @@ const DEFAULT_ENV_KEYS: Record<string, string> = {
};
/**
* 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);
}

View file

@ -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);

View file

@ -1586,7 +1586,7 @@ export class Config {
async switchModel(
authType: AuthType,
modelId: string,
options?: { requireCachedCredentials?: boolean },
options?: { requireCachedCredentials?: boolean; baseUrl?: string },
): Promise<void> {
await this.modelsConfig.switchModel(authType, modelId, options);
this.notifyModelChangeListeners();

View file

@ -32,6 +32,7 @@ export {
type ModelConfigSourcesInput,
type ModelConfigValidationResult,
ModelRegistry,
modelRegistryKey,
type ModelGenerationConfig,
ModelsConfig,
type ModelsConfigOptions,

View file

@ -15,7 +15,7 @@ export {
type RuntimeModelSnapshot,
} from './types.js';
export { ModelRegistry } from './modelRegistry.js';
export { ModelRegistry, modelRegistryKey } from './modelRegistry.js';
export {
ModelsConfig,

View file

@ -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);
});
});

View file

@ -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<string, ResolvedModelConfig>();
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;
}
/**

View file

@ -372,7 +372,7 @@ export class ModelsConfig {
async switchModel(
authType: AuthType,
modelId: string,
options?: { requireCachedCredentials?: boolean },
options?: { requireCachedCredentials?: boolean; baseUrl?: string },
): Promise<void> {
// 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;