Skip to content

fix: cache write tokens always 0 for Anthropic models behind an OpenAI-compatible provider #36749

Closed as duplicate

Description

@lewislf

Description

I route Anthropic models (on Bedrock) through an OpenAI-compatible gateway, configured with npm: "@ai-sdk/openai-compatible". opencode always records cache.write: 0 for those models, even though the model really does write a cache prefix and gets billed for it. Cache reads are recorded fine, so I end up with sessions showing millions of cache read tokens and zero cache writes, which can't be true: something had to write the prefix that is being read.

The cost ends up understated, since cache writes bill at 1.25x input but get counted as plain input at 1.0x.

Why it happens: the OpenAI usage shape has no cache-write field, so @ai-sdk/openai-compatible hardcodes cacheWrite: void 0 and passes the provider payload through as LanguageModelUsage.raw. Gateways in front of Anthropic do report the count there (cache_creation_input_tokens), but usage() in packages/opencode/src/session/llm/ai-sdk.ts only reads inputTokenDetails.cacheWriteTokens, and the fallbacks in Session.getUsage only cover native anthropic / vertex / bedrock metadata. So it lands on 0.

First turn of a session, same request:

opencode recorded:  {"input": 19428, "output": 4, "cache": {"write": 0, "read": 0}}
gateway returned:   {"prompt_tokens": 19428, "cache_creation_input_tokens": 19426}

Expected input: 2, cache.write: 19426.

#18440 reported the same thing for LiteLLM/Bedrock and was closed by #22224, which made getUsage read usage.cacheWriteInputTokens. That works for providers that populate the field, but @ai-sdk/openai-compatible never does, so gateways using it are still broken. #35974 (DeepSeek) and #34022 look like the same root cause on the same code path.

I have a fix working locally (fall back to raw.cache_creation_input_tokens when the SDK gives nothing) and can send a PR if you want it.

OpenCode version

1.17.19

Steps to reproduce

  1. Configure a provider with npm: "@ai-sdk/openai-compatible" pointing at a gateway that fronts an Anthropic model with prompt caching enabled (LiteLLM or similar).
  2. Send a prompt long enough to be cached.
  3. Check the assistant message tokens: cache.write is 0, and the written tokens show up as plain input instead.

Activity

  1. padraig-myers-sh commented on Aug 10, 2026

    @padraig-myers-sh

    Is it possible to get an update on this?
    Currently having to monkey patch opencode with the fix in #36752 to correct cost when routing via llmlite.

  2. Zaczero commented on Aug 18, 2026

    @Zaczero

    This reproduces in V2 as well, in a completely different file, so the fix will need porting rather
    than merging.

    V1's analysis pointed at packages/opencode/src/session/llm/ai-sdk.ts. V2 rewrote the provider layer
    into packages/ai/src/protocols/, and the same gap is now here:

    const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
    if (!usage) return undefined
    const input = usage.prompt_tokens ?? undefined
    const output = usage.completion_tokens ?? undefined
    const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
    const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
    const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
    const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
    return new Usage({
    inputTokens: input,
    outputTokens: output,
    nonCachedInputTokens: nonCached,
    cacheReadInputTokens: cached,
    cacheWriteInputTokens: cacheWrite,
    reasoningTokens: reasoning,
    totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
    providerMetadata: { openai: usage },
    })
    }

    This is the path every OpenAI-compatible provider takes. openai-compatible-chat.ts doesn't define
    its own protocol — it reuses OpenAIChat.protocol end to end and overrides only the route id:

    export const route = Route.make({
    id: ADAPTER,
    providerMetadataKey: "openai",
    protocol: OpenAIChat.protocol,
    endpoint: Endpoint.path("/chat/completions"),
    framing: Framing.sse,
    })

    So litellm, vLLM, LM Studio, Ollama-compat and every gateway fronting Anthropic all land on that one
    mapUsage, which reads cache counts only from prompt_tokens_details.cached_tokens and
    prompt_tokens_details.cache_write_tokens. A gateway reporting cache_creation_input_tokens at the
    top level of usage — exactly the payload in your report — matches neither key.

    Why this fails silently, which I think is the more important half

    The two protocols use opposite conventions, and only one of them can hide a missing field.

    Native Anthropic adds up:

    const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
    if (!usage) return undefined
    const nonCached = usage.input_tokens
    const cacheRead = usage.cache_read_input_tokens ?? undefined
    const cacheWrite = usage.cache_creation_input_tokens ?? undefined
    const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
    return new Usage({
    inputTokens,
    outputTokens: usage.output_tokens,
    nonCachedInputTokens: nonCached,
    cacheReadInputTokens: cacheRead,
    cacheWriteInputTokens: cacheWrite,
    reasoningTokens: usage.output_tokens_details?.thinking_tokens,
    totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
    providerMetadata: { anthropic: usage },

    input_tokens there is exclusive of cache, so inputTokens = nonCached + cacheRead + cacheWrite. Drop
    a field on that path and the total comes out too small — visible, and something downstream can catch.

    OpenAI Chat subtracts: prompt_tokens is inclusive, so nonCached = prompt_tokens - (cached + cacheWrite). Drop cacheWrite here and the total stays perfectly correct — the tokens simply move
    into the nonCached column. Nothing reconciles wrong, no invariant breaks, no total looks off. The
    bill is the only place the difference is observable, which is why this keeps getting refiled per
    provider (#35974, #37745, this one) instead of being caught once.

    That also means your cost estimate is understated by more than the 1.25x you mentioned whenever the
    provider prices cache writes above input, and it is silently understated in every case.

    The fix needs no schema change

    The alternate keys already survive decoding. Both the usage struct and prompt_tokens_details are
    declared with a rest record:

    const OpenAIChatUsage = Schema.StructWithRest(
    Schema.Struct({
    prompt_tokens: optionalNull(Schema.Number),
    completion_tokens: optionalNull(Schema.Number),
    total_tokens: optionalNull(Schema.Number),
    prompt_tokens_details: optionalNull(
    Schema.StructWithRest(
    Schema.Struct({
    cached_tokens: optionalNull(Schema.Number),
    cache_write_tokens: optionalNull(Schema.Number),
    }),
    [Schema.Record(Schema.String, Schema.Unknown)],
    ),
    ),
    completion_tokens_details: optionalNull(
    Schema.StructWithRest(
    Schema.Struct({
    reasoning_tokens: optionalNull(Schema.Number),
    accepted_prediction_tokens: optionalNull(Schema.Number),
    rejected_prediction_tokens: optionalNull(Schema.Number),
    }),
    [Schema.Record(Schema.String, Schema.Unknown)],
    ),
    ),
    }),
    [Schema.Record(Schema.String, Schema.Unknown)],
    )

    Schema.StructWithRest(..., [Schema.Record(Schema.String, Schema.Unknown)]) keeps unknown keys on the
    decoded value, so cache_creation_input_tokens is present on the object mapUsage already receives.
    Reading it there is additive — no schema edit, no new plumbing.

    One caveat worth stating, because it constrains where the fix can go: mapUsage sets
    providerMetadata: { openai: usage }, but that does not survive persistence. I checked 105,379
    assistant rows in my database and none carries a providerMetadata key — the persisted message keeps
    only agent, model, content, finish, cost, tokens, time, snapshot, error. So a downstream fallback
    that reads provider metadata off the stored message cannot work; it has to happen inside mapUsage
    while the raw payload is still in hand.

    Scope

    #35974 is the same function and the same gap, with DeepSeek's key names instead. Both are one patch.

  3. neriousy commented on Oct 1, 2026

    @neriousy
    Member

    Closing as a duplicate of #35974, which tracks cache-write tokens not being recorded for OpenAI-compatible providers. As noted there, both cases are the same gap in the usage mapping and need one fix. Please add your gateway/model details there.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions