12 KiB
Phase 0 fan-out recipe: lift calculateCost out of decoders
Path note (Phase 1): The CLI source has moved into the
packages/cliworkspace. Everysrc/...andtests/...path in this document now lives underpackages/cli/(e.g.src/pricing-pass.ts→packages/cli/src/pricing-pass.ts,tests/providers/<name>.test.ts→packages/cli/tests/providers/<name>.test.ts). Relative import paths insidepackages/cli/src(such as../../src/pricing-pass.js) are unaffected because the source tree moved as a whole.
Phase 0 of the @codeburn/core extraction (issue #809) moves cost computation
out of the provider decoders and into a single host-side pricing pass
(src/pricing-pass.ts). Decoders emit token counts + a costBasis marker;
priceProviderCall fills in costUSD afterwards, byte-identical to today.
The exemplar PR converted three providers (zerostack, qwen, quickdesk) and
built the seam. This document is the mechanical recipe for the remaining
providers. Follow it per-provider; each provider is one small PR.
The seam (already in place — do not re-add)
ParsedProviderCallgained two optional fields (src/providers/types.ts):costUSD?: number— now optional. Converted decoders omit it.costBasis?: 'measured' | 'estimated'.
priceProviderCallruns insrc/parser.tsright beforecanonicalizeProviderCallProject, on every parsed call. It is a no-op for calls with nocostBasis(unconverted providers), so converted and unconverted providers coexist. You never editparser.tsorpricing-pass.ts.
priceProviderCall behaviour:
costBasis |
what the pass does |
|---|---|
'estimated' |
computes costUSD = calculateCost(model, input, output+reasoning, cacheCreation, cacheRead, webSearch, speed) from the call's stored buckets |
'measured' |
leaves costUSD (which the decoder set to a provider-reported dollar figure) untouched |
| absent | leaves the call exactly as-is (unconverted) |
costBasisis orthogonal tocostIsEstimated.costIsEstimatedis a display flag meaning "the tokens are estimated" (e.g. char-count derived); it is preserved verbatim and never derived fromcostBasis. A call can becostBasis: 'estimated'withcostIsEstimated: false(real tokens, priced from the table — this is howcopilotmarks metered-token calls).
Why the estimated path is safe (the confidence lever)
For every non-whitelisted provider, the cache-read path
(cachedCallToApiCall in parser.ts) already recomputes costUSD with the
exact generic formula the pass uses:
outputForCost = provider === 'claude' ? outputTokens : outputTokens + reasoningTokens
calculateCost(model, inputTokens, outputForCost, cacheCreationInputTokens,
cacheReadInputTokens, webSearchRequests, speed, cacheCreationOneHourTokens)
So a non-whitelisted provider's report cost is already produced by this
formula on a warm cache. Converting it to costBasis: 'estimated' cannot change
report output — it just moves the identical computation earlier. The green
suite is your proof.
The "whitelist" is the provider list inside providerCallToCachedCall
(parser.ts, ~line 2380) whose costUSD is persisted to the cache:
mistral-vibe, antigravity, devin, vercel-gateway, hermes, kiro, codewhale, quickdesk
For a whitelisted provider the stored decoder cost is authoritative (not recomputed), so its exact number must be preserved — see Pattern B and the misfit list. Do not change the whitelist.
Before you start (per provider)
grep -n "calculateCost\|calculateLocalModelSavings" src/providers/<name>.ts
Classify each call site:
- Pattern A — pure token estimate:
calculateCost(model, <buckets>, ...)with no provider-reported dollar fallback. - Pattern B — passthrough with fallback:
recorded ?? calculateCost(...)orcredits > 0 ? credits*rate : calculateCost(...)(i.e. a real dollar/credit figure when present, token estimate otherwise). - Misfit — see the flag list. Stop and hand to Opus-tier; do not force it.
Then check whether the provider's test asserts cost on raw parse() output:
grep -n "costUSD\|costIsEstimated" tests/providers/<name>.test.ts # (or wherever it lives)
If yes, you will route that test's collect helper through priceProviderCall
(see "Test wiring" below).
Pattern A — pure token estimate
Steps
- Replace
costUSD: calculateCost(...)(orconst costUSD = calculateCost(...)costUSD,) withcostBasis: 'estimated',.
- Delete any now-unused local (
const costUSD = ...). - Prune the import: drop
calculateCostfrom the../models.jsimport; keep other symbols (e.g.getShortModelName). - Verify the buckets match. The generic formula uses
outputTokens + reasoningTokens,cacheCreationInputTokens,cacheReadInputTokens,webSearchRequests,speed. Confirm the decoder stores into exactly those fields whatever it passed tocalculateCost. This holds automatically for non-whitelisted providers (see confidence lever), but eyeball it.
Worked diff — zerostack (trivial, all extra buckets zero):
-import { calculateCost, getShortModelName } from '../models.js'
+import { getShortModelName } from '../models.js'
@@
webSearchRequests: 0,
- costUSD: calculateCost(model, input, output, 0, 0, 0),
+ costBasis: 'estimated',
Worked diff — qwen (reasoning billed at output rate + cache-read):
-import { calculateCost } from '../models.js'
@@
const cachedTokens = usage.cachedContentTokenCount ?? 0
-
- const costUSD = calculateCost(model, inputTokens, outputTokens + reasoningTokens, 0, cachedTokens, 0)
@@
reasoningTokens,
webSearchRequests: 0,
- costUSD,
+ costBasis: 'estimated',
qwen stores reasoningTokens and cacheReadInputTokens: cachedTokens, so the
generic formula reproduces calculateCost(model, in, out+reasoning, 0, cached, 0)
exactly.
Acceptance: grep the file clean of calculateCost; npx tsc --noEmit;
run the provider's own tests and any golden/report test that touches it.
Pattern B — passthrough with fallback
A real dollar/credit figure when the provider reports one, else a token estimate. Convert per branch:
- Measured branch → set
costUSD: <recordedCost>andcostBasis: 'measured' as const. - Fallback branch →
costBasis: 'estimated' as const(omitcostUSD). - Preserve
costIsEstimatedexactly as before.
Use a conditional spread so the two shapes stay in one yield:
Worked diff — quickdesk metrics parser:
-import { calculateCost } from '../models.js'
@@
yield {
...commonCallFields(source, basePath),
model,
inputTokens,
outputTokens,
- costUSD: recordedCost ?? calculateCost(model, inputTokens, outputTokens, 0, 0, 0),
+ ...(recordedCost !== undefined
+ ? { costUSD: recordedCost, costBasis: 'measured' as const }
+ : { costBasis: 'estimated' as const }),
costIsEstimated,
Its second (database) parser is pure Pattern A:
- costUSD: calculateCost(model, inputTokens, outputTokens, 0, 0, 0),
+ costBasis: 'estimated',
costIsEstimated: true,
Whitelisted vs not: the conversion is identical either way. If the provider
is whitelisted, its measured costUSD is persisted and used verbatim (correct).
If not, the measured costUSD was already discarded at cache-write and the
report already used the generic recompute — the conversion preserves that. Either
way, leave the whitelist alone.
Credit-based measured branches (codebuff, kiro) are Pattern B: the
credits * RATE value is the measured figure — carry it as
costUSD: creditsCost, costBasis: 'measured'; the calculateCost fallback
becomes costBasis: 'estimated'.
Test wiring
Only needed when a provider's own test asserts costUSD/costIsEstimated on the
raw parse() output (unit-level). Route the collect helper through the pass:
+import { priceProviderCall } from '../../src/pricing-pass.js'
@@
- for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call)
+ for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(priceProviderCall(call))
All assertions then pass unchanged (the pass reproduces the exact numbers).
Golden/report tests need no change — they run through parser.ts, which
already applies the pass. Providers with no cost assertion on raw output (e.g.
qwen) need no test edit at all.
Per-provider acceptance checklist
grep -n "calculateCost\|calculateLocalModelSavings" src/providers/<name>.ts→ empty.npx tsc --noEmit→ clean.npx vitest runfor the provider's test + any golden/report suite that names it → green, unchanged assertions.- No edit to
parser.ts,pricing-pass.ts,session-cache.ts, or the whitelist. - No
PROVIDER_PARSE_VERSIONSchange anywhere (grep the diff). git checkout -- src/data/*.jsonbefore committing (build refreshes the pricing snapshot).
Straightforward fan-out targets
Pattern A / B, non-whitelisted unless noted — safe for the cheap fan-out:
goose, mux, open-design, lingtai-tui, droid, cursor-agent, zcode, pi, forge, grok, warp, kimicode, cursor, zed, crush, openclaw, vscode-cline-parser, session-message, sqlite-session-parser, codebuff, kimi, hermes (whitelisted, B), kiro (whitelisted, B, credits), codewhale (whitelisted, B), quickdesk (done), zerostack (done), qwen (done)
For each whitelisted one, confirm the estimated-branch calculateCost args map
to the stored buckets with the same model before trusting Pattern A/B.
Misfits — hand to Opus-tier, do NOT mechanically convert
These break the "generic formula on stored buckets reproduces the cost"
assumption. They need a seam extension (e.g. an optional pricingModel field or
a session-cost carrier), so they are out of scope for the cheap fan-out:
antigravity— prices with apricingModelthat differs from the storedmodel(3 sites). The pass usescall.model, so'estimated'would reprice with the wrong model, and'measured'still needs acalculateCoston the pricing model inside the decoder. Whitelisted, so the wrong number would ship.mistral-vibe— computes one session-level cost and allocates it across messages (allocateCost(costUSD, assistantMessages.length)). Per-call buckets do not map to per-call cost, so the generic formula cannot reproduce it. Whitelisted.copilot— not a hard misfit, but higher-effort: multiple emit sites (input-only, output-only, full),durableSources, and deliberatecostIsEstimated: falseon token-priced calls. Each site is Pattern A, but verify every site's buckets and preserve eachcostIsEstimatedliteral exactly. Review individually rather than batch.gemini— Pattern A if it storesreasoningTokens = thoughtsandcacheReadInputTokens = cached(it does today; non-whitelisted, so the cache-read path already proves it). Convert, but double-check the buckets.
Out of scope for this recipe: the parser.ts Claude sites
The three calculateCost sites in src/parser.ts (+ applyLocalModelSavings)
belong to the native Claude decoder, which produces ParsedApiCall (not
ParsedProviderCall) and does not flow through priceProviderCall. Lifting
those requires a parallel seam on the Claude/ParsedApiCall path and is
Phase 3 (Claude decoder carve-out) work — do not attempt it as part of the
provider fan-out.