mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-16 14:32:34 +00:00
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:
parent
022c4991fc
commit
cb7f6bbb2e
4 changed files with 438 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue