codeburn/docs/core/phase0-recipe.md

12 KiB

Phase 0 fan-out recipe: lift calculateCost out of decoders

Path note (Phase 1): The CLI source has moved into the packages/cli workspace. Every src/... and tests/... path in this document now lives under packages/cli/ (e.g. src/pricing-pass.tspackages/cli/src/pricing-pass.ts, tests/providers/<name>.test.tspackages/cli/tests/providers/<name>.test.ts). Relative import paths inside packages/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)

  • ParsedProviderCall gained two optional fields (src/providers/types.ts):
    • costUSD?: number — now optional. Converted decoders omit it.
    • costBasis?: 'measured' | 'estimated'.
  • priceProviderCall runs in src/parser.ts right before canonicalizeProviderCallProject, on every parsed call. It is a no-op for calls with no costBasis (unconverted providers), so converted and unconverted providers coexist. You never edit parser.ts or pricing-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)

costBasis is orthogonal to costIsEstimated. costIsEstimated is a display flag meaning "the tokens are estimated" (e.g. char-count derived); it is preserved verbatim and never derived from costBasis. A call can be costBasis: 'estimated' with costIsEstimated: false (real tokens, priced from the table — this is how copilot marks 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(...) or credits > 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

  1. Replace costUSD: calculateCost(...) (or const costUSD = calculateCost(...)
    • costUSD,) with costBasis: 'estimated',.
  2. Delete any now-unused local (const costUSD = ...).
  3. Prune the import: drop calculateCost from the ../models.js import; keep other symbols (e.g. getShortModelName).
  4. 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 to calculateCost. 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> and costBasis: 'measured' as const.
  • Fallback branchcostBasis: 'estimated' as const (omit costUSD).
  • Preserve costIsEstimated exactly 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

  1. grep -n "calculateCost\|calculateLocalModelSavings" src/providers/<name>.tsempty.
  2. npx tsc --noEmit → clean.
  3. npx vitest run for the provider's test + any golden/report suite that names it → green, unchanged assertions.
  4. No edit to parser.ts, pricing-pass.ts, session-cache.ts, or the whitelist.
  5. No PROVIDER_PARSE_VERSIONS change anywhere (grep the diff).
  6. git checkout -- src/data/*.json before 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 a pricingModel that differs from the stored model (3 sites). The pass uses call.model, so 'estimated' would reprice with the wrong model, and 'measured' still needs a calculateCost on 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 deliberate costIsEstimated: false on token-priced calls. Each site is Pattern A, but verify every site's buckets and preserve each costIsEstimated literal exactly. Review individually rather than batch.
  • gemini — Pattern A if it stores reasoningTokens = thoughts and cacheReadInputTokens = 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.