Files
hermes-agent/hermes_state_usage.py
Teknium 6cc1efd8f9 feat(voice): auxiliary.voice_chat routes spoken voice turns to their own model
Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (#83894). A new auxiliary slot, voice_chat, picks the model for voice turns:

- CLI/TUI voice mode, the Desktop voice conversation (prompt.submit voice_turn) and messaging
  voice notes mark the turn; agent/voice_turn_route.py binds the route after the prompt, row and
  preflight compaction were settled against the main model and undoes it before memory sync and
  background review, so the next typed turn and the review run on the main model.
- A turn larger than the voice model's compression threshold stays on the main model with a
  one-time notice: switching to voice never compacts the conversation.
- Usage lands in session_model_usage under task voice_chat; it never rewrites the session's
  recorded model/route, so resume keeps the model the user picked.
- Slot appears in Desktop Settings -> Models, the dashboard, and `hermes model`; GPT-Live
  delegations keep using the session model.
2026-10-07 01:35:11 -07:00

436 lines
25 KiB
Python

"""Token/usage accounting mixin for SessionDB: the coalescing background token writer,
per-model usage rows, and billing-route columns. Writer thread state lives on the instance."""
from __future__ import annotations
import atexit
import contextlib
import logging
import threading
import time
import weakref
from typing import Any, Dict, List, Optional, Tuple
# caplog tests pin the "hermes_state" logger name.
logger = logging.getLogger("hermes_state")
_TOKEN_COUNTERS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens")
def _token_update_sql(delta: bool) -> str:
"""``UPDATE sessions`` for one usage report: *delta* adds to the stored counters (CLI
per-call path), otherwise sets them (gateway cumulative path). Cost/route columns
COALESCE-fill either way (statement text is pinned by the SQL trace harness)."""
def add(col: str) -> str: # "col + ?" / "COALESCE(col, 0) + ?" in delta mode, bare "?" otherwise
return f"{col} + ?" if delta else "?"
def add0(col: str) -> str:
return f"COALESCE({col}, 0) + ?" if delta else "?"
counters = "".join(f" {c} = {add(c)},\n" for c in _TOKEN_COUNTERS)
estimated = "COALESCE(estimated_cost_usd, 0) + COALESCE(?, 0)" if delta else "COALESCE(?, 0)"
return (
"UPDATE sessions SET\n" + counters
+ f""" estimated_cost_usd = {estimated},
actual_cost_usd = CASE
WHEN ? IS NULL THEN actual_cost_usd
ELSE {add0("actual_cost_usd")}
END,
cost_status = COALESCE(?, cost_status),
cost_source = COALESCE(?, cost_source),
pricing_version = COALESCE(?, pricing_version),
billing_provider = COALESCE(billing_provider, ?),
billing_base_url = COALESCE(billing_base_url, ?),
billing_mode = COALESCE(billing_mode, ?),
model = COALESCE(model, ?),
api_call_count = {add0("api_call_count")}
WHERE id = ?"""
)
_TOKEN_UPDATE_ABSOLUTE_SQL = _token_update_sql(delta=False)
_TOKEN_UPDATE_DELTA_SQL = _token_update_sql(delta=True)
_MODEL_USAGE_UPSERT_SQL = """INSERT INTO session_model_usage (
session_id, model, billing_provider, billing_base_url, billing_mode,
task, 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, task)
DO UPDATE SET
api_call_count = api_call_count + excluded.api_call_count,
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
reasoning_tokens = reasoning_tokens + 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"""
# Kwargs forwarded verbatim from update_token_counts / record_auxiliary_usage into
# _record_model_usage (the per-route attribution row).
_MODEL_USAGE_FIELDS = frozenset((
"model", "billing_provider", "billing_base_url", "billing_mode", "input_tokens", "output_tokens",
"cache_read_tokens", "cache_write_tokens", "reasoning_tokens", "estimated_cost_usd",
"actual_cost_usd", "cost_status", "cost_source", "api_call_count"))
class SessionUsageMixin:
"""Coalesced token writer, per-model usage rows, billing route."""
def update_session_billing_route(
self, session_id: str, *, provider: str, base_url: str, billing_mode: Optional[str] = None,
) -> None:
"""Unconditionally set the billing route (``update_token_counts`` only COALESCE-fills
NULLs) so the dashboard reflects the latest /model switch.
Route writers never touch the stored prompt; ``_stored_prompt_matches_runtime`` decides staleness.
See #48173, #48248.
"""
# Barrier against queued token deltas — see update_session_model.
self.flush_token_counts()
def _do(conn):
conn.execute("""UPDATE sessions SET
billing_provider = ?,
billing_base_url = ?,
billing_mode = COALESCE(?, billing_mode)
WHERE id = ?""", (provider, base_url, billing_mode, session_id))
self._execute_write(_do)
def queue_token_counts(self, session_id: str, **kwargs) -> None:
"""Enqueue a token/cost delta for the background writer (same kwargs as
:meth:`update_token_counts`). After close() stopped the writer, falls back to the
synchronous path and may raise."""
with self._token_queue_cond:
thread = self._token_writer_thread
writer_alive = thread is not None and thread.is_alive()
writer_stopped = self._token_writer_stop and not writer_alive
if not writer_stopped:
self._token_queue.append((session_id, kwargs))
if not writer_alive:
# Daemon so exit never hangs on accounting; the atexit hook drains
# leftovers. ``not is_alive()`` respawns a writer that died unexpectedly.
thread = threading.Thread(
target=self._token_writer_loop, name="session-db-token-writer", daemon=True)
self._token_writer_thread = thread
thread.start()
if self._token_atexit_hook is None:
self_ref = weakref.ref(self)
def _drain_at_exit() -> None:
db = self_ref()
if db is not None:
db._drain_token_queue_at_exit()
self._token_atexit_hook = _drain_at_exit
atexit.register(_drain_at_exit)
self._token_queue_cond.notify_all()
if writer_stopped:
# close() ran: enqueueing would drop the delta silently, so apply inline.
self.update_token_counts(session_id, **kwargs)
def _apply_claimed_batch(self, batch) -> None:
"""Apply a batch whose ``busy`` flag the caller already claimed, then release."""
try:
self._apply_token_batch(batch)
finally:
with self._token_queue_cond:
self._token_writer_busy = False
self._token_queue_cond.notify_all()
def flush_token_counts(self, timeout: float = 5.0) -> bool:
"""Block until every queued token delta has been applied. False on timeout (callers
then read totals stale by the queued deltas). Never raises."""
# Lock-free fast path: reads queue-then-busy (see ordering notes below).
if not self._token_queue and not self._token_writer_busy:
return True
batch = None
with self._token_queue_cond:
deadline = time.monotonic() + timeout
while self._token_queue or self._token_writer_busy:
# A live writer is authoritative even when stop-flagged: draining here would
# race its in-flight batch and reorder deltas (breaking last-non-None-wins /
# first-accounted-route / COALESCE-backfill fields). Only a dead writer lets
# the caller take leftovers; a claimed busy means "wait".
thread = self._token_writer_thread
if (thread is None or not thread.is_alive()) and not self._token_writer_busy:
self._token_writer_busy = True
batch = list(self._token_queue)
self._token_queue.clear()
break
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
self._token_queue_cond.wait(remaining)
if batch:
self._apply_claimed_batch(batch)
return True
def _token_writer_loop(self) -> None:
while True:
with self._token_queue_cond:
idle_deadline = time.monotonic() + self._TOKEN_WRITER_IDLE_SECONDS
while not self._token_queue and not self._token_writer_stop:
remaining = idle_deadline - time.monotonic()
if remaining <= 0:
# Retire under the lock queue_token_counts() spawns under, so no
# delta strands behind an exiting worker.
self._token_writer_thread = None
return
self._token_queue_cond.wait(remaining)
if not self._token_queue:
self._token_writer_thread = None
return # stop requested and fully drained
# busy BEFORE clearing the queue: flush's lock-free fast path must never see
# "empty and idle" while a popped batch is unapplied.
self._token_writer_busy = True
batch = list(self._token_queue)
self._token_queue.clear()
self._apply_claimed_batch(batch)
def _apply_token_batch(self, batch: List[Tuple[str, Dict[str, Any]]]) -> None:
"""Apply queued deltas in order, coalescing where safe. Never raises."""
try:
coalesced = self._coalesce_token_deltas(batch)
except Exception as exc:
# Coalescing must never kill the writer; the merge is only an optimization.
logger.warning("async token accounting: coalesce failed, applying raw batch: %s", exc)
coalesced = batch
for session_id, kwargs in coalesced:
try:
self.update_token_counts(session_id, **kwargs)
except Exception as exc:
# Accounting loss is logged, never raised into a turn.
logger.warning("async token accounting: apply failed (session=%s): %s", session_id, exc)
def _coalesce_token_deltas(self, batch: List[Tuple[str, Dict[str, Any]]]) -> List[Tuple[str, Dict[str, Any]]]:
"""Merge adjacent incremental deltas with an identical route, so ordering across
sessions and /model switches is preserved exactly. absolute=True never merges."""
groups: List[Tuple[Optional[tuple], str, Dict[str, Any]]] = []
for session_id, kwargs in batch:
key = None
if not kwargs.get("absolute"):
key = (session_id, *(kwargs.get(f) for f in self._TOKEN_DELTA_ROUTE_FIELDS))
if groups and key is not None and groups[-1][0] == key:
merged = groups[-1][2]
for f in self._TOKEN_DELTA_SUM_FIELDS:
merged[f] = merged.get(f, 0) + kwargs.get(f, 0)
for f in self._TOKEN_DELTA_COST_FIELDS:
value = kwargs.get(f)
if value is not None:
# All-None runs stay None so COALESCE keeps the stored value.
merged[f] = (merged.get(f) or 0.0) + value
else:
groups.append((key, session_id, dict(kwargs)))
return [(sid, kw) for _, sid, kw in groups]
def _stop_token_writer(self, join_timeout: float = 10.0) -> None:
"""Stop the writer thread and drain remaining deltas. Never raises."""
with self._token_queue_cond:
self._token_writer_stop = True
self._token_queue_cond.notify_all()
thread = self._token_writer_thread
if thread is not None and thread.is_alive():
thread.join(timeout=join_timeout)
if thread.is_alive():
# Writer stuck mid-apply: leave deltas unapplied rather than race it.
logger.warning(
"async token accounting: writer did not stop within %.0fs; "
"%d queued delta(s) not persisted", join_timeout, len(self._token_queue))
return
# Writer gone: apply leftovers synchronously under the same busy protocol. Wait out
# a flush caller-drain that already claimed busy — close() nulls the connection
# right after this returns and must not yank it mid-batch.
with self._token_queue_cond:
deadline = time.monotonic() + join_timeout
while self._token_writer_busy:
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning(
"async token accounting: concurrent drain did not "
"finish within %.0fs; %d queued delta(s) not persisted",
join_timeout, len(self._token_queue))
return
self._token_queue_cond.wait(remaining)
# busy BEFORE clearing the queue (same ordering as the writer loop).
batch = list(self._token_queue)
if batch:
self._token_writer_busy = True
self._token_queue.clear()
if batch:
self._apply_claimed_batch(batch)
def _drain_token_queue_at_exit(self) -> None:
with contextlib.suppress(Exception): # never fatal at interpreter shutdown
self._stop_token_writer()
def update_token_counts(
self, session_id: str, input_tokens: int=0, output_tokens: int=0, model: str=None, cache_read_tokens: int=0,
cache_write_tokens: int=0, reasoning_tokens: int=0, estimated_cost_usd: Optional[float]=None,
actual_cost_usd: Optional[float]=None, cost_status: Optional[str]=None, cost_source: Optional[str]=None,
pricing_version: Optional[str]=None, billing_provider: Optional[str]=None, billing_base_url: Optional[str]=None,
billing_mode: Optional[str]=None, api_call_count: int=0, absolute: bool=False,
source: Optional[str]=None, task: str = "",
) -> None:
"""Update token counters and backfill model if unset. *absolute*=False increments
(per-API-call deltas, CLI path); *absolute*=True sets directly (gateway path,
where the cached agent holds cumulative totals). ``source`` is the session's real surface
for the row-existence guard; callers that don't know it leave the placeholder. ``task`` names a
per-turn route (``voice_chat``): its calls count in the session totals and land in
``session_model_usage`` under that task, but never become the session's recorded route."""
usage = {k: v for k, v in locals().items() if k in _MODEL_USAGE_FIELDS}
# Ensure the row exists: under concurrent load create_session() may have failed on
# locking, and the UPDATE would silently affect 0 rows. When this guard is the first
# writer it must carry the agent's real source: the turn lease treats an existing row as
# proof the create already happened, so the creator never returns to repair an anonymous
# ``unknown`` placeholder and the session stays a phantom for life (#111999).
self._insert_session_row(session_id, source or "unknown", model=model)
sql = _TOKEN_UPDATE_ABSOLUTE_SQL if absolute else _TOKEN_UPDATE_DELTA_SQL
has_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)
has_accounted_usage = bool(has_usage or actual_cost_usd)
params = (
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens,
estimated_cost_usd, actual_cost_usd, actual_cost_usd, cost_status, cost_source, pricing_version,
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 attribution: the sessions row keeps one (model, provider) pair, so a
# mid-session /model switch would attribute every token to the initial model. Only
# the incremental path records here — absolute cumulative updates cannot be split
# back into routes; Insights reconciles the residual instead.
# ``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*. 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. See #51607.
record_model_usage = (not absolute) and has_usage
def _do(conn):
row = conn.execute(
"SELECT model, billing_provider, api_call_count FROM sessions WHERE id = ?", (session_id,),
).fetchone()
existing = dict(row) if row is not None else {}
# create_session records the requested route before any API call. If that fails
# and fallback succeeds, the first accounted usage is the authoritative route;
# after that keep the row as is (one row cannot represent mixed usage).
first_accounted_route = (
not task and int(existing.get("api_call_count") or 0) == 0 and has_accounted_usage and bool(model)
and bool(billing_provider)
and (existing.get("model") != model or existing.get("billing_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, task=task, **usage)
self._execute_write(_do)
def _record_model_usage(
self, conn, session_id: str, *, model: Optional[str]=None, billing_provider: Optional[str]=None,
billing_base_url: Optional[str]=None, billing_mode: Optional[str]=None, input_tokens: int=0,
output_tokens: int=0, cache_read_tokens: int=0, cache_write_tokens: int=0, reasoning_tokens: int=0,
estimated_cost_usd: Optional[float]=None, actual_cost_usd: Optional[float]=None,
cost_status: Optional[str]=None, cost_source: Optional[str]=None, api_call_count: int=0, task: str="",
) -> None:
"""Accumulate a per-API-call usage delta into session_model_usage, inside the caller's
write txn after the ``sessions`` UPDATE. A missing model/provider falls back to
the session row — except for aux rows (``task`` set), which must NOT inherit the
main-loop route (vision on gemini while the main loop runs anthropic): missing
info stays 'unknown'/empty.
``task`` distinguishes what kind of work consumed the tokens: ``''`` (empty) is the main agent loop;
auxiliary calls record their task name (``vision``, ``compression``, ``title_generation``, ...) via
:meth:`record_auxiliary_usage` (issue #23270).
"""
row = conn.execute(
"SELECT model, billing_provider, billing_base_url, billing_mode FROM sessions WHERE id = ?", (session_id,),
).fetchone()
sess = dict(row) if (row is not None and not task) else {}
counts = [v or 0 for v in (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens)]
now = time.time()
conn.execute(_MODEL_USAGE_UPSERT_SQL, (
session_id, model or sess.get("model") or "unknown",
billing_provider or sess.get("billing_provider") or "",
billing_base_url or sess.get("billing_base_url") or "",
billing_mode or sess.get("billing_mode") or "", task or "", api_call_count or 0, *counts,
float(estimated_cost_usd or 0.0), float(actual_cost_usd or 0.0), cost_status, cost_source, now, now))
def record_auxiliary_usage(
self, session_id: str, task: str, *, model: Optional[str]=None, billing_provider: Optional[str]=None,
billing_base_url: Optional[str]=None, input_tokens: int=0, output_tokens: int=0, cache_read_tokens: int=0,
cache_write_tokens: int=0, reasoning_tokens: int=0, estimated_cost_usd: Optional[float]=None,
api_call_count: int=1,
) -> None:
"""Record an auxiliary LLM call's usage (vision, compression, title generation, ...)
as a per-(model, provider, task) delta in ``session_model_usage`` WITHOUT touching
the ``sessions`` summary row (the gateway overwrites those counters with absolute
main-loop totals). ``api_call_count`` may aggregate N calls. Best-effort.
See #23270.
Background-review forks record an aggregate of N fork API calls in one write with
``task='background_review'`` (issue #87250).
"""
usage = {k: v for k, v in locals().items() if k in _MODEL_USAGE_FIELDS}
if not session_id or not task:
return
usage["api_call_count"] = 1 if api_call_count is None else int(api_call_count)
# FK to sessions.id: same guard as update_token_counts; the aux path carries no surface, so
# the placeholder stays repairable by the creator's upsert (_insert_session_row).
self._insert_session_row(session_id, "unknown")
self._execute_write(lambda conn: self._record_model_usage(conn, session_id, task=task, **usage))
def auxiliary_usage_by_task(self, session_id: str) -> Dict[str, Dict[str, float]]:
"""Per-task auxiliary usage (``task != ''``: vision, compression, title_generation, ...) summed
over the session's compression lineage. Aux calls bill to the id the turn STARTED with while
compression mints child ids mid-turn, so a single-id read misses rows (#112848)."""
if not session_id:
return {}
chain = self._session_lineage_root_to_tip(session_id)
# An explicit ``/branch`` copy owns its spend: cut the walk at the nearest branch node so a
# resumed branch never absorbs aux rows billed to its source (hermes_state_messages does the same).
for i in range(len(chain) - 1, -1, -1):
if self._is_explicit_branch_session(chain[i]):
chain = chain[i:]
break
rows = self._read_all(
f"""SELECT task,
COALESCE(SUM(api_call_count), 0) AS api_calls,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
COALESCE(SUM(estimated_cost_usd), 0) AS estimated_cost_usd
FROM session_model_usage
WHERE session_id IN ({','.join('?' * len(chain))}) AND task != ''
GROUP BY task""",
chain,
)
return {row["task"]: {k: row[k] for k in row.keys() if k != "task"} for row in rows}
def usage_totals(self, *, min_message_count: int = 1, include_archived: bool = False) -> Dict[str, float]:
"""Tokens and spend across the whole store (one scan), so the sidebar total does not
shrink with paging. Spend prefers the billed figure over the estimate."""
where = ["parent_session_id IS NULL", "message_count >= ?"]
params: List[Any] = [min_message_count]
if not include_archived:
where.append("COALESCE(archived, 0) = 0")
row = self._read_one(f"""
SELECT COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0),
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0)
FROM sessions
WHERE {' AND '.join(where)}
""", params)
return {"tokens": int(row[0] or 0), "cost_usd": float(row[1] or 0.0)}