Use parsed Claude models and remove auto defaults
This commit is contained in:
@@ -1954,6 +1954,7 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
break;
|
||||
case "timeline":
|
||||
@@ -2369,8 +2370,7 @@ export class AgentManager {
|
||||
|
||||
if (typeof normalized.model === "string") {
|
||||
const trimmed = normalized.model.trim();
|
||||
const normalizedId = trimmed.toLowerCase();
|
||||
normalized.model = trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
|
||||
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||
@@ -18,6 +19,10 @@ function tmpCwd(prefix: string): string {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
return (async function* empty() {})();
|
||||
}
|
||||
|
||||
function compactText(value: string): string {
|
||||
return value.replace(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
@@ -208,6 +213,50 @@ describe("ClaudeAgentSession integration", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.runIf(canRunClaudeIntegration)(
|
||||
"supportedModels returns the current abstract Claude SDK model shape",
|
||||
async () => {
|
||||
const claudeQuery = query({
|
||||
prompt: createEmptyPrompt(),
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: ["user", "project"],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const models = await claudeQuery.supportedModels();
|
||||
|
||||
expect(models.length).toBeGreaterThanOrEqual(3);
|
||||
expect(models).toContainEqual(
|
||||
expect.objectContaining({
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
}),
|
||||
);
|
||||
expect(models).toContainEqual(
|
||||
expect.objectContaining({
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: expect.stringContaining("Haiku 4.5"),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
models.some(
|
||||
(model) =>
|
||||
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await claudeQuery.return?.();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-basic-tool-",
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { normalizeClaudeRuntimeModelId } from "./claude-agent.js";
|
||||
import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js";
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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: SONNET,
|
||||
supportedModelIds,
|
||||
});
|
||||
expect(normalized).toBe(SONNET);
|
||||
});
|
||||
|
||||
test("maps unknown runtime Sonnet versions to the catalog Sonnet model ID", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||
supportedModelIds,
|
||||
supportedModelFamilyAliases,
|
||||
});
|
||||
expect(normalized).toBe(SONNET);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("maps unknown runtime Haiku versions to the catalog Haiku model ID", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-haiku-4-6-20260101",
|
||||
supportedModelIds,
|
||||
supportedModelFamilyAliases,
|
||||
});
|
||||
expect(normalized).toBe(HAIKU);
|
||||
});
|
||||
|
||||
test("uses configured model when runtime ID is unknown", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-custom-unknown",
|
||||
supportedModelIds,
|
||||
configuredModelId: 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", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-custom-unknown",
|
||||
supportedModelIds: new Set(["x", "y"]),
|
||||
});
|
||||
expect(normalized).toBe("claude-custom-unknown");
|
||||
});
|
||||
|
||||
test("does not force family fallback for unknown runtime families", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-custom-unknown",
|
||||
supportedModelIds,
|
||||
});
|
||||
expect(normalized).toBe("claude-custom-unknown");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
@@ -241,6 +242,13 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
describe("ClaudeAgentClient.listModels", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
function createSupportedModelsQueryMock(models: ModelInfo[]) {
|
||||
return {
|
||||
supportedModels: vi.fn(async () => models),
|
||||
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||
};
|
||||
}
|
||||
|
||||
test("returns models with required fields", async () => {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const models = await client.listModels();
|
||||
@@ -267,4 +275,87 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("prefers provider-discovered Claude defaults and effort levels", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const queryFactory = vi.fn(() => queryMock);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.supportedModels).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-sonnet-4-6",
|
||||
isDefault: true,
|
||||
label: "Sonnet 4.6",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "max", label: "Max" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves SDK ids even when descriptions are weak", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: vi.fn(() => queryMock) as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
}),
|
||||
]);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AgentDefinition,
|
||||
type CanUseTool,
|
||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||
type ModelInfo,
|
||||
type Options,
|
||||
type PermissionMode,
|
||||
type PermissionResult,
|
||||
@@ -33,11 +34,9 @@ import {
|
||||
mapTaskNotificationUserContentToToolCall,
|
||||
} from "./claude/task-notification-tool-call.js";
|
||||
import {
|
||||
buildClaudeModelFamilyAliases,
|
||||
buildClaudeSelectableModelIds,
|
||||
listClaudeCatalogModels,
|
||||
type ClaudeModelFamily,
|
||||
} from "./claude/model-catalog.js";
|
||||
normalizeClaudeModelIdFromText,
|
||||
resolveClaudeModelsFromSdkModels,
|
||||
} from "./claude/sdk-model-resolver.js";
|
||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||
|
||||
@@ -92,136 +91,6 @@ type AsyncMessageInput<T> = {
|
||||
iterable: AsyncIterable<T>;
|
||||
};
|
||||
|
||||
type NormalizeClaudeRuntimeModelIdOptions = {
|
||||
runtimeModelId: string;
|
||||
supportedModelIds: ReadonlySet<string> | null;
|
||||
supportedModelFamilyAliases?: ReadonlyMap<ClaudeModelFamily, string> | null;
|
||||
configuredModelId?: string | null;
|
||||
currentModelId?: string | null;
|
||||
};
|
||||
|
||||
function normalizeModelIdCandidate(modelId: string | null | undefined): string | null {
|
||||
if (typeof modelId !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = modelId.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function pickSupportedModelId(
|
||||
supportedModelIds: ReadonlySet<string>,
|
||||
candidate: string | null | undefined,
|
||||
): string | null {
|
||||
const normalizedCandidate = normalizeModelIdCandidate(candidate);
|
||||
if (!normalizedCandidate) {
|
||||
return 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 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 {
|
||||
const runtimeModel = options.runtimeModelId.trim();
|
||||
if (!runtimeModel) {
|
||||
return runtimeModel;
|
||||
}
|
||||
|
||||
const supportedModelIds = options.supportedModelIds;
|
||||
if (!supportedModelIds || supportedModelIds.size === 0) {
|
||||
return runtimeModel;
|
||||
}
|
||||
|
||||
if (supportedModelIds.has(runtimeModel)) {
|
||||
return runtimeModel;
|
||||
}
|
||||
|
||||
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 (runtimeFamily === "opus") {
|
||||
const alias = pickSupportedModelId(supportedModelIds, "opus");
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
||||
return familyAlias;
|
||||
}
|
||||
}
|
||||
if (runtimeFamily === "haiku") {
|
||||
const alias = pickSupportedModelId(supportedModelIds, "haiku");
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
||||
return familyAlias;
|
||||
}
|
||||
}
|
||||
|
||||
const configuredModelId = pickSupportedModelId(supportedModelIds, options.configuredModelId);
|
||||
if (configuredModelId) {
|
||||
return configuredModelId;
|
||||
}
|
||||
|
||||
const currentModelId = pickSupportedModelId(supportedModelIds, options.currentModelId);
|
||||
if (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;
|
||||
}
|
||||
|
||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -296,6 +165,8 @@ type ClaudeAgentSessionOptions = {
|
||||
queryFactory?: typeof query;
|
||||
};
|
||||
|
||||
type ClaudeThinkingEffort = "low" | "medium" | "high" | "max";
|
||||
|
||||
function resolveClaudeSpawnCommand(
|
||||
spawnOptions: SpawnOptions,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
@@ -348,6 +219,14 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyClaudePrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
return (async function* empty() {})();
|
||||
}
|
||||
|
||||
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
||||
return value === "low" || value === "medium" || value === "high" || value === "max";
|
||||
}
|
||||
|
||||
type ClaudeOptionsLogSummary = {
|
||||
cwd: string | null;
|
||||
permissionMode: string | null;
|
||||
@@ -1144,8 +1023,34 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return listClaudeCatalogModels();
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const claudeQuery = this.queryFactory({
|
||||
prompt: createEmptyClaudePrompt(),
|
||||
options: applyRuntimeSettingsToClaudeOptions(
|
||||
{
|
||||
cwd: options?.cwd ?? process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: CLAUDE_SETTING_SOURCES,
|
||||
},
|
||||
this.runtimeSettings,
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
const supportedModels = await claudeQuery.supportedModels();
|
||||
return resolveClaudeModelsFromSdkModels(supportedModels as ModelInfo[]);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await claudeQuery.return?.();
|
||||
} catch {
|
||||
// ignore control-plane shutdown errors
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
@@ -1223,9 +1128,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private lastOptionsModel: string | null = null;
|
||||
private selectableModelIds: Set<string> | null = buildClaudeSelectableModelIds();
|
||||
private selectableModelFamilyAliases: Map<ClaudeModelFamily, string> | null =
|
||||
buildClaudeModelFamilyAliases();
|
||||
private lastRuntimeModel: string | null = null;
|
||||
private compacting = false;
|
||||
private queryPumpPromise: Promise<void> | null = null;
|
||||
private queryRestartNeeded = false;
|
||||
@@ -1281,6 +1184,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
sessionId: this.claudeSessionId,
|
||||
model: this.lastOptionsModel,
|
||||
modeId: this.currentMode ?? null,
|
||||
...(this.lastRuntimeModel
|
||||
? {
|
||||
extra: {
|
||||
runtimeModel: this.lastRuntimeModel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
this.cachedRuntimeInfo = info;
|
||||
return { ...info };
|
||||
@@ -1511,6 +1421,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await query.setModel(normalizedModelId ?? undefined);
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
||||
this.lastRuntimeModel = null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
// Model change affects persistence metadata, so invalidate cached handle.
|
||||
this.persistence = null;
|
||||
@@ -1524,10 +1435,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
|
||||
this.config.thinkingOptionId = undefined;
|
||||
} else if (normalizedThinkingOptionId === "on") {
|
||||
this.config.thinkingOptionId = "on";
|
||||
} else if (normalizedThinkingOptionId === "off") {
|
||||
this.config.thinkingOptionId = "off";
|
||||
} else if (isClaudeThinkingEffort(normalizedThinkingOptionId)) {
|
||||
this.config.thinkingOptionId = normalizedThinkingOptionId;
|
||||
} else {
|
||||
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
|
||||
}
|
||||
@@ -1914,16 +1823,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private buildOptions(): ClaudeOptions {
|
||||
const configuredThinkingOptionId = this.config.thinkingOptionId;
|
||||
const thinkingOptionId =
|
||||
configuredThinkingOptionId && configuredThinkingOptionId !== "default"
|
||||
? configuredThinkingOptionId
|
||||
: "off";
|
||||
let maxThinkingTokens: number | undefined;
|
||||
if (thinkingOptionId === "on") {
|
||||
maxThinkingTokens = 10000;
|
||||
} else if (thinkingOptionId === "off") {
|
||||
maxThinkingTokens = 0;
|
||||
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
|
||||
? this.config.thinkingOptionId
|
||||
: undefined;
|
||||
let thinking: ClaudeOptions["thinking"];
|
||||
let effort: ClaudeOptions["effort"];
|
||||
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
||||
thinking = { type: "adaptive" };
|
||||
effort = thinkingOptionId;
|
||||
}
|
||||
|
||||
const appendedSystemPrompt = [
|
||||
@@ -1963,7 +1871,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||
// resume that session to continue the conversation history.
|
||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
||||
...(maxThinkingTokens !== undefined ? { maxThinkingTokens } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...this.config.extra?.claude,
|
||||
};
|
||||
|
||||
@@ -2704,15 +2613,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.currentMode = message.permissionMode;
|
||||
this.persistence = null;
|
||||
if (message.model) {
|
||||
const normalizedModel = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: message.model,
|
||||
supportedModelIds: this.selectableModelIds,
|
||||
supportedModelFamilyAliases: this.selectableModelFamilyAliases,
|
||||
configuredModelId: this.config.model ?? null,
|
||||
currentModelId: this.lastOptionsModel,
|
||||
});
|
||||
this.logger.debug({ model: message.model, normalizedModel }, "Captured model from SDK init");
|
||||
this.lastOptionsModel = normalizedModel;
|
||||
const normalizedRuntimeModel = normalizeClaudeModelIdFromText(message.model);
|
||||
this.logger.debug(
|
||||
{ runtimeModel: message.model, normalizedRuntimeModel },
|
||||
"Captured runtime model from SDK init",
|
||||
);
|
||||
if (normalizedRuntimeModel) {
|
||||
this.lastOptionsModel = normalizedRuntimeModel;
|
||||
} else if (!this.lastOptionsModel) {
|
||||
this.lastOptionsModel = this.config.model ?? null;
|
||||
}
|
||||
this.lastRuntimeModel = message.model;
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
return threadStartedSessionId;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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: "opus",
|
||||
modelId: "claude-opus-4-6",
|
||||
name: "Opus 4.6",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
isLatestInFamily: true,
|
||||
},
|
||||
{
|
||||
family: "sonnet",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
name: "Sonnet 4.6",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
isLatestInFamily: true,
|
||||
},
|
||||
{
|
||||
family: "sonnet",
|
||||
modelId: "claude-sonnet-4-5-20250929",
|
||||
name: "Sonnet 4.5",
|
||||
description: "Sonnet 4.5 · Best for everyday tasks",
|
||||
isDefault: 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;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
parseClaudeSdkModelDescriptorForTest,
|
||||
resolveClaudeModelsFromSdkModels,
|
||||
} from "./sdk-model-resolver.js";
|
||||
|
||||
describe("resolveClaudeModelsFromSdkModels", () => {
|
||||
const sdkModels: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
];
|
||||
|
||||
it("parses family and version from SDK descriptions", () => {
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[0]!)).toEqual({
|
||||
family: "sonnet",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[1]!)).toEqual({
|
||||
family: "opus",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[2]!)).toEqual({
|
||||
family: "sonnet",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[3]!)).toEqual({
|
||||
family: "haiku",
|
||||
version: "4.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps SDK models to parsed Claude model ids", () => {
|
||||
const models = resolveClaudeModelsFromSdkModels(sdkModels);
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Sonnet 4.6",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "max", label: "Max" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type { AgentModelDefinition, AgentSelectOption } from "../../agent-sdk-types.js";
|
||||
|
||||
type ParsedClaudeSdkModelDescriptor = {
|
||||
family: "opus" | "sonnet" | "haiku";
|
||||
version: string;
|
||||
};
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeClaudeVersionId(version: string): string {
|
||||
return version.replace(/\./g, "-");
|
||||
}
|
||||
|
||||
function buildClaudeModelId(parsed: ParsedClaudeSdkModelDescriptor): string {
|
||||
return `claude-${parsed.family}-${normalizeClaudeVersionId(parsed.version)}`;
|
||||
}
|
||||
|
||||
function parseClaudeSdkDescriptor(model: ModelInfo): ParsedClaudeSdkModelDescriptor | null {
|
||||
const description = normalizeWhitespace(model.description ?? "");
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = description.match(/\b(opus|sonnet|haiku)\s+(\d+(?:\.\d+)*)\b/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = match[1].toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||
const version = match[2]!;
|
||||
return { family, version };
|
||||
}
|
||||
|
||||
export function normalizeClaudeModelIdFromText(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeWhitespace(value ?? "");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeMatch = normalized.match(/\b(opus|sonnet|haiku)[-_ ]+(\d+(?:[-.]\d+)*)\b/i);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = runtimeMatch[1]!.toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||
const version = runtimeMatch[2]!.replace(/-/g, ".");
|
||||
return buildClaudeModelId({ family, version });
|
||||
}
|
||||
|
||||
function buildModelLabel(model: ModelInfo): string {
|
||||
const parsed = parseClaudeSdkDescriptor(model);
|
||||
if (!parsed) {
|
||||
return normalizeWhitespace(model.displayName || model.value);
|
||||
}
|
||||
return `${titleCase(parsed.family)} ${parsed.version}`;
|
||||
}
|
||||
|
||||
function buildThinkingOptions(model: ModelInfo): {
|
||||
thinkingOptions?: AgentSelectOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
} {
|
||||
const effortLevels = model.supportedEffortLevels ?? [];
|
||||
if (!model.supportsEffort || effortLevels.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({
|
||||
id: level,
|
||||
label: titleCase(level),
|
||||
}));
|
||||
|
||||
return {
|
||||
thinkingOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeModelsFromSdkModels(models: ModelInfo[]): AgentModelDefinition[] {
|
||||
const resolved = new Map<string, AgentModelDefinition>();
|
||||
|
||||
for (const model of models) {
|
||||
const thinking = buildThinkingOptions(model);
|
||||
const parsed = parseClaudeSdkDescriptor(model);
|
||||
const id = parsed ? buildClaudeModelId(parsed) : model.value;
|
||||
const existing = resolved.get(id);
|
||||
resolved.set(id, {
|
||||
provider: "claude",
|
||||
id,
|
||||
label: buildModelLabel(model),
|
||||
description: normalizeWhitespace(model.description ?? model.displayName ?? model.value),
|
||||
isDefault:
|
||||
existing?.isDefault === true || model.value.trim().toLowerCase() === "default" || undefined,
|
||||
...(thinking.thinkingOptions || existing?.thinkingOptions
|
||||
? { thinkingOptions: thinking.thinkingOptions ?? existing?.thinkingOptions }
|
||||
: {}),
|
||||
...(thinking.defaultThinkingOptionId || existing?.defaultThinkingOptionId
|
||||
? {
|
||||
defaultThinkingOptionId:
|
||||
thinking.defaultThinkingOptionId ?? existing?.defaultThinkingOptionId,
|
||||
}
|
||||
: {}),
|
||||
metadata: {
|
||||
sdkValues: Array.from(
|
||||
new Set([...(Array.isArray(existing?.metadata?.sdkValues) ? existing.metadata.sdkValues : []), model.value]),
|
||||
),
|
||||
sdkDisplayNames: Array.from(
|
||||
new Set([
|
||||
...(Array.isArray(existing?.metadata?.sdkDisplayNames) ? existing.metadata.sdkDisplayNames : []),
|
||||
model.displayName,
|
||||
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||
),
|
||||
sdkDescriptions: Array.from(
|
||||
new Set([
|
||||
...(Array.isArray(existing?.metadata?.sdkDescriptions) ? existing.metadata.sdkDescriptions : []),
|
||||
model.description,
|
||||
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||
),
|
||||
supportsEffort: model.supportsEffort === true,
|
||||
supportedEffortLevels: model.supportedEffortLevels,
|
||||
supportsAdaptiveThinking: model.supportsAdaptiveThinking === true,
|
||||
supportsFastMode: model.supportsFastMode === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(resolved.values());
|
||||
}
|
||||
|
||||
export function parseClaudeSdkModelDescriptorForTest(
|
||||
model: ModelInfo,
|
||||
): ParsedClaudeSdkModelDescriptor | null {
|
||||
return parseClaudeSdkDescriptor(model);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import type { AgentSnapshotPayload } from "./messages.js";
|
||||
*/
|
||||
describe("client activity tracking", () => {
|
||||
const TEST_PROVIDER = "claude";
|
||||
const TEST_MODEL = "claude-haiku-4-5";
|
||||
const TEST_MODEL = "haiku";
|
||||
const TEST_CWD = "/tmp";
|
||||
let daemon: TestPaseoDaemon;
|
||||
let client1: DaemonClient;
|
||||
|
||||
Reference in New Issue
Block a user