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;
+}