Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 48 additions & 18 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16814,24 +16814,54 @@ def _get_models_analytics(days: int = 30, profile: Optional[str] = None):
try:
cutoff = time.time() - (days * 86400)

cur = db._conn.execute("""
SELECT model,
billing_provider,
SUM(input_tokens) as input_tokens,
SUM(output_tokens) as output_tokens,
SUM(cache_read_tokens) as cache_read_tokens,
SUM(reasoning_tokens) as reasoning_tokens,
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
COUNT(*) as sessions,
SUM(COALESCE(api_call_count, 0)) as api_calls,
SUM(tool_call_count) as tool_calls,
MAX(started_at) as last_used_at,
AVG(input_tokens + output_tokens) as avg_tokens_per_session
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
GROUP BY model, billing_provider
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
""", (cutoff,))
# Query session_model_usage instead of sessions for accurate
# per-API-call model+provider attribution. The sessions table only
# records the *final* billing_provider after a /model switch, so
# mid-session switches misattribute all tokens to one provider.
# session_model_usage tracks every API call individually.
# (Fixes #71778)
try:
cur = db._conn.execute("""
SELECT u.model,
u.billing_provider,
SUM(u.input_tokens) as input_tokens,
SUM(u.output_tokens) as output_tokens,
SUM(u.cache_read_tokens) as cache_read_tokens,
SUM(u.reasoning_tokens) as reasoning_tokens,
COALESCE(SUM(u.estimated_cost_usd), 0) as estimated_cost,
COALESCE(SUM(u.actual_cost_usd), 0) as actual_cost,
COUNT(DISTINCT u.session_id) as sessions,
SUM(COALESCE(u.api_call_count, 0)) as api_calls,
0 as tool_calls,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session_model_usage has no tool-call field, so this makes every main-agent Models card report zero and hides the UI statistic at ModelsPage.tsx:520. Please preserve a deliberate session-level attribution (as Insights does) or remove/redesign the displayed metric explicitly.

MAX(u.last_seen) as last_used_at,
AVG(u.input_tokens + u.output_tokens) as avg_tokens_per_session
FROM session_model_usage u
JOIN sessions s ON s.id = u.session_id
WHERE s.started_at > ? AND u.model IS NOT NULL AND u.model != ''
AND u.task = ''
GROUP BY u.model, u.billing_provider
ORDER BY SUM(u.input_tokens) + SUM(u.output_tokens) DESC
""", (cutoff,))
except Exception:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please narrow this fallback to the expected SQLite compatibility failure. Catching every exception hides defects in this new query and silently reverts users to the inaccurate sessions aggregation; Insights limits its analogous fallback to sqlite3.OperationalError.

# Fallback for older DBs without session_model_usage table
cur = db._conn.execute("""
SELECT model,
billing_provider,
SUM(input_tokens) as input_tokens,
SUM(output_tokens) as output_tokens,
SUM(cache_read_tokens) as cache_read_tokens,
SUM(reasoning_tokens) as reasoning_tokens,
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
COUNT(*) as sessions,
SUM(COALESCE(api_call_count, 0)) as api_calls,
SUM(tool_call_count) as tool_calls,
MAX(started_at) as last_used_at,
AVG(input_tokens + output_tokens) as avg_tokens_per_session
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
GROUP BY model, billing_provider
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
""", (cutoff,))
raw_rows = [dict(r) for r in cur.fetchall()]

# Add auxiliary usage as (model, provider) rows so aux-only models
Expand Down
10 changes: 8 additions & 2 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6940,6 +6940,10 @@ def test_models_analytics_merges_session_only_duplicate_into_accounted_provider(
``model`` populated and another row with token/API accounting plus
``billing_provider``. The Models dashboard should show one provider
card, not a real card plus a misleading duplicate empty card.

After the switch to session_model_usage (fix for #71778), sessions
with zero API calls don't appear in session_model_usage at all —
only the session that actually made API calls is counted.
"""
from hermes_state import SessionDB

Expand Down Expand Up @@ -6977,11 +6981,13 @@ def test_models_analytics_merges_session_only_duplicate_into_accounted_provider(
assert len(deepseek_rows) == 1
row = deepseek_rows[0]
assert row["provider"] == "openrouter"
assert row["sessions"] == 2
# session_model_usage only counts sessions with actual API calls;
# "deepseek-session-only" has 0 API calls so it doesn't appear.
assert row["sessions"] == 1
assert row["input_tokens"] == 20_000
assert row["output_tokens"] == 7_100
assert row["api_calls"] == 9
assert row["avg_tokens_per_session"] == 13_550
assert row["avg_tokens_per_session"] == 27_100

def test_analytics_usage_includes_skill_breakdown(self):
from hermes_state import SessionDB
Expand Down
Loading