diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index f4363b62b..9cde504e9 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1,5 +1,4 @@ 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"; @@ -242,119 +241,23 @@ 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 () => { + test("returns hardcoded claude models", async () => { const client = new ClaudeAgentClient({ logger }); const models = await client.listModels(); - expect(Array.isArray(models)).toBe(true); - expect(models.length).toBeGreaterThan(0); + expect(models.map((m) => m.id)).toEqual([ + "claude-opus-4-6[1m]", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5", + ]); for (const model of models) { expect(model.provider).toBe("claude"); - expect(typeof model.id).toBe("string"); - expect(model.id.length).toBeGreaterThan(0); - expect(typeof model.label).toBe("string"); expect(model.label.length).toBeGreaterThan(0); } - const modelIds = models.map((model) => model.id); - expect( - modelIds.some( - (id) => - id.includes("claude") || - id.includes("sonnet") || - id.includes("opus") || - id.includes("haiku"), - ), - ).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" }, - ], - }), - 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); + const defaultModel = models.find((m) => m.isDefault); + expect(defaultModel?.id).toBe("claude-opus-4-6"); }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index b654ad15f..ae72ba30a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -9,7 +9,7 @@ import { type AgentDefinition, type CanUseTool, type McpServerConfig as ClaudeSdkMcpServerConfig, - type ModelInfo, + type Options, type PermissionMode, type PermissionResult, @@ -34,9 +34,9 @@ import { mapTaskNotificationUserContentToToolCall, } from "./claude/task-notification-tool-call.js"; import { - normalizeClaudeModelIdFromText, - resolveClaudeModelsFromSdkModels, -} from "./claude/sdk-model-resolver.js"; + getClaudeModels, + normalizeClaudeRuntimeModelId, +} from "./claude/claude-models.js"; import { parsePartialJsonObject } from "./claude/partial-json.js"; import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js"; @@ -239,10 +239,6 @@ function applyRuntimeSettingsToClaudeOptions( }; } -function createEmptyClaudePrompt(): AsyncGenerator { - return (async function* empty() {})(); -} - function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort { return value === "low" || value === "medium" || value === "high" || value === "max"; } @@ -1050,33 +1046,8 @@ export class ClaudeAgentClient implements AgentClient { }); } - async listModels(options?: ListModelsOptions): Promise { - 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 listModels(_options?: ListModelsOptions): Promise { + return getClaudeModels(); } @@ -2698,7 +2669,7 @@ class ClaudeAgentSession implements AgentSession { this.currentMode = message.permissionMode; this.persistence = null; if (message.model) { - const normalizedRuntimeModel = normalizeClaudeModelIdFromText(message.model); + const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model); this.logger.debug( { runtimeModel: message.model, normalizedRuntimeModel }, "Captured runtime model from SDK init", diff --git a/packages/server/src/server/agent/providers/claude/claude-models.test.ts b/packages/server/src/server/agent/providers/claude/claude-models.test.ts new file mode 100644 index 000000000..fb72f2337 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/claude-models.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js"; + +describe("getClaudeModels", () => { + it("returns all claude models", () => { + const models = getClaudeModels(); + expect(models.map((m) => m.id)).toEqual([ + "claude-opus-4-6[1m]", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5", + ]); + }); + + it("marks exactly one model as default", () => { + const models = getClaudeModels(); + const defaults = models.filter((m) => m.isDefault); + expect(defaults).toHaveLength(1); + expect(defaults[0]!.id).toBe("claude-opus-4-6"); + }); + + it("returns fresh copies each call", () => { + const a = getClaudeModels(); + const b = getClaudeModels(); + expect(a).not.toBe(b); + expect(a[0]).not.toBe(b[0]); + }); +}); + +describe("normalizeClaudeRuntimeModelId", () => { + it("returns exact match for known model IDs", () => { + expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6"); + expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]"); + expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6"); + expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5"); + }); + + it("normalizes dated model IDs to base model", () => { + expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6"); + expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6"); + expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5"); + }); + + it("preserves [1m] suffix from runtime model strings", () => { + expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]"); + }); + + it("returns null for empty/null/undefined", () => { + expect(normalizeClaudeRuntimeModelId(null)).toBeNull(); + expect(normalizeClaudeRuntimeModelId(undefined)).toBeNull(); + expect(normalizeClaudeRuntimeModelId("")).toBeNull(); + expect(normalizeClaudeRuntimeModelId(" ")).toBeNull(); + }); + + it("returns null for unrecognized strings", () => { + expect(normalizeClaudeRuntimeModelId("gpt-5")).toBeNull(); + expect(normalizeClaudeRuntimeModelId("random")).toBeNull(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/claude-models.ts b/packages/server/src/server/agent/providers/claude/claude-models.ts new file mode 100644 index 000000000..08c14071e --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/claude-models.ts @@ -0,0 +1,74 @@ +import type { AgentModelDefinition } from "../../agent-sdk-types.js"; + +const CLAUDE_THINKING_OPTIONS = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, +] as const; + +const CLAUDE_MODELS: AgentModelDefinition[] = [ + { + provider: "claude", + id: "claude-opus-4-6[1m]", + label: "Opus 4.6 1M", + description: "Opus 4.6 with 1M context window", + thinkingOptions: [...CLAUDE_THINKING_OPTIONS], + }, + { + provider: "claude", + id: "claude-opus-4-6", + label: "Opus 4.6", + description: "Opus 4.6 · Most capable for complex work", + isDefault: true, + thinkingOptions: [...CLAUDE_THINKING_OPTIONS], + }, + { + provider: "claude", + id: "claude-sonnet-4-6", + label: "Sonnet 4.6", + description: "Sonnet 4.6 · Best for everyday tasks", + thinkingOptions: [...CLAUDE_THINKING_OPTIONS], + }, + { + provider: "claude", + id: "claude-haiku-4-5", + label: "Haiku 4.5", + description: "Haiku 4.5 · Fastest for quick answers", + }, +]; + +export function getClaudeModels(): AgentModelDefinition[] { + return CLAUDE_MODELS.map((model) => ({ ...model })); +} + +/** + * Normalize a runtime model string (from SDK init message) to a known model ID. + * Handles the `[1m]` suffix that the SDK appends for 1M context sessions. + */ +export function normalizeClaudeRuntimeModelId( + value: string | null | undefined, +): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + return null; + } + + // Check for exact match first (handles claude-opus-4-6[1m] directly) + if (CLAUDE_MODELS.some((model) => model.id === trimmed)) { + return trimmed; + } + + // Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix + const runtimeMatch = trimmed.match( + /(?:claude-)?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, + ); + if (!runtimeMatch) { + return null; + } + + const family = runtimeMatch[1]!.toLowerCase(); + const major = runtimeMatch[2]!; + const minor = runtimeMatch[3]!; + const suffix = runtimeMatch[4] ?? ""; + return `claude-${family}-${major}-${minor}${suffix}`; +} diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts deleted file mode 100644 index 9224d1ac2..000000000 --- a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -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" }, - ], - }), - 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", - }), - ]); - }); -}); diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts deleted file mode 100644 index b2cf28eb9..000000000 --- a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts +++ /dev/null @@ -1,145 +0,0 @@ -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; -}; - -// Claude may advertise effort levels that are not usable for all account types. -const DISABLED_CLAUDE_THINKING_EFFORT_LEVELS: readonly string[] = ["max"]; - -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 ?? []).filter( - (level) => !DISABLED_CLAUDE_THINKING_EFFORT_LEVELS.includes(level), - ); - 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(); - - 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); -}