Files
6krrt/tui_model.py
adlee-was-taken f0ebd83a09 feat(tui): live routing-decisions panel with SSE, detail popup, breakdown
Adds a real-time view of actual routing tasks to the TUI dashboard:

backend:
- events.py: in-memory decision-event broker (pure stdlib, thread-safe).
  Bounded ring buffer + fan-out queues. persist_route_decision publishes
  here after each write so the TUI sees decisions without polling.
- dispatcher.py: GET /events/decisions SSE endpoint — replays recent
  decisions then streams live ones with :heartbeat keepalive. Wired into
  persist_route_decision's write path.

tui:
- tui.py: DashboardApp now consumes /events/decisions via a background
  thread (call_from_thread). Decisions table updates live without waiting
  for the 5s /metrics poll. New columns: id, kind, category, tier, ctx,
  selected, est $. Number keys 1-6 cycle panels.
- tui_screens.py: DecisionDetailScreen modal — press Enter or e on any
  decision row to see the full JSON (runner-ups, rejected reason, feature
  flags, confidence, context size).
- tui_model.py: pure data layer extracted from tui.py — build_model,
  build_category_breakdown, decision_row. Testable without a terminal.
- tui_sse.py: background-thread SSE consumer with reconnect.

17 new tests (events broker, SSE endpoint, TUI data model, detail popup,
live decision handling). 562 total, all passing. lsp_diagnostics clean.
2026-08-23 23:38:07 -04:00

166 lines
5.6 KiB
Python

"""Pure data layer for the LLM Router TUI — no Textual dependency.
Keeps the payload-shaping logic (``build_model``, ``build_category_breakdown``)
and the HTTP fetcher out of ``tui.py`` so that module stays a thin rendering
shell, and so this layer is importable and testable without a running TUI.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from typing import Any
import requests
DEFAULT_BASE_URL = "http://127.0.0.1:8080"
__all__ = [
"fetch_metrics",
"build_model",
"build_category_breakdown",
"decision_row",
]
def fetch_metrics(base_url: str) -> dict:
"""GET ``<base_url>/metrics`` and return the parsed JSON dict.
Raises on any HTTP or network error (via ``raise_for_status`` and the
underlying ``requests`` exception) so the caller can catch it and render
a "cannot reach router" state.
"""
url = base_url.rstrip("/") + "/metrics"
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
def build_model(data: dict) -> dict:
"""Turn raw /metrics JSON into a plain dict of rendered panel payloads.
Returns keys: ``quota`` (list of {label, value} rows), ``per_model``
(list of rows), ``verdict_mix`` (list of {verdict, count}),
``recent_decisions`` (list of rows), ``category_breakdown`` (list of
rows), ``warnings`` (list of strings).
"""
quota = data.get("quota")
if quota:
quota_rows = [
{"label": "plan_kwh", "value": quota.get("plan_kwh")},
{"label": "metered_kwh_30d", "value": quota.get("metered_kwh_30d")},
{
"label": "fraction",
"value": quota.get("metered_fraction_of_plan"),
},
{"label": "calls", "value": quota.get("metered_calls_30d")},
]
else:
quota_rows = [{"label": "quota", "value": "N/A (plan not set)"}]
per_model = [
{
"model": r.get("model_id"),
"calls": r.get("calls"),
"cost_usd": r.get("sum_cost_usd"),
"energy_kwh": r.get("sum_energy_kwh"),
"carbon_g_co2eq": r.get("sum_carbon_g_co2eq"),
}
for r in (data.get("per_model") or [])
]
mix = data.get("verdict_mix") or {}
verdict_mix = [
{"verdict": k, "count": v} for k, v in sorted(mix.items())
]
recent = []
for r in data.get("recent_decisions") or []:
recent.append(decision_row(r))
coverage = data.get("coverage") or {}
warnings = list(coverage.get("warnings") or [])
return {
"quota": quota_rows,
"per_model": per_model,
"verdict_mix": verdict_mix,
"recent_decisions": recent,
"category_breakdown": build_category_breakdown(recent),
"warnings": warnings,
}
def decision_row(r: dict) -> dict:
"""Project one route_decisions row onto the TUI's enriched decision shape.
Shared by ``build_model`` (from /metrics) and the live SSE path so the two
never drift in the fields they surface to the detail popup / table.
"""
return {
"id": r.get("id"),
"kind": r.get("kind"),
"category": r.get("task_category"),
"tier": r.get("task_tier"),
"selected": r.get("selected_model") or "none",
"selected_provider": r.get("selected_provider"),
"est_cost_usd": r.get("est_cost_usd"),
"required_context_tokens": r.get("required_context_tokens"),
"confidence": r.get("confidence"),
"classifier_ms": r.get("classifier_ms"),
"classification_source": r.get("classification_source"),
"latency_tolerance": r.get("latency_tolerance"),
"candidates_considered": r.get("candidates_considered"),
"runner_up_models": r.get("runner_up_models"),
"est_proficiency": r.get("est_proficiency"),
"rejected_reason": r.get("rejected_reason"),
"observed_at": r.get("observed_at"),
"tools": r.get("tools"),
"images": r.get("images"),
"json_mode": r.get("json_mode"),
"streamed": r.get("streamed"),
}
def build_category_breakdown(
decisions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Aggregate recent decisions by (category, tier), with the most-common
selected model and its share.
Returns a list of dicts: ``category``, ``tier``, ``count``, ``majority``
(the selected model with the most wins), ``share`` (its fraction of the
count as a float in 0..1). Newer rows add first so ties settle toward the
more recent model. This is the panel that answers "what is routing sending
coding_general to right now?" without reading every individual decision.
"""
ordered = sorted(
decisions, key=lambda d: (d.get("id") or 0), reverse=True
)
buckets: dict[tuple[Any, Any], Counter] = defaultdict(Counter)
counts: dict[tuple[Any, Any], int] = defaultdict(int)
for decision in ordered:
key = (decision.get("category"), decision.get("tier"))
counts[key] += 1
selected = decision.get("selected")
if selected is not None:
buckets[key][str(selected)] += 1
rows = []
for (category, tier), n in counts.items():
winners = buckets[(category, tier)]
if winners:
majority, majority_count = winners.most_common(1)[0]
share = majority_count / n
else:
majority, share = None, 0.0
rows.append(
{
"category": category,
"tier": tier,
"count": n,
"majority": majority if majority is not None else "none",
"share": round(share, 2),
}
)
return rows