feat: estimate and report run cost per model - #231
Conversation
|
Warning Review limit reached
Next review available in: 21 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (32)
WalkthroughThe PR adds model-level token attribution, provider-aware pricing lookup, run-cost estimation, and cost reporting. It propagates cost data through evaluator results, run summaries, reports, browser exports, CLI output, and documentation. ChangesModel-attributed cost reporting
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant ModelFactory
participant LLMCall
participant TokenTracker
participant CostEstimator
participant Report
ModelFactory->>LLMCall: provide configured model identity
LLMCall->>TokenTracker: record tokens with model and role
TokenTracker-->>CostEstimator: provide per-model breakdown
CostEstimator-->>Report: provide estimated RunCost
Report-->>Report: render testing cost and pricing coverage
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
core/src/execute/runAllBrowser.ts (1)
192-195: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the repeated usage/cost assignment into a helper.
The pattern
result.tokenUsageByModel = tracker.breakdown; result.cost = estimateRunCost(result.tokenUsageByModel);appears three times in this function: once for the partial-failure result, once for the completed evaluator result, and once for the overall report summary. Extract a small helper, for exampleattachUsageAndCost(target, tracker), so a future change to how cost is derived only needs to happen once.♻️ Proposed helper extraction
+function attachCost<T extends { tokenUsage?: unknown; tokenUsageByModel?: unknown; cost?: unknown }>( + target: T, + tracker: TokenTracker +): void { + (target as any).tokenUsage = tracker.totals; + (target as any).tokenUsageByModel = tracker.breakdown; + (target as any).cost = estimateRunCost(tracker.breakdown); +}Also applies to: 235-238, 241-247
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@core/src/execute/runAllBrowser.ts` around lines 192 - 195, In the runAllBrowser function, extract the repeated tokenUsageByModel and cost assignment into a shared helper such as attachUsageAndCost(target, tracker). Replace the duplicated logic for the partial-failure result, completed evaluator result, and overall report summary with calls to this helper, while preserving each result’s existing tokenUsage assignment and cost calculation behavior.core/src/browser.ts (1)
72-78: 🧹 Nitpick | 🔵 TrivialConfirm the vendored price table's size impact on the extension bundle.
These new exports pull the full vendored price table (over 100 rows, per pricing.test.ts) into the browser-safe entry point used by the Chrome MV3 extension. Confirm the resulting bundle size increase is acceptable for the extension's size constraints, since MV3 extensions can have stricter packaging limits than a Node CLI.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@core/src/browser.ts` around lines 72 - 78, Measure the Chrome MV3 extension bundle after adding the pricing exports from estimateRunCost, formatUsd, lookupPrice, and PRICE_TABLE_VERSION, including the vendored PRICE_TABLE. Verify the size increase remains within the extension’s packaging constraints; if it does not, avoid exposing the full price table through the browser entry point while preserving the required pricing API.core/src/pricing/lookupPrice.ts (1)
51-58: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd registry-sourced coverage to the pricing guard.
providerAcceptstreats an unlistedusage.providerthe same asopenai-compatibleand accepts any price table key, so newTokenTrackerusage can be priced even without an alias entry. DeriveVENDORED_LITELLM_PROVIDERSfromproviderRegistryData/ alias validation or addunknownto the alias map so unattributed usage cannot match priced rows.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@core/src/pricing/lookupPrice.ts` around lines 51 - 58, Update providerAccepts to distinguish unknown providers from explicitly unverifiable providers: derive the recognized provider set from providerRegistryData/alias validation, or add an explicit unknown entry in LITELLM_PROVIDER_ALIASES, and reject unlisted providers so unattributed usage cannot match priced rows while preserving openai-compatible behavior.core/src/execute/evaluatorLoop.ts (1)
251-255: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract a shared helper for attaching token usage, per-model breakdown, and cost. All three sites repeat the identical three-line pattern — read a tracker's
totalsandbreakdown, then callestimateRunCost— to populate a result or summary object. The shared root cause is the lack of one helper function; each site currently must be kept in sync by hand.
core/src/execute/evaluatorLoop.ts#L251-L255: replace the inline block with a call to a shared helper, e.g.applyCostAndUsage(evResult, evalTracker).core/src/execute/evaluatorLoop.ts#L230-L234: replace the inline block with the same shared helper, e.g.applyCostAndUsage(partialResult, evalTracker).core/src/execute/runAll.ts#L145-L150: replace the inline block with the same shared helper, e.g.applyCostAndUsage(report.summary, tokenTracker), and let the helper own the "only set fields when usage exists" guard so all three sites share one consistent zero-usage rule.♻️ Proposed shared helper
// e.g. in core/src/execute/tokenTracker.ts or core/src/pricing/estimateCost.ts export function applyCostAndUsage< T extends { tokenUsage?: TokenUsage; tokenUsageByModel?: ModelTokenUsage[]; cost?: RunCost }, >(target: T, tracker?: TokenTracker): void { if (!tracker) return; const totals = tracker.totals; if (totals.totalTokens <= 0) return; target.tokenUsage = totals; target.tokenUsageByModel = tracker.breakdown; target.cost = estimateRunCost(target.tokenUsageByModel); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@core/src/execute/evaluatorLoop.ts` around lines 251 - 255, Extract and export a shared applyCostAndUsage helper that accepts a result/summary target and optional tracker, returns without changes when no tracker or total usage exists, and otherwise assigns totals, per-model breakdown, and estimated cost. Replace the inline blocks at core/src/execute/evaluatorLoop.ts lines 230-234 and 251-255, and core/src/execute/runAll.ts lines 145-150, with calls to this helper using partialResult, evResult, and report.summary respectively.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@core/src/report/render.ts`:
- Around line 527-535: The cost displays in core/src/report/render.ts require
the same incomplete-cost handling: at lines 527-535, update the
executive-summary Testing Cost card to show “Unpriced” when
summary.cost.complete is false and totalUsd is zero, use a ≥ prefix for non-zero
partial lower bounds, and retain ≈ only for complete totals; apply the
equivalent branching at line 242 for the evaluator-header cost span. Use the
existing summary.cost fields and formatUsd path without changing complete,
non-zero behavior.
In `@scripts/build-pricing.ts`:
- Around line 157-159: Validate the parsed upstream price map in the
build-pricing flow before passing it to prune(). Define or reuse a Zod schema
for the expected map and row structure, parse the JSON value through that schema
instead of relying on the Record cast, and surface malformed rows with an
actionable validation error.
---
Nitpick comments:
In `@core/src/browser.ts`:
- Around line 72-78: Measure the Chrome MV3 extension bundle after adding the
pricing exports from estimateRunCost, formatUsd, lookupPrice, and
PRICE_TABLE_VERSION, including the vendored PRICE_TABLE. Verify the size
increase remains within the extension’s packaging constraints; if it does not,
avoid exposing the full price table through the browser entry point while
preserving the required pricing API.
In `@core/src/execute/evaluatorLoop.ts`:
- Around line 251-255: Extract and export a shared applyCostAndUsage helper that
accepts a result/summary target and optional tracker, returns without changes
when no tracker or total usage exists, and otherwise assigns totals, per-model
breakdown, and estimated cost. Replace the inline blocks at
core/src/execute/evaluatorLoop.ts lines 230-234 and 251-255, and
core/src/execute/runAll.ts lines 145-150, with calls to this helper using
partialResult, evResult, and report.summary respectively.
In `@core/src/execute/runAllBrowser.ts`:
- Around line 192-195: In the runAllBrowser function, extract the repeated
tokenUsageByModel and cost assignment into a shared helper such as
attachUsageAndCost(target, tracker). Replace the duplicated logic for the
partial-failure result, completed evaluator result, and overall report summary
with calls to this helper, while preserving each result’s existing tokenUsage
assignment and cost calculation behavior.
In `@core/src/pricing/lookupPrice.ts`:
- Around line 51-58: Update providerAccepts to distinguish unknown providers
from explicitly unverifiable providers: derive the recognized provider set from
providerRegistryData/alias validation, or add an explicit unknown entry in
LITELLM_PROVIDER_ALIASES, and reject unlisted providers so unattributed usage
cannot match priced rows while preserving openai-compatible behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5ccfe88f-beee-4796-b2ce-203d96a81c45
⛔ Files ignored due to path filters (1)
core/src/pricing/priceTable.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (28)
core/package.jsoncore/src/browser.tscore/src/evaluators/judge.tscore/src/execute/evaluatorLoop.tscore/src/execute/runAll.tscore/src/execute/runAllBrowser.tscore/src/execute/tokenTracker.tscore/src/execute/types.tscore/src/generate/generateAttacks.tscore/src/generate/generateNextTurn.tscore/src/lib/llmRetry.tscore/src/llm/openaiCompatible.tscore/src/pricing/estimateCost.tscore/src/pricing/lookupPrice.tscore/src/pricing/providerAliases.tscore/src/pricing/types.tscore/src/providers/factory.tscore/src/providers/modelIdentity.tscore/src/report/buildReport.tscore/src/report/render.tscore/src/report/types.tscore/src/run/judge.tscore/tests/modelIdentity.test.tscore/tests/pricing.test.tscore/tests/tokenTracker.test.tspackage.jsonrunners/cli/src/commands/run.tsscripts/build-pricing.ts
Follow-up to the review on #231. build:pricing --check was permanently broken. The generator emitted compact JSON.stringify output while the pre-commit Prettier hook reformatted the committed file, so the two never matched: --check reported "stale" even straight after regenerating, and the "unchanged -> no-op" fast path never fired, turning every run into a 557-line diff. The script now formats its output with the repo's Prettier config before comparing or writing. MCP baseline scans judged every tool description and resource without a token tracker, so their spend was invisible. That understated cost on every MCP run while `complete: true` claimed full coverage — the tokens never arrived, so there was no bucket to flag as unpriced. runAll now threads the run tracker into runBaselineScans. Also: - RunCost.complete documents that it means "nothing we saw was unpriced", not "nothing was missed" — a call site that reports no usage produces no bucket to flag. - costSubLabel labels a role-less bucket "unknown" rather than "mixed", which implied several phases where there were none. - Drop the PRICE_TABLE_VERSION re-export from lookupPrice; importers now take it from the module that owns it (AGENTS.md: no barrel re-exports). - Cover roleFromContext against the literal context labels used at call sites, so renaming a log string can't silently drop the role split. - Add the missing end-to-end test: createModel -> withRetry -> correct bucket, exercising the WeakMap identity registry that every other test bypasses with hand-built identities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b5ce1e5 to
69aa369
Compare
Addresses CodeRabbit's review on #231. totalUsd sums only the models that could be priced, so a run where none of them could legitimately totals 0 — and formatUsd(0) is "$0.00". Both cost displays called it unconditionally, so a run with entirely unknown cost rendered identically to one that genuinely cost nothing. That is the exact failure the pricing module documents itself as preventing; the sub-label said "0 of 1 models priced" while the headline number said free. formatCostDisplay now distinguishes all three cases: "≈" for a complete estimate, "≥" for a partial total that is a real floor, and "unpriced" when nothing could be priced at all. Applied to the executive-summary cost card and the per-evaluator span, with regression tests covering each case — including that a genuinely free run may still show $0.00. Also validate the upstream price map with Zod instead of casting JSON.parse output (AGENTS.md: Zod for all external input). Rows are parsed individually so one malformed entry cannot fail the build, and skipped rows are reported rather than folded into the drop count; a wholesale format change now fails loudly instead of silently producing an empty table. Output is byte-identical — same 557 entries, same version hash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the review on #231. build:pricing --check was permanently broken. The generator emitted compact JSON.stringify output while the pre-commit Prettier hook reformatted the committed file, so the two never matched: --check reported "stale" even straight after regenerating, and the "unchanged -> no-op" fast path never fired, turning every run into a 557-line diff. The script now formats its output with the repo's Prettier config before comparing or writing. MCP baseline scans judged every tool description and resource without a token tracker, so their spend was invisible. That understated cost on every MCP run while `complete: true` claimed full coverage — the tokens never arrived, so there was no bucket to flag as unpriced. runAll now threads the run tracker into runBaselineScans. Also: - RunCost.complete documents that it means "nothing we saw was unpriced", not "nothing was missed" — a call site that reports no usage produces no bucket to flag. - costSubLabel labels a role-less bucket "unknown" rather than "mixed", which implied several phases where there were none. - Drop the PRICE_TABLE_VERSION re-export from lookupPrice; importers now take it from the module that owns it (AGENTS.md: no barrel re-exports). - Cover roleFromContext against the literal context labels used at call sites, so renaming a log string can't silently drop the role split. - Add the missing end-to-end test: createModel -> withRetry -> correct bucket, exercising the WeakMap identity registry that every other test bypasses with hand-built identities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses CodeRabbit's review on #231. totalUsd sums only the models that could be priced, so a run where none of them could legitimately totals 0 — and formatUsd(0) is "$0.00". Both cost displays called it unconditionally, so a run with entirely unknown cost rendered identically to one that genuinely cost nothing. That is the exact failure the pricing module documents itself as preventing; the sub-label said "0 of 1 models priced" while the headline number said free. formatCostDisplay now distinguishes all three cases: "≈" for a complete estimate, "≥" for a partial total that is a real floor, and "unpriced" when nothing could be priced at all. Applied to the executive-summary cost card and the per-evaluator span, with regression tests covering each case — including that a genuinely free run may still show $0.00. Also validate the upstream price map with Zod instead of casting JSON.parse output (AGENTS.md: Zod for all external input). Rows are parsed individually so one malformed entry cannot fail the build, and skipped rows are reported rather than folded into the drop count; a wholesale format change now fails loudly instead of silently producing an empty table. Output is byte-identical — same 557 entries, same version hash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bf46049 to
0e5c360
Compare
Reports tracked total tokens but had no way to turn that into a cost. A single combined total is unpriceable in principle: a run can use a different model for the judge than the attacker, and their rates differ by more than an order of magnitude. Two parts: 1. Attribute tokens to models. TokenTracker now keeps a per-model breakdown alongside the existing flat totals, which are unchanged. Built AI SDK models no longer carry the configured provider name (openai-compatible surfaces as "custom.chat"), so createModel records identity in a WeakMap that recording sites resolve from the model object they already hold — no new parameters through call chains. Run phase is derived from the context label withRetry already takes. 2. Price it. scripts/build-pricing.ts prunes LiteLLM's community price map to the providers opfor can reach and vendors it as an inlined module (557 entries, 43 KB, no Node imports, so the extension bundles it). Lookup tries a short ladder of model-name forms, then verifies the matched row belongs to the configured provider — without that guard a mistyped Groq model would silently price at OpenAI rates. Vendored rather than fetched at runtime: opfor runs in air-gapped CI, and a security report should produce the same numbers when re-run later. Unpriceable models are reported as such, never counted as free; the report and CLI both mark the total a lower bound and name what was missed. Estimates were validated against a LiteLLM proxy's own billed cost across three vendors (0.0% delta). Labelled "Testing cost" throughout — the target's own inference spend is not observable from opfor and is excluded. Repeat (cached) input tokens are not yet counted separately, so multi-turn runs against providers that discount repeated context are over-estimated. Left for a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the review on #231. build:pricing --check was permanently broken. The generator emitted compact JSON.stringify output while the pre-commit Prettier hook reformatted the committed file, so the two never matched: --check reported "stale" even straight after regenerating, and the "unchanged -> no-op" fast path never fired, turning every run into a 557-line diff. The script now formats its output with the repo's Prettier config before comparing or writing. MCP baseline scans judged every tool description and resource without a token tracker, so their spend was invisible. That understated cost on every MCP run while `complete: true` claimed full coverage — the tokens never arrived, so there was no bucket to flag as unpriced. runAll now threads the run tracker into runBaselineScans. Also: - RunCost.complete documents that it means "nothing we saw was unpriced", not "nothing was missed" — a call site that reports no usage produces no bucket to flag. - costSubLabel labels a role-less bucket "unknown" rather than "mixed", which implied several phases where there were none. - Drop the PRICE_TABLE_VERSION re-export from lookupPrice; importers now take it from the module that owns it (AGENTS.md: no barrel re-exports). - Cover roleFromContext against the literal context labels used at call sites, so renaming a log string can't silently drop the role split. - Add the missing end-to-end test: createModel -> withRetry -> correct bucket, exercising the WeakMap identity registry that every other test bypasses with hand-built identities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses CodeRabbit's review on #231. totalUsd sums only the models that could be priced, so a run where none of them could legitimately totals 0 — and formatUsd(0) is "$0.00". Both cost displays called it unconditionally, so a run with entirely unknown cost rendered identically to one that genuinely cost nothing. That is the exact failure the pricing module documents itself as preventing; the sub-label said "0 of 1 models priced" while the headline number said free. formatCostDisplay now distinguishes all three cases: "≈" for a complete estimate, "≥" for a partial total that is a real floor, and "unpriced" when nothing could be priced at all. Applied to the executive-summary cost card and the per-evaluator span, with regression tests covering each case — including that a genuinely free run may still show $0.00. Also validate the upstream price map with Zod instead of casting JSON.parse output (AGENTS.md: Zod for all external input). Rows are parsed individually so one malformed entry cannot fail the build, and skipped rows are reported rather than folded into the drop count; a wholesale format change now fails loudly instead of silently producing an empty table. Output is byte-identical — same 557 entries, same version hash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cost precision now scales in three bands rather than two. Between a cent and a dime the third decimal is what distinguishes one evaluator from another — $0.037 vs $0.049 is a third more expensive, but both round to the same two-decimal figure — so that band keeps it. From a dime up the precision is noise, so a run total reads as money ($0.18, not $0.177). Sub-cent amounts keep two significant figures as before, since a fixed decimal count renders them "$0.0000" and they read as free. docs/cli.md said "No cost estimation is performed", which this feature made false. That section is now "Token usage and testing cost" and covers the per-model split, where prices come from, and the accuracy caveats — chiefly that multi-turn runs read high, because providers discount repeated context and opfor prices every input token at full rate. Users budgeting off this number should know it is conservative. Also documents that "testing cost" is opfor's own spend and excludes the target's inference cost, and that an unpriced model is never counted as free. Same summary added to the README and referenced from the browser extension guide. Note: the SDK's report type exposes neither tokenUsage nor cost. That gap predates this work and is left alone here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f2bc2cb to
7457ccd
Compare
Decorating every figure with ≈ added noise to the common case. That a total is a list-price estimate is already stated in the cost card's tooltip and in the docs, so a complete total now renders plain ($0.18). The other two markers stay, because there the number alone would mislead: ≥ says the real total is higher than shown, and an unpriced run has no number worth printing at all. Applied to the HTML report and the CLI summary; docs and README samples updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@core/src/pricing/types.ts`:
- Around line 56-65: Update the RunCost completeness contract and its
calculation so complete is true only when every expected call reported usage and
every reported model was priced; otherwise mark the result as incomplete. Ensure
formatCostDisplay in core/src/report/render.ts renders the total with ≥,
including for runs with omitted usage or zero recorded tokens, and add
regression coverage for both cases.
In `@docs/browser-extension.md`:
- Line 91: Update the “Token usage and testing cost” documentation to remove the
claim that the target has nothing to bill. State instead that target-side
inference costs are excluded from Opfor’s measurements and are not observable or
metered by Opfor, while preserving the explanation of attacker and judge LLM
costs.
- Line 91: Update the cost description near “Token usage and testing cost” to
accurately state that the configured LLM is used for both attacker and judge
roles, replacing the claim that separate attacker and judge LLMs are configured.
In `@README.md`:
- Line 116: Update README.md lines 116-116 to avoid claiming every run reports
complete cost; update docs/cli.md lines 220-220 to scope cost reporting to
instrumented LLM calls; update README.md lines 130-130 to mention unrecorded
calls as another lower-bound condition; and update docs/cli.md lines 249-249 to
state that incomplete usage capture can make summary.cost.totalUsd a lower
bound.
- Line 118: Update the opening output fences to specify the text language:
change the fence at README.md lines 118-118 and docs/cli.md lines 222-222 to use
the text identifier, preserving the existing output content.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ab162b8c-832e-4738-9ec0-69758a051cfb
⛔ Files ignored due to path filters (1)
core/src/pricing/priceTable.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (32)
README.mdcore/package.jsoncore/src/browser.tscore/src/evaluators/judge.tscore/src/execute/baselineScanner.tscore/src/execute/evaluatorLoop.tscore/src/execute/runAll.tscore/src/execute/runAllBrowser.tscore/src/execute/tokenTracker.tscore/src/execute/types.tscore/src/generate/generateAttacks.tscore/src/generate/generateNextTurn.tscore/src/lib/llmRetry.tscore/src/llm/openaiCompatible.tscore/src/pricing/estimateCost.tscore/src/pricing/lookupPrice.tscore/src/pricing/providerAliases.tscore/src/pricing/types.tscore/src/providers/factory.tscore/src/providers/modelIdentity.tscore/src/report/buildReport.tscore/src/report/render.tscore/src/report/types.tscore/src/run/judge.tscore/tests/modelIdentity.test.tscore/tests/pricing.test.tscore/tests/tokenTracker.test.tsdocs/browser-extension.mddocs/cli.mdpackage.jsonrunners/cli/src/commands/run.tsscripts/build-pricing.ts
🚧 Files skipped from review as they are similar to previous changes (28)
- core/package.json
- package.json
- core/src/run/judge.ts
- core/src/generate/generateNextTurn.ts
- core/src/execute/baselineScanner.ts
- core/src/evaluators/judge.ts
- core/src/execute/runAll.ts
- core/src/providers/factory.ts
- core/src/browser.ts
- core/src/execute/runAllBrowser.ts
- core/tests/tokenTracker.test.ts
- core/src/providers/modelIdentity.ts
- scripts/build-pricing.ts
- core/src/pricing/estimateCost.ts
- core/tests/modelIdentity.test.ts
- core/src/llm/openaiCompatible.ts
- core/src/generate/generateAttacks.ts
- core/src/report/buildReport.ts
- core/src/pricing/providerAliases.ts
- core/src/lib/llmRetry.ts
- runners/cli/src/commands/run.ts
- core/src/pricing/lookupPrice.ts
- core/src/execute/tokenTracker.ts
- core/src/execute/types.ts
- core/tests/pricing.test.ts
- core/src/execute/evaluatorLoop.ts
- core/src/report/render.ts
- core/src/report/types.ts
Both were introduced by the previous commit and flagged in review. "Every run reports what it cost to run" is not true: trace curation, session summarisation and generateJsonObject record no token usage, so their spend never reaches the total. The wording now says *instrumented* LLM calls, and unrecorded calls are listed alongside unpriced models as a reason the figure is a floor. docs/cli.md also spells out that summary.cost.complete reports only that every model opfor saw was priced — it cannot vouch for calls that reported nothing. The extension guide said there was "nothing to bill for the target itself". Driving a chat UI through the browser means opfor is not billed for it, but somebody is — that cost is simply invisible to us. Reworded to say target-side inference is excluded and unobservable. Also tags both sample-output fences as `text` for markdownlint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem
Reports track total token usage but there is no way to turn that into a cost — the number people actually want to see.
The blocker was not the price data.
TokenTrackerkept one combined total for the whole run, with no record of which model spent what. That is unpriceable in principle:judgeLlmcan differ fromattackerLlm, and their rates differ by more than an order of magnitude. The same 100k tokens could be 3 cents or 60 cents depending on a split we were not recording.There is also a second, unrelated price table already in the tree — a hardcoded Claude-only map in
core/src/autonomous/lib/budget.tsused byopfor hunt. That one is correct for hunt (it runs Claude models through the Claude SDK) and is deliberately left alone.Solution
1. Attribute tokens to models.
TokenTrackernow keeps a per-model breakdown alongside the existing flat totals.totalsis unchanged in behaviour, so the CLI summary, HTML report and extension popup keep working untouched.The awkward part: a built AI SDK model no longer knows the provider it was configured with —
openai-compatiblereports itself ascustom.chat, which is useless for a price lookup. Rather than threadLlmConfigthrough a dozen signatures,createModelrecords identity in aWeakMapthat every recording site resolves from the model object it already holds. Run phase (attacker/judge) is derived from thecontextlabelwithRetryalready accepted for logging.2. Price it.
scripts/build-pricing.tsprunes LiteLLM's community price map (2,986 entries) to the providers opfor can reach and vendors it as an inlined module — 557 entries, 43 KB, no Node imports so the extension bundles it.Lookup tries a short ladder of model-name forms (
azure/gpt-4o-mini→gpt-4o-mini→ prefix-stripped), then verifies the matched row belongs to the configured provider. Without that guard a mistyped Groq model silently matches OpenAI's row and gets priced at OpenAI's rates — a confidently wrong number, which is worse in a security report than no number.Design decisions worth reviewing
build:pricing --checkis not a blocking CI gate. Unlikebuild:catalog:check(stale only when this repo changes), this table goes stale when a third party reprices a model. Gating merges on that lets outside events red the build. Run it on a schedule and open a PR instead.Changes
core— newsrc/pricing/types.ts—ModelPrice,ModelCost,RunCostproviderAliases.ts— opfor provider →litellm_providernames; single source of truth shared by the generator and the lookup, so the table cannot contain rows the lookup will never acceptpriceTable.generated.ts— vendored table (generated)lookupPrice.ts— candidate ladder + provider guardestimateCost.ts—estimateRunCost,formatUsdcore— modifiedexecute/tokenTracker.ts— per-model breakdown;totalsunchangedproviders/modelIdentity.ts(new) +providers/factory.ts— identity registrylib/llmRetry.ts—modelonRetryOptions;roleFromContextevaluators/judge.ts,generate/generateAttacks.ts,generate/generateNextTurn.ts,llm/openaiCompatible.ts,run/judge.ts— attribute usage at each recording siteexecute/{types,runAll,runAllBrowser,evaluatorLoop}.ts,report/{types,buildReport,render}.ts— carry and render costbrowser.ts,package.json— export the pricing surfacerunners/cli—commands/run.tsprints testing cost with the per-model splitroot —
scripts/build-pricing.ts,npm run build:pricingtests —
pricing.test.ts(new),modelIdentity.test.ts(new),tokenTracker.test.ts(extended). 50 new tests; 238 total, 0 failures.Review fixes (second commit)
Rebased onto
masterafter #230 — the only conflict wasrender.ts, where #230 rewrote the exec strip this PR adds the cost card to. Both changes kept.build:pricing --checkwas permanently broken. The generator emitted compactJSON.stringifyoutput while the pre-commit Prettier hook reformatted the committed file, so the two never matched.--checkreported "stale" even immediately after regenerating, and the documented "unchanged → no-op" fast path never fired — every regeneration produced a 557-line diff. The script now formats its output with the repo's Prettier config before comparing or writing. Both paths verified working.complete: trueclaimed full coverage (no tokens arrived, so nothing could be flagged unpriced).runAllnow threads the run tracker throughrunBaselineScans.RunCost.completenow documents that it means "nothing we saw was unpriced", not "nothing was missed".costSubLabellabels a role-less bucketunknownrather thanmixed.PRICE_TABLE_VERSIONre-export fromlookupPrice(AGENTS.md: no barrel re-exports).roleFromContexttests pinned to the literalcontext:labels used at call sites, so renaming a log string can't silently drop the role split.createModel→withRetry→ correct bucket, exercising the WeakMap identity registry that every other test bypasses with hand-built identities.Issue
N/A
How to test
Then any run prints cost:
Worth exercising specifically:
judgeLlmto a different (pricier) model thanattackerLlmand confirm the two are priced separately — this is the case the old combined total could not express.attackerLlm.modelat a made-up model name and confirm the run reports it as not priced and marks the total a lower bound, rather than reporting $0.Screenshots
CLI output from a verification run (attacker and judge on different models):
Accuracy check — estimates vs what a LiteLLM proxy actually billed, across three vendors:
deepseek/deepseek-v4-flashanthropic/claude-haiku-4-5openai/gpt-4o-miniKnown limitations
telemetry/curation.ts×2,lib/summarizeSessionContext.ts,lib/generateJsonObject.ts). Pre-existing, not introduced here, but it propagates into cost as a slight undercount for runs using telemetry curation. This is whycomplete: trueis documented as the narrower claim. Worth a follow-up. (The MCP baseline-scan gap in this same class is fixed here.)anthropicprovider (claude-3-5-haiku-20241022) has been retired upstream and has no direct Anthropic price, so it reports as unpriced. Unrelated to this PR — that default is about two generations old and probably wants bumping regardless.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests