fix(ai): support off thinking level on Claude Haiku 5.5 and Opus 5

Both models accept thinking: {type: "disabled"}, but were treated like
Opus 5.5, which cannot turn thinking off. "off" now sends disabled
thinking without effort markers or block_binding, which Anthropic
rejects while thinking is disabled.

fixes #10753
This commit is contained in:
David Brailovsky 2026-10-15 11:22:53 +02:00
parent 550f1f3ecd
commit dc939913f6
4 changed files with 111 additions and 42 deletions

View file

@ -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

View file

@ -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<Api>): 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",

View file

@ -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

View file

@ -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<AnthropicOptions, "effort" | "thinkingEnabled"> = {},
): 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();
}
});
});