Files
6krrt/config.yaml
adlee-was-taken 5f7716e121 fix: resolve context-pruning-and-framing review findings
Address the 11 confirmed bugs and 4 cleanups from
context-pruning-and-framing-review.md, plus the image-accounting
regression it found on a second pass.

classifier framing:
- _previous_context walks back to the nearest assistant turn only,
  skipping system/user/tool so raw tool output and the system prompt
  never contaminate the framing signal; reuses context_prune.extract_text
  instead of a duplicated block parser.
- classify() drops its redundant if/not-context branch.
- framing instruction moved out of the static system prompt and into
  _classifier_user_content so it travels only with content that justifies
  it (respects context_framing opt-out).

context pruning:
- extract_text counts image_url bytes so image-bearing tool results are
  sized (and pruned) correctly.
- -- new, follow-up review -- image_url blocks now count toward the size
  trigger but not toward the replacement text, and are stubbed to a short
  marker when the message is trimmed, so an image-heavy tool result
  genuinely shrinks rather than staying full-size while tokens_saved lied
  and base64 leaked into the text block.
- tokens_saved is now the honest whole-list before/after reduction
  (max(orig - final, 0)), never a per-message estimate that could drift.
- max_summarize_chars validated >= 3000 at config load; trim branch guards
  against negative math and message growth.
- recency guard protects the newest tool result even with no user turn.
- non-text blocks survive trimming (type preserved) rather than being
  flattened away.
- dropped/stat terminology corrected to summarized; _turn_of() removed;
  module-level pinch defaults removed in favor of PinchConfig.

Dispatch and config:
- prune_context runs once before the measured-context routing decision
  (reused at dispatch, no double-prune, no in-place mutation); no-op when
  pinch is disabled.
- dead context=prev_context arg removed from the measured reroute.
- TaskRequest.context dual use documented.

15 new regression tests pin the fixes; 545 tests pass.
2026-08-23 22:23:08 -04:00

432 lines
20 KiB
YAML

# Local LLM router config
# All weights, thresholds, and provider settings live here so they can be
# tuned without touching code. Loaded/validated by config.py.
objective:
# Quality is the objective. Cost is a constraint and a tiebreak. Eco is
# logged per request but is NOT optimized here — that judgement is made
# outside this router.
#
# This replaced a weighted blend (cost 0.4 / eco 0.2 / proficiency 0.4).
# Measurement killed it: turning the cost weight from 0.4 to ZERO changed
# the winner in only 2 of 6 categories, so the blend was never steering on
# quality — while 60% of every decision adjudicated fractions of a cent
# (all real traffic to date totals $0.07).
# Proficiency differences smaller than this are treated as equal and the
# cheaper model wins. This is measurement noise, not preference: scores
# currently rest on 2-3 samples per category, so a 0.05 gap is
# indistinguishable from sampling variation and paying for it buys noise.
# Narrow it as samples accumulate.
quality_tolerance: 0.10
# Cost is priced per-request from catalog prices, NOT from a benchmark
# sweep. A fixed 400-token reference task ranked glm-5.2-fast 3.2x cheaper
# than deepseek-v4-flash; on a realistic 70k-token prompt deepseek is 5.0x
# cheaper. Attribution inverts with prompt size, so a fixed-shape benchmark
# cannot rank models for a workload of another shape. Catalog prices scaled
# to the actual request agree with the live measurement, cost nothing, and
# need no sweep.
# Share of prompt tokens served from the provider's prefix cache. Agent
# clients resend the whole conversation each turn, so most of it is a hit.
#
# 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
# around 200-400 completion tokens against enormous prompts.
assumed_completion_tokens: 500
# Per-request ceiling on measured ENERGY, in kWh. null disables it.
#
# Denominated in kWh rather than dollars because the plan is a subscription
# with a 6.25 kWh quota, not pay-per-request. Dollars accrue; a quota is a
# wall you hit mid-task. So this is the cost mandate stated as a guarantee.
# For scale: the reference task runs ~5e-06 kWh on the cheapest model and
# ~2.2e-04 on the most expensive.
max_energy_per_request: null
# The subscription's kWh allowance per billing period, for reporting burn in
# /health. Set to match your plan; null disables the report. NeuralWatt also
# returns allowance_remaining_usd per request, which is logged, but that is a
# dollar figure while the plan is denominated in energy.
plan_kwh_per_period: 6.25
context:
safety_factor: 0.75 # fraction of advertised context treated as usable
default_output_reserve_tokens: 4096
# Per-model exceptions to the two settings above, for a row whose real
# 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: {}
tiers:
# Maps a tier number to a human label, purely for logging/dashboards.
1: "cheap / simple"
2: "mid / general"
3: "frontier / high-stakes"
tiering:
# Auto-tiering pass knobs. cheap_completion_max is the completion-cost
# (per 1M tokens) ceiling below which a model is eligible for tier 1.
# model_tiers overrides the heuristic per model_id and applies to ALL
# providers (limitation vs a (model_id, provider) key).
cheap_completion_max: 1.00
# Advertised context_window at or above which a model is NOT eligible for
# tier 1, whatever it costs. Tier is a capability FLOOR (routing drops any
# row with tier < required_tier), so tier 1 means "simple work only" — and
# deciding that on price alone excluded deepseek-v4-flash from every tier-2
# request purely for being $0.28/1M, despite a 1M window and 1.00 on all
# three coding categories.
#
# 512000 sits in the empty band between the catalog's 256K class (262128)
# and its 1M class (1048560) — a 2x margin either side, so it is not fitted
# to any one model. gemma-4-31b (256K) stays tier 1; the 1M rows do not.
tier1_context_max: 512000
model_tiers: {}
proficiency:
# Blending rule: leaderboard vs self-eval, once self-eval sample size
# crosses the threshold below. Below threshold, leaderboard score alone
# is used so thin self-eval data doesn't dominate.
self_eval_min_samples: 10
leaderboard_weight: 0.3
self_eval_weight: 0.7
categories:
- coding_general
- coding_refactor
- debugging
- docs_writing
- summarization
- translation
- reasoning_math
- tool_use_agentic
- general_chat
escalation:
enabled: true
max_tier: 3
# Bump the tier when the classifier is unsure of its own call. DEFAULT OFF:
# this pays frontier prices on a hunch, before anything has gone wrong. The
# iteration budget below spends after a check has actually failed, which is
# strictly better on both mandates — the cheap attempt usually succeeds and
# costs nothing extra, and when it fails you have evidence.
preemptive_on_low_confidence: false
min_confidence_before_bump: 0.6
iteration:
# A tier is not only a capability floor, it is a budget for getting the
# answer right. These are corrective attempts AFTER a verification failure,
# not speculative retries.
#
# Retries are matched to the failure: a truncated answer gets a bigger token
# budget on the SAME model (a different one would also run out), while a
# malformed answer escalates to the next-best candidate (more tokens will
# not make unparseable output parse).
enabled: true
attempts_by_tier:
1: 0 # cheap/simple — one shot; iterating costs more than it is worth
2: 1
3: 2
# Interactive requests are capped below their tier's budget regardless of
# tier: every retry doubles time-to-answer, and in interactive use latency
# IS a quality loss. Batch work does not care.
max_attempts_interactive: 1
pinch:
# Relevance-based context pruning (ported from the MIT-licensed llmrouter's
# "pinch"). This is an OPTIONAL, pre-dispatch stage: when a conversation
# exceeds budget_tokens, the provider-bound messages are trimmed BEFORE any
# paid token is sent upstream. User/assistant/system messages are always
# kept verbatim; only old TOOL RESULTS are shortened or dropped, because
# they carry the bulk of a long agent session's tokens and are least needed
# in full by the time the next turn is answered.
#
# It does NOT touch the classifier's input, and it defaults off — enable it
# only if long sessions are shipping more prompt tokens than you want to pay
# for. Tool results can only be dropped safely because tool outputs are
# idempotent enough for a placeholder; a wrong guess here loses context, so
# start conservative (large budget, small reduction).
enabled: false
budget_tokens: 50000
keep_last_turns: 4
max_summarize_chars: 4000
routing:
# Access gating is prose-only in the NeuralWatt catalog ("Private preview
# (grant-gated)", "(Canary)"), so the poller parses it into access_level and
# routing excludes anything not listed here. Add 'preview'/'canary' only if
# the account actually holds the grant — otherwise dispatch earns a 403.
allowed_access_levels:
- public
# '-flex' rows are held server-side during peak until a capacity gap opens.
# That's correct for overnight/batch agent work and wrong for anything
# interactive, so a request has to opt in via latency_tolerance.
default_latency_tolerance: interactive # 'interactive' | 'batch'
# Minimum tool_use_agentic proficiency required of a model when the REQUEST
# carries tool definitions. A filter, not a weight, because it is a
# 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:
# 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.
#
# 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
# 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
# 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
# break-even failure rate drops to ~1.9%, which is plausible. Below the
# threshold a check costs more than the risk it removes.
min_completion_tokens: 600
# The check runs AFTER the response has gone back to the client, so it never
# adds its ~6s to anyone's latency. It exists to learn which models fail on
# real work, not to gate answers.
timeout_seconds: 60
max_output_tokens: 1024
# How far back /outcome looks when a report carries no request_id and no
# matching directory. A test run follows the completion that caused it within
# seconds, so this is deliberately short: a wide window sweeps in sessions
# that finished long ago and makes every report look ambiguous. If more than
# one conversation was active inside it, the report is refused rather than
# guessed at.
outcome_attribution_window_seconds: 120
freshness:
stale_after_days: 3
# Router refuses to route to a model whose row is stale/deprecated,
# regardless of how good its score would otherwise be.
exclude_stale: true
exclude_deprecated: true
database:
path: "router.db"
classifier:
# 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"
base_url: "http://localhost:11434/v1"
# 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
# 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.
timeout_seconds: 120
# Classification must be reproducible: at the default temperature the same
# task was classified tier 2 then tier 1 on consecutive calls, which routed
# it to two different models. Routing that changes under an identical
# prompt is untraceable.
temperature: 0
# Hard cap on the classifier's generation, to bound a failure mode that
# cascades on REASONING models: they emit a long chain of thought, blow past
# timeout_seconds, and Ollama keeps generating after the client gives up AND
# serializes per model, so one runaway request queues every later one behind
# it. Measured on qwen3.5, which failed this way on 4 of 19 calls — each one
# a 15s wait ending in a silent fallback.
#
# The current default (mistral-nemo) does not reason, so this does not bind
# for it. Kept anyway: it costs nothing when unused and is the only thing
# standing between a swapped-in reasoning model and that cascade. If you do
# 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
# Where routing lands when the classifier times out, errors, or returns
# something unparseable. A local model being slow should degrade routing,
# not refuse the request — the caller is a coding agent that would rather
# 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
# When the chat path supplies the previous turn as context (see the pinch /
# context notes), frame the classifier input as "Context: <prev> / Message:
# <current>" so a short follow-up inherits the prior turn's complexity
# instead of being classified in isolation as trivial.
context_framing: true
fallback_tier: 2
fallback_category: general_chat
response_format: "json" # ask Ollama to constrain output to valid JSON
# The dispatcher appends the authoritative category list from
# proficiency.categories to this prompt at call time. Do not enumerate the
# categories here as well — a hand-copied list drifts, and a category the
# model invents joins against nothing in the proficiency table.
system_prompt: |
You are a task router. Given a task description and any attached context,
respond with ONLY a JSON object with these fields:
{
"task_category": one of the allowed categories listed below,
"task_tier": integer 1-3, where 1 is cheap/simple, 2 is mid/general,
and 3 is frontier/high-stakes,
"required_context_tokens": integer estimate of prompt+context token count,
"confidence": float 0-1
}
dispatch_providers:
# NeuralWatt is the only provider. The dict shape and the models table's
# (model_id, provider) key are kept so a second one can be added without a
# migration.
neuralwatt:
base_url: "https://api.neuralwatt.com/v1"
api_key_env: "NEURALWATT_API_KEY"
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
# Whether to write a row to route_decisions for every routing decision
# (route | dispatch | chat | passthrough | local-vision). Off leaves the
# monitoring TUI's decision history empty; it does not change what routes.
log_route_decisions: true
# 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