{"repo":"NousResearch/hermes-agent","public_id":"28637","display_label":"Mid-session model switch persistence and token tracking","summary":"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.","subsystem":"agent","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":3,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35256},"resolution":{"state":"resolved","by":"pr-35256,pr-52666,pr-62610"},"freshness":{"state":"verified","last_verified_at":"2026-07-11T12:59:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13274725274725274},"fix_edges":[{"issue_number":28637,"pr_number":35256,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34850,"pr_number":35256,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":37794,"pr_number":52666,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48173,"pr_number":52666,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48248,"pr_number":52666,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":51607,"pr_number":62610,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":28637,"title":"Per-model token usage is lost during mid-session model switches","url":null,"state":"closed"},{"kind":"issue","number":34850,"title":"Bug: Model switch mid-session not persisted to database (dashboard shows stale model)","url":null,"state":"closed"},{"kind":"issue","number":48248,"title":"[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","url":null,"state":"closed"},{"kind":"issue","number":51607,"title":"Session model/billing fields don't reflect mid-session model switches → incorrect metrics","url":null,"state":"closed"},{"kind":"pr","number":28842,"title":"Per-model token tracking for mid-session model switches","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35256","number":35256},{"kind":"pr","value":"#34850","number":34850}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35181,"title":"fix(state): persist mid-session model switch to database","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"79f643067","number":0},{"kind":"pr","value":"#35256","number":35256}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35256,"title":"fix(state): persist mid-session model switch to database (#34850)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_state.py","scripts/release.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -10549,6 +10549,22 @@ async def _on_model_selected(\n                             except Exception as exc:\n                                 logger.warning(\"Picker model switch failed for cached agent: %s\", exc)\n \n+                        # Persist the new model to the session DB so the\n+                        # dashboard shows the updated model (#34850).\n+                        _sess_db = getattr(_self, \"_session_db\", None)\n+                        if _sess_db is not None:\n+                            try:\n+                                _sess_entry = _self.session_store.get_or_create_session(\n+                                    event.source\n+                                )\n+                                _sess_db.update_session_model(\n+                                    _sess_entry.session_id, result.new_model\n+                                )\n+                            except Exception as exc:\n+                                logger.debug(\n+                                    \"Failed to persist model switch to DB: %s\", exc\n+                                )\n+\n                         # Store model note + session override\n                         if not hasattr(_self, \"_pending_model_notes\"):\n                             _self._pending_model_notes = {}","@@ -10686,6 +10702,20 @@ async def _on_model_selected(\n             except Exception as exc:\n                 logger.warning(\"In-place model switch failed for cached agent: %s\", exc)\n \n+        # Persist the new model to the session DB so the dashboard\n+        # shows the updated model (#34850).\n+        _sess_db = getattr(self, \"_session_db\", None)\n+        if _sess_db is not None:\n+            try:\n+                _sess_entry = self.session_store.get_or_create_session(source)\n+                _sess_db.update_session_model(\n+                    _sess_entry.session_id, result.new_model\n+                )\n+            except Exception as exc:\n+                logger.debug(\n+                    \"Failed to persist model switch to DB: %s\", exc\n+                )\n+\n         # Store a note to prepend to the next user message so the model\n         # knows about the switch (avoids system messages mid-history).\n         if not hasattr(self, \"_pending_model_notes\"):"]},{"file":"hermes_state.py","hunks":["@@ -965,6 +965,20 @@ def _do(conn):\n             )\n         self._execute_write(_do)\n \n+    def update_session_model(self, session_id: str, model: str) -> None:\n+        \"\"\"Update the model for a session after a mid-session switch.\n+\n+        Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``\n+        (only filling in NULL), this unconditionally sets the model column\n+        so that the dashboard reflects the user's latest /model choice.\n+        \"\"\"\n+        def _do(conn):\n+            conn.execute(\n+                \"UPDATE sessions SET model = ? WHERE id = ?\",\n+                (model, session_id),\n+            )\n+        self._execute_write(_do)\n+\n     def update_token_counts(\n         self,\n         session_id: str,"]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"SeaXen\",\n+    \"[redacted:email]\": \"LengR\",\n     \"[redacted:email]\": \"HashClawAI\",\n     \"[redacted:email]\": \"TonyPepeBear\",\n     \"[redacted:email]\": \"nielskaspers\","]},{"file":"tests/test_hermes_state.py","hunks":["@@ -152,6 +152,30 @@ def test_update_token_counts_preserves_existing_model(self, db):\n         session = db.get_session(\"s1\")\n         assert session[\"model\"] == \"anthropic/claude-opus-4.6\"\n \n+    def test_update_session_model_overwrites_existing(self, db):\n+        \"\"\"A mid-session /model switch must overwrite the stored model.\n+\n+        update_token_counts uses COALESCE(model, ?) (first-writer-wins), so\n+        the dashboard kept showing the original model after a switch (#34850).\n+        update_session_model sets the column unconditionally.\n+        \"\"\"\n+        db.create_session(session_id=\"s1\", source=\"telegram\",\n+                          model=\"xiaomi/mimo-v2.5-pro\")\n+        # Token updates never change the model once set.\n+        db.update_token_counts(\"s1\", input_tokens=10, output_tokens=5,\n+                               model=\"xiaomi/mimo-v2.5-pro\")\n+        assert db.get_session(\"s1\")[\"model\"] == \"xiaomi/mimo-v2.5-pro\"\n+\n+        # Explicit switch overwrites it.\n+        db.update_session_model(\"s1\", \"xiaomi/mimo-v2.5\")\n+        assert db.get_session(\"s1\")[\"model\"] == \"xiaomi/mimo-v2.5\"\n+\n+        # And a subsequent token update does NOT revert it (COALESCE no-ops\n+        # because the column is now non-NULL).\n+        db.update_token_counts(\"s1\", input_tokens=10, output_tokens=5,\n+                               model=\"xiaomi/mimo-v2.5-pro\")\n+        assert db.get_session(\"s1\")[\"model\"] == \"xiaomi/mimo-v2.5\"\n+\n     def test_parent_session(self, db):\n         db.create_session(session_id=\"parent\", source=\"cli\")\n         db.create_session(session_id=\"child\", source=\"cli\", parent_session_id=\"parent\")\n"]}]},{"kind":"pr","number":48254,"title":"fix(hermes_state): persist billing provider/base_url after mid-session /model switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c7e934a5b","number":0},{"kind":"pr","value":"#52666","number":52666}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":48258,"title":"fix(state): remove COALESCE guard on billing fields for mid-session provider switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52666","number":52666},{"kind":"pr","value":"#48248","number":48248}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":48357,"title":"fix(state): allow billing fields to update on model switch (#48248)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48258","number":48258},{"kind":"pr","value":"#48258","number":48258},{"kind":"pr","value":"#48254","number":48254},{"kind":"pr","value":"#48248","number":48248},{"kind":"pr","value":"#48248","number":48248}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":49682,"title":"fix(state): use COALESCE(?, model) so mid-session model switches are persisted","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34850","number":34850},{"kind":"pr","value":"#35256","number":35256}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51634,"title":"feat(agent): track per-model token usage for mid-session model switches","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62610","number":62610}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52666,"title":"fix(hermes_state): persist billing route after mid-session /model switch (#48248)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","hermes_state.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1697,6 +1697,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo\n         old_model, old_provider, new_model, new_provider,\n     )\n \n+    # ── Persist billing route to session DB ──\n+    # The agent's _session_db / session_id may not be set in all contexts\n+    # (tests, bare agents without a session DB, etc.).  This ensures the\n+    # dashboard Model cards show the actual provider after a mid-session\n+    # /model switch instead of the stale session-creation provider.\n+    # See #48248 for the full bug description.\n+    _session_db = getattr(agent, \"_session_db\", None)\n+    _session_id = getattr(agent, \"session_id\", None)\n+    if _session_db is not None and _session_id:\n+        try:\n+            _session_db.update_session_billing_route(\n+                _session_id,\n+                provider=agent.provider,\n+                base_url=agent.base_url,\n+                billing_mode=getattr(agent, \"api_mode\", None),\n+            )\n+        except Exception:\n+            logger.warning(\n+                \"Failed to persist billing route after model switch\",\n+                exc_info=True,\n+            )\n \n \n def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,"]},{"file":"hermes_state.py","hunks":["@@ -1584,6 +1584,36 @@ def _do(conn):\n             )\n         self._execute_write(_do)\n \n+    def update_session_billing_route(\n+        self,\n+        session_id: str,\n+        *,\n+        provider: str,\n+        base_url: str,\n+        billing_mode: Optional[str] = None,\n+    ) -> None:\n+        \"\"\"Unconditionally update the billing provider/base_url for a session.\n+\n+        Unlike ``update_token_counts`` which uses ``COALESCE(billing_provider, ?)``\n+        (only filling in NULL), this unconditionally sets the billing fields so\n+        that the dashboard reflects the user's latest /model switch.\n+\n+        Also nulls ``system_prompt`` so the cached snapshot (which embeds a\n+        stale ``Model:`` / ``Provider:`` header) is rebuilt — matching the\n+        behavior of ``update_session_model`` (see #48173, #48248).\n+        \"\"\"\n+        def _do(conn):\n+            conn.execute(\n+                \"\"\"UPDATE sessions SET\n+                   billing_provider = ?,\n+                   billing_base_url = ?,\n+                   billing_mode = COALESCE(?, billing_mode),\n+                   system_prompt = NULL\n+                   WHERE id = ?\"\"\",\n+                (provider, base_url, billing_mode, session_id),\n+            )\n+        self._execute_write(_do)\n+\n     def update_token_counts(\n         self,\n         session_id: str,"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -210,6 +210,54 @@ def test_update_session_model_overwrites_existing(self, db):\n                                model=\"xiaomi/mimo-v2.5-pro\")\n         assert db.get_session(\"s1\")[\"model\"] == \"xiaomi/mimo-v2.5\"\n \n+    def test_update_session_billing_route_overwrites_after_switch(self, db):\n+        \"\"\"A mid-session provider switch must overwrite the billing route.\n+\n+        update_token_counts writes billing fields with\n+        COALESCE(billing_provider, ?) (first-writer-wins), so after a\n+        provider switch the dashboard kept attributing cost to the original\n+        provider (#48248). update_session_billing_route sets them\n+        unconditionally and nulls system_prompt so the next turn rebuilds\n+        the Model:/Provider: header (#48173).\n+        \"\"\"\n+        db.create_session(session_id=\"s1\", source=\"telegram\")\n+        # First token update seeds the billing route.\n+        db.update_token_counts(\"s1\", input_tokens=10, output_tokens=5,\n+                               billing_provider=\"openrouter\",\n+                               billing_base_url=\"https://openrouter.ai/api/v1\",\n+                               billing_mode=\"api_key\")\n+        sess = db.get_session(\"s1\")\n+        assert sess[\"billing_provider\"] == \"openrouter\"\n+        # A later token update never changes it (COALESCE first-writer-wins).\n+        db.update_token_counts(\"s1\", input_tokens=10, output_tokens=5,\n+                               billing_provider=\"ollama\",\n+                               billing_base_url=\"http://localhost:11434/v1\",\n+                               billing_mode=\"local\")\n+        assert db.get_session(\"s1\")[\"billing_provider\"] == \"openrouter\"\n+\n+        # Seed a stale prompt snapshot, then switch the billing route.\n+        db.update_system_prompt(\"s1\", \"Model: x/old\\nProvider: openrouter\")\n+        assert db.get_session(\"s1\")[\"system_prompt\"] is not None\n+        db.update_session_billing_route(\n+            \"s1\", provider=\"ollama\",\n+            base_url=\"http://localhost:11434/v1\", billing_mode=\"local\",\n+        )\n+        sess = db.get_session(\"s1\")\n+        assert sess[\"billing_provider\"] == \"ollama\"\n+        assert sess[\"billing_base_url\"] == \"http://localhost:11434/v1\"\n+        assert sess[\"billing_mode\"] == \"local\"\n+        assert sess[\"system_prompt\"] is None, \\\n+            \"system_prompt must be nulled so the next turn rebuilds Model:/Provider:\"\n+\n+        # billing_mode defaults to COALESCE — omitting it preserves the value.\n+        db.update_session_billing_route(\n+            \"s1\", provider=\"openai\",\n+            base_url=\"https://api.openai.com/v1\",\n+        )\n+        sess = db.get_session(\"s1\")\n+        assert sess[\"billing_provider\"] == \"openai\"\n+        assert sess[\"billing_mode\"] == \"local\"  # preserved (COALESCE on None)\n+\n     def test_parent_session(self, db):\n         db.create_session(session_id=\"parent\", source=\"cli\")\n         db.create_session(session_id=\"child\", source=\"cli\", parent_session_id=\"parent\")\n"]}]},{"kind":"pr","number":62610,"title":"fix(insights): attribute usage across model switches","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/codex_runtime.py","agent/insights.py","hermes_state.py","scripts/release.py","tests/agent/test_insights.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"agent/codex_runtime.py","hunks":["@@ -120,6 +120,9 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:\n                 agent._session_db.update_token_counts(\n                     agent.session_id,\n                     model=agent.model,\n+                    billing_provider=agent.provider,\n+                    billing_base_url=agent.base_url,\n+                    billing_mode=\"subscription_included\",\n                     api_call_count=1,\n                 )\n             except Exception as exc:"]},{"file":"agent/insights.py","hunks":["@@ -17,6 +17,7 @@\n \"\"\"\n \n import json\n+import sqlite3\n import time\n from collections import Counter, defaultdict\n from datetime import datetime","@@ -141,8 +142,8 @@ def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]:\n             }\n \n         # Compute insights\n-        overview = self._compute_overview(sessions, message_stats)\n-        models = self._compute_model_breakdown(sessions)\n+        models = self._compute_model_breakdown(sessions, cutoff, source)\n+        overview = self._compute_overview(sessions, message_stats, models)\n         platforms = self._compute_platform_breakdown(sessions)\n         tools = self._compute_tool_breakdown(tool_usage)\n         skills = self._compute_skill_breakdown(skill_usage)","@@ -172,7 +173,7 @@ def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]:\n                      \"message_count, tool_call_count, input_tokens, output_tokens, \"\n                      \"cache_read_tokens, cache_write_tokens, billing_provider, \"\n                      \"billing_base_url, billing_mode, estimated_cost_usd, \"\n-                     \"actual_cost_usd, cost_status, cost_source\")\n+                     \"actual_cost_usd, cost_status, cost_source, api_call_count\")\n \n     # Pre-computed query strings — f-string evaluated once at class definition,\n     # 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:\n     # Computation\n     # =========================================================================\n \n-    def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:\n+    def _compute_overview(\n+        self,\n+        sessions: List[Dict],\n+        message_stats: Dict,\n+        models: Optional[List[Dict]] = None,\n+    ) -> Dict:\n         \"\"\"Compute high-level overview statistics.\"\"\"\n         total_input = sum(s.get(\"input_tokens\") or 0 for s in sessions)\n         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:\n             else:\n                 models_without_pricing.add(display)\n \n+        if models:\n+            total_cost = sum(float(m.get(\"cost\") or 0.0) for m in models)\n+\n         # Session duration stats (guard against negative durations from clock drift)\n         durations = []\n         for s in sessions:","@@ -473,39 +482,189 @@ def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:\n             \"included_cost_sessions\": included_cost_sessions,\n         }\n \n-    def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]:\n-        \"\"\"Break down usage by model.\"\"\"\n+    _GET_MODEL_USAGE_WITH_SOURCE = (\n+        \"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,\"\n+        \" u.api_call_count, u.input_tokens, u.output_tokens,\"\n+        \" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,\"\n+        \" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,\"\n+        \" u.cost_source, u.billing_mode\"\n+        \" FROM session_model_usage u\"\n+        \" JOIN sessions s ON s.id = u.session_id\"\n+        \" WHERE s.started_at >= ? AND s.source = ?\"\n+    )\n+    _GET_MODEL_USAGE_ALL = (\n+        \"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,\"\n+        \" u.api_call_count, u.input_tokens, u.output_tokens,\"\n+        \" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,\"\n+        \" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,\"\n+        \" u.cost_source, u.billing_mode\"\n+        \" FROM session_model_usage u\"\n+        \" JOIN sessions s ON s.id = u.session_id\"\n+        \" WHERE s.started_at >= ?\"\n+    )\n+\n+    def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]:\n+        \"\"\"Fetch per-model usage rows within the window (issue #51607).\n+\n+        Returns an empty list when the table is missing (e.g. a DB opened by\n+        older code that never created it) so the caller can fall back to the\n+        per-session aggregate.\n+        \"\"\"\n+        try:\n+            if source:\n+                cursor = self._conn.execute(\n+                    self._GET_MODEL_USAGE_WITH_SOURCE, (cutoff, source)\n+                )\n+            else:\n+                cursor = self._conn.execute(self._GET_MODEL_USAGE_ALL, (cutoff,))\n+            return [dict(row) for row in cursor.fetchall()]\n+        except sqlite3.OperationalError:\n+            return []\n+\n+    def _compute_model_breakdown(\n+        self, sessions: List[Dict], cutoff: float, source: str = None\n+    ) -> List[Dict]:\n+        \"\"\"Break down token usage and cost by model.\n+\n+        Tokens and cost are attributed per model from session_model_usage, so a\n+        session that switched models mid-flight (via ``/model``) splits across\n+        every model it used instead of dumping everything on the initial model\n+        (issue #51607). Sessions without per-model rows — e.g. data written\n+        before this table existed and not yet backfilled — fall back to their\n+        single recorded (model, billing_provider) aggregate so nothing is lost.\n+\n+        Tool calls aren't tied to a specific API invocation, so they stay\n+        attributed to the session's recorded model.\n+        \"\"\"\n         model_data = defaultdict(lambda: {\n-            \"sessions\": 0, \"input_tokens\": 0, \"output_tokens\": 0,\n+            \"sessions\": set(), \"input_tokens\": 0, \"output_tokens\": 0,\n             \"cache_read_tokens\": 0, \"cache_write_tokens\": 0,\n-            \"total_tokens\": 0, \"tool_calls\": 0, \"cost\": 0.0,\n+            \"reasoning_tokens\": 0, \"total_tokens\": 0, \"api_calls\": 0,\n+            \"tool_calls\": 0, \"cost\": 0.0, \"actual_cost\": 0.0,\n         })\n \n-        for s in sessions:\n-            model = s.get(\"model\") or \"unknown\"\n+        def _accumulate(model, provider, base_url, session_id, inp, out,\n+                        cache_read, cache_write, reasoning, *,\n+                        stored_cost=None, actual_cost=None, cost_status=None):\n+            model = model or \"unknown\"\n             # Normalize: strip provider prefix for display\n             display_model = model.split(\"/\")[-1] if \"/\" in model else model\n-            d = model_data[display_model]\n-            d[\"sessions\"] += 1\n-            inp = s.get(\"input_tokens\") or 0\n-            out = s.get(\"output_tokens\") or 0\n-            cache_read = s.get(\"cache_read_tokens\") or 0\n-            cache_write = s.get(\"cache_write_tokens\") or 0\n+            d: Dict[str, Any] = model_data[display_model]\n+            d[\"sessions\"].add(session_id)\n             d[\"input_tokens\"] += inp\n             d[\"output_tokens\"] += out\n             d[\"cache_read_tokens\"] += cache_read\n             d[\"cache_write_tokens\"] += cache_write\n+            d[\"reasoning_tokens\"] += reasoning\n             d[\"total_tokens\"] += inp + out + cache_read + cache_write\n-            d[\"tool_calls\"] += s.get(\"tool_call_count\") or 0\n-            estimate, status = _estimate_cost(s)\n+            if stored_cost is None:\n+                estimate, status = _estimate_cost(\n+                    model, inp, out,\n+                    cache_read_tokens=[redacted:secret-assignment] cache_write_tokens=[redacted:secret-assignment]\n+                    provider=provider or None, base_url=base_url,\n+                )\n+            else:\n+                estimate = float(stored_cost or 0.0)\n+                status = cost_status or \"unknown\"\n             d[\"cost\"] += estimate\n-            d[\"has_pricing\"] = has_known_pricing(model, s.get(\"billing_provider\"), s.get(\"billing_base_url\"))\n+            d[\"actual_cost\"] += float(actual_cost or 0.0)\n             d[\"cost_status\"] = status\n+            if has_known_pricing(model, provider or None, base_url):\n+                d[\"has_pricing\"] = True\n+            else:\n+                d.setdefault(\"has_pricing\", False)\n+            return display_model\n+\n+        usage_rows = self._get_model_usage(cutoff, source)\n+        usage_totals = defaultdict(lambda: {\n+            \"input_tokens\": 0, \"output_tokens\": 0, \"cache_read_tokens\": 0,\n+            \"cache_write_tokens\": 0, \"reasoning_tokens\": 0,\n+            \"api_call_count\": 0, \"estimated_cost_usd\": 0.0,\n+            \"actual_cost_usd\": 0.0,\n+        })\n+        for r in usage_rows:\n+            totals: Dict[str, Any] = usage_totals[r[\"session_id\"]]\n+            for key in (\n+                \"input_tokens\", \"output_tokens\", \"cache_read_tokens\",\n+                \"cache_write_tokens\", \"reasoning_tokens\", \"api_call_count\",\n+            ):\n+                totals[key] += r[key] or 0\n+            totals[\"estimated_cost_usd\"] += r[\"estimated_cost_usd\"] or 0.0\n+            totals[\"actual_cost_usd\"] += r[\"actual_cost_usd\"] or 0.0\n+            d = _accumulate(\n+                r[\"model\"], r[\"billing_provider\"], r.get(\"billing_base_url\"),\n+                r[\"session_id\"], r[\"input_tokens\"] or 0, r[\"output_tokens\"] or 0,\n+                r[\"cache_read_tokens\"] or 0, r[\"cache_write_tokens\"] or 0,\n+                r[\"reasoning_tokens\"] or 0,\n+                stored_cost=(\n+                    r[\"estimated_cost_usd\"]\n+                    if r.get(\"cost_status\") or r.get(\"cost_source\")\n+                    else None\n+                ),\n+                actual_cost=r[\"actual_cost_usd\"],\n+                cost_status=r.get(\"cost_status\"),\n+            )\n+            model_data[d][\"api_calls\"] += r[\"api_call_count\"] or 0\n \n-        result = [\n-            {\"model\": model, **data}\n-            for model, data in model_data.items()\n-        ]\n+        # Reconcile against the aggregate row. This covers legacy sessions,\n+        # interrupted migrations, and absolute cumulative updates without\n+        # double-counting already-attributed route deltas.\n+        for s in sessions:\n+            totals = usage_totals[s[\"id\"]]\n+            inp = max(0, (s.get(\"input_tokens\") or 0) - totals[\"input_tokens\"])\n+            out = max(0, (s.get(\"output_tokens\") or 0) - totals[\"output_tokens\"])\n+            cache_read = max(\n+                0, (s.get(\"cache_read_tokens\") or 0) - totals[\"cache_read_tokens\"]\n+            )\n+            cache_write = max(\n+                0, (s.get(\"cache_write_tokens\") or 0) - totals[\"cache_write_tokens\"]\n+            )\n+            residual_cost = max(\n+                0.0, float(s.get(\"estimated_cost_usd\") or 0.0)\n+                - totals[\"estimated_cost_usd\"],\n+            )\n+            residual_actual = max(\n+                0.0, float(s.get(\"actual_cost_usd\") or 0.0)\n+                - totals[\"actual_cost_usd\"],\n+            )\n+            residual_calls = max(\n+                0, (s.get(\"api_call_count\") or 0) - totals[\"api_call_count\"]\n+            )\n+            if not (\n+                inp or out or cache_read or cache_write or residual_cost\n+                or residual_actual or residual_calls\n+            ):\n+                continue\n+            d = _accumulate(\n+                s.get(\"model\"), s.get(\"billing_provider\"),\n+                s.get(\"billing_base_url\"), s[\"id\"],\n+                inp, out, cache_read, cache_write, 0,\n+                stored_cost=residual_cost,\n+                actual_cost=residual_actual,\n+                cost_status=s.get(\"cost_status\"),\n+            )\n+            residual_bucket: Dict[str, Any] = model_data[d]\n+            residual_bucket[\"api_calls\"] += residual_calls\n+\n+        # Tool calls are attributed by the session's recorded model.\n+        for s in sessions:\n+            tool_calls = s.get(\"tool_call_count\") or 0\n+            if not tool_calls:\n+                continue\n+            model = s.get(\"model\") or \"unknown\"\n+            display_model = model.split(\"/\")[-1] if \"/\" in model else model\n+            model_data[display_model][\"tool_calls\"] += tool_calls\n+\n+        result = []\n+        for model, data in model_data.items():\n+            entry = {\"model\": model, **data}\n+            entry[\"sessions\"] = len(data[\"sessions\"])\n+            # Models that surfaced only via tool-call attribution (no token\n+            # rows) won't have these set by _accumulate — default them so the\n+            # output shape is uniform for downstream/JSON consumers.\n+            entry.setdefault(\"has_pricing\", False)\n+            entry.setdefault(\"cost_status\", \"unknown\")\n+            result.append(entry)\n         # Sort by tokens first, fall back to session count when tokens are 0\n         result.sort(key=lambda x: (x[\"total_tokens\"], x[\"sessions\"]), reverse=True)\n         return result"]},{"file":"hermes_state.py","hunks":["@@ -122,7 +122,7 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:\n \n DEFAULT_DB_PATH = get_hermes_home() / \"state.db\"\n \n-SCHEMA_VERSION = 19\n+SCHEMA_VERSION = 20\n \n # Cap on user-controlled FTS5 query input before regex/sanitizer processing.\n # 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\n     compacted INTEGER NOT NULL DEFAULT 0\n );\n \n+CREATE TABLE IF NOT EXISTS session_model_usage (\n+    session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,\n+    model TEXT NOT NULL,\n+    billing_provider TEXT NOT NULL DEFAULT '',\n+    billing_base_url TEXT NOT NULL DEFAULT '',\n+    billing_mode TEXT NOT NULL DEFAULT '',\n+    api_call_count INTEGER NOT NULL DEFAULT 0,\n+    input_tokens INTEGER NOT NULL DEFAULT 0,\n+    output_tokens INTEGER NOT NULL DEFAULT 0,\n+    cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n+    cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n+    reasoning_tokens INTEGER NOT NULL DEFAULT 0,\n+    estimated_cost_usd REAL NOT NULL DEFAULT 0,\n+    actual_cost_usd REAL NOT NULL DEFAULT 0,\n+    cost_status TEXT,\n+    cost_source TEXT,\n+    first_seen REAL,\n+    last_seen REAL,\n+    PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode)\n+);\n+\n CREATE TABLE IF NOT EXISTS state_meta (\n     key TEXT PRIMARY KEY,\n     value TEXT","@@ -794,6 +815,8 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A\n CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);\n CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);\n CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at);\n+CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id);\n+CREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model);\n \"\"\"\n \n # Indexes that reference columns added in later schema versions must be","@@ -1554,6 +1577,50 @@ def _init_schema(self):\n                     # means consumers fall back to sessions.json for those\n                     # rows until the gateway rewrites them.\n                     logger.debug(\"v18 gateway metadata backfill skipped: %s\", exc)\n+            if current_version < 20:\n+                # v20: per-model usage attribution (issue #51607). Going\n+                # forward update_token_counts() records each API call into\n+                # session_model_usage keyed by the live model, but existing\n+                # sessions only have their aggregate totals on the sessions\n+                # row. Seed one usage row per historical session from those\n+                # aggregates so insights reads uniformly from the new table.\n+                # INSERT OR IGNORE keeps it idempotent: if newer code already\n+                # wrote a (session_id, model, provider) row for a session, the\n+                # PK conflict skips the stale aggregate rather than doubling it.\n+                try:\n+                    cursor.execute(\n+                        \"\"\"INSERT OR IGNORE INTO session_model_usage (\n+                               session_id, model, billing_provider,\n+                               billing_base_url, billing_mode,\n+                               api_call_count, input_tokens,\n+                               output_tokens, cache_read_tokens,\n+                               cache_write_tokens, reasoning_tokens,\n+                               estimated_cost_usd, actual_cost_usd,\n+                               cost_status, cost_source, first_seen, last_seen\n+                           )\n+                           SELECT id, COALESCE(model, 'unknown'),\n+                                  COALESCE(billing_provider, ''),\n+                                  COALESCE(billing_base_url, ''),\n+                                  COALESCE(billing_mode, ''),\n+                                  COALESCE(api_call_count, 0),\n+                                  COALESCE(input_tokens, 0),\n+                                  COALESCE(output_tokens, 0),\n+                                  COALESCE(cache_read_tokens, 0),\n+                                  COALESCE(cache_write_tokens, 0),\n+                                  COALESCE(reasoning_tokens, 0),\n+                                  COALESCE(estimated_cost_usd, 0),\n+                                  COALESCE(actual_cost_usd, 0),\n+                                  cost_status, cost_source,\n+                                  started_at, COALESCE(ended_at, started_at)\n+                           FROM sessions\n+                           WHERE COALESCE(input_tokens, 0)\n+                                 + COALESCE(output_tokens, 0)\n+                                 + COALESCE(cache_read_tokens, 0)\n+                                 + COALESCE(cache_write_tokens, 0)\n+                                 + COALESCE(reasoning_tokens, 0) > 0\"\"\"\n+                    )\n+                except sqlite3.OperationalError:\n+                    pass\n             if current_version < SCHEMA_VERSION and fts_migrations_complete:\n                 cursor.execute(\n                     \"UPDATE schema_version SET version = ?\",","@@ -2481,6 +2548,11 @@ def update_token_counts(\n                    model = COALESCE(model, ?),\n                    api_call_count = COALESCE(api_call_count, 0) + ?\n                    WHERE id = ?\"\"\"\n+        has_accounted_usage = bool(\n+            input_tokens or output_tokens or cache_read_tokens\n+            or cache_write_tokens or reasoning_tokens or api_call_count\n+            or estimated_cost_usd or actual_cost_usd\n+        )\n         params = (\n             input_tokens,\n             output_tokens,","@@ -2493,17 +2565,167 @@ def update_token_counts(\n             cost_status,\n             cost_source,\n             pricing_version,\n-            billing_provider,\n-            billing_base_url,\n-            billing_mode,\n-            model,\n+            billing_provider if has_accounted_usage else None,\n+            billing_base_url if has_accounted_usage else None,\n+            billing_mode if has_accounted_usage else None,\n+            model if has_accounted_usage else None,\n             api_call_count,\n             session_id,\n         )\n+        # Per-model usage attribution.  ``update_token_counts`` is the single\n+        # chokepoint every per-API-call delta flows through (CLI, gateway, cron,\n+        # delegated runs — see conversation_loop / codex_runtime), and each call\n+        # carries the model/provider *active at the time of that call*.  The\n+        # ``sessions`` row only keeps one (model, billing_provider) pair, so a\n+        # mid-session ``/model`` switch otherwise attributes every token to the\n+        # initial model (issue #51607).  Recording the per-call delta into\n+        # session_model_usage keyed by the live model preserves an accurate\n+        # per-model breakdown regardless of how many times the user switches.\n+        #\n+        # Only the incremental path records here. Absolute cumulative updates\n+        # cannot be split back into routes; Insights reconciles any positive\n+        # residual against the aggregate session row instead.\n+        record_model_usage = (not absolute) and (\n+            input_tokens or output_tokens or cache_read_tokens\n+            or cache_write_tokens or reasoning_tokens or api_call_count\n+            or estimated_cost_usd\n+        )\n+\n         def _do(conn):\n+            row = conn.execute(\n+                \"SELECT model, billing_provider, api_call_count FROM sessions WHERE id = ?\",\n+                (session_id,),\n+            ).fetchone()\n+            existing_model = row[\"model\"] if row is not None else None\n+            existing_provider = row[\"billing_provider\"] if row is not None else None\n+            existing_api_calls = int((row[\"api_call_count\"] if row is not None else 0) or 0)\n+\n+            # Session creation records the requested primary route before any API\n+            # call. If it fails and fallback succeeds, the first accounted usage\n+            # event is the first authoritative route. After that, preserve the\n+            # legacy row: one row cannot represent mixed-provider usage.\n+            first_accounted_route = (\n+                existing_api_calls == 0\n+                and has_accounted_usage\n+                and bool(model)\n+                and bool(billing_provider)\n+                and (existing_model != model or existing_provider != billing_provider)\n+            )\n+            if first_accounted_route:\n+                conn.execute(\n+                    \"\"\"UPDATE sessions\n+                       SET model = ?, billing_provider = ?,\n+                       billing_base_url = ?, billing_mode = ?\n+                       WHERE id = ?\"\"\",\n+                    (model, billing_provider, billing_base_url, billing_mode, session_id),\n+                )\n             conn.execute(sql, params)\n+            if record_model_usage:\n+                self._record_model_usage(\n+                    conn,\n+                    session_id,\n+                    model=model,\n+                    billing_provider=billing_provider,\n+                    billing_base_url=billing_base_url,\n+                    billing_mode=billing_mode,\n+                    input_tokens=[redacted:secret-assignment]\n+                    output_tokens=[redacted:secret-assignment]\n+                    cache_read_tokens=[redacted:secret-assignment]\n+                    cache_write_tokens=[redacted:secret-assignment]\n+                    reasoning_tokens=[redacted:secret-assignment]\n+                    estimated_cost_usd=estimated_cost_usd,\n+                    actual_cost_usd=actual_cost_usd,\n+                    cost_status=cost_status,\n+                    cost_source=cost_source,\n+                    api_call_count=api_call_count,\n+                )\n         self._execute_write(_do)\n \n+    def _record_model_usage(\n+        self,\n+        conn,\n+        session_id: str,\n+        *,\n+        model: Optional[str],\n+        billing_provider: Optional[str],\n+        billing_base_url: Optional[str],\n+        billing_mode: Optional[str],\n+        input_tokens: int,\n+        output_tokens: int,\n+        cache_read_tokens: int,\n+        cache_write_tokens: int,\n+        reasoning_tokens: int,\n+        estimated_cost_usd: Optional[float],\n+        actual_cost_usd: Optional[float],\n+        cost_status: Optional[str],\n+        cost_source: Optional[str],\n+        api_call_count: int,\n+    ) -> None:\n+        \"\"\"Accumulate a per-API-call usage delta into session_model_usage.\n+\n+        Runs inside the caller's write transaction (after the ``sessions``\n+        UPDATE) so the per-model rows stay consistent with the summary row.\n+        When the caller omits the model/provider (some paths only pass token\n+        deltas), fall back to the values already recorded on the session row —\n+        the same COALESCE-from-session behaviour the summary update uses.\n+        \"\"\"\n+        row = conn.execute(\n+            \"SELECT model, billing_provider, billing_base_url, billing_mode \"\n+            \"FROM sessions WHERE id = ?\",\n+            (session_id,),\n+        ).fetchone()\n+        sess_model = row[\"model\"] if row is not None else None\n+        sess_provider = row[\"billing_provider\"] if row is not None else None\n+        sess_base_url = row[\"billing_base_url\"] if row is not None else None\n+        sess_billing_mode = row[\"billing_mode\"] if row is not None else None\n+\n+        eff_model = model or sess_model or \"unknown\"\n+        eff_provider = billing_provider or sess_provider or \"\"\n+        eff_base_url = billing_base_url or sess_base_url or \"\"\n+        eff_billing_mode = billing_mode or sess_billing_mode or \"\"\n+        now = time.time()\n+        conn.execute(\n+            \"\"\"INSERT INTO session_model_usage (\n+                   session_id, model, billing_provider, billing_base_url, billing_mode,\n+                   api_call_count, input_tokens, output_tokens,\n+                   cache_read_tokens, cache_write_tokens, reasoning_tokens,\n+                   estimated_cost_usd, actual_cost_usd, cost_status, cost_source,\n+                   first_seen, last_seen\n+               ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n+               ON CONFLICT(session_id, model, billing_provider, billing_base_url, billing_mode)\n+               DO UPDATE SET\n+                   api_call_count = api_call_count + excluded.api_call_count,\n+                   input_tokens = [redacted:secret-assignment] + excluded.input_tokens,\n+                   output_tokens = [redacted:secret-assignment] + excluded.output_tokens,\n+                   cache_read_tokens = [redacted:secret-assignment] + excluded.cache_read_tokens,\n+                   cache_write_tokens = [redacted:secret-assignment] + excluded.cache_write_tokens,\n+                   reasoning_tokens = [redacted:secret-assignment] + excluded.reasoning_tokens,\n+                   estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd,\n+                   actual_cost_usd = actual_cost_usd + excluded.actual_cost_usd,\n+                   cost_status = COALESCE(excluded.cost_status, cost_status),\n+                   cost_source = COALESCE(excluded.cost_source, cost_source),\n+                   last_seen = excluded.last_seen\"\"\",\n+            (\n+                session_id,\n+                eff_model,\n+                eff_provider,\n+                eff_base_url,\n+                eff_billing_mode,\n+                api_call_count or 0,\n+                input_tokens or 0,\n+                output_tokens or 0,\n+                cache_read_tokens or 0,\n+                cache_write_tokens or 0,\n+                reasoning_tokens or 0,\n+                float(estimated_cost_usd or 0.0),\n+                float(actual_cost_usd or 0.0),\n+                cost_status,\n+                cost_source,\n+                now,\n+                now,\n+            ),\n+        )\n+\n     def ensure_session(\n         self,\n         session_id: str,"]},{"file":"scripts/release.py","hunks":["@@ -1078,6 +1078,8 @@\n     \"[redacted:email]\": \"alexzhu0\",\n     \"[redacted:email]\": \"AntAISecurityLab\",\n     \"[redacted:email]\": \"briandevans\",\n+    \"[redacted:email]\": \"tcconnally\",\n+    \"[redacted:email]\": \"mijanx\",\n     \"[redacted:email]\": \"inchargeautomation-lab\",\n     \"[redacted:email]\": \"Bartok9\",\n     \"[redacted:email]\": \"sweetcornna\",","@@ -1509,6 +1511,8 @@\n     \"[redacted:email]\": \"DaniuXie\",\n     \"[redacted:email]\": \"ChanlerDev\",\n     \"[redacted:email]\": \"briandevans\",\n+    \"[redacted:email]\": \"tcconnally\",\n+    \"[redacted:email]\": \"mijanx\",\n     \"[redacted:email]\": \"h0tp-ftw\",\n     \"[redacted:email]\": \"chinadbo\",\n     \"[redacted:email]\": \"kshitijk4poor\","]},{"file":"tests/agent/test_insights.py","hunks":["@@ -320,6 +320,76 @@ def test_model_breakdown(self, populated_db):\n         claude = next(m for m in models if \"claude-sonnet\" in m[\"model\"])\n         assert claude[\"sessions\"] == 2\n \n+    def test_model_breakdown_splits_mid_session_switch(self, db):\n+        \"\"\"A session that switches models mid-flight is split across both\n+        models in the breakdown, not dumped on the initial model (#51607).\n+        \"\"\"\n+        now = time.time()\n+        db.create_session(session_id=\"sw\", source=\"cli\",\n+                          model=\"deepseek/deepseek-v4-pro\")\n+        # 40k tokens on deepseek, then switch and 50k on opus.\n+        db.update_token_counts(\"sw\", input_tokens=[redacted:secret-assignment] output_tokens=8000,\n+                               model=\"deepseek/deepseek-v4-pro\",\n+                               billing_provider=\"deepseek\", api_call_count=2)\n+        db.update_session_model(\"sw\", \"anthropic/claude-opus-4.8\")\n+        db.update_token_counts(\"sw\", input_tokens=[redacted:secret-assignment] output_tokens=4000,\n+                               model=\"anthropic/claude-opus-4.8\",\n+                               billing_provider=\"openrouter\", api_call_count=3)\n+        db._conn.commit()\n+\n+        report = InsightsEngine(db).generate(days=30)\n+        models = {m[\"model\"]: m for m in report[\"models\"]}\n+        assert \"deepseek-v4-pro\" in models\n+        assert \"claude-opus-4.8\" in models\n+        # Tokens attributed to the model that actually incurred them.\n+        assert models[\"deepseek-v4-pro\"][\"input_tokens\"] == 40000\n+        assert models[\"claude-opus-4.8\"][\"input_tokens\"] == 50000\n+        assert models[\"claude-opus-4.8\"][\"api_calls\"] == 3\n+        # The summary row's single model would have hidden one of these.\n+        assert models[\"deepseek-v4-pro\"][\"total_tokens\"] == 48000\n+        assert models[\"claude-opus-4.8\"][\"total_tokens\"] == 54000\n+\n+    def test_partial_per_model_rows_preserve_session_totals(self, db):\n+        \"\"\"A partial rolling-upgrade row must not hide aggregate residuals.\"\"\"\n+        db.create_session(session_id=\"partial\", source=\"cli\", model=\"gpt-4o\")\n+        db.update_token_counts(\n+            \"partial\", input_tokens=100, output_tokens=20,\n+            model=\"gpt-4o\", billing_provider=\"openai\", api_call_count=1,\n+        )\n+        db.update_token_counts(\n+            \"partial\", input_tokens=1000, output_tokens=200,\n+            model=\"gpt-4o\", billing_provider=\"openai\", api_call_count=10,\n+            absolute=True,\n+        )\n+\n+        report = InsightsEngine(db).generate(days=30)\n+        model = next(m for m in report[\"models\"] if m[\"model\"] == \"gpt-4o\")\n+        assert model[\"input_tokens\"] == 1000\n+        assert model[\"output_tokens\"] == 200\n+        assert model[\"api_calls\"] == 10\n+        assert sum(m[\"total_tokens\"] for m in report[\"models\"]) == \\\n+            report[\"overview\"][\"total_tokens\"]\n+\n+    def test_overview_cost_matches_per_model_stored_cost(self, db):\n+        db.create_session(session_id=\"cost\", source=\"cli\", model=\"model-a\")\n+        db.update_token_counts(\n+            \"cost\", input_tokens=10, model=\"model-a\", billing_provider=\"custom\",\n+            estimated_cost_usd=1.25, actual_cost_usd=1.0,\n+            cost_status=\"estimated\", cost_source=\"provider\", api_call_count=1,\n+        )\n+        db.update_session_model(\"cost\", \"model-b\")\n+        db.update_session_billing_route(\"cost\", provider=\"custom-b\", base_url=None)\n+        db.update_token_counts(\n+            \"cost\", input_tokens=20, model=\"model-b\", billing_provider=\"custom-b\",\n+            estimated_cost_usd=2.5, actual_cost_usd=2.0,\n+            cost_status=\"estimated\", cost_source=\"provider\", api_call_count=1,\n+        )\n+\n+        report = InsightsEngine(db).generate(days=30)\n+        assert sum(m[\"cost\"] for m in report[\"models\"]) == pytest.approx(3.75)\n+        assert report[\"overview\"][\"estimated_cost\"] == pytest.approx(3.75)\n+        assert report[\"overview\"][\"actual_cost\"] == pytest.approx(3.0)\n+\n     def test_platform_breakdown(self, populated_db):\n         engine = InsightsEngine(populated_db)\n         report = engine.generate(days=30)"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -272,6 +272,43 @@ def test_update_token_counts_backfills_model_when_null(self, db):\n         session = db.get_session(\"s1\")\n         assert session[\"model\"] == \"openai/gpt-5.4\"\n \n+    def test_first_accounted_fallback_replaces_requested_primary_route(self, db):\n+        \"\"\"First successful fallback usage must persist one coherent route pair.\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\", model=\"gpt-5.6-sol\")\n+\n+        db.update_token_counts(\n+            \"s1\",\n+            input_tokens=10,\n+            output_tokens=5,\n+            model=\"glm-5.2\",\n+            billing_provider=\"custom:zai\",\n+            billing_base_url=\"https://api.z.ai/api/coding/paas/v4/\",\n+            api_call_count=1,\n+        )\n+\n+        session = db.get_session(\"s1\")\n+        assert session[\"model\"] == \"glm-5.2\"\n+        assert session[\"billing_provider\"] == \"custom:zai\"\n+        assert session[\"billing_base_url\"] == \"https://api.z.ai/api/coding/paas/v4/\"\n+        assert session[\"api_call_count\"] == 1\n+\n+    def test_accounted_primary_route_is_not_rewritten_by_later_fallback(self, db):\n+        \"\"\"A mixed-provider session keeps its first accounted route in the legacy row.\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\", model=\"gpt-5.6-sol\")\n+        db.update_token_counts(\n+            \"s1\", input_tokens=10, output_tokens=5, model=\"gpt-5.6-sol\",\n+            billing_provider=\"openai-codex\", api_call_count=1,\n+        )\n+        db.update_token_counts(\n+            \"s1\", input_tokens=10, output_tokens=5, model=\"glm-5.2\",\n+            billing_provider=\"custom:zai\", api_call_count=1,\n+        )\n+\n+        session = db.get_session(\"s1\")\n+        assert session[\"model\"] == \"gpt-5.6-sol\"\n+        assert session[\"billing_provider\"] == \"openai-codex\"\n+        assert session[\"api_call_count\"] == 2\n+\n     def test_update_token_counts_preserves_existing_model(self, db):\n         db.create_session(session_id=\"s1\", source=\"cli\", model=\"anthropic/claude-opus-4.6\")\n         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):\n         assert sess[\"billing_provider\"] == \"openai\"\n         assert sess[\"billing_mode\"] == \"local\"  # preserved (COALESCE on None)\n \n+    def test_per_model_usage_recorded_for_single_model(self, db):\n+        \"\"\"Each per-call delta lands in session_model_usage (#51607).\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\")\n+        db.update_token_counts(\"s1\", input_tokens=200, output_tokens=100,\n+                               model=\"anthropic/claude-opus-4.8\",\n+                               billing_provider=\"anthropic\", api_call_count=1)\n+        db.update_token_counts(\"s1\", input_tokens=100, output_tokens=50,\n+                               model=\"anthropic/claude-opus-4.8\",\n+                               billing_provider=\"anthropic\", api_call_count=1)\n+\n+        rows = db._conn.execute(\n+            \"SELECT model, billing_provider, api_call_count, input_tokens, \"\n+            \"output_tokens FROM session_model_usage WHERE session_id = 's1'\"\n+        ).fetchall()\n+        assert len(rows) == 1\n+        row = rows[0]\n+        assert row[\"model\"] == \"anthropic/claude-opus-4.8\"\n+        assert row[\"billing_provider\"] == \"anthropic\"\n+        assert row[\"api_call_count\"] == 2\n+        assert row[\"input_tokens\"] == 300\n+        assert row[\"output_tokens\"] == 150\n+\n+    def test_mid_session_switch_splits_per_model_usage(self, db):\n+        \"\"\"The headline #51607 case: tokens after a /model switch are\n+        attributed to the new model, not the session's initial model.\n+\n+        The ``sessions`` summary row still holds combined totals + the latest\n+        model, but session_model_usage keeps an accurate per-model split.\n+        \"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\",\n+                          model=\"deepseek/deepseek-v4-pro\")\n+        # Pre-switch calls on deepseek.\n+        db.update_token_counts(\"s1\", input_tokens=[redacted:secret-assignment] output_tokens=[redacted:secret-assignment]\n+                               model=\"deepseek/deepseek-v4-pro\",\n+                               billing_provider=\"deepseek\", api_call_count=2)\n+        # User runs /model — the gateway persists the new model …\n+        db.update_session_model(\"s1\", \"anthropic/claude-opus-4.8\")\n+        # … and subsequent per-call deltas carry the new model/provider.\n+        db.update_token_counts(\"s1\", input_tokens=[redacted:secret-assignment] output_tokens=[redacted:secret-assignment]\n+                               model=\"anthropic/claude-opus-4.8\",\n+                               billing_provider=\"openrouter\", api_call_count=3)\n+\n+        rows = {\n+            r[\"model\"]: r\n+            for r in db._conn.execute(\n+                \"SELECT model, billing_provider, input_tokens, output_tokens, \"\n+                \"api_call_count FROM session_model_usage WHERE session_id = 's1'\"\n+            ).fetchall()\n+        }\n+        assert set(rows) == {\"deepseek/deepseek-v4-pro\",\n+                             \"anthropic/claude-opus-4.8\"}\n+        assert rows[\"deepseek/deepseek-v4-pro\"][\"input_tokens\"] == 40_000\n+        assert rows[\"deepseek/deepseek-v4-pro\"][\"api_call_count\"] == 2\n+        assert rows[\"anthropic/claude-opus-4.8\"][\"input_tokens\"] == 50_000\n+        assert rows[\"anthropic/claude-opus-4.8\"][\"billing_provider\"] == \"openrouter\"\n+        assert rows[\"anthropic/claude-opus-4.8\"][\"api_call_count\"] == 3\n+\n+        # Summary row: latest model + combined totals (unchanged behaviour).\n+        session = db.get_session(\"s1\")\n+        assert session[\"model\"] == \"anthropic/claude-opus-4.8\"\n+        assert session[\"input_tokens\"] == 90_000\n+        assert session[\"output_tokens\"] == 12_000\n+\n+    def test_per_model_usage_falls_back_to_session_model(self, db):\n+        \"\"\"When a call omits the model, attribute it to the session's\n+        recorded model — matches the COALESCE-from-session summary behaviour\n+        and keeps existing callers (which pass no model) working.\n+        \"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\",\n+                          model=\"gpt-4o\", )\n+        db.update_token_counts(\"s1\", input_tokens=10, output_tokens=5)\n+\n+        rows = db._conn.execute(\n+            \"SELECT model FROM session_model_usage WHERE session_id = 's1'\"\n+        ).fetchall()\n+        assert len(rows) == 1\n+        assert rows[0][\"model\"] == \"gpt-4o\"\n+\n+    def test_absolute_update_does_not_record_per_model(self, db):\n+        \"\"\"absolute=True overwrites the cumulative summary row (gateway path)\n+        and must NOT add per-model rows — those are accumulated from the\n+        per-call incremental path, so recording here would double-count.\n+        \"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\", model=\"gpt-4o\")\n+        db.update_token_counts(\"s1\", input_tokens=500, output_tokens=200,\n+                               model=\"gpt-4o\", absolute=True)\n+\n+        rows = db._conn.execute(\n+            \"SELECT COUNT(*) AS n FROM session_model_usage WHERE session_id = 's1'\"\n+        ).fetchone()\n+        assert rows[\"n\"] == 0\n+\n+    def test_per_model_usage_keeps_distinct_billing_routes(self, db):\n+        \"\"\"The same model through distinct billing routes must not collapse.\"\"\"\n+        db.create_session(session_id=\"routes\", source=\"cli\", model=\"shared-model\")\n+        db.update_token_counts(\n+            \"routes\", input_tokens=10, model=\"shared-model\",\n+            billing_provider=\"custom\", billing_base_url=\"https://one.example/v1\",\n+            billing_mode=\"api_key\", estimated_cost_usd=0.01, api_call_count=1,\n+        )\n+        db.update_token_counts(\n+            \"routes\", input_tokens=20, model=\"shared-model\",\n+            billing_provider=\"custom\", billing_base_url=\"https://two.example/v1\",\n+            billing_mode=\"subscription_included\", estimated_cost_usd=0.0,\n+            cost_status=\"included\", api_call_count=1,\n+        )\n+\n+        rows = db._conn.execute(\n+            \"SELECT billing_base_url, billing_mode, input_tokens \"\n+            \"FROM session_model_usage WHERE session_id = 'routes' \"\n+            \"ORDER BY billing_base_url\"\n+        ).fetchall()\n+        assert [(r[\"billing_base_url\"], r[\"billing_mode\"], r[\"input_tokens\"])\n+                for r in rows] == [\n+            (\"https://one.example/v1\", \"api_key\", 10),\n+            (\"https://two.example/v1\", \"subscription_included\", 20),\n+        ]\n+\n+    def test_metadata_only_update_does_not_replace_requested_route(self, db):\n+        db.create_session(session_id=\"metadata\", source=\"cli\", model=\"primary\")\n+        db.update_token_counts(\n+            \"metadata\", model=\"fallback\", billing_provider=\"fallback-provider\",\n+            api_call_count=0,\n+        )\n+        row = db.get_session(\"metadata\")\n+        assert row[\"model\"] == \"primary\"\n+        assert row[\"billing_provider\"] is None\n+\n+    def test_first_accounted_route_replaces_all_route_fields_atomically(self, db):\n+        db.create_session(session_id=\"route\", source=\"cli\", model=\"primary\")\n+        db.update_session_billing_route(\n+            \"route\", provider=\"primary-provider\",\n+            base_url=\"https://primary.example/v1\", billing_mode=\"api_key\",\n+        )\n+        db.update_token_counts(\n+            \"route\", model=\"fallback\", billing_provider=\"fallback-provider\",\n+            billing_base_url=None, billing_mode=None, api_call_count=1,\n+        )\n+        row = db.get_session(\"route\")\n+        assert row[\"model\"] == \"fallback\"\n+        assert row[\"billing_provider\"] == \"fallback-provider\"\n+        assert row[\"billing_base_url\"] is None\n+        assert row[\"billing_mode\"] is None\n+\n+    def test_v17_backfill_seeds_existing_session_usage(self, tmp_path):\n+        \"\"\"A DB upgraded from <17 seeds one usage row per historical session\n+        from its aggregate totals, so insights read uniformly from the table.\n+        \"\"\"\n+        db_path = tmp_path / \"legacy.db\"\n+        db = SessionDB(db_path=db_path)\n+        db.create_session(session_id=\"legacy1\", source=\"cli\", model=\"gpt-4o\")\n+        db.update_token_counts(\"legacy1\", input_tokens=1234, output_tokens=567,\n+                               model=\"gpt-4o\", billing_provider=\"openai\")\n+        # Simulate a pre-v17 database: drop the per-model rows and roll the\n+        # recorded schema version back so the backfill migration re-runs.\n+        db._conn.execute(\"DELETE FROM session_model_usage\")\n+        db._conn.execute(\"UPDATE schema_version SET version = 16\")\n+        db._conn.commit()\n+        db.close()\n+\n+        # Reopen — _init_schema should backfill from the sessions aggregate.\n+        db2 = SessionDB(db_path=db_path)\n+        try:\n+            rows = db2._conn.execute(\n+                \"SELECT model, billing_provider, input_tokens, output_tokens \"\n+                \"FROM session_model_usage WHERE session_id = 'legacy1'\"\n+            ).fetchall()\n+            assert len(rows) == 1\n+            assert rows[0][\"model\"] == \"gpt-4o\"\n+            assert rows[0][\"billing_provider\"] == \"openai\"\n+            assert rows[0][\"input_tokens\"] == 1234\n+            assert rows[0][\"output_tokens\"] == 567\n+        finally:\n+            db2.close()\n+\n     def test_parent_session(self, db):\n         db.create_session(session_id=\"parent\", source=\"cli\")\n         db.create_session(session_id=\"child\", source=\"cli\", parent_session_id=\"parent\")","@@ -1894,6 +2106,18 @@ def test_delete_session(self, db):\n         assert db.get_session(\"s1\") is None\n         assert db.message_count(session_id=\"s1\") == 0\n \n+    def test_delete_session_cascades_per_model_usage(self, db):\n+        db.create_session(session_id=\"usage\", source=\"cli\", model=\"gpt-5\")\n+        db.update_token_counts(\n+            \"usage\", input_tokens=10, model=\"gpt-5\",\n+            billing_provider=\"openai\", api_call_count=1,\n+        )\n+        assert db.delete_session(\"usage\") is True\n+        count = db._conn.execute(\n+            \"SELECT COUNT(*) FROM session_model_usage WHERE session_id = 'usage'\"\n+        ).fetchone()[0]\n+        assert count == 0\n+\n     def test_delete_nonexistent(self, db):\n         assert db.delete_session(\"nope\") is False\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}