server: hardcode Claude model catalog with runtime IDs

This commit is contained in:
Mohamed Boudra
2026-02-25 16:38:06 +07:00
parent c4523c70b5
commit 3355f7d8d9
3 changed files with 170 additions and 215 deletions

View File

@@ -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");
});

View File

@@ -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<Options["settingSources"]> = [
"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<AgentStreamEvent>;
};
type ClaudeModelFamily = "sonnet" | "opus" | "haiku";
function withTimeout<T>(params: {
promise: Promise<T>;
timeoutMs: number;
label: string;
}): Promise<T> {
const { promise, timeoutMs, label } = params;
return new Promise<T>((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<string> | 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<AgentSession> {
const claudeConfig = this.assertConfig(config);
return new ClaudeAgentSession(claudeConfig, {
@@ -1532,58 +1425,8 @@ export class ClaudeAgentClient implements AgentClient {
});
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
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<AgentModelDefinition[]> {
return listClaudeCatalogModels();
}
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
@@ -1659,8 +1502,9 @@ class ClaudeAgentSession implements AgentSession {
private activeTurnPromise: Promise<void> | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null;
private selectableModelIds: Set<string> | null = null;
private selectableModelFamilyAliases: Map<ClaudeModelFamily, string> | null = null;
private selectableModelIds: Set<string> | null = buildClaudeSelectableModelIds();
private selectableModelFamilyAliases: Map<ClaudeModelFamily, string> | null =
buildClaudeModelFamilyAliases();
private activeSidechains = new Map<string, SubAgentActivityState>();
private compacting = false;
private queryPumpPromise: Promise<void> | null = null;

View File

@@ -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<string> {
return new Set(CLAUDE_MODEL_CATALOG.map((model) => model.modelId));
}
export function buildClaudeModelFamilyAliases(): Map<ClaudeModelFamily, string> {
const aliases = new Map<ClaudeModelFamily, string>();
for (const model of CLAUDE_MODEL_CATALOG) {
if (model.isLatestInFamily || !aliases.has(model.family)) {
aliases.set(model.family, model.modelId);
}
}
return aliases;
}