[RFC] Cost Tracking Architecture: Subagent Aggregation + Multi-Model Correctness #8647

Closed
opened 2026-02-16 18:10:29 -05:00 by yindo · 4 comments
Owner

Originally created by @bluet on GitHub (Feb 5, 2026).

Originally assigned to: @thdxr on GitHub.

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:

  1. Subagent cost aggregation (#11027) - Parent sessions don't include child session costs
  2. Multi-model correctness (#7387) - Costs recalculated with wrong model pricing
  3. Semantic clarity - Single cost field can't distinguish "own" vs "total"
  4. Pricing immutability - Costs can be recalculated, causing decreases
  5. Hidden costs (#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)

  1. Rename cost to own_cost_micros for semantic clarity
  2. Add total_cost_micros field to Session schema
  3. Add migration to initialize both fields from existing message sums
  4. Update addCost() to propagate to parent chain

Effort: ~2-3 hours | Risk: Low (additive, backward compatible with fallback)

Phase 2: Multi-Model Correctness

  1. Store model/provider per call in processor.ts
  2. Pass metadata to addCost() for audit trail
  3. Never recalculate - costs are immutable once recorded

Effort: ~2-3 hours | Risk: Low

Phase 3: Hidden Costs

  1. Emit cost events for title/summary generation (#7175)
  2. Track all LLM calls that don't go through normal message flow

Effort: ~1-2 hours | Risk: Low

Phase 4 (Optional): Full Audit Trail

  1. Add CostEvent table for complete cost history
  2. Add reconciliation command to verify session costs match events
  3. 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 0 or 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

  1. Naming preference: own_cost_micros / total_cost_micros or ownCost / totalCost?
  2. Storage format: microdollars (integers) or dollars (floats)?
  3. CostEvent table: Worth the storage overhead for audit trail?
  4. 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

Originally created by @bluet on GitHub (Feb 5, 2026). Originally assigned to: @thdxr on GitHub. ## 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: 1. **Subagent cost aggregation** (#11027) - Parent sessions don't include child session costs 2. **Multi-model correctness** (#7387) - Costs recalculated with wrong model pricing 3. **Semantic clarity** - Single `cost` field can't distinguish "own" vs "total" 4. **Pricing immutability** - Costs can be recalculated, causing decreases 5. **Hidden costs** (#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: ```typescript // 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: ```typescript // 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 ```typescript // 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 ```typescript 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 ```tsx // 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) 1. **Rename `cost` to `own_cost_micros`** for semantic clarity 2. **Add `total_cost_micros`** field to Session schema 3. **Add migration** to initialize both fields from existing message sums 4. **Update `addCost()`** to propagate to parent chain **Effort:** ~2-3 hours | **Risk:** Low (additive, backward compatible with fallback) ### Phase 2: Multi-Model Correctness 1. **Store model/provider per call** in processor.ts 2. **Pass metadata to `addCost()`** for audit trail 3. **Never recalculate** - costs are immutable once recorded **Effort:** ~2-3 hours | **Risk:** Low ### Phase 3: Hidden Costs 1. **Emit cost events for title/summary generation** (#7175) 2. **Track all LLM calls** that don't go through normal message flow **Effort:** ~1-2 hours | **Risk:** Low ### Phase 4 (Optional): Full Audit Trail 1. **Add CostEvent table** for complete cost history 2. **Add reconciliation command** to verify session costs match events 3. **Support cost breakdown by model** in UI **Effort:** ~4-6 hours | **Risk:** Medium (new storage requirements) --- ## Migration Strategy ```typescript // 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 `0` or 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 1. **Naming preference:** `own_cost_micros` / `total_cost_micros` or `ownCost` / `totalCost`? 2. **Storage format:** microdollars (integers) or dollars (floats)? 3. **CostEvent table:** Worth the storage overhead for audit trail? 4. **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
yindo closed this issue 2026-02-16 18:10:29 -05:00
Author
Owner

@github-actions[bot] commented on GitHub (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:

  • #11027: Cost of session doesn't include cost of subagents
  • #7387: Multi Model Cost Calculations are Bugged
  • #7175: The token cost generated by session titles, message summary titles, and body summaries has not been accounted for
  • #7767: session-cost/spent-value under reported for sessions with 100+ messages
  • #6989: [TUI] $ spent in context decreases after request
  • #485: Cost is dropping dramatically when opencode is finishing up

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.

@github-actions[bot] commented on GitHub (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: - #11027: Cost of session doesn't include cost of subagents - #7387: Multi Model Cost Calculations are Bugged - #7175: The token cost generated by session titles, message summary titles, and body summaries has not been accounted for - #7767: session-cost/spent-value under reported for sessions with 100+ messages - #6989: [TUI] $ spent in context decreases after request - #485: Cost is dropping dramatically when opencode is finishing up 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.
Author
Owner

@bluet commented on GitHub (Feb 5, 2026):

Thanks for the cross-references! Yes, I've investigated all of these issues before filing this RFC:

Issue Status How This RFC Addresses It
#11027 No PR Primary motivation - own_cost + total_cost with parent propagation
#7387 No PR Phase 2 - Store model/pricing per-call, never recalculate
#7175 No PR Phase 3 - Emit cost events for title/summary generation
#7767 PR #7763 Foundation - PR #7763 fixes 100-msg limit, this RFC builds on it
#6989 PR #7763 Same root cause as #7767
#485 Closed but unresolved Related symptom - pricing immutability prevents cost drops

The RFC intentionally consolidates these because they share architectural root causes:

  1. No persistence#7767, #6989 (fixed by PR #7763)
  2. No parent propagation#11027 (Phase 1 of this RFC)
  3. Mutable pricing#7387, #485 (Phase 2 of this RFC)
  4. Hidden LLM calls#7175 (Phase 3 of this RFC)

Happy to update the RFC if I missed any related issues.

@bluet commented on GitHub (Feb 5, 2026): Thanks for the cross-references! Yes, I've investigated all of these issues before filing this RFC: | Issue | Status | How This RFC Addresses It | |-------|--------|---------------------------| | #11027 | No PR | **Primary motivation** - `own_cost` + `total_cost` with parent propagation | | #7387 | No PR | Phase 2 - Store model/pricing per-call, never recalculate | | #7175 | No PR | Phase 3 - Emit cost events for title/summary generation | | #7767 | PR #7763 | Foundation - PR #7763 fixes 100-msg limit, this RFC builds on it | | #6989 | PR #7763 | Same root cause as #7767 | | #485 | Closed but unresolved | Related symptom - pricing immutability prevents cost drops | The RFC intentionally consolidates these because they share architectural root causes: 1. **No persistence** → #7767, #6989 (fixed by PR #7763) 2. **No parent propagation** → #11027 (Phase 1 of this RFC) 3. **Mutable pricing** → #7387, #485 (Phase 2 of this RFC) 4. **Hidden LLM calls** → #7175 (Phase 3 of this RFC) Happy to update the RFC if I missed any related issues.
Author
Owner

@bluet commented on GitHub (Feb 6, 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 commented on GitHub (Feb 6, 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!
Author
Owner

@bluet commented on GitHub (Feb 12, 2026):

Following up - I believe this was closed prematurely. The underlying issues this RFC addresses are all still open:

  • #11027 (subagent costs not aggregated) - OPEN, no PR
  • #7387 (multi-model cost bugs) - OPEN, no PR
  • #7175 (hidden title/summary costs) - OPEN, no PR
  • PR #7763 (100-msg persistence fix) - OPEN, not yet merged

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

@bluet commented on GitHub (Feb 12, 2026): Following up - I believe this was closed prematurely. The underlying issues this RFC addresses are all still open: - #11027 (subagent costs not aggregated) - **OPEN**, no PR - #7387 (multi-model cost bugs) - **OPEN**, no PR - #7175 (hidden title/summary costs) - **OPEN**, no PR - PR #7763 (100-msg persistence fix) - **OPEN**, not yet merged 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](https://github.com/anomalyco/opencode/pull/7763#issuecomment-3856926427) 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
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#8647