Skip to content

llmdialect/ir: the cache counts in Usage are optional - #16

Closed
changkun wants to merge 6 commits into
mainfrom
ir-usage-cache-optional
Closed

changkun wants to merge 6 commits into
mainfrom
ir-usage-cache-optional

Conversation

@changkun

Copy link
Copy Markdown
Member

Summary

ir.Usage.CacheReadInputTokens and CacheWriteInputTokens were plain
integers. A backend that reports no cache figure (an OpenAI-compatible
server without per-request cache accounting writes no
prompt_tokens_details) therefore decoded to zero, and the Anthropic
frontend wrote cache_read_input_tokens: 0 and
cache_creation_input_tokens: 0. A Messages-API client reads those as a
measurement that found nothing cached, which is the opposite of "not
measured".

This change makes the two counts *int64, following the pattern
Usage.CostUSDMicro already uses for the same nil-versus-zero distinction:

  • openaichat and openairesp backends set the read count only when
    prompt_tokens_details.cached_tokens (Chat) or
    input_tokens_details.cached_tokens (Responses) is present, zero
    included, and never set a write count, since neither wire has one.
  • The anthropic backend sets each count from its own member when present
    (a null or absent member is nil), on message_delta as well as
    message_start; a later zero never erases a count already reported, and a
    count only message_delta carried is still a report.
  • The anthropic frontend omits each key whose count is nil and writes it,
    zero included, when it is not, on the response body, message_start, and
    message_delta alike.
  • The OpenAI-shaped frontends keep writing cached_tokens as 0 for nil:
    those wires always carry the member and their readers take 0 as "no
    cache read".
  • lux.Usage (and so luxsdk.Usage) carries the two as *int64 with
    omitempty, as it carries cost_usd_micro: nil is no key, an explicit
    zero travels as 0. Conversions copy, never alias.
  • bridge.Usage is unchanged: it is the floored total a meter wants.

This is a breaking change to a v0.x module, listed under Changed with
the migration.

Reproduction

Upstream Chat Completions response from a server with no cache accounting:

{"id":"chatcmpl-1","model":"m","choices":[{"index":0,"finish_reason":"stop",
  "message":{"role":"assistant","content":"hi"}}],
 "usage":{"prompt_tokens":12,"completion_tokens":3}}

Before, translated to an Anthropic Messages caller:

"usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":12,"output_tokens":3}

After:

"usage":{"input_tokens":12,"output_tokens":3}

The same upstream with "prompt_tokens_details":{"cached_tokens":0} (a
server that measured and found no hit) still yields
"cache_read_input_tokens":0, and one with {"cached_tokens":2} yields
"cache_read_input_tokens":2 with input_tokens reduced by 2 as before;
neither writes cache_creation_input_tokens, which that wire cannot report.

On a stream, message_start is synthesized before any usage is known.
Before it carried all four members as 0; after it carries
input_tokens and output_tokens only, and the cache keys appear on
message_delta when the backend reported them.

The bridge goldens under llmdialect/bridge/testdata/translate/ for the
Anthropic-fronted pairs change accordingly; the diff is removals only
(cache_creation_input_tokens:0 where a read count was reported, both
zero keys where nothing was), and the .usage goldens are unchanged.

Migration

  • Reading the counts: dereference after a nil check; nil means unknown.
  • Building an ir.Usage or lux.Usage literal: take the address of the
    count.
  • Comparing two ir.Usage values with == now compares pointers; compare
    members or use reflect.DeepEqual.

Tests

  • openaichat: TestDecodeUsageCacheReporting (absent, null, details
    without the member, reported zero, reported count; buffered and stream
    agree), TestFrontendEncodeUsageUnreportedCache, FuzzUsageDecode.
  • openairesp: TestBackendDecodeUsageCacheReporting (buffered and
    response.completed), FuzzBackendUsageDecode.
  • anthropic: TestEncodeUsageOmitsUnreportedCache (body, message_start,
    message_delta, and one-of-two reported), TestBackendDecodeUsageCacheReporting
    (neither, null, zeros, read only; and the four stream merge cases),
    FuzzBackendUsageDecode.
  • lux: TestUsageCacheCountsNilVsZero (literal wire bytes both ways, and
    no aliasing across the wire/IR boundary), FuzzDecodeResponseUsage
    (member set only when its key appeared; round trip stable).
  • bridge: goldens regenerated for the Anthropic-fronted pairs; every
    other golden and every .usage golden is byte-identical.

go test -race ./llmdialect/... ./luxsdk/... passes. Coverage measured as
the repository's gate does (-coverpkg over the subtree): ir 92.7 %,
anthropic 97.5 %, openaichat 97.6 %, openairesp 94.8 %, lux 99.6 %,
bridge 99.5 %, luxsdk 98.3 %.

Changelog

Under ## Unreleased / ### Changed, with the migration.

Usage.CacheReadInputTokens and CacheWriteInputTokens are *int64: nil
when the backend reported no such figure, a pointer to the count, zero
included, when it did, which is how CostUSDMicro already tells unknown
from zero. A plain integer folded "not measured" into a measured zero,
and the Anthropic frontend then told a Messages client that a cache
served nothing where an engine had reported nothing at all.

This is the type change and the mechanical adaptation of every
consumer: the bridge and the frontends dereference with zero for nil,
and each backend still sets the count it decoded, so no wire byte
changes here. Each backend's report is narrowed to what its wire
carried in its own change. The changelog lists the API change under
Changed with the migration.
… carried it

prompt_tokens_details and its cached_tokens are pointers on the wire
struct, so a Chat Completions server without per-request cache
accounting, which writes neither, decodes to a nil cache read count
rather than a measured zero, and a present cached_tokens, zero
included, decodes to its value. This dialect has no cache write count
and never sets one. The frontend keeps writing cached_tokens as 0 for
nil, since the wire always carries the member and its readers take 0
as a call that read no cache. A table test pins the buffered and
streamed decodes and a fuzz test holds the count to its wire member.
… carried it

input_tokens_details and its cached_tokens are pointers on the wire
struct, so a Responses server without per-request cache accounting,
which writes neither, decodes to a nil cache read count rather than a
measured zero, and a present cached_tokens, zero included, decodes to
its value. This dialect has no cache write count and never sets one.
The frontend keeps writing cached_tokens as 0 for nil, since the wire
always carries the member and its readers take 0 as a call that read
no cache. A table test pins the buffered body and response.completed
and a fuzz test holds the count to its wire member.
…rame carried it

backendUsage's two cache members are pointers, so a usage that omits
one, or writes it null as older responses did, decodes to an
unreported count rather than a measured zero, and a present member,
zero included, decodes to its value. On the stream, message_delta
merges each count through mergeCount: a count it carries fills one
message_start did not, a zero never erases a count already reported,
and a nonzero one replaces it; the write count is merged there too,
where before only the read count was read. A table test pins the
buffered and streamed decodes and a fuzz test holds each count to its
own wire member.
lux.Usage's cache_read_input_tokens and cache_write_input_tokens are
*int64 with omitempty, as cost_usd_micro already is: nil is no key,
and a count the backend reported as zero travels as 0 and stays
distinguishable from a backend that reported none, where the integer
members with omitempty folded a reported zero into absence. The
conversions copy rather than alias, so a mutation on one side of the
wire/IR boundary cannot reach the other. luxsdk.Usage is the same type
and changes with it. A test pins absent, null, zero, and a count on
both legs against literal wire bytes, and a fuzz test holds each
optional member to its own key and round-trips a decoded usage.
…d not report

encodeUsage writes cache_read_input_tokens and
cache_creation_input_tokens only when the count is non-nil, zero
included, on the response body, message_start, and message_delta
alike. A Messages-API client reads cache_read_input_tokens: 0 as a
measurement that found nothing cached, which is not what an engine
without cache accounting said, so the key must not appear for it. The
Anthropic-fronted bridge goldens change by removals only, the keys
that were zero for a backend that reported none; the .usage goldens
and every other golden are byte-identical.
@changkun

Copy link
Copy Markdown
Member Author

Rebased onto main and pushed directly (9ddb8f7..77c18d5); GitHub's rebase-merge button is disabled on this repo, so the commits landed via direct push per repo convention. Closing as merged.

@changkun changkun closed this Sep 15, 2026
@changkun
changkun deleted the ir-usage-cache-optional branch September 15, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant