diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..246b995d --- /dev/null +++ b/PLAN.md @@ -0,0 +1,242 @@ +# 模型配置重构计划 — Provider 管理系统 + +## 背景 + +当前项目的模型配置存在以下问题: +- 模型列表硬编码在 `src/server/api/models.ts` 的 `AVAILABLE_MODELS` 数组中 +- 不支持自定义 Provider(供应商) +- 不支持自定义 Base URL 和 API Key +- 无法测试模型连通性 +- 无法管理多个 Provider 并在它们之间切换 + +## 设计原则 + +1. **非侵入性** — 不修改 Claude Code 原生 settings.json 的 schema,通过 `env` 字段注入环境变量 +2. **简洁** — 不过度设计,只实现核心功能:Provider CRUD、激活切换、连通性测试 +3. **兼容** — 借鉴 cc-switch 的激活机制,通过写入 `settings.json` 的 `env` 字段实现 Provider 切换 + +## 核心机制 + +Claude Code 的 settings.json 支持 `env` 字段,会在启动时注入到 `process.env`: +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_AUTH_TOKEN": "sk-xxx" + }, + "model": "claude-opus-4-6" +} +``` + +**激活 Provider = 将其 baseUrl/apiKey/model 写入 settings.json 的 env 字段** + +--- + +## 数据模型 + +### Provider 类型定义 + +```typescript +// src/server/types/provider.ts + +interface ProviderModel { + id: string // 模型 ID,如 "claude-opus-4-6" + name: string // 显示名称,如 "Opus 4.6" + description?: string // 简短描述 + context?: string // 上下文窗口,如 "200k" +} + +interface Provider { + id: string // UUID + name: string // 显示名称,如 "Anthropic 官方"、"OpenRouter" + baseUrl: string // API Base URL + apiKey: string // API Key + models: ProviderModel[] // 该 Provider 支持的模型列表 + isActive: boolean // 是否为当前激活的 Provider + createdAt: number // 创建时间戳 + updatedAt: number // 更新时间戳 + notes?: string // 备注 +} +``` + +### 存储格式 + +文件路径:`~/.claude/providers.json` + +```json +{ + "providers": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Anthropic 官方", + "baseUrl": "https://api.anthropic.com", + "apiKey": "sk-ant-xxx", + "models": [ + { "id": "claude-opus-4-6", "name": "Opus 4.6", "description": "Most capable", "context": "200k" }, + { "id": "claude-sonnet-4-6", "name": "Sonnet 4.6", "description": "Most efficient", "context": "200k" }, + { "id": "claude-haiku-4-5", "name": "Haiku 4.5", "description": "Fastest", "context": "200k" } + ], + "isActive": true, + "createdAt": 1712476800000, + "updatedAt": 1712476800000 + }, + { + "id": "660e8400-e29b-41d4-a716-446655440001", + "name": "OpenRouter", + "baseUrl": "https://openrouter.ai/api/v1", + "apiKey": "sk-or-xxx", + "models": [ + { "id": "anthropic/claude-opus-4-6", "name": "Claude Opus 4.6", "context": "200k" } + ], + "isActive": false, + "createdAt": 1712476800000, + "updatedAt": 1712476800000 + } + ], + "activeModel": "claude-opus-4-6", + "version": 1 +} +``` + +--- + +## 实现步骤 + +### Step 1: Provider 服务层 (`src/server/services/providerService.ts`) + +创建 `ProviderService` 类,负责: + +- `listProviders()` — 读取 `~/.claude/providers.json` 并返回 provider 列表 +- `getProvider(id)` — 获取单个 provider +- `getActiveProvider()` — 获取当前激活的 provider +- `addProvider(data)` — 添加新 provider(自动生成 UUID) +- `updateProvider(id, data)` — 更新 provider 信息 +- `deleteProvider(id)` — 删除 provider(不允许删除激活中的 provider) +- `activateProvider(id, modelId)` — 激活 provider 并选择模型 + - 将旧 provider 设为 `isActive: false` + - 将新 provider 设为 `isActive: true` + - 写入 `~/.claude/settings.json` 的 `env` 字段: + ```json + { + "env": { + "ANTHROPIC_BASE_URL": "", + "ANTHROPIC_AUTH_TOKEN": "" + }, + "model": "" + } + ``` +- `testProvider(id)` / `testProviderConfig(baseUrl, apiKey, modelId)` — 连通性测试 + - 向 `baseUrl/v1/messages` 发送一个最小请求(max_tokens=1, "Hi") + - 返回 `{ success, latencyMs, error?, modelUsed? }` + +### Step 2: Provider REST API (`src/server/api/providers.ts`) + +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | `/api/providers` | 获取 provider 列表 | +| GET | `/api/providers/:id` | 获取单个 provider | +| POST | `/api/providers` | 添加 provider | +| PUT | `/api/providers/:id` | 更新 provider | +| DELETE | `/api/providers/:id` | 删除 provider | +| POST | `/api/providers/:id/activate` | 激活 provider 并选择模型 | +| POST | `/api/providers/:id/test` | 测试已保存 provider 的连通性 | +| POST | `/api/providers/test` | 测试未保存配置的连通性(用于添加时预检) | + +### Step 3: 注册路由 (`src/server/router.ts`) + +在 router 中添加 `providers` 路由: +```typescript +case 'providers': + return handleProvidersApi(req, url, segments) +``` + +### Step 4: 重构 Models API (`src/server/api/models.ts`) + +修改现有的 `/api/models` 端点: +- **GET `/api/models`** — 不再返回硬编码列表,而是从当前激活的 Provider 读取模型列表 +- **GET `/api/models/current`** — 从 providers.json 的 `activeModel` 读取 +- **PUT `/api/models/current`** — 更新 `activeModel` 并同步到 settings.json + +保留 Effort Level 相关 API 不变。 + +### Step 5: Provider 类型定义 (`src/server/types/provider.ts`) + +独立的类型文件,包含: +- `Provider` 接口 +- `ProviderModel` 接口 +- `ProviderTestResult` 接口 +- `ProvidersConfig` 接口(providers.json 的根类型) +- Zod schema 验证 + +--- + +## 文件变更清单 + +| 操作 | 文件路径 | 说明 | +|------|---------|------| +| 新建 | `src/server/types/provider.ts` | Provider 类型定义和 Zod schema | +| 新建 | `src/server/services/providerService.ts` | Provider 服务层(CRUD + 激活 + 测试) | +| 新建 | `src/server/api/providers.ts` | Provider REST API 路由处理 | +| 修改 | `src/server/router.ts` | 注册 `/api/providers` 路由 | +| 修改 | `src/server/api/models.ts` | 从 Provider 动态读取模型列表 | + +**总计**: 3 个新文件 + 2 个修改文件 + +--- + +## 激活流程图 + +``` +用户选择 Provider "OpenRouter" + 模型 "claude-opus-4-6" + │ + ├─ 1. 更新 providers.json + │ - 旧 provider: isActive = false + │ - 新 provider: isActive = true + │ - activeModel = "claude-opus-4-6" + │ + ├─ 2. 读取当前 ~/.claude/settings.json + │ + ├─ 3. 合并写入 settings.json + │ { + │ ...existingSettings, + │ "env": { + │ ...existingEnv, + │ "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1", + │ "ANTHROPIC_AUTH_TOKEN": "sk-or-xxx" + │ }, + │ "model": "claude-opus-4-6" + │ } + │ + └─ 4. 返回成功响应 +``` + +## 连通性测试流程 + +``` +POST /api/providers/test +Body: { baseUrl, apiKey, modelId } + │ + ├─ 1. 构造最小请求 + │ POST {baseUrl}/v1/messages + │ Headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" } + │ Body: { model: modelId, max_tokens: 1, messages: [{ role: "user", content: "Hi" }] } + │ + ├─ 2. 记录开始时间 + │ + ├─ 3. 发送请求(超时 15 秒) + │ + └─ 4. 返回结果 + 成功: { success: true, latencyMs: 850, modelUsed: "claude-opus-4-6" } + 失败: { success: false, error: "401 Unauthorized", latencyMs: 200 } +``` + +--- + +## 不在本次范围内 + +- 前端 UI 组件(后续单独实现) +- Provider 图标管理 +- API Key 加密存储(V2 考虑) +- 多 API 格式支持(OpenAI 兼容等,V2 考虑) +- Provider 导入/导出 +- 自动故障转移(failover) diff --git a/src/server/__tests__/providers-real.test.ts b/src/server/__tests__/providers-real.test.ts new file mode 100644 index 00000000..dea45f60 --- /dev/null +++ b/src/server/__tests__/providers-real.test.ts @@ -0,0 +1,227 @@ +/** + * 用真实的 Provider 配置测试 ProviderService + * 验证添加、激活、settings.json 同步是否正确 + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'fs/promises' +import * as path from 'path' +import * as os from 'os' +import { ProviderService } from '../services/providerService.js' + +describe('Real Provider Configs', () => { + let tmpDir: string + let service: ProviderService + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-real-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir + service = new ProviderService() + }) + + afterEach(async () => { + delete process.env.CLAUDE_CONFIG_DIR + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test('添加 MiniMax Provider 并激活', async () => { + const minimax = await service.addProvider({ + name: 'MiniMax', + baseUrl: 'https://api.minimaxi.com/anthropic', + apiKey: 'sk-api-aijgzQKtr1vLPS1KaoySY7_TRGTXCiQTCvgmoLOG31eyMfnrTBOLdtsEd_fFGAghPY_9Tlxt_jPc6bs5bziApZoEIuGq6bAERlKd-XHebifv44HMxLHDvjQ', + models: [ + { id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', description: 'MiniMax 高速模型' }, + ], + notes: 'MiniMax 官方 Anthropic 兼容接口', + }) + + // 第一个 provider 应该自动激活 + expect(minimax.isActive).toBe(true) + expect(minimax.name).toBe('MiniMax') + + // 验证 settings.json 写入 + const settings = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')) + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic') + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe(minimax.apiKey) + expect(settings.model).toBe('MiniMax-M2.7-highspeed') + + console.log('✅ MiniMax Provider 添加并自动激活成功') + console.log(' settings.json env:', JSON.stringify(settings.env, null, 2)) + console.log(' settings.json model:', settings.model) + }) + + test('添加接口AI中转站 Provider,切换激活', async () => { + // 先添加 MiniMax(自动激活) + const minimax = await service.addProvider({ + name: 'MiniMax', + baseUrl: 'https://api.minimaxi.com/anthropic', + apiKey: 'sk-api-test-minimax', + models: [ + { id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed' }, + ], + }) + expect(minimax.isActive).toBe(true) + + // 添加接口AI中转站(不应自动激活) + const jiekou = await service.addProvider({ + name: '接口AI中转站', + baseUrl: 'https://api.jiekou.ai/anthropic', + apiKey: 'sk_WGYwseQ5YZ1Kb6tXbqnd-AXIAhlqFnYLfUt2gSF-vjQ', + models: [ + { id: 'claude-opus-4-6', name: 'Opus 4.6', description: 'Most capable', context: '200k' }, + { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6', description: 'Most efficient', context: '200k' }, + { id: 'claude-haiku-4-5-20251001', name: 'Haiku 4.5', description: 'Fastest', context: '200k' }, + ], + notes: '接口AI中转站 — 支持多个 Claude 模型', + }) + expect(jiekou.isActive).toBe(false) + + // 激活接口AI中转站,选择 Opus 4.6 + await service.activateProvider(jiekou.id, 'claude-opus-4-6') + + // 验证 providers.json + const providers = await service.listProviders() + const activeMinimax = providers.find(p => p.id === minimax.id)! + const activeJiekou = providers.find(p => p.id === jiekou.id)! + expect(activeMinimax.isActive).toBe(false) + expect(activeJiekou.isActive).toBe(true) + + // 验证 settings.json + const settings = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')) + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic') + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe(jiekou.apiKey) + expect(settings.model).toBe('claude-opus-4-6') + + console.log('✅ 接口AI中转站激活成功') + console.log(' settings.json env:', JSON.stringify(settings.env, null, 2)) + console.log(' settings.json model:', settings.model) + }) + + test('切换模型 — 从 Opus 切到 Sonnet', async () => { + const jiekou = await service.addProvider({ + name: '接口AI中转站', + baseUrl: 'https://api.jiekou.ai/anthropic', + apiKey: 'sk_test_jiekou', + models: [ + { id: 'claude-opus-4-6', name: 'Opus 4.6' }, + { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6' }, + { id: 'claude-haiku-4-5-20251001', name: 'Haiku 4.5' }, + ], + }) + + // 自动激活了第一个模型 opus + let settings = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')) + expect(settings.model).toBe('claude-opus-4-6') + + // 切换到 Sonnet + await service.activateProvider(jiekou.id, 'claude-sonnet-4-6') + settings = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')) + expect(settings.model).toBe('claude-sonnet-4-6') + + console.log('✅ 模型切换成功: opus → sonnet') + }) + + test('settings.json 保留已有字段', async () => { + // 预写一个有内容的 settings.json(模拟用户已有配置) + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + effortLevel: 'high', + language: '中文', + skipDangerousModePermissionPrompt: true, + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + EXISTING_VAR: 'should_be_preserved', + }, + hooks: { + Stop: [{ hooks: [{ command: 'echo done', type: 'command' }], matcher: '' }], + }, + }, null, 2), + ) + + // 添加并激活 provider + await service.addProvider({ + name: '接口AI中转站', + baseUrl: 'https://api.jiekou.ai/anthropic', + apiKey: 'sk_test', + models: [{ id: 'claude-opus-4-6', name: 'Opus 4.6' }], + }) + + const settings = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')) + + // 验证新字段写入 + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic') + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk_test') + expect(settings.model).toBe('claude-opus-4-6') + + // 验证已有字段保留 + expect(settings.effortLevel).toBe('high') + expect(settings.language).toBe('中文') + expect(settings.skipDangerousModePermissionPrompt).toBe(true) + expect(settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(settings.env.EXISTING_VAR).toBe('should_be_preserved') + expect(settings.hooks).toBeDefined() + expect(settings.hooks.Stop).toHaveLength(1) + + console.log('✅ settings.json 已有字段全部保留') + console.log(' 完整 settings:', JSON.stringify(settings, null, 2)) + }) + + test('连通性测试 — MiniMax(预期能连但可能 401)', async () => { + // 使用假 key 测试连通性机制本身 + const result = await service.testProviderConfig({ + baseUrl: 'https://api.minimaxi.com/anthropic', + apiKey: 'sk-fake-test-key', + modelId: 'MiniMax-M2.7-highspeed', + }) + + console.log('🔌 MiniMax 连通性测试结果:') + console.log(' success:', result.success) + console.log(' latencyMs:', result.latencyMs) + console.log(' httpStatus:', result.httpStatus) + console.log(' error:', result.error) + console.log(' modelUsed:', result.modelUsed) + + // 不断言成功/失败,因为假 key 肯定失败,但验证机制正常工作 + expect(result.latencyMs).toBeGreaterThanOrEqual(0) + expect(result.modelUsed).toBe('MiniMax-M2.7-highspeed') + }) + + test('GAP 分析 — 用户配置中我们未覆盖的字段', () => { + // 用户的 MiniMax 配置包含我们未处理的字段: + const minimaxConfig = { + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-...', + ANTHROPIC_BASE_URL: 'https://api.minimaxi.com/anthropic', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'MiniMax-M2.7-highspeed', // ❌ 未支持 + ANTHROPIC_DEFAULT_OPUS_MODEL: 'MiniMax-M2.7-highspeed', // ❌ 未支持 + ANTHROPIC_DEFAULT_SONNET_MODEL: 'MiniMax-M2.7-highspeed', // ❌ 未支持 + ANTHROPIC_MODEL: 'MiniMax-M2.7-highspeed', + API_TIMEOUT_MS: '3000000', // ❌ 未支持 + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', // ❌ 未支持 + }, + skipDangerousModePermissionPrompt: true, // ❌ 未支持 + } + + // 用户的接口AI配置包含更多未覆盖字段: + const jiekouConfig = { + effortLevel: 'high', // ❌ 未同步 + enabledPlugins: { /* ... */ }, // ❌ 未同步 + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', // ❌ 未支持 + }, + hooks: { /* ... */ }, // ❌ 未同步 + } + + // 打印 GAP 分析 + console.log('\n📋 GAP 分析 — 需要在 syncToSettings 中额外支持的字段:') + console.log(' 1. ANTHROPIC_DEFAULT_HAIKU_MODEL — 各模型的 tier 默认值') + console.log(' 2. ANTHROPIC_DEFAULT_SONNET_MODEL') + console.log(' 3. ANTHROPIC_DEFAULT_OPUS_MODEL') + console.log(' 4. API_TIMEOUT_MS — 超时配置') + console.log(' 5. 其他自定义 env vars(应支持 extraEnv 字段)') + console.log(' 6. skipDangerousModePermissionPrompt — 非 env 的额外 settings 字段') + + expect(true).toBe(true) // GAP 分析通过 + }) +}) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts new file mode 100644 index 00000000..fef8bf8e --- /dev/null +++ b/src/server/__tests__/providers.test.ts @@ -0,0 +1,579 @@ +/** + * Unit tests for ProviderService and Providers REST API + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'fs/promises' +import * as path from 'path' +import * as os from 'os' +import { ProviderService } from '../services/providerService.js' +import { handleProvidersApi } from '../api/providers.js' +import type { CreateProviderInput } from '../types/provider.js' + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +let tmpDir: string +let originalConfigDir: string | undefined + +async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +} + +async function teardown() { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + await fs.rm(tmpDir, { recursive: true, force: true }) +} + +/** Create a mock Request */ +function makeRequest( + method: string, + urlStr: string, + body?: Record, +): { req: Request; url: URL; segments: string[] } { + const url = new URL(urlStr, 'http://localhost:3456') + const init: RequestInit = { method } + if (body) { + init.headers = { 'Content-Type': 'application/json' } + init.body = JSON.stringify(body) + } + const req = new Request(url.toString(), init) + const segments = url.pathname.split('/').filter(Boolean) + return { req, url, segments } +} + +/** A sample provider input for reuse across tests */ +function sampleInput(overrides?: Partial): CreateProviderInput { + return { + name: 'Test Provider', + baseUrl: 'https://api.example.com', + apiKey: 'sk-test-key-123', + models: [ + { id: 'model-a', name: 'Model A' }, + { id: 'model-b', name: 'Model B' }, + ], + ...overrides, + } +} + +/** Read the settings.json written to the temp config dir */ +async function readSettings(): Promise> { + const raw = await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8') + return JSON.parse(raw) as Record +} + +/** Read the providers.json written to the temp config dir */ +async function readProvidersConfig(): Promise> { + const raw = await fs.readFile(path.join(tmpDir, 'providers.json'), 'utf-8') + return JSON.parse(raw) as Record +} + +// ============================================================================= +// ProviderService +// ============================================================================= + +describe('ProviderService', () => { + beforeEach(setup) + afterEach(teardown) + + // ─── listProviders ─────────────────────────────────────────────────────── + + describe('listProviders', () => { + test('should return empty array when no providers exist', async () => { + const svc = new ProviderService() + const providers = await svc.listProviders() + expect(providers).toEqual([]) + }) + + test('should return all added providers', async () => { + const svc = new ProviderService() + await svc.addProvider(sampleInput({ name: 'Provider A' })) + await svc.addProvider(sampleInput({ name: 'Provider B' })) + + const providers = await svc.listProviders() + expect(providers).toHaveLength(2) + expect(providers[0].name).toBe('Provider A') + expect(providers[1].name).toBe('Provider B') + }) + }) + + // ─── addProvider ───────────────────────────────────────────────────────── + + describe('addProvider', () => { + test('should add a provider and return it with generated fields', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + expect(provider.id).toBeDefined() + expect(provider.name).toBe('Test Provider') + expect(provider.baseUrl).toBe('https://api.example.com') + expect(provider.apiKey).toBe('sk-test-key-123') + expect(provider.models).toHaveLength(2) + expect(provider.createdAt).toBeGreaterThan(0) + expect(provider.updatedAt).toBeGreaterThan(0) + }) + + test('first provider should be auto-activated', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + expect(provider.isActive).toBe(true) + }) + + test('first provider auto-activation should sync to settings.json', async () => { + const svc = new ProviderService() + await svc.addProvider(sampleInput()) + + const settings = await readSettings() + const env = settings.env as Record + expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com') + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-test-key-123') + expect(settings.model).toBe('model-a') + }) + + test('second provider should not be auto-activated', async () => { + const svc = new ProviderService() + await svc.addProvider(sampleInput({ name: 'First' })) + const second = await svc.addProvider(sampleInput({ name: 'Second' })) + + expect(second.isActive).toBe(false) + }) + + test('should preserve optional notes field', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ notes: 'dev environment' })) + + expect(provider.notes).toBe('dev environment') + }) + }) + + // ─── getProvider ───────────────────────────────────────────────────────── + + describe('getProvider', () => { + test('should return the provider by id', async () => { + const svc = new ProviderService() + const added = await svc.addProvider(sampleInput()) + + const fetched = await svc.getProvider(added.id) + expect(fetched.id).toBe(added.id) + expect(fetched.name).toBe(added.name) + }) + + test('should throw 404 for non-existent id', async () => { + const svc = new ProviderService() + + try { + await svc.getProvider('non-existent-id') + expect(true).toBe(false) // should not reach here + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(404) + } + }) + }) + + // ─── updateProvider ────────────────────────────────────────────────────── + + describe('updateProvider', () => { + test('should update provider fields', async () => { + const svc = new ProviderService() + const added = await svc.addProvider(sampleInput()) + + const updated = await svc.updateProvider(added.id, { + name: 'Updated Name', + baseUrl: 'https://new-api.example.com', + }) + + expect(updated.name).toBe('Updated Name') + expect(updated.baseUrl).toBe('https://new-api.example.com') + // unchanged fields preserved + expect(updated.apiKey).toBe('sk-test-key-123') + expect(updated.updatedAt).toBeGreaterThanOrEqual(added.updatedAt) + }) + + test('should throw 404 for non-existent provider', async () => { + const svc = new ProviderService() + + try { + await svc.updateProvider('non-existent-id', { name: 'X' }) + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(404) + } + }) + + test('updating active provider should re-sync settings.json', async () => { + const svc = new ProviderService() + const added = await svc.addProvider(sampleInput()) + + // First provider is auto-activated, so updating it should re-sync + await svc.updateProvider(added.id, { + baseUrl: 'https://new-api.example.com', + apiKey: 'sk-new-key', + }) + + const settings = await readSettings() + const env = settings.env as Record + expect(env.ANTHROPIC_BASE_URL).toBe('https://new-api.example.com') + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-new-key') + }) + }) + + // ─── deleteProvider ────────────────────────────────────────────────────── + + describe('deleteProvider', () => { + test('should delete an inactive provider', async () => { + const svc = new ProviderService() + await svc.addProvider(sampleInput({ name: 'First' })) + const second = await svc.addProvider(sampleInput({ name: 'Second' })) + + // Second is inactive, so deletion should succeed + await svc.deleteProvider(second.id) + + const providers = await svc.listProviders() + expect(providers).toHaveLength(1) + expect(providers[0].name).toBe('First') + }) + + test('should throw 409 when deleting an active provider', async () => { + const svc = new ProviderService() + const active = await svc.addProvider(sampleInput()) + + try { + await svc.deleteProvider(active.id) + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(409) + } + }) + + test('should throw 404 when deleting non-existent provider', async () => { + const svc = new ProviderService() + + try { + await svc.deleteProvider('non-existent-id') + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(404) + } + }) + }) + + // ─── activateProvider ──────────────────────────────────────────────────── + + describe('activateProvider', () => { + test('should activate a provider with a valid model', async () => { + const svc = new ProviderService() + const first = await svc.addProvider(sampleInput({ name: 'First' })) + const second = await svc.addProvider( + sampleInput({ + name: 'Second', + baseUrl: 'https://second-api.example.com', + apiKey: 'sk-second-key', + }), + ) + + await svc.activateProvider(second.id, 'model-a') + + // Second should now be active + const providers = await svc.listProviders() + const activeFirst = providers.find((p) => p.id === first.id) + const activeSecond = providers.find((p) => p.id === second.id) + expect(activeFirst!.isActive).toBe(false) + expect(activeSecond!.isActive).toBe(true) + }) + + test('should write correct settings.json on activation', async () => { + const svc = new ProviderService() + await svc.addProvider(sampleInput({ name: 'First' })) + const second = await svc.addProvider( + sampleInput({ + name: 'Second', + baseUrl: 'https://second-api.example.com', + apiKey: 'sk-second-key', + }), + ) + + await svc.activateProvider(second.id, 'model-b') + + const settings = await readSettings() + const env = settings.env as Record + expect(env.ANTHROPIC_BASE_URL).toBe('https://second-api.example.com') + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second-key') + expect(settings.model).toBe('model-b') + }) + + test('should preserve existing settings.json fields on activation', async () => { + // Pre-seed settings with an extra field + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ theme: 'dark', env: { CUSTOM_VAR: 'keep-me' } }), + ) + + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + // Re-activate to verify merge behavior + await svc.activateProvider(provider.id, 'model-a') + + const settings = await readSettings() + expect(settings.theme).toBe('dark') + const env = settings.env as Record + expect(env.CUSTOM_VAR).toBe('keep-me') + expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com') + }) + + test('should throw 400 for non-existent model id', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + try { + await svc.activateProvider(provider.id, 'non-existent-model') + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(400) + } + }) + + test('should throw 404 for non-existent provider id', async () => { + const svc = new ProviderService() + + try { + await svc.activateProvider('non-existent-id', 'model-a') + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(404) + } + }) + + test('activeModel should be persisted in providers.json', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + await svc.activateProvider(provider.id, 'model-b') + + const config = await readProvidersConfig() + expect(config.activeModel).toBe('model-b') + }) + }) + + // ─── getActiveProvider ─────────────────────────────────────────────────── + + describe('getActiveProvider', () => { + test('should return null when no providers exist', async () => { + const svc = new ProviderService() + const active = await svc.getActiveProvider() + expect(active).toBeNull() + }) + + test('should return the active provider', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + const active = await svc.getActiveProvider() + expect(active).not.toBeNull() + expect(active!.id).toBe(provider.id) + }) + }) +}) + +// ============================================================================= +// Providers REST API +// ============================================================================= + +describe('Providers API', () => { + beforeEach(setup) + afterEach(teardown) + + // ─── GET /api/providers ────────────────────────────────────────────────── + + test('GET /api/providers should return empty list initially', async () => { + const { req, url, segments } = makeRequest('GET', '/api/providers') + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(200) + const body = (await res.json()) as { providers: unknown[] } + expect(body.providers).toEqual([]) + }) + + test('GET /api/providers should list added providers', async () => { + // Seed a provider via service + const svc = new ProviderService() + await svc.addProvider(sampleInput()) + + const { req, url, segments } = makeRequest('GET', '/api/providers') + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(200) + const body = (await res.json()) as { providers: { name: string }[] } + expect(body.providers).toHaveLength(1) + expect(body.providers[0].name).toBe('Test Provider') + }) + + // ─── POST /api/providers ───────────────────────────────────────────────── + + test('POST /api/providers should create a provider', async () => { + const { req, url, segments } = makeRequest('POST', '/api/providers', { + name: 'New Provider', + baseUrl: 'https://api.example.com', + apiKey: 'sk-test', + models: [{ id: 'gpt-4', name: 'GPT-4' }], + }) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(201) + const body = (await res.json()) as { provider: { name: string; isActive: boolean } } + expect(body.provider.name).toBe('New Provider') + expect(body.provider.isActive).toBe(true) // first provider auto-activated + }) + + test('POST /api/providers should return 400 for invalid input', async () => { + const { req, url, segments } = makeRequest('POST', '/api/providers', { + name: '', // invalid: empty name + }) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(400) + }) + + // ─── GET /api/providers/:id ────────────────────────────────────────────── + + test('GET /api/providers/:id should return a provider', async () => { + const svc = new ProviderService() + const added = await svc.addProvider(sampleInput()) + + const { req, url, segments } = makeRequest('GET', `/api/providers/${added.id}`) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(200) + const body = (await res.json()) as { provider: { id: string; name: string } } + expect(body.provider.id).toBe(added.id) + }) + + test('GET /api/providers/:id should return 404 for unknown id', async () => { + const { req, url, segments } = makeRequest('GET', '/api/providers/unknown-id') + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(404) + }) + + // ─── PUT /api/providers/:id ────────────────────────────────────────────── + + test('PUT /api/providers/:id should update a provider', async () => { + const svc = new ProviderService() + const added = await svc.addProvider(sampleInput()) + + const { req, url, segments } = makeRequest('PUT', `/api/providers/${added.id}`, { + name: 'Renamed Provider', + }) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(200) + const body = (await res.json()) as { provider: { name: string } } + expect(body.provider.name).toBe('Renamed Provider') + }) + + // ─── DELETE /api/providers/:id ─────────────────────────────────────────── + + test('DELETE /api/providers/:id should delete an inactive provider', async () => { + const svc = new ProviderService() + await svc.addProvider(sampleInput({ name: 'First' })) + const second = await svc.addProvider(sampleInput({ name: 'Second' })) + + const { req, url, segments } = makeRequest('DELETE', `/api/providers/${second.id}`) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean } + expect(body.ok).toBe(true) + }) + + test('DELETE /api/providers/:id should return 409 for active provider', async () => { + const svc = new ProviderService() + const active = await svc.addProvider(sampleInput()) + + const { req, url, segments } = makeRequest('DELETE', `/api/providers/${active.id}`) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(409) + }) + + // ─── POST /api/providers/:id/activate ──────────────────────────────────── + + test('POST /api/providers/:id/activate should activate a provider', async () => { + const svc = new ProviderService() + await svc.addProvider(sampleInput({ name: 'First' })) + const second = await svc.addProvider( + sampleInput({ + name: 'Second', + baseUrl: 'https://second.example.com', + apiKey: 'sk-second', + }), + ) + + const { req, url, segments } = makeRequest( + 'POST', + `/api/providers/${second.id}/activate`, + { modelId: 'model-a' }, + ) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean } + expect(body.ok).toBe(true) + + // Verify settings were synced + const settings = await readSettings() + const env = settings.env as Record + expect(env.ANTHROPIC_BASE_URL).toBe('https://second.example.com') + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second') + expect(settings.model).toBe('model-a') + }) + + test('POST /api/providers/:id/activate should return 400 for missing modelId', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + const { req, url, segments } = makeRequest( + 'POST', + `/api/providers/${provider.id}/activate`, + {}, + ) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(400) + }) + + test('POST /api/providers/:id/activate should return 400 for invalid model', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + const { req, url, segments } = makeRequest( + 'POST', + `/api/providers/${provider.id}/activate`, + { modelId: 'non-existent-model' }, + ) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(400) + }) + + // ─── Method not allowed ────────────────────────────────────────────────── + + test('should return 405 for unsupported methods', async () => { + const { req, url, segments } = makeRequest('PATCH', '/api/providers') + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(405) + }) +}) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index 5cc175e1..0d907af6 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -9,11 +9,12 @@ */ import { SettingsService } from '../services/settingsService.js' +import { ProviderService } from '../services/providerService.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' -// ─── Static data ────────────────────────────────────────────────────────────── +// ─── Fallback models (used when no provider is configured) ──────────────────── -const AVAILABLE_MODELS = [ +const DEFAULT_MODELS = [ { id: 'claude-opus-4-6-20250610', name: 'Opus 4.6', @@ -46,6 +47,7 @@ const DEFAULT_MODEL = 'claude-sonnet-4-6-20250514' const DEFAULT_EFFORT = 'medium' const settingsService = new SettingsService() +const providerService = new ProviderService() // ─── Router ─────────────────────────────────────────────────────────────────── @@ -66,9 +68,9 @@ export async function handleModelsApi( // ── /api/models/* ───────────────────────────────────────────────── switch (sub) { case undefined: - // GET /api/models + // GET /api/models — 优先从激活的 Provider 读取模型列表 if (req.method !== 'GET') throw methodNotAllowed(req.method) - return Response.json({ models: AVAILABLE_MODELS }) + return await handleModelsList() case 'current': return await handleCurrentModel(req) @@ -83,16 +85,31 @@ export async function handleModelsApi( // ─── Handlers ───────────────────────────────────────────────────────────────── +async function handleModelsList(): Promise { + const activeProvider = await providerService.getActiveProvider() + if (activeProvider) { + return Response.json({ + models: activeProvider.models, + provider: { id: activeProvider.id, name: activeProvider.name }, + }) + } + return Response.json({ models: DEFAULT_MODELS, provider: null }) +} + async function handleCurrentModel(req: Request): Promise { if (req.method === 'GET') { const settings = await settingsService.getUserSettings() const baseModelId = (settings.model as string) || DEFAULT_MODEL const contextTier = (settings.modelContext as string) || undefined + // Build the full model list: prefer active provider's models, fall back to defaults + const activeProvider = await providerService.getActiveProvider() + const availableModels = activeProvider ? activeProvider.models : DEFAULT_MODELS + // Reconstruct composite ID for lookup (e.g. 'claude-opus-4-6-20250610:1m') const lookupId = contextTier ? `${baseModelId}:${contextTier}` : baseModelId - const model = AVAILABLE_MODELS.find((m) => m.id === lookupId) - || AVAILABLE_MODELS.find((m) => m.id === baseModelId) + const model = availableModels.find((m) => m.id === lookupId) + || availableModels.find((m) => m.id === baseModelId) || { id: baseModelId, name: baseModelId, diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts new file mode 100644 index 00000000..82247cc4 --- /dev/null +++ b/src/server/api/providers.ts @@ -0,0 +1,170 @@ +/** + * Providers REST API + * + * GET /api/providers — 列出所有 provider + * GET /api/providers/:id — 获取单个 provider + * POST /api/providers — 添加 provider + * PUT /api/providers/:id — 更新 provider + * DELETE /api/providers/:id — 删除 provider + * POST /api/providers/:id/activate — 激活 provider + * POST /api/providers/:id/test — 测试已保存 provider + * POST /api/providers/test — 测试未保存的配置 + */ + +import { z } from 'zod' +import { ProviderService } from '../services/providerService.js' +import { + CreateProviderSchema, + UpdateProviderSchema, + TestProviderSchema, + ActivateProviderSchema, +} from '../types/provider.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +const providerService = new ProviderService() + +// ─── Sanitization ───────────────────────────────────────────────────────────── + +function maskApiKey(key: string): string { + if (key.length <= 8) return '****' + return key.slice(0, 4) + '****' + key.slice(-4) +} + +function sanitizeProvider(provider: Record): Record { + if (typeof provider.apiKey === 'string') { + return { ...provider, apiKey: maskApiKey(provider.apiKey) } + } + return provider +} + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export async function handleProvidersApi( + req: Request, + url: URL, + segments: string[], +): Promise { + try { + const id = segments[2] // provider ID or 'test' + const action = segments[3] // 'activate' | 'test' | undefined + + // ── POST /api/providers/test (test unsaved configuration) ────────── + if (id === 'test' && req.method === 'POST') { + return await handleTestUnsaved(req) + } + + // ── /api/providers (no ID) ──────────────────────────────────────── + if (!id) { + if (req.method === 'GET') { + const providers = await providerService.listProviders() + return Response.json({ providers: providers.map(sanitizeProvider) }) + } + if (req.method === 'POST') { + return await handleCreate(req) + } + throw methodNotAllowed(req.method) + } + + // ── /api/providers/:id/activate ─────────────────────────────────── + if (action === 'activate') { + if (req.method !== 'POST') throw methodNotAllowed(req.method) + return await handleActivate(req, id) + } + + // ── /api/providers/:id/test ─────────────────────────────────────── + if (action === 'test') { + if (req.method !== 'POST') throw methodNotAllowed(req.method) + const result = await providerService.testProvider(id) + return Response.json({ result }) + } + + // ── /api/providers/:id (no action) ──────────────────────────────── + if (req.method === 'GET') { + const provider = await providerService.getProvider(id) + return Response.json({ provider: sanitizeProvider(provider) }) + } + if (req.method === 'PUT') { + return await handleUpdate(req, id) + } + if (req.method === 'DELETE') { + await providerService.deleteProvider(id) + return Response.json({ ok: true }) + } + + throw methodNotAllowed(req.method) + } catch (error) { + return errorResponse(error) + } +} + +// ─── Handlers ───────────────────────────────────────────────────────────────── + +async function handleCreate(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = CreateProviderSchema.parse(body) + const provider = await providerService.addProvider(input) + return Response.json({ provider }, { status: 201 }) + } catch (err) { + if (err instanceof z.ZodError) { + throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + } + throw err + } +} + +async function handleUpdate(req: Request, id: string): Promise { + const body = await parseJsonBody(req) + try { + const input = UpdateProviderSchema.parse(body) + const provider = await providerService.updateProvider(id, input) + return Response.json({ provider }) + } catch (err) { + if (err instanceof z.ZodError) { + throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + } + throw err + } +} + +async function handleActivate(req: Request, id: string): Promise { + const body = await parseJsonBody(req) + try { + const input = ActivateProviderSchema.parse(body) + await providerService.activateProvider(id, input.modelId) + return Response.json({ ok: true }) + } catch (err) { + if (err instanceof z.ZodError) { + throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + } + throw err + } +} + +async function handleTestUnsaved(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = TestProviderSchema.parse(body) + const result = await providerService.testProviderConfig(input) + return Response.json({ result }) + } catch (err) { + if (err instanceof z.ZodError) { + throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + } + throw err + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function parseJsonBody(req: Request): Promise> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} diff --git a/src/server/router.ts b/src/server/router.ts index 97209d27..21c5cc82 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -12,6 +12,7 @@ import { handleStatusApi } from './api/status.js' import { handleConversationsApi } from './api/conversations.js' import { handleTeamsApi } from './api/teams.js' import { handleFilesystemRoute } from './api/filesystem.js' +import { handleProvidersApi } from './api/providers.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -59,6 +60,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise { + try { + const raw = await fs.readFile(this.getProvidersPath(), 'utf-8') + return JSON.parse(raw) as ProvidersConfig + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { ...DEFAULT_CONFIG, providers: [] } + } + throw ApiError.internal( + `Failed to read providers config: ${err}`, + ) + } + } + + /** Atomic write: write to .tmp first, then rename */ + private async writeProvidersConfig(config: ProvidersConfig): Promise { + const filePath = this.getProvidersPath() + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + + const tmpFile = `${filePath}.tmp.${Date.now()}` + try { + await fs.writeFile(tmpFile, JSON.stringify(config, null, 2) + '\n', 'utf-8') + await fs.rename(tmpFile, filePath) + } catch (err) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write providers config: ${err}`) + } + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + /** List all providers */ + async listProviders(): Promise { + const config = await this.readProvidersConfig() + return config.providers + } + + /** Get a single provider by id; throws 404 if not found */ + async getProvider(id: string): Promise { + const config = await this.readProvidersConfig() + const provider = config.providers.find((p) => p.id === id) + if (!provider) { + throw ApiError.notFound(`Provider not found: ${id}`) + } + return provider + } + + /** Get the currently active provider, or null if none */ + async getActiveProvider(): Promise { + const config = await this.readProvidersConfig() + return config.providers.find((p) => p.isActive) ?? null + } + + /** Add a new provider. If it is the first one, activate it automatically. */ + async addProvider(input: CreateProviderInput): Promise { + const config = await this.readProvidersConfig() + const now = Date.now() + const isFirst = config.providers.length === 0 + + const provider: Provider = { + id: crypto.randomUUID(), + name: input.name, + baseUrl: input.baseUrl, + apiKey: input.apiKey, + models: input.models, + isActive: isFirst, + createdAt: now, + updatedAt: now, + ...(input.notes !== undefined && { notes: input.notes }), + } + + config.providers.push(provider) + + // Auto-activate the first provider with its first model + if (isFirst && provider.models.length > 0) { + config.activeModel = provider.models[0].id + } + + await this.writeProvidersConfig(config) + + // Sync to settings if this provider was auto-activated + if (isFirst && provider.models.length > 0) { + await this.syncToSettings(provider, provider.models[0].id) + } + + return provider + } + + /** Update an existing provider */ + async updateProvider(id: string, input: UpdateProviderInput): Promise { + const config = await this.readProvidersConfig() + const index = config.providers.findIndex((p) => p.id === id) + if (index === -1) { + throw ApiError.notFound(`Provider not found: ${id}`) + } + + const existing = config.providers[index] + const updated: Provider = { + ...existing, + ...(input.name !== undefined && { name: input.name }), + ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), + ...(input.apiKey !== undefined && { apiKey: input.apiKey }), + ...(input.models !== undefined && { models: input.models }), + ...(input.notes !== undefined && { notes: input.notes }), + updatedAt: Date.now(), + } + + config.providers[index] = updated + + // If the updated provider is active, validate activeModel still exists + if (updated.isActive && config.activeModel) { + const modelStillExists = updated.models.some((m) => m.id === config.activeModel) + if (!modelStillExists) { + config.activeModel = updated.models[0]?.id + } + } + + await this.writeProvidersConfig(config) + + // Re-sync settings if active + if (updated.isActive && config.activeModel) { + await this.syncToSettings(updated, config.activeModel) + } + + return updated + } + + /** Delete a provider; refuses to delete an active provider */ + async deleteProvider(id: string): Promise { + const config = await this.readProvidersConfig() + const index = config.providers.findIndex((p) => p.id === id) + if (index === -1) { + throw ApiError.notFound(`Provider not found: ${id}`) + } + + if (config.providers[index].isActive) { + throw ApiError.conflict( + 'Cannot delete an active provider. Deactivate it first by activating another provider.', + ) + } + + config.providers.splice(index, 1) + await this.writeProvidersConfig(config) + } + + // --------------------------------------------------------------------------- + // Activation + // --------------------------------------------------------------------------- + + /** + * Activate a provider with a specific model. + * + * 1. Validate provider exists and modelId belongs to it + * 2. Deactivate all providers + * 3. Activate the target provider + * 4. Set config.activeModel + * 5. Persist providers.json + * 6. Sync env to settings.json + */ + async activateProvider(id: string, modelId: string): Promise { + const config = await this.readProvidersConfig() + const provider = config.providers.find((p) => p.id === id) + if (!provider) { + throw ApiError.notFound(`Provider not found: ${id}`) + } + + const model = provider.models.find((m) => m.id === modelId) + if (!model) { + throw ApiError.badRequest( + `Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`, + ) + } + + // Deactivate all, then activate target + for (const p of config.providers) { + p.isActive = false + } + provider.isActive = true + config.activeModel = modelId + + await this.writeProvidersConfig(config) + await this.syncToSettings(provider, modelId) + } + + // --------------------------------------------------------------------------- + // Settings sync + // --------------------------------------------------------------------------- + + /** + * Sync the active provider's configuration to settings.json. + * + * Preserves all existing fields; only updates `env` (merging into existing + * env vars) and `model`. + */ + private async syncToSettings(provider: Provider, modelId: string): Promise { + const settingsPath = path.join(this.getConfigDir(), 'settings.json') + + // Read existing settings + let settings: Record = {} + try { + const raw = await fs.readFile(settingsPath, 'utf-8') + settings = JSON.parse(raw) as Record + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw ApiError.internal(`Failed to read settings.json: ${err}`) + } + // File doesn't exist yet — start with empty object + } + + // Merge env: preserve existing env vars, override only ours + const existingEnv = (settings.env as Record) || {} + settings.env = { + ...existingEnv, + ANTHROPIC_BASE_URL: provider.baseUrl, + ANTHROPIC_AUTH_TOKEN: provider.apiKey, + } + + // Set model + settings.model = modelId + + // Atomic write + const dir = path.dirname(settingsPath) + await fs.mkdir(dir, { recursive: true }) + + const tmpFile = `${settingsPath}.tmp.${Date.now()}` + try { + await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8') + await fs.rename(tmpFile, settingsPath) + } catch (err) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write settings.json: ${err}`) + } + } + + // --------------------------------------------------------------------------- + // Connectivity testing + // --------------------------------------------------------------------------- + + /** Test connectivity of a saved provider */ + async testProvider(id: string): Promise { + const provider = await this.getProvider(id) + + if (provider.models.length === 0) { + return { + success: false, + latencyMs: 0, + error: 'Provider has no models configured', + } + } + + return this.testProviderConfig({ + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + modelId: provider.models[0].id, + }) + } + + /** Test connectivity with an arbitrary configuration */ + async testProviderConfig(input: TestProviderInput): Promise { + const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages` + const start = Date.now() + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': input.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: input.modelId, + max_tokens: 1, + messages: [{ role: 'user', content: 'Hi' }], + }), + signal: AbortSignal.timeout(15000), + }) + + const latencyMs = Date.now() - start + + if (response.ok) { + return { + success: true, + latencyMs, + modelUsed: input.modelId, + httpStatus: response.status, + } + } + + // Non-OK response — try to extract error message + let errorMessage = `HTTP ${response.status}` + try { + const body = (await response.json()) as Record + if (body.error && typeof body.error === 'object') { + const errObj = body.error as Record + errorMessage = (errObj.message as string) || errorMessage + } else if (typeof body.message === 'string') { + errorMessage = body.message + } + } catch { + // Could not parse body — use status text + errorMessage = `HTTP ${response.status} ${response.statusText}` + } + + return { + success: false, + latencyMs, + error: errorMessage, + modelUsed: input.modelId, + httpStatus: response.status, + } + } catch (err: unknown) { + const latencyMs = Date.now() - start + + if (err instanceof DOMException && err.name === 'TimeoutError') { + return { + success: false, + latencyMs, + error: 'Request timed out after 15 seconds', + modelUsed: input.modelId, + } + } + + return { + success: false, + latencyMs, + error: err instanceof Error ? err.message : String(err), + modelUsed: input.modelId, + } + } + } +} diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts new file mode 100644 index 00000000..b7d067d3 --- /dev/null +++ b/src/server/types/provider.ts @@ -0,0 +1,79 @@ +/** + * Provider 类型定义 + * + * Provider 是自定义 API 供应商的配置单元,包含 Base URL、API Key 和可用模型列表。 + * 激活 Provider 时,其配置会写入 ~/.claude/settings.json 的 env 字段。 + */ + +import { z } from 'zod' + +// ─── Zod Schemas ────────────────────────────────────────────────────────────── + +export const ProviderModelSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + context: z.string().optional(), +}) + +export const ProviderSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1), + baseUrl: z.string().url(), + apiKey: z.string().min(1), + models: z.array(ProviderModelSchema).min(1), + isActive: z.boolean(), + createdAt: z.number(), + updatedAt: z.number(), + notes: z.string().optional(), +}) + +export const CreateProviderSchema = z.object({ + name: z.string().min(1), + baseUrl: z.string().url(), + apiKey: z.string().min(1), + models: z.array(ProviderModelSchema).min(1), + notes: z.string().optional(), +}) + +export const UpdateProviderSchema = z.object({ + name: z.string().min(1).optional(), + baseUrl: z.string().url().optional(), + apiKey: z.string().min(1).optional(), + models: z.array(ProviderModelSchema).min(1).optional(), + notes: z.string().optional(), +}) + +export const TestProviderSchema = z.object({ + baseUrl: z.string().url(), + apiKey: z.string().min(1), + modelId: z.string().min(1), +}) + +export const ActivateProviderSchema = z.object({ + modelId: z.string().min(1), +}) + +export const ProvidersConfigSchema = z.object({ + providers: z.array(ProviderSchema), + activeModel: z.string().optional(), + version: z.number(), +}) + +// ─── TypeScript Types ───────────────────────────────────────────────────────── + +export type ProviderModel = z.infer +export type Provider = z.infer +export type CreateProviderInput = z.infer +export type UpdateProviderInput = z.infer +export type TestProviderInput = z.infer +export type ActivateProviderInput = z.infer +export type ProvidersConfig = z.infer + +export interface ProviderTestResult { + success: boolean + latencyMs: number + error?: string + modelUsed?: string + httpStatus?: number +}