| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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' |
|
|
| |
|
|
| 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 |
| } |
|
|
| |
|
|
| let cliProviderModelCache: ApiModelInfo[] | null = null |
| let cliProviderModelCacheTime = 0 |
| const CLI_PROVIDER_CACHE_TTL = 5 * 60 * 1000 |
|
|
| 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 { |
| |
| 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 |
|
|
| |
| 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 { |
| |
| } |
| } |
|
|
| |
| 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 { |
| |
| } |
| } |
|
|
| |
| 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 { |
| |
| } |
| } |
| } catch { |
| |
| } |
|
|
| return [] |
| } |
|
|
| |
|
|
| export async function handleModelsApi( |
| req: Request, |
| url: URL, |
| segments: string[], |
| ): Promise<Response> { |
| try { |
| const resource = segments[1] |
| const sub = segments[2] |
|
|
| |
| if (resource === 'effort') { |
| return await handleEffort(req) |
| } |
|
|
| |
| switch (sub) { |
| case undefined: |
| |
| 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) |
| } |
| } |
|
|
| |
|
|
| 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') { |
| |
| 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) { |
| |
| |
| |
| const providerEnvModel = env.ANTHROPIC_MODEL |
| if (providerEnvModel && !explicitModel) { |
| currentModelId = providerEnvModel |
| currentModelName = providerEnvModel |
| } else { |
| currentModelId = explicitModel || providerEnvModel || activeProvider.models.main |
| currentModelName = currentModelId |
| } |
| } else { |
| |
| currentModelId = explicitModel || envModel || DEFAULT_MODEL |
| currentModelName = currentModelId |
| } |
|
|
| const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId |
|
|
| |
| 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') |
| } |
|
|
| |
| |
| 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 { |
| |
| 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) |
| } |
|
|
| |
|
|
| 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') |
| } |
|
|