Skip to content

cli: stats totals differ from stored session usage #57878

Open

Description

@maharshi365

Description

I want to track total token usage, including work I later revert. The current stats calculation reports less usage than the stored session counters.

A manual, read-only analysis of my local database found:

All-time source Tokens Recorded cost
Current stats calculation 18,998,987,936 $11,778.98
Stored session counters 19,071,908,477 $11,819.08
Difference 72,920,541 $40.09

Tokens include input, output, reasoning, cache read, and cache write, following TokenUsage.total. This was calculated directly from SQLite, not captured from CLI output.

Code inspection suggests one way stats can lose historical usage:

  • packages/core/src/session/stats.ts sums normal model usage from remaining message rows.
  • Committing a revert deletes message rows from the boundary onward in packages/core/src/session/projector.ts, without reducing cumulative session usage counters.
  • Sending a new prompt after a staged revert commits it.

Expected: historical token/cost totals should include already-consumed usage, even after a revert, without double-counting forked history. Legacy/migrated usage must also remain counted.

The measured gap is real, but I have not established how much comes from reverts versus other differences in accounting. My database's event table is empty, so I could not validate an event-based total or attribute the gap to individual reverts.

Plugins

No plugin entries found in the global config. Full effective plugin status was not verified.

OpenCode version

2.1.2, latest channel. Version read from the local service registration.

Steps to reproduce

Suggested regression case from code inspection; not yet run end-to-end:

  1. Complete two model steps and record token/cost totals.
  2. Revert before the second step, then submit a new prompt to commit the revert.
  3. Check historical stats: the reverted step's consumed tokens and cost should remain included.

Screenshot

Not applicable; database analysis and code inspection.

Operating System

Windows NT 10.0.26300, x64.

Terminal

Terminal app unavailable; TERM=xterm-256color. Shell environment: C:\WINDOWS\system32\cmd.exe.

Activity

  1. changed the title [-]fix(cli): reconcile stats with recorded token usage[/-] [+]cli: stats totals differ from stored session usage[/+] on Oct 21, 2026
  2. opencode-agent commented on Oct 21, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed analysis. I reproduced this on 2.1.2 and it still happens on the latest dev (863ea41).

    Setup: a local mock OpenAI-compatible provider that reports 1000 input + 100 output tokens for each completion.

    1. Send two prompts. opencode stats shows 2 prompts · 2 steps · 2.2k tokens.
    2. Stage a revert to the second prompt, then send a new prompt, which commits the revert and runs another model step.
    3. opencode stats still shows 2 steps · 2.2k tokens, although three completions (3.3k tokens) were used. The session's stored totals still include the reverted step (input 4000, output 400, cost $0.008).

    So the reverted step's usage drops out of stats once the revert is committed. That matches your reading: stats add up the remaining message rows, and committing the revert deletes them.

    One more thing I noticed: the stored session totals also include the session-title generation call, which stats never count (related: #46371). That is probably part of your measured gap besides reverts.

    The script below reproduces it.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduce anomalyco/opencode#57878: committed reverts drop consumed usage from `opencode stats`.
    # Usage: ./repro.sh            (uses `opencode` from PATH)
    #        OC_DEV=/path/to/repo ./repro.sh   (runs CLI from a source checkout)
    set -euo pipefail
    W=$(mktemp -d /tmp/opencode/repro57878.XXXX)
    export HOME=$W/home XDG_DATA_HOME=$W/home/data XDG_CONFIG_HOME=$W/home/cfg XDG_STATE_HOME=$W/home/state XDG_CACHE_HOME=$W/home/cache
    mkdir -p $HOME $W/proj
    export OPENCODE_SERVER_PASSWORD=repro57878
    oc() {
      if [ -n "${OC_DEV:-}" ]; then
        bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts "$@"
      else opencode "$@"; fi
    }
    # Mock OpenAI-compatible provider: every completion reports 1000 input + 100 output tokens.
    cat > $W/mock.ts <<'TS'
    Bun.serve({ port: 4011, async fetch(req) {
      const body: any = await req.json().catch(() => ({}))
      const usage = { prompt_tokens: 1000, completion_tokens: 100, total_tokens: 1100 }
      const base = { id: "c1", created: 1, model: "mock" }
      if (!body.stream) return Response.json({ ...base, object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage })
      const c = (o: any) => `data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", ...o })}\n\n`
      return new Response(c({ choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] }) + c({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }) + c({ choices: [], usage }) + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } })
    } })
    TS
    bun $W/mock.ts & MOCK=$!
    cat > $W/proj/opencode.json <<'JSON'
    { "model": "mock/mock", "small_model": "mock/mock",
      "provider": { "mock": { "npm": "@ai-sdk/openai-compatible", "name": "Mock",
        "options": { "baseURL": "http://127.0.0.1:4011/v1", "apiKey": "x" },
        "models": { "mock": { "name": "Mock", "cost": { "input": 1, "output": 10 } } } } } }
    JSON
    cd $W/proj && git init -q
    oc serve --port 4097 >$W/server.log 2>&1 & SRV=$!
    trap "kill $MOCK; pkill -f \"serve --port 4097\"" EXIT
    for i in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:4097/ && break; sleep 1; done
    DB_DIR=$XDG_DATA_HOME/opencode
    q() { DB=$(ls $DB_DIR/opencode*.db | head -1); bun -e "import {Database} from 'bun:sqlite';const db=new Database('$DB',{readonly:true});$1"; }
    oc run --server http://127.0.0.1:4097 "first" >/dev/null 2>&1
    oc run --server http://127.0.0.1:4097 -c "second" >/dev/null 2>&1
    echo "== stats after two model steps"; oc stats --server http://127.0.0.1:4097 2>&1 | grep -E "prompts"
    S=$(q "console.log(db.query('select id from session_v2').get().id)")
    U2=$(q "console.log(db.query(\"select id from session_message where type='user' order by seq\").all()[1].id)")
    echo "== stage revert to second prompt ($U2), then commit it by sending a new prompt"
    oc api --server http://127.0.0.1:4097 session.revert.stage --param sessionID=$S -d "{\"messageID\":\"$U2\"}" >/dev/null
    oc run --server http://127.0.0.1:4097 -s $S "third" >/dev/null 2>&1
    echo "== model steps actually consumed: 3 (3.3k tokens, excluding title generation)"
    echo "== stored session counters:"; q "console.log(db.query('select tokens_input, tokens_output, cost from session_v2').get())"
    echo "== stats after revert commit:"; oc stats --server http://127.0.0.1:4097 2>&1 | grep -E "prompts"

    Run it: bash repro.sh

  3. projectescape commented on Oct 23, 2026

    @projectescape

    For the workaround, I'd keep a response-usage record separate from the editable conversation. A revert changes which messages remain in context, but shouldn't remove already-consumed model usage. The title-generation call in the reproduction is another reason to count completed provider requests rather than only visible assistant turns.

    Disclosure: I build Torana, a local proxy. Its usage_logger plugin writes content-free JSONL with request ID, model, input/output and cache counts for completed responses routed through it. That gives a separate record to compare against stats after a revert, without reading prompts. It starts when enabled and is best-effort, with bounded file rotation; it isn't a billing ledger or a fix to OpenCode's counters.

    I haven't run this particular revert reproduction through Torana, so I'm suggesting an independent measurement path, not claiming a verified fix.

  4. maharshi365 commented on Oct 23, 2026

    @maharshi365
    ContributorAuthor

    5. For the workaround, I'd keep a response-usage record separate from the editable conversation. A revert changes which messages remain in context, but shouldn't remove already-consumed model usage. The title-generation call in the reproduction is another reason to count completed provider requests rather than only visible assistant turns.
    Disclosure: I build Torana, a local proxy. Its usage_logger plugin writes content-free JSONL with request ID, model, input/output and cache counts for completed responses routed through it. That gives a separate record to compare against stats after a revert, without reading prompts. It starts when enabled and is best-effort, with bounded file rotation; it isn't a billing ledger or a fix to OpenCode's counters.
    I haven't run this particular revert reproduction through Torana, so I'm suggesting an independent measurement path, not claiming a verified fix.

    Why tho? You could just read from durable event stream. No need to duplicate data.

  5. projectescape commented on Oct 23, 2026

    @projectescape

    fair, for fixing stats itself the durable event stream is the right source. i suggested an outside record because you mentioned your event table was empty, so there was no event history to check the gap against. useful as a cross-check, not as the fix.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions