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

0open issues
0open PRs
14items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35256 fix(state): persist mid-session model switch to database (#34850)

    merged to main

    files: gateway/run.py, hermes_state.py, scripts/release.py, tests/test_hermes_state.py

    Show 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)

    merged to main

    files: agent/agent_runtime_helpers.py, hermes_state.py, tests/test_hermes_state.py

    Show 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

    merged to main

    files: agent/codex_runtime.py, agent/insights.py, hermes_state.py, scripts/release.py, tests/agent/test_insights.py, tests/test_hermes_state.py

    Show 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
Show 2 more
  • #49682 fix(state): use COALESCE(?, model) so mid-session model switches are persisted · done elsewhere → #34850, #35256
  • #51634 feat(agent): track per-model token usage for mid-session model switches · merged via another change → #62610
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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#35256, #52666, #62610
Closed issues
4
Attempts
7 PRs closed without merging
Fix link checked
11 Jul 2026

For your agent

The same facts, typed.