diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b2a552980a..b416d3af6b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9711,7 +9711,71 @@ async def get_models_analytics(days: int = 30, profile: Optional[str] = None): GROUP BY model, billing_provider ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC """, (cutoff,)) - rows = [dict(r) for r in cur.fetchall()] + raw_rows = [dict(r) for r in cur.fetchall()] + + # Session rows can be created before the first billable provider call + # finishes. If that early row records only the model name, and a later + # row for the same model has real accounting + billing_provider, the + # Models page used to show a duplicate "0 tokens / — API calls" card + # next to the real provider card. Fold those session-only rows into + # the single accounted provider row when the ownership is unambiguous. + rows_by_model: Dict[str, List[Dict[str, Any]]] = {} + for row in raw_rows: + rows_by_model.setdefault(row.get("model") or "", []).append(row) + + rows: List[Dict[str, Any]] = [] + for model_rows in rows_by_model.values(): + provider_rows = [r for r in model_rows if r.get("billing_provider")] + if len(provider_rows) == 1: + target = provider_rows[0] + for row in model_rows: + if row is target or row.get("billing_provider"): + continue + has_usage = any( + (row.get(key) or 0) != 0 + for key in ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "reasoning_tokens", + "estimated_cost", + "actual_cost", + "api_calls", + "tool_calls", + ) + ) + if has_usage: + continue + target["sessions"] = (target.get("sessions") or 0) + (row.get("sessions") or 0) + target["last_used_at"] = max(target.get("last_used_at") or 0, row.get("last_used_at") or 0) + total_tokens = (target.get("input_tokens") or 0) + (target.get("output_tokens") or 0) + sessions = target.get("sessions") or 0 + target["avg_tokens_per_session"] = total_tokens / sessions if sessions else 0 + rows.append(target) + rows.extend( + r for r in model_rows + if r is not target + and (r.get("billing_provider") or any( + (r.get(key) or 0) != 0 + for key in ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "reasoning_tokens", + "estimated_cost", + "actual_cost", + "api_calls", + "tool_calls", + ) + )) + ) + else: + rows.extend(model_rows) + + rows.sort( + key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0), + reverse=True, + ) models = [] for row in rows: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index c046a8f2ec..73d5a1a667 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -3277,6 +3277,56 @@ class TestNewEndpoints: "top_skills": [], } + def test_models_analytics_merges_session_only_duplicate_into_accounted_provider(self): + """Session-only model rows should not render as duplicate zero-token cards. + + Direct-provider-on-OpenRouter sessions can leave one row with only + ``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. + """ + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session( + session_id="deepseek-session-only", + source="cli", + model="deepseek/deepseek-v4-flash", + ) + db.create_session( + session_id="deepseek-accounted", + source="cli", + model="deepseek/deepseek-v4-flash", + ) + db.update_token_counts( + "deepseek-accounted", + input_tokens=20_000, + output_tokens=7_100, + billing_provider="openrouter", + api_call_count=9, + ) + finally: + db.close() + + resp = self.client.get("/api/analytics/models?days=7") + assert resp.status_code == 200 + + models = resp.json()["models"] + deepseek_rows = [ + row for row in models + if row["model"] == "deepseek/deepseek-v4-flash" + ] + + assert len(deepseek_rows) == 1 + row = deepseek_rows[0] + assert row["provider"] == "openrouter" + assert row["sessions"] == 2 + 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 + def test_analytics_usage_includes_skill_breakdown(self): from hermes_state import SessionDB