fix(server): reconcile claude runtime model ids against live catalog
This commit is contained in:
@@ -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
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user