mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
server: hardcode Claude model catalog with runtime IDs
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user