/** * Models REST API * * GET /api/models — 获取可用模型列表 * GET /api/models/current — 获取当前选中的模型 * PUT /api/models/current — 切换模型 * GET /api/effort — 获取 Effort 等级 * PUT /api/effort — 设置 Effort 等级 */ import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' import { attributionHeaderEnvForModel } from '../services/attributionHeaderPolicy.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { hasOpenAIAuthLogin } from '../../utils/auth.js' import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js' import { OPENAI_OFFICIAL_PROVIDER_ID, OPENAI_OFFICIAL_PROVIDER_NAME, isOpenAIOfficialProviderId, } from '../services/openaiOfficialProvider.js' // ─── Fallback models (used when no provider is configured) ──────────────────── const DEFAULT_MODELS = [ { id: 'claude-opus-4-7', name: 'Opus 4.7', description: 'Most capable for ambitious work', context: '1m', }, { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6', description: 'Most efficient for everyday tasks', context: '200k', }, { id: 'claude-haiku-4-5', name: 'Haiku 4.5', description: 'Fastest for quick answers', context: '200k', }, ] as const const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'] as const const DEFAULT_MODEL = 'claude-opus-4-7' const DEFAULT_EFFORT = 'medium' const settingsService = new SettingsService() const providerService = new ProviderService() type ApiModelInfo = { id: string name: string description: string context: string } function addUniqueModel( models: ApiModelInfo[], model: ApiModelInfo | null, ): void { if (!model || !model.id.trim()) { return } if (models.some(existing => existing.id === model.id)) { return } models.push(model) } function buildProviderModelList(models: { main: string haiku: string sonnet: string opus: string }): ApiModelInfo[] { const modelList: ApiModelInfo[] = [] addUniqueModel(modelList, { id: models.main, name: models.main, description: 'Main model', context: '', }) addUniqueModel(modelList, models.haiku ? { id: models.haiku, name: models.haiku, description: 'Haiku model', context: '', } : null) addUniqueModel(modelList, models.sonnet ? { id: models.sonnet, name: models.sonnet, description: 'Sonnet model', context: '', } : null) addUniqueModel(modelList, models.opus ? { id: models.opus, name: models.opus, description: 'Opus model', context: '', } : null) return modelList } function buildOpenAIModelList(): ApiModelInfo[] { return OPENAI_CODEX_MODEL_CATALOG.map(model => ({ id: model.value, name: model.label, description: model.description, context: '', })) } function getEnvConfiguredAnthropicModels(): ApiModelInfo[] { return buildProviderModelList({ main: process.env.ANTHROPIC_MODEL?.trim() || '', haiku: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() || '', sonnet: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || '', opus: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || '', }) } function getOpenAIAuthModels(): ApiModelInfo[] { if (!hasOpenAIAuthLogin()) { return [] } return buildOpenAIModelList() } function getStandaloneModelList(): ApiModelInfo[] { const models = [...getEnvConfiguredAnthropicModels()] if (models.length === 0) { models.push(...DEFAULT_MODELS) } for (const model of getOpenAIAuthModels()) { addUniqueModel(models, model) } return models } function normalizeEffortLevel(value: unknown): (typeof EFFORT_LEVELS)[number] { return typeof value === 'string' && EFFORT_LEVELS.includes(value as (typeof EFFORT_LEVELS)[number]) ? value as (typeof EFFORT_LEVELS)[number] : DEFAULT_EFFORT } // ─── CLI provider model cache (fetched from external APIs) ───── let cliProviderModelCache: ApiModelInfo[] | null = null let cliProviderModelCacheTime = 0 const CLI_PROVIDER_CACHE_TTL = 5 * 60 * 1000 // 5 minutes export function invalidateCliProviderModelCache(): void { cliProviderModelCache = null cliProviderModelCacheTime = 0 } async function fetchCliProviderModels(): Promise { if (cliProviderModelCache && Date.now() - cliProviderModelCacheTime < CLI_PROVIDER_CACHE_TTL) { return cliProviderModelCache } try { // Read ~/.claude.json directly — avoids importing CLI-side config modules const { homedir } = await import('node:os') const { readFileSync } = await import('node:fs') const { join } = await import('node:path') let config: Record = {} try { const raw = readFileSync(join(homedir(), '.claude.json'), 'utf8') config = JSON.parse(raw) } catch { return [] } const authProvider = config.authProvider as string | undefined // ── OpenCode ────────────────────────────────────────────── if (authProvider === 'opencode' && config.openCodeApiKey) { try { const res = await fetch('https://models.dev/api.json') if (res.ok) { const data = await res.json() as any const opencodeModels = data?.opencode?.models || {} const models: ApiModelInfo[] = [] for (const [modelId, modelCfg] of Object.entries(opencodeModels) as [string, any][]) { if (modelCfg.status === 'deprecated') continue const isFree = modelCfg.cost?.input === 0 && modelCfg.cost?.output === 0 models.push({ id: modelId, name: modelCfg.name || modelId, description: isFree ? 'Free model' : 'Paid model', context: '', }) } if (models.length > 0) { cliProviderModelCache = models cliProviderModelCacheTime = Date.now() return models } } } catch { // fall through } } // ── OpenRouter ──────────────────────────────────────────── if (authProvider === 'openrouter' && config.openRouterApiKey) { try { const res = await fetch('https://openrouter.ai/api/v1/models', { headers: { Authorization: `Bearer ${config.openRouterApiKey}` }, }) if (res.ok) { const data = await res.json() as any const models: ApiModelInfo[] = (data.data || []).map((m: any) => ({ id: m.id, name: m.name || m.id, description: m.description || '', context: String(m.context_length || ''), })) if (models.length > 0) { cliProviderModelCache = models cliProviderModelCacheTime = Date.now() return models } } } catch { // fall through } } // ── NVIDIA NIM ──────────────────────────────────────────── if (authProvider === 'nvidia' && config.nvidiaApiKey) { try { const res = await fetch('https://integrate.api.nvidia.com/v1/models', { headers: { Authorization: `Bearer ${config.nvidiaApiKey}` }, }) if (res.ok) { const data = await res.json() as any const models: ApiModelInfo[] = (data.data || []).map((m: any) => ({ id: m.id, name: m.id, description: m.owned_by || '', context: '', })) if (models.length > 0) { cliProviderModelCache = models cliProviderModelCacheTime = Date.now() return models } } } catch { // fall through } } } catch { // fall through } return [] } // ─── Router ─────────────────────────────────────────────────────────────────── export async function handleModelsApi( req: Request, url: URL, segments: string[], ): Promise { try { const resource = segments[1] // 'models' | 'effort' const sub = segments[2] // 'current' | undefined // ── /api/effort ─────────────────────────────────────────────────── if (resource === 'effort') { return await handleEffort(req) } // ── /api/models/* ───────────────────────────────────────────────── switch (sub) { case undefined: // GET /api/models — 优先从激活的 Provider 读取模型列表 if (req.method !== 'GET') throw methodNotAllowed(req.method) return await handleModelsList() case 'current': return await handleCurrentModel(req) default: throw ApiError.notFound(`Unknown models endpoint: ${sub}`) } } catch (error) { return errorResponse(error) } } // ─── Handlers ───────────────────────────────────────────────────────────────── export async function readCliAuthProvider(): Promise<{ authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia' } | null> { try { const { homedir } = await import('node:os') const { readFileSync } = await import('node:fs') const { join } = await import('node:path') const raw = readFileSync(join(homedir(), '.claude.json'), 'utf8') return JSON.parse(raw) } catch { return null } } const CLI_PROVIDER_NAMES: Record = { opencode: 'OpenCode Zen', nvidia: 'NVIDIA', openrouter: 'OpenRouter', local: 'Local', anthropic: 'Anthropic', openai: 'OpenAI', } async function handleModelsList(): Promise { const cliConfig = await readCliAuthProvider() const cliAuthProvider = cliConfig?.authProvider const { providers, activeId } = await providerService.listProviders() if (activeId) { const activeProvider = providers.find((p) => p.id === activeId) if (activeProvider) { const presetType = activeProvider.presetId?.replace(/^tui-/, '') if (presetType && cliAuthProvider === presetType) { const cliModels = await fetchCliProviderModels() if (cliModels.length > 0) { return Response.json({ models: cliModels, provider: { id: activeProvider.id, name: activeProvider.name }, }) } } if (isOpenAIOfficialProviderId(activeId)) { return Response.json({ models: buildOpenAIModelList(), provider: { id: OPENAI_OFFICIAL_PROVIDER_ID, name: OPENAI_OFFICIAL_PROVIDER_NAME }, }) } return Response.json({ models: buildProviderModelList(activeProvider.models), provider: { id: activeProvider.id, name: activeProvider.name }, }) } } if (cliAuthProvider && cliAuthProvider !== 'anthropic' && cliAuthProvider !== 'openai') { const cliModels = await fetchCliProviderModels() return Response.json({ models: cliModels, provider: { id: `cli-${cliAuthProvider}`, name: CLI_PROVIDER_NAMES[cliAuthProvider] || cliAuthProvider, }, }) } return Response.json({ models: getStandaloneModelList(), provider: null }) } async function handleCurrentModel(req: Request): Promise { if (req.method === 'GET') { // Build the full model list: prefer active provider's models, fall back to defaults const { providers, activeId } = await providerService.listProviders() const isOpenAIProviderActive = isOpenAIOfficialProviderId(activeId) const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null const settings = activeProvider || isOpenAIProviderActive ? await providerService.getManagedSettings() : await settingsService.getUserSettings() const explicitModel = (settings.model as string) || '' const contextTier = (settings.modelContext as string) || undefined const env = (settings.env as Record) || {} const envModel = process.env.ANTHROPIC_MODEL?.trim() || '' let currentModelId: string let currentModelName: string if (isOpenAIProviderActive) { currentModelId = explicitModel || env.ANTHROPIC_MODEL || 'gpt-5.3-codex' currentModelName = currentModelId } else if (activeProvider) { // Provider is active — only use the provider-managed cc-haha settings. // This avoids leaking global ~/.claude/settings.json model choices into // the active provider flow. const providerEnvModel = env.ANTHROPIC_MODEL if (providerEnvModel && !explicitModel) { currentModelId = providerEnvModel currentModelName = providerEnvModel } else { currentModelId = explicitModel || providerEnvModel || activeProvider.models.main currentModelName = currentModelId } } else { // No provider — use settings model with context tier currentModelId = explicitModel || envModel || DEFAULT_MODEL currentModelName = currentModelId } const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId // Build available models for name lookup const cliModelsFallback = !isOpenAIProviderActive && !activeProvider ? await fetchCliProviderModels() : [] const availableModels = isOpenAIProviderActive ? buildOpenAIModelList() : activeProvider ? buildProviderModelList(activeProvider.models) : cliModelsFallback.length > 0 ? cliModelsFallback : getStandaloneModelList() const modelEntry = availableModels.find((m) => m.id === lookupId) || availableModels.find((m) => m.id === currentModelId) || { id: currentModelId, name: currentModelName, description: 'Custom model', context: contextTier || 'unknown', } return Response.json({ model: { ...modelEntry, context: contextTier || modelEntry.context } }) } if (req.method === 'PUT') { const body = await parseJsonBody(req) const modelId = body.modelId if (typeof modelId !== 'string' || !modelId) { throw ApiError.badRequest('Missing or invalid "modelId" in request body') } // Parse composite IDs like 'claude-opus-4-7-20250610:1m' // Persist the base model ID for CLI compatibility and context tier separately const colonIdx = modelId.indexOf(':') const baseId = colonIdx !== -1 ? modelId.slice(0, colonIdx) : modelId const contextTier = colonIdx !== -1 ? modelId.slice(colonIdx + 1) : undefined const updates: Record = { model: baseId } if (contextTier) { updates.modelContext = contextTier } else { // Clear context tier when switching to a non-composite model updates.modelContext = undefined } const { activeId } = await providerService.listProviders() if (activeId) { const currentManagedSettings = await providerService.getManagedSettings() const currentEnv = (currentManagedSettings.env as Record | undefined) ?? {} await providerService.updateManagedSettings({ ...updates, env: { ...currentEnv, ...attributionHeaderEnvForModel(baseId), }, }) } else { await settingsService.updateUserSettings(updates) } return Response.json({ ok: true, model: modelId }) } throw methodNotAllowed(req.method) } async function handleEffort(req: Request): Promise { if (req.method === 'GET') { const settings = await settingsService.getUserSettings() const level = normalizeEffortLevel(settings.effort) return Response.json({ level, available: EFFORT_LEVELS }) } if (req.method === 'PUT') { const body = await parseJsonBody(req) const level = body.level if (typeof level !== 'string') { throw ApiError.badRequest('Missing or invalid "level" in request body') } if (!EFFORT_LEVELS.includes(level as (typeof EFFORT_LEVELS)[number])) { throw ApiError.badRequest( `Invalid effort level: "${level}". Valid levels: ${EFFORT_LEVELS.join(', ')}`, ) } await settingsService.updateUserSettings({ effort: level }) return Response.json({ ok: true, level }) } throw methodNotAllowed(req.method) } // ─── Helpers ────────────────────────────────────────────────────────────────── async function parseJsonBody(req: Request): Promise> { try { return (await req.json()) as Record } catch { throw ApiError.badRequest('Invalid JSON body') } } function methodNotAllowed(method: string): ApiError { return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') }