/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useCallback, useContext, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { AuthType, ModelSlashCommandEvent, logModelSlashCommand, MAINLINE_CODER_MODEL, type AvailableModel as CoreAvailableModel, type ContentGeneratorConfig, type InputModalities, } from '@qwen-code/qwen-code-core'; 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, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; function formatModalities(modalities?: InputModalities): string { if (!modalities) return t('text-only'); const parts: string[] = []; if (modalities.image) parts.push(t('image')); if (modalities.pdf) parts.push(t('pdf')); if (modalities.audio) parts.push(t('audio')); if (modalities.video) parts.push(t('video')); if (parts.length === 0) return t('text-only'); return `${t('text')} · ${parts.join(' · ')}`; } interface ModelDialogProps { onClose: () => void; isFastModelMode?: boolean; } function maskApiKey(apiKey: string | undefined): string { if (!apiKey) return `(${t('not set')})`; const trimmed = apiKey.trim(); if (trimmed.length === 0) return `(${t('not set')})`; if (trimmed.length <= 6) return '***'; const head = trimmed.slice(0, 3); const tail = trimmed.slice(-4); return `${head}…${tail}`; } function persistModelSelection( settings: ReturnType, modelId: string, ): void { const scope = getPersistScopeForModelSelection(settings); settings.setValue(scope, 'model.name', modelId); } function persistAuthTypeSelection( settings: ReturnType, authType: AuthType, ): void { const scope = getPersistScopeForModelSelection(settings); 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 ?? `(${t('none')})`}` + `\n` + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` + `\n` + `Base URL: ${baseUrl}` + `\n` + `API key: ${maskedKey}`, }, Date.now(), ); } function formatContextWindow(size?: number): string { if (!size) return `(${t('unknown')})`; return `${size.toLocaleString('en-US')} tokens`; } function DetailRow({ label, value, }: { label: string; value: React.ReactNode; }): React.JSX.Element { return ( {label}: {value} ); } export function ModelDialog({ onClose, isFastModelMode, }: ModelDialogProps): React.JSX.Element { const config = useContext(ConfigContext); const uiState = useContext(UIStateContext); const settings = useSettings(); // Local error state for displaying errors within the dialog const [errorMessage, setErrorMessage] = useState(null); const [highlightedValue, setHighlightedValue] = useState(null); const authType = config?.getAuthType(); const availableModelEntries = useMemo(() => { const allModels = config ? config.getAllConfiguredModels() : []; // 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 registryModels) { const authType = model.authType; if (!modelsByAuthTypeMap.has(authType)) { modelsByAuthTypeMap.set(authType, []); } modelsByAuthTypeMap.get(authType)!.push(model); } // Fixed order: qwen-oauth first, then others in a stable order const authTypeOrder: AuthType[] = [ AuthType.QWEN_OAUTH, AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, ]; // 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), ); // 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, 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) )} ); // Include runtime indicator in description let description = model.description || ''; if (isRuntime) { description = description ? `${description} (Runtime)` : 'Runtime model'; } return { value, title, description, key: value, }; }, ), [availableModelEntries], ); // In fast model mode, default to the currently configured fast model const fastModelSetting = settings?.merged?.fastModel as string | undefined; const preferredModelId = isFastModelMode && fastModelSetting ? fastModelSetting : config?.getModel() || MAINLINE_CODER_MODEL; // Check if current model is a runtime model // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = isFastModelMode ? undefined // fast model is never a runtime model : config?.getActiveRuntimeModelSnapshot?.(); const preferredKey = activeRuntimeSnapshot ? activeRuntimeSnapshot.id : authType ? `${authType}::${preferredModelId}` : ''; useKeypress( (key) => { if (key.name === 'escape') { onClose(); } }, { isActive: true }, ); const initialIndex = useMemo(() => { const index = MODEL_OPTIONS.findIndex( (option) => option.value === preferredKey, ); return index === -1 ? 0 : index; }, [MODEL_OPTIONS, preferredKey]); const handleHighlight = useCallback((value: string) => { setHighlightedValue(value); }, []); const highlightedEntry = useMemo(() => { const key = highlightedValue ?? preferredKey; return availableModelEntries.find( ({ authType: t2, model, isRuntime, snapshotId }) => { const v = isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; return v === key; }, ); }, [highlightedValue, preferredKey, availableModelEntries]); const handleSelect = useCallback( async (selected: string) => { setErrorMessage(null); // Fast model mode: just save the model ID and close if (isFastModelMode) { // Extract model ID from selection key (format: "authType::modelId" or "$runtime|authType|modelId") let modelId: string; if (selected.includes('::')) { modelId = selected.split('::').slice(1).join('::'); } else if (selected.startsWith('$runtime|')) { const parts = selected.split('|'); modelId = parts[2] ?? selected; } else { modelId = selected; } const scope = getPersistScopeForModelSelection(settings); settings.setValue(scope, 'fastModel', modelId); uiState?.historyManager.addItem( { type: 'success', text: `${t('Fast Model')}: ${modelId}`, }, Date.now(), ); onClose(); return; } let after: ContentGeneratorConfig | undefined; let effectiveAuthType: AuthType | undefined; let effectiveModelId = selected; let isRuntime = false; 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; } 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; 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, isFastModelMode, ], ); const hasModels = MODEL_OPTIONS.length > 0; return ( {t('Select Model')} {!hasModels ? ( {t( 'No models available for the current authentication type ({{authType}}).', { authType: authType ? String(authType) : t('(none)'), }, )} {t( 'Please configure models in settings.modelProviders or use environment variables.', )} ) : ( )} {highlightedEntry && ( {highlightedEntry.authType !== AuthType.QWEN_OAUTH && ( <> )} )} {errorMessage && ( ✕ {errorMessage} )} {t('Enter to select, ↑↓ to navigate, Esc to close')} ); }