From 42786956c7bf2008915e5c067f8dafb2d1c3b4b9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Feb 2026 22:37:16 +0700 Subject: [PATCH] fix(server): reconcile claude runtime model ids against live catalog --- .../claude-agent.model-normalization.test.ts | 30 +++++ .../server/agent/providers/claude-agent.ts | 106 ++++++++++++++++-- ...-runtime-reconcile-claude.real.e2e.test.ts | 63 +++++++++++ 3 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts 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 b6b399bc4..ea35abd6b 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 @@ -3,6 +3,7 @@ import { normalizeClaudeRuntimeModelId } from "./claude-agent.js"; describe("normalizeClaudeRuntimeModelId", () => { const supportedModelIds = new Set(["default", "opus", "haiku"]); + const fallbackCatalogIds = new Set(["default", "sonnet", "haiku"]); test("maps runtime Sonnet IDs to default alias", () => { const normalized = normalizeClaudeRuntimeModelId({ @@ -33,6 +34,27 @@ describe("normalizeClaudeRuntimeModelId", () => { expect(normalized).toBe("opus"); }); + test("maps runtime Opus IDs to catalog-derived family alias when explicit alias is absent", () => { + 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", @@ -57,4 +79,12 @@ describe("normalizeClaudeRuntimeModelId", () => { }); expect(normalized).toBe("claude-custom-unknown"); }); + + test("does not force default fallback for unknown runtime families", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-custom-unknown", + supportedModelIds: fallbackCatalogIds, + }); + 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 d9162a2df..e99c61b05 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -65,6 +65,10 @@ import { import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; const fsPromises = promises; +const CLAUDE_SETTING_SOURCES: NonNullable = [ + "user", + "project", +]; type TurnState = "idle" | "foreground" | "autonomous"; @@ -118,6 +122,8 @@ type ForegroundTurnState = { queue: Pushable; }; +type ClaudeModelFamily = "sonnet" | "opus" | "haiku"; + function normalizeClaudeModelLabel(model: ModelInfo): string { const fallback = model.displayName?.trim() || model.value; const prefix = model.description?.split(/[·•]/)[0]?.trim() || ""; @@ -136,6 +142,7 @@ function normalizeClaudeModelLabel(model: ModelInfo): string { type NormalizeClaudeRuntimeModelIdOptions = { runtimeModelId: string; supportedModelIds: ReadonlySet | null; + supportedModelFamilyAliases?: ReadonlyMap | null; configuredModelId?: string | null; currentModelId?: string | null; }; @@ -159,6 +166,44 @@ function pickSupportedModelId( return supportedModelIds.has(normalizedCandidate) ? normalizedCandidate : null; } +function inferClaudeModelFamilyFromText( + text: string | null | undefined +): ClaudeModelFamily | null { + if (typeof text !== "string") { + return null; + } + const lowerText = text.toLowerCase(); + if (lowerText.includes("sonnet")) { + return "sonnet"; + } + if (lowerText.includes("opus")) { + return "opus"; + } + if (lowerText.includes("haiku")) { + return "haiku"; + } + return null; +} + +function inferClaudeModelFamily(model: ModelInfo): ClaudeModelFamily | null { + const descriptionPrefix = model.description?.split(/[·•]/)[0]?.trim() || null; + return ( + inferClaudeModelFamilyFromText(descriptionPrefix) ?? + inferClaudeModelFamilyFromText(model.displayName) ?? + inferClaudeModelFamilyFromText(model.value) + ); +} + +function pickFamilyAliasModelId( + familyAliases: ReadonlyMap | null | undefined, + family: ClaudeModelFamily +): string | null { + if (!familyAliases) { + return null; + } + return normalizeModelIdCandidate(familyAliases.get(family) ?? null); +} + export function normalizeClaudeRuntimeModelId( options: NormalizeClaudeRuntimeModelIdOptions ): string { @@ -172,28 +217,40 @@ export function normalizeClaudeRuntimeModelId( return runtimeModel; } - const lowerRuntimeModel = runtimeModel.toLowerCase(); - if (lowerRuntimeModel.includes("sonnet")) { + const runtimeFamily = inferClaudeModelFamilyFromText(runtimeModel); + const familyAlias = runtimeFamily + ? pickFamilyAliasModelId(options.supportedModelFamilyAliases, runtimeFamily) + : null; + if (runtimeFamily === "sonnet") { const explicitSonnet = pickSupportedModelId(supportedModelIds, "sonnet"); if (explicitSonnet) { return explicitSonnet; } + if (familyAlias && supportedModelIds.has(familyAlias)) { + return familyAlias; + } const defaultAlias = pickSupportedModelId(supportedModelIds, "default"); if (defaultAlias) { return defaultAlias; } } - if (lowerRuntimeModel.includes("opus")) { + if (runtimeFamily === "opus") { const alias = pickSupportedModelId(supportedModelIds, "opus"); if (alias) { return alias; } + if (familyAlias && supportedModelIds.has(familyAlias)) { + return familyAlias; + } } - if (lowerRuntimeModel.includes("haiku")) { + if (runtimeFamily === "haiku") { const alias = pickSupportedModelId(supportedModelIds, "haiku"); if (alias) { return alias; } + if (familyAlias && supportedModelIds.has(familyAlias)) { + return familyAlias; + } } if (supportedModelIds.has(runtimeModel)) { @@ -216,6 +273,16 @@ export function normalizeClaudeRuntimeModelId( return currentModelId; } + // If Claude reports a concrete family ID we can't map directly, prefer the + // provider default alias for unconfigured sessions so UI model/thinking state + // can still reconcile against the current model catalog. + const defaultAlias = pickSupportedModelId(supportedModelIds, "default"); + const hasConfiguredModel = normalizeModelIdCandidate(options.configuredModelId) !== null; + const hasCurrentModel = normalizeModelIdCandidate(options.currentModelId) !== null; + if (runtimeFamily && defaultAlias && !hasConfiguredModel && !hasCurrentModel) { + return defaultAlias; + } + return runtimeModel; } @@ -1397,6 +1464,7 @@ export class ClaudeAgentClient implements AgentClient { cwd: options?.cwd ?? process.cwd(), permissionMode: "plan", includePartialMessages: false, + settingSources: CLAUDE_SETTING_SOURCES, ...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}), }; @@ -1505,6 +1573,7 @@ class ClaudeAgentSession implements AgentSession { private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private lastOptionsModel: string | null = null; private selectableModelIds: Set | null = null; + private selectableModelFamilyAliases: Map | null = null; private activeSidechains = new Map(); private compacting = false; private queryPumpPromise: Promise | null = null; @@ -2201,13 +2270,31 @@ class ClaudeAgentSession implements AgentSession { private async primeSelectableModelIds(query: Query): Promise { try { const models = await query.supportedModels(); - const ids = models - .map((model) => model.value?.trim()) - .filter((id): id is string => typeof id === "string" && id.length > 0); + const ids: string[] = []; + const familyAliases = new Map(); + for (const model of models) { + const modelId = normalizeModelIdCandidate(model.value); + if (!modelId) { + continue; + } + ids.push(modelId); + const family = inferClaudeModelFamily(model); + if (family && !familyAliases.has(family)) { + familyAliases.set(family, modelId); + } + } this.selectableModelIds = new Set(ids); - this.logger.debug({ modelIds: ids }, "Primed Claude selectable model IDs"); + this.selectableModelFamilyAliases = familyAliases.size > 0 ? familyAliases : null; + this.logger.debug( + { + modelIds: ids, + modelFamilyAliases: Object.fromEntries(familyAliases), + }, + "Primed Claude selectable model IDs" + ); } catch (error) { this.selectableModelIds = null; + this.selectableModelFamilyAliases = null; this.logger.warn({ err: error }, "Failed to prime Claude selectable model IDs"); } } @@ -2295,7 +2382,7 @@ class ClaudeAgentSession implements AgentSession { preset: "claude_code", append: appendedSystemPrompt, }, - settingSources: ["user", "project"], + settingSources: CLAUDE_SETTING_SOURCES, stderr: (data: string) => { this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr"); }, @@ -3511,6 +3598,7 @@ class ClaudeAgentSession implements AgentSession { const normalizedModel = normalizeClaudeRuntimeModelId({ runtimeModelId: message.model, supportedModelIds: this.selectableModelIds, + supportedModelFamilyAliases: this.selectableModelFamilyAliases, configuredModelId: this.config.model ?? null, currentModelId: this.lastOptionsModel, }); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts new file mode 100644 index 000000000..ca4cf90c7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { isCommandAvailable } from "../agent/provider-launch-config.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-claude-runtime-model-reconcile-")); +} + +describe("daemon E2E (real claude) - runtime model reconciliation", () => { + test.runIf(isCommandAvailable("claude"))( + "normalizes runtime model to a model ID exposed by the provider catalog", + async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, + }); + + const modelsResult = await client.listProviderModels("claude", { cwd }); + expect(modelsResult.error).toBeNull(); + expect(modelsResult.models.length).toBeGreaterThan(0); + const modelIds = new Set(modelsResult.models.map((model) => model.id)); + + const agent = await client.createAgent({ + provider: "claude", + cwd, + title: "claude-runtime-model-reconcile", + }); + + await client.sendMessage(agent.id, "Reply with exactly: OK"); + const finish = await client.waitForFinish(agent.id, 180_000); + expect(finish.status).toBe("idle"); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot).not.toBeNull(); + const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null; + expect(typeof runtimeModelId).toBe("string"); + expect(runtimeModelId).not.toBe(""); + expect(modelIds.has(runtimeModelId as string)).toBe(true); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 300_000 + ); +});