cc-haha/src/server/api/models.ts
程序员阿江(Relakkes) 0fa6452cf2 fix(openai): support GPT-5.6 models (#997)
Load the authenticated Codex model catalog with a bundled fallback and carry model-native reasoning effort through the OpenAI OAuth transport. Keep desktop effort choices aligned with each model's advertised capabilities.

Constraint: Ultra remains a composite Codex delegation mode and is not exposed as a wire-level effort.
Confidence: high
Scope-risk: moderate
Tested: bun run check:server; bun run check:desktop; bun run check:persistence-upgrade; focused OAuth, WebSocket, and browser smoke checks
Not-tested: bun run verify
2026-07-11 02:55:41 +08:00

373 lines
12 KiB
TypeScript

/**
* 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 { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js'
import {
OPENAI_DEFAULT_MAIN_MODEL,
type OpenAIModelCatalogEntry,
} 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 = 'max'
const settingsService = new SettingsService()
const providerService = new ProviderService()
type ApiModelInfo = {
id: string
name: string
description: string
context: string
defaultReasoningEffort?: string
supportedReasoningEfforts?: 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(catalog: OpenAIModelCatalogEntry[]): ApiModelInfo[] {
return catalog.map(model => ({
id: model.value,
name: model.label,
description: model.description,
context: model.contextWindow ? String(model.contextWindow) : '',
defaultReasoningEffort: model.defaultReasoningEffort,
supportedReasoningEfforts: model.supportedReasoningEfforts,
}))
}
async function getOpenAIModelList(): Promise<ApiModelInfo[]> {
return buildOpenAIModelList(await getOpenAICodexModelCatalog())
}
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() || '',
})
}
async function getOpenAIAuthModels(): Promise<ApiModelInfo[]> {
if (!hasOpenAIAuthLogin()) {
return []
}
return getOpenAIModelList()
}
async function getStandaloneModelList(): Promise<ApiModelInfo[]> {
const models = [...getEnvConfiguredAnthropicModels()]
if (models.length === 0) {
models.push(...DEFAULT_MODELS)
}
for (const model of await 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
}
// ─── 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 ─────────────────────────────────────────────────────────────────
async function handleModelsList(): Promise<Response> {
const { providers, activeId } = await providerService.listProviders()
if (isOpenAIOfficialProviderId(activeId)) {
return Response.json({
models: await getOpenAIModelList(),
provider: {
id: OPENAI_OFFICIAL_PROVIDER_ID,
name: OPENAI_OFFICIAL_PROVIDER_NAME,
},
})
}
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
if (activeProvider) {
const modelList = buildProviderModelList(activeProvider.models)
return Response.json({
models: modelList,
provider: { id: activeProvider.id, name: activeProvider.name },
})
}
return Response.json({ models: await 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 || OPENAI_DEFAULT_MAIN_MODEL
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 availableModels = isOpenAIProviderActive
? await getOpenAIModelList()
: activeProvider
? buildProviderModelList(activeProvider.models)
: await 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')
}