Repository navigation
feat(cost): cache-aware token and cost accounting; meter DeepSeek and OpenRouter - #29
Merged
Merged
Conversation
… OpenRouter Every vendor's usage is now normalised to four disjoint counts -- uncached input (prompt_tokens), cache reads, cache writes, output -- and priced with the cache at its own rate: - parse: Anthropic cache_read/cache_creation_input_tokens (+ the 1-hour slice of writes), merged per field across message_start/message_delta instead of summed; OpenAI-compatible cached_tokens / cache_write_tokens taken out of the prompt (DeepSeek's prompt_cache_hit_tokens and Moonshot's top-level cached_tokens read once, never double-counted); the Responses API's input_tokens_details, and its streamed usage nested under response.completed -- a stream that used to meter as zero. - price: optional cache_read_per_mtok / cache_write_per_mtok on price rows; without them, the vendor's documented multipliers of the input rate (Anthropic, OpenAI, DeepSeek, Kimi -- sources inline). Cached tokens at a rate nobody documents make the cost null, never full-rate or zero. - report: ModelCost gains cache_read_tokens, cache_write_tokens and cache_cost_usd (the part of cost_usd spent on cache), folded into CaseCost like prompt_tokens; the run context sends them as the site's own cache_read / cache_creation; the table grows cache columns only once a case cached. - providers: deepseek (DEEPSEEK_BASE_URL, plus the harness's Anthropic-format web search on DEEPSEEK_SEARCH_BASE_URL) and openrouter (both OPENROUTER_BASE_URL and litellm's OPENROUTER_API_BASE on one port). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd OpenRouter Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
And a call that only wrote the cache is still metered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…an interim fallback The price table the site serves is the one source of cache prices, as of every other price: PriceRow gains cache_write_1h_per_mtok beside the read and write rates, and pricing.py only parses and computes. The vendor multiples move to cost/interim_cache_rates.py, marked as a stopgap to delete once /api/pricing serves the three columns. They fill per rate (a served rate always wins) and only for native model ids: the OpenRouter first-party-route exception is gone -- a routed id is priced from served rates or not at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h a commit hash The closing run description carries tp's version, and a CI build's is derived from the commit hash. For the PR's merge commit that was 0.0.0.dev1+gecc179ea4, which contains "c1" -- so the check that no case id leaks into the description failed on a hash, not a leak. Pinned to 1.2.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
For a model × harness experiment,
report.jsonnow gives, per case and per model: uncached input, cache-read, cache-write and output tokens, totalcost_usd, andcache_cost_usd(the part of the total spent on cache). This works across vendors: Anthropic, OpenAI (Chat Completions and Responses), DeepSeek, OpenRouter, Moonshot and Mistral.deepseek(DEEPSEEK_BASE_URL). Itsdeepseek-searchentry also meters the DeepSeek harness's web-search side call.openrouter, which redirects bothOPENROUTER_BASE_URLand litellm'sOPENROUTER_API_BASE.null.ModelCostandCaseCostinreport.json, the live run-context patch, and the terminal table.Everything below was checked against the vendors' current docs or source on 2026-09-11. The URLs are inline in
pricing.pyandtests/test_usage_shapes.py.What report.json carries now
Each field is on every
cost.by_model[]entry, andCaseCostsums each one the same way it sumsprompt_tokens:prompt_tokenscache_read_tokenscache_write_tokenscompletion_tokenscost_usdcache_cost_usdcost_usdspent on cache reads + writes (inside the total, not on top)Decision:
prompt_tokensstays "uncached input"The four counts are disjoint, so the input a model was sent is
prompt_tokens + cache_read_tokens + cache_write_tokens. I chose this over "all input" for three reasons:input_tokensalready excludes both cache counts.lib/runs/context.tsusageEntry) isinput/cache_read/cache_creation, withinputexcluding cache. The hook-fedv2/runs/{id}/usageroute normalises to the same thing.priceTokens/priceTokensDetailedinlib/model-pricing.ts(andscripts/recalc-null-costs.tsonmain; it is gone onuat). With "all input", those would charge cache reads at 10× their price. With "uncached input", they only leave out a component the site already declares as "cache tokens recorded, not priced".tp submitis unaffected./api/submitstoresby_modelverbatim intocases.usage(jsonb) and only sumscost_usd. The new fields ride along, and older CLIs ignore them.prompt_tokensdrops by the cached count for OpenAI, DeepSeek, OpenRouter, Moonshot and Mistral. Those vendors report cached tokens as part of the prompt; they now move tocache_read_tokens/cache_write_tokens. Anthropic is unchanged.cost_usdnow prices the cache. Before, Anthropic's cache tokens weren't priced at all, so the cost was understated. OpenAI-style cached tokens were priced at the full input rate, so the cost was overstated.response.completed, which the old parser never read. This is probably the highest-value fix in the branch.message_delta. Before, onlymessage_start's smaller count was used (e.g. 2679 → 10682 in Anthropic's own web-search sample).cost_usdcan now benullwhere it used to show a number. This happens when a call used the cache on a model with no cache rate anywhere: not on its price row, and no documented multiplier. That includes every routed OpenRouter id (anthropic/…,openai/…,deepseek/……) until the site serves cache rates for it, since routed ids get no interim fallback.Providers
deepseekDEEPSEEK_API_KEYDEEPSEEK_BASE_URL→https://api.deepseek.compackages/llm/llm-deepseek) reads it and POSTs${base}/chat/completionswith no/v1, always streaming withinclude_usage.deepseek-search(filed asdeepseek)DEEPSEEK_API_KEYDEEPSEEK_SEARCH_BASE_URL→https://api.deepseek.com/anthropic/v1web_searchis a separate billed call: non-streaming, Anthropic format, to${base}/messages.DEEPSEEK_BASE_URLdoesn't reach it.openrouterOPENROUTER_API_KEYOPENROUTER_BASE_URLandOPENROUTER_API_BASE→https://openrouter.ai/api/v1Why
openrouterredirects both variables: litellm (and so Aider) reads onlyOPENROUTER_API_BASE, the same pattern as theMOONSHOT_API_BASEprecedent. OpenRouter's TypeScript SDK reads onlyOPENROUTER_BASE_URL. Both now point at one port. When a user has set both,OPENROUTER_BASE_URLwins as the upstream._ProviderConfig.base_env: strbecamebase_envs: tuple[str, ...], plus an optionallabelfor the report's provider name.Claude Code on a third-party Anthropic-compatible endpoint was already metered, because a user-set
ANTHROPIC_BASE_URLis the upstream.test_claude_code_on_a_third_party_anthropic_endpoint_is_meteredproves it for DeepSeek'shttps://api.deepseek.com/anthropic:/anthropic/v1/messages);Pricing
The site's table is the source.
PriceRowgains three optional columns. Older clients ignore them (pydantic ignores extra fields), and the on-disk cache is written withexclude_none.cache_read_per_mtokcache_write_per_mtok: a standard write, which is Anthropic's 5-minute TTLcache_write_1h_per_mtok: Anthropic's 1-hour writepricing.pyparses and computes; it holds no prices of its own.Interim fallback, to be deleted.
src/trap/cost/interim_cache_rates.pyholds each vendor's documented cache multiple of the input rate, with its source. Its docstring and its test file (tests/test_interim_cache_rates.py) say to delete both once/api/pricingserves the three columns. It is deliberately narrow:vendor/model, e.g. OpenRouter) is priced from served rates or not at all. There is no routing-exception logic.-pro; write 1.25× on GPT-5.6+ and gpt-6.deepseek-flash/-v4-flash, 1/30× on-v4-pro.Unknown stays
null. An unknown model, or cached tokens at an unknown rate, givescost_usd=null.cache_cost_usdfollowscost_usd, and both are contagious through the folds, exactly likecost_usdwas.What trapstreet-web needs (not in this PR; target its
uatbranch)Field names below were checked against the live sources on 2026-09-11.
Serve the cache columns on
/api/pricing. This is the main path, and it lets the CLI's interim table be deleted.db/schema.tsmodel_pricing: add three nullabledoublePrecisioncolumns,cache_read_per_mtok,cache_write_per_mtokandcache_write_1h_per_mtok. Null means unknown; 0 is a real price (e.g. DeepSeek's free writes).lib/pricing-sync.ts: read the rates from the sources it already fetches:cost.cache_readcost.cache_writecache_read_input_token_costcache_creation_input_token_costcache_creation_input_token_cost_above_1hrinput_cache_readinput_cache_writeinput_cache_write_1hA rate the supplier lacks, such as models.dev's 1-hour rate, can come from the referee.
planSync'schangedcheck must compare the cache rates too. Today it compares only input/output. The table is append-only, so without this an existing row never gains cache prices. The first sync after deploy appends one new row per cached model.lib/model-pricing.ts:ModelPrice/latestPerPrefixcarry the three fields, and/api/pricingthen serves them as-is. This is additive, so older CLIs ignore the fields.That fixes every
nullcase above, including all routed OpenRouter ids, with no CLI release. Afterwardsinterim_cache_rates.pyis deleted in one commit anddefault_prices.jsonis re-captured.Price the cache on the server too.
priceTokensDetailed(lib/model-pricing.ts, used by thev2/runs/{id}/usageroute and the run-context mirror) prices only input/output and reports cache ascache_tokens_unpriced. With served rates it can pricecache_read/cache_creation, leaving only rate-less tokens unpriced. (scripts/recalc-null-costs.tsis already gone onuat.)Fix DeepSeek's price rows (a data fix, and the one the DeepSeek experiment needs first).
/api/pricinghas nodeepseek-flashrow. That is DeepSeek's current flagship id and the one its/anthropicendpoint maps Sonnet/Haiku to, anddeepseek-v4-flashdoes not prefix-match it. So a call reportingdeepseek-flashprices asnulltoday.deepseek-flash, and production still servesdeepseek-v4-flashat its pre-2026-09-10 rate. That suggests the daily sync has not landed since;sync_stateand the logs are worth a look.deepseek-v4-pro: models.dev has 0.435 input / 0.003625 cached, LiteLLM has 1.32 / 0.044, which matches DeepSeek's peak list. DeepSeek isprefer-primary(models.dev), so the sync would take the former. The site needs to decide which DeepSeek rate is "the" list price, since DeepSeek charges peak/off-peak.Nothing needed for ingestion.
/api/submitstoresby_modelverbatim. The run context'susageEntryalready hascache_read/cache_creation; tp now fills them, and a new contract test checks every key tp sends is one the spec keeps.Known gaps (verified, out of scope)
OPENAI_BASE_URL(Remove OPENAI_BASE_URL config fallback openai/codex#16720, 2026-04); the base URL is now theopenai_base_urlconfig key. It also tries a WebSocket transport first, which the proxy doesn't read.cache_write_tokenstotal with no TTL. It's priced as a 5-minute write. OpenRouter's ownusage.costis the exact charge and could serve as a cross-check later. I kept one pricing source so runs stay comparable.modelids don't match the catalogue slug, e.g.anthropic/claude-4.7-opus-20260416vsanthropic/claude-opus-4.7. That's a pre-existing prefix-match miss (nullcost), not something this PR introduces.deepseek-v4-proreroutes to Flash billing on 2026-09-14./anthropicendpoint are undocumented, so that test uses Anthropic's own shape and says so.default_prices.jsonsnapshot is not refreshed here, to keep the diff reviewable. It has no nativedeepseek-*rows at all, so offline or first-run DeepSeek costs arenulluntil the live table is fetched. The live table has a few, but notdeepseek-flash(see site item 3).usage.iterationssits outside the top-level counts), and long-context / batch / flex / priority tiers.Testing
tests/test_usage_shapes.py(new): each provider × streaming/non-streaming, from verbatim documented or recorded samples with their source:openapi.yamlResponses samples;tests/test_pricing.py: cache arithmetic from served rates, including the 1-hour column; per-rate precedence (served, then interim); routed ids priced only from served rates; unknown →null.tests/test_interim_cache_rates.py(new, to be deleted with its module): every interim multiple family, and routed ids getting no fallback.tests/test_cost.py:CostProxyend to end through a fake upstream for the DeepSeek harness stream, its web search, OpenRouter viaOPENROUTER_API_BASE, Claude Code on DeepSeek's/anthropic, and a Responses-API stream. Also covers base-URL precedence and the bucket fold.tests/test_models.py: thereport.jsonround trip of the cache breakdown per case and per model, legacy reports still loading, and unknown being contagious.tests/test_context.py/tests/test_contract_web.py: cache counts in the site's own split, checked against the web'susageEntryspec.Local results:
uv run pytest --covgives 760 passed at 100% coverage;ruff check,ruff format --check,pyright src/and the docs lint are clean. WithTRAPSTREET_WEB_SRCpointed at trapstreet-web'suattree, the contract tests pass excepttest_every_reason_the_cli_can_quote_is_one_the_bulk_route_gives. That one already fails onmain: it's grading-reason drift and unrelated to cost.Installing this branch locally, before a release
The version comes from git tags, so fetch them. Without tags the build falls back to
0.0.0.dev0, which the site's build gate may refuse on a graded run.uv tool install --forcereplaces the installedtpfor every run. To go back to the release:uv tool install --force trap-cli.To try it without touching the installed
tp, run it ephemerally from the checkout:uvx --from ~/Documents/Projects/trap tp run …No release is cut.
🤖 Generated with Claude Code