neuralwatt-router-service #2
587
CLAUDE.md
587
CLAUDE.md
@@ -7,12 +7,17 @@ next steps, and is the one to trust on what is currently true.
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A router that uses a local model (served via Ollama on an RTX 6000, 24GB) to
|
A router that uses a local model (served via Ollama) to classify incoming
|
||||||
classify incoming coding/documentation tasks — category, tier, required
|
coding/documentation tasks — category, tier, required context size — and
|
||||||
context size — and dispatch each task to the cheapest/best-fit open-weight
|
dispatch each task to the cheapest/best-fit open-weight model on **Neuralwatt
|
||||||
model on **Neuralwatt Cloud**, weighted by cost, ecological impact
|
Cloud**, weighted by cost, per-category proficiency, and a per-request energy
|
||||||
(Neuralwatt exposes real energy-per-request data), and per-category
|
ceiling.
|
||||||
proficiency.
|
|
||||||
|
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`
|
Neuralwatt is the only provider. OpenRouter was removed — the `provider`
|
||||||
column and the `(model_id, provider)` primary key stay so a second provider
|
column and the `(model_id, provider)` primary key stay so a second provider
|
||||||
@@ -24,8 +29,9 @@ can be added later without a migration.
|
|||||||
CPU-bound; iteration speed on the scoring/weighting logic matters more than
|
CPU-bound; iteration speed on the scoring/weighting logic matters more than
|
||||||
raw execution speed at this scale)
|
raw execution speed at this scale)
|
||||||
- SQLite for the decision table
|
- SQLite for the decision table
|
||||||
- Ollama (OpenAI-compatible endpoint at `localhost:11434/v1`) for local
|
- Ollama for local classification, via any OpenAI-compatible endpoint —
|
||||||
classification
|
`localhost:11434/v1`, or an Ollama on another machine across a VPN
|
||||||
|
(`classifier.base_url`)
|
||||||
- FastAPI for the dispatcher service
|
- FastAPI for the dispatcher service
|
||||||
|
|
||||||
## Billing is per-kWh, not per-token — and neither is what scoring uses
|
## Billing is per-kWh, not per-token — and neither is what scoring uses
|
||||||
@@ -85,8 +91,9 @@ is load-bearing, and `tests/test_routing.py` pins it.
|
|||||||
for real traffic, because the reference workload is the wrong shape.
|
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
|
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 84% cache
|
traffic is a 150,000-token prompt with a ~400-token completion and ~92% cache
|
||||||
hits. The attribution ratio moves with prompt size, so the ranking inverts:
|
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 |
|
| workload | winner |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -176,8 +183,16 @@ 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
|
median-across-time for free — hence `llm-router-seed.timer`, which runs a
|
||||||
small sweep every 6 hours.
|
small sweep every 6 hours.
|
||||||
|
|
||||||
Until several sweeps have accumulated, treat the cost and eco ordering as
|
Until several sweeps have accumulated, treat the eco ordering as provisional.
|
||||||
provisional. A single sweep's ranking is one sample of a moving quantity.
|
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
|
## What's built and working
|
||||||
|
|
||||||
@@ -215,7 +230,77 @@ provisional. A single sweep's ranking is one sample of a moving quantity.
|
|||||||
`code` executes the model's Python against checks, `exact` compares a
|
`code` executes the model's Python against checks, `exact` compares a
|
||||||
normalized answer, `tool` inspects the tool call structurally, and only the
|
normalized answer, `tool` inspects the tool call structurally, and only the
|
||||||
four prose categories fall back to a `judge`.
|
four prose categories fall back to a `judge`.
|
||||||
- `tests/` — 207 tests across 11 files, all passing.
|
- `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`.
|
||||||
|
- `tests/` — 356 tests across 19 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.
|
||||||
|
|
||||||
|
### 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. It ships **disabled** (`enabled: false`).
|
||||||
|
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
|
### Serving class: one base model, many rows
|
||||||
|
|
||||||
@@ -279,47 +364,145 @@ 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
|
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.
|
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: category now changes routing
|
||||||
|
|
||||||
`proficiency_score` is the ONLY category-dependent term in the composite, so
|
`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
|
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
|
all — the classifier computed it, the router paid ~10s for it, and then it
|
||||||
made no difference. It does now:
|
made no difference. It does now: sweeping 9 categories x 3 tiers currently
|
||||||
|
returns **5 distinct winners** at both 50k and 120k of context.
|
||||||
|
|
||||||
| scoring | distinct models across 27 decisions (9 categories x 3 tiers) |
|
| context | winners over 27 decisions |
|
||||||
|---|---|
|
|---|---|
|
||||||
| catalog list price | 2 |
|
| 50k | `qwen3.6-35b` (10), `gemma-4-31b` (7), `deepseek-v4-flash` (5), `kimi-k3` (3), `kimi-k3-fast` (2) |
|
||||||
| measured cost + eco | 3 |
|
| 120k | `kimi-k2.7-code` (10), `gemma-4-31b` (7), `deepseek-v4-flash` (5), `kimi-k3` (3), `kimi-k3-fast` (2) |
|
||||||
| proficiency only (cost/eco data missing) | 7 |
|
|
||||||
| **all three axes live** | **1** |
|
|
||||||
|
|
||||||
That last row is not a regression, and it is worth understanding before
|
**This spread is recent, and how it got here is the useful part.** For a long
|
||||||
tuning anything. With cost, eco and proficiency all populated,
|
time all 27 decisions returned ONE model, and that was the correct answer at
|
||||||
`qwen3.6-35b` is **Pareto-dominant**: cheapest AND cleanest in the routable
|
the time rather than a bug: with cost and eco both populated, `qwen3.6-35b`
|
||||||
set, while scoring within 0.15 of the best model on proficiency. On
|
was Pareto-dominant — cheapest AND cleanest in the routable set, while
|
||||||
`summarization` it costs 7x less and emits 65x less carbon than `kimi-k3`,
|
scoring within `quality_tolerance` of the best. No defensible weighting picks
|
||||||
which beats it 1.00 to 0.85 on quality — no defensible weighting picks
|
anything else out of that.
|
||||||
`kimi-k3` from that. One model winning is the correct answer to the question
|
|
||||||
the weights ask.
|
|
||||||
|
|
||||||
Proficiency is still doing work underneath. The runner-up ordering reorders
|
Two corrections widened it, and neither was a tuning change:
|
||||||
by category — `kimi-k3-fast` climbs to 3rd on `docs_writing` and
|
|
||||||
`summarization` (proficiency 1.00) and drops out of the top four elsewhere.
|
|
||||||
So the axis is live; it just cannot overturn a leader that wins on two axes
|
|
||||||
at once.
|
|
||||||
|
|
||||||
The 7-model spread appeared only while cost and eco were missing and scoring
|
- **Cost stopped being a benchmark average.** It is now priced per request
|
||||||
neutral 0.5. If a genuinely different balance is wanted, the lever is
|
from catalog prices scaled to the request's shape, so the ranking depends
|
||||||
`weights` in config.yaml, not more data.
|
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
|
### What the task set actually found
|
||||||
|
|
||||||
**Coding does not discriminate these models.** All 13 rows score exactly
|
**The benchmark could not discriminate these models on coding.** Every row
|
||||||
1.00 on `coding_general`, `coding_refactor` and `debugging` — and that is
|
scored exactly 1.00 on `coding_general`, `coding_refactor` and `debugging` —
|
||||||
after the tasks were deliberately hardened with touching intervals, full
|
and that is after the tasks were deliberately hardened with touching
|
||||||
semver, present-but-falsy defaults, late-binding closures and a binary search
|
intervals, full semver, present-but-falsy defaults, late-binding closures and
|
||||||
that infinite-loops. Every model in this catalog is simply good at that class
|
a binary search that infinite-loops. Every model in this catalog is simply
|
||||||
of problem, so cost and eco decide coding routes, which is the right outcome.
|
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.**
|
**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
|
`deepseek-v4-flash` scores 1.00 on all three coding categories yet **0.33 on
|
||||||
@@ -330,10 +513,42 @@ 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
|
an agent loop. The router now avoids it for those categories while still
|
||||||
picking it for coding.
|
picking it for coding.
|
||||||
|
|
||||||
Everything currently reads `source='self_eval_thin'`: real measurement, but
|
Most rows still read `source='self_eval_thin'` (118 of 132): real
|
||||||
below `self_eval_min_samples` (2-3 tasks per category per run). Re-run
|
measurement, but below `self_eval_min_samples` at 2-3 tasks per category per
|
||||||
`eval_proficiency.py` to accumulate — scores fold into a running mean rather
|
run. The 14 that have crossed it are all `docs_writing`, from the six extra
|
||||||
than replacing, so samples add up across runs.
|
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
|
### Harness bugs this shook out
|
||||||
|
|
||||||
@@ -361,6 +576,66 @@ fails. It immediately caught a check where the expected value was simply
|
|||||||
wrong — which would have docked every model on a task and been
|
wrong — which would have docked every model on a task and been
|
||||||
indistinguishable from genuine difficulty.
|
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
|
## Tier is an iteration budget, not just a floor
|
||||||
|
|
||||||
A tier used to mean only "do not route below this". It now also buys
|
A tier used to mean only "do not route below this". It now also buys
|
||||||
@@ -477,25 +752,54 @@ truncating the answer — are recorded but excluded.
|
|||||||
## The classifier is the latency floor
|
## The classifier is the latency floor
|
||||||
|
|
||||||
Every routed request pays a full local classification round-trip before a
|
Every routed request pays a full local classification round-trip before a
|
||||||
single upstream token is requested. Measured on `qwen3.5:latest`:
|
single upstream token is requested, so the classifier model is the single
|
||||||
|
biggest lever on interactive latency.
|
||||||
|
|
||||||
| | latency |
|
**The default is `mistral-nemo:12b`, and it replaced `qwen3.5:latest` on
|
||||||
|---|---|
|
measurement.** Same prompts, same system prompt, `temperature: 0`, cold load
|
||||||
| cold (model not resident) | **43s+** — a reload can exceed even the 120s ceiling |
|
excluded — 14 unambiguous category cases and 5 tier probes:
|
||||||
| warm, simple prompt | **~4s** |
|
|
||||||
| warm, prompt that triggers a long thinking trace | **up to ~25s** |
|
| | `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:
|
Four settings keep this usable, each fixing a failure seen in practice:
|
||||||
|
|
||||||
- **`max_retries=0` on the classifier client.** The OpenAI SDK retries twice
|
- **`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
|
by default, so `timeout_seconds` silently became a 3x wall-clock bound — a
|
||||||
request hung past 250s on a 120s setting and logged nothing.
|
request hung past 250s on a 120s setting and logged nothing.
|
||||||
- **`max_output_tokens: 1024`.** qwen3.5 is a reasoning model and will
|
- **`max_output_tokens: 1024`.** Bounds a REASONING model's chain of thought.
|
||||||
otherwise emit an unbounded chain of thought. That cascades: Ollama keeps
|
Unbounded, it cascades: Ollama keeps generating after the client gives up
|
||||||
generating after the client gives up *and* serializes per model, so one
|
*and* serializes per model, so one runaway request queues every later
|
||||||
runaway request queues every later request behind it and the timeouts
|
request behind it and the timeouts spread. 256 was too tight — the trace
|
||||||
spread. 256 was too tight — the trace consumed the budget and the model was
|
consumed the budget and the model was truncated before emitting any JSON,
|
||||||
truncated before emitting any JSON, which surfaced as an empty response.
|
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,
|
- **`fallback_tier` / `fallback_category`.** A classifier that times out,
|
||||||
errors, or returns garbage now degrades to a configured mid tier flagged
|
errors, or returns garbage now degrades to a configured mid tier flagged
|
||||||
`source: "fallback"` instead of returning 502/503. The caller is a coding
|
`source: "fallback"` instead of returning 502/503. The caller is a coding
|
||||||
@@ -506,10 +810,71 @@ Four settings keep this usable, each fixing a failure seen in practice:
|
|||||||
then tier 1 on consecutive calls and routed to two different models.
|
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
|
But ~10s of local overhead on every message is a real tax for an interactive
|
||||||
agent, where the upstream answer itself may take 2s. Unaddressed options:
|
agent, where the upstream answer itself may take 2s. Still unaddressed:
|
||||||
keep Ollama resident (`OLLAMA_KEEP_ALIVE`), classify once per session rather
|
classify once per session rather than per message, cache by prompt hash, or
|
||||||
than per message, cache by prompt hash, use a smaller classifier, or skip
|
skip classification for short prompts.
|
||||||
classification for short prompts. This is a design call, not a tuning one.
|
|
||||||
|
### "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
|
Note also that opencode sends **~32K prompt tokens** of system prompt and
|
||||||
tool definitions on a trivial request, so the measured-size floor in
|
tool definitions on a trivial request, so the measured-size floor in
|
||||||
@@ -543,13 +908,25 @@ that request was two orders of magnitude low.
|
|||||||
4. **Session-directory attribution picks the wrong directory.** The opencode
|
4. **Session-directory attribution picks the wrong directory.** The opencode
|
||||||
plugin now genuinely reports — 31 accepted, 3 refused as ambiguous, 22
|
plugin now genuinely reports — 31 accepted, 3 refused as ambiguous, 22
|
||||||
`succeeded` / 4 `failed` from real test runs, all folded in. But the
|
`succeeded` / 4 `failed` from real test runs, all folded in. But the
|
||||||
"most frequent path" heuristic resolved the session to
|
"most frequent path" heuristic resolved one session to a **dependency's
|
||||||
`.venv/lib/python3.14/site-packages/c2pa` 24 times versus
|
source directory** inside the project's own virtualenv (24 mentions)
|
||||||
`~/Sources/fieldwitness` 22, because reading a dependency's source
|
rather than the project being edited (22), because reading a library's
|
||||||
outweighed editing the project. It degrades safely — an ambiguous session
|
source outweighed writing the code under test. It degrades safely — an
|
||||||
is refused rather than misattributed, which is why the 3 × 409 — but the
|
ambiguous session is refused rather than misattributed, which is what the
|
||||||
heuristic needs to weight *writes* over reads, or anchor on the client's
|
3 × 409 were — but the heuristic needs to weight *writes* over reads, or
|
||||||
cwd instead of a path histogram.
|
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
|
## Known open questions
|
||||||
|
|
||||||
@@ -563,19 +940,49 @@ that request was two orders of magnitude low.
|
|||||||
- Three models still fail a split-half stability check at 7 samples. Is the
|
- 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
|
instability real (variable serving conditions) or an artifact of when the
|
||||||
sweep ran? Re-sweeping at a different hour would tell.
|
sweep ran? Re-sweeping at a different hour would tell.
|
||||||
- The tier-1 composites sit within 0.009 of each other across a 40x cost
|
- Answered, and the question no longer parses: tier-1 composites used to sit
|
||||||
range, because min-max compresses once one candidate is far cheaper than
|
within 0.009 of each other because min-max normalization compressed them.
|
||||||
the rest. Ranking is right, but margins are thin — proficiency data will
|
There is no composite any more — ranking is quality first, cost as the
|
||||||
swing these easily, which is the intent.
|
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
|
- How much context-assembly (RAG-style retrieval) belongs in the classifier
|
||||||
step vs. a separate pre-step? Leaning decoupled, undecided.
|
step vs. a separate pre-step? Leaning decoupled, undecided.
|
||||||
- Self-run eval set: minimum viable task set to start proficiency scoring
|
|
||||||
without it becoming its own maintenance burden?
|
|
||||||
- Should `eco_score` use real-time grid carbon intensity per request or a
|
- Should `eco_score` use real-time grid carbon intensity per request or a
|
||||||
stable per-model average? Currently the latter, from the reference sweep.
|
stable per-model average? Currently the latter, from the reference sweep.
|
||||||
`grid_carbon_intensity` and `grid_id` are logged per observation, so this
|
`grid_carbon_intensity` and `grid_id` are logged per observation, so this
|
||||||
stays answerable from data without a re-run.
|
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
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -589,12 +996,22 @@ python config.py # sanity-check config loads
|
|||||||
python -m uvicorn dispatcher:app --reload
|
python -m uvicorn dispatcher:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
Ollama must be running locally with the classifier model pulled — the name
|
Then set what is deployment-specific in `config.yaml`: `classifier.model` and
|
||||||
must match `classifier.model` in `config.yaml`:
|
`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
|
```bash
|
||||||
ollama pull qwen3.5:latest
|
ollama pull mistral-nemo:12b
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
`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
|
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.
|
restarts on boot shouldn't change its dependency tree underneath itself.
|
||||||
@@ -607,8 +1024,9 @@ resolves.
|
|||||||
|
|
||||||
## Run as a service
|
## Run as a service
|
||||||
|
|
||||||
`deploy/` holds three systemd **user** units — see `deploy/README.md` for
|
`deploy/` holds five systemd **user** units (dispatcher, plus a timer and a
|
||||||
install and operation. In short:
|
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
|
```bash
|
||||||
echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
||||||
@@ -626,12 +1044,17 @@ 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;
|
bind is the only thing standing between the open internet and your allowance;
|
||||||
add auth before widening `--host`.
|
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
|
## Pointing a coding agent at it
|
||||||
|
|
||||||
The `/v1` endpoints are OpenAI-compatible, so any normal client works —
|
The `/v1` endpoints are OpenAI-compatible, so any normal client works —
|
||||||
opencode, an SDK, plain curl. Repo-local `opencode.json` is already wired up,
|
opencode, an SDK, plain curl. Repo-local `opencode.json` is already wired up,
|
||||||
so `cd ~/Sources/6krrt && opencode` routes by default. For global use, merge
|
so running `opencode` from a clone of this repo routes by default. For global
|
||||||
`provider.llm-router` into `~/.config/opencode/opencode.json`.
|
use, merge `provider.llm-router` into `~/.config/opencode/opencode.json`.
|
||||||
|
|
||||||
| model name | behavior |
|
| model name | behavior |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
377
README.md
377
README.md
@@ -1,11 +1,21 @@
|
|||||||
# Local LLM Model Router
|
# Local LLM Model Router
|
||||||
|
|
||||||
A router that uses a local model (served via Ollama on an RTX 6000, 24GB) to
|
A router that uses a local model (served via Ollama) to classify incoming
|
||||||
classify incoming coding/documentation tasks — category, tier, required
|
coding/documentation tasks — category, tier, required context size — and
|
||||||
context size — and dispatch each task to the cheapest/best-fit open-weight
|
dispatch each task to the cheapest/best-fit open-weight model on **Neuralwatt
|
||||||
model on **Neuralwatt Cloud**, weighted by cost, ecological impact
|
Cloud**, weighted by cost, per-category proficiency, and a per-request energy
|
||||||
(Neuralwatt exposes real energy-per-request data), and per-category
|
ceiling.
|
||||||
proficiency.
|
|
||||||
|
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](#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
|
Neuralwatt is the only provider. The `provider` column and the
|
||||||
`(model_id, provider)` primary key stay so a second provider can be added
|
`(model_id, provider)` primary key stay so a second provider can be added
|
||||||
@@ -16,10 +26,10 @@ later without a migration.
|
|||||||
| Dimension | Detail |
|
| Dimension | Detail |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Cost model** | Per-kWh, not per-token. Flat $8.00/kWh measured across the catalog. |
|
| **Cost model** | Per-kWh, not per-token. Flat $8.00/kWh measured across the catalog. |
|
||||||
| **Latency** | ~4 s warm classification, up to ~120 s on cold start. Dispatcher response is unaffected — verification runs async. |
|
| **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. |
|
| **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`. |
|
| **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. Retry count is zero to prevent silent 3× time-outs. |
|
| **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. |
|
| **API surface** | OpenAI-compatible `/v1` endpoints, streaming chunk proxy with SSE telemetry scraping. |
|
||||||
|
|
||||||
## Verification Pipeline
|
## Verification Pipeline
|
||||||
@@ -109,7 +119,7 @@ Key behaviors:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
incoming task ───▶│ Local Classifier │ Ollama (qwen3.5:latest)
|
incoming task ───▶│ Local Classifier │ Ollama (mistral-nemo:12b)
|
||||||
│ - task_category │ ~4s warm, ~120s cold cap
|
│ - task_category │ ~4s warm, ~120s cold cap
|
||||||
│ - task_tier │ temperature: 0, max 1024 tokens
|
│ - task_tier │ temperature: 0, max 1024 tokens
|
||||||
│ - required_context │ max_retries: 0 (silent 3× cap guard)
|
│ - required_context │ max_retries: 0 (silent 3× cap guard)
|
||||||
@@ -163,19 +173,19 @@ Key behaviors:
|
|||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Language** | Python 3 (IO-bound provider APIs; iteration speed matters more than raw speed) |
|
| **Language** | Python 3.10+ (IO-bound provider APIs; iteration speed matters more than raw speed) |
|
||||||
| **Framework** | FastAPI + uvicorn |
|
| **Framework** | FastAPI + uvicorn |
|
||||||
| **Database** | SQLite (`router.db`) — decision table, energy observations, proficiency, verifications |
|
| **Database** | SQLite (`router.db`) — decision table, energy observations, proficiency, verifications |
|
||||||
| **Local Classification** | Ollama (OpenAI-compatible at `localhost:11434/v1`) |
|
| **Local Classification** | Ollama, OpenAI-compatible — `localhost:11434/v1` or an Ollama across your VPN |
|
||||||
| **Local Model** | `qwen3.5:latest` |
|
| **Local Model** | `classifier.model` — `mistral-nemo:12b` by default; any Ollama model works |
|
||||||
| **Cloud Provider** | Neuralwatt only |
|
| **Cloud Provider** | Neuralwatt only |
|
||||||
| **Config** | `config.yaml` loaded & validated by Pydantic (`config.py`) |
|
| **Config** | `config.yaml` loaded & validated by Pydantic (`config.py`) |
|
||||||
| **OpenAI Client** | `openai==3.0.0` (official SDK) |
|
| **OpenAI Client** | `openai==3.0.0` (official SDK) |
|
||||||
| **HTTP** | `requests` for poller, `httpx` (via openai/uvicorn) |
|
| **HTTP** | `requests` for poller, `httpx` (via openai/uvicorn) |
|
||||||
| **Testing** | `pytest` — 207 tests across 11 files |
|
| **Testing** | `pytest` — 356 tests across 19 files, all offline |
|
||||||
| **Config Files** | `config.yaml`, `leaderboards.yaml`, `evals/tasks.yaml` |
|
| **Config Files** | `config.yaml`, `leaderboards.yaml`, `evals/tasks.yaml` |
|
||||||
| **Deployment** | systemd user units (`.service` + `.timer` files in `deploy/`) |
|
| **Deployment** | systemd user units (`.service` + `.timer` files in `deploy/`) |
|
||||||
| **Integration** | Pre-configured in `opencode.json` — `cd` into repo uses router by default |
|
| **Integration** | `opencode.json` in the repo routes through it by default; any OpenAI-compatible client works |
|
||||||
|
|
||||||
## Pinned Dependencies (requirements.txt)
|
## Pinned Dependencies (requirements.txt)
|
||||||
|
|
||||||
@@ -212,7 +222,8 @@ restarts on boot shouldn't change its dependency tree underneath itself.
|
|||||||
| **`verification.py`** | Two-layer check: structural parse (always) + local LLM spot-check (async, size-gated). Never executes model output | Pure |
|
| **`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) |
|
| **`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) |
|
| **`leaderboard.py`** | Imports `leaderboards.yaml` priors into `proficiency`; `--check` reports gaps | Yes (DB) |
|
||||||
| **`config.py`** | YAML loader + Pydantic validators (weights sum to 1, valid tiers, etc.) | Yes (file) |
|
| **`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) |
|
||||||
|
|
||||||
## Decision Table Schema (SQLite)
|
## Decision Table Schema (SQLite)
|
||||||
|
|
||||||
@@ -270,6 +281,7 @@ parses them into `access_level` and `routing.allowed_access_levels` (default
|
|||||||
| `self_eval_samples` | INTEGER | Evidence count for blending threshold |
|
| `self_eval_samples` | INTEGER | Evidence count for blending threshold |
|
||||||
| `blended_score` | REAL | Computed: `w_lb × lb + w_se × se` |
|
| `blended_score` | REAL | Computed: `w_lb × lb + w_se × se` |
|
||||||
| `source` | TEXT | `blended` \| `self_eval` \| `self_eval_thin` \| `leaderboard` |
|
| `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 |
|
| `last_updated` | TEXT | ISO8601 |
|
||||||
|
|
||||||
**Category set** (9 categories, defined in `config.yaml`):
|
**Category set** (9 categories, defined in `config.yaml`):
|
||||||
@@ -302,8 +314,14 @@ exclude it can.
|
|||||||
|
|
||||||
**Proficiency inheritance:** `propagate_to_variants` copies evaluated
|
**Proficiency inheritance:** `propagate_to_variants` copies evaluated
|
||||||
`scores` to equivalent serving variants (same weights, same reasoning
|
`scores` to equivalent serving variants (same weights, same reasoning
|
||||||
setting, same context pool), but only when the variant has no measured
|
setting, same context pool), but never over a row that was measured
|
||||||
data itself. A `-fast` row is **not** equivalent to its `-standard` sibling.
|
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
|
### `energy_observations` — per-request telemetry
|
||||||
|
|
||||||
@@ -355,11 +373,43 @@ failure — it means the checker had nothing to say, not that the model failed.
|
|||||||
|
|
||||||
## Weighted Scoring
|
## Weighted Scoring
|
||||||
|
|
||||||
Three hard filters are applied **before** scoring (not weighted — outright disqualification):
|
Four hard filters are applied **before** scoring (not weighted — outright disqualification):
|
||||||
|
|
||||||
1. `effective_context_window ≥ required_context_tokens`
|
1. `effective_context_window ≥ required_context_tokens`
|
||||||
2. `tier ≥ required_tier` (from classifier)
|
2. `tier ≥ required_tier` (from classifier)
|
||||||
3. Serving class compatible with request's `latency_tolerance`; `access_level` reachable
|
3. Serving class compatible with request's `latency_tolerance`; `access_level` reachable
|
||||||
|
4. `tool_use_agentic` proficiency ≥ `routing.min_tool_proficiency`, **only when
|
||||||
|
the request carries a `tools` array** (ships disabled — `null`)
|
||||||
|
5. `supports_vision = 1` when the request carries `image_url` parts; `NULL`
|
||||||
|
fails closed (an unknown flag means the capability cannot be confirmed)
|
||||||
|
6. `supports_json_mode = 1` when `response_format.type` is `json_object` or
|
||||||
|
`json_schema`; `NULL` also 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.
|
||||||
|
|
||||||
|
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
|
1. drop candidates whose measured energy exceeds objective.max_energy_per_request
|
||||||
@@ -371,53 +421,78 @@ Three hard filters are applied **before** scoring (not weighted — outright dis
|
|||||||
| Setting | Value | Notes |
|
| Setting | Value | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `quality_tolerance` | 0.10 | Measurement noise, not preference: scores rest on 2-3 samples, so smaller gaps are sampling variation |
|
| `quality_tolerance` | 0.10 | Measurement noise, not preference: scores rest on 2-3 samples, so smaller gaps are sampling variation |
|
||||||
| `max_energy_per_request` | null | Per-request kWh ceiling. The plan is a 6.25 kWh quota — a wall, not a bill |
|
| `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 |
|
||||||
| `plan_kwh_per_period` | 6.25 | Reported in `/health` as burn against the allowance |
|
| `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).
|
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
|
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
|
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.
|
quality — while 60% of every decision adjudicated fractions of a cent.
|
||||||
|
|
||||||
**Why cost ≠ list price:** Neuralwatt bills flat $8.00/kWh. The catalog's
|
**Cost is priced per request, from catalog token prices scaled to the
|
||||||
`input_per_million` / `output_per_million` are **not** what gets charged.
|
request's shape** — prompt size, `assumed_completion_tokens`,
|
||||||
Measured: `cost_usd / energy_kwh = 8.00` across every model. A model listing
|
`assumed_cache_rate`. It is deliberately *not* a benchmark average.
|
||||||
at $4/1M can cost 10× more than one at $15/1M on the same prompt.
|
|
||||||
|
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
|
**Why cost ≠ eco:** Cost tracks energy (kWh), but carbon is energy × grid
|
||||||
intensity. Grid intensity spans ~49 gCO2/kWh (`FI`) to ~442 (`US-MIDA-PJM`) — roughly a 9× spread,
|
intensity. Grid intensity spanned ~49 gCO2/kWh (`FI`) to ~442
|
||||||
and it moves: the provider's own 24h blended figure was 145.4. The models disagree: `glm-5.2-fast` is
|
(`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
|
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
|
`kimi-k2.7-code` while emitting 3.6× more carbon. Collapsing them picks a
|
||||||
side.
|
side.
|
||||||
|
|
||||||
### What routing actually does today — expect one model
|
### What routing actually returns, and why it moves
|
||||||
|
|
||||||
Run `/route` across all 9 categories and 3 tiers and every one of those 27
|
Sweeping `/route` across every category and tier is the fastest way to see
|
||||||
decisions currently returns **`qwen3.6-35b`**. That is not a bug and not a
|
whether your data is doing anything. On the deployment this was written
|
||||||
misconfiguration, so it is worth stating plainly before you go looking for
|
against, 9 categories × 3 tiers currently yields **5 distinct winners**
|
||||||
one.
|
(`qwen3.6-35b`, `gemma-4-31b`, `deepseek-v4-flash`, `kimi-k3`,
|
||||||
|
`kimi-k3-fast`).
|
||||||
|
|
||||||
On this catalog `qwen3.6-35b` is **Pareto-dominant**: cheapest *and* cleanest
|
That number is a diagnostic, not a target, and it is worth knowing what each
|
||||||
in the routable set, while scoring within 0.15 of the best model on
|
outcome means:
|
||||||
proficiency. On `summarization` it costs 7× less and emits 65× less carbon
|
|
||||||
than `kimi-k3`, which beats it 1.00 to 0.85 on quality — no defensible
|
|
||||||
weighting picks `kimi-k3` there. One model winning is the correct answer to
|
|
||||||
the question the weights ask.
|
|
||||||
|
|
||||||
Proficiency still does work underneath: runner-up ordering reorders by
|
- **One winner everywhere** is a legitimate answer, not a misconfiguration.
|
||||||
category (`kimi-k3-fast` climbs to 3rd on `docs_writing` and `summarization`
|
It happened here: with cost, eco and proficiency all populated, one model
|
||||||
where it scores 1.00, and leaves the top four elsewhere). The axis is live;
|
was Pareto-dominant — cheapest *and* cleanest in the routable set while
|
||||||
it simply cannot overturn a leader that wins on two axes at once.
|
scoring within `quality_tolerance` of 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_tokens` exceeds its window, so a
|
||||||
|
long session can change model mid-conversation. Past the largest window,
|
||||||
|
`/v1/chat/completions` returns 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 `proficiency` table has data,
|
||||||
|
`task_category` cannot change a decision at all — the classifier computes
|
||||||
|
it and the router pays for it for nothing.
|
||||||
|
|
||||||
The spread widens when the leader stops being eligible. Past ~94,196 tokens
|
The spread here widened for two reasons worth copying: cost became a
|
||||||
of context `qwen3.6-35b` is filtered out and `glm-5.2-fast` takes over; past
|
per-request estimate rather than a benchmark average, and tier stopped being
|
||||||
~790K nothing qualifies and `/v1/chat/completions` returns 422 naming the
|
inferred from price alone. Both had been quietly excluding a cheap
|
||||||
constraint rather than truncating. So on a large codebase you will see the
|
large-context model from every request above tier 1.
|
||||||
model change mid-session — that is the context filter working.
|
|
||||||
|
|
||||||
If a different balance is wanted, the lever is `weights` in `config.yaml`,
|
If you want a different balance, the levers are `objective.quality_tolerance`
|
||||||
not more data.
|
(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
|
## API Endpoints
|
||||||
|
|
||||||
@@ -455,6 +530,11 @@ structural verification verdict surfaces in the `X-Router-Verification` header
|
|||||||
so a client can inspect it without parsing the response body. Valid values:
|
so a client can inspect it without parsing the response body. Valid values:
|
||||||
`ok`, `truncated`, `malformed`, `unverifiable`, `none`.
|
`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
|
## Quick Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -478,8 +558,65 @@ curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application
|
|||||||
# Admit flex rows for overnight/async work
|
# Admit flex rows for overnight/async work
|
||||||
curl -s -X POST localhost:8080/route -H 'content-type: application/json' \
|
curl -s -X POST localhost:8080/route -H 'content-type: application/json' \
|
||||||
-d '{"task":"nightly code review","latency_tolerance":"batch"}'
|
-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"}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user edit llm-router # Environment="LLM_ROUTER_LOG_LEVEL=debug"
|
||||||
|
systemctl --user restart llm-router
|
||||||
|
```
|
||||||
|
|
||||||
|
Reading it back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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)
|
## Scheduled Jobs (systemd)
|
||||||
|
|
||||||
Five user units. See `deploy/README.md` for full install/operate instructions.
|
Five user units. See `deploy/README.md` for full install/operate instructions.
|
||||||
@@ -487,17 +624,23 @@ Five user units. See `deploy/README.md` for full install/operate instructions.
|
|||||||
| Unit | Schedule | What it runs |
|
| Unit | Schedule | What it runs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `llm-router.service` | Continuous | FastAPI dispatcher |
|
| `llm-router.service` | Continuous | FastAPI dispatcher |
|
||||||
| `llm-router-poller.timer` | 2 min after boot, then every 2 h | `poller.py` → `tier.py` |
|
| `llm-router-poller.timer` | 2 min after boot, then every 2 h | triggers the poller unit |
|
||||||
| `llm-router-seed.timer` | Every 6 h | Small `seed_energy.py` sweep |
|
| `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`
|
**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
|
is 3 with `exclude_stale: true` — an unpolled catalog marks every row stale
|
||||||
in 3 days and the router returns zero candidates for everything.
|
in 3 days and the router returns zero candidates for everything.
|
||||||
|
|
||||||
**The seed timer spans time.** Energy attribution drifts with pool load
|
**The seed timer spans time.** Energy attribution drifts with pool load
|
||||||
across hours (~50× for `deepseek-v4-flash` between sweeps), so a single
|
across hours (~50× for one model between sweeps), so a single sweep measures
|
||||||
sweep measures one moment. The median has to span time — every 6 h sweep
|
one moment. The median has to span time — every 6 h sweep accumulates into a
|
||||||
accumulates into a time-weighted median automatically.
|
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`)
|
## Self-Eval Harness (`eval_proficiency.py`)
|
||||||
|
|
||||||
@@ -517,13 +660,40 @@ python eval_proficiency.py --dry-run # plan only
|
|||||||
|
|
||||||
## Classifier Reliability Notes
|
## Classifier Reliability Notes
|
||||||
|
|
||||||
Several tuning decisions keep the classifier from cascading failures:
|
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: 8000`** clamps 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: 0`** on the classifier client. The OpenAI SDK retries twice
|
- **`max_retries: 0`** on the classifier client. The OpenAI SDK retries twice
|
||||||
by default, so `timeout_seconds` silently became a 3× wall-clock bound — a
|
by default, so `timeout_seconds` silently became a 3× wall-clock bound — a
|
||||||
request could hang past 250s on a 120s setting with no log.
|
request could hang past 250s on a 120s setting with no log.
|
||||||
- **`max_output_tokens: 1024`** bounds a reasoning model's chain of thought.
|
- **`max_output_tokens: 1024`** bounds a reasoning model's chain of thought.
|
||||||
Without it, `qwen3.5` emits unbounded traces — Ollama keeps generating after
|
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
|
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
|
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
|
trace would consume the whole budget and the model would be truncated before
|
||||||
@@ -539,27 +709,39 @@ Several tuning decisions keep the classifier from cascading failures:
|
|||||||
|
|
||||||
## Pointing a Coding Agent at It
|
## Pointing a Coding Agent at It
|
||||||
|
|
||||||
The `/v1` endpoints are OpenAI-compatible. Repo-local `opencode.json` is
|
The `/v1` endpoints are OpenAI-compatible, so any normal client works —
|
||||||
already wired up (`cd ~/Sources/6krrt && opencode` routes by the router by
|
opencode, an SDK, plain curl. A repo-local `opencode.json` is included, so
|
||||||
default). For global use, merge `provider.llm-router` into
|
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`.
|
`~/.config/opencode/opencode.json`.
|
||||||
|
|
||||||
Virtual model names:
|
Virtual model names:
|
||||||
- `auto` → router picks, interactive mode (flex rows excluded)
|
- `auto` → router picks, interactive mode (flex rows excluded)
|
||||||
- `auto:batch` → router picks, admits flex rows for async work
|
- `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
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pytest # run all 207 tests
|
python -m pytest # 356 tests
|
||||||
python -m pytest --cov # with coverage
|
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 file | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `test_scoring.py` | `normalize_inverted`, `composite_score`, None-handling |
|
| `test_scoring.py` | `normalize_inverted`, `composite_score`, None-handling |
|
||||||
| `test_routing.py` | Hard filters, candidate selection & ranking across all 9 categories × 3 tiers |
|
| `test_routing.py` | Hard filters, candidate selection & ranking across all 9 categories × 3 tiers |
|
||||||
| `test_tiering.py` | Tier resolver precedence: override → reasoning → cost → mid |
|
| `test_tiering.py` | Tier resolver precedence: override → reasoning → cost + context window → mid |
|
||||||
| `test_apply_tiering.py` | DB tiering pass, sanity guards |
|
| `test_apply_tiering.py` | DB tiering pass, sanity guards |
|
||||||
| `test_poller_parsing.py` | Serving class, base model, access level parsing |
|
| `test_poller_parsing.py` | Serving class, base model, access level parsing |
|
||||||
| `test_proficiency.py` | Blending rule, accumulation |
|
| `test_proficiency.py` | Blending rule, accumulation |
|
||||||
@@ -568,9 +750,17 @@ python -m pytest --cov # with coverage
|
|||||||
| `test_task_set.py` | Reference solutions validating every `code` task's checks & `exact` answers |
|
| `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_verification.py` | Structural checks on every language, verifier edge cases, local-LLM gating |
|
||||||
| `test_feedback.py` | Failure identification, idempotency, attribution filtering |
|
| `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
|
## 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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@@ -578,15 +768,50 @@ sqlite3 router.db < schema.sql
|
|||||||
cp .env.example .env # fill in NEURALWATT_API_KEY
|
cp .env.example .env # fill in NEURALWATT_API_KEY
|
||||||
python poller.py # populate the catalog
|
python poller.py # populate the catalog
|
||||||
python tier.py # resolve tiers
|
python tier.py # resolve tiers
|
||||||
python seed_energy.py # seed reference energy observations (~5 per model)
|
python config.py # sanity-check config loads
|
||||||
python -m uvicorn dispatcher:app --reload
|
python -m uvicorn dispatcher:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
Ollama must be running locally:
|
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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ollama pull qwen3.5:latest
|
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:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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
|
## Known Limitations & Open Items
|
||||||
|
|
||||||
- **Leaderboard priors unfilled** — `leaderboards.yaml` ships empty. `python leaderboard.py --check`
|
- **Leaderboard priors unfilled** — `leaderboards.yaml` ships empty. `python leaderboard.py --check`
|
||||||
@@ -596,14 +821,22 @@ ollama pull qwen3.5:latest
|
|||||||
- **Three models unsettled** — split-half stability at 7 samples:
|
- **Three models unsettled** — split-half stability at 7 samples:
|
||||||
`kimi-k2.7-code-fast` (29×), `kimi-k3` (14×), `glm-5.2-flex` (2.2×).
|
`kimi-k2.7-code-fast` (29×), `kimi-k3` (14×), `glm-5.2-flex` (2.2×).
|
||||||
More samples needed before their positioning is trustworthy.
|
More samples needed before their positioning is trustworthy.
|
||||||
- **Verification observes but never intervenes** — failures are detected and
|
- **Retry does not reach streaming** — a failed structural check now buys
|
||||||
fed back into `proficiency` automatically (`verification.py` →
|
corrective attempts scaled by tier (`iteration.py`), but only on the
|
||||||
`feedback.py`), so the router does learn which models fail on real work.
|
non-streaming path. Once bytes have gone to the client there is nothing to
|
||||||
What is missing is action: nothing retries or escalates on a failed check.
|
take back, and buffering to allow correction would cost streaming itself.
|
||||||
That is deliberate — whether auto-escalation pays should be decided from
|
`POST /outcome` is the answer for streamed traffic: the client reports
|
||||||
the observed failure rate, which is now being collected rather than
|
afterwards, so it works identically either way.
|
||||||
guessed at.
|
- **No auth** — the service holds a billable API key with no authentication
|
||||||
- **No auth** — the service holds a billable API key with no
|
of its own. It binds loopback for that reason; widening the bind needs an
|
||||||
authentication of its own. Loopback binds only.
|
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
|
- **Context assembly (RAG)** is out of scope — the classifier sees the full
|
||||||
conversation but does not perform document/code retrieval.
|
conversation but does not perform document/code retrieval.
|
||||||
|
|||||||
81
capabilities.py
Normal file
81
capabilities.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Detect request-side capability requirements from an OpenAI-format body.
|
||||||
|
|
||||||
|
Like ``routing.py``, ``scoring.py`` and ``tiering.py``, this module is free of
|
||||||
|
I/O: the request body comes in as a dict and capabilities come out as a value
|
||||||
|
object. ``dispatcher.py`` owns the parsing of the HTTP request into that dict.
|
||||||
|
|
||||||
|
Detection is read from the request body, not inferred by a classifier. This
|
||||||
|
mirrors the existing ``tools_present`` / ``min_tool_proficiency`` philosophy in
|
||||||
|
``routing.py``: a local classifier cannot reliably identify agentic or visual
|
||||||
|
work — asked to label unambiguous tool-use prompts it got 1-2 of 6, and vision
|
||||||
|
is the same class of problem. But the request states both exactly, in the
|
||||||
|
``tools`` array and in an ``image_url`` part, and reading them is free. When
|
||||||
|
the capability matters, it is a hard filter (a model that cannot be trusted
|
||||||
|
with tools that exist is not a candidate), never a weighted preference.
|
||||||
|
|
||||||
|
``has_reasoning_request`` is the one flag that is informational only: it is
|
||||||
|
recorded so dispatcher can observe it, but never fed to a routing filter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# OpenAI response_format.types that require the model to support JSON mode.
|
||||||
|
JSON_MODE_TYPES = frozenset({"json_object", "json_schema"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RequestCapabilities:
|
||||||
|
has_images: bool = False
|
||||||
|
require_json_mode: bool = False
|
||||||
|
tools_present: bool = False
|
||||||
|
has_reasoning_request: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_capabilities(body: dict[str, Any]) -> RequestCapabilities:
|
||||||
|
"""Read the capability requirements stated by an OpenAI-format request body.
|
||||||
|
|
||||||
|
``has_images`` scans ALL messages, not just the last: vision is a property
|
||||||
|
of the whole conversation, and an image in an earlier turn still has to be
|
||||||
|
understood by whoever answers the latest one. A message whose ``content`` is
|
||||||
|
a list contributes True if any part is a dict whose ``type`` is
|
||||||
|
``image_url``. String content and non-image parts never trigger it; a raw
|
||||||
|
base64 string sitting in a text part is not an image_url and must not be
|
||||||
|
matched.
|
||||||
|
|
||||||
|
``require_json_mode`` reads ``response_format.type`` against
|
||||||
|
``JSON_MODE_TYPES``. ``text`` and an absent ``response_format`` read False.
|
||||||
|
|
||||||
|
``tools_present`` is truthy exactly when the body carries a ``tools`` array.
|
||||||
|
|
||||||
|
``has_reasoning_request`` is True if the body carries either a
|
||||||
|
``reasoning_effort`` key or a ``reasoning`` key. This flag is recorded for
|
||||||
|
observation only and is never consumed by a routing filter.
|
||||||
|
"""
|
||||||
|
has_images = _any_message_has_images(body.get("messages") or [])
|
||||||
|
response_format = body.get("response_format")
|
||||||
|
require_json_mode = (
|
||||||
|
isinstance(response_format, dict)
|
||||||
|
and response_format.get("type") in JSON_MODE_TYPES
|
||||||
|
)
|
||||||
|
return RequestCapabilities(
|
||||||
|
has_images=has_images,
|
||||||
|
require_json_mode=require_json_mode,
|
||||||
|
tools_present=bool(body.get("tools")),
|
||||||
|
has_reasoning_request="reasoning_effort" in body or "reasoning" in body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _any_message_has_images(messages: list[Any]) -> bool:
|
||||||
|
for message in messages:
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
content = message.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
continue
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
243
config.py
243
config.py
@@ -4,19 +4,37 @@ Loads and validates config.yaml for the local LLM router.
|
|||||||
Usage:
|
Usage:
|
||||||
from config import load_config
|
from config import load_config
|
||||||
cfg = load_config("config.yaml")
|
cfg = load_config("config.yaml")
|
||||||
cfg.weights.cost # etc.
|
cfg.objective.quality_tolerance # etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
class Objective(BaseModel):
|
class StrictModel(BaseModel):
|
||||||
|
"""Base for every config section: an unknown key is an error.
|
||||||
|
|
||||||
|
Pydantic ignores extra keys by default, which makes a typo or a
|
||||||
|
misplaced setting silently do nothing while the file still loads and
|
||||||
|
still looks configured. That is not hypothetical here — `max_input_chars`
|
||||||
|
was written into the `verification:` block instead of `classifier:`,
|
||||||
|
where it was accepted, ignored, and had no effect. It happened to carry
|
||||||
|
the same value as the code default, so nothing visibly broke; editing it
|
||||||
|
would simply have done nothing.
|
||||||
|
|
||||||
|
Anyone tuning this file needs a wrong key to say so.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class Objective(StrictModel):
|
||||||
"""What the router optimizes: quality, bounded by cost.
|
"""What the router optimizes: quality, bounded by cost.
|
||||||
|
|
||||||
Replaced a three-way weighted blend. See config.yaml for why — briefly,
|
Replaced a three-way weighted blend. See config.yaml for why — briefly,
|
||||||
@@ -25,7 +43,7 @@ class Objective(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
quality_tolerance: float = 0.10
|
quality_tolerance: float = 0.10
|
||||||
assumed_cache_rate: float = 0.84
|
assumed_cache_rate: float = 0.917
|
||||||
assumed_completion_tokens: int = 500
|
assumed_completion_tokens: int = 500
|
||||||
max_energy_per_request: Optional[float] = None
|
max_energy_per_request: Optional[float] = None
|
||||||
plan_kwh_per_period: Optional[float] = None
|
plan_kwh_per_period: Optional[float] = None
|
||||||
@@ -47,10 +65,44 @@ class Objective(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ContextConfig(BaseModel):
|
class ContextOverride(StrictModel):
|
||||||
|
"""Per-model context handling, for a row whose real limits are known.
|
||||||
|
|
||||||
|
Typed rather than a bare ``dict`` so a typo INSIDE an override is an error
|
||||||
|
too. That is the whole point of StrictModel, and it was not true here: the
|
||||||
|
block validated, nothing read it, and config.yaml shipped a worked example
|
||||||
|
for it -- so anyone who followed that example got silence.
|
||||||
|
|
||||||
|
Both fields are optional; whichever is absent falls back to the global.
|
||||||
|
"""
|
||||||
|
|
||||||
|
safety_factor: Optional[float] = None
|
||||||
|
output_reserve_tokens: Optional[int] = None
|
||||||
|
|
||||||
|
@field_validator("safety_factor")
|
||||||
|
@classmethod
|
||||||
|
def factor_in_range(cls, v: Optional[float]) -> Optional[float]:
|
||||||
|
if v is not None and not (0.0 < v <= 1.0):
|
||||||
|
raise ValueError(
|
||||||
|
"context.per_model_overrides[...].safety_factor must be in (0, 1]"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("output_reserve_tokens")
|
||||||
|
@classmethod
|
||||||
|
def reserve_not_negative(cls, v: Optional[int]) -> Optional[int]:
|
||||||
|
if v is not None and v < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"context.per_model_overrides[...].output_reserve_tokens must be >= 0"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ContextConfig(StrictModel):
|
||||||
safety_factor: float
|
safety_factor: float
|
||||||
default_output_reserve_tokens: int
|
default_output_reserve_tokens: int
|
||||||
per_model_overrides: dict = {}
|
# Read by poller.ModelRow.effective_context_window.
|
||||||
|
per_model_overrides: dict[str, ContextOverride] = {}
|
||||||
|
|
||||||
@field_validator("safety_factor")
|
@field_validator("safety_factor")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -60,7 +112,7 @@ class ContextConfig(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ProficiencyConfig(BaseModel):
|
class ProficiencyConfig(StrictModel):
|
||||||
self_eval_min_samples: int
|
self_eval_min_samples: int
|
||||||
leaderboard_weight: float
|
leaderboard_weight: float
|
||||||
self_eval_weight: float
|
self_eval_weight: float
|
||||||
@@ -77,7 +129,7 @@ class ProficiencyConfig(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TieringConfig(BaseModel):
|
class TieringConfig(StrictModel):
|
||||||
cheap_completion_max: float
|
cheap_completion_max: float
|
||||||
tier1_context_max: float = float("inf")
|
tier1_context_max: float = float("inf")
|
||||||
model_tiers: dict[str, int]
|
model_tiers: dict[str, int]
|
||||||
@@ -108,9 +160,20 @@ class TieringConfig(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class RoutingConfig(BaseModel):
|
class RoutingConfig(StrictModel):
|
||||||
allowed_access_levels: list[str]
|
allowed_access_levels: list[str]
|
||||||
default_latency_tolerance: str
|
default_latency_tolerance: str
|
||||||
|
# Applied only when the REQUEST carries tool definitions. None disables it.
|
||||||
|
min_tool_proficiency: Optional[float] = 0.5
|
||||||
|
tool_use_category: str = "tool_use_agentic"
|
||||||
|
|
||||||
|
# Whether a request carrying image parts is hard-restricted to vision-capable
|
||||||
|
# models. A wrong guess here is a guaranteed 400, so this gates by default
|
||||||
|
# and routing fails closed when the catalog flag is unknown.
|
||||||
|
require_vision: bool = True
|
||||||
|
# Whether a response_format requiring json_object/json_schema is hard-restricted
|
||||||
|
# to JSON-mode-capable models. Same guaranteed-failure argument.
|
||||||
|
require_json_mode: bool = True
|
||||||
|
|
||||||
@field_validator("allowed_access_levels")
|
@field_validator("allowed_access_levels")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -136,15 +199,54 @@ class RoutingConfig(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class VerificationConfig(BaseModel):
|
class VerificationConfig(StrictModel):
|
||||||
local_llm_enabled: bool = True
|
local_llm_enabled: bool = True
|
||||||
min_completion_tokens: int = 600
|
min_completion_tokens: int = 600
|
||||||
timeout_seconds: int = 60
|
timeout_seconds: int = 60
|
||||||
max_output_tokens: int = 1024
|
max_output_tokens: int = 1024
|
||||||
outcome_attribution_window_seconds: int = 120
|
outcome_attribution_window_seconds: int = 120
|
||||||
|
# The local checker's OWN endpoint, no longer derived from the
|
||||||
|
# classifier's. It speaks Ollama's NATIVE API (/api/chat, think=False),
|
||||||
|
# which no cloud provider offers, so it must keep pointing at an Ollama
|
||||||
|
# instance even when classification has been moved off this machine.
|
||||||
|
base_url: str = "http://localhost:11434"
|
||||||
|
# None means "whatever the classifier uses", which is correct only while
|
||||||
|
# both run on the same local Ollama. Set it explicitly once they diverge.
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class EscalationConfig(BaseModel):
|
class LocalVisionConfig(StrictModel):
|
||||||
|
enabled: bool = False
|
||||||
|
base_url: str = "http://localhost:11434/v1" # OpenAI-compatible (classifier shape)
|
||||||
|
api_key_env: Optional[str] = None
|
||||||
|
model: str = "qwen3-vl:4b"
|
||||||
|
timeout_seconds: int = 60
|
||||||
|
max_images: int = 4
|
||||||
|
max_image_bytes: int = 9 * 1024 * 1024 # 9 MiB, Ollama default cap
|
||||||
|
|
||||||
|
@field_validator("timeout_seconds")
|
||||||
|
@classmethod
|
||||||
|
def timeout_positive(cls, v: int) -> int:
|
||||||
|
if v <= 0:
|
||||||
|
raise ValueError("local_vision.timeout_seconds must be > 0")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("max_images")
|
||||||
|
@classmethod
|
||||||
|
def images_positive(cls, v: int) -> int:
|
||||||
|
if v <= 0:
|
||||||
|
raise ValueError("local_vision.max_images must be > 0")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("max_image_bytes")
|
||||||
|
@classmethod
|
||||||
|
def image_bytes_positive(cls, v: int) -> int:
|
||||||
|
if v <= 0:
|
||||||
|
raise ValueError("local_vision.max_image_bytes must be > 0")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationConfig(StrictModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
max_tier: int
|
max_tier: int
|
||||||
min_confidence_before_bump: float
|
min_confidence_before_bump: float
|
||||||
@@ -152,7 +254,7 @@ class EscalationConfig(BaseModel):
|
|||||||
preemptive_on_low_confidence: bool = False
|
preemptive_on_low_confidence: bool = False
|
||||||
|
|
||||||
|
|
||||||
class IterationConfig(BaseModel):
|
class IterationConfig(StrictModel):
|
||||||
"""A tier's budget for corrective attempts after a verification failure."""
|
"""A tier's budget for corrective attempts after a verification failure."""
|
||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@@ -173,41 +275,58 @@ class IterationConfig(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class FreshnessConfig(BaseModel):
|
class FreshnessConfig(StrictModel):
|
||||||
stale_after_days: int
|
stale_after_days: int
|
||||||
exclude_stale: bool
|
exclude_stale: bool
|
||||||
exclude_deprecated: bool
|
exclude_deprecated: bool
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(BaseModel):
|
class DatabaseConfig(StrictModel):
|
||||||
path: str
|
path: str
|
||||||
|
|
||||||
|
|
||||||
class ClassifierConfig(BaseModel):
|
class ClassifierConfig(StrictModel):
|
||||||
provider: str
|
provider: str
|
||||||
base_url: str
|
base_url: str
|
||||||
|
# Env var holding the API key, for a classifier served by a provider that
|
||||||
|
# actually checks one. None means unauthenticated, which is the local
|
||||||
|
# Ollama case — it ignores the key entirely but the SDK requires one.
|
||||||
|
api_key_env: Optional[str] = None
|
||||||
model: str
|
model: str
|
||||||
|
# Ceiling on the text handed to the classifier. 0 disables clamping.
|
||||||
|
max_input_chars: int = 8000
|
||||||
timeout_seconds: int
|
timeout_seconds: int
|
||||||
temperature: float = 0.0
|
temperature: float = 0.0
|
||||||
max_output_tokens: int = 1024
|
max_output_tokens: int = 1024
|
||||||
outcome_attribution_window_seconds: int = 120
|
|
||||||
fallback_tier: int = 2
|
fallback_tier: int = 2
|
||||||
fallback_category: str = "general_chat"
|
fallback_category: str = "general_chat"
|
||||||
response_format: str
|
response_format: str
|
||||||
system_prompt: str
|
system_prompt: str
|
||||||
|
|
||||||
|
|
||||||
class DispatchProvider(BaseModel):
|
class DispatchProvider(StrictModel):
|
||||||
base_url: str
|
base_url: str
|
||||||
api_key_env: str
|
api_key_env: str
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseModel):
|
class LoggingConfig(StrictModel):
|
||||||
|
# log_path is gone. Nothing ever wrote a file: the dispatcher logs to
|
||||||
|
# stderr and systemd captures that to the journal, so the setting named a
|
||||||
|
# destination that did not exist.
|
||||||
log_energy_observations: bool
|
log_energy_observations: bool
|
||||||
log_path: str
|
# LLM_ROUTER_LOG_LEVEL overrides this at runtime — see logs.resolve_level.
|
||||||
|
level: str = "info"
|
||||||
|
|
||||||
|
@field_validator("level")
|
||||||
|
@classmethod
|
||||||
|
def level_known(cls, v: str) -> str:
|
||||||
|
known = ("debug", "info", "warning", "error")
|
||||||
|
if v.strip().lower() not in known:
|
||||||
|
raise ValueError(f"logging.level must be one of {known}, got {v!r}")
|
||||||
|
return v.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
class RouterConfig(BaseModel):
|
class RouterConfig(StrictModel):
|
||||||
objective: Objective
|
objective: Objective
|
||||||
context: ContextConfig
|
context: ContextConfig
|
||||||
tiers: dict[int, str]
|
tiers: dict[int, str]
|
||||||
@@ -215,6 +334,7 @@ class RouterConfig(BaseModel):
|
|||||||
proficiency: ProficiencyConfig
|
proficiency: ProficiencyConfig
|
||||||
routing: RoutingConfig
|
routing: RoutingConfig
|
||||||
verification: VerificationConfig = VerificationConfig()
|
verification: VerificationConfig = VerificationConfig()
|
||||||
|
local_vision: LocalVisionConfig = LocalVisionConfig()
|
||||||
escalation: EscalationConfig
|
escalation: EscalationConfig
|
||||||
iteration: IterationConfig = IterationConfig()
|
iteration: IterationConfig = IterationConfig()
|
||||||
freshness: FreshnessConfig
|
freshness: FreshnessConfig
|
||||||
@@ -223,6 +343,57 @@ class RouterConfig(BaseModel):
|
|||||||
dispatch_providers: dict[str, DispatchProvider]
|
dispatch_providers: dict[str, DispatchProvider]
|
||||||
logging: LoggingConfig
|
logging: LoggingConfig
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def tool_use_category_is_a_real_category(self) -> "RouterConfig":
|
||||||
|
"""The tool filter joins on a category name; a typo would disable it.
|
||||||
|
|
||||||
|
A name that matches nothing produces NULL for every row, and NULL
|
||||||
|
means "unproven, do not disqualify" — so the filter would silently
|
||||||
|
pass everything. Failing at load beats a guard that quietly stops
|
||||||
|
guarding.
|
||||||
|
"""
|
||||||
|
if self.routing.min_tool_proficiency is None:
|
||||||
|
return self
|
||||||
|
if self.routing.tool_use_category not in self.proficiency.categories:
|
||||||
|
raise ValueError(
|
||||||
|
f"routing.tool_use_category "
|
||||||
|
f"({self.routing.tool_use_category!r}) is not in "
|
||||||
|
f"proficiency.categories — the tool-competence filter would "
|
||||||
|
f"join against nothing and silently pass every model."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def verifier_model_is_stated_once_the_hosts_differ(self) -> "RouterConfig":
|
||||||
|
"""A remote classifier must not lend its model name to the verifier.
|
||||||
|
|
||||||
|
``verification.model`` falling back to ``classifier.model`` is correct
|
||||||
|
only while both point at the same Ollama. Once classification moves
|
||||||
|
off-host the fallback names a model the local Ollama has never heard
|
||||||
|
of, and the failure is SILENT: the verifier 404s, catches it, logs
|
||||||
|
"local verification unavailable" and records no sample. Verification
|
||||||
|
would appear to be on while producing nothing.
|
||||||
|
|
||||||
|
Caught by pointing the classifier at NeuralWatt and watching the
|
||||||
|
verifier POST ``deepseek-v4-flash`` to localhost:11434. Failing at
|
||||||
|
config load instead means the misconfiguration is impossible rather
|
||||||
|
than merely documented.
|
||||||
|
"""
|
||||||
|
if not self.verification.local_llm_enabled or self.verification.model:
|
||||||
|
return self
|
||||||
|
classifier_host = urlparse(self.classifier.base_url).hostname
|
||||||
|
verifier_host = urlparse(self.verification.base_url).hostname
|
||||||
|
if classifier_host != verifier_host:
|
||||||
|
raise ValueError(
|
||||||
|
"verification.model must be set explicitly when the classifier "
|
||||||
|
f"runs on a different host ({classifier_host} vs "
|
||||||
|
f"{verifier_host}). It would otherwise fall back to "
|
||||||
|
f"classifier.model ({self.classifier.model!r}), which the local "
|
||||||
|
"Ollama does not serve — and the verifier fails silently. "
|
||||||
|
"Set verification.model, or verification.local_llm_enabled: false."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str | Path = "config.yaml") -> RouterConfig:
|
def load_config(path: str | Path = "config.yaml") -> RouterConfig:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
@@ -232,14 +403,32 @@ def load_config(path: str | Path = "config.yaml") -> RouterConfig:
|
|||||||
return RouterConfig(**raw)
|
return RouterConfig(**raw)
|
||||||
|
|
||||||
|
|
||||||
|
def summary_lines(cfg: RouterConfig) -> list[str]:
|
||||||
|
"""What ``python config.py`` prints.
|
||||||
|
|
||||||
|
A function rather than inline prints so a test can pin the attribute names.
|
||||||
|
The previous version read ``cfg.weights``, which had been replaced by
|
||||||
|
``cfg.objective`` -- so the setup step documented in both README.md and
|
||||||
|
CLAUDE.md said "Config loaded OK" and then died with AttributeError on a
|
||||||
|
config that had in fact loaded perfectly.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
"Config loaded OK",
|
||||||
|
f" objective: quality_tolerance={cfg.objective.quality_tolerance}, "
|
||||||
|
f"max_energy_per_request={cfg.objective.max_energy_per_request}, "
|
||||||
|
f"plan_kwh_per_period={cfg.objective.plan_kwh_per_period}",
|
||||||
|
f" categories: {cfg.proficiency.categories}",
|
||||||
|
f" classifier: {cfg.classifier.model} @ {cfg.classifier.base_url}",
|
||||||
|
f" verifier: {cfg.verification.model or cfg.classifier.model} "
|
||||||
|
f"@ {cfg.verification.base_url}"
|
||||||
|
+ ("" if cfg.verification.local_llm_enabled else " (disabled)"),
|
||||||
|
f" tool filter: min_tool_proficiency={cfg.routing.min_tool_proficiency}",
|
||||||
|
f" dispatch providers: {list(cfg.dispatch_providers)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
cfg_path = sys.argv[1] if len(sys.argv) > 1 else "config.yaml"
|
cfg_path = sys.argv[1] if len(sys.argv) > 1 else "config.yaml"
|
||||||
cfg = load_config(cfg_path)
|
print("\n".join(summary_lines(load_config(cfg_path))))
|
||||||
print("Config loaded OK")
|
|
||||||
print(f" weights: cost={cfg.weights.cost} eco={cfg.weights.eco} "
|
|
||||||
f"proficiency={cfg.weights.proficiency}")
|
|
||||||
print(f" categories: {cfg.proficiency.categories}")
|
|
||||||
print(f" classifier: {cfg.classifier.model} @ {cfg.classifier.base_url}")
|
|
||||||
print(f" dispatch providers: {list(cfg.dispatch_providers.keys())}")
|
|
||||||
|
|||||||
194
config.yaml
194
config.yaml
@@ -28,10 +28,18 @@ objective:
|
|||||||
# to the actual request agree with the live measurement, cost nothing, and
|
# to the actual request agree with the live measurement, cost nothing, and
|
||||||
# need no sweep.
|
# need no sweep.
|
||||||
|
|
||||||
# Share of prompt tokens served from the provider's prefix cache. Measured
|
# Share of prompt tokens served from the provider's prefix cache. Agent
|
||||||
# from real traffic: 1.9M of 2.2M prompt tokens, 84%. Agent clients resend
|
# clients resend the whole conversation each turn, so most of it is a hit.
|
||||||
# the whole conversation each turn, so most of it is a cache hit.
|
#
|
||||||
assumed_cache_rate: 0.84
|
# Measured token-weighted across 50 sessions and 40.7M tokens (2026-08-23):
|
||||||
|
# 91.7% overall, 92.6% on the sessions above 400k tokens, which are the ones
|
||||||
|
# that carry the cost. The previous 0.84 came from 2.2M tokens of much
|
||||||
|
# earlier traffic.
|
||||||
|
#
|
||||||
|
# Raising it changed NO winner in any of the nine categories at 60k context
|
||||||
|
# — checked before editing. It is here because the number should be true,
|
||||||
|
# not because the routing needed it.
|
||||||
|
assumed_cache_rate: 0.917
|
||||||
|
|
||||||
# Completion length assumed when pricing a request. Real sessions here median
|
# Completion length assumed when pricing a request. Real sessions here median
|
||||||
# around 200-400 completion tokens against enormous prompts.
|
# around 200-400 completion tokens against enormous prompts.
|
||||||
@@ -55,10 +63,22 @@ objective:
|
|||||||
context:
|
context:
|
||||||
safety_factor: 0.75 # fraction of advertised context treated as usable
|
safety_factor: 0.75 # fraction of advertised context treated as usable
|
||||||
default_output_reserve_tokens: 4096
|
default_output_reserve_tokens: 4096
|
||||||
# If a model has a known-good override, list it here; otherwise the global
|
# Per-model exceptions to the two settings above, for a row whose real
|
||||||
# safety_factor above applies.
|
# limits you have measured. The global factor has to hold for the whole
|
||||||
|
# catalog, so it is deliberately pessimistic; a model you have actually
|
||||||
|
# pushed to its limit deserves its own number.
|
||||||
|
#
|
||||||
|
# Both keys are optional and each falls back to the global on its own.
|
||||||
|
# An override of 0 reserve tokens means zero, not "unset".
|
||||||
|
#
|
||||||
|
# per_model_overrides:
|
||||||
|
# qwen3.6-35b:
|
||||||
|
# safety_factor: 0.85
|
||||||
|
# output_reserve_tokens: 8192
|
||||||
|
#
|
||||||
|
# Takes effect on the next `python poller.py` -- effective_context_window is
|
||||||
|
# computed at poll time, not per request.
|
||||||
per_model_overrides: {}
|
per_model_overrides: {}
|
||||||
# example: per_model_overrides: { "qwen3.6-35b": { safety_factor: 0.85 } }
|
|
||||||
|
|
||||||
tiers:
|
tiers:
|
||||||
# Maps a tier number to a human label, purely for logging/dashboards.
|
# Maps a tier number to a human label, purely for logging/dashboards.
|
||||||
@@ -150,15 +170,91 @@ routing:
|
|||||||
# interactive, so a request has to opt in via latency_tolerance.
|
# interactive, so a request has to opt in via latency_tolerance.
|
||||||
default_latency_tolerance: interactive # 'interactive' | 'batch'
|
default_latency_tolerance: interactive # 'interactive' | 'batch'
|
||||||
|
|
||||||
# There is deliberately no flex discount knob. Cost scoring reads the mean
|
# Minimum tool_use_agentic proficiency required of a model when the REQUEST
|
||||||
# cost actually billed for the reference workload (see seed_energy.py), and
|
# carries tool definitions. A filter, not a weight, because it is a
|
||||||
# a flex row's measured cost already is its flex cost.
|
# capability requirement rather than a preference.
|
||||||
|
#
|
||||||
|
# It does NOT ask whether the task is agentic — it asks whether the model
|
||||||
|
# can be trusted with tools that are on the table. The measured failure is
|
||||||
|
# the second one: deepseek-v4-flash scores 0.33 here, and the recorded case
|
||||||
|
# is a NON-agentic prompt ("it is 1:20pm, my meeting is at 3pm, how many
|
||||||
|
# minutes?") where it called two tools instead of subtracting. A model that
|
||||||
|
# over-reaches is a hazard wherever tools exist, not only where a classifier
|
||||||
|
# would say "agentic" — which it cannot do anyway: asked to identify six
|
||||||
|
# unambiguous tool-use prompts, the local models managed 2/6 and 1/6.
|
||||||
|
# Whether tools are present is stated in the request body. Read it.
|
||||||
|
#
|
||||||
|
# 0.5 sits in the empty band between the only two values the catalog
|
||||||
|
# currently 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.
|
||||||
|
#
|
||||||
|
# CURRENTLY DISABLED (null) pending experimentation. The trade being
|
||||||
|
# measured: opencode sends `tools` on essentially every request, so with the
|
||||||
|
# filter on, deepseek-v4-flash is excluded from ordinary agent traffic and
|
||||||
|
# the ~7x cost advantage on coding routes 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.
|
||||||
|
#
|
||||||
|
# What would settle it is outcome data, not another benchmark: run with it
|
||||||
|
# off, let POST /outcome report real pass/fail, and compare
|
||||||
|
# tool_use_agentic proficiency for deepseek before and after. That is the
|
||||||
|
# one signal here that knows whether the work actually worked.
|
||||||
|
min_tool_proficiency: null
|
||||||
|
tool_use_category: tool_use_agentic
|
||||||
|
|
||||||
|
# Request-side capability gates. These read the request body (image parts,
|
||||||
|
# response_format) and hard-restrict to models whose catalog row declares the
|
||||||
|
# capability. Unlike min_tool_proficiency these are NOT proficiency gates —
|
||||||
|
# a wrong guess is a guaranteed provider 400, so they gate by default and
|
||||||
|
# fail closed when the catalog flag is unknown.
|
||||||
|
require_vision: true
|
||||||
|
require_json_mode: true
|
||||||
|
|
||||||
|
# There is deliberately no flex discount knob. Whether a flex row is usable
|
||||||
|
# at all is a hard filter above (latency_tolerance), not a price adjustment
|
||||||
|
# -- being held server-side during peak is a latency property, and the
|
||||||
|
# catalog advertises flex and standard at the same token price anyway.
|
||||||
|
|
||||||
|
local_vision:
|
||||||
|
# Local Ollama vision fallback. Used ONLY when a request carries image
|
||||||
|
# parts and routing finds NO cloud model that supports_vision — the cloud
|
||||||
|
# catalog currently excludes the cost leader (deepseek) on vision, so a
|
||||||
|
# fallback is what keeps image requests working instead of 422ing.
|
||||||
|
# It speaks the OpenAI-compatible /v1 surface, so this is an Ollama endpoint
|
||||||
|
# and the model must be pulled (`ollama pull qwen3-vl:4b`) on that host.
|
||||||
|
enabled: true
|
||||||
|
base_url: "http://localhost:11434/v1"
|
||||||
|
api_key_env: null
|
||||||
|
model: "qwen3-vl:4b"
|
||||||
|
timeout_seconds: 60
|
||||||
|
max_images: 4
|
||||||
|
max_image_bytes: 9437184
|
||||||
|
|
||||||
verification:
|
verification:
|
||||||
# Structural checks (parse the code, never run it) are free and always on.
|
# Structural checks (parse the code, never run it) are free, pure Python and
|
||||||
|
# always on — they need no model and run anywhere, down to an RPi.
|
||||||
# This section governs the LOCAL LLM check, which is not free.
|
# This section governs the LOCAL LLM check, which is not free.
|
||||||
|
#
|
||||||
|
# Set local_llm_enabled: false on a host with no usable local inference.
|
||||||
|
# Structural checking and POST /outcome both keep working; only the
|
||||||
|
# refusal/incoherence class of failure stops being caught.
|
||||||
local_llm_enabled: true
|
local_llm_enabled: true
|
||||||
|
|
||||||
|
# This check speaks Ollama's NATIVE API (/api/chat with think=False), which
|
||||||
|
# no cloud provider offers, so it is configured SEPARATELY from the
|
||||||
|
# classifier rather than derived from it. Deriving it meant that pointing
|
||||||
|
# classification anywhere else sent these requests to <that host>/api/chat.
|
||||||
|
#
|
||||||
|
# Point it at the SAME Ollama as the classifier when that is across a VPN;
|
||||||
|
# leave the model null in that case, since the hosts then match.
|
||||||
|
base_url: "http://localhost:11434"
|
||||||
|
# null means "whatever classifier.model is", correct only while both run on
|
||||||
|
# the same local Ollama — config load REFUSES the null once the hosts differ,
|
||||||
|
# because the fallback would name a model this Ollama has never heard of and
|
||||||
|
# the verifier would fail silently.
|
||||||
|
model: "mistral-nemo:12b"
|
||||||
|
|
||||||
# Only check answers this large. Measured on real traffic: a local check
|
# Only check answers this large. Measured on real traffic: a local check
|
||||||
# costs ~15% of a median 193-token answer, so it would only pay if such
|
# costs ~15% of a median 193-token answer, so it would only pay if such
|
||||||
# answers failed more than ~15% of the time. At 1,500 completion tokens the
|
# answers failed more than ~15% of the time. At 1,500 completion tokens the
|
||||||
@@ -191,10 +287,28 @@ database:
|
|||||||
path: "router.db"
|
path: "router.db"
|
||||||
|
|
||||||
classifier:
|
classifier:
|
||||||
# Local Ollama instance doing task classification / context sizing.
|
# The local LLM that classifies each task before a cloud model answers it.
|
||||||
|
# "Local" means your hardware, not necessarily this machine — the box with
|
||||||
|
# the GPU is usually not the laptop you are typing on. It speaks plain
|
||||||
|
# OpenAI-compatible chat completions, so point it wherever Ollama lives:
|
||||||
|
#
|
||||||
|
# same machine base_url: http://localhost:11434/v1
|
||||||
|
# over a VPN base_url: http://<vpn-ip>:11434/v1
|
||||||
|
# (serving host needs deploy/ollama-over-vpn.conf; Ollama
|
||||||
|
# binds loopback-only by default and will refuse)
|
||||||
|
#
|
||||||
|
# Any OpenAI-compatible endpoint works, so a cloud model can classify too —
|
||||||
|
# set api_key_env for one that checks a key. Worth knowing before assuming
|
||||||
|
# local is the cheap option: on five prompts NeuralWatt's deepseek-v4-flash
|
||||||
|
# classified in 1.02s mean against qwen3.5's 11.58s on an RTX 6000, agreed
|
||||||
|
# with the label 5/5 against 2/4, and cost $0.093 per thousand calls. Local
|
||||||
|
# inference is not free, it is unbilled.
|
||||||
provider: "ollama"
|
provider: "ollama"
|
||||||
base_url: "http://localhost:11434/v1"
|
base_url: "http://localhost:11434/v1"
|
||||||
model: "qwen3.5:latest" # must match a model `ollama list` reports
|
# Unset means unauthenticated, which is the Ollama case. Name the env var
|
||||||
|
# holding the key when the endpoint actually checks one.
|
||||||
|
api_key_env: null
|
||||||
|
model: "mistral-nemo:12b" # must match a model `ollama list` reports
|
||||||
# A cold Ollama took 43s to answer the first classification, which blew the
|
# A cold Ollama took 43s to answer the first classification, which blew the
|
||||||
# old 30s ceiling and returned 503 to the client. Warm it is ~5s. The
|
# old 30s ceiling and returned 503 to the client. Warm it is ~5s. The
|
||||||
# ceiling is for a cold model load, not the steady state.
|
# ceiling is for a cold model load, not the steady state.
|
||||||
@@ -205,21 +319,36 @@ classifier:
|
|||||||
# prompt is untraceable.
|
# prompt is untraceable.
|
||||||
temperature: 0
|
temperature: 0
|
||||||
# Hard cap on the classifier's generation, to bound a failure mode that
|
# Hard cap on the classifier's generation, to bound a failure mode that
|
||||||
# cascades: qwen3.5 is a reasoning model and will sometimes emit a long
|
# cascades on REASONING models: they emit a long chain of thought, blow past
|
||||||
# chain of thought, blowing past timeout_seconds — and Ollama keeps
|
# timeout_seconds, and Ollama keeps generating after the client gives up AND
|
||||||
# generating after the client gives up AND serializes per model, so one
|
# serializes per model, so one runaway request queues every later one behind
|
||||||
# runaway request queues every later request behind it.
|
# it. Measured on qwen3.5, which failed this way on 4 of 19 calls — each one
|
||||||
|
# a 15s wait ending in a silent fallback.
|
||||||
#
|
#
|
||||||
# Must leave room for the thinking trace, not just the ~45-token answer. At
|
# The current default (mistral-nemo) does not reason, so this does not bind
|
||||||
# 256 the trace consumed the whole budget on some prompts and the model was
|
# for it. Kept anyway: it costs nothing when unused and is the only thing
|
||||||
# truncated before emitting any JSON at all, which read as an empty
|
# standing between a swapped-in reasoning model and that cascade. If you do
|
||||||
# response. 1024 bounds the runaway while leaving the answer reachable.
|
# swap one in, note 256 was too tight — the trace consumed the whole budget
|
||||||
|
# and the model was truncated before emitting any JSON.
|
||||||
max_output_tokens: 1024
|
max_output_tokens: 1024
|
||||||
|
|
||||||
# Where routing lands when the classifier times out, errors, or returns
|
# Where routing lands when the classifier times out, errors, or returns
|
||||||
# something unparseable. A local model being slow should degrade routing,
|
# something unparseable. A local model being slow should degrade routing,
|
||||||
# not refuse the request — the caller is a coding agent that would rather
|
# not refuse the request — the caller is a coding agent that would rather
|
||||||
# have a mid-tier answer than a 502.
|
# have a mid-tier answer than a 502.
|
||||||
|
# Ceiling on the text handed to the classifier (head + tail, middle elided).
|
||||||
|
# It decides a category and a tier; it does not need the document, and
|
||||||
|
# feeding it one is harmful rather than merely wasteful. Measured on a ~20k
|
||||||
|
# token prompt: qwen3.5 spent 28.7s and returned empty (budget consumed by
|
||||||
|
# its reasoning trace), mistral-nemo spent 41.8s echoing the input back
|
||||||
|
# inside its JSON. Both land on source: "fallback" — the same answer an
|
||||||
|
# instant failure gives, after 30-40s of local inference.
|
||||||
|
#
|
||||||
|
# Nothing is lost: chat_completions measures the real conversation with
|
||||||
|
# estimate_prompt_tokens and takes the larger value, so required_context
|
||||||
|
# never depends on what the classifier saw. 0 disables clamping.
|
||||||
|
max_input_chars: 8000
|
||||||
|
|
||||||
fallback_tier: 2
|
fallback_tier: 2
|
||||||
fallback_category: general_chat
|
fallback_category: general_chat
|
||||||
response_format: "json" # ask Ollama to constrain output to valid JSON
|
response_format: "json" # ask Ollama to constrain output to valid JSON
|
||||||
@@ -247,5 +376,26 @@ dispatch_providers:
|
|||||||
api_key_env: "NEURALWATT_API_KEY"
|
api_key_env: "NEURALWATT_API_KEY"
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
# Whether to write a row to energy_observations for every completion. Off
|
||||||
|
# means no cost accounting, no reference sweep and no /outcome attribution,
|
||||||
|
# so leave it on unless you are debugging.
|
||||||
|
#
|
||||||
|
# There is no log_path. Nothing ever wrote a file -- the dispatcher prints to
|
||||||
|
# stderr and the systemd unit hands that to the journal (`journalctl --user
|
||||||
|
# -u llm-router -f`), so the setting named a destination that did not exist.
|
||||||
log_energy_observations: true
|
log_energy_observations: true
|
||||||
log_path: "router.log"
|
|
||||||
|
# debug | info | warning | error.
|
||||||
|
#
|
||||||
|
# info gives one line per request: what it was classified as, which model won,
|
||||||
|
# what it cost, how long each stage took. debug adds why -- every candidate
|
||||||
|
# that was dropped and by which filter, the ranking with scores, the
|
||||||
|
# classifier's raw reply. It logs no conversation text at any level; prompts
|
||||||
|
# here run 60k-150k tokens and the journal is on disk.
|
||||||
|
#
|
||||||
|
# LLM_ROUTER_LOG_LEVEL overrides this, so a running service can be turned up
|
||||||
|
# without editing a tracked file:
|
||||||
|
#
|
||||||
|
# systemctl --user edit llm-router # Environment="LLM_ROUTER_LOG_LEVEL=debug"
|
||||||
|
# systemctl --user restart llm-router
|
||||||
|
level: info
|
||||||
|
|||||||
@@ -22,11 +22,18 @@ environment, so the API key has to come from a file.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. The key. User units don't see your shell env, so .env is required.
|
# 1. The key. User units don't see your shell env, so .env is required.
|
||||||
cd ~/Sources/6krrt
|
cd /path/to/this/repo
|
||||||
echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
||||||
|
|
||||||
# 2. Install and start
|
# 2. Install, pointing the units at wherever you actually cloned this.
|
||||||
cp deploy/llm-router*.{service,timer} ~/.config/systemd/user/
|
# The shipped units say %h/llm-router; %h is systemd's expansion for your
|
||||||
|
# home directory, so only the part after it needs changing. Getting this
|
||||||
|
# wrong fails at start with status=200/CHDIR rather than anything obvious.
|
||||||
|
REPO=$(pwd)
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
for u in deploy/llm-router*.{service,timer}; do
|
||||||
|
sed "s|%h/llm-router|${REPO}|g" "$u" > ~/.config/systemd/user/"$(basename "$u")"
|
||||||
|
done
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable --now llm-router.service llm-router-poller.timer llm-router-seed.timer
|
systemctl --user enable --now llm-router.service llm-router-poller.timer llm-router-seed.timer
|
||||||
|
|
||||||
@@ -43,7 +50,9 @@ systemctl --user list-timers 'llm-router*'
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user status llm-router.service
|
systemctl --user status llm-router.service
|
||||||
journalctl --user -u llm-router.service -f # request log
|
journalctl --user -u 'llm-router*' -f # everything, live (quote the glob)
|
||||||
|
journalctl --user -u llm-router -f -o cat # the request log, message only
|
||||||
|
journalctl --user -u llm-router -p warning # fallbacks, retries, refusals
|
||||||
journalctl --user -u llm-router-poller.service # catalog refreshes
|
journalctl --user -u llm-router-poller.service # catalog refreshes
|
||||||
systemctl --user restart llm-router.service # after editing config.yaml
|
systemctl --user restart llm-router.service # after editing config.yaml
|
||||||
systemctl --user start llm-router-poller.service # force a refresh now
|
systemctl --user start llm-router-poller.service # force a refresh now
|
||||||
@@ -53,6 +62,41 @@ systemctl --user start llm-router-poller.service # force a refresh now
|
|||||||
restart. The catalog is read per-request, so a poller run takes effect
|
restart. The catalog is read per-request, so a poller run takes effect
|
||||||
immediately.
|
immediately.
|
||||||
|
|
||||||
|
### Turning up the logs
|
||||||
|
|
||||||
|
`logging.level` in config.yaml is the documented setting, but flipping it means
|
||||||
|
editing a tracked file. For a running service use a drop-in instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user edit llm-router # Environment="LLM_ROUTER_LOG_LEVEL=debug"
|
||||||
|
systemctl --user restart llm-router
|
||||||
|
```
|
||||||
|
|
||||||
|
`info` gives one `route` and one `dispatch` line per request — category, tier,
|
||||||
|
model chosen, cost, latency. `debug` adds every candidate that was dropped and
|
||||||
|
by which filter, plus the ranking with scores. No conversation text is logged
|
||||||
|
at any level.
|
||||||
|
|
||||||
|
Every line carries a trace id, and the `dispatch` line carries the provider's
|
||||||
|
completion id and the session fingerprint, both of which are columns in
|
||||||
|
`energy_observations`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl --user -u llm-router --grep ' id=r9116d9' # one request, all stages
|
||||||
|
journalctl --user -u llm-router --grep 'chatcmpl-abc123' # from a DB row back to its decision
|
||||||
|
```
|
||||||
|
|
||||||
|
Severity filtering works because the service prefixes its lines with journald
|
||||||
|
priorities when systemd owns its stderr (`SyslogLevelPrefix` is on by default).
|
||||||
|
A foreground `uvicorn` prints them clean, so the same binary is readable either
|
||||||
|
way.
|
||||||
|
|
||||||
|
**The oneshot units buffer.** `poller.py` and `seed_energy.py` print progress
|
||||||
|
with plain `print()`, and Python block-buffers stdout when it is not a
|
||||||
|
terminal, so their output arrives in one dump at exit rather than
|
||||||
|
progressively. Add `Environment="PYTHONUNBUFFERED=1"` to those units if you
|
||||||
|
want to watch a sweep as it runs.
|
||||||
|
|
||||||
## A note on the bind address
|
## A note on the bind address
|
||||||
|
|
||||||
`--host 127.0.0.1` is deliberate. The service holds a billable API key and
|
`--host 127.0.0.1` is deliberate. The service holds a billable API key and
|
||||||
@@ -61,10 +105,55 @@ your allowance. `ProtectHome=read-only` plus a `ReadWritePaths` exception for
|
|||||||
the repo limits the blast radius on the filesystem, but nothing limits spend.
|
the repo limits the blast radius on the filesystem, but nothing limits spend.
|
||||||
Putting this on a LAN address needs an auth layer first.
|
Putting this on a LAN address needs an auth layer first.
|
||||||
|
|
||||||
|
## Using an Ollama on another machine
|
||||||
|
|
||||||
|
The local LLM does the classifying; it does not have to be on the machine you
|
||||||
|
are typing on, and usually the GPU 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.
|
||||||
|
|
||||||
|
On the **serving** host (the one with the GPU):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/systemd/system/ollama.service.d
|
||||||
|
sudo cp deploy/ollama-over-vpn.conf \
|
||||||
|
/etc/systemd/system/ollama.service.d/override.conf
|
||||||
|
# set OLLAMA_HOST to that host's own VPN address — `ip -4 -o addr show`
|
||||||
|
sudo nano /etc/systemd/system/ollama.service.d/override.conf
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl restart ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
On the **client** host, in `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
classifier:
|
||||||
|
base_url: "http://<vpn-ip>:11434/v1"
|
||||||
|
verification:
|
||||||
|
base_url: "http://<vpn-ip>:11434" # same host, so `model` can stay null
|
||||||
|
```
|
||||||
|
|
||||||
|
Both must move together. `verification` speaks Ollama's native `/api/chat`
|
||||||
|
and used to derive its URL from the classifier's; it no longer does, so
|
||||||
|
pointing only the classifier across the tunnel leaves the verifier talking to
|
||||||
|
a `localhost` Ollama that may not exist. Config load refuses the combination
|
||||||
|
where `verification.model` is null and the two hosts differ, because that
|
||||||
|
failure is otherwise silent — the verifier 404s, catches it, and records no
|
||||||
|
sample while appearing to be enabled.
|
||||||
|
|
||||||
|
Bind Ollama to the **VPN address, not `0.0.0.0`**. It has no authentication of
|
||||||
|
any kind: anything that reaches the port can run inference, enumerate your
|
||||||
|
models and pull new ones. Same reasoning as the dispatcher's loopback bind
|
||||||
|
above.
|
||||||
|
|
||||||
|
A cloud endpoint works too — set `classifier.api_key_env` to the env var
|
||||||
|
holding its key. On a five-prompt comparison NeuralWatt's `deepseek-v4-flash`
|
||||||
|
classified in 1.02s mean against `qwen3.5`'s 11.58s on an RTX 6000. Local
|
||||||
|
inference is not free, it is unbilled.
|
||||||
|
|
||||||
## Pointing opencode at it
|
## Pointing opencode at it
|
||||||
|
|
||||||
The repo-local `opencode.json` sets this up already, so running `opencode`
|
The repo-local `opencode.json` sets this up already, so running `opencode`
|
||||||
from inside `~/Sources/6krrt` uses the router by default. To use it from
|
from inside a clone of this repo uses the router by default. To use it from
|
||||||
anywhere, merge the `provider.llm-router` block into
|
anywhere, merge the `provider.llm-router` block into
|
||||||
`~/.config/opencode/opencode.json` and set `"model": "llm-router/auto"`.
|
`~/.config/opencode/opencode.json` and set `"model": "llm-router/auto"`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Refresh the NeuralWatt model catalog and re-resolve tiers
|
Description=Refresh the NeuralWatt model catalog and re-resolve tiers
|
||||||
Documentation=file:%h/Sources/6krrt/CLAUDE.md
|
Documentation=file:%h/llm-router/CLAUDE.md
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
WorkingDirectory=%h/Sources/6krrt
|
WorkingDirectory=%h/llm-router
|
||||||
EnvironmentFile=%h/Sources/6krrt/.env
|
EnvironmentFile=%h/llm-router/.env
|
||||||
# poller.py refreshes the catalog; tier.py re-resolves tiers from it. Tiers
|
# poller.py refreshes the catalog; tier.py re-resolves tiers from it. Tiers
|
||||||
# are derived from cost and reasoning fields the poll may have changed, so
|
# are derived from cost and reasoning fields the poll may have changed, so
|
||||||
# they always run as a pair.
|
# they always run as a pair.
|
||||||
ExecStart=%h/Sources/6krrt/.venv/bin/python poller.py
|
ExecStart=%h/llm-router/.venv/bin/python poller.py
|
||||||
ExecStart=%h/Sources/6krrt/.venv/bin/python tier.py
|
ExecStart=%h/llm-router/.venv/bin/python tier.py
|
||||||
|
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
ReadWritePaths=%h/Sources/6krrt
|
ReadWritePaths=%h/llm-router
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Sample the reference workload to keep cost/eco scoring current
|
Description=Sample the reference workload to keep cost/eco scoring current
|
||||||
Documentation=file:%h/Sources/6krrt/CLAUDE.md
|
Documentation=file:%h/llm-router/CLAUDE.md
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
WorkingDirectory=%h/Sources/6krrt
|
WorkingDirectory=%h/llm-router
|
||||||
EnvironmentFile=%h/Sources/6krrt/.env
|
EnvironmentFile=%h/llm-router/.env
|
||||||
# Fewer samples per run than a manual sweep, because the point is coverage
|
# Fewer samples per run than a manual sweep, because the point is coverage
|
||||||
# across TIME rather than depth at one moment — see the timer.
|
# across TIME rather than depth at one moment — see the timer.
|
||||||
ExecStart=%h/Sources/6krrt/.venv/bin/python seed_energy.py --samples 3
|
ExecStart=%h/llm-router/.venv/bin/python seed_energy.py --samples 3
|
||||||
|
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
ReadWritePaths=%h/Sources/6krrt
|
ReadWritePaths=%h/llm-router
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Local LLM model router (FastAPI dispatcher)
|
Description=Local LLM model router (FastAPI dispatcher)
|
||||||
Documentation=file:%h/Sources/6krrt/CLAUDE.md
|
Documentation=file:%h/llm-router/CLAUDE.md
|
||||||
# The classifier talks to Ollama on localhost and the dispatcher talks to
|
# The classifier talks to Ollama on localhost and the dispatcher talks to
|
||||||
# NeuralWatt, so both loopback and real network need to be up. Ollama is a
|
# NeuralWatt, so both loopback and real network need to be up. Ollama is a
|
||||||
# system service and cannot be ordered against from a user unit, so a failed
|
# system service and cannot be ordered against from a user unit, so a failed
|
||||||
@@ -12,11 +12,11 @@ Wants=network-online.target
|
|||||||
Type=exec
|
Type=exec
|
||||||
# config.yaml, router.db and router.log are all referenced as relative paths,
|
# config.yaml, router.db and router.log are all referenced as relative paths,
|
||||||
# so this has to be the repo root.
|
# so this has to be the repo root.
|
||||||
WorkingDirectory=%h/Sources/6krrt
|
WorkingDirectory=%h/llm-router
|
||||||
# Holds NEURALWATT_API_KEY. Create it with:
|
# Holds NEURALWATT_API_KEY. Create it with:
|
||||||
# echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
# echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
||||||
EnvironmentFile=%h/Sources/6krrt/.env
|
EnvironmentFile=%h/llm-router/.env
|
||||||
ExecStart=%h/Sources/6krrt/.venv/bin/uvicorn dispatcher:app --host 127.0.0.1 --port 8080
|
ExecStart=%h/llm-router/.venv/bin/uvicorn dispatcher:app --host 127.0.0.1 --port 8080
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
@@ -28,7 +28,7 @@ PrivateTmp=true
|
|||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
# ...except the repo, which needs to be writable for router.db and router.log.
|
# ...except the repo, which needs to be writable for router.db and router.log.
|
||||||
ReadWritePaths=%h/Sources/6krrt
|
ReadWritePaths=%h/llm-router
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
41
deploy/ollama-over-vpn.conf
Normal file
41
deploy/ollama-over-vpn.conf
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Serve this host's Ollama to the other machines you work from.
|
||||||
|
#
|
||||||
|
# The premise of this router is that a local LLM classifies your task before a
|
||||||
|
# cloud model answers it. "Local" means your hardware, not necessarily the
|
||||||
|
# machine you are typing on — the box with the GPU usually is not the laptop.
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Ollama listens on 127.0.0.1:11434 by default, so that fails with
|
||||||
|
# connection-refused until this drop-in is applied on the SERVING host.
|
||||||
|
#
|
||||||
|
# Install (needs root — ollama.service is a system unit):
|
||||||
|
# sudo mkdir -p /etc/systemd/system/ollama.service.d
|
||||||
|
# sudo cp deploy/ollama-over-vpn.conf \
|
||||||
|
# /etc/systemd/system/ollama.service.d/override.conf
|
||||||
|
# sudo nano /etc/systemd/system/ollama.service.d/override.conf # set the address
|
||||||
|
# sudo systemctl daemon-reload && sudo systemctl restart ollama
|
||||||
|
#
|
||||||
|
# Then on the client machine, in config.yaml:
|
||||||
|
# classifier.base_url: http://<vpn-ip-of-this-host>:11434/v1
|
||||||
|
# verification.base_url: http://<vpn-ip-of-this-host>:11434
|
||||||
|
#
|
||||||
|
# Bind to the VPN address, NOT 0.0.0.0. Ollama has no authentication of any
|
||||||
|
# kind: anything that can reach the port can run inference, enumerate your
|
||||||
|
# models and pull new ones. 0.0.0.0 also publishes it on whatever cafe or
|
||||||
|
# hotel wifi the laptop is sitting on. Same reasoning that keeps the
|
||||||
|
# dispatcher itself on loopback.
|
||||||
|
#
|
||||||
|
# Replace with this host's own VPN address — 10.x.x.x for WireGuard,
|
||||||
|
# 100.x.x.x for Tailscale. `ip -4 -o addr show` will tell you.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment="OLLAMA_HOST=10.0.0.1:11434"
|
||||||
|
|
||||||
|
# Keep the model resident between requests. Without this, the first
|
||||||
|
# classification after an idle gap pays a cold load — measured at 43s, which
|
||||||
|
# exceeds even the 120s ceiling once the SDK's retries are counted. Serving a
|
||||||
|
# laptop makes this matter more, not less: requests arrive in bursts separated
|
||||||
|
# by long gaps, which is exactly the pattern that keeps evicting the model.
|
||||||
|
Environment="OLLAMA_KEEP_ALIVE=24h"
|
||||||
785
dispatcher.py
785
dispatcher.py
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -96,14 +97,47 @@ def strip_fences(text: str) -> str:
|
|||||||
return code.strip()
|
return code.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_check_results(stdout: str, marker: str) -> dict[int, bool]:
|
||||||
|
"""Read the harness's own verdicts out of a script's stdout.
|
||||||
|
|
||||||
|
Keyed by check INDEX rather than counted, which bounds the total at the
|
||||||
|
number of checks even if a line somehow repeats, and drops anything that
|
||||||
|
is not exactly one of the harness's own three-token lines.
|
||||||
|
"""
|
||||||
|
results: dict[int, bool] = {}
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 3 or parts[0] != marker or parts[2] not in ("PASS", "FAIL"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
index = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
results[index] = parts[2] == "PASS"
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def score_code(model_output: str, checks: list[str]) -> tuple[float, str]:
|
def score_code(model_output: str, checks: list[str]) -> tuple[float, str]:
|
||||||
"""Execute the model's code and eval each check against it.
|
"""Execute the model's code and eval each check against it.
|
||||||
|
|
||||||
Returns (fraction of checks passing, detail). Partial credit is
|
Returns (fraction of checks passing, detail). Partial credit is
|
||||||
deliberate: a function correct on three of four cases is genuinely better
|
deliberate: a function correct on three of four cases is genuinely better
|
||||||
than one that fails everything, and a binary score throws that away.
|
than one that fails everything, and a binary score throws that away.
|
||||||
|
|
||||||
|
The verdict lines carry a per-run nonce because this harness runs the
|
||||||
|
model's code as ``__main__``, and models routinely append a demo block. One
|
||||||
|
that prints the word PASS -- `print("self-test:", f(2) == 4 and "PASS")` --
|
||||||
|
was counted as a passing check by the old substring count, and a two-check
|
||||||
|
task scored 1.50. The model cannot predict the nonce, so it cannot vote on
|
||||||
|
its own work. That was the fourth harness bug here to score the rig rather
|
||||||
|
than the model; this one inflated instead of deflating, which is why it
|
||||||
|
went unnoticed.
|
||||||
"""
|
"""
|
||||||
|
if not checks:
|
||||||
|
return 0.0, "no checks"
|
||||||
|
|
||||||
code = strip_fences(model_output)
|
code = strip_fences(model_output)
|
||||||
|
marker = f"CHECK-{secrets.token_hex(8)}"
|
||||||
harness = (
|
harness = (
|
||||||
HARNESS_PREAMBLE
|
HARNESS_PREAMBLE
|
||||||
+ code
|
+ code
|
||||||
@@ -115,7 +149,7 @@ def score_code(model_output: str, checks: list[str]) -> tuple[float, str]:
|
|||||||
+ " _ok = bool(eval(_c))\n"
|
+ " _ok = bool(eval(_c))\n"
|
||||||
+ " except Exception:\n"
|
+ " except Exception:\n"
|
||||||
+ " _ok = False\n"
|
+ " _ok = False\n"
|
||||||
+ " print('CHECK', _i, 'PASS' if _ok else 'FAIL')\n"
|
+ f" print({marker!r}, _i, 'PASS' if _ok else 'FAIL')\n"
|
||||||
)
|
)
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
script = Path(tmp) / "harness.py"
|
script = Path(tmp) / "harness.py"
|
||||||
@@ -131,9 +165,8 @@ def score_code(model_output: str, checks: list[str]) -> tuple[float, str]:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return 0.0, "timeout"
|
return 0.0, "timeout"
|
||||||
|
|
||||||
passed = proc.stdout.count("PASS")
|
results = parse_check_results(proc.stdout, marker)
|
||||||
if not checks:
|
passed = sum(1 for i in range(len(checks)) if results.get(i))
|
||||||
return 0.0, "no checks"
|
|
||||||
if passed == 0 and proc.returncode != 0:
|
if passed == 0 and proc.returncode != 0:
|
||||||
# Distinguish "wrote broken code" from "wrote code that fails cases"
|
# Distinguish "wrote broken code" from "wrote code that fails cases"
|
||||||
first_line = (proc.stderr or "").strip().splitlines()[-1:] or [""]
|
first_line = (proc.stderr or "").strip().splitlines()[-1:] or [""]
|
||||||
@@ -378,21 +411,32 @@ def eval_identities(conn: sqlite3.Connection, cfg: RouterConfig) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
ALTERNATE_JUDGE = "qwen3.6-35b"
|
# Tried in order when the configured judge belongs to the model under test.
|
||||||
|
# A LIST rather than one name, because a single alternate can itself be that
|
||||||
|
# model: with `--judge-model qwen3.6-35b`, the old guard fired on qwen3.6-35b
|
||||||
|
# and handed back qwen3.6-35b -- the exact conflict it exists to prevent, and
|
||||||
|
# silent, because self-judged scores look like any other judge output.
|
||||||
|
ALTERNATE_JUDGES = ("kimi-k3", "qwen3.6-35b", "gemma-4-31b")
|
||||||
|
|
||||||
|
|
||||||
def judge_for(model_id: str, default_judge: str) -> str:
|
def judge_for(model_id: str, default_judge: str) -> Optional[str]:
|
||||||
"""Pick a judge that is not the model being judged.
|
"""Pick a judge that is not the model being judged, or None.
|
||||||
|
|
||||||
A model scoring its own prose is a known bias, and the default judge is
|
A model scoring its own prose is a known bias, and the default judge is
|
||||||
itself in the evaluated set. Swapping in an alternate for its own family
|
itself in the evaluated set. Matching is on the FAMILY, so a -fast row does
|
||||||
costs nothing and removes the obvious conflict.
|
not get judged by its reasoning-on sibling either -- same weights.
|
||||||
|
|
||||||
|
Returns None when every candidate shares the model's family, so the caller
|
||||||
|
skips the task. No sample beats a self-graded one, which is the same rule
|
||||||
|
score_judge already follows for a judge that returned nothing usable.
|
||||||
"""
|
"""
|
||||||
from poller import parse_base_model_id
|
from poller import parse_base_model_id
|
||||||
|
|
||||||
if parse_base_model_id(model_id) == parse_base_model_id(default_judge):
|
family = parse_base_model_id(model_id)
|
||||||
return ALTERNATE_JUDGE
|
for candidate in (default_judge, *ALTERNATE_JUDGES):
|
||||||
return default_judge
|
if parse_base_model_id(candidate) != family:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -426,11 +470,11 @@ def main() -> int:
|
|||||||
print("nothing to run", file=sys.stderr)
|
print("nothing to run", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
judged = sum(1 for t in tasks if t["kind"] == "judge")
|
judge_tasks = sum(1 for t in tasks if t["kind"] == "judge")
|
||||||
print(
|
print(
|
||||||
f"{len(identities)} models x {len(tasks)} tasks = "
|
f"{len(identities)} models x {len(tasks)} tasks = "
|
||||||
f"{len(identities) * len(tasks)} calls"
|
f"{len(identities) * len(tasks)} calls"
|
||||||
+ (f" (+{len(identities) * judged} judge calls)" if judged else "")
|
+ (f" (+{len(identities) * judge_tasks} judge calls)" if judge_tasks else "")
|
||||||
)
|
)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
for i in identities:
|
for i in identities:
|
||||||
@@ -482,10 +526,17 @@ def main() -> int:
|
|||||||
elif kind == "tool":
|
elif kind == "tool":
|
||||||
score, detail = score_tool(tool_calls, task)
|
score, detail = score_tool(tool_calls, task)
|
||||||
elif kind == "judge":
|
elif kind == "judge":
|
||||||
|
judge_model = judge_for(model_id, args.judge_model)
|
||||||
|
if judge_model is None:
|
||||||
|
print(
|
||||||
|
f" {task['id']:30s} --- no judge outside "
|
||||||
|
f"{model_id}'s family, skipped"
|
||||||
|
)
|
||||||
|
continue
|
||||||
judged = score_judge(
|
judged = score_judge(
|
||||||
settings.base_url,
|
settings.base_url,
|
||||||
api_key,
|
api_key,
|
||||||
judge_for(model_id, args.judge_model),
|
judge_model,
|
||||||
task,
|
task,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|||||||
277
logs.py
Normal file
277
logs.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Structured logging for the dispatcher.
|
||||||
|
|
||||||
|
The service had no logger. It had four ``print(..., file=sys.stderr)`` calls,
|
||||||
|
all of them failure paths, so a request that WORKED said nothing at all — not
|
||||||
|
the category, the tier, the candidates, the model chosen, the cost, or the
|
||||||
|
latency. Watching a live agent session showed only uvicorn's access line, which
|
||||||
|
does not even name the model that served it.
|
||||||
|
|
||||||
|
Three things this module provides, in order of how much they matter:
|
||||||
|
|
||||||
|
**A trace id per request.** One request touches classification, filtering,
|
||||||
|
ranking, dispatch, verification and possibly a retry, and until now nothing
|
||||||
|
tied those together. The id is carried in a ContextVar so helpers read it
|
||||||
|
without every signature growing a parameter, and it is printed on every line.
|
||||||
|
|
||||||
|
**logfmt, not prose.** ``route id=r7f3a91 cat=coding_general pick=...`` reads
|
||||||
|
fine in ``journalctl`` and greps without a JSON parser. Prose reads better once
|
||||||
|
and aggregates never.
|
||||||
|
|
||||||
|
**Real journald priorities.** systemd strips a ``<N>`` prefix off a log line and
|
||||||
|
records the message at that priority (``SyslogLevelPrefix`` defaults to true),
|
||||||
|
which is what makes ``journalctl -p warning`` mean something. Without it every
|
||||||
|
line lands at PRIORITY 6 and severity cannot be filtered at all.
|
||||||
|
|
||||||
|
The prefix is emitted ONLY when ``$JOURNAL_STREAM`` is set, which systemd
|
||||||
|
exports when it owns our stderr and a terminal does not — so running uvicorn in
|
||||||
|
a foreground shell prints clean lines rather than a literal ``<6>`` on each one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
from typing import Any, Optional, TextIO
|
||||||
|
|
||||||
|
LOGGER_NAME = "llm_router"
|
||||||
|
LEVEL_ENV = "LLM_ROUTER_LOG_LEVEL"
|
||||||
|
|
||||||
|
log = logging.getLogger(LOGGER_NAME)
|
||||||
|
|
||||||
|
LEVELS = {
|
||||||
|
"debug": logging.DEBUG,
|
||||||
|
"info": logging.INFO,
|
||||||
|
"warning": logging.WARNING,
|
||||||
|
"error": logging.ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
# syslog priorities, which is what journald speaks. 5 (notice) and 1 (alert)
|
||||||
|
# are unused: nothing here is between info and warning, or above error.
|
||||||
|
PRIORITIES = {
|
||||||
|
logging.DEBUG: 7,
|
||||||
|
logging.INFO: 6,
|
||||||
|
logging.WARNING: 4,
|
||||||
|
logging.ERROR: 3,
|
||||||
|
logging.CRITICAL: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
NO_TRACE = "-"
|
||||||
|
|
||||||
|
_trace: contextvars.ContextVar[str] = contextvars.ContextVar("trace_id", default=NO_TRACE)
|
||||||
|
|
||||||
|
|
||||||
|
# --- trace ids ------------------------------------------------------------
|
||||||
|
|
||||||
|
def new_trace() -> str:
|
||||||
|
"""Start a new request trace and return its id."""
|
||||||
|
trace_id = "r" + secrets.token_hex(3)
|
||||||
|
_trace.set(trace_id)
|
||||||
|
return trace_id
|
||||||
|
|
||||||
|
|
||||||
|
def current_trace() -> str:
|
||||||
|
return _trace.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_trace(trace_id: str) -> None:
|
||||||
|
"""Adopt an existing trace id in this context.
|
||||||
|
|
||||||
|
Needed on the streaming path. A StreamingResponse's generator is iterated
|
||||||
|
from a different context than the endpoint that built it, so the ContextVar
|
||||||
|
set in the endpoint is NOT visible inside the generator — it must capture
|
||||||
|
the id as a local and re-set it here, or every streamed request logs no id
|
||||||
|
at all. That is the path all agent traffic takes.
|
||||||
|
"""
|
||||||
|
_trace.set(trace_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- rendering ------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_value(value: Any) -> str:
|
||||||
|
"""One logfmt value: unambiguous, single-line, cheap to read."""
|
||||||
|
if value is None:
|
||||||
|
return "-"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "1" if value else "0"
|
||||||
|
if isinstance(value, float):
|
||||||
|
# Six significant figures. Enough for microdollars and kWh in
|
||||||
|
# scientific notation, without 0.0007830000000000001.
|
||||||
|
return f"{value:.6g}"
|
||||||
|
text = str(value)
|
||||||
|
if text == "":
|
||||||
|
return '""'
|
||||||
|
if any(c in text for c in ' "\n\t='):
|
||||||
|
text = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
text = text.replace("\n", " ").replace("\t", " ")
|
||||||
|
return f'"{text}"'
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def render(name: str, fields: dict[str, Any], trace_id: Optional[str] = None) -> str:
|
||||||
|
parts = [name, f"id={trace_id or current_trace()}"]
|
||||||
|
parts += [f"{key}={render_value(value)}" for key, value in fields.items()]
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(
|
||||||
|
level: int, name: str, fields: dict[str, Any], trace_id: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""Emit one logfmt line, if this level is enabled.
|
||||||
|
|
||||||
|
The level check comes first so DEBUG formatting costs nothing when DEBUG is
|
||||||
|
off — this runs on every request.
|
||||||
|
"""
|
||||||
|
if log.isEnabledFor(level):
|
||||||
|
log.log(level, "%s", render(name, fields, trace_id))
|
||||||
|
|
||||||
|
|
||||||
|
def event(level: int, name: str, **fields: Any) -> None:
|
||||||
|
_emit(level, name, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def debug(name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.DEBUG, name, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def info(name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.INFO, name, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def warning(name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.WARNING, name, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def error(name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.ERROR, name, fields)
|
||||||
|
|
||||||
|
|
||||||
|
class Bound:
|
||||||
|
"""An emitter pinned to one trace id, for code the ContextVar cannot reach.
|
||||||
|
|
||||||
|
A StreamingResponse's generator is resumed by starlette through a
|
||||||
|
threadpool, and every ``next()`` runs in a FRESH COPY of the caller's
|
||||||
|
context — so a trace id set on one resumption is gone by the next, and the
|
||||||
|
``finally`` block that logs the dispatch line sees nothing. Measured: the
|
||||||
|
streamed dispatch line logged ``id=-`` until this existed.
|
||||||
|
|
||||||
|
Carrying the id explicitly is the only thing that survives that, and
|
||||||
|
streaming is the path all agent traffic takes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("trace_id",)
|
||||||
|
|
||||||
|
def __init__(self, trace_id: str):
|
||||||
|
self.trace_id = trace_id
|
||||||
|
|
||||||
|
def debug(self, name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.DEBUG, name, fields, self.trace_id)
|
||||||
|
|
||||||
|
def info(self, name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.INFO, name, fields, self.trace_id)
|
||||||
|
|
||||||
|
def warning(self, name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.WARNING, name, fields, self.trace_id)
|
||||||
|
|
||||||
|
def error(self, name: str, **fields: Any) -> None:
|
||||||
|
_emit(logging.ERROR, name, fields, self.trace_id)
|
||||||
|
|
||||||
|
|
||||||
|
def bind(trace_id: Optional[str] = None) -> Bound:
|
||||||
|
"""Pin an emitter to a trace id, defaulting to the current one."""
|
||||||
|
return Bound(trace_id or current_trace())
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_for_debug() -> bool:
|
||||||
|
"""For callers that must do real work to produce debug fields."""
|
||||||
|
return log.isEnabledFor(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
# --- configuration --------------------------------------------------------
|
||||||
|
|
||||||
|
class PriorityFormatter(logging.Formatter):
|
||||||
|
"""Plain message, optionally prefixed with a journald priority."""
|
||||||
|
|
||||||
|
def __init__(self, prefix: bool):
|
||||||
|
super().__init__("%(message)s")
|
||||||
|
self.prefix = prefix
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
message = super().format(record)
|
||||||
|
if not self.prefix:
|
||||||
|
return message
|
||||||
|
return f"<{PRIORITIES.get(record.levelno, 6)}>{message}"
|
||||||
|
|
||||||
|
|
||||||
|
def under_journald(stream: Optional[TextIO] = None) -> bool:
|
||||||
|
"""Whether this stream really is the journal.
|
||||||
|
|
||||||
|
systemd sets $JOURNAL_STREAM to the "device:inode" of the stream it
|
||||||
|
connected, and the variable is INHERITED by every child — including one
|
||||||
|
whose stderr has since been redirected to a file or a pipe. Presence alone
|
||||||
|
is therefore a false positive, and it fired immediately: a foreground
|
||||||
|
uvicorn started from a systemd-managed session, with its output redirected
|
||||||
|
to a file, wrote a literal <7> on every line.
|
||||||
|
|
||||||
|
Comparing the value against the actual fd is what systemd documents, and it
|
||||||
|
is the only check that answers the real question.
|
||||||
|
"""
|
||||||
|
expected = os.environ.get("JOURNAL_STREAM")
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
device, _, inode = expected.partition(":")
|
||||||
|
try:
|
||||||
|
stat = os.fstat((stream if stream is not None else sys.stderr).fileno())
|
||||||
|
return stat.st_dev == int(device) and stat.st_ino == int(inode)
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
# No fileno at all (a StringIO under test), or an unparseable value.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_level(configured: str) -> int:
|
||||||
|
"""The env var wins, so a live service can be debugged without a repo edit.
|
||||||
|
|
||||||
|
config.yaml is the documented home of the setting -- every knob belongs
|
||||||
|
there -- but flipping it means editing a tracked file and leaving a stray
|
||||||
|
diff. LLM_ROUTER_LOG_LEVEL goes in a systemd drop-in instead.
|
||||||
|
"""
|
||||||
|
override = os.environ.get(LEVEL_ENV)
|
||||||
|
if override:
|
||||||
|
level = LEVELS.get(override.strip().lower())
|
||||||
|
if level is not None:
|
||||||
|
return level
|
||||||
|
print(
|
||||||
|
f"{LEVEL_ENV}={override!r} is not one of {sorted(LEVELS)}; "
|
||||||
|
f"falling back to logging.level={configured!r}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return LEVELS.get((configured or "info").strip().lower(), logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
level: str = "info",
|
||||||
|
*,
|
||||||
|
stream: Optional[TextIO] = None,
|
||||||
|
journald: Optional[bool] = None,
|
||||||
|
) -> logging.Logger:
|
||||||
|
"""Install the one handler this logger gets. Idempotent.
|
||||||
|
|
||||||
|
``propagate`` is off: uvicorn configures its own loggers and a root handler
|
||||||
|
installed by anything else would print every line a second time.
|
||||||
|
"""
|
||||||
|
resolved = resolve_level(level)
|
||||||
|
log.setLevel(resolved)
|
||||||
|
log.propagate = False
|
||||||
|
for existing in list(log.handlers):
|
||||||
|
log.removeHandler(existing)
|
||||||
|
|
||||||
|
target = stream if stream is not None else sys.stderr
|
||||||
|
handler = logging.StreamHandler(target)
|
||||||
|
handler.setLevel(resolved)
|
||||||
|
handler.setFormatter(
|
||||||
|
PriorityFormatter(under_journald(target) if journald is None else journald)
|
||||||
|
)
|
||||||
|
log.addHandler(handler)
|
||||||
|
return log
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
"limit": {
|
"limit": {
|
||||||
"context": 782324,
|
"context": 782324,
|
||||||
"output": 16384
|
"output": 16384
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auto:batch": {
|
"auto:batch": {
|
||||||
@@ -22,6 +28,77 @@
|
|||||||
"limit": {
|
"limit": {
|
||||||
"context": 782324,
|
"context": 782324,
|
||||||
"output": 16384
|
"output": 16384
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deepseek-v4-flash": {
|
||||||
|
"name": "Deepseek V4 Flash (pin)",
|
||||||
|
"limit": {
|
||||||
|
"context": 1048560,
|
||||||
|
"output": 16384
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gemma-4-31b": {
|
||||||
|
"name": "Gemma 4 31B (pin)",
|
||||||
|
"limit": {
|
||||||
|
"context": 262128,
|
||||||
|
"output": 16384
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"kimi-k2.7-code": {
|
||||||
|
"name": "Kimi K2.7 Code (pin)",
|
||||||
|
"limit": {
|
||||||
|
"context": 262128,
|
||||||
|
"output": 16384
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"kimi-k3": {
|
||||||
|
"name": "Kimi K3 (pin)",
|
||||||
|
"limit": {
|
||||||
|
"context": 1048560,
|
||||||
|
"output": 16384
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"qwen3.6-35b": {
|
||||||
|
"name": "Qwen 3.6 35B (pin)",
|
||||||
|
"limit": {
|
||||||
|
"context": 131056,
|
||||||
|
"output": 16384
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
poller.py
24
poller.py
@@ -127,12 +127,34 @@ class ModelRow:
|
|||||||
deprecated: bool
|
deprecated: bool
|
||||||
|
|
||||||
def effective_context_window(self, cfg: RouterConfig) -> Optional[int]:
|
def effective_context_window(self, cfg: RouterConfig) -> Optional[int]:
|
||||||
|
"""Usable context, after the safety factor and an output reserve.
|
||||||
|
|
||||||
|
``context.per_model_overrides`` wins where it is set, which is what it
|
||||||
|
is for: the global factor is a guess that has to hold for the whole
|
||||||
|
catalog, while a row someone has actually measured deserves its own
|
||||||
|
number.
|
||||||
|
|
||||||
|
Compared with ``is not None`` rather than ``or``, so an override of 0
|
||||||
|
reserve tokens means zero rather than silently falling through to the
|
||||||
|
default. Present-but-falsy is a trap this project's own eval set tests
|
||||||
|
models on; the router should not walk into it.
|
||||||
|
"""
|
||||||
if not self.context_window:
|
if not self.context_window:
|
||||||
return None
|
return None
|
||||||
|
override = cfg.context.per_model_overrides.get(self.model_id)
|
||||||
|
|
||||||
|
factor = cfg.context.safety_factor
|
||||||
|
if override is not None and override.safety_factor is not None:
|
||||||
|
factor = override.safety_factor
|
||||||
|
|
||||||
# 11 of 19 catalog rows report no max_output_tokens, so the configured
|
# 11 of 19 catalog rows report no max_output_tokens, so the configured
|
||||||
# reserve carries most of the catalog.
|
# reserve carries most of the catalog.
|
||||||
|
if override is not None and override.output_reserve_tokens is not None:
|
||||||
|
reserve = override.output_reserve_tokens
|
||||||
|
else:
|
||||||
reserve = self.max_output_tokens or cfg.context.default_output_reserve_tokens
|
reserve = self.max_output_tokens or cfg.context.default_output_reserve_tokens
|
||||||
usable = int(self.context_window * cfg.context.safety_factor) - reserve
|
|
||||||
|
usable = int(self.context_window * factor) - reserve
|
||||||
return max(usable, 0)
|
return max(usable, 0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,68 @@ def _now() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_columns(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add a column an older router.db predates. Idempotent, and cheap.
|
||||||
|
|
||||||
|
schema.sql is CREATE TABLE IF NOT EXISTS, so it defines a NEW database and
|
||||||
|
silently does nothing to an existing one. Anything added later therefore
|
||||||
|
needs this, or the first write against a database created last week fails
|
||||||
|
with "no such column".
|
||||||
|
"""
|
||||||
|
columns = {row[1] for row in conn.execute("PRAGMA table_info(proficiency)")}
|
||||||
|
if "inherited_from" not in columns:
|
||||||
|
conn.execute("ALTER TABLE proficiency ADD COLUMN inherited_from TEXT")
|
||||||
|
conn.commit()
|
||||||
|
_backfill_inherited(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_inherited(conn: sqlite3.Connection) -> None:
|
||||||
|
"""One-time: mark rows this harness could not have measured directly.
|
||||||
|
|
||||||
|
ADD COLUMN gives every existing row NULL, which reads as "measured here"
|
||||||
|
and would leave exactly the rows this provenance was added for frozen
|
||||||
|
forever -- the migration would ship the fix and none of the repair.
|
||||||
|
|
||||||
|
Provenance that was never recorded cannot be recovered in general. It can
|
||||||
|
be for the rows that matter, and not by guessing: ``eval_identities``
|
||||||
|
selects ``latency_class='standard'`` rows, plus flex rows that have NO
|
||||||
|
standard equivalent. So a flex row WITH a standard equivalent was never a
|
||||||
|
candidate for direct evaluation, whatever its sample count says. That is
|
||||||
|
the harness's own selection rule read backwards.
|
||||||
|
|
||||||
|
Anything else keeps NULL, which is the safe direction: it means "do not
|
||||||
|
overwrite", so a real measurement is never lost to this.
|
||||||
|
"""
|
||||||
|
pairs = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT v.model_id, v.provider, s.model_id
|
||||||
|
FROM models v
|
||||||
|
JOIN models s
|
||||||
|
ON s.base_model_id = v.base_model_id
|
||||||
|
AND s.provider = v.provider
|
||||||
|
AND s.reasoning_mode = v.reasoning_mode
|
||||||
|
AND s.context_variant = v.context_variant
|
||||||
|
WHERE v.latency_class = 'flex' AND s.latency_class = 'standard'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for variant_id, provider, source_id in pairs:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE proficiency SET inherited_from = ?
|
||||||
|
WHERE model_id = ? AND provider = ? AND inherited_from IS NULL
|
||||||
|
""",
|
||||||
|
(source_id, variant_id, provider),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _read_row(
|
def _read_row(
|
||||||
conn: sqlite3.Connection, model_id: str, provider: str, category: str
|
conn: sqlite3.Connection, model_id: str, provider: str, category: str
|
||||||
) -> Optional[sqlite3.Row]:
|
) -> Optional[sqlite3.Row]:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT leaderboard_score, self_eval_score, self_eval_samples
|
SELECT leaderboard_score, self_eval_score, self_eval_samples, inherited_from
|
||||||
FROM proficiency
|
FROM proficiency
|
||||||
WHERE model_id = ? AND provider = ? AND category = ?
|
WHERE model_id = ? AND provider = ? AND category = ?
|
||||||
""",
|
""",
|
||||||
@@ -46,7 +101,9 @@ def _write(
|
|||||||
leaderboard_score: Optional[float],
|
leaderboard_score: Optional[float],
|
||||||
self_eval_score: Optional[float],
|
self_eval_score: Optional[float],
|
||||||
self_eval_samples: int,
|
self_eval_samples: int,
|
||||||
|
inherited_from: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
ensure_columns(conn)
|
||||||
blended, source = blend(
|
blended, source = blend(
|
||||||
leaderboard_score,
|
leaderboard_score,
|
||||||
self_eval_score,
|
self_eval_score,
|
||||||
@@ -59,14 +116,16 @@ def _write(
|
|||||||
"""
|
"""
|
||||||
INSERT INTO proficiency (
|
INSERT INTO proficiency (
|
||||||
model_id, provider, category, leaderboard_score,
|
model_id, provider, category, leaderboard_score,
|
||||||
self_eval_score, self_eval_samples, blended_score, source, last_updated
|
self_eval_score, self_eval_samples, blended_score, source,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
inherited_from, last_updated
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(model_id, provider, category) DO UPDATE SET
|
ON CONFLICT(model_id, provider, category) DO UPDATE SET
|
||||||
leaderboard_score = excluded.leaderboard_score,
|
leaderboard_score = excluded.leaderboard_score,
|
||||||
self_eval_score = excluded.self_eval_score,
|
self_eval_score = excluded.self_eval_score,
|
||||||
self_eval_samples = excluded.self_eval_samples,
|
self_eval_samples = excluded.self_eval_samples,
|
||||||
blended_score = excluded.blended_score,
|
blended_score = excluded.blended_score,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
|
inherited_from = excluded.inherited_from,
|
||||||
last_updated = excluded.last_updated
|
last_updated = excluded.last_updated
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -78,6 +137,7 @@ def _write(
|
|||||||
self_eval_samples,
|
self_eval_samples,
|
||||||
blended,
|
blended,
|
||||||
source,
|
source,
|
||||||
|
inherited_from,
|
||||||
_now(),
|
_now(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -102,6 +162,8 @@ def set_leaderboard(
|
|||||||
score,
|
score,
|
||||||
existing["self_eval_score"] if existing else None,
|
existing["self_eval_score"] if existing else None,
|
||||||
existing["self_eval_samples"] if existing else 0,
|
existing["self_eval_samples"] if existing else 0,
|
||||||
|
# A prior says nothing about where the self-eval half came from.
|
||||||
|
existing["inherited_from"] if existing else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +182,14 @@ def add_self_eval(
|
|||||||
``self_eval_samples`` keeps meaning "how much evidence stands behind
|
``self_eval_samples`` keeps meaning "how much evidence stands behind
|
||||||
this", which is what the blending threshold is gating on.
|
this", which is what the blending threshold is gating on.
|
||||||
"""
|
"""
|
||||||
|
# Clamped at the one place every write passes through, which is what this
|
||||||
|
# module exists to be. score_judge already bounded its output; score_code
|
||||||
|
# did not, and a harness bug let a model's own demo output push a two-check
|
||||||
|
# task to 1.50. A blended_score above 1.0 raises `best` in
|
||||||
|
# rank_candidates and shifts every other candidate's quality band, so the
|
||||||
|
# damage is not confined to the row that carries the bad number.
|
||||||
|
scores = [min(1.0, max(0.0, s)) for s in scores]
|
||||||
|
|
||||||
existing = _read_row(conn, model_id, provider, category)
|
existing = _read_row(conn, model_id, provider, category)
|
||||||
prev_score = existing["self_eval_score"] if existing else None
|
prev_score = existing["self_eval_score"] if existing else None
|
||||||
prev_samples = existing["self_eval_samples"] if existing else 0
|
prev_samples = existing["self_eval_samples"] if existing else 0
|
||||||
@@ -150,8 +220,15 @@ def propagate_to_variants(
|
|||||||
alone. Inheriting across those would attribute reasoning-on quality to a
|
alone. Inheriting across those would attribute reasoning-on quality to a
|
||||||
reasoning-off row.
|
reasoning-off row.
|
||||||
|
|
||||||
Anything already measured directly keeps its own scores; inheritance is
|
Anything measured DIRECTLY keeps its own scores; inheritance is the
|
||||||
the fallback, never an overwrite.
|
fallback, never an overwrite. A row that was inherited before is refreshed,
|
||||||
|
which needs ``inherited_from`` to tell the two apart -- both carry
|
||||||
|
self_eval_samples > 0, so the old "samples > 0 means leave it alone" test
|
||||||
|
could not distinguish them and froze every variant at its first
|
||||||
|
inheritance. Observed: kimi-k3 moved from 0.85 (n=2) to 0.957 (n=7) while
|
||||||
|
kimi-k3-flex sat at 0.85 with a week-old timestamp, and the run reported
|
||||||
|
"propagated 0 inherited rows". Flex rows serve `auto:batch`, so those
|
||||||
|
requests were ranking on scores their family had left behind.
|
||||||
|
|
||||||
Returns the number of rows written.
|
Returns the number of rows written.
|
||||||
"""
|
"""
|
||||||
@@ -184,8 +261,13 @@ def propagate_to_variants(
|
|||||||
for variant in variants:
|
for variant in variants:
|
||||||
for row in source_rows:
|
for row in source_rows:
|
||||||
existing = _read_row(conn, variant["model_id"], provider, row["category"])
|
existing = _read_row(conn, variant["model_id"], provider, row["category"])
|
||||||
if existing and (existing["self_eval_samples"] or 0) > 0:
|
measured_here = (
|
||||||
continue # measured directly; do not overwrite with the family's
|
existing is not None
|
||||||
|
and (existing["self_eval_samples"] or 0) > 0
|
||||||
|
and not existing["inherited_from"]
|
||||||
|
)
|
||||||
|
if measured_here:
|
||||||
|
continue # its own measurement outranks the family's
|
||||||
_write(
|
_write(
|
||||||
conn,
|
conn,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -195,6 +277,7 @@ def propagate_to_variants(
|
|||||||
row["leaderboard_score"],
|
row["leaderboard_score"],
|
||||||
row["self_eval_score"],
|
row["self_eval_score"],
|
||||||
row["self_eval_samples"],
|
row["self_eval_samples"],
|
||||||
|
source_model_id,
|
||||||
)
|
)
|
||||||
written += 1
|
written += 1
|
||||||
return written
|
return written
|
||||||
|
|||||||
116
routing.py
116
routing.py
@@ -10,8 +10,10 @@ Two stages, in order:
|
|||||||
that cannot hold the context, is below the required tier, is stale, or is
|
that cannot hold the context, is below the required tier, is stale, or is
|
||||||
not actually reachable by this account is not a cost tradeoff to be
|
not actually reachable by this account is not a cost tradeoff to be
|
||||||
weighed; it is not a candidate at all (design doc §3.2, §4).
|
weighed; it is not a candidate at all (design doc §3.2, §4).
|
||||||
2. **Weighted scoring** (``rank_candidates``) — order what survives by
|
2. **Ranking** (``rank_candidates``) — order what survives by quality, with
|
||||||
``w_cost*cost + w_eco*eco + w_prof*proficiency``.
|
cost breaking ties inside ``quality_tolerance``. NOT a weighted blend: that
|
||||||
|
was measured to be nearly inert, since moving the cost weight from 0.4 to
|
||||||
|
zero changed the winner in only 2 of 6 categories.
|
||||||
|
|
||||||
The flex filter is the one that is easy to get wrong. NeuralWatt's ``-flex``
|
The flex filter is the one that is easy to get wrong. NeuralWatt's ``-flex``
|
||||||
rows are the same weights at the same advertised price, so on every scored
|
rows are the same weights at the same advertised price, so on every scored
|
||||||
@@ -31,7 +33,7 @@ INTERACTIVE = "interactive"
|
|||||||
BATCH = "batch"
|
BATCH = "batch"
|
||||||
|
|
||||||
|
|
||||||
def is_eligible(
|
def rejection_reason(
|
||||||
row: dict,
|
row: dict,
|
||||||
*,
|
*,
|
||||||
required_context_tokens: int,
|
required_context_tokens: int,
|
||||||
@@ -40,35 +42,103 @@ def is_eligible(
|
|||||||
allowed_access_levels: Sequence[str],
|
allowed_access_levels: Sequence[str],
|
||||||
exclude_stale: bool,
|
exclude_stale: bool,
|
||||||
exclude_deprecated: bool,
|
exclude_deprecated: bool,
|
||||||
) -> bool:
|
min_tool_proficiency: float | None = None,
|
||||||
"""Whether a model row survives every hard filter.
|
require_vision: bool = False,
|
||||||
|
require_json_mode: bool = False,
|
||||||
|
) -> str | None:
|
||||||
|
"""Why this row is not a candidate, or None if it is one.
|
||||||
|
|
||||||
|
Phrased as the reason rather than a bool so the debug log can say WHICH
|
||||||
|
filter dropped a model and with what numbers, without a second
|
||||||
|
implementation of these rules drifting away from this one.
|
||||||
|
Reasons are single tokens so a log line needs no quoting.
|
||||||
|
|
||||||
A NULL ``effective_context_window`` fails the context filter: an unknown
|
A NULL ``effective_context_window`` fails the context filter: an unknown
|
||||||
window cannot be shown to be large enough, and silently truncating
|
window cannot be shown to be large enough, and silently truncating
|
||||||
mid-task is worse than routing elsewhere.
|
mid-task is worse than routing elsewhere.
|
||||||
|
|
||||||
|
``min_tool_proficiency`` is set when the REQUEST carries tool definitions,
|
||||||
|
and is a capability requirement rather than a preference — which is why it
|
||||||
|
is a filter and not a term in the ranking.
|
||||||
|
|
||||||
|
The distinction that matters: this does not ask whether the task is
|
||||||
|
"agentic". It asks whether the model can be trusted with tools that are
|
||||||
|
on the table. Those are different questions, and the measured failure is
|
||||||
|
the second one — `deepseek-v4-flash` scores 0.33 here, and the recorded
|
||||||
|
case is a *non*-agentic prompt ("it is 1:20pm, my meeting is at 3pm, how
|
||||||
|
many minutes?") where it called two tools instead of subtracting. A model
|
||||||
|
that over-reaches for tools is a hazard on every request where tools
|
||||||
|
exist, not only on the ones a classifier would label agentic.
|
||||||
|
|
||||||
|
That also sidesteps the classifier entirely, which is the point: asked to
|
||||||
|
identify six unambiguous tool-use prompts, the local models managed 2/6
|
||||||
|
and 1/6. Whether tools are present is stated in the request body. Reading
|
||||||
|
it is exact and free; inferring it is neither.
|
||||||
"""
|
"""
|
||||||
eff_ctx = row.get("effective_context_window")
|
eff_ctx = row.get("effective_context_window")
|
||||||
if eff_ctx is None or eff_ctx < required_context_tokens:
|
if eff_ctx is None:
|
||||||
return False
|
return "context(unknown)"
|
||||||
|
if eff_ctx < required_context_tokens:
|
||||||
|
return f"context({eff_ctx}<{required_context_tokens})"
|
||||||
|
|
||||||
tier = row.get("tier")
|
tier = row.get("tier")
|
||||||
if tier is None or tier < required_tier:
|
if tier is None:
|
||||||
return False
|
return "tier(unknown)"
|
||||||
|
if tier < required_tier:
|
||||||
|
return f"tier({tier}<{required_tier})"
|
||||||
|
|
||||||
availability = row.get("availability")
|
availability = row.get("availability")
|
||||||
if exclude_stale and availability == "stale":
|
if exclude_stale and availability == "stale":
|
||||||
return False
|
return "stale"
|
||||||
if exclude_deprecated and (availability == "deprecated" or row.get("deprecated")):
|
if exclude_deprecated and (availability == "deprecated" or row.get("deprecated")):
|
||||||
return False
|
return "deprecated"
|
||||||
|
|
||||||
if row.get("access_level", "public") not in allowed_access_levels:
|
access_level = row.get("access_level", "public")
|
||||||
return False
|
if access_level not in allowed_access_levels:
|
||||||
|
return f"access_level({access_level})"
|
||||||
|
|
||||||
# Flex rows may be queued behind a capacity gap; only batch work opts in.
|
# Flex rows may be queued behind a capacity gap; only batch work opts in.
|
||||||
if latency_tolerance == INTERACTIVE and row.get("latency_class") == "flex":
|
if latency_tolerance == INTERACTIVE and row.get("latency_class") == "flex":
|
||||||
return False
|
return "latency_class(flex)"
|
||||||
|
|
||||||
return True
|
# Capability flags fail CLOSED on unknown, deliberately unlike the tool
|
||||||
|
# gate just below. A proficiency MEASUREMENT with no evidence is "unproven,
|
||||||
|
# not bad" -- admitting it loses nothing. But a capability FLAG that is
|
||||||
|
# absent means "cannot confirm the capability", and routing a request that
|
||||||
|
# needs vision or JSON mode to a model that might lack it is a guaranteed
|
||||||
|
# provider 400. Same precedent as the context(unknown) gate above.
|
||||||
|
if require_vision:
|
||||||
|
vision = row.get("supports_vision")
|
||||||
|
if vision is None:
|
||||||
|
return "vision(unknown)"
|
||||||
|
if not vision:
|
||||||
|
return "vision(unsupported)"
|
||||||
|
if require_json_mode:
|
||||||
|
jm = row.get("supports_json_mode")
|
||||||
|
if jm is None:
|
||||||
|
return "json_mode(unknown)"
|
||||||
|
if not jm:
|
||||||
|
return "json_mode(unsupported)"
|
||||||
|
|
||||||
|
if min_tool_proficiency is not None:
|
||||||
|
tool_score = row.get("tool_proficiency")
|
||||||
|
# Absent evidence does not disqualify, consistently with the tier-1
|
||||||
|
# context gate: a model nobody has evaluated for tool use yet is
|
||||||
|
# unproven, not proven bad. Only a measured score below the bar drops
|
||||||
|
# a candidate.
|
||||||
|
if tool_score is not None and tool_score < min_tool_proficiency:
|
||||||
|
return f"tool_proficiency({tool_score:g}<{min_tool_proficiency:g})"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_eligible(row: dict, **filters) -> bool:
|
||||||
|
"""Whether a model row survives every hard filter.
|
||||||
|
|
||||||
|
A thin wrapper so there is exactly one copy of the rules; see
|
||||||
|
``rejection_reason`` for what they are and why.
|
||||||
|
"""
|
||||||
|
return rejection_reason(row, **filters) is None
|
||||||
|
|
||||||
|
|
||||||
def select_candidates(
|
def select_candidates(
|
||||||
@@ -80,6 +150,9 @@ def select_candidates(
|
|||||||
allowed_access_levels: Sequence[str],
|
allowed_access_levels: Sequence[str],
|
||||||
exclude_stale: bool,
|
exclude_stale: bool,
|
||||||
exclude_deprecated: bool,
|
exclude_deprecated: bool,
|
||||||
|
min_tool_proficiency: float | None = None,
|
||||||
|
require_vision: bool = False,
|
||||||
|
require_json_mode: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Apply every hard filter, preserving input order."""
|
"""Apply every hard filter, preserving input order."""
|
||||||
return [
|
return [
|
||||||
@@ -93,6 +166,9 @@ def select_candidates(
|
|||||||
allowed_access_levels=allowed_access_levels,
|
allowed_access_levels=allowed_access_levels,
|
||||||
exclude_stale=exclude_stale,
|
exclude_stale=exclude_stale,
|
||||||
exclude_deprecated=exclude_deprecated,
|
exclude_deprecated=exclude_deprecated,
|
||||||
|
min_tool_proficiency=min_tool_proficiency,
|
||||||
|
require_vision=require_vision,
|
||||||
|
require_json_mode=require_json_mode,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -222,8 +298,14 @@ def rank_candidates(
|
|||||||
# while a real gap (tool_use_agentic spans 0.67) still decides outright.
|
# while a real gap (tool_use_agentic spans 0.67) still decides outright.
|
||||||
best = max(r["proficiency_score"] for r in ranked)
|
best = max(r["proficiency_score"] for r in ranked)
|
||||||
|
|
||||||
def band(r: dict) -> int:
|
def band(r: dict) -> float:
|
||||||
return int((best - r["proficiency_score"]) / quality_tolerance)
|
# 0 means "no band": rank strictly by quality, with cost breaking only
|
||||||
|
# exact ties. A legitimate setting -- "never trade quality for cost" --
|
||||||
|
# and one the config validator has always accepted, while this line
|
||||||
|
# divided by it.
|
||||||
|
if quality_tolerance <= 0:
|
||||||
|
return -r["proficiency_score"]
|
||||||
|
return float(int((best - r["proficiency_score"]) / quality_tolerance))
|
||||||
|
|
||||||
ranked.sort(
|
ranked.sort(
|
||||||
key=lambda r: (
|
key=lambda r: (
|
||||||
|
|||||||
38
schema.sql
38
schema.sql
@@ -66,6 +66,13 @@ CREATE TABLE IF NOT EXISTS proficiency (
|
|||||||
self_eval_samples INTEGER DEFAULT 0,
|
self_eval_samples INTEGER DEFAULT 0,
|
||||||
blended_score REAL, -- computed: see blending rule in design doc
|
blended_score REAL, -- computed: see blending rule in design doc
|
||||||
source TEXT, -- 'leaderboard' | 'self_eval' | 'blended'
|
source TEXT, -- 'leaderboard' | 'self_eval' | 'blended'
|
||||||
|
-- Which model this row's scores were COPIED from, or NULL if they were
|
||||||
|
-- measured on this row directly. Provenance, not decoration: without it
|
||||||
|
-- an inherited row is indistinguishable from a measured one (both carry
|
||||||
|
-- self_eval_samples > 0), so propagate_to_variants could not tell which
|
||||||
|
-- rows it was allowed to refresh and froze every variant permanently at
|
||||||
|
-- its first inheritance.
|
||||||
|
inherited_from TEXT,
|
||||||
last_updated TEXT NOT NULL,
|
last_updated TEXT NOT NULL,
|
||||||
PRIMARY KEY (model_id, provider, category),
|
PRIMARY KEY (model_id, provider, category),
|
||||||
FOREIGN KEY (model_id, provider) REFERENCES models (model_id, provider)
|
FOREIGN KEY (model_id, provider) REFERENCES models (model_id, provider)
|
||||||
@@ -95,23 +102,32 @@ CREATE TABLE IF NOT EXISTS energy_observations (
|
|||||||
task_category TEXT,
|
task_category TEXT,
|
||||||
prompt_tokens INTEGER,
|
prompt_tokens INTEGER,
|
||||||
completion_tokens INTEGER,
|
completion_tokens INTEGER,
|
||||||
-- energy_kwh is what NeuralWatt BILLS, but it is not a measure of model
|
-- energy_kwh is what NeuralWatt BILLS. It equals avg_power_watts *
|
||||||
-- efficiency: it equals avg_power_watts * duration_seconds *
|
-- duration_seconds * attribution_ratio, where attribution_ratio is this
|
||||||
-- attribution_ratio, where attribution_ratio is this request's share of a
|
-- request's share of a shared multi-tenant GPU pool. Up close that term
|
||||||
-- shared multi-tenant GPU pool. Eight identical calls to one model inside
|
-- looks like noise: eight identical calls to one model inside one minute
|
||||||
-- one minute varied 20x in energy_kwh and 22x in attribution_ratio,
|
-- varied 20x, correlating +0.997 with the ratio while power and duration
|
||||||
-- correlation +0.997, while duration and power held steady. Ranking models
|
-- held steady.
|
||||||
-- on this ranks how busy the provider was, not how efficient the model is.
|
--
|
||||||
energy_kwh REAL, -- response.energy.energy_kwh (attributed, noisy)
|
-- It is not noise, and this is the ATTRIBUTED figure scoring reads. Across
|
||||||
|
-- the reference sweep the median ratio spans 750x BETWEEN models while the
|
||||||
|
-- typical spread WITHIN one is 1.8x, and the values are quantized (0.001,
|
||||||
|
-- 0.25, 0.5, 0.75) -- that is serving concurrency, a stable per-model
|
||||||
|
-- property. A model whose GPUs carry more concurrent requests genuinely
|
||||||
|
-- costs less per request. The median over repeated sweeps absorbs what
|
||||||
|
-- noise remains.
|
||||||
|
energy_kwh REAL, -- response.energy.energy_kwh (attributed)
|
||||||
energy_btu REAL, -- energy_kwh * 3412.14, purely for comedic dashboard value
|
energy_btu REAL, -- energy_kwh * 3412.14, purely for comedic dashboard value
|
||||||
|
|
||||||
-- The pre-attribution terms. avg_power_watts * duration_seconds is the
|
-- The pre-attribution terms. avg_power_watts * duration_seconds is the
|
||||||
-- pool's energy over the request, independent of how many other tenants
|
-- pool's energy over the request, independent of how many other tenants
|
||||||
-- shared it, and it is ~10x more stable (1.85x spread vs 20x on the same
|
-- shared it. Scoring on that product was TRIED and is wrong: it discards
|
||||||
-- eight calls). That product is what scoring ranks on.
|
-- the 750x between-model signal above to suppress a 1.8x within-model one.
|
||||||
|
-- Kept as a diagnostic (dispatcher.gross_energy_kwh) so the decomposition
|
||||||
|
-- stays inspectable, not as a scoring input.
|
||||||
avg_power_watts REAL, -- response.energy.avg_power_watts
|
avg_power_watts REAL, -- response.energy.avg_power_watts
|
||||||
duration_seconds REAL, -- response.energy.duration_seconds
|
duration_seconds REAL, -- response.energy.duration_seconds
|
||||||
attribution_ratio REAL, -- kept so the noise term stays inspectable
|
attribution_ratio REAL, -- the share term, kept so the identity checks out
|
||||||
|
|
||||||
-- Carbon is what design doc §4 actually scores eco on, and NeuralWatt
|
-- Carbon is what design doc §4 actually scores eco on, and NeuralWatt
|
||||||
-- reports it per-request rather than making us derive it. Grid intensity
|
-- reports it per-request rather than making us derive it. Grid intensity
|
||||||
|
|||||||
14
scoring.py
14
scoring.py
@@ -4,8 +4,18 @@ These functions are deliberately free of I/O: no DB reads, no file reads,
|
|||||||
no config import at runtime. Thresholds and weights are passed as
|
no config import at runtime. Thresholds and weights are passed as
|
||||||
arguments so the module stays testable and reusable.
|
arguments so the module stays testable and reusable.
|
||||||
|
|
||||||
Scoring model (design doc §4):
|
What is still live here: ``proficiency_score``, which is the objective
|
||||||
composite = w_cost*cost_score + w_eco*eco_score + w_prof*proficiency_score
|
``routing.rank_candidates`` orders on, and ``cost_score``, which it reports for
|
||||||
|
visibility without ranking on it.
|
||||||
|
|
||||||
|
The weighted composite below is NOT the scoring model any more. It was
|
||||||
|
``w_cost*cost + w_eco*eco + w_prof*proficiency`` until measurement retired it:
|
||||||
|
moving the cost weight from 0.4 to zero changed the winner in only 2 of 6
|
||||||
|
categories, so 40% of every decision was steering nothing, while eco was
|
||||||
|
optimizing a goal nobody had stated. Ranking is now quality first, cost as the
|
||||||
|
tiebreak inside ``quality_tolerance``, and a hard energy ceiling. ``eco_score``
|
||||||
|
and ``composite_score`` survive as arithmetic with tests; nothing in the
|
||||||
|
router calls them.
|
||||||
|
|
||||||
None-handling rule (consistent across cost/eco): candidates without data
|
None-handling rule (consistent across cost/eco): candidates without data
|
||||||
are excluded from the min/max range computation and receive a neutral 0.5.
|
are excluded from the min/max range computation and receive a neutral 0.5.
|
||||||
|
|||||||
@@ -19,10 +19,24 @@ calls to one model inside one minute spanned 20x, correlating +0.997 with
|
|||||||
the attribution ratio while power and duration held steady. Two earlier
|
the attribution ratio while power and duration held steady. Two earlier
|
||||||
sweeps of these same 13 models disagreed by up to 36x on that basis.
|
sweeps of these same 13 models disagreed by up to 36x on that basis.
|
||||||
|
|
||||||
So routing scores on `power * duration` (see `dispatcher.gross_energy_kwh`),
|
Scoring reads the ATTRIBUTED figures, not `power * duration`. Ranking on the
|
||||||
which held to 1.85x across those same eight calls. The summary prints both
|
pre-attribution product was tried and is wrong: the median attribution ratio
|
||||||
spreads side by side; if the billed column is wildly wider than the gross
|
spans 750x between models against a 1.8x spread within one, so stripping it
|
||||||
column, that is the artifact, not the models.
|
discards the larger real signal to suppress the smaller noisy one. The summary
|
||||||
|
still prints both spreads side by side, because the gap between them is what
|
||||||
|
that reasoning rests on.
|
||||||
|
|
||||||
|
What this sweep feeds today is `eco` and the per-request energy ceiling
|
||||||
|
(objective.max_energy_per_request). It no longer feeds `cost`: cost is priced
|
||||||
|
per request from catalog prices scaled to the request's shape, because a fixed
|
||||||
|
400-token workload ranks models backwards for real traffic.
|
||||||
|
|
||||||
|
Coverage across TIME is the point of running it repeatedly. Attribution tracks
|
||||||
|
pool load and pool load tracks time of day -- between two sweeps hours apart,
|
||||||
|
deepseek-v4-flash moved ~50x and qwen3.6-35b ~7x the other way, enough to
|
||||||
|
invert their ranking. More samples inside one sweep measures one moment more
|
||||||
|
precisely; `load_candidates` takes the median over ALL seed_reference rows, so
|
||||||
|
repeated sweeps accumulate into a median across time for free.
|
||||||
|
|
||||||
What lands in the table are real observations of real calls, identical in
|
What lands in the table are real observations of real calls, identical in
|
||||||
kind to what the dispatcher logs; they are simply generated deliberately
|
kind to what the dispatcher logs; they are simply generated deliberately
|
||||||
@@ -77,14 +91,28 @@ def routable_models(conn: sqlite3.Connection, allowed_levels: list[str]) -> list
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def sample_once(base_url: str, api_key: str, model_id: str, timeout: int = 300) -> dict:
|
def sample_once(
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model_id: str,
|
||||||
|
max_tokens: int = REFERENCE_MAX_TOKENS,
|
||||||
|
timeout: int = 300,
|
||||||
|
) -> dict:
|
||||||
|
"""One reference call. ``max_tokens`` is a parameter because --max-tokens is.
|
||||||
|
|
||||||
|
It used to be hardcoded while the banner printed whatever --max-tokens had
|
||||||
|
been passed, so the flag moved the report and not the request -- and the
|
||||||
|
resulting rows landed in the same seed_reference median as the 400-token
|
||||||
|
ones, quietly mixing two workload shapes in the one axis that exists to
|
||||||
|
hold the workload constant across models.
|
||||||
|
"""
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{base_url}/chat/completions",
|
f"{base_url}/chat/completions",
|
||||||
headers={"authorization": f"Bearer {api_key}"},
|
headers={"authorization": f"Bearer {api_key}"},
|
||||||
json={
|
json={
|
||||||
"model": model_id,
|
"model": model_id,
|
||||||
"messages": [{"role": "user", "content": REFERENCE_PROMPT}],
|
"messages": [{"role": "user", "content": REFERENCE_PROMPT}],
|
||||||
"max_tokens": REFERENCE_MAX_TOKENS,
|
"max_tokens": max_tokens,
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
},
|
},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -140,20 +168,29 @@ def main() -> int:
|
|||||||
results[model_id] = []
|
results[model_id] = []
|
||||||
for i in range(args.samples):
|
for i in range(args.samples):
|
||||||
try:
|
try:
|
||||||
payload = sample_once(settings.base_url, api_key, model_id)
|
payload = sample_once(
|
||||||
|
settings.base_url, api_key, model_id, args.max_tokens
|
||||||
|
)
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
print(f" {model_id:26s} sample {i + 1}: FAILED {type(e).__name__}: {e}")
|
print(f" {model_id:26s} sample {i + 1}: FAILED {type(e).__name__}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
usage = payload.get("usage") or {}
|
usage = payload.get("usage") or {}
|
||||||
telemetry = extract_telemetry(payload)
|
telemetry = extract_telemetry(payload)
|
||||||
|
# Keyword arguments deliberately. log_observation grew request_id,
|
||||||
|
# session_key and session_dir in the middle of its signature and
|
||||||
|
# made the trailing three keyword-only; this call still passed six
|
||||||
|
# positionals, so every sweep died on TypeError after its first
|
||||||
|
# BILLED call -- and TypeError is not a RequestException, so the
|
||||||
|
# `except` below never caught it.
|
||||||
log_observation(
|
log_observation(
|
||||||
model_id,
|
model_id,
|
||||||
m["provider"],
|
m["provider"],
|
||||||
SEED_CATEGORY,
|
SEED_CATEGORY,
|
||||||
usage.get("prompt_tokens"),
|
payload.get("id"),
|
||||||
usage.get("completion_tokens"),
|
prompt_tokens=usage.get("prompt_tokens"),
|
||||||
telemetry,
|
completion_tokens=usage.get("completion_tokens"),
|
||||||
|
telemetry=telemetry,
|
||||||
)
|
)
|
||||||
if telemetry.allowance_remaining_usd is not None:
|
if telemetry.allowance_remaining_usd is not None:
|
||||||
if allowance_start is None:
|
if allowance_start is None:
|
||||||
|
|||||||
84
tests/test_capabilities.py
Normal file
84
tests/test_capabilities.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Tests for capabilities.py — request-side capability detection.
|
||||||
|
|
||||||
|
Each case proves one detection fires and that the others stay False, mirroring
|
||||||
|
the "rejects and does not over-reject" shape of test_routing.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from capabilities import detect_capabilities
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_parts_yields_no_capabilities():
|
||||||
|
caps = detect_capabilities({"messages": [{"content": "plain text"}]})
|
||||||
|
assert caps == detect_capabilities({})
|
||||||
|
assert not caps.has_images
|
||||||
|
assert not caps.require_json_mode
|
||||||
|
assert not caps.tools_present
|
||||||
|
assert not caps.has_reasoning_request
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_url_part_sets_has_images():
|
||||||
|
body = {
|
||||||
|
"messages": [
|
||||||
|
{"content": [{"type": "image_url", "url": "https://x/img.png"}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert detect_capabilities(body).has_images is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_in_an_earlier_message_counts():
|
||||||
|
body = {
|
||||||
|
"messages": [
|
||||||
|
{"content": [{"type": "image_url", "url": "https://x/1.png"}]},
|
||||||
|
{"content": "now what do you make of it?"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert detect_capabilities(body).has_images is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_image_parts_do_not_trigger():
|
||||||
|
body = {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "hello"},
|
||||||
|
{"type": "tool_use", "id": "t1"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert detect_capabilities(body).has_images is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_content_does_not_trigger_images():
|
||||||
|
# A raw base64 string in a text part is not an image_url part.
|
||||||
|
body = {"messages": [{"content": "data:image/png;base64,AA=="}]}
|
||||||
|
assert detect_capabilities(body).has_images is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_object_response_format_sets_require_json_mode():
|
||||||
|
body = {"response_format": {"type": "json_object"}}
|
||||||
|
assert detect_capabilities(body).require_json_mode is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_schema_response_format_sets_require_json_mode():
|
||||||
|
body = {"response_format": {"type": "json_schema", "json_schema": {}}}
|
||||||
|
assert detect_capabilities(body).require_json_mode is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_or_missing_response_format_does_not_require_json_mode():
|
||||||
|
assert detect_capabilities({}).require_json_mode is False
|
||||||
|
assert detect_capabilities({"response_format": {"type": "text"}}).require_json_mode is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_array_sets_tools_present():
|
||||||
|
assert detect_capabilities({"tools": [{"type": "function"}]}).tools_present is True
|
||||||
|
# An empty array is no tools at all.
|
||||||
|
assert detect_capabilities({"tools": []}).tools_present is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_sets_has_reasoning_request():
|
||||||
|
assert detect_capabilities({"reasoning_effort": "high"}).has_reasoning_request is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_param_sets_has_reasoning_request():
|
||||||
|
assert detect_capabilities({"reasoning": {"enabled": True}}).has_reasoning_request is True
|
||||||
807
tests/test_chat_completions.py
Normal file
807
tests/test_chat_completions.py
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
"""Tests for the OpenAI-compatible surface — the endpoint every client uses.
|
||||||
|
|
||||||
|
This file exists because it did not. `/v1/chat/completions` is how opencode,
|
||||||
|
an SDK and plain curl all reach the router, and it had zero tests, which is
|
||||||
|
how a NameError survived on the pass-through path: `alternatives` read
|
||||||
|
`decision.runners_up`, but `decision` is only bound inside `if wants_routing`,
|
||||||
|
so every non-streaming request naming a real model id returned 500 before the
|
||||||
|
provider was ever called.
|
||||||
|
|
||||||
|
Nothing here touches the network. The classifier, the provider and the local
|
||||||
|
verifier are all stubbed, so the tests run in milliseconds and pin behaviour
|
||||||
|
rather than reachability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import dispatcher
|
||||||
|
from dispatcher import Classification, app
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCHEMA_SQL = (ROOT / "schema.sql").read_text()
|
||||||
|
|
||||||
|
CHEAP = "cheap-model"
|
||||||
|
DEAR = "dear-model"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
"""Just enough of requests.Response for both dispatcher paths."""
|
||||||
|
|
||||||
|
def __init__(self, payload=None, *, status_code=200, lines=None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload or {}
|
||||||
|
self._lines = lines or []
|
||||||
|
self.text = json.dumps(self._payload)
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
def iter_lines(self, decode_unicode=False):
|
||||||
|
yield from self._lines
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def completion(model, content="hello there", *, completion_tokens=12):
|
||||||
|
"""A provider response shaped like NeuralWatt's, telemetry blocks included."""
|
||||||
|
return {
|
||||||
|
"id": "chatcmpl-test-1",
|
||||||
|
"model": model,
|
||||||
|
"choices": [
|
||||||
|
{"message": {"role": "assistant", "content": content},
|
||||||
|
"finish_reason": "stop"}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 31, "completion_tokens": completion_tokens},
|
||||||
|
"energy": {"energy_kwh": 5.0e-05, "avg_power_watts": 400.0,
|
||||||
|
"duration_seconds": 1.4, "attribution_ratio": 0.25,
|
||||||
|
"carbon_g_co2eq": 2.4e-03, "carbon_source": "agent_cache",
|
||||||
|
"grid_id": "FI"},
|
||||||
|
"cost": {"request_cost_usd": 4.0e-04},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def router(tmp_path, monkeypatch):
|
||||||
|
"""The dispatcher pointed at a throwaway catalog, with nothing dialled out."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
for model_id, completion_price, vision in (
|
||||||
|
(CHEAP, 0.30, 1),
|
||||||
|
(DEAR, 9.00, 0),
|
||||||
|
):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, tier, context_window,
|
||||||
|
effective_context_window, max_output_tokens,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion,
|
||||||
|
supports_vision, supports_json_mode,
|
||||||
|
latency_class, reasoning_mode, context_variant,
|
||||||
|
access_level, availability, last_updated
|
||||||
|
) VALUES (?, 'neuralwatt', ?, 2, 262128, 192500, 16384, ?, ?,
|
||||||
|
?, 1, 'standard', 'default', 'full', 'public', 'active',
|
||||||
|
'2026-08-22T00:00:00+00:00')
|
||||||
|
""",
|
||||||
|
(model_id, model_id, completion_price / 3, completion_price, vision),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.database, "path", str(db_path))
|
||||||
|
# No Ollama in the test environment, and the local check would otherwise
|
||||||
|
# fire as a background task and try to reach localhost:11434.
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.verification, "local_llm_enabled", False)
|
||||||
|
# The local vision fallback is off unless a test opts in; without this it
|
||||||
|
# would fire for every image request and try to reach localhost.
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", False)
|
||||||
|
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, json=None, stream=False, timeout=None):
|
||||||
|
calls.append({"url": url, "body": json, "stream": stream})
|
||||||
|
if stream:
|
||||||
|
return FakeResponse(lines=STREAM_LINES)
|
||||||
|
return FakeResponse(completion(json["model"]))
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.requests, "post", fake_post)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dispatcher, "classify",
|
||||||
|
lambda task, context: Classification(
|
||||||
|
task_category="coding_general", task_tier=2,
|
||||||
|
required_context_tokens=100, confidence=0.9,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield TestClient(app), calls, db_path
|
||||||
|
|
||||||
|
|
||||||
|
STREAM_LINES = [
|
||||||
|
'data: {"id":"chatcmpl-stream-1","choices":[{"delta":{"content":"hel"}}]}',
|
||||||
|
"",
|
||||||
|
'data: {"id":"chatcmpl-stream-1","choices":[{"delta":{"content":"lo"},'
|
||||||
|
'"finish_reason":"stop"}],"usage":{"prompt_tokens":31,'
|
||||||
|
'"completion_tokens":9}}',
|
||||||
|
"",
|
||||||
|
': energy {"energy_kwh": 5e-05, "carbon_g_co2eq": 0.0024, '
|
||||||
|
'"carbon_source": "agent_cache"}',
|
||||||
|
': cost {"request_cost_usd": 0.0004}',
|
||||||
|
"data: [DONE]",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _messages(text="write me a function"):
|
||||||
|
return [{"role": "user", "content": text}]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the regression -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_real_model_id_is_dispatched_rather_than_routed(router):
|
||||||
|
"""The documented pass-through: 'any real model id — dispatched as asked'.
|
||||||
|
|
||||||
|
This raised NameError on `decision` before the provider was ever called,
|
||||||
|
so the whole path 500'd while streaming clients never noticed.
|
||||||
|
"""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": DEAR, "messages": _messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == DEAR, "the caller's choice must survive"
|
||||||
|
assert resp.headers["X-Router-Model"] == DEAR
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_model_id_does_not_pay_for_classification(router, monkeypatch):
|
||||||
|
"""A named model has nothing to classify; the ~2s round-trip is skipped."""
|
||||||
|
client, _, _ = router
|
||||||
|
|
||||||
|
def explode(task, context):
|
||||||
|
raise AssertionError("classifier consulted for an explicit model id")
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher, "classify", explode)
|
||||||
|
assert client.post(
|
||||||
|
"/v1/chat/completions", json={"model": CHEAP, "messages": _messages()}
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --- routing --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_auto_routes_and_reports_the_model_it_actually_used(router):
|
||||||
|
"""Quality ties, so cost breaks it — and the client is told what ran."""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == CHEAP
|
||||||
|
assert resp.headers["X-Router-Model"] == CHEAP
|
||||||
|
# The body must stay a valid OpenAI response naming the real model, not 'auto'.
|
||||||
|
assert resp.json()["model"] == CHEAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_provider_prefixed_router_name_still_routes(router):
|
||||||
|
"""opencode sends `llm-router/auto`; only the virtual names are stripped."""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "llm-router/auto", "messages": _messages()},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == CHEAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_eligible_model_is_a_422_naming_the_filters(router, monkeypatch):
|
||||||
|
"""A dead end must say which constraint killed it, not just 'no model'."""
|
||||||
|
client, calls, _ = router
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dispatcher, "classify",
|
||||||
|
lambda task, context: Classification(
|
||||||
|
task_category="coding_general", task_tier=3,
|
||||||
|
required_context_tokens=100, confidence=0.9,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions", json={"model": "auto", "messages": _messages()}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "tier >= 3" in resp.json()["detail"]
|
||||||
|
assert not calls, "nothing should be dispatched when nothing qualifies"
|
||||||
|
|
||||||
|
|
||||||
|
# --- streaming ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_stream_is_proxied_verbatim_including_the_telemetry_comments(router):
|
||||||
|
"""NeuralWatt's energy/cost blocks are SSE comments; clients ignore them.
|
||||||
|
|
||||||
|
They must still reach the client untouched — the router reads them on the
|
||||||
|
way past rather than buffering the stream to strip them.
|
||||||
|
"""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": DEAR, "messages": _messages(), "stream": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["stream"] is True
|
||||||
|
body = resp.text
|
||||||
|
assert "hel" in body and "lo" in body
|
||||||
|
assert ': energy {"energy_kwh": 5e-05' in body
|
||||||
|
assert "data: [DONE]" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_streamed_call_still_logs_its_energy(router):
|
||||||
|
"""Streaming is how every agent client talks; unlogged, it is most of the traffic."""
|
||||||
|
client, _, db_path = router
|
||||||
|
client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": DEAR, "messages": _messages(), "stream": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT model_id, request_id, energy_kwh, cost_usd, completion_tokens "
|
||||||
|
"FROM energy_observations ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert row["model_id"] == DEAR
|
||||||
|
assert row["request_id"] == "chatcmpl-stream-1"
|
||||||
|
assert row["energy_kwh"] == pytest.approx(5e-05)
|
||||||
|
assert row["cost_usd"] == pytest.approx(4e-04)
|
||||||
|
assert row["completion_tokens"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dropped_upstream_connection_ends_the_stream_cleanly(router, monkeypatch):
|
||||||
|
"""The upstream connection can die mid-stream (NeuralWatt closing early, a
|
||||||
|
network blip). Left uncaught, requests.exceptions.ChunkedEncodingError
|
||||||
|
propagated straight out of the generator, which Starlette surfaced as an
|
||||||
|
unhandled ASGI exception -- a full traceback in the log, and the client's
|
||||||
|
connection cut dead with no error payload or [DONE].
|
||||||
|
"""
|
||||||
|
import requests as requests_module
|
||||||
|
|
||||||
|
client, _, _ = router
|
||||||
|
|
||||||
|
def broken_lines():
|
||||||
|
yield 'data: {"id":"chatcmpl-stream-1","choices":[{"delta":{"content":"hel"}}]}'
|
||||||
|
raise requests_module.exceptions.ChunkedEncodingError("Response ended prematurely")
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, json=None, stream=False, timeout=None):
|
||||||
|
return FakeResponse(lines=broken_lines())
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.requests, "post", fake_post)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": DEAR, "messages": _messages(), "stream": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.text
|
||||||
|
assert "hel" in body
|
||||||
|
assert "upstream stream interrupted" in body
|
||||||
|
assert "data: [DONE]" in body
|
||||||
|
|
||||||
|
|
||||||
|
# --- what a request leaves in the journal ---------------------------------
|
||||||
|
#
|
||||||
|
# The reason this exists: watching a live session showed only uvicorn's access
|
||||||
|
# line, which does not even name the model that served it.
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logbuf():
|
||||||
|
"""Capture what would reach the journal, through the real formatter."""
|
||||||
|
import logs
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
logs.configure("debug", stream=buf, journald=False)
|
||||||
|
yield buf
|
||||||
|
for handler in list(logs.log.handlers):
|
||||||
|
logs.log.removeHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _lines(logbuf, event):
|
||||||
|
return [ln for ln in logbuf.getvalue().splitlines() if ln.startswith(f"{event} ")]
|
||||||
|
|
||||||
|
|
||||||
|
def _fields(line):
|
||||||
|
out = {}
|
||||||
|
for token in line.split(" ")[1:]:
|
||||||
|
if "=" in token:
|
||||||
|
key, _, value = token.partition("=")
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_routed_request_logs_exactly_one_decision_line(router, logbuf):
|
||||||
|
client, _, _ = router
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages()})
|
||||||
|
|
||||||
|
routes = _lines(logbuf, "route")
|
||||||
|
|
||||||
|
assert len(routes) == 1, "routing twice for context must not log twice"
|
||||||
|
fields = _fields(routes[0])
|
||||||
|
assert fields["pick"] == CHEAP
|
||||||
|
assert fields["cat"] == "coding_general"
|
||||||
|
assert fields["tier"] == "2"
|
||||||
|
assert int(fields["ms"]) >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dispatch_line_carries_the_join_key(router, logbuf):
|
||||||
|
"""rid is what pivots a journal line to its energy_observations row."""
|
||||||
|
client, _, _ = router
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages()})
|
||||||
|
|
||||||
|
fields = _fields(_lines(logbuf, "dispatch")[0])
|
||||||
|
|
||||||
|
assert fields["rid"] == "chatcmpl-test-1"
|
||||||
|
assert fields["model"] == CHEAP
|
||||||
|
assert fields["c_tok"] == "12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_line_of_one_request_shares_a_trace_id(router, logbuf):
|
||||||
|
client, _, _ = router
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages()})
|
||||||
|
|
||||||
|
ids = {_fields(ln)["id"] for ln in logbuf.getvalue().splitlines()}
|
||||||
|
|
||||||
|
assert len(ids) == 1
|
||||||
|
assert ids != {"-"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_streamed_request_still_has_a_trace_id(router, logbuf):
|
||||||
|
"""The trap: a StreamingResponse generator runs in a different context.
|
||||||
|
|
||||||
|
Read from the ContextVar inside the generator it comes back empty, and
|
||||||
|
every streamed request -- which is all agent traffic -- logs a blank id.
|
||||||
|
"""
|
||||||
|
client, _, _ = router
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": DEAR, "messages": _messages(), "stream": True})
|
||||||
|
|
||||||
|
dispatched = _fields(_lines(logbuf, "dispatch")[0])
|
||||||
|
|
||||||
|
assert dispatched["id"] != "-"
|
||||||
|
assert dispatched["stream"] == "1"
|
||||||
|
assert dispatched["rid"] == "chatcmpl-stream-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_says_which_filter_dropped_a_model(router, logbuf):
|
||||||
|
"""'No model satisfies the hard filters' is otherwise a dead end."""
|
||||||
|
client, _, db_path = router
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, tier, context_window, effective_context_window,
|
||||||
|
latency_class, access_level, availability, last_updated
|
||||||
|
) VALUES ('held-model', 'neuralwatt', 2, 262128, 192500, 'flex',
|
||||||
|
'public', 'active', '2026-08-22T00:00:00+00:00')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages()})
|
||||||
|
|
||||||
|
dropped = {_fields(ln)["model"]: _fields(ln)["reason"]
|
||||||
|
for ln in _lines(logbuf, "filter")}
|
||||||
|
|
||||||
|
assert dropped["held-model"] == "latency_class(flex)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pass_through_is_labelled_as_one(router, logbuf):
|
||||||
|
client, _, _ = router
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": DEAR, "messages": _messages()})
|
||||||
|
|
||||||
|
assert not _lines(logbuf, "route"), "nothing was routed"
|
||||||
|
assert _fields(_lines(logbuf, "passthrough")[0])["model"] == DEAR
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_conversation_text_ever_reaches_the_log(router, logbuf):
|
||||||
|
"""Prompts here run 60k-150k tokens and the journal is on disk.
|
||||||
|
|
||||||
|
Asserted at DEBUG, the most verbose level, on both the request and the
|
||||||
|
answer.
|
||||||
|
"""
|
||||||
|
secret = "SQUAMOUS-EPHEMERAL-9137"
|
||||||
|
client, _, _ = router
|
||||||
|
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages(f"refactor {secret}")})
|
||||||
|
|
||||||
|
assert secret not in logbuf.getvalue()
|
||||||
|
# The stubbed provider answers "hello there"; that must not appear either.
|
||||||
|
assert "hello there" not in logbuf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_decision_line_reports_the_real_provenance(router, logbuf):
|
||||||
|
"""The re-route for measured context must not read as a client override.
|
||||||
|
|
||||||
|
chat_completions routes a second time when the conversation measures larger
|
||||||
|
than the classifier guessed, passing category and tier back in — which
|
||||||
|
makes the resulting Classification say source='override'. In a log line
|
||||||
|
that means "the client chose this", and the client chose nothing: the
|
||||||
|
classifier ran and only the context figure was replaced.
|
||||||
|
"""
|
||||||
|
client, _, _ = router
|
||||||
|
# ~1200 chars / 3 = 400 tokens measured, above the stubbed estimate of 100.
|
||||||
|
client.post("/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages("refactor " + "x " * 600)})
|
||||||
|
|
||||||
|
fields = _fields(_lines(logbuf, "route")[0])
|
||||||
|
|
||||||
|
assert fields["src"] == "classifier", "the classifier did decide this"
|
||||||
|
assert fields["ctx_src"] == "measured", "but the context came from measurement"
|
||||||
|
assert int(fields["ctx"]) > 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_caller_supplied_context_says_so(router, logbuf):
|
||||||
|
client, _, _ = router
|
||||||
|
client.post("/route", json={"task": "refactor this",
|
||||||
|
"task_category": "coding_general",
|
||||||
|
"task_tier": 2,
|
||||||
|
"required_context_tokens": 5000})
|
||||||
|
|
||||||
|
fields = _fields(_lines(logbuf, "route")[0])
|
||||||
|
|
||||||
|
assert fields["src"] == "override"
|
||||||
|
assert fields["ctx_src"] == "caller"
|
||||||
|
|
||||||
|
|
||||||
|
# --- capability gates: vision and JSON mode -------------------------------
|
||||||
|
|
||||||
|
def _image_messages(text="what is in this image?"):
|
||||||
|
"""A user turn carrying an inline image_url part, OpenAI multimodal shape."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": text},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,AAAA"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_cheap_by_tier(client_type, db_path):
|
||||||
|
"""Make CHEAP ineligible so DEAR is the only remaining candidate."""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute("UPDATE models SET tier = 1 WHERE model_id = ?", (CHEAP,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_vision_fake(monkeypatch, router_calls, content="local caption", status=200):
|
||||||
|
"""Point the local vision fallback at a fake that returns `content`."""
|
||||||
|
def fake_post(url, headers=None, json=None, stream=False, timeout=None):
|
||||||
|
if "/chat/completions" in url:
|
||||||
|
router_calls.append({"url": url, "body": json, "stream": stream, "local": True})
|
||||||
|
return FakeResponse(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"message": {"role": "assistant", "content": content},
|
||||||
|
"finish_reason": "stop"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
status_code=status,
|
||||||
|
)
|
||||||
|
router_calls.append({"url": url, "body": json, "stream": stream})
|
||||||
|
return FakeResponse(completion(json["model"]))
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.requests, "post", fake_post)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_request_is_routed_to_a_vision_model(router):
|
||||||
|
"""Image parts hard-restrict to vision-capable rows; CHEAP has vision."""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == CHEAP
|
||||||
|
assert resp.headers["X-Router-Model"] == CHEAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_request_excludes_a_non_vision_candidate(router):
|
||||||
|
"""DEAR is eligible-tiered but lacks vision, so no candidate meets the ask."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "vision" in resp.json()["detail"]
|
||||||
|
assert not calls, "a non-vision model must never be dispatched for an image"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_vision_model_uses_local_fallback_when_enabled(router, monkeypatch):
|
||||||
|
"""No cloud vision candidate, local vision on: the local model answers."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
_local_vision_fake(monkeypatch, calls, content="local caption")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["choices"][0]["message"]["content"] == "local caption"
|
||||||
|
local = [c for c in calls if c["url"].endswith("/chat/completions")]
|
||||||
|
assert local, "the local fallback should POST to its own /chat/completions"
|
||||||
|
parts = local[0]["body"]["messages"][0]["content"]
|
||||||
|
assert any(
|
||||||
|
isinstance(p, dict) and p.get("type") == "image_url" for p in parts
|
||||||
|
), "the image_url part must survive into the local call"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fallback_respects_streaming(router, monkeypatch):
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
_local_vision_fake(monkeypatch, calls, content="streamed caption")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages(), "stream": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "streamed caption" in resp.text
|
||||||
|
assert "data: [DONE]" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fallback_failure_still_422s(router, monkeypatch):
|
||||||
|
"""A local vision failure must be visible, not swallowed into a 200."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
_local_vision_fake(monkeypatch, calls, status=500)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "vision" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_json_object_response_format_routes_to_a_json_capable_model(router):
|
||||||
|
"""response_format json_object hard-restricts to JSON-mode-capable rows."""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "auto",
|
||||||
|
"messages": _messages(),
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == CHEAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_json_object_request_without_a_json_capable_model_422s(router):
|
||||||
|
client, calls, db_path = router
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute("UPDATE models SET supports_json_mode = 0")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "auto",
|
||||||
|
"messages": _messages(),
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "json" in resp.json()["detail"]
|
||||||
|
assert not calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_text_request_is_unaffected_by_the_gates(router):
|
||||||
|
"""No images, no response_format: routing is exactly as before."""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == CHEAP
|
||||||
|
assert resp.headers["X-Router-Model"] == CHEAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pinned_non_vision_model_with_an_image_is_a_clear_422(router):
|
||||||
|
"""A pin that cannot satisfy the request fails early with a named reason."""
|
||||||
|
client, calls, _ = router
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": DEAR, "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "vision" in resp.json()["detail"]
|
||||||
|
assert not calls, "no provider call should happen for an impossible pin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_two_pass_reroute_passes_image_capability(router):
|
||||||
|
"""The measured-context reroute must carry the image gate, not lose it."""
|
||||||
|
client, calls, _ = router
|
||||||
|
# ~1200 chars / 3 = 400 measured tokens > the stubbed estimate of 100, so
|
||||||
|
# chat_completions reroutes — and the reroute must still require vision.
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages("refactor " + "x " * 600)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["X-Router-Model"] == CHEAP
|
||||||
|
assert calls[0]["body"]["model"] == CHEAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fallback_refuses_a_remote_image_url(router, monkeypatch):
|
||||||
|
"""A remote http(s) URL is an SSRF vector by indirection and must not be
|
||||||
|
forwarded to the local model, even when the fallback is enabled."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
_local_vision_fake(monkeypatch, calls, content="should not be used")
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "describe this"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "http://internal/private.png"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": messages},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "vision" in resp.json()["detail"]
|
||||||
|
local = [c for c in calls if c.get("local")]
|
||||||
|
assert not local, "a remote image URL must never reach the local vision endpoint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fallback_wont_masquerade_an_empty_answer_as_200(router, monkeypatch):
|
||||||
|
"""A 200 with empty content is a failed answer, and must fall through to 422
|
||||||
|
rather than return an empty 200 — a hidden failure must not look like a win."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
_local_vision_fake(monkeypatch, calls, content=" ")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "vision" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fallback_refuses_too_many_images(router, monkeypatch):
|
||||||
|
"""An image count above the budget refuses the local call before spending it."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
_local_vision_fake(monkeypatch, calls, content="caption")
|
||||||
|
|
||||||
|
many = _image_messages()
|
||||||
|
for _ in range(dispatcher.cfg.local_vision.max_images + 1):
|
||||||
|
many[0]["content"].append({"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}})
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": many},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
local = [c for c in calls if c.get("local")]
|
||||||
|
assert not local, "over-budget image count must skip the local call"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fallback_refuses_when_api_key_env_is_missing(router, monkeypatch):
|
||||||
|
"""A configured local vision api_key_env with no env var declines the call."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "api_key_env", "LOCAL_VISION_KEY")
|
||||||
|
monkeypatch.delenv("LOCAL_VISION_KEY", raising=False)
|
||||||
|
_local_vision_fake(monkeypatch, calls, content="caption")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
local = [c for c in calls if c.get("local")]
|
||||||
|
assert not local, "a missing vision key must skip the local call"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fallback_refuses_an_unparseable_response(router, monkeypatch):
|
||||||
|
"""An unparseable local response body must not yield a 200."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
_drop_cheap_by_tier(dispatcher, db_path)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", True)
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, json=None, stream=False, timeout=None):
|
||||||
|
calls.append({"url": url, "body": json, "stream": stream, "local": True})
|
||||||
|
return FakeResponse({}, status_code=200)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.requests, "post", fake_post)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "auto", "messages": _image_messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "vision" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pinned_non_json_model_with_json_response_format_422s(router):
|
||||||
|
"""The JSON-mode arm of the pass-through check mirrors the vision one: a pin
|
||||||
|
whose model lacks JSON mode gets a clear 422, not a provider 400."""
|
||||||
|
client, calls, db_path = router
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute("UPDATE models SET supports_json_mode = 0 WHERE model_id = ?", (DEAR,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": DEAR,
|
||||||
|
"messages": _messages(),
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "json" in resp.json()["detail"]
|
||||||
|
assert not calls, "no provider call should happen for an impossible json pin"
|
||||||
63
tests/test_classifier_input.py
Normal file
63
tests/test_classifier_input.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""The classifier gets the instruction, not the document.
|
||||||
|
|
||||||
|
It decides a category and a tier. Handing it a pasted document is harmful
|
||||||
|
rather than merely wasteful — measured on a ~20k-token prompt, both local
|
||||||
|
models failed and neither failed cleanly:
|
||||||
|
|
||||||
|
qwen3.5 28.7s, finish_reason=length, empty content
|
||||||
|
mistral-nemo 41.8s, echoed the input back inside its JSON
|
||||||
|
|
||||||
|
Both surface as unparseable output, i.e. 30-40s of local inference spent to
|
||||||
|
reach the same `source: "fallback"` an instant failure would have produced.
|
||||||
|
With the clamp, the same prompts classify correctly in ~2.2s.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dispatcher import clamp_for_classifier
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_input_is_untouched():
|
||||||
|
assert clamp_for_classifier("classify me", 8000) == "classify me"
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_at_the_limit_is_untouched():
|
||||||
|
text = "x" * 8000
|
||||||
|
assert clamp_for_classifier(text, 8000) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_instruction_survives_at_either_end():
|
||||||
|
# Which end carries the instruction depends on how the caller phrased it:
|
||||||
|
# "Translate this: <doc>" puts it first, "<doc> — translate this" last.
|
||||||
|
# The middle of a pasted document is the one part that never carries it.
|
||||||
|
text = "HEAD-INSTRUCTION " + ("filler " * 20000) + " TAIL-INSTRUCTION"
|
||||||
|
out = clamp_for_classifier(text, 8000)
|
||||||
|
assert "HEAD-INSTRUCTION" in out
|
||||||
|
assert "TAIL-INSTRUCTION" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamped_output_is_bounded():
|
||||||
|
text = "y" * 500_000
|
||||||
|
out = clamp_for_classifier(text, 8000)
|
||||||
|
# Head + tail + the elision marker, not the original.
|
||||||
|
assert len(out) < 8200
|
||||||
|
assert len(out) < len(text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_elision_is_visible_to_the_model():
|
||||||
|
# The model should be able to tell it is seeing an excerpt, rather than
|
||||||
|
# silently reasoning about a document that appears to jump mid-sentence.
|
||||||
|
out = clamp_for_classifier("z" * 50_000, 8000)
|
||||||
|
assert "elided" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_disables_clamping():
|
||||||
|
# Escape hatch: a deployment with a large-context classifier and a reason
|
||||||
|
# to use it should not have to patch code.
|
||||||
|
text = "w" * 100_000
|
||||||
|
assert clamp_for_classifier(text, 0) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_is_treated_as_disabled_not_as_a_crash():
|
||||||
|
text = "w" * 100_000
|
||||||
|
assert clamp_for_classifier(text, -1) == text
|
||||||
232
tests/test_config_endpoints.py
Normal file
232
tests/test_config_endpoints.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""The classifier and the local verifier are separate endpoints.
|
||||||
|
|
||||||
|
They used to be one: the verifier derived its URL by stripping ``/v1`` off
|
||||||
|
``classifier.base_url``. That silently coupled two unrelated decisions, and
|
||||||
|
the coupling only becomes visible when the classifier is moved off-host —
|
||||||
|
pointing classification at a cloud provider would have sent every local
|
||||||
|
verification to ``<provider>/api/chat``, which does not exist.
|
||||||
|
|
||||||
|
The verifier speaks Ollama's NATIVE API (``/api/chat`` with ``think=False``)
|
||||||
|
because that is the only way to disable the reasoning trace, so it cannot
|
||||||
|
follow the classifier anywhere the classifier can go.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from config import RouterConfig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def raw() -> dict:
|
||||||
|
with open("config.yaml") as fh:
|
||||||
|
return yaml.safe_load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_the_classifier_to_a_cloud_provider_leaves_the_verifier_local(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1"
|
||||||
|
cfg["classifier"]["api_key_env"] = "NEURALWATT_API_KEY"
|
||||||
|
cfg["classifier"]["model"] = "deepseek-v4-flash"
|
||||||
|
|
||||||
|
loaded = RouterConfig(**cfg)
|
||||||
|
|
||||||
|
assert loaded.classifier.base_url == "https://api.neuralwatt.com/v1"
|
||||||
|
# The whole point: this did NOT follow the line above.
|
||||||
|
assert "localhost" in loaded.verification.base_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_verifier_defaults_to_a_local_ollama(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["verification"].pop("base_url", None)
|
||||||
|
assert RouterConfig(**cfg).verification.base_url == "http://localhost:11434"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_absent_api_key_env_means_unauthenticated(raw):
|
||||||
|
# The local Ollama case. It ignores the key entirely, but the SDK requires
|
||||||
|
# one to be set, so the dispatcher substitutes a placeholder. Asserted on
|
||||||
|
# an explicit config rather than on whatever config.yaml currently says,
|
||||||
|
# which is a deployment choice and not a property of the code.
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["classifier"]["base_url"] = "http://localhost:11434/v1"
|
||||||
|
cfg["classifier"].pop("api_key_env", None)
|
||||||
|
assert RouterConfig(**cfg).classifier.api_key_env is None
|
||||||
|
|
||||||
|
cfg["classifier"]["api_key_env"] = "NEURALWATT_API_KEY"
|
||||||
|
assert RouterConfig(**cfg).classifier.api_key_env == "NEURALWATT_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_model_may_be_unset_while_both_run_on_one_host(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["classifier"]["base_url"] = "http://localhost:11434/v1"
|
||||||
|
cfg["verification"]["model"] = None
|
||||||
|
loaded = RouterConfig(**cfg)
|
||||||
|
# Resolved by the dispatcher as `verification.model or classifier.model`,
|
||||||
|
# which is right only because the hosts match.
|
||||||
|
assert loaded.verification.model is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unset_verifier_model_is_refused_once_the_hosts_differ(raw):
|
||||||
|
# The failure this prevents is SILENT, which is why it is a load-time
|
||||||
|
# error rather than a documented caveat. Observed directly: with the
|
||||||
|
# classifier on NeuralWatt and this left null, the verifier POSTed
|
||||||
|
# `deepseek-v4-flash` to localhost:11434, 404d, caught it, logged "local
|
||||||
|
# verification unavailable" and recorded no sample. Verification looked
|
||||||
|
# enabled while producing nothing.
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1"
|
||||||
|
cfg["classifier"]["model"] = "deepseek-v4-flash"
|
||||||
|
cfg["verification"]["model"] = None
|
||||||
|
cfg["verification"]["local_llm_enabled"] = True
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="verification.model must be set"):
|
||||||
|
RouterConfig(**cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_split_host_setup_loads_once_the_verifier_model_is_stated(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1"
|
||||||
|
cfg["classifier"]["api_key_env"] = "NEURALWATT_API_KEY"
|
||||||
|
cfg["classifier"]["model"] = "deepseek-v4-flash"
|
||||||
|
cfg["verification"]["model"] = "qwen3.5:latest"
|
||||||
|
|
||||||
|
loaded = RouterConfig(**cfg)
|
||||||
|
assert loaded.classifier.model == "deepseek-v4-flash"
|
||||||
|
assert loaded.verification.model == "qwen3.5:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabling_the_local_check_lifts_the_requirement(raw):
|
||||||
|
# A host with no local inference at all: nothing to name, nothing to guard.
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1"
|
||||||
|
cfg["verification"]["model"] = None
|
||||||
|
cfg["verification"]["local_llm_enabled"] = False
|
||||||
|
|
||||||
|
loaded = RouterConfig(**cfg)
|
||||||
|
assert loaded.verification.local_llm_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_host_with_no_local_inference_can_turn_the_local_check_off(raw):
|
||||||
|
# An RPi has no usable local model. Structural verification is pure Python
|
||||||
|
# and keeps running; only the LLM check goes away.
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["verification"]["local_llm_enabled"] = False
|
||||||
|
loaded = RouterConfig(**cfg)
|
||||||
|
assert loaded.verification.local_llm_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- an unknown key is an error, not a no-op ------------------------------
|
||||||
|
|
||||||
|
def test_a_key_in_the_wrong_section_is_rejected(raw):
|
||||||
|
# Exactly the mistake that shipped: max_input_chars belongs to the
|
||||||
|
# classifier and was written into verification, where pydantic's default
|
||||||
|
# extra="ignore" accepted it, dropped it, and left the code default in
|
||||||
|
# force. It carried the same value, so nothing looked wrong -- but editing
|
||||||
|
# it would have done nothing at all.
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["verification"]["max_input_chars"] = 4000
|
||||||
|
with pytest.raises(ValueError, match="max_input_chars"):
|
||||||
|
RouterConfig(**cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_misspelled_key_is_rejected(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["routing"]["min_tool_proficency"] = 0.5 # sic
|
||||||
|
with pytest.raises(ValueError, match="min_tool_proficency"):
|
||||||
|
RouterConfig(**cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_config_has_no_unknown_keys(raw):
|
||||||
|
# Guards the whole file, not just the sections a test happens to name.
|
||||||
|
RouterConfig(**raw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tool_filter_can_be_turned_off_from_config(raw):
|
||||||
|
# It is a knob to experiment with, so null must be a legal value rather
|
||||||
|
# than something requiring a code change.
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["routing"]["min_tool_proficiency"] = None
|
||||||
|
assert RouterConfig(**cfg).routing.min_tool_proficiency is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabling_the_filter_lifts_the_category_name_check(raw):
|
||||||
|
# With no filter there is nothing to join, so an unused category name
|
||||||
|
# must not block startup.
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["routing"]["min_tool_proficiency"] = None
|
||||||
|
cfg["routing"]["tool_use_category"] = "not_a_real_category"
|
||||||
|
assert RouterConfig(**cfg).routing.min_tool_proficiency is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- the CLI sanity check -------------------------------------------------
|
||||||
|
|
||||||
|
def test_the_config_summary_names_fields_that_exist(raw):
|
||||||
|
"""`python config.py` is the documented setup step, and it crashed.
|
||||||
|
|
||||||
|
It printed cfg.weights, which had been replaced by cfg.objective, so the
|
||||||
|
one command whose job is to prove the config is fine reported "Config
|
||||||
|
loaded OK" and then died with AttributeError. A function plus this test
|
||||||
|
means the names cannot rot silently again.
|
||||||
|
"""
|
||||||
|
from config import summary_lines
|
||||||
|
|
||||||
|
lines = summary_lines(RouterConfig(**raw))
|
||||||
|
|
||||||
|
assert lines[0] == "Config loaded OK"
|
||||||
|
body = "\n".join(lines[1:])
|
||||||
|
assert "quality_tolerance=" in body
|
||||||
|
assert "classifier:" in body
|
||||||
|
assert "dispatch providers:" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_removed_key_is_rejected_rather_than_ignored(raw):
|
||||||
|
"""log_path named a file nothing ever wrote; leaving it valid would lie."""
|
||||||
|
import copy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["logging"]["log_path"] = "router.log"
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="log_path|Extra inputs"):
|
||||||
|
RouterConfig(**cfg)
|
||||||
|
|
||||||
|
|
||||||
|
# --- capability gates and the local vision fallback ------------------------
|
||||||
|
|
||||||
|
def test_the_shipped_config_loads_with_the_new_keys(raw):
|
||||||
|
loaded = RouterConfig(**raw)
|
||||||
|
assert loaded.routing.require_vision is True
|
||||||
|
assert loaded.routing.require_json_mode is True
|
||||||
|
assert loaded.local_vision.model == "qwen3-vl:4b"
|
||||||
|
assert loaded.local_vision.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_typo_in_require_vision_is_rejected(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["routing"]["require_visoin"] = True # sic
|
||||||
|
with pytest.raises(ValueError, match="require_visoin"):
|
||||||
|
RouterConfig(**cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_vision_can_be_disabled_from_config(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["local_vision"]["enabled"] = False
|
||||||
|
assert RouterConfig(**cfg).local_vision.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_vision_defaults_when_absent(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg.pop("local_vision")
|
||||||
|
assert RouterConfig(**cfg).local_vision.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonpositive_local_timeout_is_rejected(raw):
|
||||||
|
cfg = copy.deepcopy(raw)
|
||||||
|
cfg["local_vision"]["timeout_seconds"] = 0
|
||||||
|
with pytest.raises(ValueError, match="timeout_seconds"):
|
||||||
|
RouterConfig(**cfg)
|
||||||
@@ -13,6 +13,8 @@ point of testing it.
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from eval_proficiency import (
|
from eval_proficiency import (
|
||||||
|
judge_for,
|
||||||
|
parse_check_results,
|
||||||
extract_judge_json,
|
extract_judge_json,
|
||||||
judge_for,
|
judge_for,
|
||||||
normalize_answer,
|
normalize_answer,
|
||||||
@@ -207,3 +209,92 @@ def test_a_single_leading_space_does_not_destroy_a_correct_answer():
|
|||||||
def test_indented_fenced_block_is_still_recovered():
|
def test_indented_fenced_block_is_still_recovered():
|
||||||
code = ' ```python\ndef f(x):\n return x * 2\n```'
|
code = ' ```python\ndef f(x):\n return x * 2\n```'
|
||||||
assert score_code(code, ["f(2)==4"])[0] == 1.0
|
assert score_code(code, ["f(2)==4"])[0] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- harness integrity ----------------------------------------------------
|
||||||
|
#
|
||||||
|
# The harness runs the model's code as `__main__`, so anything the model prints
|
||||||
|
# lands in the same stdout the verdicts are read from. Counting the substring
|
||||||
|
# "PASS" therefore let a model vote on its own work.
|
||||||
|
|
||||||
|
def test_a_models_own_demo_output_cannot_award_it_marks():
|
||||||
|
"""Measured at 1.50 on a two-check task before the verdicts carried a nonce."""
|
||||||
|
code = (
|
||||||
|
"def add(a, b):\n"
|
||||||
|
" return a + b\n"
|
||||||
|
"\n"
|
||||||
|
'if __name__ == "__main__":\n'
|
||||||
|
' print("self-test:", add(1, 2) == 3 and "PASS" or "FAIL")\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
score, detail = score_code(code, ["add(1,2)==3", "add(-1,1)==0"])
|
||||||
|
|
||||||
|
assert score == 1.0
|
||||||
|
assert detail == "2/2 checks"
|
||||||
|
|
||||||
|
|
||||||
|
def test_printing_PASS_cannot_rescue_a_wrong_answer():
|
||||||
|
"""The failure that matters: noise must not manufacture credit."""
|
||||||
|
code = (
|
||||||
|
"def add(a, b):\n"
|
||||||
|
" return 0\n"
|
||||||
|
"\n"
|
||||||
|
'if __name__ == "__main__":\n'
|
||||||
|
' print("PASS PASS PASS")\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both checks genuinely fail for a function that always returns 0.
|
||||||
|
assert score_code(code, ["add(1,2)==3", "add(2,2)==4"])[0] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_score_can_never_exceed_one():
|
||||||
|
"""Whatever a model prints, the fraction stays a fraction."""
|
||||||
|
code = "def f(x):\n return x\n" + 'print("CHECK 0 PASS\\n" * 50)\n'
|
||||||
|
|
||||||
|
assert score_code(code, ["f(1)==1"])[0] <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_results_are_read_by_index_not_counted():
|
||||||
|
marker = "CHECK-deadbeef"
|
||||||
|
stdout = (
|
||||||
|
"PASS\n" # the model's noise
|
||||||
|
f"{marker} 0 PASS\n"
|
||||||
|
f"{marker} 1 FAIL\n"
|
||||||
|
"CHECK 2 PASS\n" # right shape, wrong marker
|
||||||
|
f"{marker} 0 PASS\n" # a repeat cannot double-count
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parse_check_results(stdout, marker) == {0: True, 1: False}
|
||||||
|
|
||||||
|
|
||||||
|
# --- judge selection ------------------------------------------------------
|
||||||
|
|
||||||
|
def test_the_configured_judge_is_used_when_it_is_a_different_model():
|
||||||
|
assert judge_for("gemma-4-31b", "kimi-k3") == "kimi-k3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_model_is_never_judged_by_its_own_family():
|
||||||
|
"""Including the case that broke it: the judge and the alternate agreeing."""
|
||||||
|
assert judge_for("kimi-k3", "kimi-k3") != "kimi-k3"
|
||||||
|
# `--judge-model qwen3.6-35b` used to hand qwen3.6-35b back to itself.
|
||||||
|
assert judge_for("qwen3.6-35b", "qwen3.6-35b") != "qwen3.6-35b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fast_row_is_not_judged_by_its_reasoning_on_sibling():
|
||||||
|
"""Same weights, so the conflict is the same one."""
|
||||||
|
assert judge_for("qwen3.6-35b-fast", "qwen3.6-35b") not in (
|
||||||
|
"qwen3.6-35b", "qwen3.6-35b-fast"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_judge_outside_the_family_means_no_sample():
|
||||||
|
"""Skipping beats self-grading, the same rule an unusable judge reply follows."""
|
||||||
|
assert judge_for("kimi-k3", "kimi-k3", ) is not None # alternates exist
|
||||||
|
import eval_proficiency
|
||||||
|
|
||||||
|
original = eval_proficiency.ALTERNATE_JUDGES
|
||||||
|
try:
|
||||||
|
eval_proficiency.ALTERNATE_JUDGES = ("kimi-k3-fast",)
|
||||||
|
assert eval_proficiency.judge_for("kimi-k3", "kimi-k3") is None
|
||||||
|
finally:
|
||||||
|
eval_proficiency.ALTERNATE_JUDGES = original
|
||||||
|
|||||||
@@ -170,3 +170,22 @@ def test_context_estimate_reads_multimodal_text_parts():
|
|||||||
{"type": "image_url", "image_url": {"url": "data:..."}},
|
{"type": "image_url", "image_url": {"url": "data:..."}},
|
||||||
]}]
|
]}]
|
||||||
assert estimate_prompt_tokens(msgs) == 100
|
assert estimate_prompt_tokens(msgs) == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_estimate_counts_tool_definitions():
|
||||||
|
# upstream_body = {**body, "model": target} forwards `tools` to the
|
||||||
|
# provider verbatim on every call, so it counts against the same context
|
||||||
|
# window and has to count here too. Omitting it undercounts every
|
||||||
|
# tool-carrying request -- nearly all agent traffic -- which is exactly
|
||||||
|
# how qwen3.6-35b got a live 400 for "too long ... even after
|
||||||
|
# compaction" while the router's own estimate said it fit.
|
||||||
|
from dispatcher import estimate_prompt_tokens
|
||||||
|
|
||||||
|
msgs = [{"role": "user", "content": "a" * 300}]
|
||||||
|
tools = [{"type": "function", "function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
|
||||||
|
}}]
|
||||||
|
without_tools = estimate_prompt_tokens(msgs)
|
||||||
|
with_tools = estimate_prompt_tokens(msgs, tools=tools)
|
||||||
|
assert with_tools > without_tools
|
||||||
|
|||||||
238
tests/test_logs.py
Normal file
238
tests/test_logs.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Tests for the router's structured logging.
|
||||||
|
|
||||||
|
Written against the real handler and formatter rather than pytest's caplog,
|
||||||
|
because the thing worth pinning is what actually lands in the journal --
|
||||||
|
including the priority prefix, which caplog would never see.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import logs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_logger(monkeypatch):
|
||||||
|
"""Every test gets a fresh logger and a clean environment."""
|
||||||
|
monkeypatch.delenv(logs.LEVEL_ENV, raising=False)
|
||||||
|
monkeypatch.delenv("JOURNAL_STREAM", raising=False)
|
||||||
|
logs.set_trace(logs.NO_TRACE)
|
||||||
|
yield
|
||||||
|
for handler in list(logs.log.handlers):
|
||||||
|
logs.log.removeHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(level="debug", **kwargs):
|
||||||
|
buf = io.StringIO()
|
||||||
|
logs.configure(level, stream=buf, **kwargs)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
# --- rendering ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_line_is_logfmt_with_the_trace_id_first():
|
||||||
|
buf = _capture()
|
||||||
|
logs.set_trace("r123456")
|
||||||
|
|
||||||
|
logs.info("route", cat="coding_general", tier=2)
|
||||||
|
|
||||||
|
assert buf.getvalue() == "route id=r123456 cat=coding_general tier=2\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_values_with_spaces_are_quoted():
|
||||||
|
buf = _capture()
|
||||||
|
|
||||||
|
logs.info("filter", detail="held during peak")
|
||||||
|
|
||||||
|
assert 'detail="held during peak"' in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_value_never_breaks_the_line():
|
||||||
|
"""One event, one line -- a newline in a detail string would forge a record."""
|
||||||
|
buf = _capture()
|
||||||
|
|
||||||
|
logs.info("verify", detail="line one\nline two")
|
||||||
|
|
||||||
|
assert buf.getvalue().count("\n") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_is_a_dash_and_bools_are_binary():
|
||||||
|
buf = _capture()
|
||||||
|
|
||||||
|
logs.info("route", pick=None, tools=True, stream=False)
|
||||||
|
|
||||||
|
assert "pick=- tools=1 stream=0" in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_floats_are_readable_not_repr():
|
||||||
|
"""Microdollars and kWh, without 0.0007830000000000001."""
|
||||||
|
buf = _capture()
|
||||||
|
|
||||||
|
logs.info("dispatch", usd=0.000783123456, kwh=4.75e-05)
|
||||||
|
|
||||||
|
assert "usd=0.000783123" in buf.getvalue()
|
||||||
|
assert "kwh=4.75e-05" in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_string_is_visible():
|
||||||
|
buf = _capture()
|
||||||
|
|
||||||
|
logs.info("classify", cat="")
|
||||||
|
|
||||||
|
assert 'cat=""' in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# --- levels ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_debug_is_silent_at_info_level():
|
||||||
|
buf = _capture("info")
|
||||||
|
|
||||||
|
logs.debug("filter", model="m")
|
||||||
|
logs.info("route", pick="m")
|
||||||
|
|
||||||
|
assert "filter" not in buf.getvalue()
|
||||||
|
assert "route" in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_for_debug_reports_the_level():
|
||||||
|
_capture("info")
|
||||||
|
assert logs.enabled_for_debug() is False
|
||||||
|
_capture("debug")
|
||||||
|
assert logs.enabled_for_debug() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_env_var_overrides_the_configured_level(monkeypatch):
|
||||||
|
"""So a live service can be turned up without editing a tracked file."""
|
||||||
|
monkeypatch.setenv(logs.LEVEL_ENV, "debug")
|
||||||
|
buf = _capture("warning")
|
||||||
|
|
||||||
|
logs.debug("filter", model="m")
|
||||||
|
|
||||||
|
assert "filter" in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nonsense_env_level_falls_back_rather_than_crashing(monkeypatch):
|
||||||
|
monkeypatch.setenv(logs.LEVEL_ENV, "loud")
|
||||||
|
|
||||||
|
assert logs.resolve_level("warning") == logging.WARNING
|
||||||
|
|
||||||
|
|
||||||
|
# --- journald -------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_no_priority_prefix_in_a_terminal():
|
||||||
|
"""A foreground uvicorn should not print a literal <6> on every line."""
|
||||||
|
buf = _capture(journald=False)
|
||||||
|
|
||||||
|
logs.info("route", pick="m")
|
||||||
|
|
||||||
|
assert buf.getvalue().startswith("route ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_prefix_under_journald():
|
||||||
|
"""This is what makes `journalctl -p warning` mean anything."""
|
||||||
|
buf = _capture(journald=True)
|
||||||
|
|
||||||
|
logs.warning("retry", verdict="malformed")
|
||||||
|
logs.error("upstream", status=500)
|
||||||
|
logs.info("route", pick="m")
|
||||||
|
|
||||||
|
lines = buf.getvalue().splitlines()
|
||||||
|
assert lines[0].startswith("<4>") # warning
|
||||||
|
assert lines[1].startswith("<3>") # error
|
||||||
|
assert lines[2].startswith("<6>") # info
|
||||||
|
|
||||||
|
|
||||||
|
def test_journald_is_detected_by_matching_the_actual_stream(tmp_path, monkeypatch):
|
||||||
|
"""The env var alone is not enough -- it is inherited by every child.
|
||||||
|
|
||||||
|
A foreground uvicorn started from a systemd-managed session, with output
|
||||||
|
redirected to a file, inherited JOURNAL_STREAM and put a literal <7> on
|
||||||
|
every line. systemd documents comparing the value against the fd.
|
||||||
|
"""
|
||||||
|
path = tmp_path / "out.log"
|
||||||
|
with open(path, "w") as handle:
|
||||||
|
stat = os.fstat(handle.fileno())
|
||||||
|
|
||||||
|
monkeypatch.setenv("JOURNAL_STREAM", f"{stat.st_dev}:{stat.st_ino}")
|
||||||
|
assert logs.under_journald(handle) is True
|
||||||
|
|
||||||
|
# Same variable, a different stream: this is the inherited case.
|
||||||
|
monkeypatch.setenv("JOURNAL_STREAM", f"{stat.st_dev}:{stat.st_ino + 1}")
|
||||||
|
assert logs.under_journald(handle) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_journald_variable_means_no_prefix():
|
||||||
|
assert logs.under_journald() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stream_with_no_fileno_is_not_journald():
|
||||||
|
"""StringIO under test, and anything else without an fd."""
|
||||||
|
assert logs.under_journald(io.StringIO()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_malformed_journald_variable_is_ignored(monkeypatch):
|
||||||
|
monkeypatch.setenv("JOURNAL_STREAM", "not-a-device")
|
||||||
|
assert logs.under_journald() is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- trace ids ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_trace_id_is_stable_until_a_new_one_is_started():
|
||||||
|
first = logs.new_trace()
|
||||||
|
|
||||||
|
assert logs.current_trace() == first
|
||||||
|
assert logs.new_trace() != first
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_ids_do_not_collide():
|
||||||
|
assert len({logs.new_trace() for _ in range(200)}) == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_is_idempotent():
|
||||||
|
"""Re-importing or re-configuring must not double every line."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
logs.configure("info", stream=buf, journald=False)
|
||||||
|
logs.configure("info", stream=buf, journald=False)
|
||||||
|
|
||||||
|
logs.info("route", pick="m")
|
||||||
|
|
||||||
|
assert buf.getvalue().count("route") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_logger_does_not_propagate():
|
||||||
|
"""uvicorn configures its own logging; a root handler would double-print."""
|
||||||
|
_capture()
|
||||||
|
assert logs.log.propagate is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- bound emitters -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_bound_emitter_ignores_the_ambient_trace():
|
||||||
|
"""What the streaming path needs: an id that survives a context copy."""
|
||||||
|
buf = _capture()
|
||||||
|
bound = logs.bind("rfixed1")
|
||||||
|
logs.set_trace("rother2")
|
||||||
|
|
||||||
|
bound.info("dispatch", model="m")
|
||||||
|
|
||||||
|
assert "id=rfixed1" in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_defaults_to_the_current_trace():
|
||||||
|
buf = _capture()
|
||||||
|
logs.set_trace("rcurrent")
|
||||||
|
|
||||||
|
logs.bind().warning("retry", verdict="malformed")
|
||||||
|
|
||||||
|
assert "id=rcurrent" in buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bound_emitter_respects_the_level():
|
||||||
|
buf = _capture("info")
|
||||||
|
|
||||||
|
logs.bind("r1").debug("filter", model="m")
|
||||||
|
|
||||||
|
assert buf.getvalue() == ""
|
||||||
156
tests/test_outcome_attribution.py
Normal file
156
tests/test_outcome_attribution.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Tests for which completion an outcome report attaches to.
|
||||||
|
|
||||||
|
`POST /outcome` is the only ground truth the router gets, so attributing one
|
||||||
|
to the wrong conversation is worse than losing it: a model gets penalized for
|
||||||
|
work it never did. The guard is a short window plus a refusal -- if more than
|
||||||
|
one conversation was served inside it, the report is refused rather than
|
||||||
|
guessed at.
|
||||||
|
|
||||||
|
The window was not a window. `observed_at` is written by
|
||||||
|
`datetime.now(timezone.utc).isoformat()`, which separates date from time with
|
||||||
|
'T', while `datetime('now', ...)` returns a space. Compared as strings, 'T'
|
||||||
|
sorts after ' ', so the time of day never participated once the dates matched
|
||||||
|
and a 120-second window admitted everything served that day. Measured on the
|
||||||
|
live DB: 14 rows across 2 sessions where the correct comparison matched 0.
|
||||||
|
|
||||||
|
These tests write timestamps through the same call the dispatcher uses, so
|
||||||
|
they stay honest if that format ever changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import dispatcher
|
||||||
|
from dispatcher import AMBIGUOUS, SEED_CATEGORY, _most_recent_if_unambiguous
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCHEMA_SQL = (ROOT / "schema.sql").read_text()
|
||||||
|
|
||||||
|
# Short, so "earlier today" is unambiguously outside it. The old row below sits
|
||||||
|
# at the first instant of the current UTC day -- the earliest time that still
|
||||||
|
# shares today's date, which is what the string comparison needed to go wrong.
|
||||||
|
WINDOW_SECONDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_of_today() -> datetime:
|
||||||
|
return _now().replace(hour=0, minute=0, second=0, microsecond=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def short_window(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dispatcher.cfg.verification,
|
||||||
|
"outcome_attribution_window_seconds",
|
||||||
|
WINDOW_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(tmp_path):
|
||||||
|
conn = sqlite3.connect(tmp_path / "test.db")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _observe(conn, *, when: datetime, session_key: str, request_id: str,
|
||||||
|
category: str = "coding_general"):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO energy_observations (
|
||||||
|
model_id, provider, request_id, session_key, task_category, observed_at
|
||||||
|
) VALUES ('m', 'neuralwatt', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
# Written exactly the way log_observation writes it.
|
||||||
|
(request_id, session_key, category, when.isoformat()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_session_from_earlier_today_is_outside_the_window(db):
|
||||||
|
"""The regression: same calendar date is not the same as inside 5 seconds."""
|
||||||
|
_observe(db, when=_start_of_today(), session_key="morning", request_id="old")
|
||||||
|
_observe(db, when=_now(), session_key="now", request_id="new")
|
||||||
|
|
||||||
|
row = _most_recent_if_unambiguous(db)
|
||||||
|
|
||||||
|
assert row is not AMBIGUOUS, "an 8-hours-stale session must not create ambiguity"
|
||||||
|
assert row["request_id"] == "new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_old_session_alone_is_not_recent_enough_to_attribute(db):
|
||||||
|
"""With nothing inside the window there is nothing to attach to -- 404, not a guess."""
|
||||||
|
_observe(db, when=_start_of_today(), session_key="morning", request_id="old")
|
||||||
|
|
||||||
|
assert _most_recent_if_unambiguous(db) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_live_conversations_are_refused(db):
|
||||||
|
"""The guard the window exists to serve, still working."""
|
||||||
|
_observe(db, when=_now(), session_key="alice", request_id="a")
|
||||||
|
_observe(db, when=_now(), session_key="bob", request_id="b")
|
||||||
|
|
||||||
|
assert _most_recent_if_unambiguous(db) is AMBIGUOUS
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_conversation_across_several_turns_is_not_ambiguous(db):
|
||||||
|
"""Several completions, one session -- the ordinary case must still attribute."""
|
||||||
|
for i in range(3):
|
||||||
|
_observe(db, when=_now(), session_key="alice", request_id=f"a{i}")
|
||||||
|
|
||||||
|
row = _most_recent_if_unambiguous(db)
|
||||||
|
|
||||||
|
assert row is not AMBIGUOUS
|
||||||
|
assert row["request_id"] == "a2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reference_sweep_is_not_a_conversation(db):
|
||||||
|
"""seed_energy's traffic must never make a real report look ambiguous."""
|
||||||
|
_observe(db, when=_now(), session_key=None, request_id="seed",
|
||||||
|
category=SEED_CATEGORY)
|
||||||
|
_observe(db, when=_now(), session_key="alice", request_id="a")
|
||||||
|
|
||||||
|
row = _most_recent_if_unambiguous(db)
|
||||||
|
|
||||||
|
assert row is not AMBIGUOUS
|
||||||
|
assert row["request_id"] == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quota_burn_counts_only_the_last_thirty_days(db, tmp_path, monkeypatch):
|
||||||
|
"""Same comparison, same fix -- an old row must not inflate the burn figure.
|
||||||
|
|
||||||
|
The row sits at the START of the day 30 days ago: the cutoff falls on that
|
||||||
|
same date but later in it, which is exactly the case a string comparison
|
||||||
|
gets wrong. A row from 45 days ago would be excluded either way and would
|
||||||
|
pin nothing.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.database, "path", str(tmp_path / "test.db"))
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.objective, "plan_kwh_per_period", 6.25)
|
||||||
|
just_outside = (_now() - timedelta(days=30)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=1
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO energy_observations (model_id, provider, energy_kwh, observed_at)
|
||||||
|
VALUES ('m', 'neuralwatt', 1.0, ?)
|
||||||
|
""",
|
||||||
|
(just_outside.isoformat(),),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO energy_observations (model_id, provider, energy_kwh, observed_at)
|
||||||
|
VALUES ('m', 'neuralwatt', 0.25, ?)
|
||||||
|
""",
|
||||||
|
(_now().isoformat(),),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert dispatcher.quota_burn()["metered_kwh_30d"] == pytest.approx(0.25)
|
||||||
@@ -105,3 +105,87 @@ def test_family_never_collapses_to_empty():
|
|||||||
# A degenerate id made only of suffix tokens must keep a segment, or every
|
# A degenerate id made only of suffix tokens must keep a segment, or every
|
||||||
# such row would share one meaningless family
|
# such row would share one meaningless family
|
||||||
assert parse_base_model_id("flex") == "flex"
|
assert parse_base_model_id("flex") == "flex"
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-model context overrides ------------------------------------------
|
||||||
|
#
|
||||||
|
# The knob validated and was never read, while config.yaml shipped a worked
|
||||||
|
# example for it. Anyone who followed that example got silence.
|
||||||
|
|
||||||
|
def _row(**overrides):
|
||||||
|
from poller import ModelRow
|
||||||
|
|
||||||
|
fields = dict(
|
||||||
|
model_id="qwen3.6-35b", provider="neuralwatt", base_model_id="qwen3.6-35b",
|
||||||
|
display_name=None, cost_per_1m_prompt=None, cost_per_1m_completion=None,
|
||||||
|
cost_per_1m_prompt_cached=None, context_window=200_000,
|
||||||
|
max_output_tokens=None, supports_tools=True, supports_json_mode=True,
|
||||||
|
supports_vision=False, supports_reasoning=True,
|
||||||
|
reasoning_default_enabled=True, latency_class="standard",
|
||||||
|
reasoning_mode="default", context_variant="full", access_level="public",
|
||||||
|
pricing_tbd=False, deprecated=False,
|
||||||
|
)
|
||||||
|
fields.update(overrides)
|
||||||
|
return ModelRow(**fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(overrides=None):
|
||||||
|
import copy
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from config import RouterConfig
|
||||||
|
|
||||||
|
with open("config.yaml") as fh:
|
||||||
|
raw = copy.deepcopy(yaml.safe_load(fh))
|
||||||
|
raw["context"]["per_model_overrides"] = overrides or {}
|
||||||
|
return RouterConfig(**raw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_global_safety_factor_applies_without_an_override():
|
||||||
|
# 200000 * 0.75 - 4096
|
||||||
|
assert _row().effective_context_window(_cfg()) == 145_904
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_per_model_safety_factor_is_actually_applied():
|
||||||
|
cfg = _cfg({"qwen3.6-35b": {"safety_factor": 0.85}})
|
||||||
|
|
||||||
|
# 200000 * 0.85 - 4096
|
||||||
|
assert _row().effective_context_window(cfg) == 165_904
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_override_for_another_model_does_not_leak():
|
||||||
|
cfg = _cfg({"kimi-k3": {"safety_factor": 0.85}})
|
||||||
|
|
||||||
|
assert _row().effective_context_window(cfg) == 145_904
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_zero_output_reserve_means_zero_not_unset():
|
||||||
|
"""Present-but-falsy: `or` would silently fall through to 4096."""
|
||||||
|
cfg = _cfg({"qwen3.6-35b": {"output_reserve_tokens": 0}})
|
||||||
|
|
||||||
|
assert _row().effective_context_window(cfg) == 150_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_override_reserve_beats_the_models_advertised_ceiling():
|
||||||
|
cfg = _cfg({"qwen3.6-35b": {"output_reserve_tokens": 8192}})
|
||||||
|
|
||||||
|
assert _row(max_output_tokens=16384).effective_context_window(cfg) == 141_808
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_key_falls_back_independently():
|
||||||
|
cfg = _cfg({"qwen3.6-35b": {"safety_factor": 0.85}})
|
||||||
|
|
||||||
|
# factor overridden, reserve still the global default
|
||||||
|
assert _row().effective_context_window(cfg) == 165_904
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_typo_inside_an_override_is_an_error():
|
||||||
|
"""The point of typing it: StrictModel has to reach inside the block."""
|
||||||
|
with pytest.raises(Exception, match="safety_factr|Extra inputs"):
|
||||||
|
_cfg({"qwen3.6-35b": {"safety_factr": 0.85}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_out_of_range_override_is_an_error():
|
||||||
|
with pytest.raises(Exception, match="safety_factor"):
|
||||||
|
_cfg({"qwen3.6-35b": {"safety_factor": 1.5}})
|
||||||
|
|||||||
@@ -177,3 +177,175 @@ def test_a_directly_measured_variant_is_never_overwritten(tmp_path):
|
|||||||
"SELECT self_eval_score FROM proficiency WHERE model_id='kimi-k3-flex'"
|
"SELECT self_eval_score FROM proficiency WHERE model_id='kimi-k3-flex'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert got["self_eval_score"] == 0.2
|
assert got["self_eval_score"] == 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_score_above_one_never_reaches_the_table(tmp_path):
|
||||||
|
"""The single write path is where a bad scorer gets stopped.
|
||||||
|
|
||||||
|
A blended_score above 1.0 does not just misreport one row: it raises
|
||||||
|
`best` in rank_candidates and shifts every other candidate's quality band.
|
||||||
|
"""
|
||||||
|
from config import load_config
|
||||||
|
from proficiency_store import add_self_eval
|
||||||
|
|
||||||
|
cfg = load_config("config.yaml")
|
||||||
|
conn = _models_db(tmp_path, [("kimi-k3", "kimi-k3", "standard", "default", "full")])
|
||||||
|
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "coding_general", [1.5, 1.0])
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT self_eval_score, blended_score FROM proficiency WHERE model_id = 'kimi-k3'"
|
||||||
|
).fetchone()
|
||||||
|
assert row["self_eval_score"] == 1.0
|
||||||
|
assert row["blended_score"] <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_negative_score_is_floored_at_zero(tmp_path):
|
||||||
|
from config import load_config
|
||||||
|
from proficiency_store import add_self_eval
|
||||||
|
|
||||||
|
cfg = load_config("config.yaml")
|
||||||
|
conn = _models_db(tmp_path, [("kimi-k3", "kimi-k3", "standard", "default", "full")])
|
||||||
|
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "coding_general", [-0.5, 0.5])
|
||||||
|
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT self_eval_score FROM proficiency WHERE model_id = 'kimi-k3'"
|
||||||
|
).fetchone()["self_eval_score"] == 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_inherited_variant_refreshes_when_its_family_is_remeasured(tmp_path):
|
||||||
|
"""Inheritance was a one-shot: a variant froze at its first copy, forever.
|
||||||
|
|
||||||
|
The old guard skipped any row with self_eval_samples > 0, and an inherited
|
||||||
|
row has samples > 0 because inheritance copies them -- so it could never
|
||||||
|
tell "measured here" from "copied here" and never refreshed. Observed live:
|
||||||
|
kimi-k3 reached 0.957 while kimi-k3-flex sat at 0.85 with a week-old
|
||||||
|
timestamp, and the eval reported "propagated 0 inherited rows". Flex rows
|
||||||
|
serve auto:batch, so those requests ranked on stale scores.
|
||||||
|
"""
|
||||||
|
from config import load_config
|
||||||
|
from proficiency_store import add_self_eval, propagate_to_variants
|
||||||
|
|
||||||
|
cfg = load_config("config.yaml")
|
||||||
|
conn = _models_db(tmp_path, [
|
||||||
|
("kimi-k3", "kimi-k3", "standard", "default", "full"),
|
||||||
|
("kimi-k3-flex", "kimi-k3", "flex", "default", "full"),
|
||||||
|
])
|
||||||
|
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "docs_writing", [0.85, 0.85])
|
||||||
|
assert propagate_to_variants(conn, cfg, "kimi-k3", "nw") == 1
|
||||||
|
inherited = conn.execute(
|
||||||
|
"SELECT blended_score, inherited_from FROM proficiency WHERE model_id='kimi-k3-flex'"
|
||||||
|
).fetchone()
|
||||||
|
assert inherited["blended_score"] == pytest.approx(0.85)
|
||||||
|
assert inherited["inherited_from"] == "kimi-k3"
|
||||||
|
|
||||||
|
# The family learns more; the variant must follow it.
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "docs_writing", [1.0, 1.0, 1.0, 1.0])
|
||||||
|
|
||||||
|
assert propagate_to_variants(conn, cfg, "kimi-k3", "nw") == 1
|
||||||
|
refreshed = conn.execute(
|
||||||
|
"SELECT blended_score, self_eval_samples FROM proficiency WHERE model_id='kimi-k3-flex'"
|
||||||
|
).fetchone()
|
||||||
|
assert refreshed["blended_score"] == pytest.approx(0.95)
|
||||||
|
assert refreshed["self_eval_samples"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_measured_variant_still_outranks_its_family(tmp_path):
|
||||||
|
"""The protection the old guard was reaching for, kept intact."""
|
||||||
|
from config import load_config
|
||||||
|
from proficiency_store import add_self_eval, propagate_to_variants
|
||||||
|
|
||||||
|
cfg = load_config("config.yaml")
|
||||||
|
conn = _models_db(tmp_path, [
|
||||||
|
("kimi-k3", "kimi-k3", "standard", "default", "full"),
|
||||||
|
("kimi-k3-flex", "kimi-k3", "flex", "default", "full"),
|
||||||
|
])
|
||||||
|
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "docs_writing", [1.0])
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3-flex", "nw", "docs_writing", [0.2])
|
||||||
|
|
||||||
|
assert propagate_to_variants(conn, cfg, "kimi-k3", "nw") == 0
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT blended_score, inherited_from FROM proficiency WHERE model_id='kimi-k3-flex'"
|
||||||
|
).fetchone()
|
||||||
|
assert row["blended_score"] == pytest.approx(0.2)
|
||||||
|
assert row["inherited_from"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_database_without_the_column_is_migrated(tmp_path):
|
||||||
|
"""schema.sql only defines a NEW database; existing ones need the ALTER."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from config import load_config
|
||||||
|
from proficiency_store import add_self_eval, ensure_columns
|
||||||
|
|
||||||
|
cfg = load_config("config.yaml")
|
||||||
|
conn = _models_db(tmp_path, [("kimi-k3", "kimi-k3", "standard", "default", "full")])
|
||||||
|
conn.execute("ALTER TABLE proficiency DROP COLUMN inherited_from")
|
||||||
|
assert "inherited_from" not in {
|
||||||
|
r[1] for r in conn.execute("PRAGMA table_info(proficiency)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_columns(conn)
|
||||||
|
ensure_columns(conn) # idempotent
|
||||||
|
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "docs_writing", [1.0])
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT inherited_from FROM proficiency WHERE model_id='kimi-k3'"
|
||||||
|
).fetchone()["inherited_from"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_unfreezes_variants_that_predate_the_column(tmp_path):
|
||||||
|
"""The migration must ship the repair, not just the fix.
|
||||||
|
|
||||||
|
ADD COLUMN gives every existing row NULL, and NULL means "measured here" --
|
||||||
|
so without the backfill, the rows this whole change exists for would stay
|
||||||
|
frozen and the live flex rows would never catch up.
|
||||||
|
"""
|
||||||
|
from config import load_config
|
||||||
|
from proficiency_store import add_self_eval, ensure_columns, propagate_to_variants
|
||||||
|
|
||||||
|
cfg = load_config("config.yaml")
|
||||||
|
conn = _models_db(tmp_path, [
|
||||||
|
("kimi-k3", "kimi-k3", "standard", "default", "full"),
|
||||||
|
("kimi-k3-flex", "kimi-k3", "flex", "default", "full"),
|
||||||
|
])
|
||||||
|
# An old database: both rows carry samples, neither records provenance.
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "docs_writing", [0.957])
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3-flex", "nw", "docs_writing", [0.85])
|
||||||
|
conn.execute("ALTER TABLE proficiency DROP COLUMN inherited_from")
|
||||||
|
|
||||||
|
ensure_columns(conn)
|
||||||
|
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT inherited_from FROM proficiency WHERE model_id='kimi-k3-flex'"
|
||||||
|
).fetchone()["inherited_from"] == "kimi-k3"
|
||||||
|
# ...so the next run actually moves it.
|
||||||
|
assert propagate_to_variants(conn, cfg, "kimi-k3", "nw") == 1
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT blended_score FROM proficiency WHERE model_id='kimi-k3-flex'"
|
||||||
|
).fetchone()["blended_score"] == pytest.approx(0.957)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_backfill_never_touches_a_standard_row(tmp_path):
|
||||||
|
"""Only flex rows with a standard equivalent are provably inherited."""
|
||||||
|
from config import load_config
|
||||||
|
from proficiency_store import add_self_eval, ensure_columns
|
||||||
|
|
||||||
|
cfg = load_config("config.yaml")
|
||||||
|
conn = _models_db(tmp_path, [
|
||||||
|
("kimi-k3", "kimi-k3", "standard", "default", "full"),
|
||||||
|
("kimi-k3-fast", "kimi-k3", "standard", "reduced", "full"),
|
||||||
|
])
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3", "nw", "docs_writing", [1.0])
|
||||||
|
add_self_eval(conn, cfg, "kimi-k3-fast", "nw", "docs_writing", [0.888])
|
||||||
|
conn.execute("ALTER TABLE proficiency DROP COLUMN inherited_from")
|
||||||
|
|
||||||
|
ensure_columns(conn)
|
||||||
|
|
||||||
|
for model_id in ("kimi-k3", "kimi-k3-fast"):
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT inherited_from FROM proficiency WHERE model_id=?", (model_id,)
|
||||||
|
).fetchone()["inherited_from"] is None
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ proving it rejects and a case proving it does not over-reject.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from routing import is_eligible, rank_candidates, select_candidates
|
from routing import (
|
||||||
|
is_eligible,
|
||||||
|
rank_candidates,
|
||||||
|
rejection_reason,
|
||||||
|
select_candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +30,8 @@ def _row(**overrides) -> dict:
|
|||||||
"latency_class": "standard",
|
"latency_class": "standard",
|
||||||
"reasoning_mode": "default",
|
"reasoning_mode": "default",
|
||||||
"context_variant": "full",
|
"context_variant": "full",
|
||||||
|
"supports_vision": 1,
|
||||||
|
"supports_json_mode": 1,
|
||||||
}
|
}
|
||||||
row.update(overrides)
|
row.update(overrides)
|
||||||
return row
|
return row
|
||||||
@@ -233,3 +240,211 @@ def test_unmeasured_cost_is_admitted_under_a_ceiling():
|
|||||||
def test_ceiling_at_exactly_the_cost_admits():
|
def test_ceiling_at_exactly_the_cost_admits():
|
||||||
rows = [_row(model_id="borderline", energy=1e-6, proficiency=0.5)]
|
rows = [_row(model_id="borderline", energy=1e-6, proficiency=0.5)]
|
||||||
assert len(rank_candidates(rows, max_energy_per_request=1e-6)) == 1
|
assert len(rank_candidates(rows, max_energy_per_request=1e-6)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- tool competence is read from the request, not guessed at -------------
|
||||||
|
|
||||||
|
def test_tools_in_the_request_exclude_a_model_that_overreaches_for_them():
|
||||||
|
# deepseek-v4-flash measures 0.33 here. The recorded failure is a
|
||||||
|
# NON-agentic prompt ("it is 1:20pm, my meeting is at 3pm, how many
|
||||||
|
# minutes?") where it called two tools instead of subtracting -- so the
|
||||||
|
# hazard is tools being available, not the task being agentic.
|
||||||
|
weak = _row(model_id="overreacher", proficiency=1.0, tool_proficiency=0.33)
|
||||||
|
strong = _row(model_id="reliable", proficiency=1.0, tool_proficiency=1.0)
|
||||||
|
assert _eligible(weak, min_tool_proficiency=0.5) is False
|
||||||
|
assert _eligible(strong, min_tool_proficiency=0.5) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_tools_the_same_model_is_fine():
|
||||||
|
# The filter applies only when the request carries tool definitions;
|
||||||
|
# otherwise a cheap tool-clumsy model is a perfectly good choice.
|
||||||
|
weak = _row(model_id="overreacher", tool_proficiency=0.33)
|
||||||
|
assert _eligible(weak, min_tool_proficiency=None) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unmeasured_model_is_unproven_not_disqualified():
|
||||||
|
# Same principle as the tier-1 context gate: absent evidence must not
|
||||||
|
# decide anything. A model nobody has evaluated for tool use yet is
|
||||||
|
# unproven, not proven bad.
|
||||||
|
unknown = _row(model_id="unevaluated", tool_proficiency=None)
|
||||||
|
assert _eligible(unknown, min_tool_proficiency=0.5) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_threshold_is_exclusive_at_the_boundary():
|
||||||
|
assert _eligible(_row(tool_proficiency=0.5), min_tool_proficiency=0.5) is True
|
||||||
|
assert _eligible(_row(tool_proficiency=0.49), min_tool_proficiency=0.5) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tool_filter_can_change_the_winner():
|
||||||
|
# The whole point: it is a hard filter, so it removes a candidate that
|
||||||
|
# would otherwise win on cost rather than merely penalizing it.
|
||||||
|
rows = [
|
||||||
|
_row(model_id="cheap-clumsy", proficiency=1.0, tool_proficiency=0.33,
|
||||||
|
cost_per_1m_prompt=0.1, cost_per_1m_completion=0.2),
|
||||||
|
_row(model_id="dearer-reliable", proficiency=1.0, tool_proficiency=1.0,
|
||||||
|
cost_per_1m_prompt=1.0, cost_per_1m_completion=2.0),
|
||||||
|
]
|
||||||
|
no_tools = select_candidates(
|
||||||
|
rows, required_context_tokens=1000, required_tier=1,
|
||||||
|
latency_tolerance="interactive", allowed_access_levels=["public"],
|
||||||
|
exclude_stale=True, exclude_deprecated=True, min_tool_proficiency=None)
|
||||||
|
with_tools = select_candidates(
|
||||||
|
rows, required_context_tokens=1000, required_tier=1,
|
||||||
|
latency_tolerance="interactive", allowed_access_levels=["public"],
|
||||||
|
exclude_stale=True, exclude_deprecated=True, min_tool_proficiency=0.5)
|
||||||
|
|
||||||
|
assert rank_candidates(no_tools, prompt_tokens=1000)[0]["model_id"] == "cheap-clumsy"
|
||||||
|
assert rank_candidates(with_tools, prompt_tokens=1000)[0]["model_id"] == "dearer-reliable"
|
||||||
|
|
||||||
|
|
||||||
|
# --- capability filters ----------------------------------------------------
|
||||||
|
|
||||||
|
def test_vision_required_excludes_a_model_without_vision():
|
||||||
|
blind = _row(model_id="blind", supports_vision=0)
|
||||||
|
sighted = _row(model_id="sighted", supports_vision=1)
|
||||||
|
assert _eligible(blind, require_vision=True) is False
|
||||||
|
assert _eligible(sighted, require_vision=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_not_required_admits_everyone():
|
||||||
|
# The gate is off by default; not asking for vision must not exclude
|
||||||
|
blind = _row(model_id="blind", supports_vision=0)
|
||||||
|
assert _eligible(blind, require_vision=False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_vision_capability_fails_closed():
|
||||||
|
# A capability FLAG that is absent means "cannot confirm". Unlike a
|
||||||
|
# proficiency MEASUREMENT, admit-on-None would route a vision request to a
|
||||||
|
# model that might lack it -- a guaranteed provider 400.
|
||||||
|
unknown = _row(model_id="unknown", supports_vision=None)
|
||||||
|
assert _eligible(unknown, require_vision=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_mode_required_excludes_without_support():
|
||||||
|
plain = _row(model_id="plain", supports_json_mode=0)
|
||||||
|
json = _row(model_id="json", supports_json_mode=1)
|
||||||
|
assert _eligible(plain, require_json_mode=True) is False
|
||||||
|
assert _eligible(json, require_json_mode=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_json_mode_fails_closed():
|
||||||
|
unknown = _row(model_id="unknown", supports_json_mode=None)
|
||||||
|
assert _eligible(unknown, require_json_mode=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_mode_gate_admits_supporting_model():
|
||||||
|
assert _eligible(_row(supports_json_mode=1), require_json_mode=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_vision_filter_can_change_the_winner():
|
||||||
|
# Same shape as the tool-filter winner test: the gate is a hard filter, so
|
||||||
|
# it removes a model that would otherwise win on cost rather than merely
|
||||||
|
# penalizing it.
|
||||||
|
rows = [
|
||||||
|
_row(model_id="cheap-blind", proficiency=1.0, supports_vision=0,
|
||||||
|
cost_per_1m_prompt=0.1, cost_per_1m_completion=0.2),
|
||||||
|
_row(model_id="dearer-sighted", proficiency=1.0, supports_vision=1,
|
||||||
|
cost_per_1m_prompt=1.0, cost_per_1m_completion=2.0),
|
||||||
|
]
|
||||||
|
no_images = select_candidates(
|
||||||
|
rows, required_context_tokens=1000, required_tier=1,
|
||||||
|
latency_tolerance="interactive", allowed_access_levels=["public"],
|
||||||
|
exclude_stale=True, exclude_deprecated=True, require_vision=False)
|
||||||
|
with_images = select_candidates(
|
||||||
|
rows, required_context_tokens=1000, required_tier=1,
|
||||||
|
latency_tolerance="interactive", allowed_access_levels=["public"],
|
||||||
|
exclude_stale=True, exclude_deprecated=True, require_vision=True)
|
||||||
|
|
||||||
|
assert rank_candidates(no_images, prompt_tokens=1000)[0]["model_id"] == "cheap-blind"
|
||||||
|
assert rank_candidates(with_images, prompt_tokens=1000)[0]["model_id"] == "dearer-sighted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejection_reason_names_the_missing_capability():
|
||||||
|
assert _reason(_row(supports_vision=0), require_vision=True) == "vision(unsupported)"
|
||||||
|
assert _reason(_row(supports_vision=None), require_vision=True) == "vision(unknown)"
|
||||||
|
assert _reason(_row(supports_json_mode=0), require_json_mode=True) == "json_mode(unsupported)"
|
||||||
|
assert _reason(_row(supports_json_mode=None), require_json_mode=True) == "json_mode(unknown)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_tolerance_ranks_strictly_on_quality():
|
||||||
|
""""Never trade quality for cost" is a legitimate setting.
|
||||||
|
|
||||||
|
The validator has always accepted 0; `band()` divided by it, so every
|
||||||
|
request raised ZeroDivisionError on a config that had loaded cleanly.
|
||||||
|
"""
|
||||||
|
rows = [_row(model_id="cheap", cost=1e-9, proficiency=0.98),
|
||||||
|
_row(model_id="dear", cost=1e-3, proficiency=1.00)]
|
||||||
|
|
||||||
|
assert rank_candidates(rows, quality_tolerance=0)[0]["model_id"] == "dear"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_tolerance_still_breaks_exact_ties_on_cost():
|
||||||
|
rows = [_row(model_id="dear", cost=1e-3, proficiency=1.00),
|
||||||
|
_row(model_id="cheap", cost=1e-9, proficiency=1.00)]
|
||||||
|
|
||||||
|
assert rank_candidates(rows, quality_tolerance=0)[0]["model_id"] == "cheap"
|
||||||
|
|
||||||
|
|
||||||
|
# --- rejection reasons ----------------------------------------------------
|
||||||
|
#
|
||||||
|
# is_eligible returns a bool, so a dropped model used to vanish without
|
||||||
|
# explanation and "no model satisfies the hard filters" was a dead end. The
|
||||||
|
# reason string is what the debug log prints, so it has to be exact rather than
|
||||||
|
# re-derived somewhere else.
|
||||||
|
|
||||||
|
def _reason(row, **overrides):
|
||||||
|
kwargs = {
|
||||||
|
"required_context_tokens": 10_000,
|
||||||
|
"required_tier": 2,
|
||||||
|
"latency_tolerance": "interactive",
|
||||||
|
"allowed_access_levels": ["public"],
|
||||||
|
"exclude_stale": True,
|
||||||
|
"exclude_deprecated": True,
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
return rejection_reason(row, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_eligible_row_has_no_reason():
|
||||||
|
assert _reason(_row()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reason_names_the_filter_and_its_numbers():
|
||||||
|
assert _reason(_row(effective_context_window=5_000)) == "context(5000<10000)"
|
||||||
|
assert _reason(_row(tier=1)) == "tier(1<2)"
|
||||||
|
assert _reason(_row(availability="stale")) == "stale"
|
||||||
|
assert _reason(_row(availability="deprecated")) == "deprecated"
|
||||||
|
assert _reason(_row(access_level="canary")) == "access_level(canary)"
|
||||||
|
assert _reason(_row(latency_class="flex")) == "latency_class(flex)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_values_say_unknown_rather_than_comparing():
|
||||||
|
assert _reason(_row(effective_context_window=None)) == "context(unknown)"
|
||||||
|
assert _reason(_row(tier=None)) == "tier(unknown)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tool_filter_reports_the_measured_score():
|
||||||
|
reason = _reason(_row(tool_proficiency=0.33), min_tool_proficiency=0.5)
|
||||||
|
|
||||||
|
assert reason == "tool_proficiency(0.33<0.5)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasons_are_single_tokens():
|
||||||
|
"""They go straight into a logfmt value; a space would force quoting."""
|
||||||
|
for row in (_row(tier=1), _row(availability="stale"),
|
||||||
|
_row(access_level="canary"), _row(latency_class="flex"),
|
||||||
|
_row(supports_vision=0), _row(supports_vision=None),
|
||||||
|
_row(supports_json_mode=0), _row(supports_json_mode=None)):
|
||||||
|
reason = _reason(row, require_vision=True, require_json_mode=True)
|
||||||
|
assert reason is not None
|
||||||
|
assert " " not in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_eligible_still_agrees_with_the_reason():
|
||||||
|
"""One copy of the rules, two views of it."""
|
||||||
|
for row in (_row(), _row(tier=1), _row(latency_class="flex")):
|
||||||
|
assert is_eligible(row, **{
|
||||||
|
"required_context_tokens": 10_000, "required_tier": 2,
|
||||||
|
"latency_tolerance": "interactive", "allowed_access_levels": ["public"],
|
||||||
|
"exclude_stale": True, "exclude_deprecated": True,
|
||||||
|
}) == (_reason(row) is None)
|
||||||
|
|||||||
142
tests/test_seed_sweep.py
Normal file
142
tests/test_seed_sweep.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""End-to-end test of the reference sweep, with the provider stubbed.
|
||||||
|
|
||||||
|
The sweep is what makes `eco` and the per-request energy ceiling real numbers
|
||||||
|
instead of the neutral 0.5, and `llm-router-seed.timer` runs it every six
|
||||||
|
hours. It had been dead for weeks: `log_observation` grew `request_id`,
|
||||||
|
`session_key` and `session_dir` in the middle of its signature and made the
|
||||||
|
trailing three keyword-only, while this call still passed six positionals. The
|
||||||
|
result was a TypeError -- not a RequestException, so the per-sample `except`
|
||||||
|
never caught it -- raised AFTER the first billed completion. Every scheduled
|
||||||
|
run spent money and wrote nothing.
|
||||||
|
|
||||||
|
So this is a smoke test of `main()` rather than an assertion about a
|
||||||
|
signature: it catches this class of drift wherever it next appears, and it
|
||||||
|
costs nothing to run because `sample_once` never leaves the process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import dispatcher
|
||||||
|
import seed_energy
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCHEMA_SQL = (ROOT / "schema.sql").read_text()
|
||||||
|
|
||||||
|
MODEL = "gemma-4-31b"
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(model_id):
|
||||||
|
return {
|
||||||
|
"id": "chatcmpl-seed-1",
|
||||||
|
"model": model_id,
|
||||||
|
"choices": [{"message": {"content": "A B-tree is..."},
|
||||||
|
"finish_reason": "length"}],
|
||||||
|
"usage": {"prompt_tokens": 31, "completion_tokens": 400},
|
||||||
|
"energy": {"energy_kwh": 4.75e-05, "avg_power_watts": 420.0,
|
||||||
|
"duration_seconds": 1.6, "attribution_ratio": 0.25,
|
||||||
|
"carbon_g_co2eq": 2.32e-04, "carbon_source": "agent_cache",
|
||||||
|
"grid_id": "FI"},
|
||||||
|
"cost": {"request_cost_usd": 3.8e-04, "allowance_remaining_usd": 49.5},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sweep(tmp_path, monkeypatch):
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, tier, latency_class, access_level,
|
||||||
|
availability, cost_per_1m_completion, last_updated
|
||||||
|
) VALUES (?, 'neuralwatt', 1, 'standard', 'public', 'active', 0.42,
|
||||||
|
'2026-08-22T00:00:00+00:00')
|
||||||
|
""",
|
||||||
|
(MODEL,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# main() loads its own config; log_observation reads dispatcher's. They must
|
||||||
|
# agree on the database or the sweep writes somewhere the test cannot see.
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.database, "path", str(db_path))
|
||||||
|
monkeypatch.setattr(seed_energy, "load_config", lambda path: dispatcher.cfg)
|
||||||
|
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
|
||||||
|
monkeypatch.setattr(seed_energy.time, "sleep", lambda _: None)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_sample(base_url, api_key, model_id, max_tokens=400, timeout=300):
|
||||||
|
calls.append({"model_id": model_id, "max_tokens": max_tokens})
|
||||||
|
return _payload(model_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(seed_energy, "sample_once", fake_sample)
|
||||||
|
yield calls, db_path
|
||||||
|
|
||||||
|
|
||||||
|
def _run(monkeypatch, *argv):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["seed_energy.py", *argv])
|
||||||
|
return seed_energy.main()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sweep_writes_the_observation_it_paid_for(sweep, monkeypatch):
|
||||||
|
"""The regression: this raised TypeError after the first billed call."""
|
||||||
|
calls, db_path = sweep
|
||||||
|
|
||||||
|
assert _run(monkeypatch, "--samples", "2") == 0
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM energy_observations WHERE task_category = ?",
|
||||||
|
(seed_energy.SEED_CATEGORY,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert len(rows) == 2
|
||||||
|
row = rows[0]
|
||||||
|
assert row["model_id"] == MODEL
|
||||||
|
assert row["prompt_tokens"] == 31
|
||||||
|
assert row["completion_tokens"] == 400
|
||||||
|
assert row["energy_kwh"] == pytest.approx(4.75e-05)
|
||||||
|
assert row["carbon_g_co2eq"] == pytest.approx(2.32e-04)
|
||||||
|
assert row["cost_usd"] == pytest.approx(3.8e-04)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_records_the_completion_id(sweep, monkeypatch):
|
||||||
|
"""Positional drift silently dropped request_id; it is the join key for /outcome."""
|
||||||
|
_, db_path = sweep
|
||||||
|
_run(monkeypatch, "--samples", "1")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
request_id = conn.execute(
|
||||||
|
"SELECT request_id FROM energy_observations"
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert request_id == "chatcmpl-seed-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_tokens_reaches_the_request(sweep, monkeypatch):
|
||||||
|
"""The flag was parsed, printed in the banner, and never sent."""
|
||||||
|
calls, _ = sweep
|
||||||
|
_run(monkeypatch, "--samples", "1", "--max-tokens", "800")
|
||||||
|
|
||||||
|
assert calls[0]["max_tokens"] == 800
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dry_run_spends_nothing(sweep, monkeypatch):
|
||||||
|
calls, db_path = sweep
|
||||||
|
|
||||||
|
assert _run(monkeypatch, "--dry-run") == 0
|
||||||
|
assert not calls
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM energy_observations").fetchone()[0] == 0
|
||||||
|
conn.close()
|
||||||
Reference in New Issue
Block a user