README gains the Local Vision fallback section (the Ollama/qwen3-vl path for image requests with no cloud vision candidate — previously only the cloud capability gates were documented) plus route_decisions table, metrics.py/tui.py/router_cli.py in the module list, GET /metrics endpoint and keys, and monitoring usage snippets. CLAUDE gains metrics.py/tui.py/router_cli.py in what's-built and a Monitoring: route_decisions persistence section. Also corrects verifications.kind/verdict to match schema.sql.
51 KiB
Local LLM Model Router
A router that uses a local model (served via Ollama) to classify incoming coding/documentation tasks — category, tier, required context size — and dispatch each task to the cheapest/best-fit open-weight model on Neuralwatt Cloud, weighted by cost, per-category proficiency, and a per-request energy ceiling.
The local model does the classifying, so it wants a GPU, but not necessarily
your GPU — classifier.base_url takes any OpenAI-compatible endpoint, so
the usual shape is the router on your laptop and Ollama on a workstation
across a VPN. See Setup.
Numbers in this README are measurements, not specifications. They come from one deployment against one provider account, and the catalog, prices, grid intensity and pool load all move. They are here because the reasoning behind a design choice is worth more than the choice, and re-running the measurement is how you check whether it still holds for you.
Neuralwatt is the only provider. The provider column and the
(model_id, provider) primary key stay so a second provider can be added
later without a migration.
At a Glance
| Dimension | Detail |
|---|---|
| Cost model | Per-kWh, not per-token. Flat $8.00/kWh measured across the catalog. |
| Latency | Classification is the floor: ~5-15 s warm on a local reasoning model, up to ~120 s cold. A cloud classifier measured ~1 s. Verification is async and never blocks the response. |
| Quality | Quality is the objective; cost is a per-request kWh ceiling plus a tiebreak. Eco is logged but no longer optimized. Proficiency blends external benchmarks with a self-run eval harness. |
| Verification | Every response structurally checked (free) + local LLM spot-check on long prose answers (~6 s, never blocks). Failures fold back into proficiency via feedback.py. |
| Fault tolerance | Classifier failure degrades to a mid-tier fallback rather than 502/503. SDK retry count is zero, to prevent timeout_seconds silently becoming a 3× wall-clock bound. |
| API surface | OpenAI-compatible /v1 endpoints, streaming chunk proxy with SSE telemetry scraping. |
Verification Pipeline
Every completion passes through a two-layer check before routing learns from it and without adding to client-facing latency for the async layer.
Structural Verification — verification.py
Free, exact checks on what a model just returned — no code execution. Extracts fenced code blocks and validates them by parsing, not running:
| Language | Check | Safeguard |
|---|---|---|
Python (python, py, python3) |
ast.parse |
Syntax tree, no evaluation |
JSON (json, jsonc) |
json.loads |
Strict parse |
YAML (yaml, yml) |
yaml.safe_load |
No arbitrary objects |
Shell (bash, sh, zsh) |
bash -n (caller's job) |
Parse-only, never runs |
| Other | unverifiable (not a failure) |
Prose answers land here |
Plus two universal checks:
finish_reason == "length"— decisive truncation, even if parsed content looks fine (the most dangerous case: a fragment that parses)- Unterminated code fences — the response ran out of tokens mid-block
Key design decisions:
- Never executes model output. The eval harness (
eval_proficiency.py) does run generated code, but there the prompts are ones this project authored, so what comes back is bounded. Here the code is whatever the user asked for and could do anything. unverifiableis not a failure. Most prose answers land there (correctly — there's nothing structural to check), and counting it as wrong would penalize models for the checker's limits.- Measured on real traffic: a wasted cloud completion costs about 52 local checks at 1,500 tokens and 139 at 4,000. A zero-cost check pays trivially.
Local LLM Verification — verification.py (cont.)
For answers nothing structural can judge (prose, reasoning, refusals), a local model spot-checks the response after it has gone back to the client, so its ~6 s never lands on anyone's latency.
The prompt frames the local model as a judge: "Does this answer actually
address the user's request?" and returns {"ok": true|false, "reason": "..."}.
Gating and safeguards:
- Size threshold — only responses ≥ 600 completion tokens trigger a local
check (configurable via
verification.min_completion_tokens). A check on a 193-token answer costs ~15% of the answer; at 1,500 tokens the break-even failure rate drops to ~1.9%. - Middle elision — if the answer is longer than the limit (default 4,000), the middle is elided, not the head, so the checker judges the real ending. An elision marker tells the checker not to flag it as a defect.
- Thinking disabled — the local model is a reasoning model and will otherwise
emit unbounded chain-of-thought. Verification uses Ollama's native endpoint
with
think=False, so it answers in ~25 tokens instead of burning through the budget on reasoning. - Malfunction safety — an empty or unparseable verdict produces no sample rather than a false failure. An empty response is settled by an if-statement, never asked of a model what code can decide.
Feedback Loop — feedback.py
Observation → learning. feedback.py folds verification failures into
proficiency so routing improves on your traffic, not just the fixed
23-task benchmark:
python feedback.py --dry-run # preview what would change
python feedback.py # apply
Key behaviors:
- Only failures are folded in. A structural 'ok' means the code parsed, not that it was correct — scoring a parse-success as a proficiency 1.0 would flatten every score toward the ceiling. The coding categories already sit at 1.00 for every model; this would spread that flatness everywhere.
- One 0.0 sample per failure, added to the running mean so the penalty scales with failure rate rather than replacing the benchmark score outright.
- Idempotent —
applied_atmarks consumed rows so re-running cannot penalize a model repeatedly for the same response. - Not the model's fault — a client that sets
max_tokens=40and gets a truncated answer caused that itself. Those failures are recorded in the verifications table but excluded from proficiency feedback.
Architecture
┌─────────────────────┐
incoming task ───▶│ Local Classifier │ Ollama (mistral-nemo:12b)
│ - task_category │ ~4s warm, ~120s cold cap
│ - task_tier │ temperature: 0, max 1024 tokens
│ - required_context │ max_retries: 0 (silent 3× cap guard)
│ - confidence │
└──────────┬───────────┘
▼
┌─────────────────────┐
│ Classifier Fallback │ tier 2 / general_chat on failure
│ (graceful degrade) │ — not a 502/503
└──────────┬───────────┘
▼
┌─────────────────┐
│ Escalation │ low-confidence tier bump
│ (optional) │ threshold: 0.6 confidence
└────────┬─────────┘
▼
┌─────────────────────┐
│ Hard Filters │ context window ≥ required
│ (routing.py) │ tier ≥ required
│ │ freshness (active, not stale)
│ │ access_level allowed
│ │ latency_class compatible
└──────────┬───────────┘
▼
┌─────────────────────┐
│ Quality-first select │ max proficiency, cheapest
│ (routing.py) │ among equals, under a
│ │ per-request kWh ceiling
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Dispatcher / Provider │──▶ Neuralwatt Cloud
│ (FastAPI API) │──▶ OpenAI-compatible /v1
│ │ Streaming SSE + energy scrape
└───────┬──────────────┘
▼
┌─────────────────────────┐
│ Verification Pipeline │
│ Layer 1: Structural │ ast.parse, json.load, yaml.safe_load
│ Layer 2: Local LLM │ async, ~6s, >600 token gate
└──────┬──────────────────┘
│
▼
┌─────────────────────┐
│ feedback.py │ failures → proficiency updates
│ (on-demand agent) │ idempotent, attributed-only
└─────────────────────┘
Tech Stack
| Layer | Technology |
|---|---|
| Language | Python 3.10+ (IO-bound provider APIs; iteration speed matters more than raw speed) |
| Framework | FastAPI + uvicorn |
| Database | SQLite (router.db) — decision table, energy observations, proficiency, verifications |
| Local Classification | Ollama, OpenAI-compatible — localhost:11434/v1 or an Ollama across your VPN |
| Local Model | classifier.model — mistral-nemo:12b by default; any Ollama model works |
| Cloud Provider | Neuralwatt only |
| Config | config.yaml loaded & validated by Pydantic (config.py) |
| OpenAI Client | openai==3.0.0 (official SDK) |
| HTTP | requests for poller, httpx (via openai/uvicorn) |
| Testing | pytest — 356 tests across 19 files, all offline |
| Config Files | config.yaml, leaderboards.yaml, evals/tasks.yaml |
| Deployment | systemd user units (.service + .timer files in deploy/) |
| Integration | opencode.json in the repo routes through it by default; any OpenAI-compatible client works |
Pinned Dependencies (requirements.txt)
pyyaml==6.0.3
pydantic==2.13.4
requests==2.34.2
fastapi==0.141.1
uvicorn[standard]==0.52.1
openai==3.0.0
python-dotenv==1.2.2
pytest==9.1.1
pytest-cov==7.1.0
Pinned intentionally. Recreating the venv with >= constraints silently jumped
openai 2.53 → 3.0 and httpx → httpx2 without warning; a service that
restarts on boot shouldn't change its dependency tree underneath itself.
Modules
| Module | Role | I/O? |
|---|---|---|
dispatcher.py |
FastAPI service: routes, calls providers, logs, streams | Yes (DB, network) |
poller.py |
Fetches Neuralwatt catalog, normalizes, upserts models table |
Yes (network, DB) |
scoring.py |
normalize_inverted + composite_score weighted formula |
Pure |
routing.py |
Hard filters (select_candidates) + ranking (rank_candidates) |
Pure |
tiering.py |
Pure tier resolver: 1=cheap, 2=mid, 3=frontier | Pure |
tier.py |
DB tiering pass: reads models, resolves, writes tier column |
Yes (DB) |
proficiency.py |
Score blending: leaderboard + self-eval → weighted composite | Pure |
proficiency_store.py |
DB access for proficiency table; single write path ensuring blended_score never drifts |
Yes (DB) |
seed_energy.py |
Reference workload sweep: fixed prompt × N runs per model | Yes (network, DB) |
eval_proficiency.py |
Self-eval harness: 4 scoring kinds against N models | Yes (network, DB, subprocess) |
verification.py |
Two-layer check: structural parse (always) + local LLM spot-check (async, size-gated). Never executes model output | Pure |
feedback.py |
Folds observed verification failures into proficiency; routing learns from real traffic |
Yes (DB) |
leaderboard.py |
Imports leaderboards.yaml priors into proficiency; --check reports gaps |
Yes (DB) |
iteration.py |
Retry budget per tier, and matching the retry to the failure kind | Pure |
config.py |
YAML loader + Pydantic validators (blend weights sum to 1, valid tiers, endpoints separately addressable) | Yes (file) |
metrics.py |
Read-only aggregations for /health and GET /metrics: quota burn, coverage, recent decisions, per-model totals, verdict mix, top proficiency |
Yes (DB) |
tui.py |
Textual terminal dashboard over GET /metrics; foreground tool, not a service |
Yes (network) |
router_cli.py |
One-shot routing probe: POSTs to /route and prints the decision tree |
Yes (network) |
Decision Table Schema (SQLite)
Three data tables plus one observability table, PRAGMA foreign_keys = ON:
models — one row per served model variant
| Column | Type | Notes |
|---|---|---|
model_id |
TEXT | Full catalog id (e.g. glm-5.2-short-fast-flex) |
provider |
TEXT | neuralwatt |
base_model_id |
TEXT | Model family (e.g. glm-5.2). Proficiency/leaderboard keys here. |
display_name |
TEXT | Human-readable name |
cost_per_1m_prompt |
REAL | Listed USD per 1M input tokens |
cost_per_1m_completion |
REAL | Listed USD per 1M output tokens |
cost_per_1m_prompt_cached |
REAL | Cached prefix price (null if no cache discount) |
context_window |
INTEGER | Advertised max tokens |
effective_context_window |
INTEGER | advertised × safety_factor − reserve |
max_output_tokens |
INTEGER | |
tier |
INTEGER | 1–3, set by tier.py pass |
supports_tools |
INTEGER | Boolean 0/1 |
supports_json_mode |
INTEGER | |
supports_vision |
INTEGER | |
supports_reasoning |
INTEGER | "API accepts reasoning param" — NOT a quality signal |
reasoning_default_enabled |
INTEGER | The actual tier-bearing signal |
latency_class |
TEXT | standard | flex (-flex: discounted async, held during peak) |
reasoning_mode |
TEXT | default | reduced (-fast: reasoning capped) |
context_variant |
TEXT | full | short (-short: 200K pool bounded budget) |
access_level |
TEXT | public | preview | canary |
pricing_tbd |
INTEGER | |
deprecated |
INTEGER | |
availability |
TEXT | active | deprecated | stale |
last_updated |
TEXT | ISO8601 |
Serving class: Neuralwatt ships ~6 base models as 19 catalog rows. The id
suffixes are three orthogonal dimensions (glm-5.2-short-fast-flex), parsed
by poller.parse_serving_class into columns. Rows carry identical catalog
pricing, so routing would pick between them arbitrarily without these — the
latency_tolerance hard filter resolves it.
Access gating: 6 of 19 rows are prose-gated
("Private preview (grant-gated)", "(Canary)"). poller.parse_access_level
parses them into access_level and routing.allowed_access_levels (default
[public]) excludes them, so dispatch won't earn a 403.
proficiency — one row per (model, provider, category)
| Column | Type | Notes |
|---|---|---|
model_id |
TEXT | |
provider |
TEXT | |
category |
TEXT | See category list below |
leaderboard_score |
REAL | 0–1, from external benchmarks |
self_eval_score |
REAL | 0–1, from self-eval harness |
self_eval_samples |
INTEGER | Evidence count for blending threshold |
blended_score |
REAL | Computed: w_lb × lb + w_se × se |
source |
TEXT | blended | self_eval | self_eval_thin | leaderboard |
inherited_from |
TEXT | Model this row was copied from, NULL if measured directly |
last_updated |
TEXT | ISO8601 |
Category set (9 categories, defined in config.yaml):
| Category | Example | Scoring type |
|---|---|---|
coding_general |
Merge intervals, parse semver, word wrap | Code (execution) |
coding_refactor |
Remove repetition, refactor dispatch chain | Code (execution) |
debugging |
Fix closure leak, fix binary search, fix regex | Code (execution) |
reasoning_math |
Percent trap, rate trap, counting | Exact match |
tool_use_agentic |
Right tool / right args / no tool when empty | Structural |
docs_writing |
Docstring quality, must-mention gotchas | Judge |
summarization |
Root-cause isolation, buried-lede identification | Judge |
translation |
Technical register, hedging/informal tone | Judge |
general_chat |
Simple explanations, measured pushback | Judge |
Scoring kinds (4 types, objective wherever the category admits it):
code— runs model-generated Python in a subprocess, scores fraction of checks passingexact— normalizes & compares a single answertool— structural: which tool was called, which args matchedjudge— a strong model scores against a rubric (prose categories only)
Blending: Once self_eval_samples ≥ self_eval_min_samples (default 10):
blended = 0.3 × leaderboard + 0.7 × self_eval. Before that, falls back
to leaderboard alone. If neither exists, the row scores neutral 0.5.
Source labels distinguish confidence levels: self_eval_thin means
real measurement but below the sample threshold — a caller wanting to
exclude it can.
Proficiency inheritance: propagate_to_variants copies evaluated
scores to equivalent serving variants (same weights, same reasoning
setting, same context pool), but never over a row that was measured
directly. A -fast row is not equivalent to its -standard sibling.
"Measured directly" is inherited_from IS NULL, not self_eval_samples > 0
— inheritance copies the sample count too, so sample count alone cannot tell
an inherited row from a measured one, and using it meant a variant inherited
exactly once and then froze forever. ensure_columns() adds the column and
backfills provenance on databases that predate it.
energy_observations — per-request telemetry
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | Autoincrement |
model_id / provider |
TEXT | |
task_category |
TEXT | |
prompt_tokens / completion_tokens |
INTEGER | |
energy_kwh |
REAL | Attributed billed figure (noisy, 20× within-model) |
energy_btu |
REAL | kwh × 3412.14, dashboard value |
avg_power_watts / duration_seconds |
REAL | Pre-attribution product, ~1.8× within-model |
attribution_ratio |
REAL | Request's share of shared GPU pool (stable quantized: 0.001, 0.25, 0.5, 0.75) |
carbon_g_co2eq |
REAL | Reported by provider |
grid_carbon_intensity |
REAL | gCO2/kWh at call time |
grid_id |
TEXT | e.g. FI |
carbon_source |
TEXT | static_fallback (constant, excluded from routing) or live measurement |
cost_usd |
REAL | Billed figure, not tokens × list price |
allowance_remaining_usd |
REAL | |
service_tier |
TEXT | As billed |
observed_at |
TEXT | ISO8601 |
Attribution noise: Billed energy_kwh = avg_power_watts × duration × attribution_ratio. The attribution term looks like noise up close (8 identical
calls varied 20×), but ranks 750× between models while within-model spread is
1.8× — it's a stable per-model property reflecting serving concurrency. Routing
scores on the attributed figures with a median over all seed_reference
rows, so repeated sweeps accumulate into a median-across-time.
verifications — response quality observations
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | Autoincrement |
model_id / provider |
TEXT | |
task_category |
TEXT | Optional (prose answers may lack a category) |
kind |
TEXT | structural | local_llm | client_outcome |
verdict |
TEXT | ok | truncated | malformed | unverifiable | succeeded | failed |
detail |
TEXT | Human-readable reason |
completion_tokens |
INTEGER | Wasted answer cost, for payoff sum |
observed_at |
TEXT | ISO8601 |
applied_at |
TEXT | Set by feedback.py when folded into proficiency |
model_attributable |
INTEGER | 1 = model's fault; 0 = client caused (e.g. tight cap) |
Verifications drive the feedback loop: feedback.py reads unanswered failures,
applies a 0.0 sample per failure to proficiency, and marks them
applied_at for idempotency. unverifiable is recorded but not treated as a
failure — it means the checker had nothing to say, not that the model failed.
route_decisions — routing observability
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | Autoincrement |
observed_at |
TEXT | ISO8601, UTC |
kind |
TEXT | route | dispatch | chat | passthrough | local_vision |
task_category |
TEXT | |
task_tier |
INTEGER | 1–3 |
required_context_tokens |
INTEGER | |
confidence |
REAL | Classifier confidence |
classifier_ms |
INTEGER | Classification latency |
classification_source |
TEXT | classifier | override | fallback |
latency_tolerance |
TEXT | interactive | batch |
candidates_considered |
INTEGER | How many survived hard filters |
selected_model |
TEXT | Null when no model was selected |
selected_provider |
TEXT | neuralwatt or local (vision fallback) |
runner_up_models |
TEXT | JSON array of up to 3 runner-up candidates |
est_cost_usd |
REAL | Estimated cost of the selected model |
est_proficiency |
REAL | Estimated proficiency for the task category |
rejected_reason |
TEXT | Active filters when nothing was selected |
session_key |
TEXT | Hashed session fingerprint ONLY |
tools |
INTEGER | 0/1 — request carried a tools array |
images |
INTEGER | 0/1 — request carried image parts |
json_mode |
INTEGER | 0/1 — request required JSON mode |
streamed |
INTEGER | 0/1 — response was streamed |
route_decisions stores one row per routing decision so "how is routing
performing" is answerable: which model was picked, for what category/tier,
how long classification took, and — when nothing was selected — which hard
filter shut it out. It is an observability table: nothing in routing reads it.
It stores only a hashed session fingerprint in session_key; session_dir,
prompts, and answers are deliberately excluded. A test enforces that the write
path does not store prompt or answer text. The write is gated by
logging.log_route_decisions and is best-effort: a failed write is logged
at warning and swallowed so monitoring cannot slow or fail a request.
Weighted Scoring
Six hard filters are applied before scoring (not weighted — outright disqualification):
effective_context_window ≥ required_context_tokenstier ≥ required_tier(from classifier)- Serving class compatible with request's
latency_tolerance;access_levelreachable tool_use_agenticproficiency ≥routing.min_tool_proficiency, only when the request carries atoolsarray (ships disabled —null)supports_vision = 1when the request carriesimage_urlparts;NULLfails closed (an unknown flag means the capability cannot be confirmed)supports_json_mode = 1whenresponse_format.typeisjson_objectorjson_schema;NULLalso fails closed
Filters 4–6 are read from the request body rather than inferred. A tools
array states whether tool definitions are on the table, image_url parts state
whether vision is needed, and response_format states whether JSON mode is
needed. Local classifiers identified unambiguous tool-use prompts only 1-2
times in 6, so inferring capability requirements does not work; the request
states them exactly and for free.
The tool gate is a quality measurement filter: a model with no measured
tool_use_agentic score is admitted, because "unproven" is not "proven bad".
Only a measured score below routing.min_tool_proficiency is dropped. The
vision and JSON-mode gates are capability flag filters. There every
routable catalog row has supports_tools = 1, so a flag gate would be inert;
vision and JSON mode are not universal, and an absent or NULL flag means
"cannot confirm the capability", so the model is dropped. A wrong guess on a
capability flag is a guaranteed provider 400.
The tool filter ships off (null), because agent clients send tools on
nearly every request, so enabling it excludes the cheapest model from ordinary
agent traffic. Whether that trade is worth it is an empirical question best
settled with POST /outcome data rather than a 3-task benchmark score. Set it
to 0.5 to turn it on. The vision and JSON-mode gates ship on, because a
wrong guess is a guaranteed 400.
Local Vision Fallback
Cloud vision is not universal in the catalog, and the most economical coding
rows do not declare supports_vision. Routing an image request through them
would earn a provider-side 400, so when routing.require_vision is true only
rows with supports_vision = 1 survive the hard filters. If no cloud
candidate survives, the router can fall back to a local vision model instead
of returning 422.
local_vision: in config.yaml controls this path:
| Key | Default | Purpose |
|---|---|---|
enabled |
true |
Whether the fallback runs at all |
base_url |
http://localhost:11434/v1 |
OpenAI-compatible Ollama endpoint |
api_key_env |
null |
Env var holding an API key, if the endpoint needs one |
model |
qwen3-vl:4b |
Vision model on that Ollama |
timeout_seconds |
60 |
Request timeout |
max_images |
4 |
Refuse requests with more image parts |
max_image_bytes |
9437184 (9 MiB) |
Refuse requests whose image payload exceeds this |
The fallback is enabled by default both in config.yaml and in
LocalVisionConfig, so omitting the section still turns it on. Disable it
explicitly (enabled: false) on a host with no local Ollama or one that has
not pulled the vision model.
When enabled and no cloud candidate is selected, _run_local_vision sends the
original message list — with image_url parts intact — to the configured
Ollama model. The local answer then replaces the cloud completion:
_local_vision_response returns a normal OpenAI-shaped response, including a
stream-wrapped version when the client asked for stream: true. It does not
inject a caption into a cloud call, because the streaming proxy cannot rewrite
bytes mid-stream.
Security and budget guards:
- Only inline
data:URIs are accepted. A remotehttp(s)image URL is declined, because pointing a local model at an arbitrary URL would let an unauthenticated caller make the router fetch internal resources (SSRF). - Image count and total payload size are bounded by
max_imagesandmax_image_bytesbefore the local call is made. - If the local call fails for any reason, the request falls through to the
ordinary
422 No model satisfies the hard filtersrather than returning a silent empty response.
Pull the model on whichever Ollama the fallback points at:
ollama pull qwen3-vl:4b
Config is strict (extra="forbid"): a misspelled or misplaced key fails at
load instead of being silently ignored.
1. drop candidates whose measured energy exceeds objective.max_energy_per_request
2. rank by proficiency for the task's category
3. treat differences smaller than objective.quality_tolerance as equal
4. among equals, pick the cheapest
| Setting | Value | Notes |
|---|---|---|
quality_tolerance |
0.10 | Measurement noise, not preference: scores rest on 2-3 samples, so smaller gaps are sampling variation |
assumed_cache_rate |
0.917 | Share of prompt tokens served from the provider's prefix cache. Agent clients resend the conversation each turn, so most of it hits. Measured token-weighted over 40.7M tokens; measure your own against the provider's session view |
assumed_completion_tokens |
500 | Completion length assumed when pricing a candidate |
max_energy_per_request |
null | Per-request kWh ceiling — a wall, not a bill. Null disables it |
plan_kwh_per_period |
6.25 | Set this to your own plan's quota. Reported in /health as burn against the allowance; it does not gate anything |
This replaced a weighted blend (cost 0.4 / eco 0.2 / proficiency 0.4). Measurement retired it: turning the cost weight from 0.4 to zero changed the winner in only 2 of 6 categories, so the blend was never steering on quality — while 60% of every decision adjudicated fractions of a cent.
Cost is priced per request, from catalog token prices scaled to the
request's shape — prompt size, assumed_completion_tokens,
assumed_cache_rate. It is deliberately not a benchmark average.
Neuralwatt bills flat per-kWh rather than per-token, so list price is not what gets charged. Scoring used measured billed cost from a fixed 400-token reference sweep for exactly that reason — and that was wrong for real traffic, because the ranking depends on the workload's shape, not just the model. On a 400-token prompt one model looked 3.2× cheaper than another; on a realistic 70k-token prompt the same pair inverted and the second was 5.0× cheaper. The provider's attribution ratio moves with prompt size, so a fixed-shape benchmark cannot rank models for a workload of a different shape.
List price is still not what is billed, but billing is capped at a multiple of it, so it tracks the real ordering and bounds it — and it is free, needs no sweep, and refreshes whenever the poller runs.
Why cost ≠ eco: Cost tracks energy (kWh), but carbon is energy × grid
intensity. Grid intensity spanned ~49 gCO2/kWh (FI) to ~442
(US-MIDA-PJM) when measured — and it moves with time of day. The models
disagree: glm-5.2-fast is
2nd cheapest but 6th cleanest; kimi-k3-flex draws 3.7× less energy than
kimi-k2.7-code while emitting 3.6× more carbon. Collapsing them picks a
side.
What routing actually returns, and why it moves
Sweeping /route across every category and tier is the fastest way to see
whether your data is doing anything. On the deployment this was written
against, 9 categories × 3 tiers currently yields 5 distinct winners
(qwen3.6-35b, gemma-4-31b, deepseek-v4-flash, kimi-k3,
kimi-k3-fast).
That number is a diagnostic, not a target, and it is worth knowing what each outcome means:
- One winner everywhere is a legitimate answer, not a misconfiguration.
It happened here: with cost, eco and proficiency all populated, one model
was Pareto-dominant — cheapest and cleanest in the routable set while
scoring within
quality_toleranceof the best. No defensible weighting picks anything else. If you see this, check whether the leader really is dominant before reaching for the config. - Winners that change with context size are the hard filters working.
A model is dropped once
required_context_tokensexceeds its window, so a long session can change model mid-conversation. Past the largest window,/v1/chat/completionsreturns 422 naming the constraint rather than silently truncating. - Winners that change by category mean proficiency is live. That is the
only category-dependent term, so until the
proficiencytable has data,task_categorycannot change a decision at all — the classifier computes it and the router pays for it for nothing.
The spread here widened for two reasons worth copying: cost became a per-request estimate rather than a benchmark average, and tier stopped being inferred from price alone. Both had been quietly excluding a cheap large-context model from every request above tier 1.
If you want a different balance, the levers are objective.quality_tolerance
(how big a quality gap must be before it outranks cost) and
objective.max_energy_per_request (a hard ceiling). There is no weight to
tune — quality is the objective and cost is the tiebreak, which replaced an
earlier weighted blend.
API Endpoints
The dispatcher binds 127.0.0.1:8080. No auth of its own — loopback
is the only thing standing between the open internet and your billing
allowance.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Catalog/reachability status, scoring coverage, warnings |
GET |
/metrics |
Aggregated observability JSON: quota burn, coverage, recent decisions, per-model totals, verdict mix, top proficiency; loopback-only, no auth |
POST |
/route |
Classify task, rank candidates, return selected model — no provider call, no cost |
POST |
/dispatch |
Same as /route, plus complete the provider call, stream response, log observation |
GET |
/v1/models |
OpenAI-compatible model list (router virtual models + catalog) |
POST |
/v1/chat/completions |
OpenAI-compatible completions — routes then proxies, streaming supported |
/metrics returns a single JSON object with these top-level keys:
quota— kWh metered in the last 30 days againstobjective.plan_kwh_per_periodcoverage— routable-model counts with energy/proficiency data plus warningsrecent_decisions— last 50 rows fromroute_decisionsper_model— per-model aggregates over the last 30 days ofenergy_observationsverdict_mix— counts by verification verdict over the last 7 daystop_proficiency— top models byblended_scoreforcoding_generalgenerated_at— ISO8601 timestamp
It exposes no conversation text, prompts, or session_dir; it is bound to
loopback and unauthenticated exactly like /health.
Input to /route and /dispatch can include task_category, task_tier,
and required_context_tokens overrides — these skip the classifier, useful
for testing routing without the classifier in the loop.
Two virtual router models:
auto— normal routing,-flexrows excluded (interactive)auto:batch— admits-flexrows (overnight/async work)
Ask for any real model id in /v1/chat/completions and it dispatches
directly, still logged — routing is transparent, not opaque.
Streaming (chunk-by-chunk proxy): Tokens render as they arrive. Neuralwatt
emits energy and cost as SSE comment lines (: energy {...}) before
data: [DONE] — ordinary clients ignore comments, so the stream flows
untouched while the router scrapes telemetry on the way past. Without this,
streamed calls would log no energy at all.
Verification headers (non-streaming): When streaming is not used, the
structural verification verdict surfaces in the X-Router-Verification header
so a client can inspect it without parsing the response body. Valid values:
ok, truncated, malformed, unverifiable, none.
Capability 422s: When no model survives the hard filters, the 422 names the
active constraints. That now includes "vision-capable model" or
"json-mode-capable model" when the request carried images or a JSON-mode
response_format, alongside the existing context/tier/latency/tool reasons.
Quick Usage
# Pick a model without spending anything
curl -s -X POST localhost:8080/route -H 'content-type: application/json' \
-d '{"task":"Refactor this 800-line Django view into service objects."}'
# With explicit category/tier overrides (skips classifier)
curl -s -X POST localhost:8080/route -H 'content-type: application/json' \
-d '{"task":"Refactor this 800-line Django view","task_category":"coding_refactor","task_tier":2}'
# Actually call the winner and log energy/cost
curl -s -X POST localhost:8080/dispatch -H 'content-type: application/json' \
-d '{"task":"What is a Python context manager?"}'
# OpenAI-compatible — works with any SDK or agent client
curl -s localhost:8080/v1/models
curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"auto","messages":[{"role":"user","content":"hello"}]}'
# Admit flex rows for overnight/async work
curl -s -X POST localhost:8080/route -H 'content-type: application/json' \
-d '{"task":"nightly code review","latency_tolerance":"batch"}'
# Image URL request (routed only to vision-capable catalog rows)
curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"auto","messages":[{"role":"user","content":[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="}}]}]}'
# JSON-mode request via response_format
curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"auto","messages":[{"role":"user","content":"Return a JSON object with field answer"}],"response_format":{"type":"json_object"}}'
# Monitoring: aggregate router health
curl -s localhost:8080/metrics | python -m json.tool
# Terminal dashboard foreground tool (requires `textual`; runs until you press q)
python tui.py
# One-shot routing probe with no spend
python router_cli.py "Refactor this Django view into service objects"
python router_cli.py "Summarize this diff" --category summarization --tier 2
Monitoring
Three foreground tools read the running router without spending quota:
GET /metrics— JSON summary of quota, coverage, recent routing decisions, per-model usage, verdict mix, and top proficiency. No auth; loopback only.tui.py— Textual terminal dashboard that polls/metricsevery few seconds. Run it in a terminal with the service already up. It is a separate entrypoint, not a systemd unit.router_cli.py "<task>"— POSTs to/routeonce and prints the full decision tree, including candidates, selected model, estimated cost, and proficiency. Use--category,--tier, and--contextto override the classifier deterministically, or--jsonfor raw output.
textual is pinned in requirements.txt solely for tui.py. It is imported
only by that module; the FastAPI service dispatch path never touches it, so
the router itself has no UI dependency.
Logging and Traceability
One route line per request says what was decided; one dispatch line says
what it cost. Both carry a trace id, and rid/sess are columns in
energy_observations, so a journal line pivots to its database row and back.
route id=r9116d9 cat=coding_refactor tier=2 ctx=500 src=classifier tools=0
latency=interactive cand=8 pick=deepseek-v4-flash est_usd=0.00016296 ms=1868
dispatch id=r9116d9 model=deepseek-v4-flash rid=chatcmpl-... sess=c090d751
p_tok=59511 c_tok=415 kwh=4.8e-05 usd=0.000783 verdict=ok total_ms=5692
At debug it also says why — the classifier's answer and timing, every
candidate that was dropped and by which filter, and the ranking with scores:
classify id=r9116d9 cat=coding_refactor tier=2 confidence=0.95 ms=1863
filter id=r9116d9 model=gemma-4-31b reason=tier(1<2)
filter id=r9116d9 model=glm-5.2-short reason=access_level(preview)
filter id=r9116d9 model=kimi-k3-flex reason=latency_class(flex)
rank id=r9116d9 pos=0 model=deepseek-v4-flash prof=1 est_usd=0.00016296
No conversation text is logged at any level, prompt or answer — prompts here run 60k–150k tokens and the journal is on disk. A test enforces it.
Set the level in config.yaml (logging.level), or override it without
touching a tracked file:
systemctl --user edit llm-router # Environment="LLM_ROUTER_LOG_LEVEL=debug"
systemctl --user restart llm-router
Reading it back:
journalctl --user -u 'llm-router*' -f # quote the glob in zsh
journalctl --user -u llm-router -f -o cat # message only
journalctl --user -u llm-router -p warning # severity actually filters
journalctl --user -u llm-router --grep 'chatcmpl-abc123' # one request's trail
journalctl --user -u llm-router --grep ' id=r9116d9' # every stage of it
-p works because the service emits journald priority prefixes when — and only
when — systemd owns its stderr. Run uvicorn in a terminal and the lines come
out clean.
Scheduled Jobs (systemd)
Five user units. See deploy/README.md for full install/operate instructions.
| Unit | Schedule | What it runs |
|---|---|---|
llm-router.service |
Continuous | FastAPI dispatcher |
llm-router-poller.timer |
2 min after boot, then every 2 h | triggers the poller unit |
llm-router-poller.service |
oneshot | poller.py → tier.py |
llm-router-seed.timer |
Every 6 h | triggers the seed unit |
llm-router-seed.service |
oneshot | small seed_energy.py sweep |
The poller timer is load-bearing, not optional. freshness.stale_after_days
is 3 with exclude_stale: true — an unpolled catalog marks every row stale
in 3 days and the router returns zero candidates for everything.
The seed timer spans time. Energy attribution drifts with pool load across hours (~50× for one model between sweeps), so a single sweep measures one moment. The median has to span time — every 6 h sweep accumulates into a median-across-time automatically.
This now feeds eco only. Cost is priced per request from catalog prices, so
routing no longer depends on the sweep at all; disabling this timer costs you
carbon figures, not routing quality.
Self-Eval Harness (eval_proficiency.py)
python eval_proficiency.py # every routable model × every task
python eval_proficiency.py --models kimi-k3 # subset of models
python eval_proficiency.py --categories coding_general
python eval_proficiency.py --dry-run # plan only
- Runs every task through the target provider, scores it, writes to
proficiency. - Scores accumulate (running mean), so repeated runs tighten estimates.
propagate_to_variantscopies evaluated scores to equivalent serving variants.- Safety: code tasks run in a temp directory with a 15s wall-clock timeout — bounded isolation, not a container.
- Judge tasks skip the model being judged (avoids self-scoring bias).
Classifier Reliability Notes
The classifier is the one blocking LLM call on the request path, so it sets
the latency floor for every routed request. classifier.base_url /
api_key_env / model accept any OpenAI-compatible endpoint — a local
Ollama, an Ollama on another machine across a VPN, or a cloud model.
Pick a non-reasoning model. The classifier's entire output is ~45 tokens
of JSON, and a reasoning model spends its budget getting there. Replacing
qwen3.5 with mistral-nemo:12b on identical prompts, cold load excluded:
qwen3.5 |
mistral-nemo:12b |
|
|---|---|---|
| category correct | 10/14 | 9/14 |
| tier correct | 1/5 (2 crashed) | 3/5 |
| hard failures | 4 of 19 calls | 0 |
| latency mean / max | 6.6s / 15.6s | 1.7s / 1.9s |
Accuracy is a wash; the failure column and the tail are not. Every failure was
an unbounded thinking trace producing no JSON, which degrades to
source: "fallback" after ~15s. End-to-end /route went ~10s → ~1.7s.
Several settings keep it from cascading failures:
-
max_input_chars: 8000clamps what the classifier is shown to head + tail. It decides a category and a tier; it does not need the document, and feeding it one is harmful rather than wasteful — on a ~20k-token prompt one model returned empty after 28.7s and the other spent 41.8s echoing the input into its JSON. Clamped, the same prompts classify in ~2.2s. -
max_retries: 0on the classifier client. The OpenAI SDK retries twice by default, sotimeout_secondssilently became a 3× wall-clock bound — a request could hang past 250s on a 120s setting with no log. -
max_output_tokens: 1024bounds a reasoning model's chain of thought. Without it, a reasoning model emits unbounded traces — Ollama keeps generating after the client gives up AND serializes per model, so one runaway request queues every later request behind it. 256 was tried first and was too tight; the trace would consume the whole budget and the model would be truncated before emitting JSON. -
temperature: 0prevents non-reproducible classifications. At the default, the same prompt classified tier 2 then tier 1 on consecutive calls, routing to two different models. -
Graceful fallback routes to
fallback_tier: 2/fallback_category: general_chatwhen the classifier times out, errors, or returns unparseable output. A coding agent would rather have a mid-tier answer than an error. Escalation deliberately skips fallbacks so an unavailable local model doesn't silently promote every request to the frontier tier.
Pointing a Coding Agent at It
The /v1 endpoints are OpenAI-compatible, so any normal client works —
opencode, an SDK, plain curl. A repo-local opencode.json is included, so
running opencode from a clone of this repo routes through the router by
default. For global use, merge its provider.llm-router block into
~/.config/opencode/opencode.json.
Virtual model names:
auto→ router picks, interactive mode (flex rows excluded)auto:batch→ router picks, admits flex rows for async work
opencode image support: The repo's opencode.json declares
"modalities": {"input": ["text", "image"]} for every llm-router model.
This is required: opencode strips image parts client-side unless the provider
model declares image input. Without it, images never reach the router at all.
For global use, make sure the merged ~/.config/opencode/opencode.json entry
carries the same modality block.
Testing
python -m pytest # 356 tests
python -m pytest --cov # with coverage
No test calls a provider or a local model — the pure modules take rows and config as arguments, so the suite runs offline on a clean checkout.
| Test file | What it covers |
|---|---|
test_scoring.py |
normalize_inverted, composite_score, None-handling |
test_routing.py |
Hard filters, candidate selection & ranking across all 9 categories × 3 tiers |
test_tiering.py |
Tier resolver precedence: override → reasoning → cost + context window → mid |
test_apply_tiering.py |
DB tiering pass, sanity guards |
test_poller_parsing.py |
Serving class, base model, access level parsing |
test_proficiency.py |
Blending rule, accumulation |
test_load_candidates.py |
load_candidates: cost/eco/proficiency join |
test_eval_scoring.py |
Code, exact, tool, and judge scoring |
test_task_set.py |
Reference solutions validating every code task's checks & exact answers |
test_verification.py |
Structural checks on every language, verifier edge cases, local-LLM gating |
test_feedback.py |
Failure identification, idempotency, attribution filtering |
test_iteration.py |
Retry budget per tier, and matching the retry to the failure kind |
test_session_identity.py |
Outcome attribution: session matching, ambiguity refusal |
test_config_endpoints.py |
Classifier and verifier are separately addressable; guards on the split |
Setup
You need: a Neuralwatt API key, Python 3.10+ (suite verified on 3.10 and 3.14), and an Ollama reachable from wherever this runs with a classifier model pulled. Nothing else is assumed about the host — routing itself is SQLite and arithmetic.
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
sqlite3 router.db < schema.sql
cp .env.example .env # fill in NEURALWATT_API_KEY
python poller.py # populate the catalog
python tier.py # resolve tiers
python config.py # sanity-check config loads
python -m uvicorn dispatcher:app --reload
Then edit config.yaml for your own setup — at minimum:
| Key | Why |
|---|---|
classifier.model |
must match a model ollama list reports |
classifier.base_url |
where that Ollama actually is |
objective.plan_kwh_per_period |
your plan's quota; /health reports burn against it |
objective.assumed_cache_rate |
0.917 was measured from one client's traffic (40.7M tokens). Check yours against the provider's per-session cache-hit figures |
python seed_energy.py is optional. It sweeps a fixed reference workload to
populate eco, which is logged but is not an objective — routing works
without it. It costs real money and quota, so it is not in the path above.
Where Ollama lives
ollama pull mistral-nemo:12b # or whatever you set as classifier.model
It does not have to be on the machine running the router; the box with the GPU usually isn't the laptop. To use one across a VPN, point both endpoints at it:
classifier:
base_url: "http://<vpn-ip>:11434/v1"
verification:
base_url: "http://<vpn-ip>:11434" # same host, so `model` can stay null
and apply deploy/ollama-over-vpn.conf on the serving host — Ollama binds
127.0.0.1 by default and will otherwise refuse. Bind it to the VPN address
rather than 0.0.0.0: Ollama has no authentication, so anything reaching the
port can run inference and enumerate your models.
Both endpoints move together because the verifier speaks Ollama's native
API and cannot follow the classifier to a cloud provider. Config load refuses
the case where they are on different hosts and verification.model is null,
because that combination fails silently.
Known Limitations & Open Items
- Leaderboard priors unfilled —
leaderboards.yamlships empty.python leaderboard.py --checknames every active family lacking a prior. Must source figures from published benchmarks; inventing plausible numbers is the same failure as the provider'sstatic_fallbackcarbon constant. - Three models unsettled — split-half stability at 7 samples:
kimi-k2.7-code-fast(29×),kimi-k3(14×),glm-5.2-flex(2.2×). More samples needed before their positioning is trustworthy. - Retry does not reach streaming — a failed structural check now buys
corrective attempts scaled by tier (
iteration.py), but only on the non-streaming path. Once bytes have gone to the client there is nothing to take back, and buffering to allow correction would cost streaming itself.POST /outcomeis the answer for streamed traffic: the client reports afterwards, so it works identically either way. - No auth — the service holds a billable API key with no authentication
of its own. It binds loopback for that reason; widening the bind needs an
auth layer first. The same applies to an Ollama shared over a VPN, which
also has no auth — bind it to the VPN address, never
0.0.0.0. - Local energy is not accounted for — the router measures what the cloud provider bills and reports, but the electricity its own classifier and verifier burn on your hardware is invisible to it. Since the whole premise is spending cheap local compute to avoid wasting expensive cloud compute, that half of the ledger is currently taken on faith. Closing it means metering local draw and pricing it against a real tariff rather than assuming local is free.
- Context assembly (RAG) is out of scope — the classifier sees the full conversation but does not perform document/code retrieval.