neuralwatt-router-service #2

Merged
alee merged 26 commits from neuralwatt-router-service into main 2026-08-23 16:20:48 +00:00
Owner

Big uplift weekend work.

Big uplift weekend work.
alee added 26 commits 2026-08-23 16:20:40 +00:00
"Local" was conflated with "on this host". The premise of this router is that
a local model classifies the task before a cloud model answers it, and that
does not require the GPU to be in the machine you are typing on -- 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 already accepted any OpenAI-compatible endpoint, but two
things stopped it working.

The API key was hardcoded to the literal "ollama". Fine for Ollama, which
ignores it, and a 401 for anything that checks. classifier.api_key_env now
names the env var to read, and is left unset for the Ollama case. A key_env
that is set but missing from the environment raises at client construction
rather than being passed through: classify() catches everything and degrades
to a fallback tier, so an auth failure would otherwise be indistinguishable
from a slow model, forever.

The bigger one: local verification derived its URL by stripping /v1 off
classifier.base_url. It speaks Ollama's NATIVE /api/chat, which is the only
way to set think=False, so it cannot follow the classifier to a cloud
provider -- and moving the classifier anywhere at all silently redirected it.
It now has its own verification.base_url and verification.model.

verification.model may stay null only while both run on one host, which is the
common case including across a VPN, since both point at the same Ollama. Once
the hostnames differ, config load REFUSES the null. That guard exists because
the failure it prevents is silent, and was observed rather than imagined: with
the classifier pointed at NeuralWatt, the verifier POSTed deepseek-v4-flash to
localhost:11434, 404d, caught it, logged "local verification unavailable" and
recorded no sample. Verification would have looked enabled while producing
nothing -- the same shape as the harness bugs this project keeps finding.

Verified end to end over a genuine non-loopback address, both paths: /route
classified with source=classifier, and the verifier POSTed to the remote host
with the right model. Ollama binds 127.0.0.1 by default, so
deploy/ollama-over-vpn.conf carries the drop-in for the serving host. It binds
the VPN address rather than 0.0.0.0 deliberately: Ollama has no authentication
of any kind, and 0.0.0.0 publishes it on whatever wifi the laptop is sitting
on. Same reasoning as the dispatcher's loopback bind.

Recorded while measuring this, because it undercuts an assumption the design
rests on: a cloud endpoint satisfies the classifier interface too, and did it
better. Five prompts, same system prompt, temperature 0 -- NeuralWatt's
deepseek-v4-flash averaged 1.02s 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.
qwen3.5's one hard failure was the documented runaway-thinking-trace mode:
15.76s, no JSON, silent fallback tier. Local inference is not free, it is
unbilled. The shipped default stays local Ollama, because switching spends
quota and that is a deployment choice rather than a code one.

Tests 256 -> 264.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
The README documented one deployment rather than the project. It stated the
author's GPU in the opening sentence as though it were a requirement, reported
one account's 6.25 kWh plan quota as a fixed property, and pointed at a
hardcoded home directory for the opencode integration. Someone cloning this
had no way to tell which numbers were facts about the software and which were
facts about the machine it happened to run on.

The measurements stay -- they are the most useful thing in the file -- but
they are now framed as measurements. A note up front says they come from one
deployment against one provider account, that the catalog, prices, grid
intensity and pool load all move, and that re-running the measurement is how
you check whether a conclusion still holds. Setup gains a table of the keys a
new user must actually change, plan_kwh_per_period among them.

Reading it end to end also turned up that several sections were simply no
longer true, which is worse than being parochial:

- "expect one model" described a Pareto-dominant winner taking all 27
  category/tier decisions. It is now 5 distinct winners, because cost became a
  per-request estimate and tier stopped being inferred from price alone. The
  section now explains what each outcome MEANS as a diagnostic, which is what
  a reader actually needs, rather than pinning a snapshot.
- "the lever is `weights` in config.yaml" pointed at a config block that no
  longer exists; the weighted blend was replaced by objective.
- "Verification observes but never intervenes -- nothing retries or escalates"
  has been false since iteration.py landed.
- "Why cost != list price" argued against using catalog prices, which is now
  exactly what routing does, for reasons the old text predates.
- 207 tests across 11 files (264 across 14); three test files undocumented;
  iteration.py missing from the module table; the systemd table promised five
  units and listed three.
- Latency claimed ~4s warm classification; measured 11.58s mean.

Python floor was asserted as 3.11+ on no evidence -- nothing declares
requires-python. Built a 3.10 venv and ran the suite: 264 pass, so the README
now says 3.10+ and says it is verified. No [project] table added to
pyproject.toml, since this is not a distributable package and declaring an
untested floor there would be a promise rather than a fact.

Recorded a new open item in both files: local energy is not on the ledger. The
router meters what the provider bills but not what its own classifier and
verifier burn on local hardware, which is how "local compute is free" survived
as an assumption long enough to be measured wrong. Closing it means metering
local draw and pricing it against real utility rate data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
CLAUDE.md claims to be the file to trust on what is currently true, which
makes staleness in it worse than staleness anywhere else. Several sections
were describing a router that no longer exists.

The proficiency section was the worst of it. It documented all 27
category/tier decisions returning a single Pareto-dominant model, and spent
several paragraphs explaining why that was correct rather than a bug. It IS
still a legitimate outcome and the explanation was worth keeping, but it is no
longer what happens: sweeping 9 categories x 3 tiers now returns 5 distinct
winners at both 50k and 120k of context. Rewritten around what a reader
actually needs -- what each outcome means as a diagnostic, and which two
corrections widened it (cost priced per request, tier no longer inferred from
price), so the section survives the next time the numbers move.

"Coding does not discriminate these models" was true of the benchmark and is
now false of the data: coding_general spans 0.86-1.00, because feedback.py
folded 29 samples of real traffic into glm-5.2-fast and crossed
self_eval_min_samples doing it. That is the intended shape of the system --
benchmark sets a floor, real traffic refines it -- so it is now stated that
way, with the per-category spread tabulated. coding_refactor and debugging are
still flat at 1.00 and that is now an open question rather than a boast.

Also stale: 207 tests across 11 files (264 across 14); "the lever is `weights`
in config.yaml", a block that no longer exists; an open question about tier-1
composites being compressed by min-max normalization, which cannot be asked
any more because there is no composite; an open question asking for a minimum
viable eval set, which has existed for some time (23 tasks, four scoring
kinds, verified by counting them rather than trusting the number).

De-personalised the same way as the README: the author's GPU is out of the
opening line, private paths are gone from the outcome-attribution finding
(the evidence survives -- a dependency's source directory inside the project
venv outranked the project being edited), and a note up front says every
measurement here came from one deployment against one account and should be
re-run before being trusted.

The systemd units were the concrete version of the same problem. All three
hardcoded %h/Sources/6krrt, so anyone else copying them per the documented
install would get status=200/CHDIR and no useful message. They now say
%h/llm-router and the install step substitutes the real path with sed.
Verified the substitution reproduces the currently-installed unit byte for
byte, so the running service is unaffected; only the templates changed.

Recorded while checking: deploy/ has five units and one Ollama drop-in, not
the three the docs claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
Swapped on measurement, not on reputation. Same prompts, same system prompt,
temperature 0, cold load excluded -- 14 unambiguous category cases and 5 tier
probes:

                        qwen3.5      mistral-nemo:12b
  category correct      10/14        9/14
  tier correct          1/5          3/5
  hard failures         4 of 19      0
  latency mean/max      6.6s/15.6s   1.7s/1.9s

Category accuracy is a wash and should not be the deciding factor. The failure
column is. All four failures are the runaway-thinking-trace mode already
documented for qwen3.5: ~15s of local inference producing no JSON, degrading
to source="fallback" -- tier 2, general_chat. A reasoning model is simply the
wrong tool for a job whose entire output is ~45 tokens of JSON, and it is the
tail that hurts, not the mean: mistral-nemo's SLOWEST call is faster than
qwen3.5's median. End-to-end /route went ~10s to ~1.7s.

Both scored 5/5 discriminating good answers from refusals, wrong-question
answers and incoherence, so the verifier moved with it and only one model
needs to stay resident. think:False is accepted for a non-reasoning model, so
that path needed no change.

The prompt for this turned up a real pre-existing bug. Asked to explicitly set
num_ctx, I checked what it actually is rather than assuming: Ollama 0.22 runs
this model at n_ctx=32768, so the common advice to set 16384 would HALVE it.
But testing the boundary showed the classifier falls apart well before that,
and for an unrelated reason -- output budget, not input window:

  ~20k-token prompt   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. That is 30-40 seconds of local inference
spent to reach the same fallback an instant failure would have produced, and
it predates this swap -- neither model is at fault, the input is.

classifier.max_input_chars (8000) clamps the classifier's input to head +
tail with the middle elided. Head AND tail because the instruction sits at one
end or the other depending on phrasing ("Translate this: <doc>" vs "<doc> --
translate this"), and the middle of a pasted document is the one region that
never carries it. Nothing is lost by clamping: chat_completions measures the
real conversation with estimate_prompt_tokens and takes the larger value, so
required_context never depended on what the classifier saw. Same prompts now
classify correctly in ~2.2s.

Recorded as open item 6, because it is the one thing that got slightly worse
and it is not small: neither local model can identify agentic work. On six
unambiguous tool-use prompts qwen3.5 scored 2/6 and mistral-nemo 1/6.
tool_use_agentic has the widest proficiency spread in the table (0.33-1.00)
and deepseek-v4-flash, currently winning coding, sits at the bottom of it, so
this is the most expensive classification error available. The fix is probably
not a better classifier: whether a request is agentic is structurally
observable, since agent clients send `tools` in the request body and
chat_completions never looks at it.

Tests 264 -> 271.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
Closes the gap the classifier swap exposed. Neither local model can identify
agentic work -- on six unambiguous tool-use prompts qwen3.5 scored 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
wrong 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
every coding route, sits at the bottom of it.

The fix is 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; inferring
it demonstrably does not work.

Applied as a hard filter rather than a category override, and that distinction
is the substance of the change. The question is not "is this task agentic" but
"can this model be trusted with tools that exist". The recorded failure is
specifically the second one: deepseek-v4-flash was given a NON-agentic prompt
-- "it is 1:20pm and my meeting starts at 3pm, how many minutes away?", both
times supplied -- and called two tools rather than subtracting. A model that
over-reaches for tools is a hazard on every request where tools are available,
whatever a classifier would have called the task. Overriding the category
would have encoded the wrong question and thrown away the task signal as well.

routing.min_tool_proficiency (0.5) drops candidates below it, but only when
the request carries tools:

  coding_general @ 50k, no tools     -> deepseek-v4-flash  $0.0024
  coding_general @ 50k, tools present -> qwen3.6-35b        $0.0041

Verified live through /v1/chat/completions with identical bodies differing
only by the tools array. Safety costs 1.7x on that route and is paid only
where tools exist; without them the cheap model still wins, which is the whole
reason for making this a request property rather than a global setting.

0.5 sits in the empty band between the only two values the catalog holds (0.33
and 1.00), so it is fitted to neither. 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, for the same reason.

Two smaller things, both about failing loudly:

Config load refuses a routing.tool_use_category that is not in
proficiency.categories. A name matching nothing yields NULL for every row, and
NULL means "do not disqualify", so a typo would silently switch the filter off
while appearing to be configured.

The 422 now names the tool constraint when it applied. Omitting it sends
someone hunting through tier and context settings for a limit that came from
the tools array.

Tests 271 -> 276.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
min_tool_proficiency is now null -- the tool-competence filter ships off,
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, a model measured at 0.33
on tool use handles requests where tools are available. Which is right is an
empirical question and the 0.33 comes from 3 benchmark tasks. POST /outcome
settles it: run with the filter off, let real pass/fail accumulate, and
compare deepseek's tool_use_agentic proficiency before and after. feedback.py
folds client outcomes in both directions, so successes count too.

Auditing which settings existed only as code defaults found a real bug in the
previous commit. classifier.max_input_chars had been written into the
`verification:` block instead of `classifier:`, because `max_output_tokens:
1024` appears in BOTH sections and the edit anchored on the first match.
Pydantic's default extra="ignore" accepted it, discarded it, and left the code
default in force -- which carried the same value, so behaviour was correct and
the config file was a lie. Editing that line would have done nothing.

So the fix is the class, not the instance. Every config model now inherits
StrictModel (extra="forbid"), and both of these fail at load:

    verification.max_input_chars    # right key, wrong section
    routing.min_tool_proficency     # sic

A test asserts the shipped config.yaml has no unknown keys, so the whole file
is guarded rather than the sections a test happens to name. This matters most
to whoever is tuning the file, which is the entire point of putting knobs
there.

Also removed classifier.outcome_attribution_window_seconds: declared, never
read, and shadowing verification.outcome_attribution_window_seconds, which is
the one dispatcher.py actually uses.

Verified live that the filter is off -- identical /v1/chat/completions bodies
differing only by the `tools` array now both route to deepseek-v4-flash, where
before they split.

Tests 276 -> 281.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
`POST /v1/chat/completions` naming a real model id -- documented as "any real
model id: dispatched as asked, still logged" -- returned 500 on every
non-streaming request. `alternatives` read `decision.runners_up`, but
`decision` is only bound inside `if wants_routing:`, so a caller who picked
their own model got UnboundLocalError before the provider was ever called.

The `budget` expression two lines above is guarded by `cfg.iteration.enabled
and wants_routing`, which is why only this line tripped. Empty is the right
value for the pass-through anyway: there is no ranking behind a model the
caller named themselves, and the budget is already 0, so the retry loop breaks
on its first pass.

It survived because the `/v1` surface -- the endpoint opencode, an SDK and
plain curl all reach the router through -- had ZERO tests. Streaming clients
never hit it (the generator does not touch `decision`), so the one path most
traffic takes hid the one path it broke.

So the fix ships with the coverage that was missing rather than only the line:
tests/test_chat_completions.py stubs the classifier, the provider and the
local verifier, and pins pass-through, routing, the provider-prefixed `auto`
name opencode sends, the 422 that names its filters, and that a streamed call
still logs its energy. Reverting the one-line fix fails two of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
`observed_at` is written by `datetime.now(timezone.utc).isoformat()`, which
separates date from time with 'T':

    2026-08-23T00:20:04.577131+00:00

`datetime('now', '-120 seconds')` returns a space instead:

    2026-08-23 00:18:04

Compared as TEXT, 'T' (0x54) sorts after ' ' (0x20), so once the two dates
match the time of day never participates. The window was not 120 seconds, it
was "everything served today" -- and near midnight it reached into yesterday
as well.

Measured against the live router.db: the query as written matched 14 rows
across 2 distinct session_keys, while the same query through julianday()
matched 0. That is what produced the 409s. `_most_recent_if_unambiguous` saw
two conversations where there had been one for hours, so `POST /outcome`
refused reports it should have attributed -- and it made
verification.outcome_attribution_window_seconds inert at every value, which is
the worse half: the setting was tuned from 30 minutes down to 120 seconds to
fix exactly this symptom, and the tuning could not have done anything.

`quota_burn` had the same defect on its '-30 days' bound, worth about a day of
slop on a 30-day sum.

Both now compare instants via julianday(), the form poller.mark_stale already
used. julianday() parses the stored offset and normalizes to UTC, and nothing
is lost to it -- there is no index on observed_at.

The tests write their timestamps through the same isoformat() call the
dispatcher uses, so they stay honest if the write format changes, and they
place the "old" row at the first instant of the current UTC day: sharing
today's date is precisely what the string comparison needed to go wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
`log_observation` grew request_id, session_key and session_dir in the middle
of its signature in 6e729ad, and made the trailing three keyword-only.
seed_energy.py still passed six positionals, so every run raised:

    TypeError: log_observation() missing 3 required keyword-only arguments:
    'prompt_tokens', 'completion_tokens', and 'telemetry'

after making one real, billed completion. TypeError is not a
RequestException, so the per-sample `except` did not catch it and the whole
sweep aborted. llm-router-seed.timer has been failing every six hours since,
spending money and writing nothing.

What that froze: `eco`, and the `energy` figure `routing.within_budget`
enforces objective.max_energy_per_request against. Both silently kept whatever
they had, while /health went on reporting the axis as covered -- the exact
"silent empty axis" failure scoring_coverage() was written to prevent, arriving
through a door it does not watch.

The call now passes those arguments by keyword, and passes `payload["id"]` as
the request_id that positional drift had been dropping. That id is the join key
POST /outcome attributes reports through, so the sweep's own rows were also
unreportable.

--max-tokens is fixed in the same pass, because it was the same class of lie:
argparse parsed it, the banner printed it, and sample_once hardcoded 400. Rows
from `--max-tokens 800` were recorded as ordinary seed_reference samples and
folded into the same median, mixing two workload shapes in the one axis that
exists to hold the workload constant across models.

The test runs main() end to end with sample_once stubbed, rather than asserting
anything about a signature -- this class of drift should be caught wherever it
next appears, not only where it appeared this time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
score_code ran `passed = proc.stdout.count("PASS")`, and the harness executes
the model's code as `__main__`. Models routinely append a demo block:

    if __name__ == "__main__":
        print("self-test:", add(1, 2) == 3 and "PASS" or "FAIL")

That line is printed into the same stdout the verdicts are read from, so it
was counted as a passing check. Measured: a two-check task scored 1.50.

Nothing downstream clamped it. score_judge bounds its output to [0, 1] and
score_code did not, so `accumulate` and `blend` -- plain arithmetic both --
carried it into blended_score. A proficiency above 1.0 is not a local error:
rank_candidates takes `best = max(proficiency_score)` and measures every other
candidate's band from it, so one inflated row moves the quality band for the
whole comparison.

Two changes, because either alone is a half-fix. The verdict lines now carry a
per-run nonce (`CHECK-<8 random bytes>`) and are parsed strictly, by check
INDEX rather than counted -- the model cannot predict the nonce, so it cannot
vote on its own work, and indexing bounds the total at len(checks) even if a
line repeats. And add_self_eval clamps to [0, 1], because proficiency_store is
documented as the one path every write goes through, and that promise is worth
something only if it is enforced there.

This is the fourth harness bug in this project to score the rig rather than the
model. The other three deflated -- an empty content field, a leading space, an
unparseable judge reply -- and were caught by reading per-task detail that
looked impossibly bad. This one inflated, which is why it survived: a model
scoring 1.00 on coding looks like the good news the summary already reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
judge_for exists because a model scoring its own prose is a known bias and the
default judge is in the evaluated set. It fell back to a single hardcoded
ALTERNATE_JUDGE = "qwen3.6-35b" -- which is also in that set, so with
`--judge-model qwen3.6-35b` the guard fired on qwen3.6-35b and returned
qwen3.6-35b. The one case it exists to prevent was the one case it produced.

It degraded silently, which is the part that matters: a self-graded score
looks exactly like any other judge output, so nothing in the run would say so.

Now it tries the configured judge first and then a list of alternates, taking
the first from a different family, and returns None when every candidate
shares the model's family -- the caller skips the task rather than recording a
self-graded sample. That is the same rule score_judge already applies to a
judge reply it cannot parse: no sample beats a false one.

Matching stays on the family via parse_base_model_id, so a -fast row is not
judged by its reasoning-on sibling either. Same weights, same conflict.

Also renames the judge-task COUNT computed before the loop, which shared the
name `judged` with the per-task judge result inside it. Harmless today only
because the count is consumed before the loop starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
The previous commit made config strict so a WRONG key fails at load. That
guards typos; it says nothing about keys that are spelled correctly, parse
cleanly, and are read by nobody. Four of those:

`context.per_model_overrides` was the worst, because config.yaml shipped a
worked example for it -- follow that example and the model's effective window
does not move. It is now typed (ContextOverride, not a bare dict, so a typo
INSIDE an override is an error too) and read by
poller.ModelRow.effective_context_window. Both keys fall back independently,
compared with `is not None` rather than `or`: an override of 0 reserve tokens
must mean zero, and present-but-falsy defaults are a trap evals/tasks.yaml
deliberately tests models on.

`logging.log_path` named "router.log", which nothing ever wrote -- the
dispatcher prints to stderr and systemd hands that to the journal. Deleted,
with a note in config.yaml saying where the logs actually are.

`tiers` is documented as labels "for logging/dashboards" and reached neither.
/health is the dashboard surface, so it reports them now.

`objective.quality_tolerance: 0` is a fifth of the same family, arriving from
the other direction: the validator accepts [0, 1) and `band()` divided by it,
so a legitimate setting -- "never trade quality for cost" -- turned every
request into a 500 on a config that had loaded cleanly. 0 now means no band:
strict quality ordering, cost breaking only exact ties.

And `python config.py`, the setup step documented in README.md and CLAUDE.md,
printed cfg.weights -- replaced by cfg.objective some commits ago. It said
"Config loaded OK" and then died with AttributeError, which is a bad look for
the one command whose entire job is to prove the config is fine. The summary
is now a function so a test pins the attribute names against the next rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
This project's comments carry the reasoning, which makes a stale one worse
than none: it is a confident account of a decision that was reversed. CLAUDE.md
already records the reversals correctly, so these were the copies that
disagreed with it, and a reader has no way to tell which one is current.

schema.sql said "That product is what scoring ranks on" about
avg_power_watts * duration_seconds. Scoring reads the ATTRIBUTED figures, and
ranking on the pre-attribution product was tried and rejected -- it discards a
750x between-model signal to suppress a 1.8x within-model one. seed_energy.py's
docstring carried the same claim, and also still described the sweep as feeding
cost; it feeds eco and the energy ceiling now.

load_candidates' docstring described `cost` as the sweep's median.
rank_candidates overwrites it with routing.estimated_cost, priced from catalog
prices scaled to the request -- the measured value survives only as a fallback
for a row the catalog has no price for. The /health warning about missing sweep
data said the same thing and is corrected the same way.

routing.py and scoring.py both still stated the retired weighted composite as
the scoring model. scoring.py additionally now says which of its functions the
router actually calls: eco_score and composite_score are arithmetic with tests
and no callers.

config.yaml's "no flex discount knob" note justified itself with cost scoring
that no longer works that way; the real reason is that latency_tolerance is a
hard filter, and flex and standard carry the same catalog price anyway.

Test counts in README.md and CLAUDE.md were 264/14 files; 320 across 18 now.

CLAUDE.md's "until several sweeps have accumulated" section gets the correction
it needs most: none have. The sweep has been dying on its first billed call
since 6e729ad, so that accumulation starts from the next run, not from months
of history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
Watching a live agent session against the router showed one thing:

    uvicorn[42989]: INFO: 127.0.0.1:37458 - "POST /v1/chat/completions" 200 OK

That was the whole story, because the dispatcher had no logger. It had four
print(file=sys.stderr) calls and every one of them was a failure path, so a
request that WORKED said nothing -- not the category, the tier, the model
chosen, the cost, or the latency. The access line does not even name the model
that served it.

This commit is the machinery; the events themselves come next.

Three parts, in order of how much they matter.

A TRACE ID per request, in a ContextVar so helpers read it without every
signature growing a parameter. One request touches classification, filtering,
ranking, dispatch, verification and possibly a retry, and nothing tied those
together before. set_trace() exists for the streaming path specifically: a
StreamingResponse's generator is iterated from a different context than the
endpoint that built it, so the ContextVar set in the endpoint is invisible
inside the generator -- and that is the path all agent traffic takes.

LOGFMT rather than prose. `route id=r7f3a91 cat=coding_general pick=...` reads
fine in journalctl and greps without a JSON parser. Values are quoted only when
they need it, None renders as `-`, and floats get six significant figures so a
microdollar cost is readable instead of 0.0007830000000000001. A newline inside
a value is flattened: one event must be one line, or a detail string could
forge a record.

REAL JOURNALD PRIORITIES. systemd strips a `<N>` prefix and records the message
at that priority (SyslogLevelPrefix defaults to true), which is what makes
`journalctl -p warning` mean anything -- until now every line landed at
PRIORITY 6 and severity could not be filtered at all. The prefix is emitted
only when $JOURNAL_STREAM is set, which systemd exports when it owns our stderr
and a shell does not, so a foreground uvicorn prints clean lines instead of a
literal <6> on each one.

The level lives in config.yaml per the usual rule, but LLM_ROUTER_LOG_LEVEL
overrides it, because turning up a running service should not mean editing a
tracked file and leaving a stray diff.

The four existing prints are converted rather than left alongside. Two parallel
logging systems is how the next one gets missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
The machinery landed last commit; these are the events. What a routed request
now leaves behind, at info:

    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 prof=1 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=unverifiable
      upstream_ms=3980 total_ms=5692

`rid` and `sess` are columns in energy_observations, so a journal line pivots
to its database row and back with a --grep. That is why no schema change was
needed to get traceability.

At debug it also says why, which is the half that was never recorded anywhere:

    classify id=r9116d9 cat=coding_refactor tier=2 ctx=500 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

Those reasons come from routing.rejection_reason, which is is_eligible turned
inside out: it returns the failing filter and its numbers instead of False, and
is_eligible is now a one-line wrapper over it. One copy of the rules, so the
log cannot drift from the decision it describes.

log_decision is called by the ENDPOINTS, not by route(). chat_completions
routes twice whenever the measured conversation exceeds the classifier's
estimate -- with opencode sending ~32K of system prompt, essentially always --
and logging inside route() would double every line for no new information.

Two things the tests caught that reasoning had not:

The streaming path could not use the ContextVar. starlette resumes the
generator through a threadpool and every next() gets a fresh COPY of the
caller's context, so a trace id set inside is gone by the finally block that
logs the dispatch line. It logged id=- until the id was carried explicitly by
logs.bind() -- on the path all agent traffic takes.

$JOURNAL_STREAM is inherited. A foreground uvicorn started from a
systemd-managed session, output redirected to a file, put a literal <7> on
every line: the variable was set, but described a stream that was no longer
ours. It is now compared against the actual fd's device:inode, which is what
systemd documents.

A test asserts no conversation text reaches the log at any level, prompt or
answer. Prompts here run 60k-150k tokens and the journal is on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
The journalctl recipes, in both READMEs, with the parts that are not obvious
from the man page:

- quote the unit glob -- zsh expands `llm-router*` itself and errors
- `-p warning` now filters, because the service emits journald priority
  prefixes when systemd owns its stderr; before, every line was PRIORITY 6 and
  severity could not be filtered at all
- `--grep ' id=r9116d9'` pulls one request's every stage; `--grep 'chatcmpl-'`
  pivots from an energy_observations row back to the decision that made it
- the drop-in for LLM_ROUTER_LOG_LEVEL=debug, so turning a running service up
  does not mean editing a tracked file

And one thing that will otherwise waste an evening: 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 a sweep that takes minutes
prints nothing until it exits. PYTHONUNBUFFERED=1 on those units fixes it;
noted rather than applied, since it only matters if you are watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
Live traffic through the new logging showed every agent request as:

    route ... cat=coding_refactor tier=2 ctx=56308 src=override tools=1

`src=override` means "the client chose this", and the client chose nothing.
chat_completions routes a second time when the measured conversation exceeds
the classifier's estimate -- with opencode sending ~32K of system prompt, on
essentially every request -- and that second call passes category and tier back
in, so the resulting Classification reads source='override'. The classifier had
in fact run and decided both; only the context figure was replaced.

A traceability field that misreports where a decision came from is worse than
no field, because it is confidently wrong in the direction someone debugging
would act on: it points at the client for a choice the router made.

The line now carries both, separately:

    route ... ctx=56308 ctx_src=measured src=classifier tools=1

ctx_src is caller | classifier | measured, so /route and /dispatch report their
own provenance honestly too.

Found by reading the live journal after deploying, not by a test -- the tests
stub the classifier and never exercised the re-route path, which is the one all
real traffic takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
Both came from real traffic, and both were re-measured against a day the
router actually served -- 50 sessions, 567 calls, 40.7M tokens, 2026-08-23.

quota_burn's docstring claimed the router accounted for ~86% of the energy the
provider charged for. It is now 96.9% (0.1543 kWh metered against 0.1593 kWh
charged). Nothing was fixed to achieve that; more traffic simply moves through
the router than around it than when the earlier check was taken. The remainder
is mostly eval_proficiency.py, which calls the provider directly and writes no
observation -- worth knowing, because it means the figure is a floor by
construction rather than by accident.

assumed_cache_rate was 0.84, measured on 2.2M tokens. Token-weighted across
40.7M it is 91.7%, and 92.6% on the sessions above 400k tokens, which are the
ones carrying the cost. Now 0.917.

The routing consequence of that second change is: none. All nine categories
return the same winner at 60k context under both rates, checked before
editing. It is changed because the number should be true, not because the
ranking needed it -- which is the same reason the earlier pass deleted a knob
that named a log file nothing wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
propagate_to_variants skipped any row with self_eval_samples > 0, meaning
"this one was measured directly, do not overwrite it with the family's". But
inheritance COPIES self_eval_samples, so an inherited row also has samples > 0
and the test could not tell the two apart. A variant could be inherited once
and never again.

Found by re-measuring docs_writing. kimi-k3 moved 0.85 (n=2) -> 0.957 (n=7)
while kimi-k3-flex sat at 0.85 with a timestamp from 2026-08-16, and the run
reported "propagated 0 inherited rows". Flex rows are what `auto:batch`
admits, so overnight work was ranking on scores its family had left behind by
a week -- silently, since a stale score looks exactly like a fresh one.

Provenance is the missing fact, so the table now records it:
proficiency.inherited_from names the model a row's scores were copied from,
NULL when they were measured on that row. propagate_to_variants refreshes
where it is set and still refuses where it is not.

The migration ships the repair, not just the fix. ADD COLUMN gives every
existing row NULL, which reads as "measured here" -- so on its own it would
have left exactly the rows this change exists for frozen forever. Provenance
that was never recorded cannot be recovered in general, but it can for the
rows that matter, and not by guessing: eval_identities selects standard rows
plus flex rows with no standard equivalent, so a flex row WITH one was never a
candidate for direct evaluation whatever its sample count says. That is the
harness's own selection rule read backwards. Everything else keeps NULL, which
is the safe direction -- it means "do not overwrite", so a real measurement is
never lost to the backfill.

schema.sql is CREATE TABLE IF NOT EXISTS, which defines a new database and
does nothing to an existing one, so ensure_columns() carries the ALTER for
databases that predate the column. Applied to the live router.db.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
Six more docs_writing passes (n=2 -> 14) moved every score in the category
and left nothing at the ceiling. kimi-k3-fast read 1.00 on two samples and
won every docs route; at n=11 it reads 0.864. The winner is now
kimi-k2.7-code, 3.2x cheaper at 50k, with no config change.

The lesson is the one the project already applies elsewhere and nearly
missed here: when a category looks flat, add samples before reaching for
quality_tolerance. Both ties broken so far -- coding_general via real
traffic, docs_writing via more passes -- were broken by evidence, not
settings. coding_refactor and debugging are still flat on 2-3 samples each,
which is now a known failure mode rather than a conclusion.

Also records what the docs spread actually rests on (one rubric line about
hashability), the sample density the self-judging guard costs, and the
inherited_from provenance column in both READMEs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
A request states whether it needs vision (image_url parts), JSON mode (response_format), tools (a tools array) or reasoning. Reading them is exact and free; a classifier cannot reliably identify agentic or visual work. has_reasoning_request is informational only.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Two keyword-only gates on rejection_reason/select_candidates, mirroring min_tool_proficiency. Capability flags fail CLOSED on unknown (a wrong guess is a guaranteed provider 400), unlike proficiency measurements, which admit on absent evidence. Reasoning stays on tiering; tools stay on the measured gate.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
routing.require_vision and require_json_mode default on (a wrong guess is a 400). A local_vision block configures the Ollama vision fallback; it ships disabled. All keys declared on their StrictModel so a typo fails at load.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
chat_completions reads capabilities once and threads them into both passes of the re-route; 422s name vision/json-mode; a pinned model that can't satisfy the request gets a clear 422. The local_vision fallback (off by default) answers image requests that find no cloud vision candidate, rejecting remote image URLs as an SSRF guard and refusing empty answers rather than returning a 200. estimate_prompt_tokens now counts tool definitions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
opencode strips image parts client-side unless a provider model declares input image modality. Every llm-router model now carries modalities {input: [text, image]} so image requests actually reach the router, which then gates on supports_vision (or falls back to local vision). Same change applied locally to ~/.config/opencode/opencode.json (outside this repo).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
The fail-closed asymmetry (capability flags fail closed on unknown; quality measurements admit on absent evidence), why tools stay on the measured gate and reasoning on tiering, the local vision fallback semantics, the capabilities.py module, and the opencode modalities requirement.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
alee merged commit f074552527 into main 2026-08-23 16:20:48 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alee/6krrt#2