mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge branch 'worktree-feature-model-config' into docs/ui-clone-requirements
This commit is contained in:
commit
bd658d98f6
242
PLAN.md
Normal file
242
PLAN.md
Normal file
@ -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": "<provider.baseUrl>",
|
||||
"ANTHROPIC_AUTH_TOKEN": "<provider.apiKey>"
|
||||
},
|
||||
"model": "<modelId>"
|
||||
}
|
||||
```
|
||||
- `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)
|
||||
227
src/server/__tests__/providers-real.test.ts
Normal file
227
src/server/__tests__/providers-real.test.ts
Normal file
@ -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 分析通过
|
||||
})
|
||||
})
|
||||
579
src/server/__tests__/providers.test.ts
Normal file
579
src/server/__tests__/providers.test.ts
Normal file
@ -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<string, unknown>,
|
||||
): { 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>): 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<Record<string, unknown>> {
|
||||
const raw = await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Read the providers.json written to the temp config dir */
|
||||
async function readProvidersConfig(): Promise<Record<string, unknown>> {
|
||||
const raw = await fs.readFile(path.join(tmpDir, 'providers.json'), 'utf-8')
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<string, string>
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -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<Response> {
|
||||
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<Response> {
|
||||
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,
|
||||
|
||||
170
src/server/api/providers.ts
Normal file
170
src/server/api/providers.ts
Normal file
@ -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<string, unknown>): Record<string, unknown> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
function methodNotAllowed(method: string): ApiError {
|
||||
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
@ -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<Response> {
|
||||
const path = url.pathname
|
||||
@ -59,6 +60,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'teams':
|
||||
return handleTeamsApi(req, url, segments)
|
||||
|
||||
case 'providers':
|
||||
return handleProvidersApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
379
src/server/services/providerService.ts
Normal file
379
src/server/services/providerService.ts
Normal file
@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Provider Service — AI provider configuration management
|
||||
*
|
||||
* Manages custom API providers (base URL, API key, models).
|
||||
* When a provider is activated, its configuration is synced to
|
||||
* ~/.claude/settings.json so that Claude Code uses it.
|
||||
*
|
||||
* Storage: ~/.claude/providers.json (atomic write via .tmp + rename)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import type {
|
||||
Provider,
|
||||
CreateProviderInput,
|
||||
UpdateProviderInput,
|
||||
ProvidersConfig,
|
||||
TestProviderInput,
|
||||
ProviderTestResult,
|
||||
} from '../types/provider.js'
|
||||
|
||||
const DEFAULT_CONFIG: ProvidersConfig = {
|
||||
providers: [],
|
||||
version: 1,
|
||||
}
|
||||
|
||||
export class ProviderService {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Config directory, overridable via CLAUDE_CONFIG_DIR (for testing) */
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
/** Path to the providers configuration file */
|
||||
private getProvidersPath(): string {
|
||||
return path.join(this.getConfigDir(), 'providers.json')
|
||||
}
|
||||
|
||||
/** Read providers config; returns empty defaults when the file does not exist */
|
||||
private async readProvidersConfig(): Promise<ProvidersConfig> {
|
||||
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<void> {
|
||||
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<Provider[]> {
|
||||
const config = await this.readProvidersConfig()
|
||||
return config.providers
|
||||
}
|
||||
|
||||
/** Get a single provider by id; throws 404 if not found */
|
||||
async getProvider(id: string): Promise<Provider> {
|
||||
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<Provider | null> {
|
||||
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<Provider> {
|
||||
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<Provider> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const settingsPath = path.join(this.getConfigDir(), 'settings.json')
|
||||
|
||||
// Read existing settings
|
||||
let settings: Record<string, unknown> = {}
|
||||
try {
|
||||
const raw = await fs.readFile(settingsPath, 'utf-8')
|
||||
settings = JSON.parse(raw) as Record<string, unknown>
|
||||
} 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<string, string>) || {}
|
||||
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<ProviderTestResult> {
|
||||
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<ProviderTestResult> {
|
||||
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<string, unknown>
|
||||
if (body.error && typeof body.error === 'object') {
|
||||
const errObj = body.error as Record<string, unknown>
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/server/types/provider.ts
Normal file
79
src/server/types/provider.ts
Normal file
@ -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<typeof ProviderModelSchema>
|
||||
export type Provider = z.infer<typeof ProviderSchema>
|
||||
export type CreateProviderInput = z.infer<typeof CreateProviderSchema>
|
||||
export type UpdateProviderInput = z.infer<typeof UpdateProviderSchema>
|
||||
export type TestProviderInput = z.infer<typeof TestProviderSchema>
|
||||
export type ActivateProviderInput = z.infer<typeof ActivateProviderSchema>
|
||||
export type ProvidersConfig = z.infer<typeof ProvidersConfigSchema>
|
||||
|
||||
export interface ProviderTestResult {
|
||||
success: boolean
|
||||
latencyMs: number
|
||||
error?: string
|
||||
modelUsed?: string
|
||||
httpStatus?: number
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user