fix: refine auth message to give explicit tip

This commit is contained in:
mingholy.lmh 2026-01-07 22:56:57 +08:00
parent ded1ebcdff
commit 5ea841dd02
9 changed files with 148 additions and 71 deletions

View file

@ -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', () => {

View file

@ -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';

View file

@ -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}}':

View file

@ -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}}',

View file

@ -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}}',

View file

@ -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.':

View file

@ -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.`,
);
}

View file

@ -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<ContentGeneratorConfig>(
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();
});

View file

@ -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 <tag>",
@ -54,8 +56,7 @@
"Share the current conversation to a markdown or json file. Usage: /chat share <file>",
"Usage: /approval-mode <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
}