Skip to content

opencode stats over-reports Total Cost by double-counting forked sessions #36944

Description

@evatths

Summary

opencode stats computes Total Cost as SUM(session.cost). Forking a session copies the parent's prior messages into the new session (new message IDs, but the original time.created / cost / tokens). Those turns were never re-sent to the provider, so they were never re-billed — yet each fork's session.cost rollup includes them. The result: shared history is counted once per fork, inflating the reported total.

Version

opencode 1.17.20

Impact

opencode stats Total Cost is over-reported roughly in proportion to how much a user forks. In one dataset the over-count was ~11%. Deduplicating on the billed event brings the total down accordingly.

The figures below are illustrative/synthetic (not real usage), included only to show the relationship between the values. The ~11% over-count is the real observed magnitude.

sessions                          : 1200
opencode stats  (SUM session.cost): $4,000.00
SUM(message.cost)  raw            : $4,060.00
deduplicated billed events        : $3,556.00
OVER-COUNT vs reality             : $444.00 (11.1%)

opencode stats matches SUM(session.cost) exactly, confirming that is the aggregate used.

Root cause

Cost is a property of a billed event, but the aggregate keys on the row/session, which forking duplicates. Note session.parent_id is often NULL on the copies, so lineage can't be relied on to detect them, and "different message IDs" does not imply independent spend — copies always get new IDs.

Concrete pattern (illustrative numbers): groups of sessions are byte-identical copies of one another (same message count, same per-message timestamps and cost) — i.e. forks that never diverged. Each copy's session.cost is added to the total again:

Identical-copy session clusters (same billed events, counted N times):
  cluster #1:  x3 @ $30.00  ->  $60.00 phantom
  cluster #2:  x2 @ $12.00  ->  $12.00 phantom
  cluster #3:  x3 @ $8.00   ->  $16.00 phantom
  cluster #4:  x2 @ $2.50   ->   $2.50 phantom

(These are only the forks that never diverged; continued forks also duplicate their inherited prefix, which typically accounts for the bulk of the over-count.)

Repro

cp ~/.local/share/opencode/opencode.db /tmp/repro.db   # DB is live; snapshot it
python3 fork_cost_double_count_repro.py /tmp/repro.db
fork_cost_double_count_repro.py
#!/usr/bin/env python3
"""Repro: `opencode stats` (== SUM(session.cost)) double-counts forked sessions."""
import sqlite3, json, sys, collections, hashlib

db = sqlite3.connect(sys.argv[1] if len(sys.argv) > 1 else "/tmp/repro.db")
db.row_factory = sqlite3.Row

stats_total = db.execute("SELECT COALESCE(SUM(cost),0) FROM session").fetchone()[0]
n_sessions  = db.execute("SELECT COUNT(*) FROM session").fetchone()[0]

# De-duplicate on the *billed event* (immutable properties of one generation).
raw = dedup = 0.0
seen = set()
for r in db.execute("SELECT data FROM message"):
    d = json.loads(r["data"]); c = d.get("cost") or 0.0
    if c <= 0: continue
    t = d.get("time") or {}; tk = d.get("tokens") or {}; ca = tk.get("cache") or {}
    fp = (t.get("created"), d.get("providerID"), d.get("modelID"),
          tk.get("input"), tk.get("output"), tk.get("reasoning"),
          ca.get("read"), ca.get("write"), round(c, 10))
    raw += c
    if fp not in seen:
        seen.add(fp); dedup += c

print(f"sessions                          : {n_sessions}")
print(f"opencode stats  (SUM session.cost): ${stats_total:,.2f}")
print(f"SUM(message.cost)  raw            : ${raw:,.2f}")
print(f"deduplicated billed events        : ${dedup:,.2f}")
print(f"OVER-COUNT vs reality             : ${stats_total - dedup:,.2f} "
      f"({100*(stats_total-dedup)/stats_total:.1f}%)")

# Concrete example: byte-identical copy clusters (forks that never diverged).
ev = collections.defaultdict(list)
for r in db.execute("SELECT session_id, data FROM message"):
    d = json.loads(r["data"]); c = d.get("cost") or 0.0
    if c <= 0: continue
    ev[r["session_id"]].append(((d.get("time") or {}).get("created"), round(c, 10)))
cost_of = {r["id"]: (r["cost"] or 0.0) for r in db.execute("SELECT id,cost FROM session")}
clusters = collections.defaultdict(list)
for sid, e in ev.items():
    e.sort(); clusters[hashlib.md5(json.dumps(e).encode()).hexdigest()].append(sid)
print("\nIdentical-copy session clusters (same billed events, counted N times):")
rows = [((len(ss)-1)*cost_of[ss[0]], len(ss), cost_of[ss[0]])
        for ss in clusters.values() if len(ss) > 1]
for i, (extra, n, cost) in enumerate(sorted(rows, reverse=True)[:8], 1):
    print(f"  cluster #{i}:  x{n} @ ${cost:.2f}  ->  ${extra:.2f} phantom")

Suggested fix

Deduplicate on a billed-event fingerprint before summing — e.g. (time.created, providerID, modelID, tokens.input, tokens.output, tokens.reasoning, tokens.cache.read, tokens.cache.write, cost) — or give inherited (copied) messages a zero rollup cost in forks so each event is counted only in its originating session.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions