diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cd7b7871a..79de1c868 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -27,6 +27,7 @@ - Fixed Xiaomi MiMo models failing with a 400 at the `minimal` thinking level by sending only the documented thinking on/off toggle and exposing only the `off` and `high` levels ([#10818](https://github.com/earendil-works/pi/issues/10818)) - Fixed `openai-codex/gpt-6.1-sol` using a 272K context window instead of the 500K window Codex now provides ([#10840](https://github.com/earendil-works/pi/issues/10840)) - Fixed tool calls whose final arguments are malformed JSON silently running with only the fields parsed before the error; such calls now carry `argumentsError` and fail validation with the raw arguments text, so the model sees what it sent ([#10897](https://github.com/earendil-works/pi/issues/10897)) +- Fixed Claude Haiku 5.5 and Claude Opus 5 not supporting the `off` thinking level; `off` now sends `thinking: {type: "disabled"}` without effort settings ([#10753](https://github.com/earendil-works/pi/issues/10753)) ## [1.2.0] - 2026-10-12 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 1ae67d5e0..29480825e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -622,6 +622,20 @@ function supportsAnthropicMidConvoEffort(modelId: string): boolean { ); } +// Claude models that accept `thinking: {type: "disabled"}` alongside per-message effort. +// Opus 5.5, Sonnet 5.5, Fable 5.1, and Mythos 5.1 reject it. +// https://platform.claude.com/docs/en/build-with-claude/thinking#configuring-thinking +const ANTHROPIC_DISABLED_THINKING_MODELS = new Set(["claude-opus-5", "claude-haiku-5-5", "claude-haiku-5.5"]); + +function supportsAnthropicDisabledThinkingWithMidConvoEffort(modelId: string): boolean { + // Strip OpenRouter's "anthropic/" or "~anthropic/" prefix and any dated snapshot suffix. + const id = modelId + .toLowerCase() + .replace(/^~?anthropic\//, "") + .replace(/-\d{8}$/, ""); + return ANTHROPIC_DISABLED_THINKING_MODELS.has(id); +} + function supportsAnthropicMidConvoSystemMessages(modelId: string): boolean { return ( /^claude-opus-(?:4[.-]8|5(?:[.-]5)?)(?:-\d{8})?$/.test(modelId) || @@ -839,7 +853,9 @@ function applyAnthropicMessagesCompatMetadata(model: Model): void { const compat = getAnthropicMessagesCompat(model.provider, model.id); if (compat) { mergeAnthropicMessagesCompat(model, compat); - if (compat.supportsMidConvoEffort) mergeThinkingLevelMap(model, { off: null }); + if (compat.supportsMidConvoEffort && !supportsAnthropicDisabledThinkingWithMidConvoEffort(model.id)) { + mergeThinkingLevelMap(model, { off: null }); + } } } @@ -2813,7 +2829,8 @@ async function generateModels() { (candidate.provider === "github-copilot" && candidate.id === "claude-opus-5.5") ) { mergeThinkingLevelMap(candidate, { - off: null, + // Haiku 5.5 accepts disabled thinking; Opus 5.5 and Sonnet 5.5 reject it. + ...(candidate.id === "claude-haiku-5-5" ? {} : { off: null }), minimal: null, low: "low", medium: "medium", diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index dee42ee65..a10dd52d8 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -578,14 +578,12 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( const currentTools = getCurrentTools(normalizedContext.messages); (async () => { - const providerThinkingLevel = model.compat?.supportsMidConvoEffort ? (options?.effort ?? "high") : undefined; const output: AssistantMessage = { role: "assistant", content: [], api: model.api as Api, provider: model.provider, model: model.id, - ...(providerThinkingLevel === undefined ? {} : { providerThinkingLevel }), usage: { input: 0, output: 0, @@ -646,6 +644,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( for (;;) { options?.signal?.throwIfAborted(); const built = buildParams(model, normalizedContext, isOAuth, options, disablePreferredStrictTools); + if (built.providerThinkingLevel) output.providerThinkingLevel = built.providerThinkingLevel; let params = built.params; const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { @@ -1160,7 +1159,12 @@ function buildParams( isOAuthToken: boolean, options?: AnthropicOptions, disablePreferredStrictTools = false, -): { params: MessageCreateParamsStreaming; hasPreferredStrictTools: boolean } { +): { + params: MessageCreateParamsStreaming; + hasPreferredStrictTools: boolean; + /** Effort of the final effort marker, recorded on the reply so later requests can replay it. */ + providerThinkingLevel: AnthropicEffort | undefined; +} { const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env); const compat = getAnthropicCompat(model); const initialSystemMessage = getInitialSystemMessage(context.messages); @@ -1194,22 +1198,23 @@ function buildParams( const initialTools = initialSystemMessage?.toolsAdded ?? []; const nativeToolChanges = compat.supportsMidConvoSystemMessages && compat.supportsMidConvoToolChanges && initialTools.length > 0; + const thinkingDisabled = + model.reasoning && options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null; + // Effort cannot change between messages while thinking is disabled, so "off" sends no effort markers. + const effortMessages = model.compat?.supportsMidConvoEffort === true && !thinkingDisabled; const converted = convertMessages( conversationMessages, isOAuthToken, cacheControl, compat.allowEmptySignature, - model.compat?.supportsMidConvoEffort === true ? model.provider : undefined, + effortMessages ? model.provider : undefined, nativeToolChanges ? convertToolDefinitions : undefined, ); const activeEffort = options?.effort ?? "high"; const betaFeatures = getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options); const params: MessageCreateParamsStreaming = { model: model.id, - messages: - model.compat?.supportsMidConvoEffort === true - ? insertThinkingLevelMessages(converted, activeEffort) - : converted.messages, + messages: effortMessages ? insertThinkingLevelMessages(converted, activeEffort) : converted.messages, max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, ...(betaFeatures.length > 0 ? { betas: betaFeatures } : {}), @@ -1266,36 +1271,36 @@ function buildParams( } } - // Managed effort models always use adaptive thinking so prefix mismatches can - // be dropped instead of surfacing as persistent 400 responses. - if (model.compat?.supportsMidConvoEffort === true) { + if (thinkingDisabled) { + // No effort is sent, so the model default applies. Anthropic rejects `block_binding` + // with disabled thinking. + params.thinking = { type: "disabled" }; + } else if (effortMessages) { + // Managed effort models use adaptive thinking so prefix mismatches can + // be dropped instead of surfacing as persistent 400 responses. params.thinking = { type: "adaptive", display: options?.thinkingDisplay ?? "summarized", block_binding: { prefix_mismatch_behavior: "drop_block" }, }; params.output_config = { effort: "high" }; - } else if (model.reasoning) { - if (options?.thinkingEnabled) { - // Default to "summarized" so Opus 4.7 and Mythos Preview behave like - // older Claude 4 models (whose API default is also "summarized"). - const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; - if (model.compat?.forceAdaptiveThinking === true) { - // Adaptive thinking: Claude decides when and how much to think. - params.thinking = { type: "adaptive", display }; - if (options.effort) { - params.output_config = { effort: options.effort }; - } - } else { - // Budget-based thinking for older models - params.thinking = { - type: "enabled", - budget_tokens: options.thinkingBudgetTokens || 1024, - display, - }; + } else if (model.reasoning && options?.thinkingEnabled) { + // Default to "summarized" so Opus 4.7 and Mythos Preview behave like + // older Claude 4 models (whose API default is also "summarized"). + const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; + if (model.compat?.forceAdaptiveThinking === true) { + // Adaptive thinking: Claude decides when and how much to think. + params.thinking = { type: "adaptive", display }; + if (options.effort) { + params.output_config = { effort: options.effort }; } - } else if (options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null) { - params.thinking = { type: "disabled" }; + } else { + // Budget-based thinking for older models + params.thinking = { + type: "enabled", + budget_tokens: options.thinkingBudgetTokens || 1024, + display, + }; } } @@ -1319,7 +1324,7 @@ function buildParams( params.fallbacks = allowedFallbackModels.map((fallback) => ({ model: fallback.model })); } - return { params, hasPreferredStrictTools }; + return { params, hasPreferredStrictTools, providerThinkingLevel: effortMessages ? activeEffort : undefined }; } // Normalize tool call IDs to match Anthropic's required pattern and length diff --git a/packages/ai/test/anthropic-mid-conversation-effort.test.ts b/packages/ai/test/anthropic-mid-conversation-effort.test.ts index 99d1ecff4..3ba1fb2bc 100644 --- a/packages/ai/test/anthropic-mid-conversation-effort.test.ts +++ b/packages/ai/test/anthropic-mid-conversation-effort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { stream } from "../src/api/anthropic-messages.ts"; +import { type AnthropicOptions, stream } from "../src/api/anthropic-messages.ts"; import { getModel, normalizeContext } from "../src/compat.ts"; import type { AssistantMessage, Context, Model } from "../src/types.ts"; @@ -64,14 +64,14 @@ function assistant(model: Model<"anthropic-messages">, level?: string): Assistan async function capture( model: Model<"anthropic-messages">, context: Context, - effort?: "low" | "medium" | "high" | "xhigh" | "max", + options: Pick = {}, ): Promise<{ payload: CapturedPayload; message: AssistantMessage }> { let payload: CapturedPayload | undefined; const result = stream(model, normalizeContext(context), { apiKey: "test-key", cacheRetention: "none", thinkingEnabled: true, - effort, + ...options, onPayload: (value) => { payload = value as CapturedPayload; throw new Error("payload captured"); @@ -91,11 +91,11 @@ function effortMessages(payload: CapturedPayload): WireMessage[] { describe("Anthropic mid-conversation effort", () => { it("reconstructs an exact historical marker prefix and appends the current marker", async () => { const model = managedModel(); - const first = await capture(model, { messages: [user("one", 1)] }, "low"); + const first = await capture(model, { messages: [user("one", 1)] }, { effort: "low" }); const second = await capture( model, { messages: [user("one", 1), assistant(model, "low"), user("two", 2)] }, - "high", + { effort: "high" }, ); expect(first.payload.messages).toEqual([ @@ -120,7 +120,7 @@ describe("Anthropic mid-conversation effort", () => { it.each(["low", "medium", "high", "xhigh", "max"] as const)("preserves native effort %s", async (effort) => { const model = managedModel(); - const { payload, message } = await capture(model, { messages: [user("one", 1)] }, effort); + const { payload, message } = await capture(model, { messages: [user("one", 1)] }, { effort }); expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort } }]); expect(message.providerThinkingLevel).toBe(effort); }); @@ -143,15 +143,50 @@ describe("Anthropic mid-conversation effort", () => { const { payload } = await capture( model, { messages: [user("one", 1), legacy, user("two", 2), otherProvider, user("three", 3)] }, - "medium", + { effort: "medium" }, ); expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort: "medium" } }]); }); + it("sends plain disabled thinking without effort when off is supported", async () => { + const model = managedModel(); + model.thinkingLevelMap = { minimal: null, low: "low", medium: "medium", high: "high", max: "max" }; + // Anthropic rejects block_binding with disabled thinking, and rejects effort markers + // that change level (here low, then medium) while thinking is disabled. + const { payload, message } = await capture( + model, + { + messages: [ + user("one", 1), + assistant(model, "low"), + user("two", 2), + assistant(model, "medium"), + user("three", 3), + ], + }, + { thinkingEnabled: false }, + ); + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + expect(effortMessages(payload)).toEqual([]); + expect(message.providerThinkingLevel).toBeUndefined(); + }); + + it("keeps adaptive thinking when off is unsupported", async () => { + const { payload, message } = await capture( + managedModel(), + { messages: [user("one", 1)] }, + { thinkingEnabled: false }, + ); + expect(payload.thinking?.type).toBe("adaptive"); + expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort: "high" } }]); + expect(message.providerThinkingLevel).toBe("high"); + }); + it("leaves unsupported models on top-level effort", async () => { const model = managedModel(); model.compat = { forceAdaptiveThinking: true }; - const { payload, message } = await capture(model, { messages: [user("one", 1)] }, "low"); + const { payload, message } = await capture(model, { messages: [user("one", 1)] }, { effort: "low" }); expect(payload.messages).toEqual([{ role: "user", content: "one" }]); expect(payload.output_config).toEqual({ effort: "low" }); expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); @@ -207,4 +242,15 @@ describe("Anthropic mid-conversation effort", () => { expect(getModel("openrouter", "anthropic/claude-opus-5").compat?.supportsMidConvoEffort).toBeUndefined(); expect(getModel("anthropic", "claude-opus-5").compat?.allowedFallbackModels).toBeUndefined(); }); + + it("supports off only on managed models that accept disabled thinking", () => { + for (const id of ["claude-opus-5", "claude-haiku-5-5"] as const) { + const model = getModel("anthropic", id); + expect(model.compat?.supportsMidConvoEffort).toBe(true); + expect(model.thinkingLevelMap?.off).not.toBeNull(); + } + for (const id of ["claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1"] as const) { + expect(getModel("anthropic", id).thinkingLevelMap?.off).toBeNull(); + } + }); });