- README.md: update test count (356→562, 27 files), add events.py, tui_model.py, tui_sse.py, tui_screens.py to Modules table, add GET /events/decisions to API Endpoints, describe live SSE feed + detail popup + category breakdown in Monitoring, update textual import note, add new test files to Testing table. - CLAUDE.md: update dispatcher description (SSE endpoint), tui description (live feed, modal, breakdown, tui_model split), test count (562, 27 files), add events.py entry. - AGENTS.md: new agent-facing working guide — stack snapshot, module map with file boundaries and import discipline, conventions, test commands, open items, post-change checklist.
1162 lines
60 KiB
Markdown
1162 lines
60 KiB
Markdown
# Local LLM Model Router — project brief
|
||
|
||
`README.md` documents what is actually built, module by module.
|
||
`design/local-llm-model-router.md` holds the architecture and rationale,
|
||
including parts still unbuilt. This file is the working state + immediate
|
||
next steps, and is the one to trust on what is currently true.
|
||
|
||
## What this is
|
||
|
||
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.
|
||
|
||
Every measurement in this file was taken on one deployment against one
|
||
provider account. They are recorded because the reasoning is worth more than
|
||
the conclusion, but treat them as observations with a date on them, not as
|
||
constants — the catalog, prices, grid intensity and pool load all move. When
|
||
a number here decides something, re-run the measurement before trusting it.
|
||
|
||
Neuralwatt is the only provider. OpenRouter was removed — the `provider`
|
||
column and the `(model_id, provider)` primary key stay so a second provider
|
||
can be added later without a migration.
|
||
|
||
## Stack
|
||
|
||
- Python (chosen over Rust — this is I/O-bound against provider APIs, not
|
||
CPU-bound; iteration speed on the scoring/weighting logic matters more than
|
||
raw execution speed at this scale)
|
||
- SQLite for the decision table
|
||
- Ollama for local classification, via any OpenAI-compatible endpoint —
|
||
`localhost:11434/v1`, or an Ollama on another machine across a VPN
|
||
(`classifier.base_url`)
|
||
- FastAPI for the dispatcher service
|
||
|
||
## Billing is per-kWh, not per-token — and neither is what scoring uses
|
||
|
||
**Measured against the live API, 2026-08-11.** Neuralwatt bills a flat
|
||
**$8.00 per kWh** and the catalog's `input_per_million` /
|
||
`output_per_million` prices are not what this account is charged. Confirmed
|
||
across five models; `cost_usd / energy_kwh` came back 8.00 every time:
|
||
|
||
| model | list $/1M out | completion tokens | billed USD | kWh | $/kWh |
|
||
|---|---|---|---|---|---|
|
||
| deepseek-v4-flash | 0.28 | 600 | 5.00e-06 | 5.88e-07 | 8.50\* |
|
||
| gemma-4-31b | 0.42 | 540 | 3.80e-04 | 4.75e-05 | 8.00 |
|
||
| qwen3.6-35b-fast | 1.15 | 600 | 2.53e-04 | 3.16e-05 | 8.01 |
|
||
| kimi-k2.7-code-fast | 4.00 | 600 | 2.25e-03 | 2.81e-04 | 8.00 |
|
||
| kimi-k3-fast | 15.00 | 539 | 2.17e-04 | 2.71e-05 | 8.00 |
|
||
|
||
\* rounding — billed cost is quantized to ~1e-06.
|
||
|
||
The precise rule, validated against all 65 samples of the reference sweep
|
||
(61/65 within 2%; the 4 outliers are microdollar rounding, not misses):
|
||
|
||
```
|
||
cost_usd = min( $8.00/kWh x energy_kwh , 3 x list token price )
|
||
```
|
||
|
||
The ceiling bound in only 2 of 65 samples, both `deepseek-v4-flash` energy
|
||
spikes, and matched to the cent: 31 prompt x $0.14/1M + 400 completion x
|
||
$0.28/1M = 1.1634e-04, x3 = 3.4902e-04, billed 0.000349.
|
||
|
||
**List price ranks models backwards.** Not approximately — invertedly:
|
||
|
||
| | list $/1M | actually billed | gCO2eq |
|
||
|---|---|---|---|
|
||
| `deepseek-v4-flash` | **0.28** | 9.80e-05 | 8.90e-04 |
|
||
| `gemma-4-31b` | 0.42 | **5.02e-05** | **2.32e-04** |
|
||
|
||
deepseek lists 33% cheaper, costs 95% more, and emits 284% more carbon.
|
||
|
||
**But cost and eco are NOT the same axis** — the tempting simplification, and
|
||
it is wrong. Cost tracks energy, but carbon is energy x the serving region's
|
||
grid intensity, and models run in different regions:
|
||
|
||
| grid | gCO2/kWh | models |
|
||
|---|---|---|
|
||
| `FI` | ~49-50 | most of the catalog; varies by time |
|
||
| `FI` (reported) | 475 | `glm-5.2-fast`, `glm-5.2-flex` |
|
||
| `US-MIDA-PJM` | ~442 | the `kimi-k3` family |
|
||
|
||
A 13.6x spread, so the two axes disagree: `glm-5.2-fast` is the 2nd cheapest
|
||
model and only the 6th cleanest; `kimi-k3-flex` draws 3.7x *less* energy than
|
||
`kimi-k2.7-code` while emitting 3.6x *more* carbon. Weighting them separately
|
||
is load-bearing, and `tests/test_routing.py` pins it.
|
||
|
||
**Superseded — cost no longer comes from the sweep at all.** `cost` was the
|
||
*median* measured USD over the reference sweep. That was measured to be WRONG
|
||
for real traffic, because the reference workload is the wrong shape.
|
||
|
||
The sweep sends a 400-token prompt with a 400-token completion. Real agent
|
||
traffic is a 150,000-token prompt with a ~400-token completion and ~92% cache
|
||
hits (token-weighted over 50 sessions and 40.7M tokens on 2026-08-23; the
|
||
figure was 84% when measured on 2.2M tokens of earlier traffic). The attribution ratio moves with prompt size, so the ranking inverts:
|
||
|
||
| workload | winner |
|
||
|---|---|
|
||
| reference sweep (400/400) | `glm-5.2-fast`, 3.2x cheaper |
|
||
| realistic (70k prompt, short answer) | **`deepseek-v4-flash`, 5.0x cheaper** |
|
||
|
||
Same two models, opposite answer. `glm-5.2-fast` sits at attribution 0.006 on
|
||
a toy prompt and 0.50 on a 70k one — it batches beautifully on small prompts
|
||
and badly on real ones. `deepseek-v4-flash` barely moves (0.21 -> 0.25).
|
||
|
||
So `routing.estimated_cost` prices each request from **catalog token prices,
|
||
scaled to that request's actual shape** (prompt size, assumed completion
|
||
length, `assumed_cache_rate`). List price is not what gets billed, but billing
|
||
is capped at 3x list, so it tracks the real ordering and bounds it — and on
|
||
the one case that was checked live it agrees with the measurement in direction
|
||
and magnitude (7.8x predicted vs 5.0x measured). It is also free, needs no
|
||
sweep, and refreshes whenever the poller runs.
|
||
|
||
Three signals said `deepseek-v4-flash` — catalog token price (7.8x cheaper),
|
||
NeuralWatt's own published per-request energy (~10x lower), and a live 70k
|
||
measurement (5.0x cheaper). Only the 400-token benchmark disagreed. Trust the
|
||
workload you actually run.
|
||
|
||
`eco` still comes from the sweep's median gCO2eq, and is still not an
|
||
objective. `flex_cost_multiplier` is gone: a flex row's measured cost already
|
||
is its flex cost.
|
||
|
||
**Open, and worth knowing:** NeuralWatt's model cards publish *gross* energy
|
||
(~1.99e-04 kWh for deepseek, ~1.91e-03 for GLM), while the billed figure is
|
||
gross x attribution. GLM burns roughly 7x more actual electricity per request
|
||
and charges ~5x less, because far more tenants share its GPUs. Anything built
|
||
on `eco` inherits that inversion — the attributed carbon figure answers "what
|
||
is my share", not "what was burned".
|
||
|
||
## Energy attribution: signal that looks like noise
|
||
|
||
Billed energy decomposes exactly:
|
||
|
||
```
|
||
energy_kwh = avg_power_watts x duration_seconds x attribution_ratio
|
||
```
|
||
|
||
`attribution_ratio` is the request's share of a shared multi-tenant GPU pool.
|
||
Up close it looks like pure noise — eight rapid identical calls to one model
|
||
spanned 20x in billed energy, correlating **+0.997** with the ratio while
|
||
power and duration held steady. Two sweeps of the same 13 models with the
|
||
same prompt disagreed by up to 36x.
|
||
|
||
Scoring on the pre-attribution product (`power x duration`) was tried, and it
|
||
is **wrong**. Across the sweep:
|
||
|
||
| | spread |
|
||
|---|---|
|
||
| median attribution, **between** models | **750x** |
|
||
| typical spread **within** one model | **1.8x** |
|
||
|
||
The ratios are quantized (0.001, 0.25, 0.5, 0.75) — that is serving
|
||
concurrency, a stable per-model property, not weather. A model whose GPUs
|
||
carry far more concurrent requests genuinely costs less per request, and
|
||
that is most of the real cost difference in the catalog: `deepseek-v4-flash`
|
||
bills ~1000x under its share of pool gross. Stripping attribution discards a
|
||
750x real signal to suppress a 1.8x one.
|
||
|
||
So scoring reads the attributed figures, and the **median** absorbs what
|
||
noise remains. A split-half check on the 7-sample sweep (median of first
|
||
three vs last four) shows that working:
|
||
|
||
- **10 of 13 models agree within 1.4x** — stable enough to route on
|
||
- **3 do not**: `kimi-k2.7-code-fast` (29x), `kimi-k3` (14x),
|
||
`glm-5.2-flex` (2.2x). Those need more samples before their position is
|
||
trustworthy.
|
||
|
||
`dispatcher.gross_energy_kwh` remains as a diagnostic on the identity, not a
|
||
scoring input.
|
||
|
||
### Attribution drifts across hours, so sampling must too
|
||
|
||
Within about 30 minutes the billed figures reproduce (0.3-1.1x on a
|
||
spot-check). Across hours they do not: between two sweeps,
|
||
`deepseek-v4-flash` moved roughly 50x and `qwen3.6-35b` about 7x the other
|
||
way — enough to **invert their cost ranking**. Attribution tracks pool load,
|
||
and pool load tracks time of day.
|
||
|
||
More samples inside one sweep does not fix this; it measures one moment more
|
||
precisely. Coverage across time does. `load_candidates` already takes the
|
||
median over ALL `seed_reference` rows, so repeated sweeps accumulate into a
|
||
median-across-time for free — hence `llm-router-seed.timer`, which runs a
|
||
small sweep every 6 hours.
|
||
|
||
Until several sweeps have accumulated, treat the eco ordering as provisional.
|
||
A single sweep's ranking is one sample of a moving quantity.
|
||
|
||
**And none have accumulated since 6e729ad.** That commit moved
|
||
`log_observation`'s trailing arguments to keyword-only without updating
|
||
`seed_energy.py`, so every timer run since spent one billed completion and then
|
||
died on `TypeError` — which is not a `RequestException`, so the per-sample
|
||
`except` did not catch it. Fixed, and the sweep now has an offline end-to-end
|
||
test, but the accumulation this section describes starts from the next run
|
||
rather than from months of history.
|
||
|
||
## What's built and working
|
||
|
||
- `schema.sql` — `models`, `proficiency`, `energy_observations`. Applies
|
||
cleanly (`sqlite3 router.db < schema.sql`). `models` carries the serving
|
||
class columns (below); `energy_observations` carries real carbon/cost.
|
||
- `poller.py` — fetches Neuralwatt's `/models` endpoint (public,
|
||
unauthenticated), normalizes, upserts, marks stale rows. **Verified
|
||
against the live API**: 19 models, and the `metadata.pricing` /
|
||
`metadata.capabilities` / `metadata.limits` field mappings are confirmed
|
||
correct.
|
||
- `config.yaml` / `config.py` — weights, thresholds, provider settings,
|
||
Pydantic-validated.
|
||
- `scoring.py` — one `normalize_inverted` (cost and eco normalize
|
||
identically; they differ only in what is fed to them) plus the weighted
|
||
composite.
|
||
- `seed_energy.py` — runs a fixed reference task N times per routable model
|
||
and writes `energy_observations` rows tagged `seed_reference`. This is what
|
||
makes `cost` and `eco` real numbers instead of the neutral 0.5. Re-run it
|
||
after the catalog gains models: `python seed_energy.py --samples 5`
|
||
(13 models x 5 = 65 calls, and the whole sweep cost **under a cent**).
|
||
- `tiering.py` / `tier.py` — pure tier resolver + the DB pass that applies it.
|
||
- `routing.py` — pure hard filters and ranking.
|
||
- `dispatcher.py` — FastAPI service. `GET /health`, `POST /route` (classify
|
||
and pick, no provider call), `POST /dispatch` (route, call, log), plus an
|
||
OpenAI-compatible `GET /v1/models` and `POST /v1/chat/completions`, and a
|
||
Server-Sent Events `GET /events/decisions` stream that fans every recorded
|
||
routing decision out to the monitoring TUI live.
|
||
- `proficiency.py` / `proficiency_store.py` — pure blending arithmetic, and
|
||
the only path that writes the `proficiency` table (so `blended_score` and
|
||
`source` can never drift from the inputs that produced them).
|
||
- `events.py` — in-memory decision-event broker (pure stdlib, no Textual): a
|
||
bounded ring buffer of recent route decisions plus thread-safe fan-out to
|
||
SSE subscribers. `persist_route_decision` publishes here after each write,
|
||
so the dashboard sees decisions in near real time without polling. The
|
||
durable source of truth stays the `route_decisions` SQLite table; this is
|
||
just the volatile live fan-out.
|
||
- `leaderboards.yaml` / `leaderboard.py` — curated per-family priors and their
|
||
importer. **Ships empty by design**; `python leaderboard.py --check` names
|
||
every active family lacking a prior.
|
||
- `evals/tasks.yaml` / `eval_proficiency.py` — the self-eval task set and
|
||
runner. Four scoring kinds, objective wherever the category allows it:
|
||
`code` executes the model's Python against checks, `exact` compares a
|
||
normalized answer, `tool` inspects the tool call structurally, and only the
|
||
four prose categories fall back to a `judge`.
|
||
- `capabilities.py` — request-side capability detection. `detect_capabilities`
|
||
reads an OpenAI-format body and returns `RequestCapabilities` (`has_images`,
|
||
`require_json_mode`, `tools_present`, `has_reasoning_request`). It scans all
|
||
messages for `image_url` parts and reads `response_format.type` against
|
||
`{json_object, json_schema}`. Detection is read from the request body, not
|
||
inferred by a classifier.
|
||
- `logs.py` — the service's structured logging: a per-request trace id in a
|
||
ContextVar, logfmt rendering, and journald priority prefixes emitted only
|
||
when systemd actually owns stderr. `logs.bind()` exists because a
|
||
StreamingResponse's generator is resumed in a fresh copy of the caller's
|
||
context, so the ContextVar cannot reach it — the path all agent traffic
|
||
takes. Level from `logging.level`, overridden by `LLM_ROUTER_LOG_LEVEL`.
|
||
- `metrics.py` / `GET /metrics` — read-only observability aggregation. Keeps
|
||
the dashboard helpers outside `dispatcher.py` to avoid an import cycle:
|
||
`dispatcher` imports `metrics` for `/health` and `/metrics`, so `metrics`
|
||
takes `(conn, cfg)` arguments and never imports `dispatcher`. Returns quota
|
||
burn against `plan_kwh_per_period`, scoring coverage warnings, recent
|
||
`route_decisions`, per-model aggregates over `energy_observations`,
|
||
verification verdict mix, and top proficiency by category.
|
||
- `tui.py` — Textual terminal dashboard over `GET /metrics` and
|
||
`GET /events/decisions`. A foreground entrypoint (`python tui.py`), not a
|
||
service. `textual` is imported only in the TUI modules (`tui.py`,
|
||
`tui_screens.py`, `tui_sse.py`), so the router's dispatch path has no UI
|
||
dependency. The dashboard has a live routing-decisions feed (via the SSE
|
||
endpoint, so new decisions appear without waiting for the 5s `/metrics`
|
||
poll), a category → model breakdown panel, and a detail popup (Enter or `e`
|
||
on any decision row) showing the full decision JSON. The pure data layer
|
||
(`build_model`, `build_category_breakdown`, `decision_row`) is split into
|
||
`tui_model.py` so it is testable without a terminal.
|
||
- `router_cli.py` — one-shot `/route` probe. Posts a task to the running router
|
||
and prints the decision tree, or emits raw JSON with `--json`. Spends no
|
||
quota because it only routes.
|
||
- `tests/` — 562 tests across 27 files, all passing, all offline. Verified on
|
||
Python 3.10 and 3.14; nothing declares `requires-python`, so 3.10 is the
|
||
tested floor rather than a promised one.
|
||
|
||
### Monitoring: route_decisions persistence
|
||
|
||
`route_decisions` is the newest observability table (not a scoring input). It
|
||
records one row per routing decision — `route` \| `dispatch` \| `chat` |
|
||
`passthrough` \| `local_vision` — with category, tier, selected model, runners
|
||
up, estimated cost/proficiency, classifier latency, rejection reason, and
|
||
request feature flags (`tools`, `images`, `json_mode`, `streamed`). It stores
|
||
only a hashed `session_key`; `session_dir`, prompts, and answers are excluded,
|
||
and a test enforces that the write path never stores conversation text.
|
||
|
||
The write is gated by `logging.log_route_decisions` and is **best-effort**: a
|
||
failed write is logged and swallowed, because a decision record is worth
|
||
having but never worth failing or slowing a request for. The table is created
|
||
from code at module load (`_ensure_route_decisions_table`) and again on every
|
||
write (`ensure_route_decisions` inside `persist_route_decision`), mirroring
|
||
the `proficiency_store.ensure_columns` migration pattern: `schema.sql` is
|
||
`CREATE TABLE IF NOT EXISTS`, but a live `router.db` predating this table
|
||
needs the code-side migration. Both the module-load hook and the write-path
|
||
guarantee are idempotent and leave existing rows intact.
|
||
|
||
### Request-side capability gates
|
||
|
||
The router treats some capabilities as hard filters, read directly from the
|
||
request body. Two new gates live in `routing.rejection_reason` and
|
||
`routing.select_candidates`:
|
||
|
||
- `require_vision` — active when the request carries `image_url` parts and
|
||
`routing.require_vision` is true. A model passes only if its catalog row says
|
||
`supports_vision = 1`. `supports_vision = NULL` fails closed: an unknown flag
|
||
means the capability cannot be confirmed, and routing an image request to a
|
||
model that might lack vision is a guaranteed provider 400.
|
||
- `require_json_mode` — active when `response_format.type` is `json_object` or
|
||
`json_schema` and `routing.require_json_mode` is true. A model passes only if
|
||
`supports_json_mode = 1`; `NULL` also fails closed, for the same reason.
|
||
|
||
Both gates default to **on** in `config.yaml`, because a wrong guess produces a
|
||
400. This is a deliberate asymmetry against the tool-proficiency gate below:
|
||
capability **flags** fail closed on unknown, while quality **measurements**
|
||
(tool proficiency, energy) admit on absent evidence ("unproven, not bad").
|
||
|
||
`tools_present` remains a measurement gate, not a flag gate. Every routable
|
||
catalog row has `supports_tools = 1`, so that flag would be inert. What matters
|
||
is the measured `tool_use_agentic` proficiency. A model with no measured tool
|
||
score is admitted; a measured score below `routing.min_tool_proficiency` is
|
||
dropped — but only when the request carries a `tools` array. This gated filter
|
||
ships disabled (`null`).
|
||
|
||
`has_reasoning_request` is detected in `capabilities.py` but is informational
|
||
only. Reasoning stays on tiering: `reasoning_default_enabled` decides the tier,
|
||
not `supports_reasoning` (which only means "the endpoint accepts a reasoning
|
||
param" and is true for 17 of 19 rows). There is no reasoning capability gate.
|
||
|
||
### Pass-through capability check
|
||
|
||
When a client pins a real model id in `/v1/chat/completions`, the router
|
||
dispatches as asked. Before spending a provider call, `_check_pinned_capabilities`
|
||
reads the model's `supports_vision` and `supports_json_mode` flags and returns a
|
||
clear 422 if the pin cannot satisfy the request. The pin could never have worked,
|
||
so failing early is better than an opaque provider 400.
|
||
|
||
### Local Ollama vision fallback
|
||
|
||
`local_vision:` in `config.yaml` configures a fallback path for image requests
|
||
that find no cloud vision candidate — the cloud catalog excludes the cost
|
||
leader (deepseek) on vision, so without this fallback every image request
|
||
that would otherwise have routed there 422s instead. It is a **core feature**,
|
||
**enabled by default** (`enabled: true`, both in `config.yaml` and in
|
||
`LocalVisionConfig`'s own default, so a config that omits the section still
|
||
gets it). Disable it explicitly (`enabled: false`) on a host with no local
|
||
Ollama, or one that hasn't pulled the vision model.
|
||
|
||
When enabled and routing returns no selected model, `_run_local_vision` sends
|
||
the original messages, with `image_url` parts intact, to a local Ollama model
|
||
(`qwen3-vl:4b` by default). The local answer then **replaces** the completion:
|
||
`_local_vision_response` returns a normal OpenAI-shaped response, including a
|
||
stream-wrapped version for `stream: true`. It does not inject a caption into a
|
||
cloud call, because the streaming proxy cannot rewrite bytes mid-stream.
|
||
|
||
Budget guards refuse images too numerous or too large before the local call is
|
||
made. If the local call fails for any reason, it falls through to the ordinary
|
||
`422 No model satisfies the hard filters` so the failure is visible rather than a
|
||
silent empty response.
|
||
|
||
### Serving class: one base model, many rows
|
||
|
||
Neuralwatt ships ~6 base models as 19 catalog rows. The id suffixes are three
|
||
**orthogonal** dimensions (hence `glm-5.2-short-fast-flex`), parsed by
|
||
`poller.parse_serving_class` into columns:
|
||
|
||
| suffix | column | meaning |
|
||
|---|---|---|
|
||
| `-flex` | `latency_class` | discounted async; held server-side during peak until a capacity gap opens |
|
||
| `-fast` | `reasoning_mode` | thinking disabled or capped to a short budget — *not* queue priority |
|
||
| `-short` | `context_variant` | 200K pool with a bounded reasoning budget |
|
||
|
||
These rows carry **identical catalog pricing**, so without these columns all
|
||
7 GLM rows tie exactly and the router picks arbitrarily — which could send an
|
||
interactive request to a preemptible flex row. `latency_tolerance`
|
||
(`interactive` | `batch`) is therefore a hard filter in `routing.py`, not a
|
||
weight. Suffixes are matched as whole `-`-delimited segments so
|
||
`deepseek-v4-flash` is not misread as a `-fast` row.
|
||
|
||
### Access gating is prose-only
|
||
|
||
6 of 19 rows are restricted, and the catalog says so only in free text
|
||
("Private preview (grant-gated)", "(Canary)") — there is no structured field.
|
||
`poller.parse_access_level` parses it into `access_level`, and
|
||
`routing.allowed_access_levels` (default `[public]`) excludes them, so the
|
||
dispatcher doesn't select a model that 403s. 13 of 19 rows are routable.
|
||
|
||
### Tiering
|
||
|
||
Tier on `reasoning_default_enabled` (from `metadata.reasoning.default_enabled`,
|
||
falling back to `capabilities.reasoning`), **not** `supports_reasoning`.
|
||
`capabilities.reasoning` only means "the endpoint accepts a reasoning
|
||
param" — it is true for 17 of 19 rows, and tiering on it put 17 models in
|
||
tier 3 and left tier 1 empty. A `-fast` row does not inherit its sibling's
|
||
tier 3. Cost is checked before the reasoning rule so $0.28/1M models can
|
||
reach tier 1.
|
||
|
||
**Cheapness is not a capability ceiling** (`tier1_context_max`, default
|
||
512000). Tier is a *floor* — `routing.py` drops any row with
|
||
`tier < required_tier` — so tier 1 means "simple work only", not "cheap".
|
||
Deciding that on price alone put `deepseek-v4-flash` in tier 1 for no reason
|
||
but its $0.28/1M completion price, which excluded it **outright** from every
|
||
tier-2 request. It has a 1M advertised window and scores 1.00 on all three
|
||
coding categories. That was the same substitution the cost axis already had
|
||
to unlearn: price is a market signal, not a capability measurement.
|
||
|
||
Tier 1 now requires the model to be small **and** cheap. The gate reads the
|
||
**advertised** `context_window`, whose catalog values are the clean market
|
||
classes — 131056 / 199984 / 262128 / 1048560 — rather than
|
||
`effective_context_window`, which varies within a class. 512000 sits in the
|
||
empty band between the 256K and 1M classes with a 2x margin either side, so it
|
||
is not fitted to any one model. The gate only ever demotes; a huge window
|
||
never promotes an expensive model into tier 1, and a *missing* window does not
|
||
block it, since absent evidence should not decide anything.
|
||
|
||
Distribution moved **4 / 6 / 9 -> 1 / 9 / 9** — only the three deepseek rows
|
||
changed. With cost priced per-request, `deepseek-v4-flash` now wins
|
||
`coding_general` at every context size (16.5x cheaper than `kimi-k3` at 200k)
|
||
and is still correctly absent from `tool_use_agentic`, where its measured 0.33
|
||
drops it out of the quality band. That is the eval data earning it the slot
|
||
rather than a thumb on the scale — no `model_tiers` override was needed.
|
||
|
||
### Why tools and reasoning stay on their existing signals
|
||
|
||
Tools stay on the measured `tool_use_agentic` proficiency gate, not a
|
||
`supports_tools` flag gate. Every routable catalog row already has
|
||
`supports_tools = 1`, so a flag gate would be inert. The real signal is the
|
||
measured proficiency, because the observed failure is a model over-reaching for
|
||
tools on a non-agentic prompt. `routing.min_tool_proficiency` captures that
|
||
measurement and only applies when the request carries a `tools` array.
|
||
|
||
Reasoning stays on tiering (`reasoning_default_enabled`), not on a new flag gate.
|
||
`supports_reasoning` only means the endpoint accepts a reasoning parameter, and
|
||
that is true for 17 of 19 rows — nearly the whole catalog. `has_reasoning_request`
|
||
is detected purely for observation. Making it a gate would add no useful
|
||
filtering, because the decision of whether a request needs reasoning is already
|
||
encoded in the requested tier.
|
||
|
||
The fail-closed asymmetry, stated plainly: **capability flags fail closed on
|
||
unknown; quality measurements admit on absent evidence.** A missing
|
||
`supports_vision` or `supports_json_mode` flag means "cannot confirm", so the
|
||
model is dropped. A missing `tool_use_agentic` score or energy measurement means
|
||
"unproven, not bad", so the model is admitted. The first wrong guess is a
|
||
guaranteed 400; the second is just an empty data point that the neutral default
|
||
handles.
|
||
|
||
## Proficiency: category now changes routing
|
||
|
||
`proficiency_score` is the ONLY category-dependent term in the ranking, so
|
||
until this table had data, `task_category` could not change a decision at
|
||
all — the classifier computed it, the router paid ~10s for it, and then it
|
||
made no difference. It does now: sweeping 9 categories x 3 tiers currently
|
||
returns **5 distinct winners** at both 50k and 120k of context.
|
||
|
||
| context | winners over 27 decisions |
|
||
|---|---|
|
||
| 50k | `qwen3.6-35b` (10), `gemma-4-31b` (7), `deepseek-v4-flash` (5), `kimi-k3` (3), `kimi-k3-fast` (2) |
|
||
| 120k | `kimi-k2.7-code` (10), `gemma-4-31b` (7), `deepseek-v4-flash` (5), `kimi-k3` (3), `kimi-k3-fast` (2) |
|
||
|
||
**This spread is recent, and how it got here is the useful part.** For a long
|
||
time all 27 decisions returned ONE model, and that was the correct answer at
|
||
the time rather than a bug: with cost and eco both populated, `qwen3.6-35b`
|
||
was Pareto-dominant — cheapest AND cleanest in the routable set, while
|
||
scoring within `quality_tolerance` of the best. No defensible weighting picks
|
||
anything else out of that.
|
||
|
||
Two corrections widened it, and neither was a tuning change:
|
||
|
||
- **Cost stopped being a benchmark average.** It is now priced per request
|
||
from catalog prices scaled to the request's shape, so the ranking depends
|
||
on the workload instead of on a 400-token reference sweep that no real
|
||
traffic resembles.
|
||
- **Tier stopped being inferred from price.** `deepseek-v4-flash` was pinned
|
||
to tier 1 for being cheap, which excluded it from every tier-2 request
|
||
regardless of what any score said.
|
||
|
||
Note what changes between the two rows above: only the leader, and only
|
||
because of the hard context filter. That is the filter working, not the
|
||
scoring disagreeing with itself.
|
||
|
||
**If you see one model win everything again, check for dominance before
|
||
reaching for config.** One winner is a legitimate outcome. The lever, if a
|
||
genuinely different balance is wanted, is `objective.quality_tolerance` (how
|
||
large a quality gap must be before it outranks a cost saving) or
|
||
`objective.max_energy_per_request` (a hard ceiling). There is no weight to
|
||
tune — quality is the objective and cost is the tiebreak.
|
||
|
||
### What the task set actually found
|
||
|
||
**The benchmark could not discriminate these models on coding.** Every row
|
||
scored exactly 1.00 on `coding_general`, `coding_refactor` and `debugging` —
|
||
and that is after the tasks were deliberately hardened with touching
|
||
intervals, full semver, present-but-falsy defaults, late-binding closures and
|
||
a binary search that infinite-loops. Every model in this catalog is simply
|
||
good at that class of problem, so cost decides coding routes, which is the
|
||
right outcome.
|
||
|
||
**Real traffic broke one of those ties, which the benchmark never could.**
|
||
`coding_general` now spans 0.86-1.00: `glm-5.2-fast` fell to 0.862 over 29
|
||
samples folded in by `feedback.py` from an actual agent session, and crossed
|
||
`self_eval_min_samples` on the way, so it reads `self_eval` rather than
|
||
`self_eval_thin`. That is the intended shape of this system — the 23-task
|
||
benchmark establishes a floor, and your own traffic is what refines it.
|
||
`coding_refactor` and `debugging` are still flat at 1.00, awaiting the same
|
||
treatment.
|
||
|
||
**A 1.00 can also be a sampling artifact, and `docs_writing` was one.** At 2
|
||
samples per model the category read 0.70-1.00 with a model at the ceiling, and
|
||
the router paid for that ceiling: `kimi-k3-fast` won every docs route. Six more
|
||
benchmark passes moved every score and left NOTHING at 1.00:
|
||
|
||
| model | n=2 | n=11-14 |
|
||
|---|---|---|
|
||
| `kimi-k3` | 0.85 | **0.973** |
|
||
| `kimi-k2.7-code` | 0.85 | 0.886 |
|
||
| `deepseek-v4-flash` | 0.80 | 0.864 |
|
||
| `kimi-k3-fast` | **1.00** | 0.864 |
|
||
| `qwen3.6-35b` | 0.85 | 0.800 |
|
||
| `gemma-4-31b` | 0.85 | 0.786 |
|
||
|
||
The winner moved to `kimi-k2.7-code`, **3.2x cheaper** at 50k of context
|
||
($0.0441 -> $0.0136), with no config change — `kimi-k3` scores higher but sits
|
||
inside `quality_tolerance`, so cost breaks the tie. `deepseek-v4-flash`
|
||
($0.0024) misses the band by 0.009, which is the kind of margin the tolerance
|
||
exists to describe rather than a verdict.
|
||
|
||
**The whole spread rests on one rubric line, though.** `docs_function` is
|
||
effectively saturated — 1.00 on nine of every ten samples — and nearly every
|
||
`docs_gotcha` deduction is the same omission: the model documents that order is
|
||
preserved, that the first occurrence is kept, and what `key` does, then never
|
||
says elements must be hashable. That is real discrimination, since it is a real
|
||
property of the function, but one sentence is deciding a category. Treat this
|
||
ordering as thinner than n=14 makes it look.
|
||
|
||
**The self-judging guard costs sample density, and it shows up here.** Most
|
||
models reached n=14; `kimi-k3` and `kimi-k3-fast` reached only 11, because
|
||
those two are the ones diverted to the alternate judge `qwen3.6-35b`, which
|
||
returns unparseable JSON more often than `kimi-k3` does. The guard is still
|
||
right — a model grading its own family is worse than a thinner sample — but
|
||
the alternate judges should be picked for parseability, not just for being
|
||
someone else.
|
||
|
||
The reflex when a category looks flat is to reach for `quality_tolerance`.
|
||
Neither tie broken so far was broken that way: `coding_general` opened up when
|
||
`feedback.py` folded in real traffic, and `docs_writing` opened up on six more
|
||
benchmark passes. Both were samples, not settings. `coding_refactor` and
|
||
`debugging` are still flat at 1.00 on 2-3 samples each — which is now a state
|
||
this project has mistaken for a measurement once.
|
||
|
||
Current spread by category, widest first:
|
||
|
||
| category | spread |
|
||
|---|---|
|
||
| `tool_use_agentic` | 0.33 - 1.00 |
|
||
| `summarization` | 0.60 - 1.00 |
|
||
| `reasoning_math` | 0.67 - 1.00 |
|
||
| `docs_writing` | 0.66 - 0.97 |
|
||
| `general_chat` | 0.80 - 1.00 |
|
||
| `translation` | 0.85 - 1.00 |
|
||
| `coding_general` | 0.86 - 1.00 |
|
||
| `coding_refactor`, `debugging` | flat at 1.00 |
|
||
|
||
**What does discriminate is tool use, arithmetic traps, and prose.**
|
||
`deepseek-v4-flash` scores 1.00 on all three coding categories yet **0.33 on
|
||
`tool_use_agentic`** and 0.67 on `reasoning_math`. Verified live, not an
|
||
artifact: given "It is 1:20pm and my meeting starts at 3pm, how many minutes
|
||
away?" — both times supplied — it calls *two* tools rather than subtracting.
|
||
It over-reaches for tools, which is exactly the failure mode that matters in
|
||
an agent loop. The router now avoids it for those categories while still
|
||
picking it for coding.
|
||
|
||
Most rows still read `source='self_eval_thin'` (118 of 132): real
|
||
measurement, but below `self_eval_min_samples` at 2-3 tasks per category per
|
||
run. The 14 that have crossed it are all `docs_writing`, from the six extra
|
||
passes above. Two paths thicken it, and they are complementary — re-run
|
||
`eval_proficiency.py` to accumulate benchmark samples, or just use the router
|
||
and let `feedback.py` fold in real outcomes. Both fold into a running mean
|
||
rather than replacing, so samples add up across runs.
|
||
|
||
### A score is only as fresh as the row it was copied to
|
||
|
||
Proficiency is a property of the weights, not the queue, so the eval harness
|
||
scores one row per family and `propagate_to_variants` copies the result onto
|
||
the serving variants — `kimi-k3-flex` gets `kimi-k3`'s number, because no
|
||
benchmark rates a `-flex` row separately.
|
||
|
||
That copy used to happen **exactly once per variant, ever.** The guard skipped
|
||
any row with `self_eval_samples > 0`, meaning "measured directly, do not
|
||
overwrite" — but inheritance copies the sample count too, so after the first
|
||
propagation an inherited row was indistinguishable from a measured one and was
|
||
never refreshed again. `kimi-k3-flex` sat at 0.85/n=2 while `kimi-k3` moved to
|
||
0.973/n=11.
|
||
|
||
`proficiency.inherited_from` records the provenance that was missing, and the
|
||
**migration** was the delicate half, not the fix: `ADD COLUMN` gives every
|
||
existing row NULL, which reads as "measured here", so shipping the guard alone
|
||
would have permanently frozen the exact rows it exists to unfreeze. The
|
||
backfill infers provenance from the harness's own selection rule rather than
|
||
guessing — `eval_identities` only ever evaluates standard rows plus flex rows
|
||
with **no** standard equivalent, so a flex row that has one was never a
|
||
candidate for direct evaluation, whatever its sample count claims. Everything
|
||
else keeps NULL, which fails safe: NULL means "do not overwrite", so no real
|
||
measurement can be lost to a wrong guess.
|
||
|
||
Confirmed on the live database, and on the catalog's one genuine exception —
|
||
`glm-5.2` is canary, so `glm-5.2-flex` is the routable row the harness scores
|
||
directly, and its NULL is correct.
|
||
|
||
### Harness bugs this shook out
|
||
|
||
Three separate defects, each of which scored the rig rather than the model,
|
||
and each caught by reading per-task detail rather than the summary:
|
||
|
||
- **Token budget.** `max_tokens` was shared between a reasoning model's trace
|
||
and its answer. At 1200, qwen3.6-35b spent ~4,200 characters thinking and
|
||
returned an EMPTY content field, scoring 0.00 on tasks it can plainly do.
|
||
Now 24000, clamped per model (gemma-4-31b caps at 16384), and
|
||
`finish_reason: length` skips the sample instead of scoring it.
|
||
- **One leading space.** kimi-k2.7-code returns `" def f(...)"`, which becomes
|
||
IndentationError once the harness prepends its imports — 0.00 across all
|
||
nine coding tasks for a model with "code" in its name.
|
||
- **Judge failures scored as model failures.** 44% of judge calls returned
|
||
unparseable output (the judge is itself a reasoning model and leaks its
|
||
thinking despite `response_format`). Each was recorded as 0.0. Now the JSON
|
||
is extracted from surrounding prose and an unusable reply yields no sample.
|
||
|
||
`tests/test_task_set.py` exists so this stops happening: it implements a
|
||
reference solution for every `code` task and asserts it passes every check,
|
||
recomputes every `exact` answer (one by brute force), and confirms each
|
||
refactor target already passes its own checks while each debugging target
|
||
fails. It immediately caught a check where the expected value was simply
|
||
wrong — which would have docked every model on a task and been
|
||
indistinguishable from genuine difficulty.
|
||
|
||
## Tool competence is read from the request, not guessed at
|
||
|
||
Neither local classifier can identify agentic work. Asked to label six
|
||
unambiguous tool-use prompts ("read the config then update the manifest",
|
||
"run the tests and fix what fails"), `qwen3.5` got 2/6 and `mistral-nemo`
|
||
1/6 — and `mistral-nemo`'s misses collapse to `general_chat`, which is also
|
||
the configured `fallback_category`, so qwen3.5's crashes land in the same
|
||
place.
|
||
|
||
That mattered because `tool_use_agentic` has the widest proficiency spread in
|
||
the table (0.33-1.00) and `deepseek-v4-flash` — the current winner on coding —
|
||
sits at the bottom of it.
|
||
|
||
**The fix was not a better classifier.** Whether tools are on the table is
|
||
stated in the request: every agent client sends a `tools` array, and
|
||
`chat_completions` never looked at it. Reading it is exact and free.
|
||
|
||
It is applied as a **hard filter**, not a category override, and the
|
||
distinction is load-bearing. The question is not "is this task agentic" but
|
||
"can this model be trusted with tools that exist". The recorded failure is
|
||
precisely the second one: `deepseek-v4-flash` was given a *non*-agentic prompt
|
||
("it is 1:20pm and my meeting is at 3pm, how many minutes away?", both times
|
||
supplied) and called two tools rather than subtracting. A model that
|
||
over-reaches is a hazard on every request where tools are available, whatever
|
||
a classifier would have labelled the task.
|
||
|
||
So `routing.min_tool_proficiency` drops any candidate whose measured
|
||
`tool_use_agentic` score is below it, but only when the request carries tools:
|
||
|
||
| request | winner on `coding_general` @ 50k |
|
||
|---|---|
|
||
| no tools | `deepseek-v4-flash` ($0.0024) |
|
||
| tools present | `qwen3.6-35b` ($0.0041) |
|
||
|
||
Verified live through `/v1/chat/completions` with identical bodies differing
|
||
only by the `tools` array. The cost of safety here is 1.7x on that route,
|
||
paid only where tools exist.
|
||
|
||
**It is currently set to `null`, i.e. OFF**, deliberately and pending
|
||
experiment. opencode sends `tools` on essentially every request, so with the
|
||
filter on, `deepseek-v4-flash` is excluded from ordinary agent traffic and its
|
||
~7x cost advantage goes unused; with it off, that advantage applies and a
|
||
model measured at 0.33 on tool use handles requests where tools are on the
|
||
table. Which is right is an empirical question and the benchmark cannot
|
||
answer it — the 0.33 comes from 3 tasks.
|
||
|
||
What settles it is `POST /outcome`: run with the filter off, let real pass/fail
|
||
reports accumulate, and compare `deepseek-v4-flash`'s `tool_use_agentic`
|
||
proficiency before and after. That is the one signal here that knows whether
|
||
the work actually worked, and `feedback.py` folds client outcomes in both
|
||
directions, so success counts too.
|
||
|
||
0.5 sits in the empty band between the only two values the catalog holds
|
||
(0.33 and 1.00), so it is not fitted to either. A model with **no** measured
|
||
tool score is unproven rather than proven bad and is not dropped — the same
|
||
rule as the tier-1 context gate. Config load refuses a
|
||
`routing.tool_use_category` that is not a real category, because a name
|
||
matching nothing yields NULL for every row and NULL means "do not
|
||
disqualify": the filter would silently stop filtering.
|
||
|
||
## Tier is an iteration budget, not just a floor
|
||
|
||
A tier used to mean only "do not route below this". It now also buys
|
||
corrective attempts after a verification failure:
|
||
|
||
| tier | batch | interactive |
|
||
|---|---|---|
|
||
| 1 | 0 retries | 0 |
|
||
| 2 | 1 | 1 |
|
||
| 3 | 2 | 1 |
|
||
|
||
Interactive is capped below its tier because every retry doubles
|
||
time-to-answer, and in interactive use latency **is** a quality loss.
|
||
|
||
Retries are matched to the failure, since the causes differ:
|
||
|
||
- **truncated** — raise the token budget on the same model; a different one
|
||
would run out too. If there is no cap to raise, the model's own output
|
||
ceiling is the wall, so escalate to a candidate that can emit more.
|
||
- **malformed** — more tokens will not make unparseable output parse, so
|
||
escalate to the next-ranked candidate.
|
||
- **ok / unverifiable** — buy nothing. Retrying `unverifiable` would burn
|
||
quota across the majority of prose traffic for no signal.
|
||
|
||
`escalation.preemptive_on_low_confidence` is now **off by default**. Bumping
|
||
the tier because the classifier was unsure pays frontier prices before
|
||
anything has gone wrong; spending after a check has actually failed is better
|
||
on both mandates — the cheap attempt usually succeeds, and when it fails you
|
||
have evidence rather than a hunch.
|
||
|
||
## The only ground truth: `POST /outcome`
|
||
|
||
Everything else the router records is a proxy. Structural checks know whether
|
||
code *parses*. The local checker guesses whether prose *looks* right. Neither
|
||
knows whether the answer did the job — the client does, because it ran the
|
||
tests.
|
||
|
||
```bash
|
||
# id comes from the completion body, or any stream chunk
|
||
curl -s localhost:8080/outcome -H 'content-type: application/json' \
|
||
-d '{"request_id":"chatcmpl-...","ok":false,"detail":"tests failed"}'
|
||
```
|
||
|
||
Two things make this the highest-value signal available:
|
||
|
||
- **It is the only quality signal that survives streaming.** A retry cannot
|
||
reach a streamed response because the bytes are already gone; a report
|
||
arrives afterwards and works either way. Every agent client streams.
|
||
- **Its successes count.** `feedback.py` folds client outcomes in BOTH
|
||
directions, unlike checks, where only failures count. A parser's 'ok' means
|
||
the code parsed and would inflate scores; a client's 'succeeded' means the
|
||
work worked.
|
||
|
||
An unknown `request_id` returns 404 rather than being quietly accepted — a
|
||
client whose reports go nowhere should find out.
|
||
|
||
## Verification: what local compute is actually good for
|
||
|
||
Local inference is a poor substitute for cloud completions here — 7-82x the
|
||
energy, ~670x the carbon on Michigan's grid, and slower (6.2s vs 1.4-2.0s).
|
||
But it is very good at stopping a cloud completion from being wasted, and
|
||
completions are where all the money is: fitted on real traffic, a completion
|
||
token costs **201x** a prompt token.
|
||
|
||
| check | cost | what it catches |
|
||
|---|---|---|
|
||
| structural (`verification.py`) | **free** | truncation, malformed code/JSON/YAML, empty answers |
|
||
| local LLM (Ollama) | ~$6.9e-05, ~6s | refusals, wrong-question answers, incoherence |
|
||
|
||
Structural checks run on every response and never execute the code — they
|
||
parse it. The local LLM check runs only on answers above
|
||
`verification.min_completion_tokens`, in the background after the client has
|
||
its response, because it costs ~15% of a median 193-token answer and only
|
||
pays above ~600 tokens.
|
||
|
||
### An agent turn is not a prose answer
|
||
|
||
Both checkers had mirror halves of the same blind spot, and real traffic is
|
||
what found it. On the first genuine agent session (63 completions, a shipped
|
||
feature, 349 passing tests, clean mypy and ruff):
|
||
|
||
| checker | called it | actually |
|
||
|---|---|---|
|
||
| structural | `malformed: empty response` x29 | turns that ended in a tool call |
|
||
| local LLM | `cuts off mid-sentence` x8 of 9 | same turns, judged from the other side |
|
||
|
||
A turn that calls a tool has empty or half-finished text **by design**. Both
|
||
paths now take `has_tool_calls` and return `unverifiable`; in `verify_response`
|
||
that check outranks even `finish_reason == 'length'`, because stopping
|
||
mid-sentence at a call boundary is not a budget overrun. `worth_local_check`
|
||
declines outright, which also stops paying ~6s of local inference to
|
||
mis-grade a tool call.
|
||
|
||
Had `feedback.py` run before this, it would have applied ~12 false failures to
|
||
the models that had just shipped the feature. That is the **fourth** harness
|
||
bug in this project that would have scored the rig rather than the model, and
|
||
the first caught by real traffic instead of a synthetic test. The pre-fix rows
|
||
are kept but set `model_attributable = 0`, so the record survives without
|
||
steering routing.
|
||
|
||
**`client_capped` was also over-applied.** It marked *every* verdict
|
||
non-attributable whenever the client set `max_tokens` — and opencode always
|
||
does — so real failures were invisible to feedback. A client's token cap
|
||
explains a `truncated` verdict and nothing else; it is now scoped to exactly
|
||
that.
|
||
|
||
`feedback.py` folds observed failures into `proficiency`, so routing learns
|
||
from your traffic rather than only the 23-task benchmark. Only **failures**
|
||
are folded in: a structural 'ok' means the code parsed, not that it was
|
||
correct, and recording those as 1.0 would flatten every score toward the
|
||
ceiling. Failures the model did not cause — a client's own tight `max_tokens`
|
||
truncating the answer — are recorded but excluded.
|
||
|
||
## The classifier is the latency floor
|
||
|
||
Every routed request pays a full local classification round-trip before a
|
||
single upstream token is requested, so the classifier model is the single
|
||
biggest lever on interactive latency.
|
||
|
||
**The default is `mistral-nemo:12b`, and it replaced `qwen3.5:latest` on
|
||
measurement.** Same prompts, same system prompt, `temperature: 0`, cold load
|
||
excluded — 14 unambiguous category cases and 5 tier probes:
|
||
|
||
| | `qwen3.5` | `mistral-nemo:12b` |
|
||
|---|---|---|
|
||
| category correct | 10/14 | 9/14 |
|
||
| tier correct | 1/5 (2 of them crashed) | **3/5** |
|
||
| **hard failures** | **4 of 19 calls (21%)** | **0** |
|
||
| latency mean / median / max | 6.6 / 5.5 / **15.6s** | 1.7 / 1.8 / **1.9s** |
|
||
|
||
Category accuracy is a wash. What decides it is the failure column and the
|
||
tail. Every one of those 4 failures is the runaway-thinking-trace mode below:
|
||
~15s spent to produce no JSON, which degrades to `source: "fallback"` — tier
|
||
2, `general_chat`. A reasoning model is the wrong tool for a job whose entire
|
||
output is ~45 tokens of JSON.
|
||
|
||
`mistral-nemo` does not reason by default, which is why its tail is flat: its
|
||
slowest call (1.94s) is faster than qwen3.5's median. End-to-end `/route`
|
||
went ~10s -> **~1.7s**. Both scored 5/5 on the local verification task, so
|
||
the verifier moved with it and only one model stays resident.
|
||
|
||
Four settings keep this usable, each fixing a failure seen in practice:
|
||
|
||
- **`max_retries=0` on the classifier client.** The OpenAI SDK retries twice
|
||
by default, so `timeout_seconds` silently became a 3x wall-clock bound — a
|
||
request hung past 250s on a 120s setting and logged nothing.
|
||
- **`max_output_tokens: 1024`.** Bounds a REASONING model's chain of thought.
|
||
Unbounded, it cascades: Ollama keeps generating after the client gives up
|
||
*and* serializes per model, so one runaway request queues every later
|
||
request behind it and the timeouts spread. 256 was too tight — the trace
|
||
consumed the budget and the model was truncated before emitting any JSON,
|
||
which surfaced as an empty response. Does not bind for `mistral-nemo`; kept
|
||
because it costs nothing unused and is the only guard if a reasoning model
|
||
is swapped back in.
|
||
- **`max_input_chars: 8000`.** The classifier decides a category and a tier;
|
||
it does not need the document. Feeding it one is harmful, not merely
|
||
wasteful — on a ~20k-token prompt `qwen3.5` spent 28.7s and returned empty
|
||
while `mistral-nemo` spent 41.8s echoing the input back inside its JSON.
|
||
Both land on `source: "fallback"`, i.e. 30-40s of local inference buying the
|
||
answer an instant failure would have given. Clamped to head + tail (the
|
||
instruction sits at one end or the other; never the middle), the same
|
||
prompts classify correctly in ~2.2s. Nothing is lost:
|
||
`chat_completions` measures the real conversation with
|
||
`estimate_prompt_tokens` and takes the larger value.
|
||
- **`fallback_tier` / `fallback_category`.** A classifier that times out,
|
||
errors, or returns garbage now degrades to a configured mid tier flagged
|
||
`source: "fallback"` instead of returning 502/503. The caller is a coding
|
||
agent that would rather have a mid-tier answer than an error. Escalation
|
||
deliberately skips fallbacks, so an unavailable local model does not
|
||
silently promote every request to the frontier tier.
|
||
- **`temperature: 0`.** At the default, the same prompt classified tier 2
|
||
then tier 1 on consecutive calls and routed to two different models.
|
||
|
||
But ~10s of local overhead on every message is a real tax for an interactive
|
||
agent, where the upstream answer itself may take 2s. Still unaddressed:
|
||
classify once per session rather than per message, cache by prompt hash, or
|
||
skip classification for short prompts.
|
||
|
||
### "Local" means your hardware, not this machine
|
||
|
||
The premise is that a local LLM classifies the task before a cloud model
|
||
answers it. That does not require the GPU to be in the machine you are typing
|
||
on, and usually it isn't — most developers already have WireGuard or a VPN
|
||
back to a home lab. So the normal shape is router and editor on the laptop,
|
||
Ollama on the workstation, `classifier.base_url` pointing across the tunnel.
|
||
|
||
`classifier.base_url` / `api_key_env` / `model` take any OpenAI-compatible
|
||
endpoint. Verified end to end against a non-loopback address, classifier and
|
||
verifier both.
|
||
|
||
Ollama binds `127.0.0.1` by default, so this fails with connection-refused
|
||
until the serving host applies `deploy/ollama-over-vpn.conf`. Bind it to the
|
||
VPN address, not `0.0.0.0`: Ollama has no auth of any kind, so anything that
|
||
can reach the port can run inference and enumerate your models, and `0.0.0.0`
|
||
publishes it on whatever wifi the laptop is sitting on.
|
||
|
||
A cloud endpoint also satisfies that interface, and measured better than the
|
||
local one — worth knowing before assuming local is the cheap side. Five
|
||
prompts, same system prompt, `temperature: 0`:
|
||
|
||
| | local `qwen3.5` (RTX 6000) | NeuralWatt `deepseek-v4-flash` |
|
||
|---|---|---|
|
||
| mean latency | **11.58s** (4.95-15.76) | **1.02s** |
|
||
| categories agreed with the label | 2 of 4 | **5 of 5** |
|
||
| hard failures | 1 of 5 (empty after 15.76s) | 0 |
|
||
| energy per call | ~7e-05 kWh, on your meter | 1.17e-05 kWh attributed |
|
||
| cost per 1,000 calls | electricity + 6.6GB resident | **$0.093** (0.19% of quota) |
|
||
|
||
11x faster, more accurate, and less attributed energy, on a machine with a
|
||
24GB card sitting idle. The one hard failure is the documented
|
||
runaway-thinking-trace mode — `qwen3.5` spent 15.76s and emitted no JSON,
|
||
degrading to `source: "fallback"`, i.e. a silent tier-2 guess.
|
||
`deepseek-v4-flash` does not reason by default, so that mode does not exist
|
||
for it. End-to-end `/route` goes ~10s -> **~1.0s**.
|
||
|
||
Local classification was assumed to be the cheap option because local compute
|
||
felt free. It is not free, it is just unbilled — the same substitution the
|
||
cost axis had to unlearn. The shipped default is still local Ollama, because
|
||
switching spends quota and that is a deployment choice, not a code one.
|
||
|
||
### The verifier follows, but only to another Ollama
|
||
|
||
The local LLM check speaks Ollama's **native** `/api/chat` (the only way to
|
||
set `think: False`), so it follows the classifier across a VPN but not to a
|
||
cloud provider. It used to derive its URL by stripping `/v1` off
|
||
`classifier.base_url`, which meant moving the classifier at all would have
|
||
pointed it at `<that host>/api/chat`. It now has its own
|
||
`verification.base_url` and `verification.model`.
|
||
|
||
`verification.model` may be null only while both run on one host. Config load
|
||
**refuses** the null once the hostnames differ, because the failure is silent:
|
||
observed directly, with the classifier on NeuralWatt the verifier POSTed
|
||
`deepseek-v4-flash` to `localhost:11434`, 404'd, caught it, logged "local
|
||
verification unavailable" and recorded no sample. Verification would have
|
||
looked enabled while producing nothing.
|
||
|
||
Structural verification needs no model at all — it is pure Python — so
|
||
`verification.local_llm_enabled: false` leaves a host with no local inference
|
||
fully functional, minus the refusal/incoherence class of failure.
|
||
|
||
Note also that opencode sends **~32K prompt tokens** of system prompt and
|
||
tool definitions on a trivial request, so the measured-size floor in
|
||
`estimate_prompt_tokens` does real work — the classifier's own estimate for
|
||
that request was two orders of magnitude low.
|
||
|
||
## What's NOT built yet — pick up here
|
||
|
||
1. **Leaderboard priors are unfilled.** `leaderboards.yaml` ships empty on
|
||
purpose — inventing plausible-looking benchmark numbers would put
|
||
fabricated data straight into routing, the same failure as the provider's
|
||
`static_fallback` carbon constant this project already excludes. Until real
|
||
sourced figures go in, a newly listed NeuralWatt family has no prior and
|
||
relies entirely on self-eval accumulating. `python leaderboard.py --check`
|
||
lists what is missing.
|
||
|
||
2. **Sampling depth for three models — now `eco`-only.** 7 samples/model gives
|
||
split-half agreement within 1.4x for 10 of 13, but `kimi-k2.7-code-fast`
|
||
(29x), `kimi-k3` (14x) and `glm-5.2-flex` (2.2x) are still unsettled. This
|
||
no longer touches cost, which is priced per-request from the catalog, so it
|
||
only affects `eco` — which is not an objective. Low priority unless eco
|
||
comes back.
|
||
|
||
3. **Retry does not reach streaming.** The iteration budget (`iteration.py`)
|
||
retries after a failed check, but only on the non-streaming path — once
|
||
bytes have gone to the client there is nothing to take back. Buffering to
|
||
fix that would cost streaming itself, a worse trade for interactive work.
|
||
`POST /outcome` is the answer for streamed traffic: it arrives afterwards,
|
||
so it works identically either way.
|
||
|
||
4. **Session-directory attribution picks the wrong directory.** The opencode
|
||
plugin now genuinely reports — 31 accepted, 3 refused as ambiguous, 22
|
||
`succeeded` / 4 `failed` from real test runs, all folded in. But the
|
||
"most frequent path" heuristic resolved one session to a **dependency's
|
||
source directory** inside the project's own virtualenv (24 mentions)
|
||
rather than the project being edited (22), because reading a library's
|
||
source outweighed writing the code under test. It degrades safely — an
|
||
ambiguous session is refused rather than misattributed, which is what the
|
||
3 × 409 were — but the heuristic needs to weight *writes* over reads, or
|
||
anchor on the client's cwd instead of a path histogram.
|
||
|
||
5. **Local energy is not on the ledger.** The router meters what NeuralWatt
|
||
bills and reports, but the electricity its own classifier and verifier burn
|
||
on local hardware is invisible to it. The whole premise is spending cheap
|
||
local compute to avoid wasting expensive cloud compute, and right now only
|
||
one side of that trade is measured — which is how "local is free" survived
|
||
as an assumption long enough to be wrong (a hosted classifier measured
|
||
faster, more accurate and lower attributed energy than the local one).
|
||
Closing it means metering local draw (nvidia-smi / RAPL / a smart plug) and
|
||
pricing it against a real tariff — utility rate data, ideally the user's own
|
||
plan, including time-of-use bands. That would also make the local-vs-cloud
|
||
comparison an actual number rather than a shrug.
|
||
|
||
## Known open questions
|
||
|
||
- Answered: cost and eco stay separate axes — grid intensity spans 13.6x
|
||
across the catalog, so they rank models differently.
|
||
- Answered: the GLM rows reporting `grid_id: FI` at 475 gCO2/kWh were
|
||
`carbon_source: static_fallback` — a substituted constant, not a
|
||
measurement. They are now excluded from eco rather than trusted. Still
|
||
worth asking NeuralWatt why the fallback keeps the original `grid_id`,
|
||
since that is what made it look like a real regional difference.
|
||
- Three models still fail a split-half stability check at 7 samples. Is the
|
||
instability real (variable serving conditions) or an artifact of when the
|
||
sweep ran? Re-sweeping at a different hour would tell.
|
||
- Answered, and the question no longer parses: tier-1 composites used to sit
|
||
within 0.009 of each other because min-max normalization compressed them.
|
||
There is no composite any more — ranking is quality first, cost as the
|
||
tiebreak inside `quality_tolerance` — so nothing normalizes and nothing
|
||
compresses.
|
||
- Answered: the eval set exists (`evals/tasks.yaml`, 23 tasks, four scoring
|
||
kinds) and `tests/test_task_set.py` keeps it honest. The open part is
|
||
narrower now — `coding_refactor` and `debugging` are still flat at 1.00
|
||
across every model, so those tasks discriminate nothing and either need
|
||
hardening again or should be conceded as non-discriminating. **Try samples
|
||
before hardening.** `docs_writing` looked flat at the top too, and six more
|
||
passes spread it 0.66-0.97 without touching a task; two samples per model is
|
||
not enough to tell a saturated task from an unsampled one.
|
||
- How much context-assembly (RAG-style retrieval) belongs in the classifier
|
||
step vs. a separate pre-step? Leaning decoupled, undecided.
|
||
- Should `eco_score` use real-time grid carbon intensity per request or a
|
||
stable per-model average? Currently the latter, from the reference sweep.
|
||
`grid_carbon_intensity` and `grid_id` are logged per observation, so this
|
||
stays answerable from data without a re-run.
|
||
|
||
## Config is strict: an unknown key is an error
|
||
|
||
Pydantic ignores extra keys by default, which means a typo or a misplaced
|
||
setting loads cleanly, does nothing, and still looks configured. Every config
|
||
model now inherits `StrictModel` (`extra="forbid"`), so both of these fail at
|
||
load rather than silently:
|
||
|
||
```
|
||
verification.max_input_chars # right key, wrong section
|
||
routing.min_tool_proficency # sic
|
||
```
|
||
|
||
This is not hypothetical. `max_input_chars` shipped into the `verification:`
|
||
block instead of `classifier:` and was accepted and discarded — it happened to
|
||
match the code default, so behaviour was correct and the file was a lie.
|
||
Editing it would have done nothing.
|
||
|
||
The corollary worth keeping: **every knob belongs in `config.yaml`, not only
|
||
in a Pydantic default.** A default the file never mentions is invisible to
|
||
anyone tuning it. `classifier.outcome_attribution_window_seconds` was removed
|
||
in the same pass — it was declared, never read, and shadowed the
|
||
`verification` one that actually is.
|
||
|
||
## Setup
|
||
|
||
```bash
|
||
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 set what is deployment-specific in `config.yaml`: `classifier.model` and
|
||
`classifier.base_url` for your Ollama, and `objective.plan_kwh_per_period` to
|
||
your own plan's quota (it is reported in `/health` as burn against the
|
||
allowance; it does not gate anything).
|
||
|
||
Ollama must be reachable with the classifier model pulled — the name must
|
||
match `classifier.model` in `config.yaml`:
|
||
```bash
|
||
ollama pull mistral-nemo:12b
|
||
```
|
||
|
||
`local_vision` ships **enabled** as a core feature (see below), so also pull
|
||
its model unless you're turning it off:
|
||
```bash
|
||
ollama pull qwen3-vl:4b
|
||
```
|
||
|
||
It does not have to be on this machine. To use one across a VPN, point
|
||
`classifier.base_url` and `verification.base_url` at it and apply
|
||
`deploy/ollama-over-vpn.conf` on the serving host — Ollama binds loopback-only
|
||
by default and will otherwise refuse.
|
||
|
||
`requirements.txt` is pinned. Recreating the venv with the old `>=` ranges
|
||
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.
|
||
Bump deliberately.
|
||
|
||
`pyproject.toml` puts the repo root on `sys.path` for pytest — the modules
|
||
live at the root rather than in a package, so `pytest` (console script) and
|
||
`python -m pytest` would otherwise disagree about whether `import config`
|
||
resolves.
|
||
|
||
## Run as a service
|
||
|
||
`deploy/` holds five systemd **user** units (dispatcher, plus a timer and a
|
||
oneshot service each for the poller and the seed sweep) and one drop-in for a
|
||
*system* Ollama — see `deploy/README.md` for install and operation. In short:
|
||
|
||
```bash
|
||
echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
||
cp deploy/llm-router*.{service,timer} ~/.config/systemd/user/
|
||
systemctl --user daemon-reload
|
||
systemctl --user enable --now llm-router.service llm-router-poller.timer
|
||
```
|
||
|
||
The dispatcher binds `127.0.0.1:8080`. **The poller timer is load-bearing,
|
||
not housekeeping**: `stale_after_days` is 3 with `exclude_stale: true`, so an
|
||
unpolled catalog eventually marks every row stale and the router returns zero
|
||
candidates for everything.
|
||
|
||
The service holds a billable API key and has **no auth of its own**. Loopback
|
||
bind is the only thing standing between the open internet and your allowance;
|
||
add auth before widening `--host`.
|
||
|
||
The same applies to an Ollama shared over a VPN — it has no auth either, so
|
||
`deploy/ollama-over-vpn.conf` binds it to the VPN address rather than
|
||
`0.0.0.0`, which would publish it on whatever network the client happens to
|
||
be on.
|
||
|
||
## Pointing a coding agent at it
|
||
|
||
The `/v1` endpoints are OpenAI-compatible, so any normal client works —
|
||
opencode, an SDK, plain curl. Repo-local `opencode.json` is already wired up,
|
||
so running `opencode` from a clone of this repo routes by default. For global
|
||
use, merge `provider.llm-router` into `~/.config/opencode/opencode.json`.
|
||
|
||
| model name | behavior |
|
||
|---|---|
|
||
| `auto` | router picks; flex rows excluded so nothing is held during peak |
|
||
| `auto:batch` | router picks; flex rows admitted, for overnight/async work |
|
||
| any real model id | dispatched as asked, still logged |
|
||
|
||
Streaming is proxied chunk by chunk rather than buffered, so tokens still
|
||
render as they arrive. NeuralWatt emits its energy and cost blocks as SSE
|
||
**comment** lines (`: energy {...}`) before `data: [DONE]` — ordinary clients
|
||
ignore comments, so the stream passes through untouched while the router
|
||
reads the telemetry on the way past. Without that, streamed calls would log
|
||
no energy at all, which is most of the point of this project.
|
||
|
||
## Try it
|
||
|
||
```bash
|
||
# 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."}'
|
||
|
||
# Same, but admit flex rows (held during peak, fine for overnight work)
|
||
curl -s -X POST localhost:8080/route -H 'content-type: application/json' \
|
||
-d '{"task":"nightly code review","latency_tolerance":"batch"}'
|
||
|
||
# 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?"}'
|
||
|
||
# The OpenAI-compatible surface any client can use
|
||
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"}]}'
|
||
|
||
# What did routing actually cost and burn?
|
||
sqlite3 -header -column router.db \
|
||
"SELECT model_id, prompt_tokens, completion_tokens, energy_kwh, carbon_g_co2eq, cost_usd
|
||
FROM energy_observations ORDER BY id DESC LIMIT 10;"
|
||
```
|
||
|
||
`/route` takes `task_category`, `task_tier`, and `required_context_tokens`
|
||
overrides, which skip the classifier — useful for testing routing changes
|
||
deterministically.
|