Files
6krrt/metrics.py
adlee-was-taken bfb0ff7c00 feat(metrics): persist routing decisions and expose GET /metrics
The router previously recorded only completions (energy_observations),
not the routing decisions behind them, so 'how routing is performing' was
not answerable from data. This adds:

- route_decisions table + idempotent ensure_route_decisions (guarded
  CREATE TABLE IF NOT EXISTS, never regenerates a live DB) gated by
  logging.log_route_decisions; every decision kind (route/dispatch/chat/
  passthrough/local-vision) is persisted best-effort via
  persist_route_decision (never fails a request; only session_key, never
  session_dir). The table is ensured on the write path (mirroring
  proficiency_store._write -> ensure_columns) so a live DB that predates
  the feature migrates safely.
- metrics.py aggregator moved quota_burn/scoring_coverage in from the
  dispatcher (breaking a would-be circular import) and adds
  recent_decisions/per_model/verdict_mix/top_proficiency; /health now
  imports them and GET /metrics exposes the 7-key JSON (window-bounded,
  loopback-only, no auth).
- observed_at indexes on energy_observations/verifications.
2026-08-23 20:20:09 -04:00

211 lines
7.3 KiB
Python

"""Read-only aggregation helpers for the router dashboard / /health.
This module MUST NOT import ``dispatcher`` — it exists specifically to break
what would otherwise be a circular import (dispatcher wants /health metrics,
metrics wants the config and DB path that dispatcher already knows).
All functions take an ``sqlite3.Connection`` (with ``row_factory`` set) and
optionally a ``RouterConfig`` instance; none rely on module-level globals.
Functions
---------
quota_burn — kWh metered in the last 30 d, against the plan allowance
scoring_coverage — which scoring axes actually have data
recent_decisions — last N rows from the route_decisions observability table
per_model — per-model aggregates over energy_observations (last 30 d)
verdict_mix — counts by verdict from verifications (last N days)
top_proficiency — top models by blended_score for a category
"""
from __future__ import annotations
import sqlite3
from typing import Any, List, Optional
SEED_CATEGORY = "seed_reference"
FALLBACK_CARBON_SOURCE = "static_fallback"
def quota_burn(
conn: sqlite3.Connection,
cfg: Any,
) -> Optional[dict]:
"""Energy this router has metered, against the plan's allowance.
Accepts ``(conn, cfg)`` so the caller owns the connection and the config
— metrics.py never touches dispatcher's module-level ``cfg`` or its
``_db()`` helper, which is exactly why this module must never import
dispatcher.
"""
if not cfg.objective.plan_kwh_per_period:
return None
row = conn.execute(
"""
SELECT COALESCE(SUM(energy_kwh), 0) kwh, COUNT(*) n
FROM energy_observations
WHERE julianday(observed_at) > julianday('now', '-30 days')
"""
).fetchone()
plan = cfg.objective.plan_kwh_per_period
return {
"plan_kwh": plan,
"metered_kwh_30d": round(float(row["kwh"]), 5),
"metered_fraction_of_plan": round(float(row["kwh"]) / plan, 4),
"metered_calls_30d": row["n"],
"note": "router-metered only; traffic bypassing the router is not counted",
}
def scoring_coverage(
conn: sqlite3.Connection,
cfg: Any,
) -> dict:
"""Report which scoring axes actually have data behind them."""
placeholders = ",".join("?" * len(cfg.routing.allowed_access_levels))
routable = [
(r["model_id"], r["provider"])
for r in conn.execute(
f"""
SELECT model_id, provider FROM models
WHERE availability = 'active'
AND access_level IN ({placeholders})
""",
tuple(cfg.routing.allowed_access_levels),
)
]
with_energy = {
(r["model_id"], r["provider"])
for r in conn.execute(
"SELECT DISTINCT model_id, provider FROM energy_observations "
"WHERE task_category = ?",
(SEED_CATEGORY,),
)
}
with_proficiency = {
(r["model_id"], r["provider"])
for r in conn.execute(
"SELECT DISTINCT model_id, provider FROM proficiency "
"WHERE blended_score IS NOT NULL"
)
}
total = len(routable)
missing_energy = [m for m, p in routable if (m, p) not in with_energy]
missing_proficiency = [m for m, p in routable if (m, p) not in with_proficiency]
warnings: List[str] = []
quota = quota_burn(conn, cfg)
if quota and quota["metered_fraction_of_plan"] > 0.8:
warnings.append(
f"metered usage is {quota['metered_fraction_of_plan']*100:.0f}% of the "
f"{quota['plan_kwh']} kWh plan allowance. A quota is a wall, not a bill — "
"requests fail rather than costing more."
)
if missing_energy:
warnings.append(
f"{len(missing_energy)}/{total} routable models have no reference-workload "
f"observations — eco scores the neutral 0.5 for them and "
f"objective.max_energy_per_request cannot bound them. (Cost is "
f"unaffected: it is priced per request from catalog prices.) "
f"Run: python seed_energy.py --samples 7"
)
if missing_proficiency:
warnings.append(
f"{len(missing_proficiency)}/{total} routable models have no proficiency "
f"data — task_category cannot influence their ranking. "
f"Run: python eval_proficiency.py"
)
return {
"routable_models": total,
"with_energy_data": total - len(missing_energy),
"with_proficiency_data": total - len(missing_proficiency),
"quota": quota,
"warnings": warnings,
}
def recent_decisions(
conn: sqlite3.Connection,
limit: int = 50,
) -> List[dict]:
"""Last *N* rows from route_decisions, ordered by id DESC."""
return [
dict(row)
for row in conn.execute(
"""
SELECT id, observed_at, kind, task_category, task_tier,
required_context_tokens, confidence, classifier_ms,
classification_source, latency_tolerance,
candidates_considered, selected_model, selected_provider,
runner_up_models, est_cost_usd, est_proficiency,
rejected_reason, session_key, tools, images, json_mode, streamed
FROM route_decisions
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
]
def per_model(conn: sqlite3.Connection) -> List[dict]:
"""Per-model aggregates over the last 30 d of energy_observations."""
return [
dict(row)
for row in conn.execute(
"""
SELECT model_id,
provider,
COUNT(*) AS calls,
COALESCE(SUM(cost_usd), 0) AS sum_cost_usd,
COALESCE(SUM(energy_kwh), 0) AS sum_energy_kwh,
COALESCE(SUM(carbon_g_co2eq), 0) AS sum_carbon_g_co2eq,
AVG(completion_tokens) AS avg_completion_tokens,
AVG(attribution_ratio) AS avg_attribution_ratio
FROM energy_observations
WHERE julianday(observed_at) > julianday('now', '-30 days')
GROUP BY model_id, provider
"""
).fetchall()
]
def verdict_mix(
conn: sqlite3.Connection,
since_days: int = 7,
) -> dict:
"""Counts by verdict from verifications in the last *since_days*."""
rows = conn.execute(
"""
SELECT verdict, COUNT(*) n
FROM verifications
WHERE julianday(observed_at) > julianday('now', '-' || ? || ' days')
GROUP BY verdict
""",
(str(since_days),),
).fetchall()
return {row["verdict"]: row["n"] for row in rows}
def top_proficiency(
conn: sqlite3.Connection,
category: str,
) -> List[dict]:
"""Top models by blended_score for *category*, ordered DESC."""
return [
dict(row)
for row in conn.execute(
"""
SELECT model_id, provider, blended_score, source,
self_eval_samples
FROM proficiency
WHERE category = ?
AND blended_score IS NOT NULL
ORDER BY blended_score DESC
""",
(category,),
).fetchall()
]