mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop-managed sessions were blocking the first visible token on slow or failing regular MCP connections, which made the UI feel much slower than direct bin runs. This keeps partial assistant streaming enabled for desktop sessions and moves regular MCP connection work off the critical path only for sdk-url startup. Constraint: Desktop sdk-url sessions must preserve chat responsiveness even when local MCP endpoints fail or hang Rejected: Leave MCP startup fully blocking in sdk-url mode | first visible token stayed several seconds behind direct CLI runs Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep blocking MCP startup for plain -p sessions unless you re-measure first-turn latency and tool availability tradeoffs Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/conversations.test.ts; same-prompt timing comparison for direct bin vs sdk-url session path Not-tested: Immediate first-turn availability of slow regular MCP tools in desktop sdk-url sessions
223 lines
9.3 KiB
TypeScript
223 lines
9.3 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 { ApiError, errorResponse } from '../middleware/errorHandler.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()
|
|
|
|
// ─── 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()
|
|
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
|
if (activeProvider) {
|
|
// Convert ModelMapping to model list for API compatibility
|
|
const modelList = [
|
|
{ id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' },
|
|
...(activeProvider.models.haiku !== activeProvider.models.main ? [{ id: activeProvider.models.haiku, name: activeProvider.models.haiku, description: 'Haiku model', context: '' }] : []),
|
|
...(activeProvider.models.sonnet !== activeProvider.models.main ? [{ id: activeProvider.models.sonnet, name: activeProvider.models.sonnet, description: 'Sonnet model', context: '' }] : []),
|
|
...(activeProvider.models.opus !== activeProvider.models.main ? [{ id: activeProvider.models.opus, name: activeProvider.models.opus, description: 'Opus model', context: '' }] : []),
|
|
]
|
|
return Response.json({
|
|
models: modelList,
|
|
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 explicitModel = (settings.model as string) || ''
|
|
const contextTier = (settings.modelContext as string) || undefined
|
|
const env = (settings.env as Record<string, string>) || {}
|
|
|
|
// Build the full model list: prefer active provider's models, fall back to defaults
|
|
const { providers, activeId } = await providerService.listProviders()
|
|
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
|
|
|
let currentModelId: string
|
|
let currentModelName: string
|
|
|
|
if (activeProvider) {
|
|
// Provider is active — use the model from env (set by syncToSettings when provider was activated)
|
|
// unless user explicitly set a different model ID in settings
|
|
const providerEnvModel = env.ANTHROPIC_MODEL
|
|
if (providerEnvModel && (!explicitModel || explicitModel === DEFAULT_MODEL)) {
|
|
// No explicit model override — use the provider's configured model
|
|
currentModelId = providerEnvModel
|
|
currentModelName = providerEnvModel
|
|
} else {
|
|
// User explicitly set a model (possibly from the provider's model list)
|
|
currentModelId = explicitModel || providerEnvModel || activeProvider.models.main
|
|
currentModelName = currentModelId
|
|
}
|
|
} else {
|
|
// No provider — use settings model with context tier
|
|
currentModelId = explicitModel || DEFAULT_MODEL
|
|
currentModelName = currentModelId
|
|
}
|
|
|
|
const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId
|
|
|
|
// Build available models for name lookup
|
|
const availableModels = activeProvider
|
|
? [
|
|
{ id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' },
|
|
...(activeProvider.models.haiku && activeProvider.models.haiku !== activeProvider.models.main ? [{ id: activeProvider.models.haiku, name: activeProvider.models.haiku, description: 'Haiku model', context: '' }] : []),
|
|
...(activeProvider.models.sonnet && activeProvider.models.sonnet !== activeProvider.models.main ? [{ id: activeProvider.models.sonnet, name: activeProvider.models.sonnet, description: 'Sonnet model', context: '' }] : []),
|
|
...(activeProvider.models.opus && activeProvider.models.opus !== activeProvider.models.main ? [{ id: activeProvider.models.opus, name: activeProvider.models.opus, description: 'Opus model', context: '' }] : []),
|
|
]
|
|
: DEFAULT_MODELS
|
|
|
|
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
|
|
}
|
|
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 = (settings.effort as string) || DEFAULT_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')
|
|
}
|