fix(server): reconcile claude runtime model ids against live catalog

This commit is contained in:
Mohamed Boudra
2026-02-22 22:37:16 +07:00
parent 9b6f5e028c
commit 42786956c7
3 changed files with 190 additions and 9 deletions
@@ -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");
});
});
@@ -65,6 +65,10 @@ import {
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
"user",
"project",
];
type TurnState = "idle" | "foreground" | "autonomous";
@@ -118,6 +122,8 @@ type ForegroundTurnState = {
queue: Pushable<AgentStreamEvent>;
};
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<string> | null;
supportedModelFamilyAliases?: ReadonlyMap<ClaudeModelFamily, string> | 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<ClaudeModelFamily, string> | 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<string> | null = null;
private selectableModelFamilyAliases: Map<ClaudeModelFamily, string> | null = null;
private activeSidechains = new Map<string, SubAgentActivityState>();
private compacting = false;
private queryPumpPromise: Promise<void> | null = null;
@@ -2201,13 +2270,31 @@ class ClaudeAgentSession implements AgentSession {
private async primeSelectableModelIds(query: Query): Promise<void> {
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<ClaudeModelFamily, string>();
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,
});
@@ -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
);
});