Summary
On every Anthropic model that uses managed per-message effort (compat.supportsMidConvoEffort: true: Claude Opus 5 / 5.5, Sonnet 5.5, Fable / Mythos 5.1, and Haiku 5.5 once #2911 lands), the effort the user picked never reaches the API. Every thinking-on request goes out with the hard-coded top-level output_config.effort: "high" and no per-message effort marker.
Reproduction
Minimal, offline. A capturing fetch records the real request body:
import { stream } from "./packages/ai/src/api/anthropic-messages.ts";
import { getModel, normalizeContext } from "./packages/ai/src/compat.ts";
let wire: any;
let hook: any;
const fetch = async (_url: RequestInfo | URL, init?: RequestInit) => {
wire = JSON.parse(String(init?.body));
return new Response("{}", { status: 500 });
};
const model = getModel("anthropic", "claude-sonnet-5-5");
await stream(model, normalizeContext({ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }), {
apiKey: "dummy", fetch, maxRetries: 0, thinkingEnabled: true, effort: "medium",
onPayload: (p) => { hook = structuredClone(p); },
}).result();
console.log(hook.messages.at(-1)); // { role: "system", content: [], output_config: { effort: "medium" } }
console.log(wire.messages.at(-1)); // { role: "user", ... }: the marker is gone
console.log(wire.output_config); // { effort: "high" }
Expected
The trailing per-message marker { role: "system", content: [], output_config: { effort: <chosen> } } that buildParams appends (and the historical markers before earlier assistant turns) is present in the HTTP body, so the chosen effort (low / medium / high / xhigh / max) is what the API applies.
Actual
onPayload sees the marker, but the HTTP body does not. The API applies the top-level output_config.effort: "high" from buildParams on every thinking-on turn, whatever the user selected. Users who pick medium or low pay for high, and users who pick xhigh or max get high.
Evidence
packages/ai/src/api/anthropic-messages.ts: buildParams sets top-level output_config: { effort: "high" } on the managed-effort branch and appends the active marker in insertThinkingLevelMessages (historical markers too). Those markers are intentionally content-less.
packages/ai/src/api/anthropic-messages.ts createRequest: after onPayload, it runs demoteUnavailableToolReferences(params).
packages/ai/src/api/anthropic-tool-references.ts:183-186: in demoteUnavailableToolReferences, if (content.length === 0) { changed = true; continue; } drops every message whose rebuilt content is empty, whether or not the demotion touched it. The marker has content: [], so it is always deleted.
- The sibling
demoteToolReferenceReplay drops an empty message only when it changed that message, so it keeps the markers.
Root cause
The empty-message drop was added with the demotion pass in 5ecb304 (2026-07-28), when no request carried content-less messages. Per-turn effort markers came later in 4e69b0c (2026-09-02), with deliberately empty content. They have been stripped since they shipped. The existing tests assert only the onPayload view, so nothing noticed. The pass moved into its own module in 13a9444 without behavior change.
Scope and acceptance criteria
demoteUnavailableToolReferences drops a message only when the demotion itself emptied it. Untouched messages, including content-less effort markers, pass through unchanged.
- A regression test captures the actual HTTP request body (custom
fetch) for a medium-effort turn on at least Claude Sonnet 5.5 and Claude Opus 5.5, and asserts the trailing marker carries effort: "medium". It fails on main and passes with the fix.
- The existing demotion behavior is preserved: a message emptied by demotion is still dropped.
Related
Summary
On every Anthropic model that uses managed per-message effort (
compat.supportsMidConvoEffort: true: Claude Opus 5 / 5.5, Sonnet 5.5, Fable / Mythos 5.1, and Haiku 5.5 once #2911 lands), the effort the user picked never reaches the API. Every thinking-on request goes out with the hard-coded top-leveloutput_config.effort: "high"and no per-message effort marker.Reproduction
Minimal, offline. A capturing
fetchrecords the real request body:Expected
The trailing per-message marker
{ role: "system", content: [], output_config: { effort: <chosen> } }thatbuildParamsappends (and the historical markers before earlier assistant turns) is present in the HTTP body, so the chosen effort (low / medium / high / xhigh / max) is what the API applies.Actual
onPayloadsees the marker, but the HTTP body does not. The API applies the top-leveloutput_config.effort: "high"frombuildParamson every thinking-on turn, whatever the user selected. Users who pickmediumorlowpay forhigh, and users who pickxhighormaxgethigh.Evidence
packages/ai/src/api/anthropic-messages.ts:buildParamssets top-leveloutput_config: { effort: "high" }on the managed-effort branch and appends the active marker ininsertThinkingLevelMessages(historical markers too). Those markers are intentionally content-less.packages/ai/src/api/anthropic-messages.tscreateRequest: afteronPayload, it runsdemoteUnavailableToolReferences(params).packages/ai/src/api/anthropic-tool-references.ts:183-186: indemoteUnavailableToolReferences,if (content.length === 0) { changed = true; continue; }drops every message whose rebuilt content is empty, whether or not the demotion touched it. The marker hascontent: [], so it is always deleted.demoteToolReferenceReplaydrops an empty message only when it changed that message, so it keeps the markers.Root cause
The empty-message drop was added with the demotion pass in 5ecb304 (2026-07-28), when no request carried content-less messages. Per-turn effort markers came later in 4e69b0c (2026-09-02), with deliberately empty content. They have been stripped since they shipped. The existing tests assert only the
onPayloadview, so nothing noticed. The pass moved into its own module in 13a9444 without behavior change.Scope and acceptance criteria
demoteUnavailableToolReferencesdrops a message only when the demotion itself emptied it. Untouched messages, including content-less effort markers, pass through unchanged.fetch) for a medium-effort turn on at least Claude Sonnet 5.5 and Claude Opus 5.5, and asserts the trailing marker carrieseffort: "medium". It fails on main and passes with the fix.Related