Visitar URL original
Anthropic server-side fallback: summed iteration usage makes the next step see about twice the real prompt size · Issue #60792 · anomalyco/opencode · GitHub
Skip to content

Anthropic server-side fallback: summed iteration usage makes the next step see about twice the real prompt size #60792

Open

Description

@dhaern

Description

With Anthropic's server-side refusal fallback (server-side-fallback-2026-07-01), a turn that the requested model declines and a fallback model then serves comes back with one usage.iterations entry per attempt. Per Anthropic's docs, the top-level usage covers only the attempt that produced the returned message, and "tokens from different models are never summed into one field" (Refusals and fallback, billing and rate limits).

mapUsage sums every iteration into inputTokens, cacheReadInputTokens and cacheWriteInputTokens (anthropic-messages.ts#L1155-L1183). That sum is right for cost, because both attempts are billed. The problem is what happens to it next:

  • SessionUsage.tokens stores the summed fields as the assistant message's tokens (usage.ts#L11-L19).
  • SessionCompaction.estimatePrompt reads input + cache.read + cache.write from the latest assistant message as the measured prompt size (compaction.ts#L1104-L1127). The runner passes that estimate to the auto-compaction check and to outputLimit for the next step (llm.ts#L241-L270, model-request.ts#L91-L99).
  • Plugins that read message tokens as context size get the same figure.

So the step after a fallback sees roughly twice its real prompt size: the declined attempt read the same prompt as the attempt that answered.

contextTokens looks like it was meant for this case, but mapUsage only sets it when the last iteration has type === "message". On a fallback the last iteration is fallback_message, so it stays undefined. dev (e8238bae) has the same code.

Unit repro on v2.2.4, written like the fixtures in packages/ai/test/provider/anthropic-compaction.test.ts. The usage block is copied from the fallback example in Anthropic's docs:

const usage = {
  input_tokens: 412, output_tokens: 264, cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
  iterations: [
    { type: "message", model: "claude-fable-5", input_tokens: 535, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
    { type: "fallback_message", model: "claude-opus-4-8", input_tokens: 412, output_tokens: 264, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
  ],
}
// fixedResponse(sseEvents({ type: "message_start", message: { usage } },
//   { type: "message_delta", delta: { stop_reason: "end_turn" }, usage }, { type: "message_stop" }))
// LLMClient.generate(ai.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-8"), prompt: "hello" }))

Result: inputTokens: 947, nonCachedInputTokens: 947, contextTokens: undefined. The response itself reports input_tokens: 412.

The same thing in real sessions (Claude Opus 5.5 falling back to Opus 4.8, with prompt caching). Each value is input + cache.read + cache.write from the stored assistant message:

Previous step Step served by the fallback Next step
123,005 315,425 119,785
134,023 270,916 136,265
76,433 156,288 81,749
107,754 221,773 117,795
124,977 248,960 131,754
167,626 336,678 n/a (see below)

Most other steps served by the fallback model in the same sessions stay at about 1.0x. I take those to be turns that sticky routing sent straight to the fallback, with a single attempt. The raw iterations array is not persisted, so these pairs back up the repro but do not prove it on their own.

The last row is where I noticed it. A context-management plugin read 336,678 tokens from that message, 111% of its configured limit, while its own estimate of the prompt was about 120K. It went into its emergency path and the turn was interrupted (reported as cortexkit/magic-context#828). Inside OpenCode the inflated figure goes into the auto-compaction trigger and the output limit of the next step. With a 1M window I did not see either one fire, so I can't show that part happening.

Expected: whatever measures the prompt uses the attempt that produced the message (the last iteration, fallback_message included), and cost keeps using the per-attempt sum.

Plugins

@cortexkit/opencode-anthropic-auth 2.4.7 (sends the server-side fallback beta), @cortexkit/opencode-magic-context 0.49.33, oh-my-opencode-slim

OpenCode version

2.2.4 (also seen on 2.2.2)

Steps to reproduce

  1. Use an Anthropic model with server-side fallback enabled (anthropic-beta: server-side-fallback-2026-07-01 plus fallbacks).
  2. Send a turn that the requested model declines and the fallback model answers.
  3. Compare that assistant message's tokens (input + cache.read + cache.write) with the steps before and after it. It is about double.

Or run the unit fixture above.

Screenshot

No response

Operating System

Ubuntu 24.04 (aarch64)

Terminal

tmux

Activity

  1. opencodejr commented on Oct 31, 2026

    @opencodejr

    Thanks for the detailed report and the fixture. I reproduced this on v2.2.4, and it still happens on the latest dev (e8238ba).

    The script below feeds the fallback usage block from Anthropic's docs through LLMClient.generate, then through core's SessionUsage.tokens. On both versions:

    • inputTokens and nonCachedInputTokens come out as 947, and contextTokens is undefined.
    • The stored message tokens.input is 947, so input + cache.read + cache.write (the figure estimatePrompt treats as the measured prompt) is 947.
    • The response's own top-level usage reports 412.

    The cause matches your analysis: mapUsage sums every iteration and only sets contextTokens when the last iteration is message, not fallback_message. I only checked the usage mapping. I didn't run a full session to see the next step's auto-compaction or output limit react to the inflated number.

    Related: #46285 (the PR that added iteration summing for Anthropic usage).

    Reproduction script (anthropic-fallback-usage.test.ts)
    // Repro for anomalyco/opencode#60792: Anthropic server-side fallback usage.
    // Run from packages/ai: bun test test/provider/anthropic-fallback-usage.test.ts
    import { expect } from "bun:test"
    import { Effect } from "effect"
    import { ai } from "../../src/effect.js"
    import { LLMClient } from "../../src/route/client.js"
    import { Anthropic } from "../../src/providers/index.js"
    import { SessionUsage } from "../../../core/src/session/usage.js"
    import { testEffect } from "../lib/effect.js"
    import { fixedResponse } from "../lib/http.js"
    import { sseEvents } from "../lib/sse.js"
    
    // Usage block from Anthropic's "Refusals and fallback" docs example.
    const usage = {
      input_tokens: 412,
      output_tokens: 264,
      cache_read_input_tokens: 0,
      cache_creation_input_tokens: 0,
      iterations: [
        { type: "message", model: "claude-fable-5", input_tokens: 535, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
        { type: "fallback_message", model: "claude-opus-4-8", input_tokens: 412, output_tokens: 264, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
      ],
    }
    
    testEffect(
      fixedResponse(
        sseEvents(
          { type: "message_start", message: { usage } },
          { type: "message_delta", delta: { stop_reason: "end_turn" }, usage },
          { type: "message_stop" },
        ),
      ),
    ).effect("fallback turn: prompt size should be the answering attempt (412), not the sum", () =>
      Effect.gen(function* () {
        const result = yield* LLMClient.generate(
          ai.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-8"), prompt: "hello" }),
        )
        const u = result.usage!
        const tokens = SessionUsage.tokens(u)
        const measuredPrompt = tokens.input + tokens.cache.read + tokens.cache.write // what estimatePrompt reads
        console.log({
          inputTokens: u.inputTokens,
          nonCachedInputTokens: u.nonCachedInputTokens,
          contextTokens: u.contextTokens,
          storedTokens: tokens,
          measuredPrompt,
        })
        // The response's top-level usage reports 412 input tokens for the message returned.
        expect(u.contextTokens).toBe(412)
        expect(measuredPrompt).toBe(412)
      }),
    )

    Run it: bun anthropic-fallback-usage.test.ts

  2. dhaern commented on Oct 31, 2026

    @dhaern
    Author

    Another case from today on v2.2.4, same session, which shows how far the inflated number travels.

    One Opus 5.5 step at 04:56:35 UTC went through a server-side fallback (the stored message carries the fallback marker). The stored usage for that step is:

    {"input":4,"output":1590,"reasoning":0,"cache":{"read":416217,"write":1439}}

    The step before it reported 217,121 input tokens. Magic Context read the next value as inputTokens=417660 against a 302,222 limit (138%) and went into emergency mode. Its emergency drop then refused to run because the "fixed floor ≈391071 already exceeds ceiling 250000", and that floor is derived from the same summed figure. The turn was cut twice in a row (04:58:01 and 05:00:21) with EmergencyFailClosedError before a third attempt went through at 58,845 tokens. The Magic Context side is tracked in cortexkit/magic-context#828.

    For anyone hitting this with @cortexkit/opencode-anthropic-auth: the plugin adds fallbacks: "default" and the server-side-fallback-2026-07-01 beta to Opus requests by default. Setting OPENCODE_ANTHROPIC_AUTH_CONTENT_FILTER_FALLBACK=legacy in the server's environment stops it from sending them, so there are no extra iterations for mapUsage to add up. A refusal then comes back as a normal content-filter finish instead of being answered by claude-opus-4-8 in the same response. That avoids the trigger, but the summing in mapUsage is still wrong for every client that uses server-side fallback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions