Skip to content

Anthropic per-message effort never reaches the wire: demoteUnavailableToolReferences drops the content-less effort markers #2912

Description

@code-yeongyu

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

Activity

  1. code-yeongyu commented on Oct 8, 2026

    @code-yeongyu
    OwnerAuthor

    Fixed by #2913, merged as 4a2f738.

    What changed

    • The per-message Anthropic effort markers now reach the wire. demoteUnavailableToolReferences no longer drops a content-less effort marker.
    • The whole-request cleanup only drops a native search call whose result could not be resolved, together with any message that cleanup leaves empty. A resolvable split pair is never touched.

    Evidence at the merged head 4f416ad

    • Failing-first tests for the dropped marker, for a message the cleanup itself empties, and for the [text, server_tool_use] split pair. Each failed before the fix and passes after.
    • Build, bun run check and the full packages/ai suite: 371 files, 3692 passed, 0 failed.
    • GitHub CI: 29 checks green.
    • Review: two independent passes approved it, an initial review and a delta review after the follow-ups.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions