diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 68a936c0e..2a3bd222c 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -291,10 +291,6 @@ class Connection { ).toResult(); } - if (details?.includes('/auth')) { - return RequestError.authRequired(details).toResult(); - } - return RequestError.internalError(details).toResult(); } } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9a2d2555e..1e310356f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -33,10 +33,7 @@ import { loadCliConfig } from '../config/config.js'; // Import the modular Session class import { Session } from './session/Session.js'; -import { - formatAcpModelId, - parseAcpBaseModelId, -} from '../utils/acpModelUtils.js'; +import { formatAcpModelId } from '../utils/acpModelUtils.js'; export async function runAcpAgent( config: Config, @@ -413,22 +410,35 @@ class GeminiAgent { const currentAuthType = config.getAuthType(); const allConfiguredModels = config.getAllConfiguredModels(); - const baseCurrentModelId = parseAcpBaseModelId(rawCurrentModelId); - const currentModelId = this.formatCurrentModelId( - baseCurrentModelId, - currentAuthType, - ); + // Check if current model is a runtime model + // Runtime models use $runtime|${authType}|${modelId} format + const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); + const currentModelId = activeRuntimeSnapshot + ? formatAcpModelId( + activeRuntimeSnapshot.id, + activeRuntimeSnapshot.authType, + ) + : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); const availableModels = allConfiguredModels; - const mappedAvailableModels = availableModels.map((model) => ({ - modelId: formatAcpModelId(model.id, model.authType), - name: model.label, - description: model.description ?? null, - _meta: { - contextLimit: model.contextWindowSize ?? tokenLimit(model.id), - }, - })); + const mappedAvailableModels = availableModels.map((model) => { + // For runtime models, use runtimeSnapshotId as modelId for ACP protocol + // This allows ACP clients to correctly identify and switch to runtime models + const effectiveModelId = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : model.id; + + return { + modelId: formatAcpModelId(effectiveModelId, model.authType), + name: model.label, + description: model.description ?? null, + _meta: { + contextLimit: model.contextWindowSize ?? tokenLimit(model.id), + }, + }; + }); return { currentModelId, diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 401d459cf..e33101df5 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -124,10 +124,6 @@ describe('Session', () => { AuthType.USE_OPENAI, 'qwen3-coder-plus', undefined, - { - reason: 'user_request_acp', - context: 'session/set_model', - }, ); expect(mockConfig.getModel).toHaveBeenCalled(); expect(result).toEqual({ diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 7f16f8ecb..bd596b878 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -383,10 +383,6 @@ export class Session implements SessionContext { selectedAuthType === AuthType.QWEN_OAUTH ? { requireCachedCredentials: true } : undefined, - { - reason: 'user_request_acp', - context: 'session/set_model', - }, ); // Get updated model info diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index cd1476556..1306456c9 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -182,10 +182,6 @@ describe('', () => { AuthType.QWEN_OAUTH, MAINLINE_CODER, undefined, - { - reason: 'user_manual', - context: 'Model switched via /model dialog', - }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -242,10 +238,6 @@ describe('', () => { AuthType.QWEN_OAUTH, MAINLINE_CODER, { requireCachedCredentials: true }, - { - reason: 'user_manual', - context: 'AuthType+model switched via /model dialog', - }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index f57abbd84..8c102890f 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -20,7 +20,7 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; -import { UIStateContext } from '../contexts/UIStateContext.js'; +import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { MAINLINE_CODER } from '../models/availableModels.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; @@ -103,6 +103,46 @@ function persistAuthTypeSelection( settings.setValue(scope, 'security.auth.selectedType', authType); } +interface HandleModelSwitchSuccessParams { + settings: ReturnType; + uiState: UIState | null; + after: ContentGeneratorConfig | undefined; + effectiveAuthType: AuthType | undefined; + effectiveModelId: string; + isRuntime: boolean; +} + +function handleModelSwitchSuccess({ + settings, + uiState, + after, + effectiveAuthType, + effectiveModelId, + isRuntime, +}: HandleModelSwitchSuccessParams): void { + persistModelSelection(settings, effectiveModelId); + if (effectiveAuthType) { + persistAuthTypeSelection(settings, effectiveAuthType); + } + + const baseUrl = after?.baseUrl ?? t('(default)'); + const maskedKey = maskApiKey(after?.apiKey); + uiState?.historyManager.addItem( + { + type: 'info', + text: + `authType: ${effectiveAuthType ?? '(none)'}` + + `\n` + + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` + + `\n` + + `Base URL: ${baseUrl}` + + `\n` + + `API key: ${maskedKey}`, + }, + Date.now(), + ); +} + function ConfigRow({ label, value, @@ -154,9 +194,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const availableModelEntries = useMemo(() => { const allModels = config ? config.getAllConfiguredModels() : []; - // Group models by authType + // Separate runtime models from registry models + const runtimeModels = allModels.filter((m) => m.isRuntimeModel); + const registryModels = allModels.filter((m) => !m.isRuntimeModel); + + // Group registry models by authType const modelsByAuthTypeMap = new Map(); - for (const model of allModels) { + for (const model of registryModels) { const authType = model.authType; if (!modelsByAuthTypeMap.has(authType)) { modelsByAuthTypeMap.set(authType, []); @@ -173,43 +217,91 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { AuthType.USE_VERTEX_AI, ]; - // Filter to only include authTypes that have models and maintain order + // Filter to only include authTypes that have registry models and maintain order const availableAuthTypes = new Set(modelsByAuthTypeMap.keys()); const orderedAuthTypes = authTypeOrder.filter((t) => availableAuthTypes.has(t), ); - return orderedAuthTypes.flatMap((t) => { - const models = modelsByAuthTypeMap.get(t) ?? []; - return models.map((m) => ({ authType: t, model: m })); - }); + // Build ordered list: runtime models first, then registry models grouped by authType + const result: Array<{ + authType: AuthType; + model: CoreAvailableModel; + isRuntime?: boolean; + snapshotId?: string; + }> = []; + + // Add all runtime models first + for (const runtimeModel of runtimeModels) { + result.push({ + authType: runtimeModel.authType, + model: runtimeModel, + isRuntime: true, + snapshotId: runtimeModel.runtimeSnapshotId, + }); + } + + // Add registry models grouped by authType + for (const t of orderedAuthTypes) { + for (const model of modelsByAuthTypeMap.get(t) ?? []) { + result.push({ authType: t, model, isRuntime: false }); + } + } + + return result; }, [config]); const MODEL_OPTIONS = useMemo( () => - availableModelEntries.map(({ authType: t2, model }) => { - const value = `${t2}::${model.id}`; - const title = ( - - - [{t2}] + availableModelEntries.map( + ({ authType: t2, model, isRuntime, snapshotId }) => { + // Runtime models use snapshotId directly (format: $runtime|${authType}|${modelId}) + const value = + isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + + const title = ( + + + [{t2}] + + {` ${model.label}`} + {isRuntime && ( + (Runtime) + )} - {` ${model.label}`} - - ); - const description = model.description || ''; - return { - value, - title, - description, - key: value, - }; - }), + ); + + // Include runtime indicator in description + let description = model.description || ''; + if (isRuntime) { + description = description + ? `${description} (Runtime)` + : 'Runtime model'; + } + + return { + value, + title, + description, + key: value, + }; + }, + ), [availableModelEntries], ); const preferredModelId = config?.getModel() || MAINLINE_CODER; - const preferredKey = authType ? `${authType}::${preferredModelId}` : ''; + // Check if current model is a runtime model + // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format + const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.(); + const preferredKey = activeRuntimeSnapshot + ? activeRuntimeSnapshot.id + : authType + ? `${authType}::${preferredModelId}` + : ''; useKeypress( (key) => { @@ -229,67 +321,81 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const handleSelect = useCallback( async (selected: string) => { - // Clear any previous error setErrorMessage(null); - const sep = '::'; - const idx = selected.indexOf(sep); - const selectedAuthType = ( - idx >= 0 ? selected.slice(0, idx) : authType - ) as AuthType; - const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected; + let after: ContentGeneratorConfig | undefined; + let effectiveAuthType: AuthType | undefined; + let effectiveModelId = selected; + let isRuntime = false; - if (config) { - try { - await config.switchModel( - selectedAuthType, - modelId, - selectedAuthType !== authType && - selectedAuthType === AuthType.QWEN_OAUTH - ? { requireCachedCredentials: true } - : undefined, - { - reason: 'user_manual', - context: - selectedAuthType === authType - ? 'Model switched via /model dialog' - : 'AuthType+model switched via /model dialog', - }, - ); - } catch (e) { - const baseErrorMessage = e instanceof Error ? e.message : String(e); - setErrorMessage( - `Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`, - ); - return; + if (!config) { + onClose(); + return; + } + + try { + // Determine if this is a runtime model selection + // Runtime model format: $runtime|${authType}|${modelId} + isRuntime = selected.startsWith('$runtime|'); + + let selectedAuthType: AuthType; + let modelId: string; + + if (isRuntime) { + // For runtime models, extract authType from the snapshot ID + // Format: $runtime|${authType}|${modelId} + const parts = selected.split('|'); + if (parts.length >= 2 && parts[0] === '$runtime') { + selectedAuthType = parts[1] as AuthType; + } else { + selectedAuthType = authType as AuthType; + } + 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 event = new ModelSlashCommandEvent(modelId); - logModelSlashCommand(config, event); - const after = config.getContentGeneratorConfig?.() as + await config.switchModel( + selectedAuthType, + modelId, + selectedAuthType !== authType && + selectedAuthType === AuthType.QWEN_OAUTH + ? { requireCachedCredentials: true } + : undefined, + ); + + if (!isRuntime) { + const event = new ModelSlashCommandEvent(modelId); + logModelSlashCommand(config, event); + } + + after = config.getContentGeneratorConfig?.() as | ContentGeneratorConfig | undefined; - const effectiveAuthType = - after?.authType ?? selectedAuthType ?? authType; - const effectiveModelId = after?.model ?? modelId; - - persistModelSelection(settings, effectiveModelId); - persistAuthTypeSelection(settings, effectiveAuthType); - - const baseUrl = after?.baseUrl ?? t('(default)'); - const maskedKey = maskApiKey(after?.apiKey); - uiState?.historyManager.addItem( - { - type: 'info', - text: - `authType: ${effectiveAuthType}\n` + - `Using model: ${effectiveModelId}\n` + - `Base URL: ${baseUrl}\n` + - `API key: ${maskedKey}`, - }, - Date.now(), - ); + effectiveAuthType = after?.authType ?? selectedAuthType ?? authType; + effectiveModelId = after?.model ?? modelId; + } catch (e) { + const baseErrorMessage = e instanceof Error ? e.message : String(e); + const errorPrefix = isRuntime + ? 'Failed to switch to runtime model.' + : `Failed to switch model to '${effectiveModelId ?? selected}'.`; + setErrorMessage(`${errorPrefix}\n\n${baseErrorMessage}`); + return; } + + handleModelSwitchSuccess({ + settings, + uiState, + after, + effectiveAuthType, + effectiveModelId, + isRuntime, + }); onClose(); }, [authType, config, onClose, settings, uiState, setErrorMessage], diff --git a/packages/cli/src/utils/acpModelUtils.ts b/packages/cli/src/utils/acpModelUtils.ts index 039ae758b..1def62533 100644 --- a/packages/cli/src/utils/acpModelUtils.ts +++ b/packages/cli/src/utils/acpModelUtils.ts @@ -33,6 +33,12 @@ export function parseAcpBaseModelId(value: string): string { /** * Parses an ACP model option string into `{ modelId, authType? }`. * + * Supports the following formats: + * - `${modelId}(${authType})` - Standard registry model (e.g., "gpt-4(USE_OPENAI)") + * - `${snapshotId}(${authType})` - Runtime model snapshot (e.g., "$runtime|USE_OPENAI|gpt-4(USE_OPENAI)") + * where snapshotId is in format `$runtime|${authType}|${modelId}` + * - Plain model ID - Returns as-is with no authType + * * If the string ends with `(...)` and `...` is a valid `AuthType`, returns both; * otherwise returns the trimmed input as `modelId` only. */ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d33d70ad2..761238c7e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -112,6 +112,7 @@ import { ModelsConfig, type ModelProvidersConfig, type AvailableModel, + type RuntimeModelSnapshot, } from '../models/index.js'; import type { ClaudeMarketplaceConfig } from '../extension/claude-converter.js'; @@ -708,6 +709,9 @@ export class Config { await this.geminiClient.initialize(); + // Detect and capture runtime model snapshot (from CLI/ENV/credentials) + this.modelsConfig.detectAndCaptureRuntimeModel(); + logStartSession(this, new StartSessionEvent(this)); } @@ -970,26 +974,35 @@ export class Config { * Delegates to ModelsConfig. */ getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] { - return this._modelsConfig.getAllConfiguredModels(authTypes); + return this.modelsConfig.getAllConfiguredModels(authTypes); } /** - * Switch authType+model via registry-backed selection. + * Get the currently active runtime model snapshot. + * Delegates to ModelsConfig. + */ + getActiveRuntimeModelSnapshot(): RuntimeModelSnapshot | undefined { + return this.modelsConfig.getActiveRuntimeModelSnapshot(); + } + + /** + * Switch authType+model. + * Supports both registry-backed models and runtime model snapshots. + * + * For runtime models, the modelId should be in format `$runtime|${authType}|${modelId}`. * This triggers a refresh of the ContentGenerator when required (always on authType changes). * For qwen-oauth model switches that are hot-update safe, this may update in place. * * @param authType - Target authentication type - * @param modelId - Target model ID + * @param modelId - Target model ID (or `$runtime|${authType}|${modelId}` for runtime models) * @param options - Additional options like requireCachedCredentials - * @param metadata - Metadata for logging/tracking */ async switchModel( authType: AuthType, modelId: string, options?: { requireCachedCredentials?: boolean }, - metadata?: { reason?: string; context?: string }, ): Promise { - await this.modelsConfig.switchModel(authType, modelId, options, metadata); + await this.modelsConfig.switchModel(authType, modelId, options); } getMaxSessionTurns(): number { diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 7525074a5..0a18d64e4 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -12,6 +12,7 @@ export { type ResolvedModelConfig, type AvailableModel, type ModelSwitchMetadata, + type RuntimeModelSnapshot, } from './types.js'; export { ModelRegistry } from './modelRegistry.js'; diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 82fb7790e..701386ac8 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -861,4 +861,405 @@ describe('ModelsConfig', () => { ).toBe(true); }); }); + + describe('Runtime Model Snapshot', () => { + it('should detect and capture runtime model from CLI source', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'gpt-4-turbo', + apiKey: 'sk-test-key', + baseUrl: 'https://api.openai.com/v1', + }, + generationConfigSources: { + model: { kind: 'cli', detail: '--model' }, + apiKey: { kind: 'cli', detail: '--openaiApiKey' }, + baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + expect(snapshotId).toBe('$runtime|openai|gpt-4-turbo'); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.id).toBe('$runtime|openai|gpt-4-turbo'); + expect(snapshot?.authType).toBe(AuthType.USE_OPENAI); + expect(snapshot?.modelId).toBe('gpt-4-turbo'); + expect(snapshot?.apiKey).toBe('sk-test-key'); + expect(snapshot?.baseUrl).toBe('https://api.openai.com/v1'); + }); + + it('should detect and capture runtime model from ENV source', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'gpt-4o', + apiKey: 'sk-env-key', + baseUrl: 'https://api.openai.com/v1', + }, + generationConfigSources: { + model: { kind: 'settings', detail: 'settings.model.name' }, + apiKey: { kind: 'env', envKey: 'OPENAI_API_KEY' }, + baseUrl: { kind: 'settings', detail: 'settings.openaiBaseUrl' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + expect(snapshotId).toBe('$runtime|openai|gpt-4o'); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.modelId).toBe('gpt-4o'); + expect(snapshot?.apiKey).toBe('sk-env-key'); + }); + + it('should not capture registry models as runtime', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + model: 'gpt-4-turbo', + apiKey: 'sk-test-key', + baseUrl: 'https://api.openai.com/v1', + }, + generationConfigSources: { + model: { kind: 'cli', detail: '--model' }, + apiKey: { kind: 'cli', detail: '--openaiApiKey' }, + baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + // Should not create snapshot since model exists in registry + expect(snapshotId).toBeUndefined(); + expect(modelsConfig.getActiveRuntimeModelSnapshot()).toBeUndefined(); + }); + + it('should not capture runtime model without valid credentials', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'custom-model', + // Missing apiKey and baseUrl + }, + generationConfigSources: { + model: { kind: 'cli', detail: '--model' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + expect(snapshotId).toBeUndefined(); + }); + + it('should switch to runtime model and apply snapshot configuration', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'runtime-model', + apiKey: 'sk-runtime-key', + baseUrl: 'https://runtime.example.com/v1', + samplingParams: { temperature: 0.7, max_tokens: 2000 }, + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + // Create initial snapshot + const initialSnapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + expect(initialSnapshotId).toBeDefined(); + + // Change to a different state + // Note: this updates the existing snapshot, changing its ID + modelsConfig.updateCredentials({ + model: 'different-model', + apiKey: 'different-key', + baseUrl: 'https://different.example.com/v1', + }); + + // The snapshot ID has changed because we updated the model + const updatedSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId(); + expect(updatedSnapshotId).toBe('$runtime|openai|different-model'); + + // Create a separate snapshot for the original runtime model + // (simulate having multiple runtime models available) + modelsConfig['runtimeModelSnapshots'].set( + '$runtime|openai|runtime-model', + { + id: '$runtime|openai|runtime-model', + authType: AuthType.USE_OPENAI, + modelId: 'runtime-model', + apiKey: 'sk-runtime-key', + baseUrl: 'https://runtime.example.com/v1', + generationConfig: { + samplingParams: { temperature: 0.7, max_tokens: 2000 }, + }, + sources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + createdAt: Date.now(), + }, + ); + + // Switch back to original runtime model + await modelsConfig.switchToRuntimeModel('$runtime|openai|runtime-model'); + + const gc = currentGenerationConfig(modelsConfig); + expect(gc.model).toBe('runtime-model'); + expect(gc.apiKey).toBe('sk-runtime-key'); + expect(gc.baseUrl).toBe('https://runtime.example.com/v1'); + expect(gc.samplingParams?.temperature).toBe(0.7); + expect(gc.samplingParams?.max_tokens).toBe(2000); + }); + + it('should throw error when switching to non-existent runtime snapshot', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + await expect( + modelsConfig.switchToRuntimeModel('$runtime|openai|nonexistent'), + ).rejects.toThrow( + "Runtime model snapshot '$runtime|openai|nonexistent' not found", + ); + }); + + it('should return runtime option first in getAllConfiguredModels', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'registry-model', + name: 'Registry Model', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + model: 'runtime-model', + apiKey: 'sk-test-key', + baseUrl: 'https://runtime.example.com/v1', + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + modelsConfig.detectAndCaptureRuntimeModel(); + + const allModels = modelsConfig.getAllConfiguredModels(); + + // Runtime model should be first for USE_OPENAI + const openaiModels = allModels.filter( + (m) => m.authType === AuthType.USE_OPENAI, + ); + expect(openaiModels.length).toBe(2); + expect(openaiModels[0].isRuntimeModel).toBe(true); + // AvailableModel.id should be modelId, runtimeSnapshotId should be snapshot.id + expect(openaiModels[0].id).toBe('runtime-model'); + expect(openaiModels[0].runtimeSnapshotId).toBe( + '$runtime|openai|runtime-model', + ); + expect(openaiModels[0].label).toBe('runtime-model'); + expect(openaiModels[1].isRuntimeModel).toBeUndefined(); + expect(openaiModels[1].id).toBe('registry-model'); + }); + + it('should create/update runtime snapshot via updateCredentials', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + // Update with complete credentials + modelsConfig.updateCredentials({ + model: 'custom-model', + apiKey: 'sk-custom-key', + baseUrl: 'https://custom.example.com/v1', + }); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.modelId).toBe('custom-model'); + expect(snapshot?.apiKey).toBe('sk-custom-key'); + expect(snapshot?.baseUrl).toBe('https://custom.example.com/v1'); + }); + + it('should update existing runtime snapshot when credentials change', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'initial-model', + apiKey: 'sk-initial-key', + baseUrl: 'https://initial.example.com/v1', + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + // Create initial snapshot + modelsConfig.detectAndCaptureRuntimeModel(); + + // Update credentials with different model + modelsConfig.updateCredentials({ + model: 'updated-model', + apiKey: 'sk-updated-key', + }); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.modelId).toBe('updated-model'); + expect(snapshot?.apiKey).toBe('sk-updated-key'); + // baseUrl should be preserved from initial + expect(snapshot?.baseUrl).toBe('https://initial.example.com/v1'); + }); + + it('should enforce per-authType snapshot limit', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + // Create first snapshot for USE_OPENAI + modelsConfig.updateCredentials({ + model: 'model-a', + apiKey: 'sk-key-a', + baseUrl: 'https://a.example.com/v1', + }); + + const firstSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId(); + expect(firstSnapshotId).toBe('$runtime|openai|model-a'); + + // Create second snapshot for USE_OPENAI (different model) + modelsConfig.updateCredentials({ + model: 'model-b', + apiKey: 'sk-key-b', + baseUrl: 'https://b.example.com/v1', + }); + + const secondSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId(); + expect(secondSnapshotId).toBe('$runtime|openai|model-b'); + + // First snapshot should be cleaned up + expect(modelsConfig.getActiveRuntimeModelSnapshot()?.id).toBe( + secondSnapshotId, + ); + }); + + it('should support multiple authTypes with separate snapshots', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + // Create OpenAI snapshot + modelsConfig.updateCredentials({ + model: 'openai-model', + apiKey: 'sk-openai-key', + baseUrl: 'https://openai.example.com/v1', + }); + + // Verify OpenAI snapshot exists + const openaiSnapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(openaiSnapshot?.authType).toBe(AuthType.USE_OPENAI); + expect(openaiSnapshot?.modelId).toBe('openai-model'); + + // Switch to Anthropic via switchToRuntimeModel + // First create an Anthropic snapshot manually + modelsConfig['runtimeModelSnapshots'].set( + '$runtime|anthropic|anthropic-model', + { + id: '$runtime|anthropic|anthropic-model', + authType: AuthType.USE_ANTHROPIC, + modelId: 'anthropic-model', + apiKey: 'sk-anthropic-key', + baseUrl: 'https://anthropic.example.com/v1', + sources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + createdAt: Date.now(), + }, + ); + + // Switch to the Anthropic runtime model + await modelsConfig.switchToRuntimeModel( + '$runtime|anthropic|anthropic-model', + ); + + // Should now have Anthropic snapshot active + const anthropicSnapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(anthropicSnapshot?.authType).toBe(AuthType.USE_ANTHROPIC); + expect(anthropicSnapshot?.modelId).toBe('anthropic-model'); + }); + + it('should rollback state when switchToRuntimeModel fails', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'runtime-model', + apiKey: 'sk-runtime-key', + baseUrl: 'https://runtime.example.com/v1', + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + // Create snapshot + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + expect(snapshotId).toBeDefined(); + + // Set up onModelChange to fail + modelsConfig.setOnModelChange(async () => { + throw new Error('refresh failed'); + }); + + // Store baseline state + const baselineModel = modelsConfig.getModel(); + const baselineGc = snapshotGenerationConfig(modelsConfig); + + // Try to switch - should fail + await expect( + modelsConfig.switchToRuntimeModel(snapshotId!), + ).rejects.toThrow('refresh failed'); + + // State should be rolled back + expect(modelsConfig.getModel()).toBe(baselineModel); + expect(modelsConfig.getGenerationConfig()).toMatchObject({ + model: baselineGc.model, + apiKey: baselineGc.apiKey, + baseUrl: baselineGc.baseUrl, + }); + }); + }); }); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 0dc2bd336..bc5f56796 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -18,6 +18,7 @@ import { type ResolvedModelConfig, type AvailableModel, type ModelSwitchMetadata, + type RuntimeModelSnapshot, } from './types.js'; import { MODEL_GENERATION_CONFIG_FIELDS, @@ -99,6 +100,31 @@ export class ModelsConfig { // Flag indicating whether authType was explicitly provided (not defaulted) private readonly authTypeWasExplicitlyProvided: boolean; + /** + * Runtime model snapshot storage. + * + * These snapshots store runtime-resolved model configurations that are NOT from + * modelProviders registry (e.g., models with manually set credentials). + * + * Key: snapshotId (format: `$runtime|${authType}|${modelId}`) + * Uses `$runtime|` prefix since `$` and `|` are unlikely to appear in real model IDs. + * This prevents conflicts with model IDs containing `-` or `:` characters. + * Value: RuntimeModelSnapshot containing the model's configuration + * + * Note: This is different from state snapshots used for rollback during model switching. + * RuntimeModelSnapshot stores persistent model configurations, while state snapshots + * are temporary and used only for error recovery. + */ + private runtimeModelSnapshots: Map = new Map(); + + /** + * Currently active RuntimeModelSnapshot ID. + * + * When set, indicates that the current model is a runtime model (not from registry). + * This ID is included in state snapshots for rollback purposes. + */ + private activeRuntimeModelSnapshotId: string | undefined; + private static deepClone(value: T): T { if (value === null || typeof value !== 'object') { return value; @@ -115,38 +141,6 @@ export class ModelsConfig { return out as T; } - private snapshotState(): { - currentAuthType: AuthType | undefined; - generationConfig: Partial; - generationConfigSources: ContentGeneratorConfigSources; - strictModelProviderSelection: boolean; - requireCachedQwenCredentialsOnce: boolean; - hasManualCredentials: boolean; - } { - return { - currentAuthType: this.currentAuthType, - generationConfig: ModelsConfig.deepClone(this._generationConfig), - generationConfigSources: ModelsConfig.deepClone( - this.generationConfigSources, - ), - strictModelProviderSelection: this.strictModelProviderSelection, - requireCachedQwenCredentialsOnce: this.requireCachedQwenCredentialsOnce, - hasManualCredentials: this.hasManualCredentials, - }; - } - - private restoreState( - snapshot: ReturnType, - ): void { - this.currentAuthType = snapshot.currentAuthType; - this._generationConfig = snapshot.generationConfig; - this.generationConfigSources = snapshot.generationConfigSources; - this.strictModelProviderSelection = snapshot.strictModelProviderSelection; - this.requireCachedQwenCredentialsOnce = - snapshot.requireCachedQwenCredentialsOnce; - this.hasManualCredentials = snapshot.hasManualCredentials; - } - constructor(options: ModelsConfigOptions = {}) { this.modelRegistry = new ModelRegistry(options.modelProvidersConfig); this.onModelChange = options.onModelChange; @@ -166,6 +160,53 @@ export class ModelsConfig { this.currentAuthType = options.initialAuthType; } + /** + * Create a snapshot of the current ModelsConfig state for rollback purposes. + * Used before model switching operations to enable recovery on errors. + * + * Note: This is different from RuntimeModelSnapshot which stores runtime model configs. + */ + private createStateSnapshotForRollback(): { + currentAuthType: AuthType | undefined; + generationConfig: Partial; + generationConfigSources: ContentGeneratorConfigSources; + strictModelProviderSelection: boolean; + requireCachedQwenCredentialsOnce: boolean; + hasManualCredentials: boolean; + activeRuntimeModelSnapshotId: string | undefined; + } { + return { + currentAuthType: this.currentAuthType, + generationConfig: ModelsConfig.deepClone(this._generationConfig), + generationConfigSources: ModelsConfig.deepClone( + this.generationConfigSources, + ), + strictModelProviderSelection: this.strictModelProviderSelection, + requireCachedQwenCredentialsOnce: this.requireCachedQwenCredentialsOnce, + hasManualCredentials: this.hasManualCredentials, + activeRuntimeModelSnapshotId: this.activeRuntimeModelSnapshotId, + }; + } + + /** + * Restore ModelsConfig state from a previously created state snapshot. + * Used for rollback when model switching operations fail. + * + * @param snapshot - The state snapshot to restore + */ + private rollbackToStateSnapshot( + snapshot: ReturnType, + ): void { + this.currentAuthType = snapshot.currentAuthType; + this._generationConfig = snapshot.generationConfig; + this.generationConfigSources = snapshot.generationConfigSources; + this.strictModelProviderSelection = snapshot.strictModelProviderSelection; + this.requireCachedQwenCredentialsOnce = + snapshot.requireCachedQwenCredentialsOnce; + this.hasManualCredentials = snapshot.hasManualCredentials; + this.activeRuntimeModelSnapshotId = snapshot.activeRuntimeModelSnapshotId; + } + /** * Get current model ID */ @@ -210,6 +251,7 @@ export class ModelsConfig { * Notes: * - By default, returns models across all authTypes. * - qwen-oauth models are always ordered first. + * - Runtime model option (if active) is included before registry models of the same authType. */ getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] { const inputAuthTypes = @@ -236,8 +278,16 @@ export class ModelsConfig { } } + // Get runtime model option + const runtimeOption = this.getRuntimeModelOption(); + const allModels: AvailableModel[] = []; for (const authType of orderedAuthTypes) { + // Add runtime option first if it matches this authType + if (runtimeOption && runtimeOption.authType === authType) { + allModels.push(runtimeOption); + } + // Add registry models allModels.push(...this.modelRegistry.getModelsForAuthType(authType)); } return allModels; @@ -296,16 +346,29 @@ export class ModelsConfig { } /** - * Switch model (and optionally authType) via registry-backed selection. - * This is a superset of the previous split APIs for model-only vs authType+model switching. + * Switch model (and optionally authType). + * Supports both registry-backed models and RuntimeModelSnapshots. + * + * For runtime models, the modelId can be: + * - A RuntimeModelSnapshot ID (format: `$runtime|${authType}|${modelId}`) + * - With explicit `$runtime|` prefix (format: `$runtime|${authType}|${modelId}`) + * + * When called from ACP integration, the modelId has already been parsed + * by parseAcpModelOption, which strips any (${authType}) suffix. */ async switchModel( authType: AuthType, modelId: string, options?: { requireCachedCredentials?: boolean }, - _metadata?: ModelSwitchMetadata, ): Promise { - const snapshot = this.snapshotState(); + // Check if this is a RuntimeModelSnapshot reference + const runtimeModelSnapshotId = this.extractRuntimeModelSnapshotId(modelId); + if (runtimeModelSnapshotId) { + await this.switchToRuntimeModel(runtimeModelSnapshotId); + return; + } + + const rollbackSnapshot = this.createStateSnapshotForRollback(); if (authType === AuthType.QWEN_OAUTH && options?.requireCachedCredentials) { this.requireCachedQwenCredentialsOnce = true; } @@ -326,18 +389,77 @@ export class ModelsConfig { const requiresRefresh = isAuthTypeChange ? true - : this.checkRequiresRefresh(snapshot.generationConfig.model || ''); + : this.checkRequiresRefresh( + rollbackSnapshot.generationConfig.model || '', + ); if (this.onModelChange) { await this.onModelChange(authType, requiresRefresh); } } catch (error) { // Rollback on error - this.restoreState(snapshot); + this.rollbackToStateSnapshot(rollbackSnapshot); throw error; } } + /** + * Prefix used to identify RuntimeModelSnapshot IDs. + * Chosen to avoid conflicts with real model IDs which may contain `-` or `:`. + */ + private static readonly RUNTIME_SNAPSHOT_PREFIX = '$runtime|'; + + /** + * Build a RuntimeModelSnapshot ID from authType and modelId. + * The format is: `$runtime|${authType}|${modelId}` + * + * This is the canonical way to construct snapshot IDs, ensuring + * consistency across creation and lookup. + * + * @param authType - The authentication type + * @param modelId - The model ID + * @returns The snapshot ID in format `$runtime|${authType}|${modelId}` + */ + private buildRuntimeModelSnapshotId( + authType: AuthType, + modelId: string, + ): string { + return `${ModelsConfig.RUNTIME_SNAPSHOT_PREFIX}${authType}|${modelId}`; + } + + /** + * Extract RuntimeModelSnapshot ID from modelId if it's a runtime model reference. + * + * Supports the following formats: + * - Direct snapshot ID: `$runtime|${authType}|${modelId}` → returns as-is if exists in Map + * - Direct snapshot ID match: returns if exists in Map + * + * Note: When called from ACP integration via setModel, the modelId has already + * been parsed by parseAcpModelOption which strips any (${authType}) suffix. + * So we don't need to handle ACP format here - the ACP layer handles that. + * + * @param modelId - The model ID to parse + * @returns The RuntimeModelSnapshot ID if found, undefined otherwise + */ + private extractRuntimeModelSnapshotId(modelId: string): string | undefined { + // Check if modelId starts with the runtime snapshot prefix + if (modelId.startsWith(ModelsConfig.RUNTIME_SNAPSHOT_PREFIX)) { + // Verify the snapshot exists + if (this.runtimeModelSnapshots.has(modelId)) { + return modelId; + } + // Even with prefix, if it doesn't exist, don't return it + return undefined; + } + + // Check if modelId itself is a valid snapshot ID (exists in Map) + if (this.runtimeModelSnapshots.has(modelId)) { + return modelId; + } + + return undefined; + } + /** * Get generation config for ContentGenerator creation */ @@ -387,6 +509,9 @@ export class ModelsConfig { * to maintain provider atomicity (either fully applied or not at all). * Other layers (CLI, env, settings, defaults) will participate in resolve. * + * Also updates or creates a RuntimeModelSnapshot when credentials form a complete config + * for a model not in the registry. This allows the runtime model to be reused later. + * * @param settingsGenerationConfig Optional generation config from settings.json * to merge after clearing provider-sourced config. * This ensures settings.model.generationConfig fields @@ -447,6 +572,66 @@ export class ModelsConfig { if (settingsGenerationConfig) { this.mergeSettingsGenerationConfig(settingsGenerationConfig); } + + // Sync with runtime model snapshot if we have a complete configuration + this.syncRuntimeModelSnapshotWithCredentials(); + } + + /** + * Sync RuntimeModelSnapshot with current credentials. + * + * Creates or updates a RuntimeModelSnapshot when current credentials form a complete + * configuration for a model not in the registry. This enables: + * - Reusing the runtime model configuration later + * - Showing the runtime model as an available option in model lists + * + * Only creates snapshots for models NOT in the registry (to avoid duplication). + */ + private syncRuntimeModelSnapshotWithCredentials(): void { + const currentAuthType = this.currentAuthType; + const { model, apiKey, baseUrl } = this._generationConfig; + + // Early return if missing required fields + if (!model || !currentAuthType || !apiKey || !baseUrl) { + return; + } + + // Check if model exists in registry - if so, don't create RuntimeModelSnapshot + if (this.modelRegistry.hasModel(currentAuthType, model)) { + return; + } + + // If we have an active snapshot, update it + if ( + this.activeRuntimeModelSnapshotId && + this.runtimeModelSnapshots.has(this.activeRuntimeModelSnapshotId) + ) { + const snapshot = this.runtimeModelSnapshots.get( + this.activeRuntimeModelSnapshotId, + )!; + + // Update snapshot with current values (already verified to exist above) + snapshot.apiKey = apiKey; + snapshot.baseUrl = baseUrl; + snapshot.modelId = model; + + // Update ID if model changed + const newSnapshotId = this.buildRuntimeModelSnapshotId( + snapshot.authType, + snapshot.modelId, + ); + if (newSnapshotId !== snapshot.id) { + this.runtimeModelSnapshots.delete(snapshot.id); + snapshot.id = newSnapshotId; + this.runtimeModelSnapshots.set(newSnapshotId, snapshot); + this.activeRuntimeModelSnapshotId = newSnapshotId; + } + + snapshot.createdAt = Date.now(); + } else { + // Create new snapshot + this.detectAndCaptureRuntimeModel(); + } } /** @@ -784,4 +969,248 @@ export class ModelsConfig { setOnModelChange(callback: OnModelChangeCallback): void { this.onModelChange = callback; } + + /** + * Detect and capture RuntimeModelSnapshot during initialization. + * + * Checks if the current configuration represents a runtime model (not from + * modelProviders registry) and captures it as a RuntimeModelSnapshot. + * + * This enables runtime models to persist across sessions and appear in model lists. + * + * @returns Created snapshot ID, or undefined if current config is a registry model + */ + detectAndCaptureRuntimeModel(): string | undefined { + const { + model: currentModel, + apiKey, + baseUrl, + apiKeyEnvKey, + ...generationConfig + } = this._generationConfig; + const currentAuthType = this.currentAuthType; + + if (!currentModel || !currentAuthType) { + return undefined; + } + + // Check if model exists in registry - if so, it's not a runtime model + if (this.modelRegistry.hasModel(currentAuthType, currentModel)) { + // Current is a registry model, clear any previous RuntimeModelSnapshot for this authType + this.clearRuntimeModelSnapshotForAuthType(currentAuthType); + return undefined; + } + + // Check if we have valid credentials (apiKey + baseUrl) + const hasValidCredentials = + this._generationConfig.apiKey && this._generationConfig.baseUrl; + + if (!hasValidCredentials) { + return undefined; + } + + // Create or update RuntimeModelSnapshot + const snapshotId = this.buildRuntimeModelSnapshotId( + currentAuthType, + currentModel, + ); + const snapshot: RuntimeModelSnapshot = { + id: snapshotId, + authType: currentAuthType, + modelId: currentModel, + apiKey, + baseUrl, + apiKeyEnvKey, + generationConfig, + sources: { ...this.generationConfigSources }, + createdAt: Date.now(), + }; + + this.runtimeModelSnapshots.set(snapshotId, snapshot); + this.activeRuntimeModelSnapshotId = snapshotId; + + // Enforce per-authType limit + this.cleanupOldRuntimeModelSnapshots(); + + return snapshotId; + } + + /** + * Get the currently active RuntimeModelSnapshot. + * + * @returns The active RuntimeModelSnapshot, or undefined if no runtime model is active + */ + getActiveRuntimeModelSnapshot(): RuntimeModelSnapshot | undefined { + if (!this.activeRuntimeModelSnapshotId) { + return undefined; + } + return this.runtimeModelSnapshots.get(this.activeRuntimeModelSnapshotId); + } + + /** + * Get the ID of the currently active RuntimeModelSnapshot. + * + * @returns The active snapshot ID, or undefined if no runtime model is active + */ + getActiveRuntimeModelSnapshotId(): string | undefined { + return this.activeRuntimeModelSnapshotId; + } + + /** + * Switch to a RuntimeModelSnapshot. + * + * Applies the configuration from a previously captured RuntimeModelSnapshot. + * Uses state rollback pattern: creates a state snapshot before switching and + * restores it on error. + * + * @param snapshotId - The ID of the RuntimeModelSnapshot to switch to + */ + async switchToRuntimeModel(snapshotId: string): Promise { + const runtimeModelSnapshot = this.runtimeModelSnapshots.get(snapshotId); + if (!runtimeModelSnapshot) { + throw new Error(`Runtime model snapshot '${snapshotId}' not found`); + } + + const rollbackSnapshot = this.createStateSnapshotForRollback(); + + try { + const isAuthTypeChange = + runtimeModelSnapshot.authType !== this.currentAuthType; + this.currentAuthType = runtimeModelSnapshot.authType; + this.activeRuntimeModelSnapshotId = snapshotId; + + // Apply runtime configuration + this.strictModelProviderSelection = false; + this.hasManualCredentials = true; // Mark as manual to prevent provider override + + this._generationConfig.model = runtimeModelSnapshot.modelId; + this.generationConfigSources['model'] = { + kind: 'programmatic', + detail: 'runtimeModelSwitch', + }; + + if (runtimeModelSnapshot.apiKey) { + this._generationConfig.apiKey = runtimeModelSnapshot.apiKey; + this.generationConfigSources['apiKey'] = runtimeModelSnapshot.sources[ + 'apiKey' + ] || { + kind: 'programmatic', + detail: 'runtimeModelSwitch', + }; + } + + if (runtimeModelSnapshot.baseUrl) { + this._generationConfig.baseUrl = runtimeModelSnapshot.baseUrl; + this.generationConfigSources['baseUrl'] = runtimeModelSnapshot.sources[ + 'baseUrl' + ] || { + kind: 'programmatic', + detail: 'runtimeModelSwitch', + }; + } + + if (runtimeModelSnapshot.apiKeyEnvKey) { + this._generationConfig.apiKeyEnvKey = runtimeModelSnapshot.apiKeyEnvKey; + } + + // Apply generation config + if (runtimeModelSnapshot.generationConfig) { + Object.assign( + this._generationConfig, + runtimeModelSnapshot.generationConfig, + ); + } + + const requiresRefresh = isAuthTypeChange; + + if (this.onModelChange) { + await this.onModelChange( + runtimeModelSnapshot.authType, + requiresRefresh, + ); + } + } catch (error) { + this.rollbackToStateSnapshot(rollbackSnapshot); + throw error; + } + } + + /** + * Get the active RuntimeModelSnapshot as an AvailableModel option. + * + * Converts the active RuntimeModelSnapshot to an AvailableModel format for display + * in model lists. Returns undefined if no runtime model is active. + * + * @returns The runtime model as an AvailableModel option, or undefined + */ + private getRuntimeModelOption(): AvailableModel | undefined { + const snapshot = this.getActiveRuntimeModelSnapshot(); + if (!snapshot) { + return undefined; + } + + return { + id: snapshot.modelId, + label: snapshot.modelId, + authType: snapshot.authType, + /** + * `isVision` is for automatic switching of qwen-oauth vision model. + * Runtime models are basically specified via CLI arguments, env variables, + * or settings for other auth types. + */ + isVision: false, + contextWindowSize: snapshot.generationConfig?.contextWindowSize, + isRuntimeModel: true, + runtimeSnapshotId: snapshot.id, + }; + } + + /** + * Clear all RuntimeModelSnapshots for a specific authType. + * + * Removes all RuntimeModelSnapshots associated with the given authType. + * Called when switching to a registry model to avoid stale RuntimeModelSnapshots. + * + * @param authType - The authType whose snapshots should be cleared + */ + private clearRuntimeModelSnapshotForAuthType(authType: AuthType): void { + for (const [id, snapshot] of this.runtimeModelSnapshots.entries()) { + if (snapshot.authType === authType) { + this.runtimeModelSnapshots.delete(id); + if (this.activeRuntimeModelSnapshotId === id) { + this.activeRuntimeModelSnapshotId = undefined; + } + } + } + } + + /** + * Cleanup old RuntimeModelSnapshots to enforce per-authType limit. + * + * Keeps only the latest RuntimeModelSnapshot for each authType. + * Older snapshots are removed to prevent unbounded growth. + */ + private cleanupOldRuntimeModelSnapshots(): void { + const snapshotsByAuthType = new Map(); + + for (const snapshot of this.runtimeModelSnapshots.values()) { + const existing = snapshotsByAuthType.get(snapshot.authType); + if (!existing || snapshot.createdAt > existing.createdAt) { + snapshotsByAuthType.set(snapshot.authType, snapshot); + } + } + + this.runtimeModelSnapshots.clear(); + for (const snapshot of snapshotsByAuthType.values()) { + this.runtimeModelSnapshots.set(snapshot.id, snapshot); + } + + // Update active snapshot ID if it was removed + if ( + this.activeRuntimeModelSnapshotId && + !this.runtimeModelSnapshots.has(this.activeRuntimeModelSnapshotId) + ) { + this.activeRuntimeModelSnapshotId = undefined; + } + } } diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index 1a4d0c897..da3a2c5cf 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -8,6 +8,7 @@ import type { AuthType, ContentGeneratorConfig, } from '../core/contentGenerator.js'; +import type { ConfigSources } from '../utils/configResolver.js'; /** * Model capabilities configuration @@ -92,6 +93,12 @@ export interface AvailableModel { authType: AuthType; isVision?: boolean; contextWindowSize?: number; + + /** Whether this is a runtime model (not from modelProviders) */ + isRuntimeModel?: boolean; + + /** Runtime model snapshot ID (if isRuntimeModel is true) */ + runtimeSnapshotId?: string; } /** @@ -103,3 +110,35 @@ export interface ModelSwitchMetadata { /** Additional context */ context?: string; } + +/** + * Runtime model snapshot - captures complete model configuration from non-modelProviders sources + */ +export interface RuntimeModelSnapshot { + /** Snapshot unique identifier */ + id: string; + + /** Associated AuthType */ + authType: AuthType; + + /** Model ID */ + modelId: string; + + /** API Key (may come from env/cli/manual input) */ + apiKey?: string; + + /** Base URL (may come from env/cli/settings/credentials) */ + baseUrl?: string; + + /** Environment variable name (if apiKey comes from env) */ + apiKeyEnvKey?: string; + + /** Generation config (sampling parameters, etc.) */ + generationConfig?: ModelGenerationConfig; + + /** Configuration source tracking */ + sources: ConfigSources; + + /** Snapshot creation timestamp */ + createdAt: number; +}