From f1063cdfbbdee85d83b2fdaba43063fe6abc9da9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 30 Jun 2026 16:41:42 +0200 Subject: [PATCH] feat(catalog): added capability flags and thinking support for Claude Sonnet 5 - Added an `isAnthropicAdaptiveGenAtLeast` utility to classify adaptive-thinking Claude generations at or above a given version threshold. - Enabled adaptive thinking display, sampling restrictions, and mid-conversation system message flags for Claude Sonnet 5+ models. - Updated Bedrock and OpenRouter adaptive reasoning effort maps to support five-tier scales on Sonnet 5+ models. --- packages/catalog/CHANGELOG.md | 11 ++++ packages/catalog/src/identity/classify.ts | 14 ++++ packages/catalog/src/identity/family.ts | 26 ++++---- packages/catalog/src/model-thinking.ts | 25 +++----- packages/catalog/test/identity-family.test.ts | 30 ++++++++- packages/catalog/test/model-thinking.test.ts | 31 ++++++++- .../agent-session-thinking-loop-retry.test.ts | 64 +++++++++++++++++++ 7 files changed, 169 insertions(+), 32 deletions(-) diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 093e78428..34da27d2f 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Added + +- Added adaptive thinking display, API restrictions, and mid-conversation system message support for Claude Sonnet 5 +- Extended 5-tier adaptive reasoning effort map support on OpenRouter and direct APIs to Claude Sonnet 5 and higher + +- Added compatibility flags and routines for Claude Sonnet 5, aligning its capabilities with Claude Opus 4.8 and Fable 5. Specifically: +- Enabled adaptive thinking display support (`supportsAdaptiveThinkingDisplay`). +- Applied sampling parameter and default thinking omission API restrictions (`hasOpus47ApiRestrictions`). +- Added mid-conversation system message support (`supportsMidConversationSystemMessages`). +- Extended the 5-tier adaptive reasoning effort map (including real `xhigh` and `max` levels) and Bedrock Converse/OpenRouter adaptive reasoning support. + ## [16.2.7] - 2026-06-30 ### Fixed diff --git a/packages/catalog/src/identity/classify.ts b/packages/catalog/src/identity/classify.ts index 624b071c7..7028b56e5 100644 --- a/packages/catalog/src/identity/classify.ts +++ b/packages/catalog/src/identity/classify.ts @@ -158,6 +158,20 @@ export function isFableOrMythos(kind: AnthropicKind): boolean { return kind === "fable" || kind === "mythos"; } +/** + * Returns true if the parsed Anthropic model is part of the adaptive-thinking + * Claude generation at or above a specific capability threshold. + * - Opus has a configurable minimum version floor (e.g. "4.6", "4.7", "4.8"). + * - Sonnet, Fable, and Mythos all require version 5 or higher. + */ +export function isAnthropicAdaptiveGenAtLeast(parsed: AnthropicModel, opusMin: "4.6" | "4.7" | "4.8"): boolean { + if (parsed.kind === "opus") { + return semverGte(parsed.version, opusMin); + } + // Sonnet 5+, Fable 5+, Mythos 5+, and any future gen-5+ models + return semverGte(parsed.version, "5"); +} + function createSemVer(major: number, minor: number, patch = 0): SemVer { return { major, minor, patch }; } diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index 031a77c31..1f80ead61 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -9,6 +9,7 @@ import { bareModelId, + isAnthropicAdaptiveGenAtLeast, isFableOrMythos, parseAnthropicModel, parseGlmModel, @@ -201,41 +202,38 @@ export const modelFamilyToken = memo((modelId: string): string => { }); /** - * Adaptive thinking `display` is supported starting with Claude Opus 4.7 and - * the Claude Fable/Mythos 5 generation. Older adaptive-thinking models - * (Opus 4.6, Sonnet 4.6+) reject the field. Classifier-based, so dotted and - * dashed version forms both match while bare dated ids + * Adaptive thinking `display` is supported starting with Claude Opus 4.7+, + * Sonnet 5+, and the Claude Fable/Mythos 5 generation. Older adaptive-thinking + * models (Opus 4.6, Sonnet 4.6) reject the field. Classifier-based, so dotted + * and dashed version forms both match while bare dated ids * (`claude-opus-4-20250514` = Opus 4.0) stay excluded. */ export const supportsAdaptiveThinkingDisplay = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); - if (!parsed) return false; - if (isFableOrMythos(parsed.kind)) return semverGte(parsed.version, "5"); - return parsed.kind === "opus" && semverGte(parsed.version, "4.7"); + return parsed !== null && isAnthropicAdaptiveGenAtLeast(parsed, "4.7"); }); /** - * Returns true for Anthropic models with Opus 4.7+/Fable/Mythos API restrictions: + * Returns true for Anthropic models with Opus 4.7+, Sonnet 5+, and Fable/Mythos 5+ + * API restrictions: * - Sampling parameters (temperature/top_p/top_k) return 400 error * - Thinking content is omitted by default (needs display: "summarized") */ export const hasOpus47ApiRestrictions = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); - if (!parsed) return false; - return (parsed.kind === "opus" && semverGte(parsed.version, "4.7")) || isFableOrMythos(parsed.kind); + return parsed !== null && isAnthropicAdaptiveGenAtLeast(parsed, "4.7"); }); /** * Mid-conversation `role: "system"` messages (system instructions appended at * non-first positions in the `messages` array) are supported starting with - * Claude Opus 4.8 and the Claude Fable/Mythos 5 generation. Earlier Claude - * models reject the role. + * Claude Opus 4.8+, Sonnet 5+, and the Claude Fable/Mythos 5 generation. + * Earlier Claude models reject the role. * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages */ export const supportsMidConversationSystemMessages = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); - if (!parsed) return false; - return (parsed.kind === "opus" && semverGte(parsed.version, "4.8")) || isFableOrMythos(parsed.kind); + return parsed !== null && isAnthropicAdaptiveGenAtLeast(parsed, "4.8"); }); export const isAnthropicFableOrMythosModel = memo((modelId: string): boolean => { diff --git a/packages/catalog/src/model-thinking.ts b/packages/catalog/src/model-thinking.ts index 03daa8b45..b0e01af59 100644 --- a/packages/catalog/src/model-thinking.ts +++ b/packages/catalog/src/model-thinking.ts @@ -12,7 +12,7 @@ import { type AnthropicModel, bareModelId, type GeminiModel, - isFableOrMythos, + isAnthropicAdaptiveGenAtLeast, type OpenAIModel, type ParsedModel, parseAnthropicModel, @@ -441,12 +441,11 @@ function isDeepseekReasoningModel(spec: ModelSpec): bool function getOpenRouterAnthropicReasoningEffortMap(modelId: string): EffortMap | undefined { const parsed = parseAnthropicModel(bareModelId(modelId)); if (!parsed) return undefined; - // Adaptive efforts on OpenRouter's completions front: Fable/Mythos and - // Opus 4.6+ only — Sonnet stays on the plain effort vocabulary there. - const isOpusAdaptive = parsed.kind === "opus" && semverGte(parsed.version, "4.6"); - if (!isFableOrMythos(parsed.kind) && !isOpusAdaptive) return undefined; + // Adaptive efforts on OpenRouter's completions front: Fable/Mythos, Sonnet 5+, + // and Opus 4.6+ only — older Sonnet versions stay on the plain effort vocabulary there. + if (!isAnthropicAdaptiveGenAtLeast(parsed, "4.6")) return undefined; - const hasRealXHigh = isFableOrMythos(parsed.kind) || semverGte(parsed.version, "4.7"); + const hasRealXHigh = isAnthropicAdaptiveGenAtLeast(parsed, "4.7"); return hasRealXHigh ? ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER : ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER; } @@ -521,7 +520,7 @@ function inferAnthropicSupportedEfforts( (spec.api === "anthropic-messages" || spec.api === "bedrock-converse-stream") && semverGte(parsedModel.version, "4.6") ) { - return parsedModel.kind === "opus" || isFableOrMythos(parsedModel.kind) + return isAnthropicAdaptiveGenAtLeast(parsedModel, "4.6") ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH : DEFAULT_REASONING_EFFORTS; } @@ -601,10 +600,7 @@ function inferThinkingControlMode( case "bedrock-converse-stream": if (parsedModel.family === "anthropic") { - if ( - semverGte(parsedModel.version, "4.6") && - (parsedModel.kind === "opus" || isFableOrMythos(parsedModel.kind)) - ) { + if (isAnthropicAdaptiveGenAtLeast(parsedModel, "4.6")) { return "anthropic-adaptive"; } // Opus 4.5 on Bedrock metadata mirrors the direct-Anthropic @@ -627,19 +623,18 @@ function isOpenRouterAnthropicAdaptiveReasoningModel( ): boolean { if (!isOpenAICompatReasoningApi(spec.api)) return false; if (!modelMatchesHost(spec, "openrouter")) return false; - return isFableOrMythos(parsedModel.kind) || (parsedModel.kind === "opus" && semverGte(parsedModel.version, "4.6")); + return isAnthropicAdaptiveGenAtLeast(parsedModel, "4.6"); } /** - * Opus 4.7+ and Fable/Mythos on the Messages API expose the full five-tier + * Opus 4.7+, Sonnet 5+, and Fable/Mythos 5+ on the Messages API expose the full five-tier * adaptive scale (low/medium/high/xhigh/max). Bedrock Converse stays on the * four-tier scale regardless of model version. */ function anthropicModelHasRealXHighEffort(spec: ModelSpec, parsedModel: ParsedModel): boolean { if (spec.api !== "anthropic-messages") return false; if (parsedModel.family !== "anthropic") return false; - if (isFableOrMythos(parsedModel.kind)) return true; - return parsedModel.kind === "opus" && semverGte(parsedModel.version, "4.7"); + return isAnthropicAdaptiveGenAtLeast(parsedModel, "4.7"); } // --------------------------------------------------------------------------- diff --git a/packages/catalog/test/identity-family.test.ts b/packages/catalog/test/identity-family.test.ts index b6b204b41..7d8fd4095 100644 --- a/packages/catalog/test/identity-family.test.ts +++ b/packages/catalog/test/identity-family.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + hasOpus47ApiRestrictions, isClaudeModelId, isGlmVisionModelId, isGrokReasoningEffortCapable, @@ -11,6 +12,7 @@ import { isReasoningGlmModelId, modelFamilyToken, supportsAdaptiveThinkingDisplay, + supportsMidConversationSystemMessages, } from "@oh-my-pi/pi-catalog/identity"; describe("isKimiModelId", () => { @@ -44,10 +46,12 @@ describe("isClaudeModelId", () => { }); describe("supportsAdaptiveThinkingDisplay", () => { - test("allows Claude Fable 5 and Opus 4.7 or newer only", () => { + test("allows Claude Fable 5, Opus 4.7 or newer, and Sonnet 5 or newer only", () => { expect(supportsAdaptiveThinkingDisplay("claude-fable-5")).toBe(true); expect(supportsAdaptiveThinkingDisplay("claude-opus-4-7")).toBe(true); expect(supportsAdaptiveThinkingDisplay("claude-opus-5-0")).toBe(true); + expect(supportsAdaptiveThinkingDisplay("claude-sonnet-5")).toBe(true); + expect(supportsAdaptiveThinkingDisplay("us.anthropic.claude-sonnet-5")).toBe(true); // Dotted and dashed version separators are equivalent. expect(supportsAdaptiveThinkingDisplay("claude-opus-4.7")).toBe(true); expect(supportsAdaptiveThinkingDisplay("anthropic/claude-opus-4.8")).toBe(true); @@ -58,6 +62,30 @@ describe("supportsAdaptiveThinkingDisplay", () => { }); }); +describe("hasOpus47ApiRestrictions", () => { + test("allows Claude Fable 5, Opus 4.7 or newer, and Sonnet 5 or newer only", () => { + expect(hasOpus47ApiRestrictions("claude-fable-5")).toBe(true); + expect(hasOpus47ApiRestrictions("claude-opus-4-7")).toBe(true); + expect(hasOpus47ApiRestrictions("claude-opus-4.8")).toBe(true); + expect(hasOpus47ApiRestrictions("claude-sonnet-5")).toBe(true); + expect(hasOpus47ApiRestrictions("us.anthropic.claude-sonnet-5")).toBe(true); + expect(hasOpus47ApiRestrictions("claude-opus-4-6")).toBe(false); + expect(hasOpus47ApiRestrictions("claude-sonnet-4-6")).toBe(false); + expect(hasOpus47ApiRestrictions("claude-sonnet-4-5")).toBe(false); + }); +}); + +describe("supportsMidConversationSystemMessages", () => { + test("allows Claude Fable 5, Opus 4.8 or newer, and Sonnet 5 or newer only", () => { + expect(supportsMidConversationSystemMessages("claude-fable-5")).toBe(true); + expect(supportsMidConversationSystemMessages("claude-opus-4-8")).toBe(true); + expect(supportsMidConversationSystemMessages("claude-sonnet-5")).toBe(true); + expect(supportsMidConversationSystemMessages("us.anthropic.claude-sonnet-5")).toBe(true); + expect(supportsMidConversationSystemMessages("claude-opus-4-7")).toBe(false); + expect(supportsMidConversationSystemMessages("claude-sonnet-4-6")).toBe(false); + }); +}); + describe("isMinimaxM2FamilyModelId", () => { test("matches every M2-generation id shape served by aggregator/native hosts", () => { // Fireworks/OpenCode/openrouter direct ids and `-highspeed`/`-lightning` variants. diff --git a/packages/catalog/test/model-thinking.test.ts b/packages/catalog/test/model-thinking.test.ts index ee20b0369..6d6019fe1 100644 --- a/packages/catalog/test/model-thinking.test.ts +++ b/packages/catalog/test/model-thinking.test.ts @@ -367,6 +367,12 @@ describe("model thinking derivation", () => { provider: "amazon-bedrock", }); const sonnet46 = createModel({ id: "claude-sonnet-4.6", api: "anthropic-messages", provider: "anthropic" }); + const sonnet5 = createModel({ id: "claude-sonnet-5", api: "anthropic-messages", provider: "anthropic" }); + const sonnet5Bedrock = createModel({ + id: "global.anthropic.claude-sonnet-5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + }); const mythos = createModel({ id: "claude-mythos-5", api: "anthropic-messages", provider: "anthropic" }); const mythosBedrock = createModel({ id: "global.anthropic.claude-mythos-5", @@ -399,6 +405,8 @@ describe("model thinking derivation", () => { expect(sonnet45Bedrock.thinking?.mode).toBe("budget"); expect(opus46.thinking?.mode).toBe("anthropic-adaptive"); expect(sonnet46.thinking?.mode).toBe("anthropic-adaptive"); + expect(sonnet5.thinking?.mode).toBe("anthropic-adaptive"); + expect(sonnet5Bedrock.thinking?.mode).toBe("anthropic-adaptive"); expect(mythosBedrock.thinking?.mode).toBe("anthropic-adaptive"); expect(minimaxM2.thinking).toEqual({ mode: "anthropic-adaptive", @@ -437,13 +445,16 @@ describe("model thinking derivation", () => { expect(mapEffortToAnthropicAdaptiveEffort(opus47, Effort.XHigh)).toBe("max"); expect(mapEffortToAnthropicAdaptiveEffort(mythos, Effort.High)).toBe("xhigh"); expect(mapEffortToAnthropicAdaptiveEffort(mythosBedrock, Effort.XHigh)).toBe("max"); + expect(mapEffortToAnthropicAdaptiveEffort(sonnet5, Effort.High)).toBe("xhigh"); + expect(mapEffortToAnthropicAdaptiveEffort(sonnet5Bedrock, Effort.XHigh)).toBe("max"); // Bedrock Converse keeps the four-tier legacy mapping; xhigh aliases to "max". expect(opus47Bedrock.thinking?.effortMap).toEqual({ minimal: "low", xhigh: "max" }); expect(mapEffortToAnthropicAdaptiveEffort(opus47Bedrock, Effort.High)).toBe("high"); + expect(mapEffortToAnthropicAdaptiveEffort(sonnet5Bedrock, Effort.High)).toBe("high"); expect(() => mapEffortToAnthropicAdaptiveEffort(sonnet46, Effort.XHigh)).toThrow(/not supported/); }); - it("bakes adaptive display support for Opus 4.7+ and Fable/Mythos 5", () => { + it("bakes adaptive display support for Opus 4.7+, Sonnet 5+, and Fable/Mythos 5", () => { const opus46 = createModel({ id: "claude-opus-4.6", api: "anthropic-messages", provider: "anthropic" }); const opus47 = createModel({ id: "claude-opus-4-7", api: "anthropic-messages", provider: "anthropic" }); // Dotted and dashed version forms are equivalent; bare dated ids stay Opus 4.0. @@ -459,6 +470,12 @@ describe("model thinking derivation", () => { api: "bedrock-converse-stream", provider: "amazon-bedrock", }); + const sonnet5 = createModel({ id: "claude-sonnet-5", api: "anthropic-messages", provider: "anthropic" }); + const sonnet5Bedrock = createModel({ + id: "global.anthropic.claude-sonnet-5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + }); expect(opus46.thinking?.supportsDisplay).toBeUndefined(); expect(opus47.thinking?.supportsDisplay).toBe(true); @@ -466,6 +483,8 @@ describe("model thinking derivation", () => { expect(opus4Dated.thinking?.supportsDisplay).toBeUndefined(); expect(fable.thinking?.supportsDisplay).toBe(true); expect(fableBedrock.thinking?.supportsDisplay).toBe(true); + expect(sonnet5.thinking?.supportsDisplay).toBe(true); + expect(sonnet5Bedrock.thinking?.supportsDisplay).toBe(true); }); it("backfills wire facts onto explicit thinking, explicit values winning", () => { @@ -526,10 +545,12 @@ describe("model thinking derivation", () => { it("bakes sampling-param rejection into anthropic compat", () => { const sonnet45 = createModel({ id: "claude-sonnet-4-5", api: "anthropic-messages", provider: "anthropic" }); const opus47 = createModel({ id: "claude-opus-4.7", api: "anthropic-messages", provider: "anthropic" }); + const sonnet5 = createModel({ id: "claude-sonnet-5", api: "anthropic-messages", provider: "anthropic" }); const fable = createModel({ id: "claude-fable-5", api: "anthropic-messages", provider: "anthropic" }); expect(sonnet45.compat.supportsSamplingParams).toBe(true); expect(opus47.compat.supportsSamplingParams).toBe(false); + expect(sonnet5.compat.supportsSamplingParams).toBe(false); expect(fable.compat.supportsSamplingParams).toBe(false); }); @@ -685,11 +706,17 @@ describe("model thinking runtime helpers", () => { api: "openai-completions", provider: "openrouter", }); - + const sonnet5 = createModel({ + id: "anthropic/claude-sonnet-5", + api: "openai-completions", + provider: "openrouter", + }); expect(fable.thinking?.efforts.at(-1)).toBe(Effort.XHigh); expect(opus46.thinking?.efforts.at(-1)).toBe(Effort.XHigh); expect(sonnet46.thinking?.efforts.at(-1)).toBe(Effort.High); + expect(sonnet5.thinking?.efforts.at(-1)).toBe(Effort.XHigh); expect(requireSupportedEffort(fable, Effort.XHigh)).toBe(Effort.XHigh); + expect(requireSupportedEffort(sonnet5, Effort.XHigh)).toBe(Effort.XHigh); }); it("enables xhigh for openai-responses and openai-codex-responses APIs", () => { diff --git a/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts b/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts index 06fe36b3a..9e6fe705e 100644 --- a/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts +++ b/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts @@ -310,4 +310,68 @@ describe("AgentSession thinking-loop retry", () => { expect(assistants).toHaveLength(1); expect(assistants[0].content).toEqual([{ type: "text", text: "Recovered after retry." }]); }); + + it("injects a redirect notice on each consecutive thinking-loop retry", async () => { + const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model; + const modelRegistry = new ModelRegistry(authStorage); + const calls: string[] = []; + const contexts: Context[] = []; + const agent = new Agent({ + getApiKey: requestedModel => `${requestedModel.provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + convertToLlm, + streamFn: (requestedModel, context, _options?: SimpleStreamOptions) => { + calls.push(`${requestedModel.provider}/${requestedModel.id}`); + contexts.push(context); + return calls.length <= 2 ? errorIdOnlyThinkingLoopStream(requestedModel) : successStream(requestedModel); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.enabled": true, + "retry.baseDelayMs": 0, + "retry.maxDelayMs": 5_000, + "retry.maxRetries": 2, + "retry.modelFallback": false, + "todo.enabled": false, + "model.loopGuard.enabled": true, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); + + await session.prompt("Trigger redirect injection after two thinking loops"); + await session.waitForIdle(); + + expect(calls).toHaveLength(3); + const redirects = session.agent.state.messages.filter( + (message): message is CustomMessage => + message.role === "custom" && message.customType === "thinking-loop-redirect", + ); + expect(redirects).toHaveLength(2); + const extractText = (content: string | Array<{ type: string; text?: string }>): string => + typeof content === "string" + ? content + : content.map(part => (part.type === "text" ? (part.text ?? "") : "")).join(""); + const thirdAttemptRedirectDevMsgs = contexts[2].messages.filter( + message => message.role === "developer" && extractText(message.content).includes("thinking_loop_detected"), + ); + expect(thirdAttemptRedirectDevMsgs).toHaveLength(2); + + const assistants = session.agent.state.messages.filter( + (message): message is AssistantMessage => message.role === "assistant", + ); + expect(assistants).toHaveLength(1); + expect(assistants[0].content).toEqual([{ type: "text", text: "Recovered after retry." }]); + }); });