mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
550f1f3ecd
commit
dc939913f6
4 changed files with 111 additions and 42 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue