codev / src /server /api /models.ts
chenbhao's picture
fix: nvidia provider chat
92e96ba
Raw
History Blame Contribute Delete
17.6 kB
/**
* 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<ApiModelInfo[]> {
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<string, unknown> = {}
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<Response> {
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<string, string> = {
opencode: 'OpenCode Zen',
nvidia: 'NVIDIA',
openrouter: 'OpenRouter',
local: 'Local',
anthropic: 'Anthropic',
openai: 'OpenAI',
}
async function handleModelsList(): Promise<Response> {
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<Response> {
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<string, string>) || {}
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<string, unknown> = { 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<string, string> | 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<Response> {
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<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')
}