Mid-session model switch persistence and token tracking
NousResearch/hermes-agent · cluster 28637 · agent
Data as of
Nothing open on GitHub. Fix merged in #35256. Check it covers your case.
The short answer
- Known?
- Yes. 14 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #35256. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PRs #35256, #52666, #62610. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes address bugs where switching models mid-session fails to update billing provider, base URL, and model fields in the database, causing stale dashboard data. Related work adds per-model token usage tracking across mid-session model switches.
Open issues0
Open PRs0
Fixes on record
-
#35256 fix(state): persist mid-session model switch to database (#34850)
files:
gateway/run.py,hermes_state.py,scripts/release.py,tests/test_hermes_state.pyShow the diff
-
gateway/run.py@@ -10549,6 +10549,22 @@ async def _on_model_selected( except Exception as exc: logger.warning("Picker model switch failed for cached agent: %s", exc) + # Persist the new model to the session DB so the + # dashboard shows the updated model (#34850). + _sess_db = getattr(_self, "_session_db", None) + if _sess_db is not None: + try: + _sess_entry = _self.session_store.get_or_create_session( + event.source + ) + _sess_db.update_session_model( + _sess_entry.session_id, result.new_model + ) + except Exception as exc: + logger.debug( + "Failed to persist model switch to DB: %s", exc + ) + # Store model note + session override if not hasattr(_self, "_pending_model_notes"): _self._pending_model_notes = {} @@ -10686,6 +10702,20 @@ async def _on_model_selected( except Exception as exc: logger.warning("In-place model switch failed for cached agent: %s", exc) + # Persist the new model to the session DB so the dashboard + # shows the updated model (#34850). + _sess_db = getattr(self, "_session_db", None) + if _sess_db is not None: + try: + _sess_entry = self.session_store.get_or_create_session(source) + _sess_db.update_session_model( + _sess_entry.session_id, result.new_model + ) + except Exception as exc: + logger.debug( + "Failed to persist model switch to DB: %s", exc + ) + # Store a note to prepend to the next user message so the model # knows about the switch (avoids system messages mid-history). if not hasattr(self, "_pending_model_notes"): -
hermes_state.py@@ -965,6 +965,20 @@ def _do(conn): ) self._execute_write(_do) + def update_session_model(self, session_id: str, model: str) -> None: + """Update the model for a session after a mid-session switch. + + Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)`` + (only filling in NULL), this unconditionally sets the model column + so that the dashboard reflects the user's latest /model choice. + """ + def _do(conn): + conn.execute( + "UPDATE sessions SET model = ? WHERE id = ?", + (model, session_id), + ) + self._execute_write(_do) + def update_token_counts( self, session_id: str, -
scripts/release.py@@ -46,6 +46,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "[redacted:email]": "SeaXen", + "[redacted:email]": "LengR", "[redacted:email]": "HashClawAI", "[redacted:email]": "TonyPepeBear", "[redacted:email]": "nielskaspers", -
tests/test_hermes_state.py@@ -152,6 +152,30 @@ def test_update_token_counts_preserves_existing_model(self, db): session = db.get_session("s1") assert session["model"] == "anthropic/claude-opus-4.6" + def test_update_session_model_overwrites_existing(self, db): + """A mid-session /model switch must overwrite the stored model. + + update_token_counts uses COALESCE(model, ?) (first-writer-wins), so + the dashboard kept showing the original model after a switch (#34850). + update_session_model sets the column unconditionally. + """ + db.create_session(session_id="s1", source="telegram", + model="xiaomi/mimo-v2.5-pro") + # Token updates never change the model once set. + db.update_token_counts("s1", input_tokens=10, output_tokens=5, + model="xiaomi/mimo-v2.5-pro") + assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5-pro" + + # Explicit switch overwrites it. + db.update_session_model("s1", "xiaomi/mimo-v2.5") + assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5" + + # And a subsequent token update does NOT revert it (COALESCE no-ops + # because the column is now non-NULL). + db.update_token_counts("s1", input_tokens=10, output_tokens=5, + model="xiaomi/mimo-v2.5-pro") + assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5" + 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")
-
-
#52666 fix(hermes_state): persist billing route after mid-session /model switch (#48248)
files:
agent/agent_runtime_helpers.py,hermes_state.py,tests/test_hermes_state.pyShow the diff
-
agent/agent_runtime_helpers.py@@ -1697,6 +1697,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo old_model, old_provider, new_model, new_provider, ) + # ── Persist billing route to session DB ── + # The agent's _session_db / session_id may not be set in all contexts + # (tests, bare agents without a session DB, etc.). This ensures the + # dashboard Model cards show the actual provider after a mid-session + # /model switch instead of the stale session-creation provider. + # See #48248 for the full bug description. + _session_db = getattr(agent, "_session_db", None) + _session_id = getattr(agent, "session_id", None) + if _session_db is not None and _session_id: + try: + _session_db.update_session_billing_route( + _session_id, + provider=agent.provider, + base_url=agent.base_url, + billing_mode=getattr(agent, "api_mode", None), + ) + except Exception: + logger.warning( + "Failed to persist billing route after model switch", + exc_info=True, + ) def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str, -
hermes_state.py@@ -1584,6 +1584,36 @@ def _do(conn): ) self._execute_write(_do) + def update_session_billing_route( + self, + session_id: str, + *, + provider: str, + base_url: str, + billing_mode: Optional[str] = None, + ) -> None: + """Unconditionally update the billing provider/base_url for a session. + + Unlike ``update_token_counts`` which uses ``COALESCE(billing_provider, ?)`` + (only filling in NULL), this unconditionally sets the billing fields so + that the dashboard reflects the user's latest /model switch. + + Also nulls ``system_prompt`` so the cached snapshot (which embeds a + stale ``Model:`` / ``Provider:`` header) is rebuilt — matching the + behavior of ``update_session_model`` (see #48173, #48248). + """ + def _do(conn): + conn.execute( + """UPDATE sessions SET + billing_provider = ?, + billing_base_url = ?, + billing_mode = COALESCE(?, billing_mode), + system_prompt = NULL + WHERE id = ?""", + (provider, base_url, billing_mode, session_id), + ) + self._execute_write(_do) + def update_token_counts( self, session_id: str, -
tests/test_hermes_state.py@@ -210,6 +210,54 @@ def test_update_session_model_overwrites_existing(self, db): model="xiaomi/mimo-v2.5-pro") assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5" + def test_update_session_billing_route_overwrites_after_switch(self, db): + """A mid-session provider switch must overwrite the billing route. + + update_token_counts writes billing fields with + COALESCE(billing_provider, ?) (first-writer-wins), so after a + provider switch the dashboard kept attributing cost to the original + provider (#48248). update_session_billing_route sets them + unconditionally and nulls system_prompt so the next turn rebuilds + the Model:/Provider: header (#48173). + """ + db.create_session(session_id="s1", source="telegram") + # First token update seeds the billing route. + db.update_token_counts("s1", input_tokens=10, output_tokens=5, + billing_provider="openrouter", + billing_base_url="https://openrouter.ai/api/v1", + billing_mode="api_key") + sess = db.get_session("s1") + assert sess["billing_provider"] == "openrouter" + # A later token update never changes it (COALESCE first-writer-wins). + db.update_token_counts("s1", input_tokens=10, output_tokens=5, + billing_provider="ollama", + billing_base_url="http://localhost:11434/v1", + billing_mode="local") + assert db.get_session("s1")["billing_provider"] == "openrouter" + + # Seed a stale prompt snapshot, then switch the billing route. + db.update_system_prompt("s1", "Model: x/old\nProvider: openrouter") + assert db.get_session("s1")["system_prompt"] is not None + db.update_session_billing_route( + "s1", provider="ollama", + base_url="http://localhost:11434/v1", billing_mode="local", + ) + sess = db.get_session("s1") + assert sess["billing_provider"] == "ollama" + assert sess["billing_base_url"] == "http://localhost:11434/v1" + assert sess["billing_mode"] == "local" + assert sess["system_prompt"] is None, \ + "system_prompt must be nulled so the next turn rebuilds Model:/Provider:" + + # billing_mode defaults to COALESCE — omitting it preserves the value. + db.update_session_billing_route( + "s1", provider="openai", + base_url="https://api.openai.com/v1", + ) + sess = db.get_session("s1") + assert sess["billing_provider"] == "openai" + assert sess["billing_mode"] == "local" # preserved (COALESCE on None) + 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")
-
-
#62610 fix(insights): attribute usage across model switches
files:
agent/codex_runtime.py,agent/insights.py,hermes_state.py,scripts/release.py,tests/agent/test_insights.py,tests/test_hermes_state.pyShow the diff
-
agent/codex_runtime.py@@ -120,6 +120,9 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]: agent._session_db.update_token_counts( agent.session_id, model=agent.model, + billing_provider=agent.provider, + billing_base_url=agent.base_url, + billing_mode="subscription_included", api_call_count=1, ) except Exception as exc: -
agent/insights.py@@ -17,6 +17,7 @@ """ import json +import sqlite3 import time from collections import Counter, defaultdict from datetime import datetime @@ -141,8 +142,8 @@ def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]: } # Compute insights - overview = self._compute_overview(sessions, message_stats) - models = self._compute_model_breakdown(sessions) + models = self._compute_model_breakdown(sessions, cutoff, source) + overview = self._compute_overview(sessions, message_stats, models) platforms = self._compute_platform_breakdown(sessions) tools = self._compute_tool_breakdown(tool_usage) skills = self._compute_skill_breakdown(skill_usage) @@ -172,7 +173,7 @@ def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]: "message_count, tool_call_count, input_tokens, output_tokens, " "cache_read_tokens, cache_write_tokens, billing_provider, " "billing_base_url, billing_mode, estimated_cost_usd, " - "actual_cost_usd, cost_status, cost_source") + "actual_cost_usd, cost_status, cost_source, api_call_count") # Pre-computed query strings — f-string evaluated once at class definition, # not at runtime, so no user-controlled value can alter the query structure. @@ -399,7 +400,12 @@ def _get_message_stats(self, cutoff: float, source: str = None) -> Dict: # Computation # ========================================================================= - def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict: + def _compute_overview( + self, + sessions: List[Dict], + message_stats: Dict, + models: Optional[List[Dict]] = None, + ) -> Dict: """Compute high-level overview statistics.""" total_input = sum(s.get("input_tokens") or 0 for s in sessions) total_output = sum(s.get("output_tokens") or 0 for s in sessions) @@ -431,6 +437,9 @@ def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict: else: models_without_pricing.add(display) + if models: + total_cost = sum(float(m.get("cost") or 0.0) for m in models) + # Session duration stats (guard against negative durations from clock drift) durations = [] for s in sessions: @@ -473,39 +482,189 @@ def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict: "included_cost_sessions": included_cost_sessions, } - def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]: - """Break down usage by model.""" + _GET_MODEL_USAGE_WITH_SOURCE = ( + "SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url," + " u.api_call_count, u.input_tokens, u.output_tokens," + " u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens," + " u.estimated_cost_usd, u.actual_cost_usd, u.cost_status," + " u.cost_source, u.billing_mode" + " FROM session_model_usage u" + " JOIN sessions s ON s.id = u.session_id" + " WHERE s.started_at >= ? AND s.source = ?" + ) + _GET_MODEL_USAGE_ALL = ( + "SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url," + " u.api_call_count, u.input_tokens, u.output_tokens," + " u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens," + " u.estimated_cost_usd, u.actual_cost_usd, u.cost_status," + " u.cost_source, u.billing_mode" + " FROM session_model_usage u" + " JOIN sessions s ON s.id = u.session_id" + " WHERE s.started_at >= ?" + ) + + def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]: + """Fetch per-model usage rows within the window (issue #51607). + + Returns an empty list when the table is missing (e.g. a DB opened by + older code that never created it) so the caller can fall back to the + per-session aggregate. + """ + try: + if source: + cursor = self._conn.execute( + self._GET_MODEL_USAGE_WITH_SOURCE, (cutoff, source) + ) + else: + cursor = self._conn.execute(self._GET_MODEL_USAGE_ALL, (cutoff,)) + return [dict(row) for row in cursor.fetchall()] + except sqlite3.OperationalError: + return [] + + def _compute_model_breakdown( + self, sessions: List[Dict], cutoff: float, source: str = None + ) -> List[Dict]: + """Break down token usage and cost by model. + + Tokens and cost are attributed per model from session_model_usage, so a + session that switched models mid-flight (via ``/model``) splits across + every model it used instead of dumping everything on the initial model + (issue #51607). Sessions without per-model rows — e.g. data written + before this table existed and not yet backfilled — fall back to their + single recorded (model, billing_provider) aggregate so nothing is lost. + + Tool calls aren't tied to a specific API invocation, so they stay + attributed to the session's recorded model. + """ model_data = defaultdict(lambda: { - "sessions": 0, "input_tokens": 0, "output_tokens": 0, + "sessions": set(), "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, - "total_tokens": 0, "tool_calls": 0, "cost": 0.0, + "reasoning_tokens": 0, "total_tokens": 0, "api_calls": 0, + "tool_calls": 0, "cost": 0.0, "actual_cost": 0.0, }) - for s in sessions: - model = s.get("model") or "unknown" + def _accumulate(model, provider, base_url, session_id, inp, out, + cache_read, cache_write, reasoning, *, + stored_cost=None, actual_cost=None, cost_status=None): + model = model or "unknown" # Normalize: strip provider prefix for display display_model = model.split("/")[-1] if "/" in model else model - d = model_data[display_model] - d["sessions"] += 1 - inp = s.get("input_tokens") or 0 - out = s.get("output_tokens") or 0 - cache_read = s.get("cache_read_tokens") or 0 - cache_write = s.get("cache_write_tokens") or 0 + d: Dict[str, Any] = model_data[display_model] + d["sessions"].add(session_id) d["input_tokens"] += inp d["output_tokens"] += out d["cache_read_tokens"] += cache_read d["cache_write_tokens"] += cache_write + d["reasoning_tokens"] += reasoning d["total_tokens"] += inp + out + cache_read + cache_write - d["tool_calls"] += s.get("tool_call_count") or 0 - estimate, status = _estimate_cost(s) + if stored_cost is None: + estimate, status = _estimate_cost( + model, inp, out, + cache_read_tokens=[redacted:secret-assignment] cache_write_tokens=[redacted:secret-assignment] + provider=provider or None, base_url=base_url, + ) + else: + estimate = float(stored_cost or 0.0) + status = cost_status or "unknown" d["cost"] += estimate - d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")) + d["actual_cost"] += float(actual_cost or 0.0) d["cost_status"] = status + if has_known_pricing(model, provider or None, base_url): + d["has_pricing"] = True + else: + d.setdefault("has_pricing", False) + return display_model + + usage_rows = self._get_model_usage(cutoff, source) + usage_totals = defaultdict(lambda: { + "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, + "cache_write_tokens": 0, "reasoning_tokens": 0, + "api_call_count": 0, "estimated_cost_usd": 0.0, + "actual_cost_usd": 0.0, + }) + for r in usage_rows: + totals: Dict[str, Any] = usage_totals[r["session_id"]] + for key in ( + "input_tokens", "output_tokens", "cache_read_tokens", + "cache_write_tokens", "reasoning_tokens", "api_call_count", + ): + totals[key] += r[key] or 0 + totals["estimated_cost_usd"] += r["estimated_cost_usd"] or 0.0 + totals["actual_cost_usd"] += r["actual_cost_usd"] or 0.0 + d = _accumulate( + r["model"], r["billing_provider"], r.get("billing_base_url"), + r["session_id"], r["input_tokens"] or 0, r["output_tokens"] or 0, + r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0, + r["reasoning_tokens"] or 0, + stored_cost=( + r["estimated_cost_usd"] + if r.get("cost_status") or r.get("cost_source") + else None + ), + actual_cost=r["actual_cost_usd"], + cost_status=r.get("cost_status"), + ) + model_data[d]["api_calls"] += r["api_call_count"] or 0 - result = [ - {"model": model, **data} - for model, data in model_data.items() - ] + # Reconcile against the aggregate row. This covers legacy sessions, + # interrupted migrations, and absolute cumulative updates without + # double-counting already-attributed route deltas. + for s in sessions: + totals = usage_totals[s["id"]] + inp = max(0, (s.get("input_tokens") or 0) - totals["input_tokens"]) + out = max(0, (s.get("output_tokens") or 0) - totals["output_tokens"]) + cache_read = max( + 0, (s.get("cache_read_tokens") or 0) - totals["cache_read_tokens"] + ) + cache_write = max( + 0, (s.get("cache_write_tokens") or 0) - totals["cache_write_tokens"] + ) + residual_cost = max( + 0.0, float(s.get("estimated_cost_usd") or 0.0) + - totals["estimated_cost_usd"], + ) + residual_actual = max( + 0.0, float(s.get("actual_cost_usd") or 0.0) + - totals["actual_cost_usd"], + ) + residual_calls = max( + 0, (s.get("api_call_count") or 0) - totals["api_call_count"] + ) + if not ( + inp or out or cache_read or cache_write or residual_cost + or residual_actual or residual_calls + ): + continue + d = _accumulate( + s.get("model"), s.get("billing_provider"), + s.get("billing_base_url"), s["id"], + inp, out, cache_read, cache_write, 0, + stored_cost=residual_cost, + actual_cost=residual_actual, + cost_status=s.get("cost_status"), + ) + residual_bucket: Dict[str, Any] = model_data[d] + residual_bucket["api_calls"] += residual_calls + + # Tool calls are attributed by the session's recorded model. + for s in sessions: + tool_calls = s.get("tool_call_count") or 0 + if not tool_calls: + continue + model = s.get("model") or "unknown" + display_model = model.split("/")[-1] if "/" in model else model + model_data[display_model]["tool_calls"] += tool_calls + + result = [] + for model, data in model_data.items(): + entry = {"model": model, **data} + entry["sessions"] = len(data["sessions"]) + # Models that surfaced only via tool-call attribution (no token + # rows) won't have these set by _accumulate — default them so the + # output shape is uniform for downstream/JSON consumers. + entry.setdefault("has_pricing", False) + entry.setdefault("cost_status", "unknown") + result.append(entry) # Sort by tokens first, fall back to session count when tokens are 0 result.sort(key=lambda x: (x["total_tokens"], x["sessions"]), reverse=True) return result -
hermes_state.py@@ -122,7 +122,7 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]: DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 19 +SCHEMA_VERSION = 20 # Cap on user-controlled FTS5 query input before regex/sanitizer processing. # Search queries do not need to be arbitrarily large, and bounding them keeps @@ -768,6 +768,27 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A compacted INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS session_model_usage ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + model TEXT NOT NULL, + billing_provider TEXT NOT NULL DEFAULT '', + billing_base_url TEXT NOT NULL DEFAULT '', + billing_mode TEXT NOT NULL DEFAULT '', + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost_usd REAL NOT NULL DEFAULT 0, + actual_cost_usd REAL NOT NULL DEFAULT 0, + cost_status TEXT, + cost_source TEXT, + first_seen REAL, + last_seen REAL, + PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode) +); + CREATE TABLE IF NOT EXISTS state_meta ( key TEXT PRIMARY KEY, value TEXT @@ -794,6 +815,8 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at); +CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id); +CREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model); """ # Indexes that reference columns added in later schema versions must be @@ -1554,6 +1577,50 @@ def _init_schema(self): # means consumers fall back to sessions.json for those # rows until the gateway rewrites them. logger.debug("v18 gateway metadata backfill skipped: %s", exc) + if current_version < 20: + # v20: per-model usage attribution (issue #51607). Going + # forward update_token_counts() records each API call into + # session_model_usage keyed by the live model, but existing + # sessions only have their aggregate totals on the sessions + # row. Seed one usage row per historical session from those + # aggregates so insights reads uniformly from the new table. + # INSERT OR IGNORE keeps it idempotent: if newer code already + # wrote a (session_id, model, provider) row for a session, the + # PK conflict skips the stale aggregate rather than doubling it. + try: + cursor.execute( + """INSERT OR IGNORE INTO session_model_usage ( + session_id, model, billing_provider, + billing_base_url, billing_mode, + api_call_count, input_tokens, + output_tokens, cache_read_tokens, + cache_write_tokens, reasoning_tokens, + estimated_cost_usd, actual_cost_usd, + cost_status, cost_source, first_seen, last_seen + ) + SELECT id, COALESCE(model, 'unknown'), + COALESCE(billing_provider, ''), + COALESCE(billing_base_url, ''), + COALESCE(billing_mode, ''), + COALESCE(api_call_count, 0), + COALESCE(input_tokens, 0), + COALESCE(output_tokens, 0), + COALESCE(cache_read_tokens, 0), + COALESCE(cache_write_tokens, 0), + COALESCE(reasoning_tokens, 0), + COALESCE(estimated_cost_usd, 0), + COALESCE(actual_cost_usd, 0), + cost_status, cost_source, + started_at, COALESCE(ended_at, started_at) + FROM sessions + WHERE COALESCE(input_tokens, 0) + + COALESCE(output_tokens, 0) + + COALESCE(cache_read_tokens, 0) + + COALESCE(cache_write_tokens, 0) + + COALESCE(reasoning_tokens, 0) > 0""" + ) + except sqlite3.OperationalError: + pass if current_version < SCHEMA_VERSION and fts_migrations_complete: cursor.execute( "UPDATE schema_version SET version = ?", @@ -2481,6 +2548,11 @@ def update_token_counts( model = COALESCE(model, ?), api_call_count = COALESCE(api_call_count, 0) + ? WHERE id = ?""" + has_accounted_usage = bool( + input_tokens or output_tokens or cache_read_tokens + or cache_write_tokens or reasoning_tokens or api_call_count + or estimated_cost_usd or actual_cost_usd + ) params = ( input_tokens, output_tokens, @@ -2493,17 +2565,167 @@ def update_token_counts( cost_status, cost_source, pricing_version, - billing_provider, - billing_base_url, - billing_mode, - model, + billing_provider if has_accounted_usage else None, + billing_base_url if has_accounted_usage else None, + billing_mode if has_accounted_usage else None, + model if has_accounted_usage else None, api_call_count, session_id, ) + # Per-model usage attribution. ``update_token_counts`` is the single + # chokepoint every per-API-call delta flows through (CLI, gateway, cron, + # delegated runs — see conversation_loop / codex_runtime), and each call + # carries the model/provider *active at the time of that call*. The + # ``sessions`` row only keeps one (model, billing_provider) pair, so a + # mid-session ``/model`` switch otherwise attributes every token to the + # initial model (issue #51607). Recording the per-call delta into + # session_model_usage keyed by the live model preserves an accurate + # per-model breakdown regardless of how many times the user switches. + # + # Only the incremental path records here. Absolute cumulative updates + # cannot be split back into routes; Insights reconciles any positive + # residual against the aggregate session row instead. + record_model_usage = (not absolute) and ( + input_tokens or output_tokens or cache_read_tokens + or cache_write_tokens or reasoning_tokens or api_call_count + or estimated_cost_usd + ) + def _do(conn): + row = conn.execute( + "SELECT model, billing_provider, api_call_count FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + existing_model = row["model"] if row is not None else None + existing_provider = row["billing_provider"] if row is not None else None + existing_api_calls = int((row["api_call_count"] if row is not None else 0) or 0) + + # Session creation records the requested primary route before any API + # call. If it fails and fallback succeeds, the first accounted usage + # event is the first authoritative route. After that, preserve the + # legacy row: one row cannot represent mixed-provider usage. + first_accounted_route = ( + existing_api_calls == 0 + and has_accounted_usage + and bool(model) + and bool(billing_provider) + and (existing_model != model or existing_provider != billing_provider) + ) + if first_accounted_route: + conn.execute( + """UPDATE sessions + SET model = ?, billing_provider = ?, + billing_base_url = ?, billing_mode = ? + WHERE id = ?""", + (model, billing_provider, billing_base_url, billing_mode, session_id), + ) conn.execute(sql, params) + if record_model_usage: + self._record_model_usage( + conn, + session_id, + model=model, + billing_provider=billing_provider, + billing_base_url=billing_base_url, + billing_mode=billing_mode, + input_tokens=[redacted:secret-assignment] + output_tokens=[redacted:secret-assignment] + cache_read_tokens=[redacted:secret-assignment] + cache_write_tokens=[redacted:secret-assignment] + reasoning_tokens=[redacted:secret-assignment] + estimated_cost_usd=estimated_cost_usd, + actual_cost_usd=actual_cost_usd, + cost_status=cost_status, + cost_source=cost_source, + api_call_count=api_call_count, + ) self._execute_write(_do) + def _record_model_usage( + self, + conn, + session_id: str, + *, + model: Optional[str], + billing_provider: Optional[str], + billing_base_url: Optional[str], + billing_mode: Optional[str], + input_tokens: int, + output_tokens: int, + cache_read_tokens: int, + cache_write_tokens: int, + reasoning_tokens: int, + estimated_cost_usd: Optional[float], + actual_cost_usd: Optional[float], + cost_status: Optional[str], + cost_source: Optional[str], + api_call_count: int, + ) -> None: + """Accumulate a per-API-call usage delta into session_model_usage. + + Runs inside the caller's write transaction (after the ``sessions`` + UPDATE) so the per-model rows stay consistent with the summary row. + When the caller omits the model/provider (some paths only pass token + deltas), fall back to the values already recorded on the session row — + the same COALESCE-from-session behaviour the summary update uses. + """ + row = conn.execute( + "SELECT model, billing_provider, billing_base_url, billing_mode " + "FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + sess_model = row["model"] if row is not None else None + sess_provider = row["billing_provider"] if row is not None else None + sess_base_url = row["billing_base_url"] if row is not None else None + sess_billing_mode = row["billing_mode"] if row is not None else None + + eff_model = model or sess_model or "unknown" + eff_provider = billing_provider or sess_provider or "" + eff_base_url = billing_base_url or sess_base_url or "" + eff_billing_mode = billing_mode or sess_billing_mode or "" + now = time.time() + conn.execute( + """INSERT INTO session_model_usage ( + session_id, model, billing_provider, billing_base_url, billing_mode, + api_call_count, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, + estimated_cost_usd, actual_cost_usd, cost_status, cost_source, + first_seen, last_seen + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, model, billing_provider, billing_base_url, billing_mode) + DO UPDATE SET + api_call_count = api_call_count + excluded.api_call_count, + input_tokens = [redacted:secret-assignment] + excluded.input_tokens, + output_tokens = [redacted:secret-assignment] + excluded.output_tokens, + cache_read_tokens = [redacted:secret-assignment] + excluded.cache_read_tokens, + cache_write_tokens = [redacted:secret-assignment] + excluded.cache_write_tokens, + reasoning_tokens = [redacted:secret-assignment] + excluded.reasoning_tokens, + estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd, + actual_cost_usd = actual_cost_usd + excluded.actual_cost_usd, + cost_status = COALESCE(excluded.cost_status, cost_status), + cost_source = COALESCE(excluded.cost_source, cost_source), + last_seen = excluded.last_seen""", + ( + session_id, + eff_model, + eff_provider, + eff_base_url, + eff_billing_mode, + api_call_count or 0, + input_tokens or 0, + output_tokens or 0, + cache_read_tokens or 0, + cache_write_tokens or 0, + reasoning_tokens or 0, + float(estimated_cost_usd or 0.0), + float(actual_cost_usd or 0.0), + cost_status, + cost_source, + now, + now, + ), + ) + def ensure_session( self, session_id: str, -
scripts/release.py@@ -1078,6 +1078,8 @@ "[redacted:email]": "alexzhu0", "[redacted:email]": "AntAISecurityLab", "[redacted:email]": "briandevans", + "[redacted:email]": "tcconnally", + "[redacted:email]": "mijanx", "[redacted:email]": "inchargeautomation-lab", "[redacted:email]": "Bartok9", "[redacted:email]": "sweetcornna", @@ -1509,6 +1511,8 @@ "[redacted:email]": "DaniuXie", "[redacted:email]": "ChanlerDev", "[redacted:email]": "briandevans", + "[redacted:email]": "tcconnally", + "[redacted:email]": "mijanx", "[redacted:email]": "h0tp-ftw", "[redacted:email]": "chinadbo", "[redacted:email]": "kshitijk4poor", -
tests/agent/test_insights.py@@ -320,6 +320,76 @@ def test_model_breakdown(self, populated_db): 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=[redacted:secret-assignment] 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=[redacted:secret-assignment] 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_partial_per_model_rows_preserve_session_totals(self, db): + """A partial rolling-upgrade row must not hide aggregate residuals.""" + db.create_session(session_id="partial", source="cli", model="gpt-4o") + db.update_token_counts( + "partial", input_tokens=100, output_tokens=20, + model="gpt-4o", billing_provider="openai", api_call_count=1, + ) + db.update_token_counts( + "partial", input_tokens=1000, output_tokens=200, + model="gpt-4o", billing_provider="openai", api_call_count=10, + absolute=True, + ) + + report = InsightsEngine(db).generate(days=30) + model = next(m for m in report["models"] if m["model"] == "gpt-4o") + assert model["input_tokens"] == 1000 + assert model["output_tokens"] == 200 + assert model["api_calls"] == 10 + assert sum(m["total_tokens"] for m in report["models"]) == \ + report["overview"]["total_tokens"] + + def test_overview_cost_matches_per_model_stored_cost(self, db): + db.create_session(session_id="cost", source="cli", model="model-a") + db.update_token_counts( + "cost", input_tokens=10, model="model-a", billing_provider="custom", + estimated_cost_usd=1.25, actual_cost_usd=1.0, + cost_status="estimated", cost_source="provider", api_call_count=1, + ) + db.update_session_model("cost", "model-b") + db.update_session_billing_route("cost", provider="custom-b", base_url=None) + db.update_token_counts( + "cost", input_tokens=20, model="model-b", billing_provider="custom-b", + estimated_cost_usd=2.5, actual_cost_usd=2.0, + cost_status="estimated", cost_source="provider", api_call_count=1, + ) + + report = InsightsEngine(db).generate(days=30) + assert sum(m["cost"] for m in report["models"]) == pytest.approx(3.75) + assert report["overview"]["estimated_cost"] == pytest.approx(3.75) + assert report["overview"]["actual_cost"] == pytest.approx(3.0) + def test_platform_breakdown(self, populated_db): engine = InsightsEngine(populated_db) report = engine.generate(days=30) -
tests/test_hermes_state.py@@ -272,6 +272,43 @@ def test_update_token_counts_backfills_model_when_null(self, db): session = db.get_session("s1") assert session["model"] == "openai/gpt-5.4" + def test_first_accounted_fallback_replaces_requested_primary_route(self, db): + """First successful fallback usage must persist one coherent route pair.""" + db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol") + + db.update_token_counts( + "s1", + input_tokens=10, + output_tokens=5, + model="glm-5.2", + billing_provider="custom:zai", + billing_base_url="https://api.z.ai/api/coding/paas/v4/", + api_call_count=1, + ) + + session = db.get_session("s1") + assert session["model"] == "glm-5.2" + assert session["billing_provider"] == "custom:zai" + assert session["billing_base_url"] == "https://api.z.ai/api/coding/paas/v4/" + assert session["api_call_count"] == 1 + + def test_accounted_primary_route_is_not_rewritten_by_later_fallback(self, db): + """A mixed-provider session keeps its first accounted route in the legacy row.""" + db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol") + db.update_token_counts( + "s1", input_tokens=10, output_tokens=5, model="gpt-5.6-sol", + billing_provider="openai-codex", api_call_count=1, + ) + db.update_token_counts( + "s1", input_tokens=10, output_tokens=5, model="glm-5.2", + billing_provider="custom:zai", api_call_count=1, + ) + + session = db.get_session("s1") + assert session["model"] == "gpt-5.6-sol" + assert session["billing_provider"] == "openai-codex" + assert session["api_call_count"] == 2 + def test_update_token_counts_preserves_existing_model(self, db): db.create_session(session_id="s1", source="cli", model="anthropic/claude-opus-4.6") db.update_token_counts("s1", input_tokens=10, output_tokens=5, model="openai/gpt-5.4") @@ -351,6 +388,181 @@ def test_update_session_billing_route_overwrites_after_switch(self, db): 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=[redacted:secret-assignment] output_tokens=[redacted:secret-assignment] + 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=[redacted:secret-assignment] output_tokens=[redacted:secret-assignment] + 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_per_model_usage_keeps_distinct_billing_routes(self, db): + """The same model through distinct billing routes must not collapse.""" + db.create_session(session_id="routes", source="cli", model="shared-model") + db.update_token_counts( + "routes", input_tokens=10, model="shared-model", + billing_provider="custom", billing_base_url="https://one.example/v1", + billing_mode="api_key", estimated_cost_usd=0.01, api_call_count=1, + ) + db.update_token_counts( + "routes", input_tokens=20, model="shared-model", + billing_provider="custom", billing_base_url="https://two.example/v1", + billing_mode="subscription_included", estimated_cost_usd=0.0, + cost_status="included", api_call_count=1, + ) + + rows = db._conn.execute( + "SELECT billing_base_url, billing_mode, input_tokens " + "FROM session_model_usage WHERE session_id = 'routes' " + "ORDER BY billing_base_url" + ).fetchall() + assert [(r["billing_base_url"], r["billing_mode"], r["input_tokens"]) + for r in rows] == [ + ("https://one.example/v1", "api_key", 10), + ("https://two.example/v1", "subscription_included", 20), + ] + + def test_metadata_only_update_does_not_replace_requested_route(self, db): + db.create_session(session_id="metadata", source="cli", model="primary") + db.update_token_counts( + "metadata", model="fallback", billing_provider="fallback-provider", + api_call_count=0, + ) + row = db.get_session("metadata") + assert row["model"] == "primary" + assert row["billing_provider"] is None + + def test_first_accounted_route_replaces_all_route_fields_atomically(self, db): + db.create_session(session_id="route", source="cli", model="primary") + db.update_session_billing_route( + "route", provider="primary-provider", + base_url="https://primary.example/v1", billing_mode="api_key", + ) + db.update_token_counts( + "route", model="fallback", billing_provider="fallback-provider", + billing_base_url=None, billing_mode=None, api_call_count=1, + ) + row = db.get_session("route") + assert row["model"] == "fallback" + assert row["billing_provider"] == "fallback-provider" + assert row["billing_base_url"] is None + assert row["billing_mode"] is None + + 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") @@ -1894,6 +2106,18 @@ def test_delete_session(self, db): assert db.get_session("s1") is None assert db.message_count(session_id="s1") == 0 + def test_delete_session_cascades_per_model_usage(self, db): + db.create_session(session_id="usage", source="cli", model="gpt-5") + db.update_token_counts( + "usage", input_tokens=10, model="gpt-5", + billing_provider="openai", api_call_count=1, + ) + assert db.delete_session("usage") is True + count = db._conn.execute( + "SELECT COUNT(*) FROM session_model_usage WHERE session_id = 'usage'" + ).fetchone()[0] + assert count == 0 + def test_delete_nonexistent(self, db): assert db.delete_session("nope") is False
-
7 PRs closed without merging
From the close comments: merged via another change 3 · superseded by another PR 2 · done elsewhere 1 · duplicate 1
-
#28842 Per-model token tracking for mid-session model switches · superseded by another PR →
#35256,#34850 -
#35181 fix(state): persist mid-session model switch to database · merged via another change →
79f6430,#35256 -
#48254 fix(hermes_state): persist billing provider/base_url after mid-session /model switch · merged via another change →
c7e934a,#52666 -
#48258 fix(state): remove COALESCE guard on billing fields for mid-session provider switch · superseded by another PR →
#52666,#48248 -
#48357 fix(state): allow billing fields to update on model switch (#48248) · duplicate →
#48258,#48254,#48248
Closed issues · 4
- Closed#28637Per-model token usage is lost during mid-session model switches
- Closed#34850Bug: Model switch mid-session not persisted to database (dashboard shows stale model)
- Closed#48248[Bug]: `billing_provider` / `billing_base_url` are write-once via COALESCE — mid-session `/model` switch leaves stale provider in session DB and misleads dashboard model cards
- Closed#51607Session model/billing fields don't reflect mid-session model switches → incorrect metrics
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.13: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab