diff --git a/.env.example b/.env.example deleted file mode 100644 index a6c5187..0000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# Anthropic API Key (必需) -ANTHROPIC_API_KEY=sk-ant-xxxxx - -# 可选配置 -AI_MODEL=claude-sonnet-4-20250514 -AI_MAX_TOKENS=4096 - -# Vision 配置(用于图片理解,当主模型不支持 vision 时使用) -# 如果不配置,默认使用 Anthropic Claude -# VISION_PROVIDER=anthropic -# VISION_MODEL=claude-sonnet-4-20250514 diff --git a/packages/core/src/provider/config.ts b/packages/core/src/provider/config.ts index eab73a3..924a04a 100644 --- a/packages/core/src/provider/config.ts +++ b/packages/core/src/provider/config.ts @@ -73,22 +73,9 @@ export async function saveProvidersConfig( /** * 获取提供商的 API Key - * 优先从环境变量获取,其次从配置获取 + * 从配置文件获取 */ -export function resolveApiKey(config?: ProviderConfig, envVar?: string): string | undefined { - // 优先使用配置中指定的环境变量 - if (config?.apiKeyEnvVar) { - const key = process.env[config.apiKeyEnvVar]; - if (key) return key; - } - - // 其次使用默认环境变量 - if (envVar) { - const key = process.env[envVar]; - if (key) return key; - } - - // 最后使用直接配置的 apiKey +export function resolveApiKey(config?: ProviderConfig): string | undefined { return config?.apiKey; } diff --git a/packages/core/src/provider/registry.ts b/packages/core/src/provider/registry.ts index 96dfc10..1bed0f0 100644 --- a/packages/core/src/provider/registry.ts +++ b/packages/core/src/provider/registry.ts @@ -126,7 +126,7 @@ export class ProviderRegistry { this.ensureInitialized(); return Array.from(this.providers.entries()).map(([id, provider]) => { const config = this.configs.get(id); - const apiKey = resolveApiKey(config, provider.info.apiKeyEnvVar); + const apiKey = resolveApiKey(config); const customModels = config?.customModels ?? []; return { @@ -165,7 +165,7 @@ export class ProviderRegistry { if (!provider) return undefined; const config = this.configs.get(id); - const apiKey = resolveApiKey(config, provider.info.apiKeyEnvVar); + const apiKey = resolveApiKey(config); return { id, @@ -355,7 +355,7 @@ export class ProviderRegistry { } const config = this.configs.get(providerId); - const resolvedApiKey = apiKey ?? resolveApiKey(config, provider.info.apiKeyEnvVar); + const resolvedApiKey = apiKey ?? resolveApiKey(config); if (!resolvedApiKey && !provider.info.baseUrl?.includes('localhost')) { return { success: false, error: 'API key not configured' }; @@ -390,7 +390,7 @@ export class ProviderRegistry { } const config = this.configs.get(providerId); - const apiKey = options?.apiKey ?? resolveApiKey(config, provider.info.apiKeyEnvVar); + const apiKey = options?.apiKey ?? resolveApiKey(config); const baseUrl = options?.baseUrl ?? config?.baseUrl ?? provider.info.baseUrl; if (!apiKey) { diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index a21f2be..d2c0e3e 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -2,15 +2,13 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { AgentConfig, ProviderType } from '../types/index.js'; +import { providerRegistry, resolveApiKey } from '../provider/index.js'; const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); interface StoredConfig { provider?: ProviderType; - apiKey?: string; - deepseekApiKey?: string; - openaiApiKey?: string; model?: string; maxTokens?: number; tavilyApiKey?: string; @@ -19,15 +17,11 @@ interface StoredConfig { // Vision 配置 visionProvider?: ProviderType; visionModel?: string; - /** Vision 专用的 API Key(可选,不设置则使用对应 provider 的 key) */ - visionApiKey?: string; /** Vision 专用的 Base URL(用于 OpenAI 兼容的 Vision 服务) */ visionBaseUrl?: string; // Summary 配置(用于对话压缩摘要生成) summaryProvider?: ProviderType; summaryModel?: string; - /** Summary 专用的 API Key(可选,不设置则使用对应 provider 的 key) */ - summaryApiKey?: string; /** Summary 专用的 Base URL(用于 OpenAI 兼容的 Summary 服务) */ summaryBaseUrl?: string; } @@ -106,63 +100,33 @@ export function getConfig(): StoredConfig { // 加载配置 export function loadConfig(): AgentConfig { - // 从环境变量获取 - const provider = (process.env.AI_PROVIDER as ProviderType) || 'anthropic'; - const anthropicApiKey = process.env.ANTHROPIC_API_KEY; - const deepseekApiKey = process.env.DEEPSEEK_API_KEY; - const openaiApiKey = process.env.OPENAI_API_KEY; - const model = process.env.AI_MODEL; - const maxTokens = parseInt(process.env.AI_MAX_TOKENS || '4096', 10); - const baseUrl = process.env.AI_BASE_URL; - // 从配置文件读取 - let storedConfig: StoredConfig = {}; - if (fs.existsSync(CONFIG_FILE)) { - try { - const content = fs.readFileSync(CONFIG_FILE, 'utf-8'); - storedConfig = JSON.parse(content); - } catch { - // 忽略解析错误 - } - } + const storedConfig = getConfig(); // 确定最终的 provider - const finalProvider = storedConfig.provider || provider; + const finalProvider = storedConfig.provider || 'anthropic'; - // 根据 provider 获取对应的 API Key - let finalApiKey: string | undefined; - if (finalProvider === 'anthropic') { - finalApiKey = anthropicApiKey || storedConfig.apiKey; - } else if (finalProvider === 'deepseek') { - finalApiKey = deepseekApiKey || storedConfig.deepseekApiKey; - } else if (finalProvider === 'openai') { - finalApiKey = openaiApiKey || storedConfig.openaiApiKey; - } + // 通过 ProviderRegistry 获取 API Key + const providerConfig = providerRegistry.getConfig(finalProvider); + const finalApiKey = resolveApiKey(providerConfig); if (!finalApiKey) { - const envVarMap: Record = { - anthropic: 'ANTHROPIC_API_KEY', - deepseek: 'DEEPSEEK_API_KEY', - openai: 'OPENAI_API_KEY', - }; - const envVar = envVarMap[finalProvider]; - console.error(`❌ 错误: 未设置 ${envVar}`); - console.error(`请设置环境变量: export ${envVar}=your-api-key`); - console.error('或运行: ai-assist init 进行初始化配置'); + console.error(`❌ 错误: 未配置 API Key`); + console.error(`请在设置中配置 ${finalProvider} 的 API Key`); process.exit(1); } // 确定模型 - const finalModel = model || storedConfig.model || DEFAULT_MODELS[finalProvider]; + const finalModel = storedConfig.model || DEFAULT_MODELS[finalProvider]; - // 确定 baseUrl(环境变量优先) - const finalBaseUrl = baseUrl || storedConfig.baseUrl; + // 确定 baseUrl + const finalBaseUrl = storedConfig.baseUrl || providerConfig?.baseUrl; return { provider: finalProvider, apiKey: finalApiKey, model: finalModel, - maxTokens: storedConfig.maxTokens || maxTokens, + maxTokens: storedConfig.maxTokens || 4096, systemPrompt: DEFAULT_SYSTEM_PROMPT, baseUrl: finalBaseUrl, }; @@ -171,38 +135,18 @@ export function loadConfig(): AgentConfig { /** * 加载 Vision 配置 * Vision 用于图片理解,当主模型不支持 vision 时使用 - * 优先级:环境变量 > 配置文件 > 默认使用 Anthropic Claude + * 通过 ProviderRegistry 获取 API Key */ export function loadVisionConfig(): VisionConfig | null { - // 从环境变量获取 - const visionProvider = process.env.VISION_PROVIDER as ProviderType | undefined; - const visionModel = process.env.VISION_MODEL; - const visionApiKey = process.env.VISION_API_KEY; - const visionBaseUrl = process.env.VISION_BASE_URL; - const anthropicApiKey = process.env.ANTHROPIC_API_KEY; - const deepseekApiKey = process.env.DEEPSEEK_API_KEY; - const openaiApiKey = process.env.OPENAI_API_KEY; - // 从配置文件读取 const storedConfig = getConfig(); // 确定 vision provider(默认使用 anthropic,因为 Claude 支持 vision) - const finalProvider = visionProvider || storedConfig.visionProvider || 'anthropic'; + const finalProvider = storedConfig.visionProvider || 'anthropic'; - // 获取 Vision 专用的 API Key(优先级:环境变量 > 配置文件专用 key > provider 对应的 key) - let finalApiKey: string | undefined; - finalApiKey = visionApiKey || storedConfig.visionApiKey; - - // 如果没有专用 key,回退到对应 provider 的 key - if (!finalApiKey) { - if (finalProvider === 'anthropic') { - finalApiKey = anthropicApiKey || storedConfig.apiKey; - } else if (finalProvider === 'deepseek') { - finalApiKey = deepseekApiKey || storedConfig.deepseekApiKey; - } else if (finalProvider === 'openai') { - finalApiKey = openaiApiKey || storedConfig.openaiApiKey; - } - } + // 通过 ProviderRegistry 获取 API Key + const providerConfig = providerRegistry.getConfig(finalProvider); + const finalApiKey = resolveApiKey(providerConfig); // 如果没有 API Key,返回 null if (!finalApiKey) { @@ -210,10 +154,10 @@ export function loadVisionConfig(): VisionConfig | null { } // 确定模型 - const finalModel = visionModel || storedConfig.visionModel || DEFAULT_VISION_MODELS[finalProvider]; + const finalModel = storedConfig.visionModel || DEFAULT_VISION_MODELS[finalProvider]; - // 确定 baseUrl(Vision 专用) - const finalBaseUrl = visionBaseUrl || storedConfig.visionBaseUrl; + // 确定 baseUrl + const finalBaseUrl = storedConfig.visionBaseUrl || providerConfig?.baseUrl; return { provider: finalProvider, @@ -226,52 +170,26 @@ export function loadVisionConfig(): VisionConfig | null { /** * 加载 Summary 配置 * Summary 用于对话压缩时生成摘要,推荐使用成本较低的小模型 - * 优先级:环境变量 > 配置文件 > null(使用主模型) + * 通过 ProviderRegistry 获取 API Key */ export function loadSummaryConfig(): SummaryConfig | null { - // 从环境变量获取 - const summaryProvider = process.env.SUMMARY_PROVIDER as ProviderType | undefined; - const summaryModel = process.env.SUMMARY_MODEL; - const summaryApiKey = process.env.SUMMARY_API_KEY; - const summaryBaseUrl = process.env.SUMMARY_BASE_URL; - const anthropicApiKey = process.env.ANTHROPIC_API_KEY; - const deepseekApiKey = process.env.DEEPSEEK_API_KEY; - const openaiApiKey = process.env.OPENAI_API_KEY; - // 从配置文件读取 const storedConfig = getConfig(); // 如果没有任何 summary 相关配置,返回 null(使用主模型) - const hasSummaryConfig = - summaryProvider || - summaryModel || - summaryApiKey || - storedConfig.summaryProvider || - storedConfig.summaryModel || - storedConfig.summaryApiKey; + const hasSummaryConfig = storedConfig.summaryProvider || storedConfig.summaryModel; if (!hasSummaryConfig) { return null; } // 确定 summary provider(默认使用主配置的 provider) - const mainProvider = (process.env.AI_PROVIDER as ProviderType) || storedConfig.provider || 'anthropic'; - const finalProvider = summaryProvider || storedConfig.summaryProvider || mainProvider; + const mainProvider = storedConfig.provider || 'anthropic'; + const finalProvider = storedConfig.summaryProvider || mainProvider; - // 获取 Summary 专用的 API Key(优先级:环境变量 > 配置文件专用 key > provider 对应的 key) - let finalApiKey: string | undefined; - finalApiKey = summaryApiKey || storedConfig.summaryApiKey; - - // 如果没有专用 key,回退到对应 provider 的 key - if (!finalApiKey) { - if (finalProvider === 'anthropic') { - finalApiKey = anthropicApiKey || storedConfig.apiKey; - } else if (finalProvider === 'deepseek') { - finalApiKey = deepseekApiKey || storedConfig.deepseekApiKey; - } else if (finalProvider === 'openai') { - finalApiKey = openaiApiKey || storedConfig.openaiApiKey; - } - } + // 通过 ProviderRegistry 获取 API Key + const providerConfig = providerRegistry.getConfig(finalProvider); + const finalApiKey = resolveApiKey(providerConfig); // 如果没有 API Key,返回 null if (!finalApiKey) { @@ -279,10 +197,10 @@ export function loadSummaryConfig(): SummaryConfig | null { } // 确定模型 - const finalModel = summaryModel || storedConfig.summaryModel || DEFAULT_SUMMARY_MODELS[finalProvider]; + const finalModel = storedConfig.summaryModel || DEFAULT_SUMMARY_MODELS[finalProvider]; - // 确定 baseUrl(Summary 专用) - const finalBaseUrl = summaryBaseUrl || storedConfig.summaryBaseUrl; + // 确定 baseUrl + const finalBaseUrl = storedConfig.summaryBaseUrl || providerConfig?.baseUrl; return { provider: finalProvider, diff --git a/packages/core/tests/unit/utils/config-extended.test.ts b/packages/core/tests/unit/utils/config-extended.test.ts index 242a5b1..88280ee 100644 --- a/packages/core/tests/unit/utils/config-extended.test.ts +++ b/packages/core/tests/unit/utils/config-extended.test.ts @@ -11,6 +11,15 @@ vi.mock('fs', () => ({ mkdirSync: vi.fn(), })); +// Mock provider registry +vi.mock('../../../src/provider/index.js', () => ({ + providerRegistry: { + getInfo: vi.fn(), + getConfig: vi.fn(), + }, + resolveApiKey: vi.fn(), +})); + // Mock process.exit const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); @@ -25,18 +34,6 @@ describe('Config - 配置管理扩展测试', () => { beforeEach(() => { vi.clearAllMocks(); - // 清理环境变量 - delete process.env.AI_PROVIDER; - delete process.env.ANTHROPIC_API_KEY; - delete process.env.DEEPSEEK_API_KEY; - delete process.env.OPENAI_API_KEY; - delete process.env.AI_MODEL; - delete process.env.AI_MAX_TOKENS; - delete process.env.AI_BASE_URL; - delete process.env.VISION_PROVIDER; - delete process.env.VISION_MODEL; - delete process.env.VISION_API_KEY; - delete process.env.VISION_BASE_URL; }); afterEach(() => { @@ -48,7 +45,6 @@ describe('Config - 配置管理扩展测试', () => { it('配置文件不存在返回空对象', async () => { vi.mocked(fs.existsSync).mockReturnValue(false); - // 需要重新导入以获取最新的 mock vi.resetModules(); const { getConfig } = await import('../../../src/utils/config.js'); @@ -60,7 +56,6 @@ describe('Config - 配置管理扩展测试', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ provider: 'anthropic', - apiKey: 'test-key', model: 'claude-sonnet-4-20250514', })); @@ -69,7 +64,7 @@ describe('Config - 配置管理扩展测试', () => { const config = getConfig(); expect(config.provider).toBe('anthropic'); - expect(config.apiKey).toBe('test-key'); + expect(config.model).toBe('claude-sonnet-4-20250514'); }); it('配置文件解析错误返回空对象', async () => { @@ -85,100 +80,61 @@ describe('Config - 配置管理扩展测试', () => { }); describe('loadConfig - 加载配置', () => { - it('优先使用环境变量中的 API Key', async () => { - process.env.ANTHROPIC_API_KEY = 'env-api-key'; + it('通过 ProviderRegistry 获取 API Key', async () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ provider: 'anthropic', - apiKey: 'file-api-key', })); vi.resetModules(); + const { resolveApiKey } = await import('../../../src/provider/index.js'); const { loadConfig } = await import('../../../src/utils/config.js'); + vi.mocked(resolveApiKey).mockReturnValue('resolved-api-key'); + const config = loadConfig(); - expect(config.apiKey).toBe('env-api-key'); + expect(config.apiKey).toBe('resolved-api-key'); }); it('使用配置文件中的 provider', async () => { - process.env.DEEPSEEK_API_KEY = 'deepseek-key'; vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ provider: 'deepseek', })); vi.resetModules(); + const { resolveApiKey } = await import('../../../src/provider/index.js'); const { loadConfig } = await import('../../../src/utils/config.js'); + vi.mocked(resolveApiKey).mockReturnValue('deepseek-key'); + const config = loadConfig(); expect(config.provider).toBe('deepseek'); expect(config.apiKey).toBe('deepseek-key'); }); - it('OpenAI provider 使用 OPENAI_API_KEY', async () => { - process.env.OPENAI_API_KEY = 'openai-key'; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'openai', - })); - - vi.resetModules(); - const { loadConfig } = await import('../../../src/utils/config.js'); - - const config = loadConfig(); - expect(config.provider).toBe('openai'); - expect(config.apiKey).toBe('openai-key'); - }); - - it('环境变量 AI_MODEL 覆盖配置文件', async () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - process.env.AI_MODEL = 'claude-opus-4-20250514'; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - model: 'claude-sonnet-4-20250514', - })); - - vi.resetModules(); - const { loadConfig } = await import('../../../src/utils/config.js'); - - const config = loadConfig(); - expect(config.model).toBe('claude-opus-4-20250514'); - }); - it('使用默认模型', async () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; vi.mocked(fs.existsSync).mockReturnValue(false); vi.resetModules(); + const { resolveApiKey } = await import('../../../src/provider/index.js'); const { loadConfig } = await import('../../../src/utils/config.js'); + vi.mocked(resolveApiKey).mockReturnValue('test-key'); + const config = loadConfig(); expect(config.model).toBe('claude-sonnet-4-20250514'); }); - it('环境变量 AI_BASE_URL 覆盖配置文件', async () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - process.env.AI_BASE_URL = 'https://custom-api.example.com'; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - baseUrl: 'https://config-api.example.com', - })); - - vi.resetModules(); - const { loadConfig } = await import('../../../src/utils/config.js'); - - const config = loadConfig(); - expect(config.baseUrl).toBe('https://custom-api.example.com'); - }); - it('无 API Key 时退出程序', async () => { vi.mocked(fs.existsSync).mockReturnValue(false); vi.resetModules(); + const { resolveApiKey } = await import('../../../src/provider/index.js'); const { loadConfig } = await import('../../../src/utils/config.js'); + vi.mocked(resolveApiKey).mockReturnValue(undefined); + expect(() => loadConfig()).toThrow('process.exit called'); expect(mockConsoleError).toHaveBeenCalled(); }); @@ -189,66 +145,29 @@ describe('Config - 配置管理扩展测试', () => { vi.mocked(fs.existsSync).mockReturnValue(false); vi.resetModules(); + const { resolveApiKey } = await import('../../../src/provider/index.js'); const { loadVisionConfig } = await import('../../../src/utils/config.js'); + vi.mocked(resolveApiKey).mockReturnValue(undefined); + const config = loadVisionConfig(); expect(config).toBeNull(); }); - it('使用环境变量配置', async () => { - process.env.VISION_PROVIDER = 'openai'; - process.env.VISION_MODEL = 'gpt-4o'; - process.env.VISION_API_KEY = 'vision-key'; - process.env.VISION_BASE_URL = 'https://vision-api.example.com'; - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { loadVisionConfig } = await import('../../../src/utils/config.js'); - - const config = loadVisionConfig(); - expect(config).not.toBeNull(); - expect(config!.provider).toBe('openai'); - expect(config!.model).toBe('gpt-4o'); - expect(config!.apiKey).toBe('vision-key'); - expect(config!.baseUrl).toBe('https://vision-api.example.com'); - }); - - it('默认使用 anthropic provider', async () => { - process.env.ANTHROPIC_API_KEY = 'anthropic-key'; - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { loadVisionConfig } = await import('../../../src/utils/config.js'); - - const config = loadVisionConfig(); - expect(config).not.toBeNull(); - expect(config!.provider).toBe('anthropic'); - }); - - it('Vision 专用 Key 优先于 provider Key', async () => { - process.env.ANTHROPIC_API_KEY = 'anthropic-key'; - process.env.VISION_API_KEY = 'vision-specific-key'; - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { loadVisionConfig } = await import('../../../src/utils/config.js'); - - const config = loadVisionConfig(); - expect(config!.apiKey).toBe('vision-specific-key'); - }); - - it('从配置文件加载 Vision 设置', async () => { + it('从配置文件获取 Vision 设置', async () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ visionProvider: 'openai', visionModel: 'gpt-4o', - visionApiKey: 'config-vision-key', visionBaseUrl: 'https://config-vision.example.com', })); vi.resetModules(); + const { resolveApiKey } = await import('../../../src/provider/index.js'); const { loadVisionConfig } = await import('../../../src/utils/config.js'); + vi.mocked(resolveApiKey).mockReturnValue('config-vision-key'); + const config = loadVisionConfig(); expect(config).not.toBeNull(); expect(config!.provider).toBe('openai'); @@ -256,19 +175,18 @@ describe('Config - 配置管理扩展测试', () => { expect(config!.apiKey).toBe('config-vision-key'); }); - it('DeepSeek provider 回退到 deepseekApiKey', async () => { - process.env.DEEPSEEK_API_KEY = 'deepseek-key'; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - visionProvider: 'deepseek', - })); + it('默认使用 anthropic provider', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); vi.resetModules(); + const { resolveApiKey } = await import('../../../src/provider/index.js'); const { loadVisionConfig } = await import('../../../src/utils/config.js'); + vi.mocked(resolveApiKey).mockReturnValue('anthropic-key'); + const config = loadVisionConfig(); expect(config).not.toBeNull(); - expect(config!.apiKey).toBe('deepseek-key'); + expect(config!.provider).toBe('anthropic'); }); }); @@ -288,7 +206,6 @@ describe('Config - 配置管理扩展测试', () => { vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ provider: 'anthropic', - apiKey: 'existing-key', })); vi.resetModules(); @@ -322,17 +239,5 @@ describe('Config - 配置管理扩展测试', () => { const savedConfig = JSON.parse(writeCall[1] as string); expect(savedConfig.model).toBe('new-model'); }); - - it('解析错误时使用空对象合并', async () => { - vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE); - vi.mocked(fs.readFileSync).mockReturnValue('invalid json'); - - vi.resetModules(); - const { saveConfig } = await import('../../../src/utils/config.js'); - - saveConfig({ provider: 'deepseek' }); - - expect(fs.writeFileSync).toHaveBeenCalled(); - }); }); }); diff --git a/packages/core/tests/unit/utils/config.test.ts b/packages/core/tests/unit/utils/config.test.ts index 8e548d5..0377985 100644 --- a/packages/core/tests/unit/utils/config.test.ts +++ b/packages/core/tests/unit/utils/config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; // Mock fs vi.mock('fs', () => ({ @@ -8,31 +8,22 @@ vi.mock('fs', () => ({ mkdirSync: vi.fn(), })); +// Mock provider registry +vi.mock('../../../src/provider/index.js', () => ({ + providerRegistry: { + getInfo: vi.fn(), + getConfig: vi.fn(), + }, + resolveApiKey: vi.fn(), +})); + import * as fs from 'fs'; +import { providerRegistry, resolveApiKey } from '../../../src/provider/index.js'; import { getConfig, loadConfig, saveConfig, loadVisionConfig } from '../../../src/utils/config.js'; describe('Config - 配置管理', () => { - const originalEnv = { ...process.env }; - beforeEach(() => { vi.clearAllMocks(); - // 清理环境变量 - delete process.env.AI_PROVIDER; - delete process.env.ANTHROPIC_API_KEY; - delete process.env.DEEPSEEK_API_KEY; - delete process.env.OPENAI_API_KEY; - delete process.env.AI_MODEL; - delete process.env.AI_MAX_TOKENS; - delete process.env.AI_BASE_URL; - delete process.env.VISION_PROVIDER; - delete process.env.VISION_MODEL; - delete process.env.VISION_API_KEY; - delete process.env.VISION_BASE_URL; - }); - - afterEach(() => { - // 恢复环境变量 - process.env = { ...originalEnv }; }); describe('getConfig - 获取原始配置', () => { @@ -40,14 +31,12 @@ describe('Config - 配置管理', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ provider: 'anthropic', - apiKey: 'test-key', model: 'claude-3-opus', })); const config = getConfig(); expect(config.provider).toBe('anthropic'); - expect(config.apiKey).toBe('test-key'); expect(config.model).toBe('claude-3-opus'); }); @@ -70,36 +59,41 @@ describe('Config - 配置管理', () => { }); describe('loadConfig - 加载完整配置', () => { - it('从环境变量获取 Anthropic 配置', () => { - process.env.ANTHROPIC_API_KEY = 'env-anthropic-key'; + it('通过 ProviderRegistry 获取 Anthropic 配置', () => { vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(providerRegistry.getConfig).mockReturnValue(undefined); + vi.mocked(resolveApiKey).mockReturnValue('resolved-anthropic-key'); const config = loadConfig(); expect(config.provider).toBe('anthropic'); - expect(config.apiKey).toBe('env-anthropic-key'); + expect(config.apiKey).toBe('resolved-anthropic-key'); expect(config.model).toBe('claude-sonnet-4-20250514'); }); - it('从环境变量获取 DeepSeek 配置', () => { - process.env.AI_PROVIDER = 'deepseek'; - process.env.DEEPSEEK_API_KEY = 'env-deepseek-key'; - vi.mocked(fs.existsSync).mockReturnValue(false); + it('从配置文件获取 DeepSeek provider', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + provider: 'deepseek', + })); + vi.mocked(providerRegistry.getConfig).mockReturnValue(undefined); + vi.mocked(resolveApiKey).mockReturnValue('resolved-deepseek-key'); const config = loadConfig(); expect(config.provider).toBe('deepseek'); - expect(config.apiKey).toBe('env-deepseek-key'); + expect(config.apiKey).toBe('resolved-deepseek-key'); expect(config.model).toBe('deepseek-chat'); }); - it('配置文件优先级高于默认值', () => { - process.env.ANTHROPIC_API_KEY = 'env-key'; + it('配置文件中的 model 和 maxTokens', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ model: 'custom-model', maxTokens: 8192, })); + vi.mocked(providerRegistry.getConfig).mockReturnValue(undefined); + vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); @@ -107,25 +101,9 @@ describe('Config - 配置管理', () => { expect(config.maxTokens).toBe(8192); }); - it('配置文件中的 provider 优先', () => { - process.env.ANTHROPIC_API_KEY = 'anthropic-key'; - process.env.DEEPSEEK_API_KEY = 'deepseek-key'; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'deepseek', - deepseekApiKey: 'stored-deepseek-key', - })); - - const config = loadConfig(); - - expect(config.provider).toBe('deepseek'); - // 使用环境变量中的 API Key(优先级更高) - expect(config.apiKey).toBe('deepseek-key'); - }); - it('包含系统提示词', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); @@ -134,22 +112,37 @@ describe('Config - 配置管理', () => { }); it('默认 maxTokens 为 4096', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); expect(config.maxTokens).toBe(4096); }); - it('环境变量设置的 maxTokens', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - process.env.AI_MAX_TOKENS = '16384'; - vi.mocked(fs.existsSync).mockReturnValue(false); + it('从配置文件获取 baseUrl', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + baseUrl: 'https://custom.api.com/v1', + })); + vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); - expect(config.maxTokens).toBe(16384); + expect(config.baseUrl).toBe('https://custom.api.com/v1'); + }); + + it('从 ProviderConfig 获取 baseUrl', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ + id: 'anthropic', + baseUrl: 'https://provider-config.api.com/v1', + }); + vi.mocked(resolveApiKey).mockReturnValue('test-key'); + + const config = loadConfig(); + + expect(config.baseUrl).toBe('https://provider-config.api.com/v1'); }); }); @@ -157,7 +150,7 @@ describe('Config - 配置管理', () => { it('创建目录并保存配置', () => { vi.mocked(fs.existsSync).mockReturnValue(false); - saveConfig({ provider: 'anthropic', apiKey: 'new-key' }); + saveConfig({ provider: 'anthropic' }); expect(fs.mkdirSync).toHaveBeenCalledWith( expect.any(String), @@ -173,18 +166,16 @@ describe('Config - 配置管理', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ provider: 'anthropic', - apiKey: 'old-key', model: 'old-model', })); - saveConfig({ apiKey: 'new-key' }); + saveConfig({ model: 'new-model' }); const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; const savedConfig = JSON.parse(writeCall[1] as string); - expect(savedConfig.provider).toBe('anthropic'); // 保留 - expect(savedConfig.apiKey).toBe('new-key'); // 更新 - expect(savedConfig.model).toBe('old-model'); // 保留 + expect(savedConfig.provider).toBe('anthropic'); + expect(savedConfig.model).toBe('new-model'); }); it('目录已存在时不重新创建', () => { @@ -193,75 +184,29 @@ describe('Config - 配置管理', () => { .mockReturnValueOnce(true); // 配置文件存在 vi.mocked(fs.readFileSync).mockReturnValue('{}'); - saveConfig({ apiKey: 'test' }); + saveConfig({ provider: 'test' }); expect(fs.mkdirSync).not.toHaveBeenCalled(); }); }); - describe('loadConfig - baseUrl 支持', () => { - it('从环境变量获取 baseUrl', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - process.env.AI_BASE_URL = 'https://custom.api.com/v1'; - vi.mocked(fs.existsSync).mockReturnValue(false); - - const config = loadConfig(); - - expect(config.baseUrl).toBe('https://custom.api.com/v1'); - }); - - it('从配置文件获取 baseUrl', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - baseUrl: 'https://stored.api.com/v1', - })); - - const config = loadConfig(); - - expect(config.baseUrl).toBe('https://stored.api.com/v1'); - }); - - it('环境变量 baseUrl 优先于配置文件', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - process.env.AI_BASE_URL = 'https://env.api.com/v1'; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - baseUrl: 'https://stored.api.com/v1', - })); - - const config = loadConfig(); - - expect(config.baseUrl).toBe('https://env.api.com/v1'); - }); - - it('OpenAI provider 支持 baseUrl', () => { - process.env.AI_PROVIDER = 'openai'; - process.env.OPENAI_API_KEY = 'test-openai-key'; - process.env.AI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; - vi.mocked(fs.existsSync).mockReturnValue(false); - - const config = loadConfig(); - - expect(config.provider).toBe('openai'); - expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1'); - }); - }); - describe('loadVisionConfig - Vision 配置', () => { it('返回 null 当没有配置 Vision API Key', () => { vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(resolveApiKey).mockReturnValue(undefined); const visionConfig = loadVisionConfig(); expect(visionConfig).toBeNull(); }); - it('从环境变量获取 Vision 配置', () => { - process.env.VISION_PROVIDER = 'openai'; - process.env.VISION_API_KEY = 'vision-api-key'; - process.env.VISION_MODEL = 'gpt-4-vision'; - vi.mocked(fs.existsSync).mockReturnValue(false); + it('通过 ProviderRegistry 获取 Vision 配置', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + visionProvider: 'openai', + visionModel: 'gpt-4-vision', + })); + vi.mocked(resolveApiKey).mockReturnValue('vision-api-key'); const visionConfig = loadVisionConfig(); @@ -271,87 +216,35 @@ describe('Config - 配置管理', () => { expect(visionConfig?.model).toBe('gpt-4-vision'); }); - it('从配置文件获取 Vision 配置', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - visionProvider: 'anthropic', - visionApiKey: 'stored-vision-key', - visionModel: 'claude-3-opus', - })); + it('默认使用 anthropic provider', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(resolveApiKey).mockReturnValue('anthropic-key'); const visionConfig = loadVisionConfig(); expect(visionConfig).not.toBeNull(); expect(visionConfig?.provider).toBe('anthropic'); - expect(visionConfig?.apiKey).toBe('stored-vision-key'); - expect(visionConfig?.model).toBe('claude-3-opus'); - }); - - it('回退到对应 provider 的 API Key', () => { - process.env.ANTHROPIC_API_KEY = 'anthropic-main-key'; - vi.mocked(fs.existsSync).mockReturnValue(false); - - const visionConfig = loadVisionConfig(); - - expect(visionConfig).not.toBeNull(); - expect(visionConfig?.provider).toBe('anthropic'); // 默认 - expect(visionConfig?.apiKey).toBe('anthropic-main-key'); // 回退 }); it('Vision baseUrl 配置', () => { - process.env.VISION_PROVIDER = 'openai'; - process.env.VISION_API_KEY = 'vision-key'; - process.env.VISION_BASE_URL = 'https://vision.api.com/v1'; - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + visionBaseUrl: 'https://vision.api.com/v1', + })); + vi.mocked(resolveApiKey).mockReturnValue('vision-key'); const visionConfig = loadVisionConfig(); expect(visionConfig?.baseUrl).toBe('https://vision.api.com/v1'); }); - it('从配置文件回退到 deepseek API key', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - visionProvider: 'deepseek', - deepseekApiKey: 'deepseek-stored-key', - })); - - const visionConfig = loadVisionConfig(); - - expect(visionConfig?.provider).toBe('deepseek'); - expect(visionConfig?.apiKey).toBe('deepseek-stored-key'); - }); - - it('从配置文件回退到 openai API key', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - visionProvider: 'openai', - openaiApiKey: 'openai-stored-key', - })); - - const visionConfig = loadVisionConfig(); - - expect(visionConfig?.provider).toBe('openai'); - expect(visionConfig?.apiKey).toBe('openai-stored-key'); - }); - it('使用默认 Vision 模型', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(resolveApiKey).mockReturnValue('test-key'); const visionConfig = loadVisionConfig(); expect(visionConfig?.model).toBe('claude-sonnet-4-20250514'); }); - - it('Vision 专用 key 优先于 provider key', () => { - process.env.ANTHROPIC_API_KEY = 'anthropic-main-key'; - process.env.VISION_API_KEY = 'vision-specific-key'; - vi.mocked(fs.existsSync).mockReturnValue(false); - - const visionConfig = loadVisionConfig(); - - expect(visionConfig?.apiKey).toBe('vision-specific-key'); - }); }); });