diff --git a/api/routes.py b/api/routes.py index 3e5ae1bc..eff7e3c3 100644 --- a/api/routes.py +++ b/api/routes.py @@ -101,6 +101,8 @@ def _sync_session_title_to_insights(session) -> None: title=session.title, message_count=len(messages), profile=getattr(session, "profile", None), + cache_read_tokens=getattr(session, "cache_read_tokens", None) or 0, + cache_write_tokens=getattr(session, "cache_write_tokens", None) or 0, ) except Exception: logger.debug("Failed to update session title in state.db", exc_info=True) @@ -20690,6 +20692,8 @@ def _handle_chat_sync(handler, body): model=s.model, title=s.title, message_count=len(s.messages), + cache_read_tokens=s.cache_read_tokens or 0, + cache_write_tokens=s.cache_write_tokens or 0, # #2762 / #2827 parity with api/streaming.py:5078: pass the # session's profile explicitly so a future refactor that # backgrounds this handler doesn't silently leak writes to diff --git a/api/state_sync.py b/api/state_sync.py index b35a57bf..07cc1ccb 100644 --- a/api/state_sync.py +++ b/api/state_sync.py @@ -134,7 +134,8 @@ def sync_session_start(session_id: str, model=None, profile: Optional[str] = Non def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0, estimated_cost=None, model=None, title: Optional[str] = None, - message_count: Optional[int] = None, profile: Optional[str] = None) -> None: + message_count: Optional[int] = None, profile: Optional[str] = None, + cache_read_tokens: int = 0, cache_write_tokens: int = 0) -> None: """Update token usage and title for a WebUI session in state.db. Called after each turn completes. Uses absolute=True to set totals (the WebUI Session already accumulates across turns). @@ -153,11 +154,17 @@ def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int= try: # Ensure session exists first (idempotent) db.ensure_session(session_id=session_id, source='webui', model=model) - # Set absolute token counts + # Set absolute token counts. WebUI's sidecar already accumulates + # input/output/cache totals across turns, so mirror the same absolute + # values into state.db. Omitting cache counters makes insights/reporting + # show false 0% hit rates even when the live stream and sidecar saw warm + # prefix reads. db.update_token_counts( session_id=session_id, input_tokens=input_tokens, output_tokens=output_tokens, + cache_read_tokens=cache_read_tokens, + cache_write_tokens=cache_write_tokens, estimated_cost_usd=estimated_cost, model=model, absolute=True, diff --git a/api/streaming.py b/api/streaming.py index 0fd7a829..6b32c7f4 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -9287,6 +9287,8 @@ def _run_agent_streaming( model=model, title=s.title, message_count=len(s.messages), + cache_read_tokens=s.cache_read_tokens or 0, + cache_write_tokens=s.cache_write_tokens or 0, # #2762: pass the session's profile explicitly so the # background-thread state.db lookup doesn't fall # through to the process-global active profile and diff --git a/tests/test_issue2762_state_sync_profile_kwarg.py b/tests/test_issue2762_state_sync_profile_kwarg.py index b6f82f30..a420e9dc 100644 --- a/tests/test_issue2762_state_sync_profile_kwarg.py +++ b/tests/test_issue2762_state_sync_profile_kwarg.py @@ -82,12 +82,14 @@ def _read_session(db_path: Path, session_id: str): if not db_path.exists(): return None conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row try: # Real state.db schema (see api/state_sync.py + hermes_cli StateDB): # `sessions` table has `id` as PRIMARY KEY (not session_id). Use real # column names so the test queries the actual schema. cur = conn.execute( - "SELECT id AS session_id, title, input_tokens, output_tokens " + "SELECT id AS session_id, title, input_tokens, output_tokens, " + "cache_read_tokens, cache_write_tokens " "FROM sessions WHERE id = ?", (session_id,), ) @@ -213,6 +215,8 @@ def test_sync_session_usage_writes_only_to_named_profile(two_profile_homes): title='2762 regression test', message_count=3, profile='maiko', + cache_read_tokens=1200, + cache_write_tokens=300, ) maiko_row = _read_session(two_profile_homes['maiko'] / 'state.db', '2762-regression') @@ -220,6 +224,8 @@ def test_sync_session_usage_writes_only_to_named_profile(two_profile_homes): assert maiko_row is not None, \ "sync_session_usage(profile='maiko') did not write to maiko's state.db" + assert maiko_row["cache_read_tokens"] == 1200 + assert maiko_row["cache_write_tokens"] == 300 assert hiyuki_row is None, \ "sync_session_usage(profile='maiko') leaked into hiyuki's state.db — #2762 regression"