diff --git a/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts b/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts index ea35abd6b..b9f7e25bc 100644 --- a/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts @@ -1,75 +1,84 @@ import { describe, expect, test } from "vitest"; import { normalizeClaudeRuntimeModelId } from "./claude-agent.js"; +import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js"; describe("normalizeClaudeRuntimeModelId", () => { - const supportedModelIds = new Set(["default", "opus", "haiku"]); - const fallbackCatalogIds = new Set(["default", "sonnet", "haiku"]); + function latestModelId(family: "sonnet" | "opus" | "haiku"): string { + const latest = CLAUDE_MODEL_CATALOG.find( + (model) => model.family === family && model.isLatestInFamily + ); + if (latest) { + return latest.modelId; + } + const fallback = CLAUDE_MODEL_CATALOG.find((model) => model.family === family); + if (!fallback) { + throw new Error(`Missing Claude model family in catalog: ${family}`); + } + return fallback.modelId; + } - test("maps runtime Sonnet IDs to default alias", () => { + const SONNET = latestModelId("sonnet"); + const OPUS = latestModelId("opus"); + const HAIKU = latestModelId("haiku"); + const supportedModelIds = new Set([SONNET, OPUS, HAIKU]); + const supportedModelFamilyAliases = new Map([ + ["sonnet", SONNET], + ["opus", OPUS], + ["haiku", HAIKU], + ] as const); + + test("preserves runtime model when it already exists in the supported catalog", () => { const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-sonnet-4-5-20250929", + runtimeModelId: SONNET, supportedModelIds, }); - expect(normalized).toBe("default"); + expect(normalized).toBe(SONNET); }); - test("prefers alias even when runtime versioned Sonnet ID appears in supported list", () => { + test("maps unknown runtime Sonnet versions to the catalog Sonnet model ID", () => { const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-sonnet-4-5-20250929", - supportedModelIds: new Set([ - "default", - "opus", - "haiku", - "claude-sonnet-4-5-20250929", - ]), + runtimeModelId: "claude-sonnet-4-6-20260101", + supportedModelIds, + supportedModelFamilyAliases, }); - expect(normalized).toBe("default"); + expect(normalized).toBe(SONNET); }); - test("maps runtime Opus IDs to opus alias", () => { + test("maps unknown runtime Opus versions to the catalog Opus model ID", () => { const normalized = normalizeClaudeRuntimeModelId({ runtimeModelId: "claude-opus-4-5-20251101", supportedModelIds, + supportedModelFamilyAliases, }); - expect(normalized).toBe("opus"); + expect(normalized).toBe(OPUS); }); - test("maps runtime Opus IDs to catalog-derived family alias when explicit alias is absent", () => { + test("maps unknown runtime Haiku versions to the catalog Haiku model ID", () => { const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-opus-4-6", - supportedModelIds: fallbackCatalogIds, - supportedModelFamilyAliases: new Map([ - ["sonnet", "sonnet"], - ["opus", "default"], - ["haiku", "haiku"], - ]), - }); - expect(normalized).toBe("default"); - }); - - test("falls back to default for known runtime families when no explicit alias exists", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-opus-4-6", - supportedModelIds: fallbackCatalogIds, - }); - expect(normalized).toBe("default"); - }); - - test("maps runtime Haiku IDs to haiku alias", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-haiku-4-5-20251001", + runtimeModelId: "claude-haiku-4-6-20260101", supportedModelIds, + supportedModelFamilyAliases, }); - expect(normalized).toBe("haiku"); + expect(normalized).toBe(HAIKU); }); test("uses configured model when runtime ID is unknown", () => { const normalized = normalizeClaudeRuntimeModelId({ runtimeModelId: "claude-custom-unknown", supportedModelIds, - configuredModelId: "opus", + configuredModelId: OPUS, }); - expect(normalized).toBe("opus"); + expect(normalized).toBe(OPUS); + }); + + test("uses current model when runtime and configured IDs are unknown", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-custom-unknown", + supportedModelIds, + configuredModelId: "claude-unknown", + currentModelId: HAIKU, + }); + expect(normalized).toBe(HAIKU); }); test("preserves runtime model when mapping is not possible", () => { @@ -80,10 +89,10 @@ describe("normalizeClaudeRuntimeModelId", () => { expect(normalized).toBe("claude-custom-unknown"); }); - test("does not force default fallback for unknown runtime families", () => { + test("does not force family fallback for unknown runtime families", () => { const normalized = normalizeClaudeRuntimeModelId({ runtimeModelId: "claude-custom-unknown", - supportedModelIds: fallbackCatalogIds, + supportedModelIds, }); expect(normalized).toBe("claude-custom-unknown"); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index bb2bc10f5..fb7603f97 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -9,7 +9,6 @@ import { type AgentDefinition, type CanUseTool, type McpServerConfig as ClaudeSdkMcpServerConfig, - type ModelInfo, type Options, type PermissionMode, type PermissionResult, @@ -34,6 +33,12 @@ import { mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, } from "./claude/task-notification-tool-call.js"; +import { + buildClaudeModelFamilyAliases, + buildClaudeSelectableModelIds, + listClaudeCatalogModels, + type ClaudeModelFamily, +} from "./claude/model-catalog.js"; import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; import type { @@ -74,34 +79,6 @@ const CLAUDE_SETTING_SOURCES: NonNullable = [ "user", "project", ]; -const CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS = 20_000; -const CLAUDE_MODEL_QUERY_SHUTDOWN_TIMEOUT_MS = 2_000; - -type ClaudeFallbackModel = { - id: string; - label: string; - description: string; - isDefault?: boolean; -}; - -const CLAUDE_FALLBACK_MODELS: readonly ClaudeFallbackModel[] = [ - { - id: "default", - label: "Sonnet 4.5", - description: "Best for everyday tasks", - isDefault: true, - }, - { - id: "opus", - label: "Opus 4.6", - description: "Most capable model for deep analysis and complex code changes", - }, - { - id: "haiku", - label: "Haiku 4.5", - description: "Fastest Claude model for lightweight tasks", - }, -]; type TurnState = "idle" | "foreground" | "autonomous"; @@ -155,83 +132,6 @@ type ForegroundTurnState = { queue: Pushable; }; -type ClaudeModelFamily = "sonnet" | "opus" | "haiku"; - -function withTimeout(params: { - promise: Promise; - timeoutMs: number; - label: string; -}): Promise { - const { promise, timeoutMs, label } = params; - return new Promise((resolve, reject) => { - const timeoutHandle = setTimeout(() => { - reject(new Error(`${label} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - promise.then( - (value) => { - clearTimeout(timeoutHandle); - resolve(value); - }, - (error) => { - clearTimeout(timeoutHandle); - reject(error); - } - ); - }); -} - -function toClaudeModelDefinition(params: { - id: string; - label: string; - description?: string; - isDefault?: boolean; -}): AgentModelDefinition { - return { - provider: "claude", - id: params.id, - label: params.label, - description: params.description, - ...(params.isDefault ? { isDefault: true } : {}), - thinkingOptions: [ - { id: "off", label: "Off", isDefault: true }, - { id: "on", label: "On" }, - ], - defaultThinkingOptionId: "off", - metadata: params.description - ? { - description: params.description, - } - : undefined, - }; -} - -function buildClaudeFallbackModels(): AgentModelDefinition[] { - return CLAUDE_FALLBACK_MODELS.map((model) => - toClaudeModelDefinition({ - id: model.id, - label: model.label, - description: model.description, - isDefault: model.isDefault, - }) - ); -} - -function normalizeClaudeModelLabel(model: ModelInfo): string { - const fallback = model.displayName?.trim() || model.value; - const prefix = model.description?.split(/[·•]/)[0]?.trim() || ""; - if (!prefix) return fallback; - - // Prefer concrete versioned labels from description (e.g. "Opus 4.6", - // "Sonnet 4.5"), especially when displayName is generic like - // "Default (recommended)". - if (/\d/.test(prefix)) { - return prefix; - } - - return fallback; -} - type NormalizeClaudeRuntimeModelIdOptions = { runtimeModelId: string; supportedModelIds: ReadonlySet | null; @@ -301,6 +201,10 @@ export function normalizeClaudeRuntimeModelId( return runtimeModel; } + if (supportedModelIds.has(runtimeModel)) { + return runtimeModel; + } + const runtimeFamily = inferClaudeModelFamilyFromText(runtimeModel); const familyAlias = runtimeFamily ? pickFamilyAliasModelId(options.supportedModelFamilyAliases, runtimeFamily) @@ -337,10 +241,6 @@ export function normalizeClaudeRuntimeModelId( } } - if (supportedModelIds.has(runtimeModel)) { - return runtimeModel; - } - const configuredModelId = pickSupportedModelId( supportedModelIds, options.configuredModelId @@ -1495,13 +1395,6 @@ export class ClaudeAgentClient implements AgentClient { } } - private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions { - return applyRuntimeSettingsToClaudeOptions( - options, - this.runtimeSettings - ); - } - async createSession(config: AgentSessionConfig): Promise { const claudeConfig = this.assertConfig(config); return new ClaudeAgentSession(claudeConfig, { @@ -1532,58 +1425,8 @@ export class ClaudeAgentClient implements AgentClient { }); } - async listModels(options?: ListModelsOptions): Promise { - const prompt = (async function* empty() {})(); - const claudeOptions: Options = { - cwd: options?.cwd ?? process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: CLAUDE_SETTING_SOURCES, - ...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}), - }; - - const claudeQuery = query({ - prompt, - options: this.applyRuntimeSettings(claudeOptions), - }); - try { - const models: ModelInfo[] = await withTimeout({ - promise: claudeQuery.supportedModels(), - timeoutMs: CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS, - label: "Claude model discovery", - }); - if (models.length === 0) { - this.logger.warn( - "Claude SDK returned an empty model catalog; using fallback Claude model aliases" - ); - return buildClaudeFallbackModels(); - } - return models.map((model) => - toClaudeModelDefinition({ - id: model.value, - label: normalizeClaudeModelLabel(model), - description: model.description, - }) - ); - } catch (error) { - this.logger.warn( - { err: error }, - "Failed to fetch Claude model catalog from SDK; using fallback Claude model aliases" - ); - return buildClaudeFallbackModels(); - } finally { - if (typeof claudeQuery.return === "function") { - try { - await withTimeout({ - promise: claudeQuery.return(), - timeoutMs: CLAUDE_MODEL_QUERY_SHUTDOWN_TIMEOUT_MS, - label: "Claude model query shutdown", - }); - } catch { - // ignore shutdown errors - } - } - } + async listModels(_options?: ListModelsOptions): Promise { + return listClaudeCatalogModels(); } async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise { @@ -1659,8 +1502,9 @@ class ClaudeAgentSession implements AgentSession { private activeTurnPromise: Promise | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private lastOptionsModel: string | null = null; - private selectableModelIds: Set | null = null; - private selectableModelFamilyAliases: Map | null = null; + private selectableModelIds: Set | null = buildClaudeSelectableModelIds(); + private selectableModelFamilyAliases: Map | null = + buildClaudeModelFamilyAliases(); private activeSidechains = new Map(); private compacting = false; private queryPumpPromise: Promise | null = null; diff --git a/packages/server/src/server/agent/providers/claude/model-catalog.ts b/packages/server/src/server/agent/providers/claude/model-catalog.ts new file mode 100644 index 000000000..b4f5bc689 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/model-catalog.ts @@ -0,0 +1,102 @@ +import type { AgentModelDefinition } from "../../agent-sdk-types.js"; + +/** + * Temporary hardcoded Claude model catalog. + * + * Why: + * - Claude SDK model discovery currently returns abstract options like + * "default", "opus", and "haiku". + * - Runtime init messages report concrete model IDs like + * "claude-opus-4-6". + * - That mismatch breaks model selection + thinking reconciliation in UI. + * + * We keep a single flat list with all model data in one place. + * If Claude SDK model discovery becomes consistent with runtime IDs, switch + * listModels back to SDK discovery and remove this file. + */ + +export type ClaudeCatalogModel = { + family: "sonnet" | "opus" | "haiku"; + modelId: string; + name: string; + description: string; + isDefault?: boolean; + isLatestInFamily?: boolean; +}; + +export const CLAUDE_MODEL_CATALOG: readonly ClaudeCatalogModel[] = [ + { + family: "sonnet", + modelId: "claude-sonnet-4-5-20250929", + name: "Sonnet 4.5", + description: "Sonnet 4.5 · Best for everyday tasks", + isDefault: true, + isLatestInFamily: true, + }, + { + family: "opus", + modelId: "claude-opus-4-6", + name: "Opus 4.6", + description: "Opus 4.6 · Most capable for complex work", + isLatestInFamily: true, + }, + { + family: "haiku", + modelId: "claude-haiku-4-5-20251001", + name: "Haiku 4.5", + description: "Haiku 4.5 · Fastest for quick answers", + isLatestInFamily: true, + }, +]; + +export type ClaudeModelFamily = ClaudeCatalogModel["family"]; + +function toClaudeModelDefinition(params: { + id: string; + label: string; + description?: string; + isDefault?: boolean; +}): AgentModelDefinition { + return { + provider: "claude", + id: params.id, + label: params.label, + description: params.description, + ...(params.isDefault ? { isDefault: true } : {}), + thinkingOptions: [ + { id: "off", label: "Off", isDefault: true }, + { id: "on", label: "On" }, + ], + defaultThinkingOptionId: "off", + metadata: params.description + ? { + description: params.description, + } + : undefined, + }; +} + +export function listClaudeCatalogModels(): AgentModelDefinition[] { + return CLAUDE_MODEL_CATALOG.map((model) => + toClaudeModelDefinition({ + id: model.modelId, + label: model.name, + description: model.description, + isDefault: model.isDefault, + }) + ); +} + +export function buildClaudeSelectableModelIds(): Set { + return new Set(CLAUDE_MODEL_CATALOG.map((model) => model.modelId)); +} + +export function buildClaudeModelFamilyAliases(): Map { + const aliases = new Map(); + for (const model of CLAUDE_MODEL_CATALOG) { + if (model.isLatestInFamily || !aliases.has(model.family)) { + aliases.set(model.family, model.modelId); + } + } + return aliases; +}