{"repo":"NousResearch/hermes-agent","public_id":"58592","display_label":"Token usage tracking fixes for insights/analytics","summary":"These issues and PRs address gaps in session token accounting, ensuring auxiliary model usage, summarizer tokens, and CLI token counts are all captured and reflected in insights reporting.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65603,"merged_at":"2026-07-16T12:39:34+00:00"},"resolution":{"state":"resolved","by":"pr-65603"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T12:39:34+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":9979,"pr_number":65537,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-16T11:23:12+00:00"},{"issue_number":9979,"pr_number":65603,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T12:39:34+00:00"},{"issue_number":23270,"pr_number":65537,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T11:23:12+00:00"},{"issue_number":58592,"pr_number":65537,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-16T11:23:12+00:00"},{"issue_number":58592,"pr_number":65603,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T12:39:34+00:00"}],"members":[{"kind":"issue","number":58592,"title":"[Bug]: `hermes insights` not tracking all tokens.","url":null,"state":"closed"},{"kind":"pr","number":60758,"title":"fix(compression): count summarizer token usage in session accounting","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65537","number":65537},{"kind":"pr","value":"#65603","number":65603},{"kind":"pr","value":"#58592","number":58592}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":65537,"title":"feat(analytics): record auxiliary model usage per task in session accounting","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/aux_accounting.py","agent/auxiliary_client.py","agent/title_generator.py","hermes_cli/web_server.py","hermes_state.py","run_agent.py","tests/agent/test_auxiliary_client.py","tests/agent/test_auxiliary_compression_timeout_floor.py","tests/agent/test_unsupported_parameter_retry.py","tests/agent/test_unsupported_temperature_retry.py","tests/hermes_state/test_aux_usage_accounting.py","tests/run_agent/test_moa_streaming.py"],"fix_hunks":[{"file":"agent/aux_accounting.py","hunks":["@@ -0,0 +1,138 @@\n+\"\"\"Ambient session-accounting context for auxiliary LLM calls.\n+\n+Auxiliary calls (vision, compression, title generation, web_extract,\n+session_search, ...) funnel through ``agent.auxiliary_client`` which has no\n+session handle — so their token usage was historically discarded, leaving\n+dashboard analytics blind to aux model spend (issue #23270).\n+\n+Instead of threading ``session_db``/``session_id`` parameters through every\n+aux call site, the agent loop publishes them here (mirroring the Nous Portal\n+conversation context in ``agent.portal_tags``) and the auxiliary client\n+records usage at its single response-validation chokepoint.\n+\n+ContextVar semantics give us the right isolation for free:\n+\n+* concurrent agents in one process (gateway sessions, delegate subagents)\n+  never see each other's accounting context;\n+* worker threads spawned via ``tools.thread_context.propagate_context_to_thread``\n+  (MoA fan-out, background review) inherit the parent turn's context;\n+* asyncio tasks inherit the context of the code that created them.\n+\n+MoA reference/aggregator slots are explicitly EXCLUDED from recording:\n+``agent/conversation_loop.py`` already folds MoA advisor usage and cost into\n+the main loop's ``update_token_counts`` delta, so recording them here would\n+double-count (see ``_EXCLUDED_TASKS``).\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import logging\n+from contextvars import ContextVar\n+from typing import Any, Optional\n+\n+logger = logging.getLogger(__name__)\n+\n+# (session_db, session_id) for the active agent turn, or None outside one.\n+_accounting: ContextVar[Optional[tuple]] = ContextVar(\n+    \"aux_accounting_context\", default=None\n+)\n+\n+# Aux tasks whose usage is already accounted by the main loop — recording\n+# them here would double-count. MoA advisor/aggregator usage is folded into\n+# conversation_loop's update_token_counts delta (tokens AND cost).\n+_EXCLUDED_TASKS = frozenset({\"moa_reference\", \"moa_aggregator\"})\n+\n+\n+def set_accounting_context(session_db: Any, session_id: Optional[str]):\n+    \"\"\"Publish the active session's accounting handles for aux usage recording.\n+\n+    Called by the agent loop at turn entry. Returns the ContextVar token so\n+    callers can ``reset_accounting_context(token)`` on turn exit. Publishing\n+    ``None`` handles (no DB / no session id) clears the context.\n+    \"\"\"\n+    if session_db is None or not session_id:\n+        return _accounting.set(None)\n+    return _accounting.set((session_db, session_id))\n+\n+\n+def reset_accounting_context(token) -> None:\n+    \"\"\"Restore the previous accounting context (pair with ``set_...``).\"\"\"\n+    try:\n+        _accounting.reset(token)\n+    except Exception:\n+        _accounting.set(None)\n+\n+\n+def get_accounting_context() -> Optional[tuple]:\n+    \"\"\"Return ``(session_db, session_id)`` for the active turn, or ``None``.\"\"\"\n+    return _accounting.get()\n+\n+\n+def record_aux_usage(\n+    response: Any,\n+    task: Optional[str],\n+    *,\n+    provider: Optional[str] = None,\n+    base_url: Optional[str] = None,\n+) -> None:\n+    \"\"\"Record an auxiliary response's token usage against the ambient session.\n+\n+    Called from the auxiliary client's response-validation chokepoint. Strictly\n+    best-effort: any failure is swallowed (accounting must never break an aux\n+    call). No-ops when:\n+\n+    * no accounting context is published (call is outside any agent turn),\n+    * the task is main-loop-accounted (MoA slots — see ``_EXCLUDED_TASKS``),\n+    * the response carries no usage object.\n+\n+    The model is read from ``response.model`` (accurate even after the aux\n+    client's provider-fallback chains); *provider*/*base_url* reflect the\n+    originally-resolved route and are best-effort.\n+    \"\"\"\n+    try:\n+        if not task or task in _EXCLUDED_TASKS:\n+            return\n+        ctx = _accounting.get()\n+        if ctx is None:\n+            return\n+        session_db, session_id = ctx\n+        raw_usage = getattr(response, \"usage\", None)\n+        if raw_usage is None:\n+            return\n+\n+        from agent.usage_pricing import estimate_usage_cost, normalize_usage\n+\n+        usage = normalize_usage(raw_usage, provider=provider)\n+        if not (\n+            usage.input_tokens or usage.output_tokens\n+            or usage.cache_read_tokens or usage.cache_write_tokens\n+            or usage.reasoning_tokens\n+        ):\n+            return\n+\n+        model = str(getattr(response, \"model\", \"\") or \"\") or \"unknown\"\n+        estimated_cost = None\n+        try:\n+            cost = estimate_usage_cost(\n+                model, usage, provider=provider, base_url=base_url\n+            )\n+            if cost.amount_usd is not None:\n+                estimated_cost = float(cost.amount_usd)\n+        except Exception:\n+            logger.debug(\"Aux usage cost estimation failed\", exc_info=True)\n+\n+        session_db.record_auxiliary_usage(\n+            session_id,\n+            task,\n+            model=model,\n+            billing_provider=provider,\n+            billing_base_url=base_url,\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,\n+        )\n+    except Exception:\n+        logger.debug(\"Aux usage recording failed (non-fatal)\", exc_info=True)"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -6626,7 +6626,12 @@ def _build_call_kwargs(\n     return kwargs\n \n \n-def _validate_llm_response(response: Any, task: str = None) -> Any:\n+def _validate_llm_response(\n+    response: Any,\n+    task: Optional[str] = None,\n+    provider: Optional[str] = None,\n+    base_url: Optional[str] = None,\n+) -> Any:\n     \"\"\"Validate that an LLM response has the expected .choices[0].message shape.\n \n     Fails fast with a clear error instead of letting malformed payloads","@@ -6634,11 +6639,21 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:\n     AttributeError (e.g. \"'str' object has no attribute 'choices'\").\n \n     See #7264.\n+\n+    Also the single accounting chokepoint for auxiliary usage: every\n+    successful non-streaming aux response passes through here exactly once,\n+    so token usage is recorded against the ambient session context published\n+    by the agent loop (``agent.aux_accounting``, issue #23270). Recording is\n+    best-effort and never affects validation. *provider*/*base_url* are\n+    optional accounting hints — fallback-path calls omit them and the row\n+    keeps the model (read from the response itself) with an empty route.\n     \"\"\"\n     if response is None:\n         raise RuntimeError(\n             f\"Auxiliary {task or 'call'}: LLM returned None response\"\n         )\n+    from agent.aux_accounting import record_aux_usage\n+    record_aux_usage(response, task, provider=provider, base_url=base_url)\n     # Allow SimpleNamespace responses from adapters (CodexAuxiliaryClient,\n     # AnthropicAuxiliaryClient) — they have .choices[0].message.\n     try:","@@ -6905,7 +6920,8 @@ def call_llm(\n         # for the transient retry every auxiliary task shares. (PR #16587)\n         try:\n             return _validate_llm_response(\n-                client.chat.completions.create(**kwargs), task)\n+                client.chat.completions.create(**kwargs), task,\n+                provider=resolved_provider, base_url=_base_info)\n         except Exception as transient_err:\n             if not _is_transient_transport_error(transient_err):\n                 raise","@@ -7484,7 +7500,8 @@ async def async_call_llm(\n         # for the rationale. (PR #16587)\n         try:\n             return _validate_llm_response(\n-                await client.chat.completions.create(**kwargs), task)\n+                await client.chat.completions.create(**kwargs), task,\n+                provider=resolved_provider, base_url=_client_base)\n         except Exception as transient_err:\n             if not _is_transient_transport_error(transient_err):\n                 raise"]},{"file":"agent/title_generator.py","hunks":["@@ -151,6 +151,7 @@ def auto_title_session(\n     # ``conversation=`` Portal tag as the turn it titles. Root-of-lineage for\n     # consistency with the agent loop (a no-op on first exchange, where\n     # titling happens, but correct if this ever runs on a continuation).\n+    from agent.aux_accounting import set_accounting_context\n     from agent.portal_tags import set_conversation_context\n \n     conversation_id = session_id","@@ -159,6 +160,9 @@ def auto_title_session(\n     except Exception:\n         pass\n     set_conversation_context(conversation_id)\n+    # Same for the accounting context, so the title call's token usage is\n+    # recorded against this session (task='title_generation', #23270).\n+    set_accounting_context(session_db, session_id)\n \n     title = generate_title(\n         user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -14297,6 +14297,115 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None\n # ---------------------------------------------------------------------------\n \n \n+def _aux_usage_rows(db, cutoff: float) -> List[Dict[str, Any]]:\n+    \"\"\"Per-(model, task) auxiliary usage within the window (issue #23270).\n+\n+    Reads the task-dimension rows (task != '') that record_auxiliary_usage\n+    writes into session_model_usage. Returns [] when the table predates the\n+    task column (older DB opened read-only by newer code).\n+    \"\"\"\n+    try:\n+        cur = db._conn.execute(\"\"\"\n+            SELECT u.model,\n+                   u.task,\n+                   u.billing_provider,\n+                   SUM(u.input_tokens) as input_tokens,\n+                   SUM(u.output_tokens) as output_tokens,\n+                   SUM(u.cache_read_tokens) as cache_read_tokens,\n+                   SUM(u.reasoning_tokens) as reasoning_tokens,\n+                   COALESCE(SUM(u.estimated_cost_usd), 0) as estimated_cost,\n+                   COUNT(DISTINCT u.session_id) as sessions,\n+                   SUM(COALESCE(u.api_call_count, 0)) as api_calls,\n+                   MAX(u.last_seen) as last_used_at\n+            FROM session_model_usage u\n+            JOIN sessions s ON s.id = u.session_id\n+            WHERE s.started_at > ? AND u.task != ''\n+            GROUP BY u.model, u.task, u.billing_provider\n+            ORDER BY SUM(u.input_tokens) + SUM(u.output_tokens) DESC\n+        \"\"\", (cutoff,))\n+        return [dict(r) for r in cur.fetchall()]\n+    except Exception:\n+        # Table predates the task column (older DB opened by newer code) —\n+        # aux breakdown is simply unavailable.\n+        return []\n+\n+\n+def _merge_aux_into_by_model(\n+    by_model: List[Dict[str, Any]], aux_rows: List[Dict[str, Any]]\n+) -> List[Dict[str, Any]]:\n+    \"\"\"Fold aux usage rows into the sessions-derived per-model list.\n+\n+    Aux usage lives only in session_model_usage (never in the sessions\n+    counters), so adding it here cannot double-count. Models that ONLY\n+    appear via aux calls (e.g. a dedicated vision model) get their own\n+    entry — previously they were entirely invisible.\n+    \"\"\"\n+    if not aux_rows:\n+        return by_model\n+    merged: Dict[str, Dict[str, Any]] = {}\n+    for row in by_model:\n+        merged[row.get(\"model\") or \"unknown\"] = row\n+    for aux in aux_rows:\n+        model = aux.get(\"model\") or \"unknown\"\n+        target = merged.get(model)\n+        if target is None:\n+            target = {\n+                \"model\": model,\n+                \"input_tokens\": 0,\n+                \"output_tokens\": 0,\n+                \"estimated_cost\": 0,\n+                \"sessions\": 0,\n+                \"api_calls\": 0,\n+            }\n+            merged[model] = target\n+        target[\"input_tokens\"] = (target.get(\"input_tokens\") or 0) + (aux.get(\"input_tokens\") or 0)\n+        target[\"output_tokens\"] = (target.get(\"output_tokens\") or 0) + (aux.get(\"output_tokens\") or 0)\n+        target[\"estimated_cost\"] = (target.get(\"estimated_cost\") or 0) + (aux.get(\"estimated_cost\") or 0)\n+        target[\"api_calls\"] = (target.get(\"api_calls\") or 0) + (aux.get(\"api_calls\") or 0)\n+        tasks = target.setdefault(\"aux_tasks\", [])\n+        tasks.append({\n+            \"task\": aux.get(\"task\") or \"\",\n+            \"input_tokens\": aux.get(\"input_tokens\") or 0,\n+            \"output_tokens\": aux.get(\"output_tokens\") or 0,\n+            \"estimated_cost\": aux.get(\"estimated_cost\") or 0,\n+            \"api_calls\": aux.get(\"api_calls\") or 0,\n+        })\n+    result = list(merged.values())\n+    result.sort(\n+        key=lambda r: (r.get(\"input_tokens\") or 0) + (r.get(\"output_tokens\") or 0),\n+        reverse=True,\n+    )\n+    return result\n+\n+\n+def _aux_task_summary(aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n+    \"\"\"Aggregate aux usage rows across models into a per-task summary.\"\"\"\n+    by_task: Dict[str, Dict[str, Any]] = {}\n+    for aux in aux_rows:\n+        task = aux.get(\"task\") or \"\"\n+        d = by_task.setdefault(task, {\n+            \"task\": task,\n+            \"input_tokens\": 0,\n+            \"output_tokens\": 0,\n+            \"estimated_cost\": 0,\n+            \"api_calls\": 0,\n+            \"models\": [],\n+        })\n+        d[\"input_tokens\"] += aux.get(\"input_tokens\") or 0\n+        d[\"output_tokens\"] += aux.get(\"output_tokens\") or 0\n+        d[\"estimated_cost\"] += aux.get(\"estimated_cost\") or 0\n+        d[\"api_calls\"] += aux.get(\"api_calls\") or 0\n+        model = aux.get(\"model\") or \"unknown\"\n+        if model not in d[\"models\"]:\n+            d[\"models\"].append(model)\n+    result = list(by_task.values())\n+    result.sort(\n+        key=lambda r: (r.get(\"input_tokens\") or 0) + (r.get(\"output_tokens\") or 0),\n+        reverse=True,\n+    )\n+    return result\n+\n+\n @app.get(\"/api/analytics/usage\")\n async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):\n     from agent.insights import InsightsEngine","@@ -14331,6 +14440,14 @@ async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):\n         \"\"\", (cutoff,))\n         by_model = [dict(r) for r in cur2.fetchall()]\n \n+        # Fold in auxiliary usage (vision, compression, title_generation, ...)\n+        # recorded per (model, task) in session_model_usage. Aux calls never\n+        # touch the sessions counters, so this is add-only — no double count.\n+        # Without it the models list shows only the main agent model even when\n+        # aux models are actively burning tokens (issue #23270).\n+        aux_rows = _aux_usage_rows(db, cutoff)\n+        by_model = _merge_aux_into_by_model(by_model, aux_rows)\n+\n         cur3 = db._conn.execute(\"\"\"\n             SELECT SUM(input_tokens) as total_input,\n                    SUM(output_tokens) as total_output,","@@ -14357,6 +14474,9 @@ async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):\n         return {\n             \"daily\": daily,\n             \"by_model\": by_model,\n+            # Aux-task summary across models (vision, compression, ...). Lets\n+            # the dashboard answer \"what is compression costing me\" directly.\n+            \"by_task\": _aux_task_summary(aux_rows),\n             \"totals\": totals,\n             \"period_days\": days,\n             \"skills\": skills,","@@ -14399,6 +14519,28 @@ async def get_models_analytics(days: int = 30, profile: Optional[str] = None):\n         \"\"\", (cutoff,))\n         raw_rows = [dict(r) for r in cur.fetchall()]\n \n+        # Add auxiliary usage as (model, provider) rows so aux-only models\n+        # (dedicated vision/compression models) appear on the Models page\n+        # instead of being invisible (issue #23270). Keyed by\n+        # model+billing_provider to match the GROUP BY above.\n+        for aux in _aux_usage_rows(db, cutoff):\n+            raw_rows.append({\n+                \"model\": aux.get(\"model\") or \"unknown\",\n+                \"billing_provider\": aux.get(\"billing_provider\") or \"\",\n+                \"input_tokens\": aux.get(\"input_tokens\") or 0,\n+                \"output_tokens\": aux.get(\"output_tokens\") or 0,\n+                \"cache_read_tokens\": aux.get(\"cache_read_tokens\") or 0,\n+                \"reasoning_tokens\": aux.get(\"reasoning_tokens\") or 0,\n+                \"estimated_cost\": aux.get(\"estimated_cost\") or 0,\n+                \"actual_cost\": 0,\n+                \"sessions\": aux.get(\"sessions\") or 0,\n+                \"api_calls\": aux.get(\"api_calls\") or 0,\n+                \"tool_calls\": 0,\n+                \"last_used_at\": aux.get(\"last_used_at\"),\n+                \"avg_tokens_per_session\": 0,\n+                \"aux_task\": aux.get(\"task\") or \"\",\n+            })\n+\n         # Session rows can be created before the first billable provider call\n         # finishes. If that early row records only the model name, and a later\n         # row for the same model has real accounting + billing_provider, the"]},{"file":"hermes_state.py","hunks":["@@ -140,7 +140,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 = 21\n+SCHEMA_VERSION = 22\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","@@ -826,6 +826,7 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A\n     billing_provider TEXT NOT NULL DEFAULT '',\n     billing_base_url TEXT NOT NULL DEFAULT '',\n     billing_mode TEXT NOT NULL DEFAULT '',\n+    task 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,","@@ -838,7 +839,7 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A\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+    PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task)\n );\n \n CREATE TABLE IF NOT EXISTS state_meta (","@@ -1704,6 +1705,72 @@ def _init_schema(self):\n                     )\n                 except sqlite3.OperationalError:\n                     pass\n+            if current_version < 22:\n+                # v22: task-dimension usage attribution (issue #23270).\n+                # session_model_usage gains a ``task`` column ('' = main agent\n+                # loop; 'vision'/'compression'/'title_generation'/... =\n+                # auxiliary calls) so aux model spend is visible in analytics.\n+                # The column participates in the PRIMARY KEY and SQLite cannot\n+                # ALTER a PK, so rebuild the table. The reconciler will have\n+                # already ADDed the plain column on legacy DBs (harmless);\n+                # the rebuild bakes it into the PK properly. Existing rows are\n+                # main-loop accounting by definition → task=''.\n+                try:\n+                    legacy_pk = cursor.execute(\n+                        \"SELECT COUNT(*) FROM pragma_table_info('session_model_usage') \"\n+                        \"WHERE name = 'task' AND pk > 0\"\n+                    ).fetchone()[0]\n+                    if not legacy_pk:\n+                        cursor.execute(\"ALTER TABLE session_model_usage RENAME TO session_model_usage_v21\")\n+                        cursor.execute(\n+                            \"\"\"CREATE TABLE 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+                                   task 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, task)\n+                               )\"\"\"\n+                        )\n+                        cursor.execute(\n+                            \"\"\"INSERT INTO session_model_usage (\n+                                   session_id, model, billing_provider, billing_base_url,\n+                                   billing_mode, task, api_call_count, input_tokens,\n+                                   output_tokens, cache_read_tokens, cache_write_tokens,\n+                                   reasoning_tokens, estimated_cost_usd, actual_cost_usd,\n+                                   cost_status, cost_source, first_seen, last_seen\n+                               )\n+                               SELECT session_id, model, billing_provider, billing_base_url,\n+                                      billing_mode, '', api_call_count, input_tokens,\n+                                      output_tokens, cache_read_tokens, cache_write_tokens,\n+                                      reasoning_tokens, estimated_cost_usd, actual_cost_usd,\n+                                      cost_status, cost_source, first_seen, last_seen\n+                               FROM session_model_usage_v21\"\"\"\n+                        )\n+                        cursor.execute(\"DROP TABLE session_model_usage_v21\")\n+                        cursor.execute(\n+                            \"CREATE INDEX IF NOT EXISTS idx_session_model_usage_session \"\n+                            \"ON session_model_usage(session_id)\"\n+                        )\n+                        cursor.execute(\n+                            \"CREATE INDEX IF NOT EXISTS idx_session_model_usage_model \"\n+                            \"ON session_model_usage(model)\"\n+                        )\n+                except sqlite3.OperationalError as exc:\n+                    logger.debug(\"v22 session_model_usage rebuild skipped: %s\", exc)\n             if current_version < SCHEMA_VERSION and fts_migrations_complete:\n                 cursor.execute(\n                     \"UPDATE schema_version SET version = ?\",","@@ -2786,6 +2853,7 @@ def _record_model_usage(\n         cost_status: Optional[str],\n         cost_source: Optional[str],\n         api_call_count: int,\n+        task: str = \"\",\n     ) -> None:\n         \"\"\"Accumulate a per-API-call usage delta into session_model_usage.\n ","@@ -2794,6 +2862,11 @@ def _record_model_usage(\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+        ``task`` distinguishes what kind of work consumed the tokens:\n+        ``''`` (empty) is the main agent loop; auxiliary calls record their\n+        task name (``vision``, ``compression``, ``title_generation``, ...)\n+        via :meth:`record_auxiliary_usage` (issue #23270).\n         \"\"\"\n         row = conn.execute(\n             \"SELECT model, billing_provider, billing_base_url, billing_mode \"","@@ -2805,20 +2878,30 @@ def _record_model_usage(\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+        # Aux-task rows (task != '') must NOT inherit the session's main-loop\n+        # route: an aux call may use a completely different provider/model\n+        # (vision on gemini while the main loop runs anthropic). Missing info\n+        # stays 'unknown'/empty rather than borrowing a misleading route.\n+        if task:\n+            eff_model = model or \"unknown\"\n+            eff_provider = billing_provider or \"\"\n+            eff_base_url = billing_base_url or \"\"\n+            eff_billing_mode = billing_mode or \"\"\n+        else:\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+                   task, 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+               ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n+               ON CONFLICT(session_id, model, billing_provider, billing_base_url, billing_mode, task)\n                DO UPDATE SET\n                    api_call_count = api_call_count + excluded.api_call_count,\n                    input_tokens = [redacted:secret-assignment] + excluded.input_tokens,","@@ -2837,6 +2920,7 @@ def _record_model_usage(\n                 eff_provider,\n                 eff_base_url,\n                 eff_billing_mode,\n+                task or \"\",\n                 api_call_count or 0,\n                 input_tokens or 0,\n                 output_tokens or 0,","@@ -2863,6 +2947,65 @@ def ensure_session(\n         self._insert_session_row(session_id, source, model=model, **kwargs)\n         return session_id\n \n+    def record_auxiliary_usage(\n+        self,\n+        session_id: str,\n+        task: str,\n+        *,\n+        model: Optional[str] = None,\n+        billing_provider: Optional[str] = None,\n+        billing_base_url: Optional[str] = None,\n+        input_tokens: int = 0,\n+        output_tokens: int = 0,\n+        cache_read_tokens: int = 0,\n+        cache_write_tokens: int = 0,\n+        reasoning_tokens: int = 0,\n+        estimated_cost_usd: Optional[float] = None,\n+    ) -> None:\n+        \"\"\"Record an auxiliary LLM call's usage against *session_id* (issue #23270).\n+\n+        Auxiliary calls (vision, compression, title_generation, web_extract,\n+        session_search, ...) historically discarded their usage, leaving the\n+        dashboard's per-model analytics blind to aux model spend. This writes\n+        a per-(model, provider, task) delta into ``session_model_usage`` —\n+        the same table the main loop's ``update_token_counts`` feeds — WITHOUT\n+        touching the ``sessions`` summary row. That separation is deliberate:\n+        the gateway overwrites session counters with absolute main-loop totals,\n+        so folding aux tokens into the summary row would either be clobbered\n+        or double-counted. Insights/analytics read the union of both.\n+\n+        Best-effort by contract: callers must never fail an aux call because\n+        accounting failed.\n+        \"\"\"\n+        if not session_id or not task:\n+            return\n+        # FK on session_model_usage.session_id → sessions.id: ensure the row\n+        # exists (same INSERT OR IGNORE guard update_token_counts uses — the\n+        # initial create_session() can fail under concurrent SQLite locking).\n+        self._insert_session_row(session_id, \"unknown\")\n+\n+        def _do(conn):\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=None,\n+                input_tokens=[redacted:secret-assignment] or 0,\n+                output_tokens=[redacted:secret-assignment] or 0,\n+                cache_read_tokens=[redacted:secret-assignment] or 0,\n+                cache_write_tokens=[redacted:secret-assignment] or 0,\n+                reasoning_tokens=[redacted:secret-assignment] or 0,\n+                estimated_cost_usd=estimated_cost_usd,\n+                actual_cost_usd=None,\n+                cost_status=None,\n+                cost_source=None,\n+                api_call_count=1,\n+                task=task,\n+            )\n+        self._execute_write(_do)\n+\n     def prune_empty_ghost_sessions(self, sessions_dir: \"Optional[Path]\" = None) -> int:\n         \"\"\"Remove empty TUI ghost sessions (no messages, no title, >24hr old).\"\"\"\n         cutoff = time.time() - 86400  # Only sessions older than 24 hours"]},{"file":"run_agent.py","hunks":["@@ -5917,6 +5917,10 @@ def run_conversation(\n         moa_config: Optional[dict[str, Any]] = None,\n     ) -> Dict[str, Any]:\n         \"\"\"Forwarder — see ``agent.conversation_loop.run_conversation``.\"\"\"\n+        from agent.aux_accounting import (\n+            reset_accounting_context,\n+            set_accounting_context,\n+        )\n         from agent.conversation_loop import run_conversation\n         from agent.portal_tags import (\n             reset_conversation_context,","@@ -5928,6 +5932,13 @@ def run_conversation(\n         # (which copy this Context into their thread) — inherits the\n         # ``conversation=<root>`` tag with zero per-call-site plumbing.\n         token = [redacted:secret-assignment]\n+        # Publish the session accounting handles the same way so auxiliary\n+        # calls record their token usage into session_model_usage (task\n+        # dimension) — the fix for aux spend being invisible in analytics\n+        # (issue #23270).\n+        acct_token = set_accounting_context(\n+            getattr(self, \"_session_db\", None), getattr(self, \"session_id\", None)\n+        )\n         try:\n             return run_conversation(\n                 self,","@@ -5941,6 +5952,7 @@ def run_conversation(\n                 moa_config=moa_config,\n             )\n         finally:\n+            reset_accounting_context(acct_token)\n             reset_conversation_context(token)\n \n     def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:"]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -1474,7 +1474,7 @@ class _Auth401(Exception):\n             patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"nous\", \"nous-model\", None, None, None)),\n             patch(\"agent.auxiliary_client._get_cached_client\", return_value=(stale_client, \"nous-model\")),\n             patch(\"agent.auxiliary_client.OpenAI\", return_value=fresh_client),\n-            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task: resp),\n+            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task, **_kw: resp),\n             patch(\"agent.auxiliary_client._resolve_nous_runtime_api\", return_value=(\"fresh-agent-key\", \"https://inference-api.nousresearch.com/v1\")),\n         ):\n             result = call_llm(","@@ -1506,7 +1506,7 @@ class _Payment404(Exception):\n             patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"nous\", \"nous-model\", None, None, None)),\n             patch(\"agent.auxiliary_client._get_cached_client\", return_value=(stale_client, \"nous-model\")),\n             patch(\"agent.auxiliary_client.OpenAI\", return_value=fresh_client),\n-            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task: resp),\n+            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task, **_kw: resp),\n             patch(\"agent.auxiliary_client._resolve_nous_runtime_api\", return_value=(\"fresh-agent-key\", \"https://inference-api.nousresearch.com/v1\")),\n             patch(\n                 \"hermes_cli.nous_account.get_nous_portal_account_info\",","@@ -1544,7 +1544,7 @@ class _Auth401(Exception):\n             patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"nous\", \"nous-model\", None, None, None)),\n             patch(\"agent.auxiliary_client._get_cached_client\", return_value=(stale_client, \"nous-model\")),\n             patch(\"agent.auxiliary_client._to_async_client\", return_value=(fresh_async_client, \"nous-model\")),\n-            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task: resp),\n+            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task, **_kw: resp),\n             patch(\"agent.auxiliary_client._resolve_nous_runtime_api\", return_value=(\"fresh-agent-key\", \"https://inference-api.nousresearch.com/v1\")),\n         ):\n             result = await async_call_llm(","@@ -1577,7 +1577,7 @@ class _Payment404(Exception):\n             patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"nous\", \"nous-model\", None, None, None)),\n             patch(\"agent.auxiliary_client._get_cached_client\", return_value=(stale_client, \"nous-model\")),\n             patch(\"agent.auxiliary_client._to_async_client\", return_value=(fresh_async_client, \"nous-model\")),\n-            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task: resp),\n+            patch(\"agent.auxiliary_client._validate_llm_response\", side_effect=lambda resp, _task, **_kw: resp),\n             patch(\"agent.auxiliary_client._resolve_nous_runtime_api\", return_value=(\"fresh-agent-key\", \"https://inference-api.nousresearch.com/v1\")),\n             patch(\n                 \"hermes_cli.nous_account.get_nous_portal_account_info\",","@@ -2760,7 +2760,7 @@ def _patches(self, client):\n             ),\n             patch(\n                 \"agent.auxiliary_client._validate_llm_response\",\n-                side_effect=lambda resp, _task: resp,\n+                side_effect=lambda resp, _task, **_kw: resp,\n             ),\n         )\n "]},{"file":"tests/agent/test_auxiliary_compression_timeout_floor.py","hunks":["@@ -65,7 +65,7 @@ def _patches(client, *, task_timeout):\n         patch(\"agent.auxiliary_client._get_cached_client\",\n               return_value=(client, \"gpt-5.5\")),\n         patch(\"agent.auxiliary_client._validate_llm_response\",\n-              side_effect=lambda resp, _task: resp),\n+              side_effect=lambda resp, _task, **_kw: resp),\n         patch(\"agent.auxiliary_client._get_task_timeout\",\n               return_value=task_timeout),\n     )"]},{"file":"tests/agent/test_unsupported_parameter_retry.py","hunks":["@@ -102,7 +102,7 @@ def test_sync_max_tokens_retry_skipped_when_max_tokens_is_none(self):\n             patch(\"agent.auxiliary_client._get_cached_client\",\n                   return_value=(client, \"gpt-5.5\")),\n             patch(\"agent.auxiliary_client._validate_llm_response\",\n-                  side_effect=lambda resp, _task: resp),\n+                  side_effect=lambda resp, _task, **_kw: resp),\n         ):\n             with pytest.raises(RuntimeError):\n                 call_llm(","@@ -129,7 +129,7 @@ async def test_async_max_tokens_retry_skipped_when_max_tokens_is_none(self):\n             patch(\"agent.auxiliary_client._get_cached_client\",\n                   return_value=(client, \"gpt-5.5\")),\n             patch(\"agent.auxiliary_client._validate_llm_response\",\n-                  side_effect=lambda resp, _task: resp),\n+                  side_effect=lambda resp, _task, **_kw: resp),\n         ):\n             with pytest.raises(RuntimeError):\n                 await async_call_llm("]},{"file":"tests/agent/test_unsupported_temperature_retry.py","hunks":["@@ -97,7 +97,7 @@ def test_retries_once_without_temperature(self, error_message):\n             patch(\"agent.auxiliary_client._get_cached_client\",\n                   return_value=(client, \"gpt-5.5\")),\n             patch(\"agent.auxiliary_client._validate_llm_response\",\n-                  side_effect=lambda resp, _task: resp),\n+                  side_effect=lambda resp, _task, **_kw: resp),\n         ):\n             result = call_llm(\n                 task=\"compression\",","@@ -135,7 +135,7 @@ def test_non_temperature_400_does_not_retry_as_temperature(self):\n             patch(\"agent.auxiliary_client._get_cached_client\",\n                   return_value=(client, \"gpt-5.5\")),\n             patch(\"agent.auxiliary_client._validate_llm_response\",\n-                  side_effect=lambda resp, _task: resp),\n+                  side_effect=lambda resp, _task, **_kw: resp),\n             patch(\"agent.auxiliary_client._try_payment_fallback\",\n                   return_value=None),\n         ):","@@ -165,7 +165,7 @@ def test_no_retry_when_temperature_not_in_kwargs(self):\n             patch(\"agent.auxiliary_client._get_cached_client\",\n                   return_value=(client, \"gpt-5.5\")),\n             patch(\"agent.auxiliary_client._validate_llm_response\",\n-                  side_effect=lambda resp, _task: resp),\n+                  side_effect=lambda resp, _task, **_kw: resp),\n             patch(\"agent.auxiliary_client._try_payment_fallback\",\n                   return_value=None),\n         ):","@@ -197,7 +197,7 @@ async def test_async_retries_once_without_temperature(self):\n             patch(\"agent.auxiliary_client._get_cached_client\",\n                   return_value=(client, \"gpt-5.5\")),\n             patch(\"agent.auxiliary_client._validate_llm_response\",\n-                  side_effect=lambda resp, _task: resp),\n+                  side_effect=lambda resp, _task, **_kw: resp),\n         ):\n             result = await async_call_llm(\n                 task=\"session_search\",","@@ -232,7 +232,7 @@ async def test_async_non_temperature_400_does_not_retry(self):\n             patch(\"agent.auxiliary_client._get_cached_client\",\n                   return_value=(client, \"gpt-5.5\")),\n             patch(\"agent.auxiliary_client._validate_llm_response\",\n-                  side_effect=lambda resp, _task: resp),\n+                  side_effect=lambda resp, _task, **_kw: resp),\n             patch(\"agent.auxiliary_client._try_payment_fallback\",\n                   return_value=None),\n         ):"]},{"file":"tests/hermes_state/test_aux_usage_accounting.py","hunks":["@@ -0,0 +1,342 @@\n+\"\"\"Tests for auxiliary usage accounting (issue #23270).\n+\n+Auxiliary LLM calls (vision, compression, title_generation, ...) record\n+their token usage into session_model_usage with a ``task`` dimension via\n+the ambient accounting context (agent/aux_accounting.py), making aux model\n+spend visible in analytics.\n+\"\"\"\n+from pathlib import Path\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]\n+def db(tmp_path):\n+    return SessionDB(tmp_path / \"state.db\")\n+\n+\n+def _mk_response(model=\"aux-model\", prompt=100, completion=20):\n+    return SimpleNamespace(\n+        model=model,\n+        usage=SimpleNamespace(\n+            prompt_tokens=[redacted:secret-assignment]\n+            completion_tokens=[redacted:secret-assignment]\n+            total_tokens=prompt + completion,\n+        ),\n+        choices=[SimpleNamespace(message=SimpleNamespace(content=\"ok\"))],\n+    )\n+\n+\n+def _usage_rows(db, session_id):\n+    with db._lock:\n+        rows = db._conn.execute(\n+            \"SELECT * FROM session_model_usage WHERE session_id = ? ORDER BY task\",\n+            (session_id,),\n+        ).fetchall()\n+    return [dict(r) for r in rows]\n+\n+\n+class TestRecordAuxiliaryUsage:\n+    def test_records_task_row(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.record_auxiliary_usage(\n+            \"s1\", \"vision\", model=\"gemini-3-flash\",\n+            billing_provider=\"gemini\", input_tokens=500, output_tokens=50,\n+        )\n+        rows = _usage_rows(db, \"s1\")\n+        assert len(rows) == 1\n+        r = rows[0]\n+        assert r[\"task\"] == \"vision\"\n+        assert r[\"model\"] == \"gemini-3-flash\"\n+        assert r[\"billing_provider\"] == \"gemini\"\n+        assert r[\"input_tokens\"] == 500\n+        assert r[\"output_tokens\"] == 50\n+        assert r[\"api_call_count\"] == 1\n+\n+    def test_accumulates_same_task_and_model(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        for _ in range(3):\n+            db.record_auxiliary_usage(\n+                \"s1\", \"compression\", model=\"glm-5\", input_tokens=1000, output_tokens=100,\n+            )\n+        rows = _usage_rows(db, \"s1\")\n+        assert len(rows) == 1\n+        assert rows[0][\"input_tokens\"] == 3000\n+        assert rows[0][\"api_call_count\"] == 3\n+\n+    def test_task_rows_do_not_touch_session_counters(self, db):\n+        \"\"\"Aux usage must NOT increment sessions.input_tokens — the gateway\n+        overwrites those with absolute main-loop totals.\"\"\"\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.record_auxiliary_usage(\"s1\", \"vision\", model=\"m\", input_tokens=999)\n+        sess = db.get_session(\"s1\")\n+        assert (sess.get(\"input_tokens\") or 0) == 0\n+\n+    def test_task_row_does_not_inherit_session_route(self, db):\n+        \"\"\"An aux call on a different provider must not borrow the session's\n+        main-loop model/provider.\"\"\"\n+        db.create_session(\"s1\", source=\"cli\", model=\"anthropic/claude-opus-4.6\")\n+        db.update_token_counts(\n+            \"s1\", input_tokens=10, model=\"anthropic/claude-opus-4.6\",\n+            billing_provider=\"anthropic\", api_call_count=1,\n+        )\n+        db.record_auxiliary_usage(\"s1\", \"vision\", input_tokens=5)  # no model given\n+        rows = {r[\"task\"]: r for r in _usage_rows(db, \"s1\")}\n+        assert rows[\"vision\"][\"model\"] == \"unknown\"\n+        assert rows[\"vision\"][\"billing_provider\"] == \"\"\n+        # main-loop row unaffected\n+        assert rows[\"\"][\"model\"] == \"anthropic/claude-opus-4.6\"\n+\n+    def test_main_loop_and_aux_rows_coexist(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.update_token_counts(\n+            \"s1\", input_tokens=100, output_tokens=10,\n+            model=\"main-model\", billing_provider=\"nous\", api_call_count=1,\n+        )\n+        db.record_auxiliary_usage(\n+            \"s1\", \"title_generation\", model=\"main-model\",\n+            billing_provider=\"nous\", input_tokens=40, output_tokens=8,\n+        )\n+        rows = _usage_rows(db, \"s1\")\n+        tasks = sorted(r[\"task\"] for r in rows)\n+        assert tasks == [\"\", \"title_generation\"]\n+\n+    def test_noop_without_session_or_task(self, db):\n+        db.record_auxiliary_usage(\"\", \"vision\", input_tokens=5)\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.record_auxiliary_usage(\"s1\", \"\", input_tokens=5)\n+        assert _usage_rows(db, \"s1\") == []\n+\n+    def test_creates_session_row_if_missing(self, db):\n+        \"\"\"FK safety: recording against a not-yet-created session must not fail.\"\"\"\n+        db.record_auxiliary_usage(\"ghost\", \"vision\", model=\"m\", input_tokens=5)\n+        rows = _usage_rows(db, \"ghost\")\n+        assert len(rows) == 1\n+\n+\n+class TestSchemaMigrationV22:\n+    def test_v21_db_migrates_with_existing_rows(self, tmp_path):\n+        \"\"\"A legacy DB with pre-task rows migrates: rows preserved, task=''.\"\"\"\n+        import sqlite3 as _sq\n+        db = SessionDB(tmp_path / \"state.db\")\n+        db.create_session(\"legacy\", source=\"cli\")\n+        db.update_token_counts(\n+            \"legacy\", input_tokens=42, model=\"old-model\",\n+            billing_provider=\"openrouter\", api_call_count=1,\n+        )\n+        db.close()\n+\n+        # Rebuild the legacy (v21) table shape: task column absent.\n+        conn = _sq.connect(tmp_path / \"state.db\")\n+        conn.executescript(\"\"\"\n+            CREATE TABLE smu_old AS SELECT session_id, model, billing_provider,\n+                billing_base_url, billing_mode, api_call_count, input_tokens,\n+                output_tokens, cache_read_tokens, cache_write_tokens,\n+                reasoning_tokens, estimated_cost_usd, actual_cost_usd,\n+                cost_status, cost_source, first_seen, last_seen\n+                FROM session_model_usage;\n+            DROP TABLE session_model_usage;\n+            CREATE TABLE 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+            INSERT INTO session_model_usage SELECT * FROM smu_old;\n+            DROP TABLE smu_old;\n+            UPDATE schema_version SET version = 21;\n+        \"\"\")\n+        conn.commit()\n+        conn.close()\n+\n+        # Reopen: v22 migration rebuilds the table with task in the PK.\n+        db2 = SessionDB(tmp_path / \"state.db\")\n+        with db2._lock:\n+            pk_cols = [\n+                r[1] for r in db2._conn.execute(\n+                    \"SELECT * FROM pragma_table_info('session_model_usage') WHERE pk > 0\"\n+                ).fetchall()\n+            ]\n+            row = db2._conn.execute(\n+                \"SELECT task, input_tokens FROM session_model_usage WHERE session_id = 'legacy'\"\n+            ).fetchone()\n+        assert \"task\" in pk_cols\n+        assert row is not None\n+        assert row[0] == \"\"  # legacy rows are main-loop accounting\n+        assert row[1] == 42\n+        # And the new dimension works post-migration.\n+        db2.record_auxiliary_usage(\"legacy\", \"vision\", model=\"v\", input_tokens=1)\n+        db2.close()\n+\n+\n+class TestAmbientAccountingContext:\n+    def test_record_aux_usage_writes_through_context(self, db):\n+        from agent.aux_accounting import (\n+            record_aux_usage,\n+            reset_accounting_context,\n+            set_accounting_context,\n+        )\n+\n+        db.create_session(\"s1\", source=\"cli\")\n+        token = [redacted:secret-assignment] \"s1\")\n+        try:\n+            record_aux_usage(_mk_response(model=\"aux-m\"), \"vision\", provider=\"gemini\")\n+        finally:\n+            reset_accounting_context(token)\n+        rows = _usage_rows(db, \"s1\")\n+        assert len(rows) == 1\n+        assert rows[0][\"task\"] == \"vision\"\n+        assert rows[0][\"model\"] == \"aux-m\"\n+        assert rows[0][\"input_tokens\"] == 100\n+        assert rows[0][\"output_tokens\"] == 20\n+\n+    def test_noop_outside_context(self, db):\n+        from agent.aux_accounting import record_aux_usage\n+\n+        db.create_session(\"s1\", source=\"cli\")\n+        record_aux_usage(_mk_response(), \"vision\")\n+        assert _usage_rows(db, \"s1\") == []\n+\n+    def test_moa_tasks_excluded(self, db):\n+        \"\"\"MoA advisor usage is already folded into the main-loop delta by\n+        conversation_loop — recording it here would double-count.\"\"\"\n+        from agent.aux_accounting import (\n+            record_aux_usage,\n+            reset_accounting_context,\n+            set_accounting_context,\n+        )\n+\n+        db.create_session(\"s1\", source=\"cli\")\n+        token = [redacted:secret-assignment] \"s1\")\n+        try:\n+            record_aux_usage(_mk_response(), \"moa_reference\")\n+            record_aux_usage(_mk_response(), \"moa_aggregator\")\n+        finally:\n+            reset_accounting_context(token)\n+        assert _usage_rows(db, \"s1\") == []\n+\n+    def test_no_usage_object_is_noop(self, db):\n+        from agent.aux_accounting import (\n+            record_aux_usage,\n+            reset_accounting_context,\n+            set_accounting_context,\n+        )\n+\n+        db.create_session(\"s1\", source=\"cli\")\n+        resp = SimpleNamespace(model=\"m\", choices=[])\n+        token = [redacted:secret-assignment] \"s1\")\n+        try:\n+            record_aux_usage(resp, \"vision\")\n+        finally:\n+            reset_accounting_context(token)\n+        assert _usage_rows(db, \"s1\") == []\n+\n+    def test_recording_failure_never_raises(self, db):\n+        from agent.aux_accounting import (\n+            record_aux_usage,\n+            reset_accounting_context,\n+            set_accounting_context,\n+        )\n+\n+        class ExplodingDB:\n+            def record_auxiliary_usage(self, *a, **kw):\n+                raise RuntimeError(\"disk full\")\n+\n+        token = [redacted:secret-assignment] \"s1\")\n+        try:\n+            record_aux_usage(_mk_response(), \"vision\")  # must not raise\n+        finally:\n+            reset_accounting_context(token)\n+\n+    def test_validate_llm_response_records(self, db):\n+        \"\"\"The aux client's validation chokepoint feeds the recorder.\"\"\"\n+        from agent.aux_accounting import (\n+            reset_accounting_context,\n+            set_accounting_context,\n+        )\n+        from agent.auxiliary_client import _validate_llm_response\n+\n+        db.create_session(\"s1\", source=\"cli\")\n+        token = [redacted:secret-assignment] \"s1\")\n+        try:\n+            out = _validate_llm_response(_mk_response(), \"web_extract\", provider=\"openrouter\")\n+        finally:\n+            reset_accounting_context(token)\n+        assert out is not None\n+        rows = _usage_rows(db, \"s1\")\n+        assert len(rows) == 1\n+        assert rows[0][\"task\"] == \"web_extract\"\n+        assert rows[0][\"billing_provider\"] == \"openrouter\"\n+\n+    def test_context_isolated_between_copied_contexts(self, db):\n+        import contextvars\n+\n+        from agent.aux_accounting import get_accounting_context, set_accounting_context\n+\n+        def _set_and_get(sid):\n+            set_accounting_context(db, sid)\n+            return get_accounting_context()[1]\n+\n+        a = contextvars.copy_context().run(_set_and_get, \"agent-a\")\n+        b = contextvars.copy_context().run(_set_and_get, \"agent-b\")\n+        assert (a, b) == (\"agent-a\", \"agent-b\")\n+        assert get_accounting_context() is None\n+\n+\n+class TestAnalyticsAuxRows:\n+    def test_aux_usage_rows_and_merge(self, db):\n+        from hermes_cli.web_server import (\n+            _aux_task_summary,\n+            _aux_usage_rows,\n+            _merge_aux_into_by_model,\n+        )\n+\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.update_token_counts(\n+            \"s1\", input_tokens=1000, output_tokens=100,\n+            model=\"main-model\", billing_provider=\"nous\", api_call_count=1,\n+        )\n+        db.record_auxiliary_usage(\n+            \"s1\", \"vision\", model=\"vision-model\",\n+            billing_provider=\"gemini\", input_tokens=300, output_tokens=30,\n+        )\n+        db.record_auxiliary_usage(\n+            \"s1\", \"compression\", model=\"main-model\",\n+            billing_provider=\"nous\", input_tokens=200, output_tokens=20,\n+        )\n+\n+        aux = _aux_usage_rows(db, cutoff=0)\n+        assert {r[\"task\"] for r in aux} == {\"vision\", \"compression\"}\n+\n+        by_model = [{\n+            \"model\": \"main-model\", \"input_tokens\": 1000, \"output_tokens\": 100,\n+            \"estimated_cost\": 0, \"sessions\": 1, \"api_calls\": 1,\n+        }]\n+        merged = _merge_aux_into_by_model(by_model, aux)\n+        by_name = {r[\"model\"]: r for r in merged}\n+        # vision-only model surfaces as its own entry\n+        assert \"vision-model\" in by_name\n+        assert by_name[\"vision-model\"][\"input_tokens\"] == 300\n+        # compression folded into the main model's totals\n+        assert by_name[\"main-model\"][\"input_tokens\"] == 1200\n+        assert by_name[\"main-model\"][\"api_calls\"] == 2\n+\n+        tasks = _aux_task_summary(aux)\n+        assert {t[\"task\"] for t in tasks} == {\"vision\", \"compression\"}"]},{"file":"tests/run_agent/test_moa_streaming.py","hunks":["@@ -207,7 +207,7 @@ def create(self, **kwargs):\n \n     validated = {\"called\": False}\n \n-    def _validate(resp, task):\n+    def _validate(resp, task, provider=None, base_url=None):\n         validated[\"called\"] = True\n         return resp\n \n"]}]},{"kind":"pr","number":65603,"title":"fix(insights): include auxiliary usage in overview token totals","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/insights.py","tests/hermes_state/test_aux_usage_accounting.py"],"fix_hunks":[{"file":"agent/insights.py","hunks":["@@ -439,6 +439,18 @@ def _compute_overview(\n \n         if models:\n             total_cost = sum(float(m.get(\"cost\") or 0.0) for m in models)\n+            # Token totals likewise: the per-model breakdown includes\n+            # auxiliary usage rows (vision/compression/titles — task\n+            # dimension in session_model_usage, #23270) plus reconciled\n+            # residuals, while the sessions counters carry main-loop usage\n+            # only. Summing the breakdown keeps overview totals consistent\n+            # with the per-model table and stops `hermes insights`\n+            # undercounting aux spend (#58592, #9979).\n+            total_input = sum(int(m.get(\"input_tokens\") or 0) for m in models)\n+            total_output = sum(int(m.get(\"output_tokens\") or 0) for m in models)\n+            total_cache_read = sum(int(m.get(\"cache_read_tokens\") or 0) for m in models)\n+            total_cache_write = sum(int(m.get(\"cache_write_tokens\") or 0) for m in models)\n+            total_tokens = [redacted:secret-assignment] + total_output + total_cache_read + total_cache_write\n \n         # Session duration stats (guard against negative durations from clock drift)\n         durations = []"]},{"file":"tests/hermes_state/test_aux_usage_accounting.py","hunks":["@@ -340,3 +340,49 @@ def test_aux_usage_rows_and_merge(self, db):\n \n         tasks = _aux_task_summary(aux)\n         assert {t[\"task\"] for t in tasks} == {\"vision\", \"compression\"}\n+\n+\n+class TestInsightsAuxTotals:\n+    def test_overview_totals_include_aux_usage(self, db):\n+        \"\"\"`hermes insights` overview must count aux tokens, not just the\n+        sessions counters (issues #58592, #9979).\"\"\"\n+        from agent.insights import InsightsEngine\n+\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.update_token_counts(\n+            \"s1\", input_tokens=1000, output_tokens=100,\n+            model=\"main-model\", billing_provider=\"nous\", api_call_count=1,\n+        )\n+        db.record_auxiliary_usage(\n+            \"s1\", \"compression\", model=\"glm-5\",\n+            billing_provider=\"openrouter\", input_tokens=5000, output_tokens=500,\n+        )\n+        report = InsightsEngine(db).generate(days=30)\n+        ov = report[\"overview\"]\n+        assert ov[\"total_input_tokens\"] == 6000\n+        assert ov[\"total_output_tokens\"] == 600\n+        models = {m[\"model\"] for m in report[\"models\"]}\n+        assert {\"main-model\", \"glm-5\"} <= models\n+\n+    def test_overview_totals_not_double_counted_with_absolute_updates(self, db):\n+        \"\"\"Gateway absolute overwrites + aux rows must not inflate totals.\"\"\"\n+        from agent.insights import InsightsEngine\n+\n+        db.create_session(\"s2\", source=\"telegram\")\n+        db.update_token_counts(\n+            \"s2\", input_tokens=2000, output_tokens=200,\n+            model=\"main-model\", billing_provider=\"nous\", api_call_count=1,\n+        )\n+        db.update_token_counts(\n+            \"s2\", input_tokens=2000, output_tokens=200,\n+            model=\"main-model\", billing_provider=\"nous\",\n+            absolute=True, api_call_count=1,\n+        )\n+        db.record_auxiliary_usage(\n+            \"s2\", \"title_generation\", model=\"main-model\",\n+            billing_provider=\"nous\", input_tokens=40, output_tokens=8,\n+        )\n+        report = InsightsEngine(db).generate(days=30)\n+        ov = report[\"overview\"]\n+        assert ov[\"total_input_tokens\"] == 2040\n+        assert ov[\"total_output_tokens\"] == 208\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}