diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index c960e05a7..39652c5c5 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -149,7 +149,7 @@ describe('validateAuthMethod', () => { process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key'; const result = validateAuthMethod(AuthType.USE_ANTHROPIC); - expect(result).toContain('ANTHROPIC_BASE_URL'); + expect(result).toContain('modelProviders[].baseUrl'); }); it('should return null for USE_VERTEX_AI with custom envKey', () => { diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index c0d33b0b4..e3656a277 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -45,11 +45,19 @@ function findModelConfig( /** * Check if API key is available for the given auth type and model configuration. * Prioritizes custom envKey from modelProviders over default environment variables. + * + * @returns hasKey - whether an API key is available + * @returns checkedEnvKey - the environment variable name that was checked + * @returns isExplicitEnvKey - true if model has explicit envKey configured (no apiKey fallback allowed) */ function hasApiKeyForAuth( authType: string, settings: Settings, -): { hasKey: boolean; checkedEnvKey: string | undefined } { +): { + hasKey: boolean; + checkedEnvKey: string | undefined; + isExplicitEnvKey: boolean; +} { const modelProviders = settings.modelProviders as | ModelProvidersConfig | undefined; @@ -58,25 +66,64 @@ function hasApiKeyForAuth( // Try to find model-specific envKey from modelProviders const modelConfig = findModelConfig(modelProviders, authType, modelId); if (modelConfig?.envKey) { + // Explicit envKey configured - only check this env var, no apiKey fallback const hasKey = !!process.env[modelConfig.envKey]; - return { hasKey, checkedEnvKey: modelConfig.envKey }; + return { + hasKey, + checkedEnvKey: modelConfig.envKey, + isExplicitEnvKey: true, + }; } - // Fallback to default environment variable + // Using default environment variable - apiKey fallback is allowed const defaultEnvKey = DEFAULT_ENV_KEYS[authType]; if (defaultEnvKey) { const hasKey = !!process.env[defaultEnvKey]; if (hasKey) { - return { hasKey, checkedEnvKey: defaultEnvKey }; + return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false }; } } - // Also check settings.security.auth.apiKey as fallback + // Also check settings.security.auth.apiKey as fallback (only for default env key) if (settings.security?.auth?.apiKey) { - return { hasKey: true, checkedEnvKey: defaultEnvKey || undefined }; + return { + hasKey: true, + checkedEnvKey: defaultEnvKey || undefined, + isExplicitEnvKey: false, + }; } - return { hasKey: false, checkedEnvKey: undefined }; + return { + hasKey: false, + checkedEnvKey: defaultEnvKey, + isExplicitEnvKey: false, + }; +} + +/** + * Generate API key error message based on auth check result. + * Returns null if API key is present, otherwise returns the appropriate error message. + */ +function getApiKeyError(authMethod: string, settings: Settings): string | null { + const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( + authMethod, + settings, + ); + if (hasKey) { + return null; + } + + const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod]; + if (isExplicitEnvKey) { + return t( + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', + { envKeyHint }, + ); + } + return t( + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.', + { envKeyHint }, + ); } export function validateAuthMethod(authMethod: string): string | null { @@ -84,14 +131,22 @@ export function validateAuthMethod(authMethod: string): string | null { loadEnvironment(settings.merged); if (authMethod === AuthType.USE_OPENAI) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( + const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( authMethod, settings.merged, ); if (!hasKey) { const envKeyHint = checkedEnvKey ? `'${checkedEnvKey}'` - : "'OPENAI_API_KEY' (or configure modelProviders[].envKey)"; + : "'OPENAI_API_KEY'"; + if (isExplicitEnvKey) { + // Explicit envKey configured - only suggest setting the env var + return t( + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.', + { envKeyHint }, + ); + } + // Default env key - can use either apiKey or env var return t( 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.', { envKeyHint }, @@ -107,15 +162,9 @@ export function validateAuthMethod(authMethod: string): string | null { } if (authMethod === AuthType.USE_ANTHROPIC) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( - authMethod, - settings.merged, - ); - if (!hasKey) { - const envKeyHint = checkedEnvKey || 'ANTHROPIC_API_KEY'; - return t('{{envKeyHint}} environment variable not found.', { - envKeyHint, - }); + const apiKeyError = getApiKeyError(authMethod, settings.merged); + if (apiKeyError) { + return apiKeyError; } // Check baseUrl - can come from modelProviders or environment @@ -124,43 +173,31 @@ export function validateAuthMethod(authMethod: string): string | null { | undefined; const modelId = settings.merged.model?.name; const modelConfig = findModelConfig(modelProviders, authMethod, modelId); - const hasBaseUrl = - modelConfig?.baseUrl || process.env['ANTHROPIC_BASE_URL']; - if (!hasBaseUrl) { + + if (modelConfig && !modelConfig.baseUrl) { return t( - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.', ); } + if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) { + return t('ANTHROPIC_BASE_URL environment variable not found.'); + } return null; } if (authMethod === AuthType.USE_GEMINI) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( - authMethod, - settings.merged, - ); - if (!hasKey) { - const envKeyHint = checkedEnvKey || 'GEMINI_API_KEY'; - return t( - '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', - { envKeyHint }, - ); + const apiKeyError = getApiKeyError(authMethod, settings.merged); + if (apiKeyError) { + return apiKeyError; } return null; } if (authMethod === AuthType.USE_VERTEX_AI) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( - authMethod, - settings.merged, - ); - if (!hasKey) { - const envKeyHint = checkedEnvKey || 'GOOGLE_API_KEY'; - return t( - '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', - { envKeyHint }, - ); + const apiKeyError = getApiKeyError(authMethod, settings.merged); + if (apiKeyError) { + return apiKeyError; } process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 274f3e68d..fa4221854 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -798,8 +798,14 @@ export default { 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden.', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden. Bitte legen Sie sie in Ihrer .env-Datei oder den Systemumgebungsvariablen fest.', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - 'Umgebungsvariable ANTHROPIC_BASE_URL wurde nicht gefunden (oder konfigurieren Sie modelProviders[].baseUrl).', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden (oder setzen Sie settings.security.auth.apiKey). Bitte legen Sie sie in Ihrer .env-Datei oder den Systemumgebungsvariablen fest.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'API-Schlüssel für OpenAI-kompatible Authentifizierung fehlt. Setzen Sie die Umgebungsvariable {{envKeyHint}}.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic-Anbieter fehlt erforderliche baseUrl in modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'Umgebungsvariable ANTHROPIC_BASE_URL wurde nicht gefunden.', 'Invalid auth method selected.': 'Ungültige Authentifizierungsmethode ausgewählt.', 'Failed to authenticate. Message: {{message}}': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e34fe1710..51461f4cb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -776,8 +776,14 @@ export default { '{{envKeyHint}} environment variable not found.', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'ANTHROPIC_BASE_URL environment variable not found.', 'Invalid auth method selected.': 'Invalid auth method selected.', 'Failed to authenticate. Message: {{message}}': 'Failed to authenticate. Message: {{message}}', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 02de921b2..82f2436ef 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -792,8 +792,14 @@ export default { 'Переменная окружения {{envKeyHint}} не найдена.', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': 'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - 'Переменная окружения ANTHROPIC_BASE_URL не найдена (или настройте modelProviders[].baseUrl).', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + 'Переменная окружения {{envKeyHint}} не найдена (или установите settings.security.auth.apiKey). Укажите её в файле .env или среди системных переменных.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Установите переменную окружения {{envKeyHint}}.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'У провайдера Anthropic отсутствует обязательный baseUrl в modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'Переменная окружения ANTHROPIC_BASE_URL не найдена.', 'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.', 'Failed to authenticate. Message: {{message}}': 'Не удалось авторизоваться. Сообщение: {{message}}', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 3f6b1368a..a1b9c2033 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -734,8 +734,14 @@ export default { '未找到 {{envKeyHint}} 环境变量。', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': '未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - '未找到 ANTHROPIC_BASE_URL 环境变量(或配置 modelProviders[].baseUrl)。', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + '未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey)。请在 .env 文件或系统环境变量中进行设置。', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + '缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic 提供商缺少必需的 baseUrl,请在 modelProviders[].baseUrl 中配置。', + 'ANTHROPIC_BASE_URL environment variable not found.': + '未找到 ANTHROPIC_BASE_URL 环境变量。', 'Invalid auth method selected.': '选择了无效的认证方式。', 'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}', 'Authenticated successfully with {{authType}} credentials.': diff --git a/packages/core/src/models/modelConfigErrors.ts b/packages/core/src/models/modelConfigErrors.ts index 3504793bd..e2d86445c 100644 --- a/packages/core/src/models/modelConfigErrors.ts +++ b/packages/core/src/models/modelConfigErrors.ts @@ -110,7 +110,7 @@ export class MissingBaseUrlError extends ModelConfigError { model: string | undefined; }) { super( - `Missing baseUrl for modelProviders model '${params.model || '(unknown)'}' (authType: ${params.authType}). ` + + `Missing baseUrl for modelProviders model '${params.model || '(unknown)'}'. ` + `Configure modelProviders.${params.authType || '(unknown)'}[].baseUrl.`, ); } diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 51c54ea59..8f8441e00 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest'; import { ModelsConfig } from './modelsConfig.js'; import { AuthType } from '../core/contentGenerator.js'; +import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ModelProvidersConfig } from './types.js'; describe('ModelsConfig', () => { @@ -20,6 +21,20 @@ describe('ModelsConfig', () => { return out as T; } + function snapshotGenerationConfig( + modelsConfig: ModelsConfig, + ): ContentGeneratorConfig { + return deepClone( + modelsConfig.getGenerationConfig() as ContentGeneratorConfig, + ); + } + + function currentGenerationConfig( + modelsConfig: ModelsConfig, + ): ContentGeneratorConfig { + return modelsConfig.getGenerationConfig() as ContentGeneratorConfig; + } + it('should fully rollback state when switchModel fails after applying defaults (authType change)', async () => { const modelProvidersConfig: ModelProvidersConfig = { openai: [ @@ -60,7 +75,7 @@ describe('ModelsConfig', () => { const baselineAuthType = modelsConfig.getCurrentAuthType(); const baselineModel = modelsConfig.getModel(); const baselineStrict = modelsConfig.isStrictModelProviderSelection(); - const baselineGc = deepClone(modelsConfig.getGenerationConfig()); + const baselineGc = snapshotGenerationConfig(modelsConfig); const baselineSources = deepClone( modelsConfig.getGenerationConfigSources(), ); @@ -78,7 +93,7 @@ describe('ModelsConfig', () => { expect(modelsConfig.getModel()).toBe(baselineModel); expect(modelsConfig.isStrictModelProviderSelection()).toBe(baselineStrict); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc).toMatchObject({ model: baselineGc.model, baseUrl: baselineGc.baseUrl, @@ -117,7 +132,7 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-a'); const baselineModel = modelsConfig.getModel(); - const baselineGc = deepClone(modelsConfig.getGenerationConfig()); + const baselineGc = snapshotGenerationConfig(modelsConfig); const baselineSources = deepClone( modelsConfig.getGenerationConfigSources(), ); @@ -139,7 +154,7 @@ describe('ModelsConfig', () => { expect(modelsConfig.getGenerationConfigSources()).toEqual(baselineSources); }); - it('should preserve an explicit apiKey when switching models if envKey is missing in the environment', async () => { + it('should require provider-sourced apiKey when switching models even if envKey is missing', async () => { const modelProvidersConfig: ModelProvidersConfig = { openai: [ { @@ -168,9 +183,9 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b'); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('model-b'); - expect(gc.apiKey).toBe('manual-key'); + expect(gc.apiKey).toBeUndefined(); expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED'); }); @@ -229,7 +244,7 @@ describe('ModelsConfig', () => { modelsConfig.getModel(), ); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('model-a'); expect(gc.samplingParams?.temperature).toBe(0.9); expect(gc.samplingParams?.max_tokens).toBe(999); @@ -298,7 +313,7 @@ describe('ModelsConfig', () => { modelsConfig.getModel(), ); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('model-a'); expect(gc.samplingParams?.temperature).toBe(0.9); expect(gc.samplingParams?.max_tokens).toBe(999); @@ -332,7 +347,7 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model'); // Verify provider config is applied - let gc = modelsConfig.getGenerationConfig(); + let gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('provider-model'); expect(gc.baseUrl).toBe('https://provider.example.com/v1'); expect(gc.samplingParams?.temperature).toBe(0.1); @@ -356,7 +371,7 @@ describe('ModelsConfig', () => { }); // Verify provider-sourced config is cleared - gc = modelsConfig.getGenerationConfig(); + gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('custom-model'); // Set by updateCredentials expect(gc.apiKey).toBe('manual-api-key'); // Set by updateCredentials expect(gc.baseUrl).toBeUndefined(); // Cleared (was from provider) @@ -415,7 +430,7 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model'); // Verify provider config is applied (overwriting settings) - let gc = modelsConfig.getGenerationConfig(); + let gc = currentGenerationConfig(modelsConfig); expect(gc.samplingParams?.temperature).toBe(0.1); expect(gc.timeout).toBe(1000); @@ -425,7 +440,7 @@ describe('ModelsConfig', () => { }); // Provider-sourced config should be cleared - gc = modelsConfig.getGenerationConfig(); + gc = currentGenerationConfig(modelsConfig); expect(gc.samplingParams).toBeUndefined(); expect(gc.timeout).toBeUndefined(); // The original settings-sourced config is NOT restored automatically; @@ -444,7 +459,7 @@ describe('ModelsConfig', () => { // Switching within qwen-oauth triggers applyResolvedModelDefaults(). await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); expect(gc.apiKeyEnvKey).toBeUndefined(); }); diff --git a/scripts/unused-keys-only-in-locales.json b/scripts/unused-keys-only-in-locales.json index 53ce7d9be..45097f8da 100644 --- a/scripts/unused-keys-only-in-locales.json +++ b/scripts/unused-keys-only-in-locales.json @@ -1,5 +1,5 @@ { - "generatedAt": "2025-12-24T09:15:59.125Z", + "generatedAt": "2026-01-07T14:56:23.662Z", "keys": [ " - en-US: English", " - zh-CN: Simplified Chinese", @@ -9,9 +9,9 @@ "Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})", "Auto-edit mode - Automatically approve file edits", "Available approval modes:", + "Change auth (executes the /auth command)", "Chat history is already compressed.", - "Clearing terminal and resetting chat.", - "Clearing terminal.", + "Continue with {{model}}", "Conversation checkpoint '{{tag}}' has been deleted.", "Conversation checkpoint saved with tag: {{tag}}.", "Conversation shared to {{filePath}}", @@ -24,6 +24,7 @@ "Failed to change approval mode: {{error}}", "Failed to login. Message: {{message}}", "Failed to save approval mode: {{error}}", + "Failed to switch model to '{{modelId}}'.\n\n{{error}}", "Invalid file format. Only .md and .json are supported.", "Invalid language. Available: en-US, zh-CN", "List of saved conversations:", @@ -43,6 +44,7 @@ "Persist for this project/workspace", "Persist for this user on this machine", "Plan mode - Analyze only, do not modify files or execute commands", + "Pro quota limit reached for {{model}}.", "Qwen OAuth authentication cancelled.", "Qwen OAuth authentication timed out. Please try again.", "Resume a conversation from a checkpoint. Usage: /chat resume ", @@ -54,8 +56,7 @@ "Share the current conversation to a markdown or json file. Usage: /chat share ", "Usage: /approval-mode [--session|--user|--project]", "Usage: /language ui [zh-CN|en-US]", - "YOLO mode - Automatically approve all tools", - "clear the screen and conversation history" + "YOLO mode - Automatically approve all tools" ], - "count": 55 + "count": 56 }