diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index a1d302e9c4..57ace59248 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -52,10 +52,15 @@ interface ProviderDiagnostic { } const EXPECTED_CLAUDE_MODELS = [ + { + id: "claude-opus-5-5", + model: "Opus 5.5", + descriptionFragment: "Latest release", + }, { id: "claude-opus-5", model: "Opus 5", - descriptionFragment: "Latest release", + descriptionFragment: "Previous release", }, { id: "claude-fable-5-1", 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 25d4729dc6..118e93067c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -476,7 +476,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { force: false, }); - expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5"); + expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); expect(models.map((model) => model.id)).toContain("claude-fable-5"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index e7e660a229..c18196c5da 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -11,6 +11,7 @@ interface ClaudeModelManifestEntry { minimumClaudeCodeVersion?: string; contextWindowMaxTokens?: number; effortLevels?: readonly ClaudeEffortLevel[]; + defaultThinkingOptionId?: ClaudeEffortLevel; supportsThinkingDisabled?: boolean; supportsFastMode?: boolean; } @@ -34,10 +35,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off"; export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode"; export const CLAUDE_MODEL_MANIFEST = [ + { + id: "claude-opus-5-5", + label: "Opus 5.5", + description: "Opus 5.5 · Latest release", + defaultPriority: 3, + minimumClaudeCodeVersion: "2.1.280", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsFastMode: true, + }, { id: "claude-opus-5", label: "Opus 5", - description: "Opus 5 · Latest release", + description: "Opus 5 · Previous release", defaultPriority: 2, minimumClaudeCodeVersion: "2.1.219", contextWindowMaxTokens: 1_000_000, @@ -157,9 +169,14 @@ export const CLAUDE_MODEL_MANIFEST = [ }, ] as const satisfies readonly ClaudeModelManifestEntry[]; +function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel { + return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID; +} + function buildThinkingOptions( effortLevels: readonly ClaudeEffortLevel[] | undefined, supportsThinkingDisabled: boolean, + defaultThinkingOptionId: ClaudeEffortLevel, ): AgentSelectOption[] | undefined { if (!effortLevels) { return undefined; @@ -170,7 +187,7 @@ function buildThinkingOptions( ...effortLevels.map((id) => ({ id, label: CLAUDE_EFFORT_LABELS[id], - ...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}), + ...(id === defaultThinkingOptionId ? { isDefault: true } : {}), })), ]; @@ -196,6 +213,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD const thinkingOptions = buildThinkingOptions( model.effortLevels, model.supportsThinkingDisabled === true, + getDefaultThinkingOptionId(model), ); const definition: AgentModelDefinition = { provider: "claude", @@ -214,7 +232,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD } if (thinkingOptions) { definition.thinkingOptions = thinkingOptions; - definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID; + definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model); } definitions.push(definition); if (!("aliases" in model) || !model.aliases) { @@ -286,7 +304,7 @@ export function resolveClaudeDisabledThinkingForModel( supported: !!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true, fallbackThinkingOptionId: - model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined, + model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined, }; } @@ -350,7 +368,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) /** * Normalize a Claude Code runtime/config model string to a known manifest ID. * Runtime metadata may include provider prefixes such as Bedrock model IDs; feature - * gates should use normalizeClaudeManifestModelId instead. + * gates should use normalizeClaudeManifestModelId instead. The prefixed matches are + * unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the + * "claude-opus-5" entry. */ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null { const normalizedManifestModelId = normalizeClaudeManifestModelId(value); @@ -363,13 +383,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): return null; } - const singleSegmentMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, + const runtimeMatch = trimmed.match( + /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, ); - if (singleSegmentMatch) { - const normalizedModelId = normalizeSingleSegmentClaudeModelId( - singleSegmentMatch[1], - singleSegmentMatch[2], + if (runtimeMatch) { + const normalizedModelId = normalizeMajorMinorClaudeModelId( + runtimeMatch[1], + runtimeMatch[2], + runtimeMatch[3], trimmed.toLowerCase().includes("[1m]"), ); if (normalizedModelId) { @@ -377,17 +398,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } } - const runtimeMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, + const singleSegmentMatch = trimmed.match( + /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, ); - if (!runtimeMatch) { + if (!singleSegmentMatch) { return null; } - return normalizeMajorMinorClaudeModelId( - runtimeMatch[1], - runtimeMatch[2], - runtimeMatch[3], + return normalizeSingleSegmentClaudeModelId( + singleSegmentMatch[1], + singleSegmentMatch[2], trimmed.toLowerCase().includes("[1m]"), ); } diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 9e3cc088bd..a497826f73 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise claudeCodeVersion, @@ -50,6 +50,7 @@ describe("getClaudeModels", () => { it("returns all claude models", () => { const models = getClaudeModels(); expect(models.map((m) => m.id)).toEqual([ + "claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-fable-5", @@ -72,7 +73,7 @@ describe("getClaudeModels", () => { const models = getClaudeModels(); const defaults = models.filter((m) => m.isDefault); expect(defaults).toHaveLength(1); - expect(defaults[0].id).toBe("claude-opus-5"); + expect(defaults[0].id).toBe("claude-opus-5-5"); }); it("defines context window sizes in the catalog", () => { @@ -82,6 +83,7 @@ describe("getClaudeModels", () => { expect(contextWindows).toEqual( new Map([ + ["claude-opus-5-5", 1_000_000], ["claude-opus-5", 1_000_000], ["claude-fable-5-1", 1_000_000], ["claude-fable-5", 1_000_000], @@ -109,6 +111,11 @@ describe("getClaudeModels", () => { expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5"); expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5"); + + expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5"); + expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5"); + expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5"); + expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); }); it("derives thinking options from model effort capabilities", () => { @@ -169,6 +176,8 @@ describe("getClaudeModels", () => { }); it.each([ + ["claude-opus-5-5", false, "medium"], + ["claude-opus-5-5-20260401", false, "medium"], ["claude-opus-5", true, "high"], ["claude-opus-5-20260724", true, "high"], ["claude-sonnet-5", true, "high"], @@ -417,6 +426,19 @@ describe("normalizeClaudeRuntimeModelId", () => { "claude-opus-4-8", ); }); + + it("does not collapse a prefixed minor release onto the major it extends", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe( + "claude-opus-5-5", + ); + expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe( + "claude-fable-5-1", + ); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe( + "claude-opus-5", + ); + }); }); describe("parseClaudeCodeVersion", () => { @@ -438,7 +460,7 @@ describe("findClaudeModel", () => { describe("Claude Opus 5 catalog", () => { it("offers a single Opus 5 entry with a 1M context window", () => { const opus5Models = getClaudeModels() - .filter((model) => model.id.startsWith("claude-opus-5")) + .filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id)) .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); expect(opus5Models).toEqual([ @@ -515,6 +537,41 @@ describe("Claude Fable 5.1 catalog", () => { }); }); +describe("Claude Opus 5.5 catalog", () => { + it("offers one Opus 5.5 entry with a 1M context window", () => { + const opus55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-opus-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(opus55Models).toEqual([ + { id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => { + const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5"); + + expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(opus55?.defaultThinkingOptionId).toBe("medium"); + expect( + opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => { + expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5"); + expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5"); + expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5"); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); @@ -523,6 +580,7 @@ describe("claudeManifestModelSupportsFastMode", () => { }); it("supports fast mode on Opus 5 but not on other Claude 5 models", () => { + expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true); expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true); expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false); expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);