Skip to content

fix: bill cached input at cache prices, estimate missing usage, strip carried signatures - #40

Merged
fylorn merged 1 commit into
devfrom
fix/passthrough-usage-cache-pricing
Sep 24, 2026
Merged

fylorn merged 1 commit into
devfrom
fix/passthrough-usage-cache-pricing

Conversation

@fylorn

@fylorn fylorn commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Three billing and passthrough fixes on the generation pipeline.

Cached input was billed at the full input price. tokens() folded cache reads and writes into the prompt count, and CostTracker and the weighted-token debit priced all of it as plain input. An Anthropic turn that read 10k tokens from its cache cost, and weighed against rate limits and budgets, as much as sending them fresh.

  • models gets cache_read_weight, cache_write_weight and cache_write_1h_weight. Each is nullable and priced against the input baseline, like input_weight. Declared in the table, plus the ALTER … ADD COLUMN IF NOT EXISTS lines schema.sql uses for columns added after first boot.
  • An unset weight is derived from input_weight at Anthropic's ratios: read 0.1×, write 1.25×, 1-hour write 2×. This is documented in schema.sql, in limits::weight (CACHE_*_RATIO) and in the editor hint. OpenAI's older models discount cache reads less (0.5×, 0.25×), so a model served there should set its own.
  • Cost and weighted tokens price each bucket on its own weight (TokenCounts, Weights::resolve). input_tokens on the audit row is still the whole input. The detail gains cache_read_tokens, cache_write_tokens and cache_write_1h.
  • Admin API: create takes the three weights, PATCH takes them with null meaning "derive again", and list returns them. The model editor has three optional fields; their placeholders show the derived value. The detail sheet shows each weight, marked when it is derived.

Missing usage was billed as zero. #34 made Chat streams ask for usage. Usage can still be missing: the upstream ignores stream_options, or the caller leaves mid-stream and takes the final usage chunk with it.

  • New usage_estimate module, restoring what 407ae5f had before feat!: forward what can be forwarded, convert only what must be #26:
    • input comes from the decoded request at about four bytes a token; images and files are not counted;
    • output comes from the answer that arrived, leaving out ids and reasoning signatures.
  • If the upstream reported nothing, both counts are estimated.
  • A stream cut short keeps the upstream's input count. Its output is the larger of the upstream's running count and the estimate.
  • If no answer arrived at all, the request is still billed as zero.
  • An estimated row carries usage_estimated: true in its detail, and the gateway_usage_estimated_total counter goes up.

Passthrough forwarded carried reasoning signatures. A request forwarded as sent now goes through tw_dialect::convert::strip_carried, as the desktop gateway does. It removes the tw1. signatures an earlier conversion wrote, which Anthropic rejects. A native signature is left alone.

Test plan

  • Integration (new):
    • a_stream_without_usage_is_billed_on_an_estimate: 800 bytes in and 400 out are recorded as 200/100 tokens, cost > 0, usage_estimated;
    • a_stream_the_caller_leaves_is_billed_on_an_estimate: a raw upstream sends one chunk and hangs; the client drops the stream; the row is 499, billed 200/100, estimated;
    • cached_input_is_billed_at_the_cache_prices: Anthropic usage of 100 fresh, 10k read, 2k written and 50 out costs 0.0076 at the default baseline, the budget counter is debited 3650, and cache_read_weight = 0.5 changes the cost to 0.0156;
    • a_passthrough_request_leaves_the_gateways_own_signatures_behind: the tw1. thinking block is gone and the native one is kept;
    • model_cache_weights_round_trip: admin create, PATCH, null clear, negative rejected, list.
  • Unit: estimate, weighted tokens and cost with cache buckets.
  • fmt, clippy --all-targets -D warnings and --lib, workspace unit tests.
  • web: check:i18n, lint, test, build.

🤖 Generated with Claude Code

… carried signatures

Cached input was billed at the full input price. The prompt count put
cache reads and writes together with plain input, so an Anthropic turn
that read 10k tokens from its cache cost as much as sending them fresh,
and weighed as much against rate limits and budgets. Models get three
optional weights against the input baseline: cache read, cache write,
and 1-hour cache write. Unset, they follow the input weight at
Anthropic's ratios (0.1x, 1.25x, 2x). Cost and weighted tokens now price
each bucket on its own weight. The audit row keeps the whole input in
`input_tokens` and gains the cache split in its detail. The admin API
and the model editor carry the new weights.

A request the upstream reported no usage for was billed as zero tokens.
#34 made Chat streams ask for usage, but usage can still be missing: the
upstream ignores `stream_options`, or the caller leaves mid-stream and
takes the final usage chunk with it. The count is now estimated at about
four bytes a token from the request and from the answer that arrived,
and the row says `usage_estimated`. A stream cut short keeps the
upstream's input count and bills the larger of its running output count
and the estimate. No answer at all still bills nothing.

A request forwarded as sent now drops the `tw1.` reasoning signatures an
earlier conversion wrote, via `tw_dialect::convert::strip_carried`, as
the desktop gateway does. Anthropic refuses a whole request over them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@fylorn
fylorn force-pushed the fix/passthrough-usage-cache-pricing branch from 7daab45 to b87acfc Compare September 24, 2026 06:47
@fylorn
fylorn merged commit c1b2085 into dev Sep 24, 2026
6 checks passed
@fylorn
fylorn deleted the fix/passthrough-usage-cache-pricing branch September 24, 2026 06:57
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