feat(agent): track per-model token usage for mid-session model switches

The `sessions` table records only the initial (model, billing_provider)
for a session, so when a user switches models mid-session (via `/model`
or programmatically) every token — including the switched model's — is
attributed to the first model. Insights/billing reports then hide the
cost of the new model entirely (e.g. a session that started on deepseek
and switched to opus shows $0 for opus).

Add a `session_model_usage` table keyed (session_id, model,
billing_provider) that accumulates each per-API-call delta under the
model active at the time of the call. `update_token_counts()` is the
single chokepoint every per-call delta flows through (CLI, gateway,
cron, delegated, codex), so recording there captures accurate
attribution on every platform. Only the incremental path records — the
gateway's `absolute=True` summary overwrite is skipped to avoid
double-counting cumulative totals that can't be split per model. When a
call omits the model, it falls back to the session's recorded model,
matching the existing COALESCE-from-session summary behaviour.

Insights `_compute_model_breakdown` now aggregates tokens and cost from
`session_model_usage`, so a switched session splits correctly across
models, with a defensive fallback to the per-session aggregate for any
session lacking usage rows. A v17 migration backfills one usage row per
existing token-bearing session from its aggregate totals (idempotent via
INSERT OR IGNORE), validated lossless against a 1.3 GB production DB.

Tests: per-model recording, mid-session split, model fallback, absolute
no-double-count, v17 backfill, and an insights-level switch breakdown.

Fixes #51607.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Connally 2026-06-23 21:23:43 -05:00 committed by Teknium
parent 022c4991fc
commit cb7f6bbb2e
4 changed files with 438 additions and 20 deletions

View file

@ -320,6 +320,35 @@ class TestInsightsPopulated:
claude = next(m for m in models if "claude-sonnet" in m["model"])
assert claude["sessions"] == 2
def test_model_breakdown_splits_mid_session_switch(self, db):
"""A session that switches models mid-flight is split across both
models in the breakdown, not dumped on the initial model (#51607).
"""
now = time.time()
db.create_session(session_id="sw", source="cli",
model="deepseek/deepseek-v4-pro")
# 40k tokens on deepseek, then switch and 50k on opus.
db.update_token_counts("sw", input_tokens=40000, output_tokens=8000,
model="deepseek/deepseek-v4-pro",
billing_provider="deepseek", api_call_count=2)
db.update_session_model("sw", "anthropic/claude-opus-4.8")
db.update_token_counts("sw", input_tokens=50000, output_tokens=4000,
model="anthropic/claude-opus-4.8",
billing_provider="openrouter", api_call_count=3)
db._conn.commit()
report = InsightsEngine(db).generate(days=30)
models = {m["model"]: m for m in report["models"]}
assert "deepseek-v4-pro" in models
assert "claude-opus-4.8" in models
# Tokens attributed to the model that actually incurred them.
assert models["deepseek-v4-pro"]["input_tokens"] == 40000
assert models["claude-opus-4.8"]["input_tokens"] == 50000
assert models["claude-opus-4.8"]["api_calls"] == 3
# The summary row's single model would have hidden one of these.
assert models["deepseek-v4-pro"]["total_tokens"] == 48000
assert models["claude-opus-4.8"]["total_tokens"] == 54000
def test_platform_breakdown(self, populated_db):
engine = InsightsEngine(populated_db)
report = engine.generate(days=30)

View file

@ -351,6 +351,129 @@ class TestSessionLifecycle:
assert sess["billing_provider"] == "openai"
assert sess["billing_mode"] == "local" # preserved (COALESCE on None)
def test_per_model_usage_recorded_for_single_model(self, db):
"""Each per-call delta lands in session_model_usage (#51607)."""
db.create_session(session_id="s1", source="cli")
db.update_token_counts("s1", input_tokens=200, output_tokens=100,
model="anthropic/claude-opus-4.8",
billing_provider="anthropic", api_call_count=1)
db.update_token_counts("s1", input_tokens=100, output_tokens=50,
model="anthropic/claude-opus-4.8",
billing_provider="anthropic", api_call_count=1)
rows = db._conn.execute(
"SELECT model, billing_provider, api_call_count, input_tokens, "
"output_tokens FROM session_model_usage WHERE session_id = 's1'"
).fetchall()
assert len(rows) == 1
row = rows[0]
assert row["model"] == "anthropic/claude-opus-4.8"
assert row["billing_provider"] == "anthropic"
assert row["api_call_count"] == 2
assert row["input_tokens"] == 300
assert row["output_tokens"] == 150
def test_mid_session_switch_splits_per_model_usage(self, db):
"""The headline #51607 case: tokens after a /model switch are
attributed to the new model, not the session's initial model.
The ``sessions`` summary row still holds combined totals + the latest
model, but session_model_usage keeps an accurate per-model split.
"""
db.create_session(session_id="s1", source="cli",
model="deepseek/deepseek-v4-pro")
# Pre-switch calls on deepseek.
db.update_token_counts("s1", input_tokens=40_000, output_tokens=8_000,
model="deepseek/deepseek-v4-pro",
billing_provider="deepseek", api_call_count=2)
# User runs /model — the gateway persists the new model …
db.update_session_model("s1", "anthropic/claude-opus-4.8")
# … and subsequent per-call deltas carry the new model/provider.
db.update_token_counts("s1", input_tokens=50_000, output_tokens=4_000,
model="anthropic/claude-opus-4.8",
billing_provider="openrouter", api_call_count=3)
rows = {
r["model"]: r
for r in db._conn.execute(
"SELECT model, billing_provider, input_tokens, output_tokens, "
"api_call_count FROM session_model_usage WHERE session_id = 's1'"
).fetchall()
}
assert set(rows) == {"deepseek/deepseek-v4-pro",
"anthropic/claude-opus-4.8"}
assert rows["deepseek/deepseek-v4-pro"]["input_tokens"] == 40_000
assert rows["deepseek/deepseek-v4-pro"]["api_call_count"] == 2
assert rows["anthropic/claude-opus-4.8"]["input_tokens"] == 50_000
assert rows["anthropic/claude-opus-4.8"]["billing_provider"] == "openrouter"
assert rows["anthropic/claude-opus-4.8"]["api_call_count"] == 3
# Summary row: latest model + combined totals (unchanged behaviour).
session = db.get_session("s1")
assert session["model"] == "anthropic/claude-opus-4.8"
assert session["input_tokens"] == 90_000
assert session["output_tokens"] == 12_000
def test_per_model_usage_falls_back_to_session_model(self, db):
"""When a call omits the model, attribute it to the session's
recorded model matches the COALESCE-from-session summary behaviour
and keeps existing callers (which pass no model) working.
"""
db.create_session(session_id="s1", source="cli",
model="gpt-4o", )
db.update_token_counts("s1", input_tokens=10, output_tokens=5)
rows = db._conn.execute(
"SELECT model FROM session_model_usage WHERE session_id = 's1'"
).fetchall()
assert len(rows) == 1
assert rows[0]["model"] == "gpt-4o"
def test_absolute_update_does_not_record_per_model(self, db):
"""absolute=True overwrites the cumulative summary row (gateway path)
and must NOT add per-model rows those are accumulated from the
per-call incremental path, so recording here would double-count.
"""
db.create_session(session_id="s1", source="cli", model="gpt-4o")
db.update_token_counts("s1", input_tokens=500, output_tokens=200,
model="gpt-4o", absolute=True)
rows = db._conn.execute(
"SELECT COUNT(*) AS n FROM session_model_usage WHERE session_id = 's1'"
).fetchone()
assert rows["n"] == 0
def test_v17_backfill_seeds_existing_session_usage(self, tmp_path):
"""A DB upgraded from <17 seeds one usage row per historical session
from its aggregate totals, so insights read uniformly from the table.
"""
db_path = tmp_path / "legacy.db"
db = SessionDB(db_path=db_path)
db.create_session(session_id="legacy1", source="cli", model="gpt-4o")
db.update_token_counts("legacy1", input_tokens=1234, output_tokens=567,
model="gpt-4o", billing_provider="openai")
# Simulate a pre-v17 database: drop the per-model rows and roll the
# recorded schema version back so the backfill migration re-runs.
db._conn.execute("DELETE FROM session_model_usage")
db._conn.execute("UPDATE schema_version SET version = 16")
db._conn.commit()
db.close()
# Reopen — _init_schema should backfill from the sessions aggregate.
db2 = SessionDB(db_path=db_path)
try:
rows = db2._conn.execute(
"SELECT model, billing_provider, input_tokens, output_tokens "
"FROM session_model_usage WHERE session_id = 'legacy1'"
).fetchall()
assert len(rows) == 1
assert rows[0]["model"] == "gpt-4o"
assert rows[0]["billing_provider"] == "openai"
assert rows[0]["input_tokens"] == 1234
assert rows[0]["output_tokens"] == 567
finally:
db2.close()
def test_parent_session(self, db):
db.create_session(session_id="parent", source="cli")
db.create_session(session_id="child", source="cli", parent_session_id="parent")