Skip to content

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

@bluet

Description

@bluet

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 (Cost of session doesn't include cost of subagents #11027) - Parent sessions don't include child session costs
  2. Multi-model correctness (Multi Model Cost Calculations are Bugged #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 (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)

  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 (The token cost generated by session titles, message summary titles, and body summaries has not been accounted for. #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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions