Repository navigation
[RFC] Cost Tracking Architecture: Subagent Aggregation + Multi-Model Correctness #12377
Description
Summary
OpenCode's cost tracking has multiple interrelated gaps that cause inaccurate cost display for users with multi-agent and multi-model workflows. This RFC proposes a unified architecture to address:
- Subagent cost aggregation (Cost of session doesn't include cost of subagents #11027) - Parent sessions don't include child session costs
- Multi-model correctness (Multi Model Cost Calculations are Bugged #7387) - Costs recalculated with wrong model pricing
- Semantic clarity - Single
costfield can't distinguish "own" vs "total" - Pricing immutability - Costs can be recalculated, causing decreases
- Hidden costs (The token cost generated by session titles, message summary titles, and body summaries has not been accounted for. #7175) - Summary/title generation costs not tracked
Related Issues: #11027, #7387, #7175, #7767, #6989, #485
Related PRs: #7763 (necessary foundation, but insufficient alone)
Problem Statement
The 100-Message Bug (Being Fixed)
PR #7763 addresses the immediate bug where the sidebar only sums the last 100 messages in memory. This is a necessary fix, but not sufficient for accurate cost tracking.
The Subagent Problem (Critical)
When using Task tool to spawn subagents (explore, librarian, oracle, etc.), each creates a child session with parentID linking to the parent. However:
// task.ts - creates child session
const session = await Session.create({
parentID: ctx.sessionID, // Links to parent
// ...
})
// session/index.ts - addCost only updates current session
export async function addCost(sessionID: string, amount: number) {
await update(sessionID, (draft) => {
draft.cost = (draft.cost ?? 0) + amount
})
}
// Parent's cost is NEVER updated!Result: Users see only their main session's cost, missing all subagent spend.
User Session: $0.50 (displayed)
├── @explore: $0.10 (hidden)
├── @librarian: $0.20 (hidden)
└── @oracle: $0.30 (hidden)
Actual total: $1.10
Displayed: $0.50 (55% under-reported!)
The Multi-Model Problem
Issue #7387 reports costs DECREASING when switching models mid-session. Root cause:
// processor.ts - cost calculated with CURRENT model
const usage = Session.getUsage({
model: input.model, // Current model, not the model used for THAT call
usage: value.usage,
// ...
})When switching from expensive (opus) to cheap (haiku) model, ALL previous costs are recalculated with the cheaper model's pricing.
Correct behavior: Each LLM call should lock its cost at call time with the actual model/pricing used.
Proposed Solution
Core Principle
"Append-only cost events as source-of-truth + denormalized rollups on sessions"
Schema Changes
// Session schema (enhanced)
interface Session {
id: string
parentID?: string
// ...existing fields...
// NEW: Split cost tracking
own_cost_micros: number // This session's direct LLM spend (immutable accumulator)
total_cost_micros: number // own + all descendants (cached rollup)
}
// NEW: Cost event record (optional but recommended for audit)
interface CostEvent {
id: string
session_id: string
message_id?: string
tool_call_id?: string
// Immutable snapshot at call time
provider_id: string
model_id: string
tokens: {
input: number
output: number
cache_read?: number
cache_write?: number
}
pricing_version: string // Snapshot of rates at call time
cost_micros: number // Calculated cost in microdollars (never recalculated)
created_at: number
}Updated addCost() Function
export async function addCost(
sessionID: string,
amount: number,
metadata?: { model_id: string, provider_id: string, pricing_version: string }
) {
if (amount === 0) return
// 1. Update own_cost (this session only)
const session = await update(sessionID, (draft) => {
draft.own_cost_micros = (draft.own_cost_micros ?? 0) + amount
draft.total_cost_micros = (draft.total_cost_micros ?? 0) + amount
})
// 2. Propagate to parent chain
let currentParentID = session.parentID
while (currentParentID) {
const parent = await update(currentParentID, (draft) => {
draft.total_cost_micros = (draft.total_cost_micros ?? 0) + amount
})
currentParentID = parent.parentID
}
// 3. Optionally emit CostEvent for audit trail
if (metadata) {
await CostEvent.create({
session_id: sessionID,
...metadata,
cost_micros: amount,
created_at: Date.now()
})
}
}Sidebar Display Changes
// sidebar.tsx
const cost = createMemo(() => {
const session = session()
const hasChildren = children().length > 0
// Use total_cost for sessions with subagents, own_cost otherwise
const amount = session.total_cost_micros ?? session.own_cost_micros ??
messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return {
value: amount,
label: hasChildren ? "(incl. subagents)" : "",
}
})Implementation Plan
Phase 1: Foundation (Builds on PR #7763)
- Rename
costtoown_cost_microsfor semantic clarity - Add
total_cost_microsfield to Session schema - Add migration to initialize both fields from existing message sums
- Update
addCost()to propagate to parent chain
Effort: ~2-3 hours | Risk: Low (additive, backward compatible with fallback)
Phase 2: Multi-Model Correctness
- Store model/provider per call in processor.ts
- Pass metadata to
addCost()for audit trail - Never recalculate - costs are immutable once recorded
Effort: ~2-3 hours | Risk: Low
Phase 3: Hidden Costs
- Emit cost events for title/summary generation (The token cost generated by session titles, message summary titles, and body summaries has not been accounted for. #7175)
- Track all LLM calls that don't go through normal message flow
Effort: ~1-2 hours | Risk: Low
Phase 4 (Optional): Full Audit Trail
- Add CostEvent table for complete cost history
- Add reconciliation command to verify session costs match events
- Support cost breakdown by model in UI
Effort: ~4-6 hours | Risk: Medium (new storage requirements)
Migration Strategy
// migration.ts - run on startup after PR #7763
async function migrateCostFields() {
const sessions = await Session.list()
// Sort by depth (children first) for correct rollup
const sorted = sortByDepth(sessions)
for (const session of sorted) {
// Calculate own_cost from message sums
const ownCost = session.messages?.reduce(
(sum, m) => sum + (m.role === "assistant" ? m.cost : 0), 0
) ?? session.cost ?? 0
// Calculate total_cost = own + children's total
const childrenTotal = sessions
.filter(s => s.parentID === session.id)
.reduce((sum, s) => sum + (s.total_cost_micros ?? 0), 0)
await Session.update(session.id, (draft) => {
draft.own_cost_micros = ownCost
draft.total_cost_micros = ownCost + childrenTotal
})
}
}UX Recommendations
| Element | Display | Notes |
|---|---|---|
| Sidebar (has children) | $1.10 (incl. subagents) |
Shows total_cost |
| Sidebar (no children) | $0.50 |
Shows own_cost |
| Optional toggle | "This session only" / "Include subagents" | User preference |
| Fork display | $0.00 with lineage note |
Don't inherit, show "Forked from [parent]" |
Long-Term Vision
┌─────────────────────────────────────────────────────────────┐
│ Session: "Implement auth system" │
├─────────────────────────────────────────────────────────────┤
│ Cost: $2.47 (incl. subagents) [This session only ▼]│
│ │
│ Breakdown by model: │
│ claude-opus-4-5: $1.80 (73%) │
│ gpt-5.2: $0.42 (17%) │
│ claude-haiku-4-5: $0.25 (10%) │
│ │
│ Subagent costs: │
│ @librarian: $0.45 │
│ @explore (×3): $0.32 │
│ @oracle: $0.20 │
└─────────────────────────────────────────────────────────────┘
Breaking Changes
None. All changes are additive with backward-compatible fallbacks:
- New fields default to
0or calculated from existing data - Migration runs automatically on startup
- Existing API contracts unchanged
Acceptance Criteria
- Parent sessions display total cost including all descendant sessions
- Costs never decrease (monotonically increasing)
- Model switching mid-session doesn't affect previously calculated costs
- Fork sessions start at $0.00 (no inherited cost)
- Sidebar shows "(incl. subagents)" when children exist
- Migration correctly backfills existing sessions
Questions for Maintainers
- Naming preference:
own_cost_micros/total_cost_microsorownCost/totalCost? - Storage format: microdollars (integers) or dollars (floats)?
- CostEvent table: Worth the storage overhead for audit trail?
- PR scope: Single PR covering Phases 1-2, or separate PRs?
Happy to contribute a PR for Phase 1 once PR #7763 is merged. Would love feedback on this architecture proposal.
/cc @fwang @rekram1-node
Activity
github-actions commentedon Feb 5, 2026
This issue appears to be a comprehensive RFC that consolidates and proposes solutions for several existing issues. Please check these related issues first:
This RFC effectively proposes a unified solution addressing the root causes described in all of these issues. The architectural approach should help resolve the symptoms reported across these multiple tickets.
bluet commentedon Feb 5, 2026
Thanks for the cross-references! Yes, I've investigated all of these issues before filing this RFC:
own_cost+total_costwith parent propagationThe RFC intentionally consolidates these because they share architectural root causes:
Happy to update the RFC if I missed any related issues.
bluet commentedon Feb 7, 2026
Hi @R44VC0RP I noticed that you closed this aggregated cost tracking issue earlier today, may I know if this has been fixed somewhere? Thanks!
bluet commentedon Feb 12, 2026
Following up - I believe this was closed prematurely. The underlying issues this RFC addresses are all still open:
This RFC isn't a duplicate of those issues - it's an architecture proposal that unifies them with a concrete implementation plan. None of these issues have been fixed in recent commits.
@IdrisGit (PR #7763 author) has also expressed interest in collaborating on the subagent cost tracking piece after PR #7763 lands.
Could this be reopened? Happy to contribute a PR once the approach is agreed upon.
cc @thdxr @fwang
saad039 commentedon Mar 17, 2026
Any updates here? Can we have some clarity on whether this feature is part of the short-term roadmap or not :-)
TheFpiasta commentedon Apr 1, 2026
Why is this issue closed as compleated, when its not not compleated? Is Nowone wondering why the displayed costs and the costs in the provider like openai dont matche up? @R44VC0RP whats going on here??
norman850 commentedon Apr 3, 2026
hey @R44VC0RP would you be OK to reopen this? the bug is still here, though kilocode fixed it a few days ago here: Kilo-Org/kilocode@70633f5
Keesan12 commentedon Apr 29, 2026
This is the same failure mode MartinLoop is trying to handle.
Once coding agents can run unattended it Reinforces the need for comprehensive audit trails that aggregate all child-process costs into a single record. The run needs:
We open-sourced a small runtime around this pattern here:
https://github.com/Keesan12/Martin-Loop
Would value criticism from people running real Claude/Codex/OpenCode loops.
Andrflor commentedon Aug 12, 2026
What a very bad surprise to realize that the session that supposedly consumes a few bucks consumed way much more
The cost indicator is very missleading furthermore it's not even real prices but estimated if i do not fumble
ramonskie commentedon Aug 21, 2026
please reopen issue. as this is not addressed