Merge branch 'worktree-feature-model-config' into docs/ui-clone-requirements

This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 01:33:09 +08:00
commit bd658d98f6
8 changed files with 1703 additions and 6 deletions

242
PLAN.md Normal file
View 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

View 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 分析通过
})
})

View 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)
})
})

View File

@ -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
View 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')
}

View File

@ -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)

View 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,
}
}
}
}

View File

@ -0,0 +1,79 @@
/**
* Provider
*
* Provider API Base URLAPI 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
}