Repository navigation
Include inner interactive-toolkit cost in session cost - #13
josephschorr wants to merge 6 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
7cd4d7c to
e0f36f8
Compare
e0f36f8 to
abf64a9
Compare
samkim
left a comment
There was a problem hiding this comment.
Review of the interactive toolkit cost changes (Claude Code /code-review, medium effort). Inline comments cover lines in the diff. Three issues sit on lines outside the diff:
1. The cost stamp's "keep the higher total" check now compares totals that mix model and tool cost (pkg/agent/runner/status.go:197). The per-tool costs live only in the pod's memory, so this can fail in two ways:
- Tool spend wiped: pod A stamps $1 of model cost plus $5 of tool cost ($6). After idle, reap and wake, pod B spends $7 on the model alone and stamps $7. That replaces the record and clears
ByTool, so the $5 of sub-agent spend disappears from status and admind. - Priced breakdown blocked: an earlier unpriced stamp of $5 in tool cost alone (
PricingKnown=false) blocks a later priced $3 stamp, so the priced breakdown is never recorded.
2. The cost notice hides priced tool cost when model pricing is unknown (pkg/agent/postsession/cost/cost.go:193). costNotice returns "Session cost unavailable" and ignores c.ByTool and c.AmountMicroUSD. A session on an unpriced model that drives a $5 claude sub-run stamps $5 into status, but the notice never mentions it, so the notice and status/admind disagree.
3. Subscription (OAuth) sub-run costs are counted as billed spend. This is a product question rather than a bug: on a Max subscription, the claude CLI's total_cost_usd is an API-equivalent estimate, not money actually charged. Adding it to the notice and the admind budget overstates spend and could trip cost alerts or ceilings. Should these be excluded, or labeled as estimated?
| bs := budgetSession{ | ||
| State: s, | ||
| Est: prices.Estimate(bareModel(s.Model), s.InputTokens, s.OutputTokens), | ||
| Est: prices.Estimate(bareModel(s.Model), s.InputTokens, s.OutputTokens) + toolCostUSD(s.ByTool), |
There was a problem hiding this comment.
With tool cost folded into Est, the byModel axis now disagrees with the other axes:
- Sessions with
ByModelbuckets: byModel emits only the bucket costs, soByToolspend is dropped. WithByModel=[sonnet $1]andByTool=[claude-oauth $5], byAgentClass, bySession and byUser each show $6, but byModel totals $1. - Sessions without buckets: byModel emits
s.Est, so the full $6 is charged to the outer model and no row shows where the $5 went.
Giving tool spend its own row in byModel (or keeping it out of Est for that axis) would keep the axes consistent.
| // input is liveSessionSnapshot(a.agg), so a.agg.Snapshot() is exactly that | ||
| // set (no double-count). Without this the headline spend understates a | ||
| // sub-agent session by the full tool cost (see the codebot case). | ||
| for _, s := range a.agg.Snapshot() { |
There was a problem hiding this comment.
Tool cost comes from a live a.agg.Snapshot() on every request, but ov.ByModel comes from the Overview engine's cache, which can be up to one TTL stale. Within that window the headline mixes two points in time: a session that just finished contributes its tool cost but not its model cost, and sessions that left the aggregator lose their tool cost but keep their model cost. The "no double-count" claim in the comment only holds when the two line up. Carrying tool cost on overview.LiveSession and summing it inside the engine's compute would avoid this.
| // interactive-tool result callback, which lives in a different package; the | ||
| // runner's turn loop feeds addModelUsage internally, so that one stays private. | ||
| func (l *Loop) AddToolCost(outerTool string, costUSD float64, ok bool) { | ||
| l.usageMu.Lock() |
There was a problem hiding this comment.
ok is passed from main.go through buildToolSessionEventPublisher and then discarded with _ = ok. Since this is a new exported method, could it be dropped to AddToolCost(outerTool string, costUSD float64)? Readers will otherwise assume success affects the accounting.
| if !seen { | ||
| i = len(l.usageByTool) | ||
| l.usageByToolIdx[outerTool] = i | ||
| l.usageByTool = append(l.usageByTool, toolUsageBucket{tool: outerTool}) |
There was a problem hiding this comment.
The bucket is appended before the costUSD < 0 guard returns, so a single negative report leaves {Tool: x, Amount: 0, PricingKnown: false} in status. That contradicts the "tool buckets are always priced" contract toolCostUSD relies on. Moving the negative check above the bucket creation fixes it.
| // Round once at the USD->micro-USD boundary so accumulation is pure int64. | ||
| b.costMicroUSD += int64(math.Round(costUSD * 1e6)) | ||
| b.costReported = true | ||
| _ = ok |
There was a problem hiding this comment.
costReported is set for any non-negative cost, including 0. If the parser's ev.cost() returns 0 when total_cost_usd is absent (worth confirming), a toolkit that never reported a cost still produces a bucket with PricingKnown=true, asserting a known $0 instead of "not reported". That is the absent-versus-zero confusion Outcome.Unbilled's doc warns about. The comment in TestLoopAddToolCost_ZeroCostUnbilled says zero should not mark the bucket reported, but the test never asserts it.
Motivation
A session's
status.estimatedCostunder-reports total spend — potentially bymore than an order of magnitude — because it only prices the orchestrator
model's tokens and ignores the cost of inner interactive toolkits the agent
drives.
A passthrough coding agent that drives Claude Code, for example, can spend the
large majority of its cost inside those sub-runs. That spend is already recorded
per invocation in the
tool_sessionaudit Kind'scontent.costUsd, but itnever reaches the cost reporter — so the stamped total reflects only the
orchestrator model and omits the inner toolkit spend entirely.
Summary of changes
Rolls each interactive toolkit's provider-reported cost into the session total,
itemized so provenance stays visible. Generic — keyed by outer tool name, no
per-toolkit branching.
pkg/platform/pipeline,pkg/apis/v1alpha1): newToolUsageandToolCostBucket;SessionEndInfo.ByToolandEstimatedSessionCost.ByTool.Regenerated deepcopy, CRDs,
install.yaml, and the CRD reference doc.pkg/agent/runner):Loop.AddToolCostmirrors the existingaddModelUsage(sameusageMu, first-seen ordering, negative-cost guard,single round at the USD→micro-USD boundary). Fed in-process from the runner's
interactive-tool result callback, regardless of the
ToolSessionLogpersistgate. Surfaced through
SessionEndInfo.ByToolatfireSessionEnd.pkg/agent/postsession/cost): grand total is nowΣ ByModel + Σ ByTool; tool cost is always provider-reported (nevertable-priced) so top-level
PricingKnownis unchanged for existing sessions.The
AmountMicroUSD/PricingKnowndoc contract is updated: when a servedmodel is unpriced, the total is now a documented lower bound (the priced
components) rather than forced to 0. The closing cost notice gains a
Sub-agent toolsline.pkg/web/admind): the session-detail breakdown carriesbyTool;the budget breakdown and the Overview headline spend panel add each session's
tool cost on top of the token-repriced estimate, over the same session scope
that feeds the per-model rollup (no double-count).
Report-only: there is no cost-budget enforcement, and no payer/passthrough
marker (the inner spend is billed to the user's own subscription under
passthrough — a follow-up could annotate this).
Alternatives considered
amountMicroUSD— rejected: it breaks the"
byModelbuckets sum to the total" invariant and loses the provenance of anon-model cost. Itemizing under
byToolkeeps one headline number whileshowing where it came from.
for v1 in favor of a single itemized total; the split can be reintroduced as a
billedTomarker later without a schema break.tool_sessionrecords at SessionEnd instead of in-processaccumulation — rejected: the result callback fires regardless of the
ToolSessionLogpersist gate, so in-process accumulation is strictly morereliable and matches the existing
addModelUsageidiom.Provenance
magetargets forcodegen and the test gate
Ship gate
mage test:unitmage test:integrationmage test:e2eResults:
Regeneration
pkg/apis/v1alpha1/*_types.go→ ranmage gen:apiandmage manifestsconfig/**directly → n/a (only viagen:api)mage docs:crdmage fmt:checkcleanCoverage
accumulator, the SessionEnd wiring, the cost reporter (grand total,
itemization, unknown-model lower bound, no-tool regression), and the three
admind surfaces (session-detail, budget, Overview). Not a new agent-facing
tool call, so no bronzethread bundle is required; the whole e2e + steel
suites remain green.
touched;
estimatedCostis controller-owned status)Before requesting review
(
zz_generated.deepcopy.go,install.yaml,config/**,site/content/docs/crd-agentsession.mdx) are mechanical — re-runmage gen:api && mage manifests && mage docs:crdand confirm the outputis identical rather than reading them line by line.
Known follow-ups (deferred, non-blocking)
byModelbudget axis total excludes tool cost (tool spend has no servedmodel to attribute under) while the other axes include it — a deliberate
asymmetry worth a doc note or a synthetic "sub-agent tools" row.
{tool, $0, pricingKnown: true}bucket instatus.byTool(the parsercollapses "reported 0" and "reported nothing"); no displayed total is affected
since all surfaces gate on
> 0. AcostUSD > 0gate or a threaded "reported"bool would tidy the raw status.