From b9dd080bd17459f5d34125bd2aadc5bd1a5f1f62 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 10 Feb 2026 17:59:47 +0800 Subject: [PATCH] feat: add auth entry: coding plan --- .../cli/src/constants/codingPlanTemplates.ts | 43 +++ packages/cli/src/i18n/locales/de.js | 18 ++ packages/cli/src/i18n/locales/en.js | 37 +++ packages/cli/src/i18n/locales/ja.js | 18 ++ packages/cli/src/i18n/locales/pt.js | 18 ++ packages/cli/src/i18n/locales/ru.js | 18 ++ packages/cli/src/i18n/locales/zh.js | 36 +++ packages/cli/src/ui/AppContainer.tsx | 3 + packages/cli/src/ui/auth/AuthDialog.test.tsx | 16 +- packages/cli/src/ui/auth/AuthDialog.tsx | 292 +++++++++++++++--- packages/cli/src/ui/auth/useAuth.ts | 130 +++++++- .../cli/src/ui/components/ApiKeyInput.tsx | 61 ++++ .../cli/src/ui/components/DialogManager.tsx | 35 +-- .../ui/components/OpenAIKeyPrompt.test.tsx | 74 ----- .../cli/src/ui/components/OpenAIKeyPrompt.tsx | 280 ----------------- .../cli/src/ui/contexts/UIActionsContext.tsx | 8 +- packages/cli/src/ui/hooks/useDialogClose.ts | 7 +- packages/core/src/config/config.ts | 13 + packages/core/src/models/modelRegistry.ts | 45 ++- packages/core/src/models/modelsConfig.ts | 12 + packages/core/src/telemetry/types.ts | 4 +- 21 files changed, 721 insertions(+), 447 deletions(-) create mode 100644 packages/cli/src/constants/codingPlanTemplates.ts create mode 100644 packages/cli/src/ui/components/ApiKeyInput.tsx delete mode 100644 packages/cli/src/ui/components/OpenAIKeyPrompt.test.tsx delete mode 100644 packages/cli/src/ui/components/OpenAIKeyPrompt.tsx diff --git a/packages/cli/src/constants/codingPlanTemplates.ts b/packages/cli/src/constants/codingPlanTemplates.ts new file mode 100644 index 000000000..99c034ecc --- /dev/null +++ b/packages/cli/src/constants/codingPlanTemplates.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; + +/** + * Coding plan template - array of model configurations + * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key + */ +export type CodingPlanTemplate = ModelConfig[]; + +/** + * Environment variable key for storing the coding plan API key + */ +export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; + +/** + * CODING_PLAN_TEMPLATE defines the model configurations for coding-plan mode. + */ +export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ + { + id: 'qwen3-coder-plus', + name: 'qwen3-coder-plur', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + description: 'Qwen3 Coder Plus model from Bailian Coding Plan', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-max-2026-01-23', + name: 'qwen3-max-2026-01-23', + description: 'Qwen3 Max Thinking model from Bailian Coding Plan', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, +]; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f0054b397..5466844d8 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1371,4 +1371,22 @@ export default { 'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'Für fortgeschrittene Benutzer, die Modelle manuell konfigurieren möchten.', + 'Please configure your models in settings.json:': + 'Bitte konfigurieren Sie Ihre Modelle in settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'API-Schlüssel über Umgebungsvariable setzen (z.B. OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Modellkonfiguration zu modelProviders['openai'] (oder anderen Authentifizierungstypen) hinzufügen", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Jeder Anbieter benötigt: id, envKey (erforderlich), plus optionale baseUrl, generationConfig', + 'Use /model command to select your preferred model from the configured list': + 'Verwenden Sie den /model-Befehl, um Ihr bevorzugtes Modell aus der konfigurierten Liste auszuwählen', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 79af44452..78f57abce 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1362,4 +1362,41 @@ export default { 'Opening extensions page in your browser: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Failed to open browser. Check out the extensions gallery at {{url}}', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'Please enter your API key:': 'Please enter your API key:', + 'API key cannot be empty.': 'API key cannot be empty.', + 'API key is stored in settings.env. You can migrate it to a .env file for better security.': + 'API key is stored in settings.env. You can migrate it to a .env file for better security.', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'For advanced users who want to configure models manually.', + 'Please configure your models in settings.json:': + 'Please configure your models in settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'Set API key via environment variable (e.g., OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Add model configuration to modelProviders['openai'] (or other auth types)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig', + 'Use /model command to select your preferred model from the configured list': + 'Use /model command to select your preferred model from the configured list', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'API-KEY': 'API-KEY', + 'Coding Plan': 'Coding Plan', + Custom: 'Custom', + 'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:', + '(Press Escape to go back)': '(Press Escape to go back)', + '(Press Enter to submit, Escape to cancel)': + '(Press Enter to submit, Escape to cancel)', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index a9a27c107..e2c7306b7 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -882,4 +882,22 @@ export default { 'コードが壊れた?叩けば治るさ', 'USBの差し込みに挑戦中...', ], + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'モデルを手動で設定したい上級ユーザー向け。', + 'Please configure your models in settings.json:': + 'settings.json でモデルを設定してください:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + '環境変数を使用して API キーを設定してください(例:OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "modelProviders['openai'](または他の認証タイプ)にモデル設定を追加してください", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + '各プロバイダーには:id、envKey(必須)、およびオプションの baseUrl、generationConfig が必要です', + 'Use /model command to select your preferred model from the configured list': + '/model コマンドを使用して、設定済みリストからお好みのモデルを選択してください', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'サポートされている認証タイプ:openai、anthropic、gemini、vertex-ai など', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 1f085dfcf..3e0089fa1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1385,4 +1385,22 @@ export default { 'Abrindo página de extensões no seu navegador: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Falha ao abrir o navegador. Confira a galeria de extensões em {{url}}', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'Para usuários avançados que desejam configurar modelos manualmente.', + 'Please configure your models in settings.json:': + 'Por favor, configure seus modelos em settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'Defina a chave de API via variável de ambiente (ex: OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Adicione a configuração do modelo a modelProviders['openai'] (ou outros tipos de autenticação)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Cada provedor precisa de: id, envKey (obrigatório), além de baseUrl e generationConfig opcionais', + 'Use /model command to select your preferred model from the configured list': + 'Use o comando /model para selecionar seu modelo preferido da lista configurada', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 2a3ad1385..4bf570351 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1375,4 +1375,22 @@ export default { 'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'Для продвинутых пользователей, которые хотят настраивать модели вручную.', + 'Please configure your models in settings.json:': + 'Пожалуйста, настройте ваши модели в settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'Установите ключ API через переменную окружения (например, OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Добавьте конфигурацию модели в modelProviders['openai'] (или другие типы аутентификации)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Каждому провайдеру нужны: id, envKey (обязательно), а также опциональные baseUrl, generationConfig', + 'Use /model command to select your preferred model from the configured list': + 'Используйте команду /model, чтобы выбрать предпочитаемую модель из настроенного списка', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 10530a4ac..e314d5129 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1198,4 +1198,40 @@ export default { '正在浏览器中打开扩展页面:{{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': '打开浏览器失败。请访问扩展市场:{{url}}', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'Please enter your API key:': '请输入您的 API Key:', + 'API key cannot be empty.': 'API Key 不能为空。', + 'API key is stored in settings.env. You can migrate it to a .env file for better security.': + 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + '适合需要手动配置模型的高级用户。', + 'Please configure your models in settings.json:': + '请在 settings.json 中配置您的模型:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + '通过环境变量设置 API Key(例如:OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "将模型配置添加到 modelProviders['openai'](或其他认证类型)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + '每个提供商需要:id、envKey(必需),以及可选的 baseUrl、generationConfig', + 'Use /model command to select your preferred model from the configured list': + '使用 /model 命令从配置列表中选择您偏好的模型', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + '支持的认证类型:openai、anthropic、gemini、vertex-ai 等', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'API-KEY': 'API-KEY', + 'Coding Plan': 'Coding Plan', + Custom: '自定义', + 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', + '(Press Escape to go back)': '(按 Escape 键返回)', + '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 8181583f9..7ac34def2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -402,6 +402,7 @@ export const AppContainer = (props: AppContainerProps) => { pendingAuthType, qwenAuthState, handleAuthSelect, + handleCodingPlanSubmit, openAuthDialog, cancelAuthentication, } = useAuthCommand(settings, config, historyManager.addItem, refreshStatic); @@ -1508,6 +1509,7 @@ export const AppContainer = (props: AppContainerProps) => { setAuthState, onAuthError, cancelAuthentication, + handleCodingPlanSubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, @@ -1552,6 +1554,7 @@ export const AppContainer = (props: AppContainerProps) => { setAuthState, onAuthError, cancelAuthentication, + handleCodingPlanSubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 83208614f..a975a599e 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -169,9 +169,9 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog only shows OpenAI option now, + // Since the auth dialog shows API-KEY option now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('OpenAI'); + expect(lastFrame()).toContain('API-KEY'); }); it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => { @@ -257,15 +257,17 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog only shows OpenAI option now, + // Since the auth dialog shows API-KEY option now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('OpenAI'); + expect(lastFrame()).toContain('API-KEY'); }); }); describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => { it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => { - process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI; + // QWEN_OAUTH is the only valid AuthType that can be selected via env var + // API-KEY is not an AuthType enum value, so it cannot be selected this way + process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.QWEN_OAUTH; const settings: LoadedSettings = new LoadedSettings( { @@ -302,8 +304,8 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // This is a bit brittle, but it's the best way to check which item is selected. - expect(lastFrame()).toContain('● 2. OpenAI'); + // QWEN_OAUTH is the first option, so it should be selected + expect(lastFrame()).toContain('● 1. Qwen OAuth'); }); it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => { diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 9ae1ea2a7..f06ba360c 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -11,6 +11,7 @@ import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { ApiKeyInput } from '../components/ApiKeyInput.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; @@ -28,30 +29,54 @@ function parseDefaultAuthType( return null; } +// Sub-mode types for API-KEY authentication +type ApiKeySubMode = 'coding-plan' | 'custom'; + +// View level for navigation +type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info'; + export function AuthDialog(): React.JSX.Element { const { pendingAuthType, authError } = useUIState(); - const { handleAuthSelect: onAuthSelect } = useUIActions(); + const { handleAuthSelect: onAuthSelect, handleCodingPlanSubmit } = + useUIActions(); const config = useConfig(); const [errorMessage, setErrorMessage] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); + const [viewLevel, setViewLevel] = useState('main'); + const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState(0); - const items = [ + // Main authentication entries + const mainItems = [ { key: AuthType.QWEN_OAUTH, label: t('Qwen OAuth'), value: AuthType.QWEN_OAUTH, }, { - key: AuthType.USE_OPENAI, - label: t('OpenAI'), - value: AuthType.USE_OPENAI, + key: 'API-KEY', + label: t('API-KEY'), + value: 'API-KEY' as const, + }, + ]; + + // API-KEY sub-mode entries + const apiKeySubItems = [ + { + key: 'coding-plan', + label: t('Coding Plan'), + value: 'coding-plan' as ApiKeySubMode, + }, + { + key: 'custom', + label: t('Custom'), + value: 'custom' as ApiKeySubMode, }, ]; const initialAuthIndex = Math.max( 0, - items.findIndex((item) => { + mainItems.findIndex((item) => { // Priority 1: pendingAuthType if (pendingAuthType) { return item.value === pendingAuthType; @@ -79,29 +104,75 @@ export function AuthDialog(): React.JSX.Element { const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey); const currentSelectedAuthType = selectedIndex !== null - ? items[selectedIndex]?.value - : items[initialAuthIndex]?.value; + ? mainItems[selectedIndex]?.value + : mainItems[initialAuthIndex]?.value; - const handleAuthSelect = async (authMethod: AuthType) => { + const handleMainSelect = async ( + value: (typeof mainItems)[number]['value'], + ) => { setErrorMessage(null); - await onAuthSelect(authMethod); + + if (value === 'API-KEY') { + // Navigate to API-KEY sub-mode selection + setViewLevel('api-key-sub'); + return; + } + + // For Qwen OAuth, proceed directly + await onAuthSelect(value); }; - const handleHighlight = (authMethod: AuthType) => { - const index = items.findIndex((item) => item.value === authMethod); - setSelectedIndex(index); + const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => { + setErrorMessage(null); + + if (subMode === 'coding-plan') { + setViewLevel('api-key-input'); + } else { + setViewLevel('custom-info'); + } + }; + + const handleApiKeyInputSubmit = async (apiKey: string) => { + setErrorMessage(null); + + if (!apiKey.trim()) { + setErrorMessage(t('API key cannot be empty.')); + return; + } + + // Submit to parent for processing + await handleCodingPlanSubmit(apiKey); + }; + + const handleGoBack = () => { + setErrorMessage(null); + + if (viewLevel === 'api-key-sub') { + setViewLevel('main'); + } else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { + setViewLevel('api-key-sub'); + } }; useKeypress( (key) => { if (key.name === 'escape') { - // Prevent exit if there is an error message. - // This means they user is not authenticated yet. + // Handle Escape based on current view level + if (viewLevel === 'api-key-sub') { + handleGoBack(); + return; + } + + if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { + handleGoBack(); + return; + } + + // For main view, use existing logic if (errorMessage) { return; } if (config.getAuthType() === undefined) { - // Prevent exiting if no auth method is set setErrorMessage( t( 'You must select an auth method to proceed. Press Ctrl+C again to exit.', @@ -115,6 +186,129 @@ export function AuthDialog(): React.JSX.Element { { isActive: true }, ); + // Render main auth selection + const renderMainView = () => ( + <> + + {t('How would you like to authenticate for this project?')} + + + { + const index = mainItems.findIndex((item) => item.value === value); + setSelectedIndex(index); + }} + /> + + + ); + + // Render API-KEY sub-mode selection + const renderApiKeySubView = () => ( + <> + + {t('Select API-KEY configuration mode:')} + + + { + const index = apiKeySubItems.findIndex( + (item) => item.value === value, + ); + setApiKeySubModeIndex(index); + }} + /> + + + {t('(Press Escape to go back)')} + + + ); + + // Render API key input for coding-plan mode + const renderApiKeyInputView = () => ( + + + + ); + + // Render custom mode info + const renderCustomInfoView = () => ( + <> + + {t('Custom API-KEY Configuration')} + + + + {t('For advanced users who want to configure models manually.')} + + + + {t('Please configure your models in settings.json:')} + + + + 1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')} + + + + + 2.{' '} + {t( + "Add model configuration to modelProviders['openai'] (or other auth types)", + )} + + + + + 3.{' '} + {t( + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig', + )} + + + + + 4.{' '} + {t( + 'Use /model command to select your preferred model from the configured list', + )} + + + + + {t( + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', + )} + + + + {t('(Press Escape to go back)')} + + + ); + + const getViewTitle = () => { + switch (viewLevel) { + case 'main': + return t('Get started'); + case 'api-key-sub': + return t('API-KEY Configuration'); + case 'api-key-input': + return t('Coding Plan Setup'); + case 'custom-info': + return t('Custom Configuration'); + default: + return t('Get started'); + } + }; + return ( - {t('Get started')} - - {t('How would you like to authenticate for this project?')} - - - - + {getViewTitle()} + + {viewLevel === 'main' && renderMainView()} + {viewLevel === 'api-key-sub' && renderApiKeySubView()} + {viewLevel === 'api-key-input' && renderApiKeyInputView()} + {viewLevel === 'custom-info' && renderCustomInfoView()} + {(authError || errorMessage) && ( {authError || errorMessage} )} - - {t('(Use Enter to Set Auth)')} - - {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( - - - {t( - 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.', - )} - - + + {viewLevel === 'main' && ( + <> + + + {t('(Use Enter to Set Auth)')} + + + {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( + + + {t( + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.', + )} + + + )} + + + {t('Terms of Services and Privacy Notice for Qwen Code')} + + + + + {'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'} + + + )} - - {t('Terms of Services and Privacy Notice for Qwen Code')} - - - - {'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'} - - ); } diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 82211362c..a327152c2 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -8,6 +8,7 @@ import type { Config, ContentGeneratorConfig, ModelProvidersConfig, + ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import { AuthEvent, @@ -18,11 +19,20 @@ import { import { useCallback, useEffect, useState } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; -import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; +// OpenAICredentials type (previously imported from OpenAIKeyPrompt) +export interface OpenAICredentials { + apiKey: string; + baseUrl?: string; + model?: string; +} import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; +import { + CODING_PLAN_TEMPLATE, + CODING_PLAN_ENV_KEY, +} from '../../constants/codingPlanTemplates.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -272,6 +282,123 @@ export const useAuthCommand = ( setAuthError(null); }, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]); + /** + * Handle coding plan submission - generates configs from template and stores api-key + */ + const handleCodingPlanSubmit = useCallback( + async (apiKey: string) => { + try { + setIsAuthenticating(true); + setAuthError(null); + + const envKeyName = CODING_PLAN_ENV_KEY; + + // Get persist scope + const persistScope = getPersistScopeForModelSelection(settings); + + // Store api-key in settings.env + settings.setValue(persistScope, `env.${envKeyName}`, apiKey); + + // Sync to process.env immediately so refreshAuth can read the apiKey + process.env[envKeyName] = apiKey; + + // Generate model configs from template + const newConfigs: ProviderModelConfig[] = CODING_PLAN_TEMPLATE.map( + (templateConfig) => ({ + ...templateConfig, + envKey: envKeyName, + }), + ); + + // Get existing configs + const existingConfigs = + ( + settings.merged.modelProviders as ModelProvidersConfig | undefined + )?.[AuthType.USE_OPENAI] || []; + + // Deduplicate: check if config with same id, baseUrl, and envKey exists + const isDuplicate = (config: ProviderModelConfig) => + existingConfigs.some( + (existing) => + existing.id === config.id && + existing.baseUrl === config.baseUrl && + existing.envKey === config.envKey, + ); + + // Filter out duplicates and replace existing ones + const uniqueNewConfigs = newConfigs.filter( + (config) => !isDuplicate(config), + ); + + // Unshift new configs to the beginning + const updatedConfigs = [...uniqueNewConfigs, ...existingConfigs]; + + // Persist to modelProviders + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Also persist authType + settings.setValue( + persistScope, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + + // If there are configs, use the first one as the model + if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { + settings.setValue(persistScope, 'model.name', updatedConfigs[0].id); + } + + // Hot-reload model providers configuration before refreshAuth + // This ensures ModelsConfig has the latest configuration from settings.json + const updatedModelProviders: ModelProvidersConfig = { + ...(settings.merged.modelProviders as + | ModelProvidersConfig + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig(updatedModelProviders); + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + // Success handling + setAuthError(null); + setAuthState(AuthState.Authenticated); + setIsAuthDialogOpen(false); + setIsAuthenticating(false); + + // Trigger UI refresh + onAuthChange?.(); + + // Add success message + addItem( + { + type: MessageType.INFO, + text: t( + 'Authenticated successfully with Coding Plan. API key is stored in settings.env.', + ), + }, + Date.now(), + ); + + // Log success + const authEvent = new AuthEvent( + AuthType.USE_OPENAI, + 'coding-plan', + 'success', + ); + logAuth(config, authEvent); + } catch (error) { + handleAuthFailure(error); + } + }, + [settings, config, handleAuthFailure, addItem, onAuthChange], + ); + /** /** * We previously used a useEffect to trigger authentication automatically when @@ -322,6 +449,7 @@ export const useAuthCommand = ( pendingAuthType, qwenAuthState, handleAuthSelect, + handleCodingPlanSubmit, openAuthDialog, cancelAuthentication, }; diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx new file mode 100644 index 000000000..ee8a361d2 --- /dev/null +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { TextInput } from './shared/TextInput.js'; +import { Colors } from '../colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; + +interface ApiKeyInputProps { + onSubmit: (apiKey: string) => void; + onCancel: () => void; +} + +export function ApiKeyInput({ + onSubmit, + onCancel, +}: ApiKeyInputProps): React.JSX.Element { + const [apiKey, setApiKey] = useState(''); + const [error, setError] = useState(null); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onCancel(); + } else if (key.name === 'return') { + const trimmedKey = apiKey.trim(); + if (!trimmedKey) { + setError(t('API key cannot be empty.')); + return; + } + onSubmit(trimmedKey); + } + }, + { isActive: true }, + ); + + return ( + + + {t('Please enter your API key:')} + + + {error && ( + + {error} + + )} + + + {t('(Press Enter to submit, Escape to cancel)')} + + + + ); +} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c68afd420..b73ab1287 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -17,7 +17,6 @@ import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; -import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; @@ -56,16 +55,6 @@ export const DialogManager = ({ const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } = uiState; - const getDefaultOpenAIConfig = () => { - const fromSettings = settings.merged.security?.auth; - const modelSettings = settings.merged.model; - return { - apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '', - baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '', - model: modelSettings?.name || process.env['OPENAI_MODEL'] || '', - }; - }; - if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) { return ( { - uiActions.handleAuthSelect(AuthType.USE_OPENAI, { - apiKey, - baseUrl, - model, - }); - }} - onCancel={() => { - uiActions.cancelAuthentication(); - uiActions.setAuthState(AuthState.Updating); - }} - defaultApiKey={defaults.apiKey} - defaultBaseUrl={defaults.baseUrl} - defaultModel={defaults.model} - /> - ); - } - + // OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes + // Qwen OAuth remains as a separate flow if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) { return ( ({ - useKeypress: vi.fn(), -})); - -describe('OpenAIKeyPrompt', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('should render the prompt correctly', () => { - const onSubmit = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - , - ); - - expect(lastFrame()).toContain('OpenAI Configuration Required'); - expect(lastFrame()).toContain( - 'https://bailian.console.aliyun.com/?tab=model#/api-key', - ); - expect(lastFrame()).toContain( - 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel', - ); - }); - - it('should show the component with proper styling', () => { - const onSubmit = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - expect(output).toContain('OpenAI Configuration Required'); - expect(output).toContain('API Key:'); - expect(output).toContain('Base URL:'); - expect(output).toContain('Model:'); - expect(output).toContain( - 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel', - ); - }); - - it('should handle paste with control characters', async () => { - const onSubmit = vi.fn(); - const onCancel = vi.fn(); - - const { stdin } = render( - , - ); - - // Simulate paste with control characters - const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~'; - stdin.write(pasteWithControlChars); - - // Wait a bit for processing - await new Promise((resolve) => setTimeout(resolve, 50)); - - // The component should have filtered out the control characters - // and only kept 'sk-test123' - expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet - }); -}); diff --git a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx deleted file mode 100644 index ae65d3585..000000000 --- a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useState } from 'react'; -import { z } from 'zod'; -import { Box, Text } from 'ink'; -import { Colors } from '../colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { t } from '../../i18n/index.js'; - -interface OpenAIKeyPromptProps { - onSubmit: (apiKey: string, baseUrl: string, model: string) => void; - onCancel: () => void; - defaultApiKey?: string; - defaultBaseUrl?: string; - defaultModel?: string; -} - -export const credentialSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - baseUrl: z - .union([z.string().url('Base URL must be a valid URL'), z.literal('')]) - .optional(), - model: z.string().min(1, 'Model must be a non-empty string').optional(), -}); - -export type OpenAICredentials = z.infer; - -export function OpenAIKeyPrompt({ - onSubmit, - onCancel, - defaultApiKey, - defaultBaseUrl, - defaultModel, -}: OpenAIKeyPromptProps): React.JSX.Element { - const [apiKey, setApiKey] = useState(defaultApiKey || ''); - const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || ''); - const [model, setModel] = useState(defaultModel || ''); - const [currentField, setCurrentField] = useState< - 'apiKey' | 'baseUrl' | 'model' - >('apiKey'); - const [validationError, setValidationError] = useState(null); - - const validateAndSubmit = () => { - setValidationError(null); - - try { - const validated = credentialSchema.parse({ - apiKey: apiKey.trim(), - baseUrl: baseUrl.trim() || undefined, - model: model.trim() || undefined, - }); - - onSubmit( - validated.apiKey, - validated.baseUrl === '' ? '' : validated.baseUrl || '', - validated.model || '', - ); - } catch (error) { - if (error instanceof z.ZodError) { - const errorMessage = error.errors - .map((e) => `${e.path.join('.')}: ${e.message}`) - .join(', '); - setValidationError( - t('Invalid credentials: {{errorMessage}}', { errorMessage }), - ); - } else { - setValidationError(t('Failed to validate credentials')); - } - } - }; - - useKeypress( - (key) => { - // Handle escape - if (key.name === 'escape') { - onCancel(); - return; - } - - // Handle Enter key - if (key.name === 'return') { - if (currentField === 'apiKey') { - // 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改 - setCurrentField('baseUrl'); - return; - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - return; - } else if (currentField === 'model') { - // 只有在提交时才检查 API key 是否为空 - if (apiKey.trim()) { - validateAndSubmit(); - } else { - // 如果 API key 为空,回到 API key 字段 - setCurrentField('apiKey'); - } - } - return; - } - - // Handle Tab key for field navigation - if (key.name === 'tab') { - if (currentField === 'apiKey') { - setCurrentField('baseUrl'); - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - } else if (currentField === 'model') { - setCurrentField('apiKey'); - } - return; - } - - // Handle arrow keys for field navigation - if (key.name === 'up') { - if (currentField === 'baseUrl') { - setCurrentField('apiKey'); - } else if (currentField === 'model') { - setCurrentField('baseUrl'); - } - return; - } - - if (key.name === 'down') { - if (currentField === 'apiKey') { - setCurrentField('baseUrl'); - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - } - return; - } - - // Handle backspace/delete - if (key.name === 'backspace' || key.name === 'delete') { - if (currentField === 'apiKey') { - setApiKey((prev) => prev.slice(0, -1)); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev.slice(0, -1)); - } else if (currentField === 'model') { - setModel((prev) => prev.slice(0, -1)); - } - return; - } - - // Handle paste mode - if it's a paste event with content - if (key.paste && key.sequence) { - // 过滤粘贴相关的控制序列 - let cleanInput = key.sequence - // 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等) - .replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex - // 过滤粘贴开始标记 [200~ - .replace(/\[200~/g, '') - // 过滤粘贴结束标记 [201~ - .replace(/\[201~/g, '') - // 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留) - .replace(/^\[|~$/g, ''); - - // 再过滤所有不可见字符(ASCII < 32,除了回车换行) - cleanInput = cleanInput - .split('') - .filter((ch) => ch.charCodeAt(0) >= 32) - .join(''); - - if (cleanInput.length > 0) { - if (currentField === 'apiKey') { - setApiKey((prev) => prev + cleanInput); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev + cleanInput); - } else if (currentField === 'model') { - setModel((prev) => prev + cleanInput); - } - } - return; - } - - // Handle regular character input - if (key.sequence && !key.ctrl && !key.meta) { - // Filter control characters - const cleanInput = key.sequence - .split('') - .filter((ch) => ch.charCodeAt(0) >= 32) - .join(''); - - if (cleanInput.length > 0) { - if (currentField === 'apiKey') { - setApiKey((prev) => prev + cleanInput); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev + cleanInput); - } else if (currentField === 'model') { - setModel((prev) => prev + cleanInput); - } - } - } - }, - { isActive: true }, - ); - - return ( - - - {t('OpenAI Configuration Required')} - - {validationError && ( - - {validationError} - - )} - - - {t( - 'Please enter your OpenAI configuration. You can get an API key from', - )}{' '} - - https://bailian.console.aliyun.com/?tab=model#/api-key - - - - - - - {t('API Key:')} - - - - - {currentField === 'apiKey' ? '> ' : ' '} - {apiKey || ' '} - - - - - - - {t('Base URL:')} - - - - - {currentField === 'baseUrl' ? '> ' : ' '} - {baseUrl} - - - - - - - {t('Model:')} - - - - - {currentField === 'model' ? '> ' : ' '} - {model} - - - - - - {t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')} - - - - ); -} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 000740bed..f93ee84eb 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -17,7 +17,12 @@ import { import { type SettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; +// OpenAICredentials type (previously imported from OpenAIKeyPrompt) +export interface OpenAICredentials { + apiKey: string; + baseUrl?: string; + model?: string; +} export interface UIActions { openThemeDialog: () => void; @@ -35,6 +40,7 @@ export interface UIActions { authType: AuthType | undefined, credentials?: OpenAICredentials, ) => Promise; + handleCodingPlanSubmit: (apiKey: string) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string) => void; cancelAuthentication: () => void; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 8191e16b8..d71a21190 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -7,7 +7,12 @@ import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; -import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; +// OpenAICredentials type (previously imported from OpenAIKeyPrompt) +interface OpenAICredentials { + apiKey: string; + baseUrl?: string; + model?: string; +} export interface DialogCloseOptions { // Theme dialog diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ed07a16c5..e1598a641 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -770,6 +770,19 @@ export class Config { this.modelsConfig.updateCredentials(credentials, settingsGenerationConfig); } + /** + * Reload model providers configuration at runtime. + * This enables hot-reloading of modelProviders settings without restarting the CLI. + * Should be called before refreshAuth when settings.json has been updated. + * + * @param modelProvidersConfig - The updated model providers configuration + */ + reloadModelProvidersConfig( + modelProvidersConfig?: ModelProvidersConfig, + ): void { + this.modelsConfig.reloadModelProvidersConfig(modelProvidersConfig); + } + /** * Refresh authentication and rebuild ContentGenerator. */ diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts index bb9b5b8b1..7b9bdad77 100644 --- a/packages/core/src/models/modelRegistry.ts +++ b/packages/core/src/models/modelRegistry.ts @@ -82,7 +82,8 @@ export class ModelRegistry { } /** - * Register models for an authType + * Register models for an authType. + * If multiple models have the same id, the first one takes precedence. */ private registerAuthTypeModels( authType: AuthType, @@ -91,6 +92,13 @@ export class ModelRegistry { const modelMap = new Map(); for (const config of models) { + // Skip if a model with the same id is already registered (first one wins) + if (modelMap.has(config.id)) { + debugLogger.warn( + `Duplicate model id "${config.id}" for authType "${authType}". Using the first registered config.`, + ); + continue; + } const resolved = this.resolveModelConfig(config, authType); modelMap.set(config.id, resolved); } @@ -181,4 +189,39 @@ export class ModelRegistry { ); } } + + /** + * Reload models from updated configuration. + * Clears existing user-configured models and re-registers from new config. + * Preserves hard-coded qwen-oauth models. + */ + reloadModels(modelProvidersConfig?: ModelProvidersConfig): void { + // Clear existing user-configured models (preserve qwen-oauth) + for (const authType of this.modelsByAuthType.keys()) { + if (authType !== AuthType.QWEN_OAUTH) { + this.modelsByAuthType.delete(authType); + } + } + + // Re-register user-configured models for other authTypes + if (modelProvidersConfig) { + for (const [rawKey, models] of Object.entries(modelProvidersConfig)) { + const authType = validateAuthTypeKey(rawKey); + + if (!authType) { + debugLogger.warn( + `Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`, + ); + continue; + } + + // Skip qwen-oauth as it uses hard-coded models + if (authType === AuthType.QWEN_OAUTH) { + continue; + } + + this.registerAuthTypeModels(authType, models); + } + } + } } diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index f7925699e..9311c9279 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -1175,4 +1175,16 @@ export class ModelsConfig { this.activeRuntimeModelSnapshotId = undefined; } } + + /** + * Reload model providers configuration at runtime. + * This enables hot-reloading of modelProviders settings without restarting the CLI. + * + * @param modelProvidersConfig - The updated model providers configuration + */ + reloadModelProvidersConfig( + modelProvidersConfig?: ModelProvidersConfig, + ): void { + this.modelRegistry.reloadModels(modelProvidersConfig); + } } diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 5b410b096..98c8d5cac 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -763,13 +763,13 @@ export class AuthEvent implements BaseTelemetryEvent { 'event.name': 'auth'; 'event.timestamp': string; auth_type: AuthType; - action_type: 'auto' | 'manual'; + action_type: 'auto' | 'manual' | 'coding-plan'; status: 'success' | 'error' | 'cancelled'; error_message?: string; constructor( auth_type: AuthType, - action_type: 'auto' | 'manual', + action_type: 'auto' | 'manual' | 'coding-plan', status: 'success' | 'error' | 'cancelled', error_message?: string, ) {