Repository navigation
feat(tui): estimate session spend from catalog prices when the provider reports $0 - #59864
simonsilie wants to merge 3 commits into
Conversation
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
There was a problem hiding this comment.
The estimate works as described. With catalog prices on the simulated model and a reported cost: 0, the base shows no money figure, while this PR shows ≈ $X spent in the sidebar and ≈ $X in the footer. The reported cost still wins when it is above 0. The three touched test files pass (13 tests) and tsc -b in packages/tui is clean.
Public API: adds the TUI config key experimental["cost-estimate"] (boolean, opt-in, listed in the Experiments dialog). This is backwards compatible: experimental is already a string-to-boolean record. No SDK, server or plugin API changes.
Concerns:
- Subagent sessions show the whole family's estimate.
session.cost()returns only the child's own cost when the session has aparentID. The estimate always sumsfamily(), which resolves to the root, so a subagent view would show the root's family total. See the inline comments. - The pricing math is copied from core's
SessionUsage.calculateCost. The copy has already dropped core'sfinite()guard for malformed catalog rates (#43248). Tier or rate changes in core will not reach the TUI. The client package cannot import from core, but moving the function into@opencode/schema(next to theModelcost schema) would let core and the TUI share one implementation. Otherwise, at least keep thefinite()guard.
| const data = props.context.data.session | ||
| const models = props.context.data.location.model.list(session()?.location) | ||
| return costEstimate( | ||
| data.family(props.sessionID).map((id) => data.get(id)).filter((info) => info !== undefined), |
There was a problem hiding this comment.
data.family() resolves to the root's family, but data.session.cost() returns only the session's own cost when it has a parentID. So on a subagent session this estimate covers the root and every sibling, while the reported figure it stands in for covers only that subagent. To match cost(), estimate just [session()] when session()?.parentID is set.
There was a problem hiding this comment.
Fixed in b1bcc8a. The sidebar now estimates only the selected session when it has a parentID, while root sessions retain the family total. Regression tests cover a child with its own usage and a zero-usage child, with parent and sibling usage present in both cases.
| const estimate = enabled() | ||
| ? costEstimate( | ||
| props.context.data.session | ||
| .family(props.sessionID) |
There was a problem hiding this comment.
Same mismatch as in the sidebar: for a child session, cost(props.sessionID) is the child's own cost, but this sums the whole family resolved from the root. A shared helper next to costEstimate that follows cost()'s root/child rule would keep the two call sites consistent.
There was a problem hiding this comment.
Fixed in b1bcc8a. The footer now follows the same root/child rule as session.cost(): family totals for a root, only the selected session for a child. The parameterized renderer test covers root totals, child-only estimates, and a child with zero usage.
| * authoritative (a reported zero is intentional, e.g. subscription billing), so this only fills the | ||
| * display gap for sessions whose recorded cost is 0. Tokens price at each session's latest model. | ||
| */ | ||
| export function costEstimate(sessions: ReadonlyArray<SessionInfo>, models: ReadonlyArray<ModelInfo> | undefined) { |
There was a problem hiding this comment.
This copies core's SessionUsage.calculateCost but drops its finite() guard on catalog rates (added for malformed model costs in #43248), and it will drift from core when tiering changes. Since @opencode/client and the TUI can't depend on core, consider moving the calculation into @opencode/schema beside the Model cost schema so both sides share one implementation.
There was a problem hiding this comment.
The duplication is still present; b1bcc8a fixes per-call tier selection and subagent scope, but does not extract the pricing calculation or add the finite() guards. One distinction is that Model.Cost rates use Money.USDPerMillionTokens, which is backed by Schema.Finite, so malformed rates should be rejected at the catalog/API boundary rather than reaching this helper. That does not address the drift concern. I have kept the shared-calculation refactor out of this TUI change for now rather than expanding the schema/core surface, and am leaving this concern outstanding rather than claiming it was fixed.
There was a problem hiding this comment.
The subagent scope is fixed: in both the sidebar and the footer, a child session now estimates only itself, matching session.cost(). The flat-price path still works (≈ $0.90 spent with a reported cost: 0).
The new per-call tier handling hides the estimate for tiered models in practice. With the same simulated model plus a 200k context tier, nothing is shown after a single exchange. Title generation (and compaction) add usage to session.tokens without a matching assistant message, so the completeness check fails as soon as a title has been generated. On root views, one subagent whose messages aren't loaded, or don't add up, also removes the estimate for the whole family. See the inline comment.
The point about the copied pricing math (the missing finite() guard, drift from core) still stands.
Public API: unchanged since the last review. It only adds the opt-in TUI config key experimental["cost-estimate"] (backwards compatible).
| ? messages(session.id).flatMap((message) => ("tokens" in message && message.tokens ? [message.tokens] : [])) | ||
| : [session.tokens] | ||
| // Lifetime totals cannot select per-call tiers. Paginated or unloaded history must not produce a partial estimate. | ||
| if (usages.reduce((sum, tokens) => sum + TokenUsage.total(tokens), 0) !== TokenUsage.total(session.tokens)) return |
There was a problem hiding this comment.
This equality rarely holds in real sessions. Core adds title-generation usage (SessionTitle → recordUsage) and compaction usage to session.tokens, and neither has an assistant message, so after the first exchange the summed message tokens are less than the session total. With a tiered model I get no estimate at all after one prompt. Returning undefined also short-circuits the whole family reduce, so on a root view one child whose messages aren't loaded hides the estimate for everyone. One option is to price the per-message usages by tier and the leftover (session.tokens minus the message sum) at the untiered rate. Another is to fall back to the session total rather than omit the estimate.
There was a problem hiding this comment.
Fixed in ad549b3. The estimate no longer requires message usage to equal session totals. It starts with aggregate usage at untiered rates, then replaces the base charge with the matching tier charge for each loaded assistant call. Title usage, aggregated compactions, and unloaded history stay at base rates, so one unloaded child no longer suppresses the family estimate. Compaction totals are not treated as single long-context calls. Re-review also caught inherited fork messages adding phantom surcharges; those are now excluded from tier adjustments.
Added deterministic regression coverage for these cases, tier/cache boundaries, mixed providers, free models, and reactive UI updates. All 51 focused tests passed ten randomized runs with a fixed seed; package typecheck:tsc and the root check pass. The full TUI run has two failures in unchanged Go-art/spinner rendering tests, both reproduced in isolation; those files were left untouched.
There was a problem hiding this comment.
The tiered-model issue is fixed. With the simulated model priced with a 200k context tier and reporting cost: 0, the sidebar and footer now show ≈ $0.90 where the previous head showed nothing. The session total is priced at the untiered rate and tier surcharges are added per loaded reply, so title, compaction and unloaded usage no longer hide the estimate. The subagent scope fix from the previous round still holds. The three touched test files pass (51 tests) and tsc -b in packages/tui is clean.
Public API: unchanged since the last review. It only adds the opt-in TUI config key experimental["cost-estimate"] (backwards compatible).
Still open: the pricing math is a copy of core's calculateCost without its finite() guard; the new tokenCost helper carries the same copy.
New: the tests have grown to about 700 lines for about 100 lines of code (16 cases in session.test.ts, 10 in sidebar-context.test.tsx, several test.each matrices, including a width-parametrised reactivity test). Please cut them down to the core cases: flat price, tier surcharge with partial history, child-only scope, reported cost wins, and experiment off. Small costEstimate unit tests cover the rest more cheaply than renderer tests.
|
Addressed the latest review in 40efeba. Reduced the three TUI test files from 51 cases to 15, removing about 390 net lines. Coverage retains flat pricing, tier surcharges with partial history, child-only scope, reported-cost precedence, and the experiment-off behavior. Partial history, compaction, unloaded children, and inherited fork usage are covered by small Moved the pricing calculation into Validation: all 15 focused TUI tests passed 10 randomized runs (seed 59864); the three existing core pricing tests, including malformed rates, passed; TUI |
There was a problem hiding this comment.
The duplicated pricing math is resolved. Model.calculateCost in @opencode/schema now holds the single implementation, with the finite() guard. Core's SessionUsage.calculateCost re-exports it and the TUI estimate uses it, so the tier surcharge is just the difference between the tiered and untiered price. The trimmed tests are a reasonable size now. I re-ran the tiered-model setup in the TUI (≈ $0.90 shown, nothing on the base), the touched TUI tests and core's session-usage/session-step tests (all pass), and tsc in tui, schema and core (clean).
Public API: adds the opt-in TUI config key experimental["cost-estimate"] and a new export, calculateCost from @opencode/schema/model. SessionUsage.calculateCost keeps its signature. Both are additions, so this is backwards compatible.
No further concerns.
There was a problem hiding this comment.
This push only merges dev; the PR's own changes are identical to the version I last reviewed. The touched TUI tests and core's session-usage tests pass on the new head, and tsc -b in packages/tui is clean.
Public API: unchanged since the last review. It adds the opt-in TUI config key experimental["cost-estimate"] and exports calculateCost from @opencode/schema/model; both are backwards compatible.
No further concerns.
8a659ac to
1a082e6
Compare
There was a problem hiding this comment.
This push rebases the change onto a newer dev as one commit. The source changes are identical to the version I last reviewed; only the footer test fixtures were adjusted for the new base. The touched TUI tests and core's session-usage tests pass, tsc is clean in tui and schema, and the TUI still shows ≈ $0.90 spent for a tiered model reporting cost: 0.
Public API: unchanged since the last review. It adds the opt-in TUI config key experimental["cost-estimate"] and exports calculateCost from @opencode/schema/model; both are backwards compatible.
No further concerns.
augustovicente
left a comment
There was a problem hiding this comment.
Rebased head 1a082e65 has the same source as the version I reviewed before (only the base moved). The math holds up against core: projectFork zeroes cost/tokens and copies parent time_created, so pricing new fork calls at their own tier and skipping inherited messages is correct, and root/child scoping matches session.cost(). No blockers.
Before merge:
-
The description is stale. It still says "51 focused tests ... 510 passes" and claims mixed-provider coverage, but after the trim the three files hold ~16 cases (5 in
util/session.test.ts, 6 insidebar-context.test.tsx, 5 inprompt-footer.test.tsx), and the only provider case asserts an unmatched provider returns 0. Please re-run the stated command and update the numbers, or drop the claim. -
Coverage gaps worth closing at unit level: a model with more than one context tier, a family containing two differently-priced models, and footer cases for experiment-off and reported-cost-wins (the sidebar already has both).
-
Scope: mini/run mode doesn't get the estimate. Its footer only reads
usage.costfrom the per-step event, andRunTuiConfigdoesn't exposeexperimental. I think mini should get it too (see inline).
Nits, not blocking:
costEstimatereturns a barenumberwhile the rest of the spend path usesMoney.USD; accumulating branded money would keep the unit explicit at the call sites.Model.calculateCostis now permanent public API of a published package for a temporary TUI experiment. Fine if intentional — just worth calling out.
| (candidate) => | ||
| session.model && candidate.providerID === session.model.providerID && candidate.id === session.model.id, | ||
| ) | ||
| if (!model) return total |
There was a problem hiding this comment.
When a session's model isn't in the catalog this drops it from the total and still renders ≈ $X. For a root the caller passes the whole family, so a single child on a custom or removed model silently under-reports the family estimate while the ≈ implies the figure is complete. Consider returning undefined and rendering nothing when any session in scope is unpriced, or state explicitly that only catalog-priced sessions are included.
There was a problem hiding this comment.
Fixed in 169fe04. costEstimate now returns undefined if any scoped session has no matching catalog model or has an empty price list, and both views omit the estimate. Regression coverage includes a priced root with an unpriced child; explicitly free models still return zero.
| const spent = createMemo(() => { | ||
| if (cost() > 0) return `${money.format(cost())} spent` | ||
| // The tilde marks the catalog-price figure as an estimate; the recorded cost stays authoritative. | ||
| return estimate() > 0 ? `≈ ${money.format(estimate())} spent` : undefined |
There was a problem hiding this comment.
estimate > 0 still renders ≈ $0.00 spent for sub-cent sessions (anything under ~$0.005), so an active session can look free. Same path in the footer (prompt/footer.tsx:63). Worth a floor — <$0.01, or hide below a cent.
There was a problem hiding this comment.
Fixed in 169fe04. Positive estimates below one cent now display ≈ <$0.01 in both the sidebar and prompt footer, with renderer coverage for each. Reported costs keep their existing formatting.
| session.revert?.messageID, | ||
| ) | ||
| const cost = props.context.data.session.cost(props.sessionID) | ||
| const estimate = enabled() |
There was a problem hiding this comment.
The estimate is computed before the cost > 0 check on line 63, so every root with a reported cost now walks the family — and for tiered models, every message — on each status tick, only to throw the result away. Moving it behind cost > 0 matches the sidebar, where the memo is only read on the else branch.
There was a problem hiding this comment.
Fixed in 169fe04. The footer skips estimation when reported cost is positive. The sidebar now has the same explicit guard: Solid memos evaluate eagerly, so only conditionally reading the memo did not prevent that work. The footer regression fixture rejects family-session reads when the estimate is unused.
| { | ||
| id: "cost-estimate", | ||
| title: "Cost estimate", | ||
| description: "Estimate session spend from model catalog prices when the provider reports $0", | ||
| }, |
There was a problem hiding this comment.
With the experiment on, mini/run mode still shows nothing for providers that report $0. mini/footer.view.tsx:217 computes cost from usage()?.cost, which comes from the per-step session.step.ended event (stream-v2.transport.ts maps event.data.cost || undefined), so a reported zero is indistinguishable from "cost not reported" and the line disappears. I think mini should get the same estimate as the main footer — otherwise the feature is invisible there.
Two things it would need that aren't there today:
- The flag isn't exposed to mini:
RunTuiConfiginmini/types.tspicks a fixed set of config fields and doesn't includeexperimental, so the experiment reads as off. state.usageis per-step, not session totals. The estimate needs the session's accumulated tokens/model (asession.get, which the runtime already does elsewhere); per-call tiers would also need the loaded messages. And decide whether mini estimates just the current session or the family — the main footer gets the family for free from the client store, while mini would have to list children.
There was a problem hiding this comment.
Keeping mini/run support outside this PR. Matching the main TUI would require accumulated session/family usage, catalog prices, experiment configuration, and a history-loading policy rather than using the existing per-step usage. The PR description now explicitly limits this experiment to the main TUI sidebar and prompt footer; mini/run remains unchanged.
| ) | ||
| } | ||
|
|
||
| const finite = (value: number) => (Number.isFinite(value) ? value : 0) |
There was a problem hiding this comment.
Nit: finite is declared after the function that uses it. It's safe today (function declaration + call-time lookup), but moving it above calculateCost avoids a TDZ trap if this module ever gains top-level evaluation.
There was a problem hiding this comment.
Leaving this unchanged. There is no top-level call before finite is initialized, and the repository convention puts supporting helpers below the exported function. A future top-level caller would need to respect initialization order, but there is no current TDZ failure to fix.
| ...pricing, | ||
| cost: [ | ||
| ...pricing.cost, | ||
| { tier: { type: "context", size: 200_000 }, input: 6, output: 22.5, cache: { read: 0.6, write: 7.5 } }, |
There was a problem hiding this comment.
The tiered fixture only has one tier, so the toSorted + "largest matching tier" path and the enclosing-tier boundary aren't exercised. A second tier (e.g. size: 400_000) would cover the selection logic that the per-call surcharge depends on.
There was a problem hiding this comment.
Added in 169fe04: the fixture now has 200k and 400k context tiers, with cases exactly at and immediately above both thresholds. The above-400k case requires selecting the largest matching tier rather than the first match.
|
@augustovicente Addressed in 169fe04 and replied to each inline thread. The PR description now reflects the current scope and verification: 26 focused cases passed ten randomized runs (260 passes), and root checks plus TUI Mini/run remains outside this PR because it needs separate accumulated session/family usage plumbing; that limitation is explicit in the description. The |
There was a problem hiding this comment.
The new commit works as described. The estimate is only computed when the recorded cost is 0, a family with any session lacking catalog pricing shows no estimate instead of a partial total, and amounts under a cent display as ≈ <$0.01. The touched TUI tests pass (26), tsc -b in packages/tui is clean, and the TUI still shows ≈ $0.90 spent for a tiered model reporting cost: 0.
Public API: unchanged since the last review. It adds the opt-in TUI config key experimental["cost-estimate"] and exports calculateCost from @opencode/schema/model; both are backwards compatible.
No further concerns.
169fe04 to
b227b3c
Compare
There was a problem hiding this comment.
This push rebases the change onto a newer dev; the PR's own changes are identical to the version I last reviewed. On the rebased head, the touched TUI tests (26) and core's session-usage tests pass, tsc -b in packages/tui is clean, and the TUI still shows ≈ $0.90 spent for a tiered model reporting cost: 0.
Public API: unchanged since the last review. It adds the opt-in TUI config key experimental["cost-estimate"] and exports calculateCost from @opencode/schema/model; both are backwards compatible.
No further concerns.
|
@augustovicente Thanks! Integrated your prepared commit via fast-forward and pushed it as 1e6f074. Updated the PR description to include mini/run mode and Closes #60653. |
There was a problem hiding this comment.
The mini/run footer addition reads correctly. Mini now gets the experimental config and the model catalog. It prices the current session from its server totals, keeps them updated from session.usage.updated and session.model.selected, and counts each session.step.ended once (keyed by event ID) for tier surcharges, all through the shared costEstimate. So a reported cost wins, an unpriced session shows no estimate, and amounts under a cent show ≈ <$0.01, matching #60653's acceptance criteria. I couldn't drive mini mode here (the drive harness only starts the full TUI), so this is checked by tests: test/mini plus the cost-estimate tests pass (458, 2 skipped), and tsc -b in packages/tui is clean. A short recording of the mini footer with the experiment on would help maintainers.
Public API: unchanged since the last review. It adds the opt-in TUI config key experimental["cost-estimate"], now also read by mini/run mode, and exports calculateCost from @opencode/schema/model; both are backwards compatible.
No further concerns.











Issue for this PR
Closes #59861
Closes #60653
Type of change
What does this PR do?
Adds an opt-in Cost estimate experiment (
experimental.cost-estimate) that displays a catalog-price estimate when the provider reports $0. Subscription-included sessions, such as OpenCode Go, can show≈ $5.25 spentwithout changing the authoritative recorded cost.Root sessions estimate the family total; subagent views estimate only the selected session, matching the existing reported-cost scope. Flat-rate models use accumulated token totals. Context-tiered models price loaded assistant calls at their matching tiers. Remaining usage—including title generation, aggregated compactions, and unloaded history—is estimated at untiered rates rather than treating lifetime totals as one long-context call. Inherited fork messages do not add tier surcharges. No extra history is fetched for the estimate.
Positive reported costs take precedence and skip estimation. If any scoped session lacks catalog pricing, the estimate is omitted rather than displaying a partial family total. Free models remain unlabelled; positive estimates below one cent display
≈ <$0.01. Known approximation: all usage in a session is still priced at its latest model.How did you verify your code works?
From
packages/tui:bun test --timeout 30000 test/util/session.test.ts test/feature-plugins/sidebar-context.test.tsx test/feature-plugins/prompt-footer.test.tsx --rerun-each 10 --randomize --seed 5986426 focused cases passed ten randomized runs with seed 59864 (260 passes, zero failures). Coverage includes flat pricing, two context tiers and their boundaries, differently-priced providers/models within a family, partial history, unloaded children, inherited fork messages, root/child scope, missing catalog pricing, sub-cent display, reported-cost precedence without unnecessary estimation, and experiment off.
Before integrating the mini/run footer,
bun run checkfrom the repository root andbun run typecheck:tscfrompackages/tuipassed.After integrating the mini/run footer from #60659:
bun test --timeout 30000 --only-failures test/mini test/util/session.test.ts test/feature-plugins/sidebar-context.test.tsx test/feature-plugins/prompt-footer.test.tsx456 tests passed, 2 skipped, zero failures.
bun run checkandbun run typecheck:tscwere rerun, but could not pass in the current local environment: the full check encounters missing dependencies in@opencode/data-core, and the TUI TypeScript build reports dependency/API errors in unchanged Core and Util files (including missing@effect/sql-sqlite-nodeand@effect/platform-node/NodeWS).This experiment applies to the main TUI sidebar, prompt footer, and mini/run footer. The mini/run footer estimates only the current session from session totals and observed assistant steps; a step's reported cost remains unchanged in both experiment modes.
Visual
Screenshots / recordings
No screenshot attached. Text preview of the sidebar output, verified by renderer assertions:
Mini/run footer with the experiment enabled:
Checklist
Added by the opencode bot (AI-generated).
Related: #59634, #46320, #59653, #45417
Public API: adds the opt-in TUI config key
experimental["cost-estimate"](also read by mini/run) and exportscalculateCostfrom@opencode/schema/model(backwards compatible)