Skip to content

OpenAI-compatible providers report zero tokens_cache_read despite upstream returning cached_tokens #34022

Closed as not planned

Description

@Svtter

Description

tokens_cache_read stays 0 for OpenAI-compatible providers (litellm proxying deepseek in my case) even when the upstream returns usage.prompt_tokens_details.cached_tokens in both streaming and non-streaming responses. Other provider adapters in the same install (anthropic, zhipu, minimax) record cache hits correctly, so this is specific to the openai-compatible path.

Plugins

None relevant to this repro.

OpenCode version

1.17.11

Steps to reproduce

  1. Point opencode at an OpenAI-compatible endpoint that surfaces cache tokens. Confirm upstream actually returns them:
curl -s "$URL/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"model":"deepseek-v4-flash","messages":[{"role":"system","content":"<long repeated prefix, ~6k tokens>"}],"max_tokens":5}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["usage"])'
# {"prompt_tokens":6004,"prompt_tokens_details":{"cached_tokens":5888}, ...}

Streaming responses report the same field in the final data: chunk.

  1. Run two prompts through opencode that share a long prefix.

  2. Inspect the resulting session:

sqlite3 -readonly ~/.local/share/opencode/opencode.db \
  "SELECT substr(id,1,16), tokens_cache_read, tokens_cache_write
   FROM session ORDER BY created_at DESC LIMIT 5"

Expected: non-zero tokens_cache_read. Actual: 0.

Contrast

Same install, other providers record cache fine:

zhipu/glm-5.1            397 sessions   tokens_cache_read = 1,074,226,894
zhipu/glm-5.2            136 sessions   tokens_cache_read =   600,508,672
minimax/MiniMax-M3       395 sessions   tokens_cache_read =   126,005,095
litellm/deepseek-v4-flash 11 sessions  tokens_cache_read =             0

Related issue

#30663 reports zero cache tokens for subagent sessions on custom baseURL providers. My case differs: the main session records 0, and it reproduces deterministically, not just for subagents.

I walked the code path:

  • packages/llm/src/protocols/openai-chat.ts maps cached_tokens → cacheReadInputTokens (~line 385).
  • packages/llm/src/protocols/openai-compatible-chat.ts reuses OpenAIChat.protocol.
  • packages/core/src/session/runner/publish-llm-event.ts reads usage.cacheReadInputTokens.

The mapping exists end to end, yet the value is lost somewhere between mapUsage and the session write. I did not pin the exact line where cacheReadInputTokens drops for the openai-compatible route, but the symptom is isolated to it.

Impact

Any OpenAI-compatible provider (litellm, openrouter, vLLM, Ollama compat, LM Studio) silently loses cache accounting. Cost tables and any resume/caching savings measurement against those providers are wrong by the cache fraction.

Operating System

Linux 6.1.0-40-amd64 (Debian)

Terminal

tmux 3.3a

Activity

  1. github-actions commented on Jun 26, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. Zaczero commented on Aug 18, 2026

    @Zaczero

    On V2 (0.0.0-beta-17498) this mapping exists. openai-chat.ts reads
    prompt_tokens_details.cached_tokens into cacheReadInputTokens, and every OpenAI-compatible
    provider reuses that protocol unmodified:

    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 },
    })
    }

    Streaming is wired up as well — stream_options: { include_usage: true } is set on every request
    (openai-chat.ts:548), so the final chunk carrying cached_tokens is both requested and parsed.

    What can still lose the value on this path

    Usage state is replaced rather than merged. openai-chat.ts:692:

    const usage = mapUsage(event.usage) ?? state.usage

    The last chunk carrying any usage object wins outright. A proxy that emits usage more than once in a
    stream — a chunk with prompt_tokens_details, then a bare prompt_tokens totals chunk — loses the
    cache read to the second one. The native Anthropic protocol has no such exposure; it folds per-field
    rather than replacing:

    const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
    if (!left) return right
    if (!right) return left
    const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
    const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens
    const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
    const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
    const outputTokens = right.outputTokens ?? left.outputTokens

    This fits the contrast in the report, where zhipu and minimax record cache correctly and only the
    litellm route reports zero — it would be a property of how that proxy chunks usage rather than of the
    mapping. Unconfirmed: no litellm traffic available here to reproduce against.

    The raw SSE tail from a single litellm request settles it. If usage appears in more than one data:
    frame, that is the cause.

    Narrowing which layer drops it

    tokens_cache_read on the session row is a cumulative projection, one step removed from what the
    protocol produced. The per-message value splits the problem (V2 schema below; on 1.17.x the equivalent
    is the message table filtered on $.role):

    opencode db "SELECT json_extract(data,'$.tokens.cache.read')
                 FROM session_message WHERE type='assistant'
                 ORDER BY time_created DESC LIMIT 10"
    

    Non-zero per-message against a zero session total puts the loss in the rollup. Zero in both means it
    never survived mapUsage.

  3. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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