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", () => {
|
describe("normalizeClaudeRuntimeModelId", () => {
|
||||||
const supportedModelIds = new Set(["default", "opus", "haiku"]);
|
const supportedModelIds = new Set(["default", "opus", "haiku"]);
|
||||||
|
const fallbackCatalogIds = new Set(["default", "sonnet", "haiku"]);
|
||||||
|
|
||||||
test("maps runtime Sonnet IDs to default alias", () => {
|
test("maps runtime Sonnet IDs to default alias", () => {
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
const normalized = normalizeClaudeRuntimeModelId({
|
||||||
@@ -33,6 +34,27 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
|||||||
expect(normalized).toBe("opus");
|
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", () => {
|
test("maps runtime Haiku IDs to haiku alias", () => {
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
const normalized = normalizeClaudeRuntimeModelId({
|
||||||
runtimeModelId: "claude-haiku-4-5-20251001",
|
runtimeModelId: "claude-haiku-4-5-20251001",
|
||||||
@@ -57,4 +79,12 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
|||||||
});
|
});
|
||||||
expect(normalized).toBe("claude-custom-unknown");
|
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";
|
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||||
|
|
||||||
const fsPromises = promises;
|
const fsPromises = promises;
|
||||||
|
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
|
||||||
|
"user",
|
||||||
|
"project",
|
||||||
|
];
|
||||||
|
|
||||||
type TurnState = "idle" | "foreground" | "autonomous";
|
type TurnState = "idle" | "foreground" | "autonomous";
|
||||||
|
|
||||||
@@ -118,6 +122,8 @@ type ForegroundTurnState = {
|
|||||||
queue: Pushable<AgentStreamEvent>;
|
queue: Pushable<AgentStreamEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClaudeModelFamily = "sonnet" | "opus" | "haiku";
|
||||||
|
|
||||||
function normalizeClaudeModelLabel(model: ModelInfo): string {
|
function normalizeClaudeModelLabel(model: ModelInfo): string {
|
||||||
const fallback = model.displayName?.trim() || model.value;
|
const fallback = model.displayName?.trim() || model.value;
|
||||||
const prefix = model.description?.split(/[·•]/)[0]?.trim() || "";
|
const prefix = model.description?.split(/[·•]/)[0]?.trim() || "";
|
||||||
@@ -136,6 +142,7 @@ function normalizeClaudeModelLabel(model: ModelInfo): string {
|
|||||||
type NormalizeClaudeRuntimeModelIdOptions = {
|
type NormalizeClaudeRuntimeModelIdOptions = {
|
||||||
runtimeModelId: string;
|
runtimeModelId: string;
|
||||||
supportedModelIds: ReadonlySet<string> | null;
|
supportedModelIds: ReadonlySet<string> | null;
|
||||||
|
supportedModelFamilyAliases?: ReadonlyMap<ClaudeModelFamily, string> | null;
|
||||||
configuredModelId?: string | null;
|
configuredModelId?: string | null;
|
||||||
currentModelId?: string | null;
|
currentModelId?: string | null;
|
||||||
};
|
};
|
||||||
@@ -159,6 +166,44 @@ function pickSupportedModelId(
|
|||||||
return supportedModelIds.has(normalizedCandidate) ? normalizedCandidate : null;
|
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(
|
export function normalizeClaudeRuntimeModelId(
|
||||||
options: NormalizeClaudeRuntimeModelIdOptions
|
options: NormalizeClaudeRuntimeModelIdOptions
|
||||||
): string {
|
): string {
|
||||||
@@ -172,28 +217,40 @@ export function normalizeClaudeRuntimeModelId(
|
|||||||
return runtimeModel;
|
return runtimeModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowerRuntimeModel = runtimeModel.toLowerCase();
|
const runtimeFamily = inferClaudeModelFamilyFromText(runtimeModel);
|
||||||
if (lowerRuntimeModel.includes("sonnet")) {
|
const familyAlias = runtimeFamily
|
||||||
|
? pickFamilyAliasModelId(options.supportedModelFamilyAliases, runtimeFamily)
|
||||||
|
: null;
|
||||||
|
if (runtimeFamily === "sonnet") {
|
||||||
const explicitSonnet = pickSupportedModelId(supportedModelIds, "sonnet");
|
const explicitSonnet = pickSupportedModelId(supportedModelIds, "sonnet");
|
||||||
if (explicitSonnet) {
|
if (explicitSonnet) {
|
||||||
return explicitSonnet;
|
return explicitSonnet;
|
||||||
}
|
}
|
||||||
|
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
||||||
|
return familyAlias;
|
||||||
|
}
|
||||||
const defaultAlias = pickSupportedModelId(supportedModelIds, "default");
|
const defaultAlias = pickSupportedModelId(supportedModelIds, "default");
|
||||||
if (defaultAlias) {
|
if (defaultAlias) {
|
||||||
return defaultAlias;
|
return defaultAlias;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lowerRuntimeModel.includes("opus")) {
|
if (runtimeFamily === "opus") {
|
||||||
const alias = pickSupportedModelId(supportedModelIds, "opus");
|
const alias = pickSupportedModelId(supportedModelIds, "opus");
|
||||||
if (alias) {
|
if (alias) {
|
||||||
return alias;
|
return alias;
|
||||||
}
|
}
|
||||||
|
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
||||||
|
return familyAlias;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (lowerRuntimeModel.includes("haiku")) {
|
if (runtimeFamily === "haiku") {
|
||||||
const alias = pickSupportedModelId(supportedModelIds, "haiku");
|
const alias = pickSupportedModelId(supportedModelIds, "haiku");
|
||||||
if (alias) {
|
if (alias) {
|
||||||
return alias;
|
return alias;
|
||||||
}
|
}
|
||||||
|
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
||||||
|
return familyAlias;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportedModelIds.has(runtimeModel)) {
|
if (supportedModelIds.has(runtimeModel)) {
|
||||||
@@ -216,6 +273,16 @@ export function normalizeClaudeRuntimeModelId(
|
|||||||
return currentModelId;
|
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;
|
return runtimeModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1397,6 +1464,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
cwd: options?.cwd ?? process.cwd(),
|
cwd: options?.cwd ?? process.cwd(),
|
||||||
permissionMode: "plan",
|
permissionMode: "plan",
|
||||||
includePartialMessages: false,
|
includePartialMessages: false,
|
||||||
|
settingSources: CLAUDE_SETTING_SOURCES,
|
||||||
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
|
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1505,6 +1573,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||||
private lastOptionsModel: string | null = null;
|
private lastOptionsModel: string | null = null;
|
||||||
private selectableModelIds: Set<string> | null = null;
|
private selectableModelIds: Set<string> | null = null;
|
||||||
|
private selectableModelFamilyAliases: Map<ClaudeModelFamily, string> | null = null;
|
||||||
private activeSidechains = new Map<string, SubAgentActivityState>();
|
private activeSidechains = new Map<string, SubAgentActivityState>();
|
||||||
private compacting = false;
|
private compacting = false;
|
||||||
private queryPumpPromise: Promise<void> | null = null;
|
private queryPumpPromise: Promise<void> | null = null;
|
||||||
@@ -2201,13 +2270,31 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private async primeSelectableModelIds(query: Query): Promise<void> {
|
private async primeSelectableModelIds(query: Query): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const models = await query.supportedModels();
|
const models = await query.supportedModels();
|
||||||
const ids = models
|
const ids: string[] = [];
|
||||||
.map((model) => model.value?.trim())
|
const familyAliases = new Map<ClaudeModelFamily, string>();
|
||||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
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.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) {
|
} catch (error) {
|
||||||
this.selectableModelIds = null;
|
this.selectableModelIds = null;
|
||||||
|
this.selectableModelFamilyAliases = null;
|
||||||
this.logger.warn({ err: error }, "Failed to prime Claude selectable model IDs");
|
this.logger.warn({ err: error }, "Failed to prime Claude selectable model IDs");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2295,7 +2382,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
preset: "claude_code",
|
preset: "claude_code",
|
||||||
append: appendedSystemPrompt,
|
append: appendedSystemPrompt,
|
||||||
},
|
},
|
||||||
settingSources: ["user", "project"],
|
settingSources: CLAUDE_SETTING_SOURCES,
|
||||||
stderr: (data: string) => {
|
stderr: (data: string) => {
|
||||||
this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr");
|
this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr");
|
||||||
},
|
},
|
||||||
@@ -3511,6 +3598,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const normalizedModel = normalizeClaudeRuntimeModelId({
|
const normalizedModel = normalizeClaudeRuntimeModelId({
|
||||||
runtimeModelId: message.model,
|
runtimeModelId: message.model,
|
||||||
supportedModelIds: this.selectableModelIds,
|
supportedModelIds: this.selectableModelIds,
|
||||||
|
supportedModelFamilyAliases: this.selectableModelFamilyAliases,
|
||||||
configuredModelId: this.config.model ?? null,
|
configuredModelId: this.config.model ?? null,
|
||||||
currentModelId: this.lastOptionsModel,
|
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