Files
6krrt/routing.py
adlee-was-taken 6e6d02fac6 fix: resolve remaining capability-gate review findings
Implements the bugs_to_fix/capability-gate-followups.md plan (#1, #2, #3,
#4, #5, #6).

- #1: local vision fallback honors require_json_mode - an image + json_object
  request now falls through to the 422 naming the missing capability instead
  of returning prose that breaks the json contract
- #2: the pinned-model capability check now honors cfg.routing.require_vision /
  require_json_mode, so a pin and routed traffic face the same gate
- #3: drop 'image' modality from deepseek-v4-flash (supports_vision = 0)
- #4: README hard-filter count Four -> Six
- #5: extract routing.capability_gate_reason() shared by rejection_reason and
  the pinned check, so the flag rule cannot drift between the two
- #6: single iter_image_url_values() generator replaces four image-part
  traversals; _run_local_vision computes count/bytes once

Adds 9 tests (capability_gate_reason unit coverage, json-mode-gated local
fallback, vision gate off for pins). Full suite: 416 passing.
2026-08-23 13:03:39 -04:00

337 lines
14 KiB
Python

"""Pure candidate selection and ranking for the local LLM model router.
Like ``scoring.py`` and ``tiering.py``, this module is free of I/O: rows come
in as dicts, thresholds come in as arguments. ``dispatcher.py`` owns the DB
reads and the provider call.
Two stages, in order:
1. **Hard filters** (``select_candidates``) — disqualify outright. A model
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
weighed; it is not a candidate at all (design doc §3.2, §4).
2. **Ranking** (``rank_candidates``) — order what survives by quality, with
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``
rows are the same weights at the same advertised price, so on every scored
dimension they tie exactly with their standard sibling — but they are "held
server-side during peak until a capacity gap opens". Left to the scorer, a
coin flip decides whether an interactive request waits out a capacity gap.
Latency tolerance is therefore a filter, not a weight.
"""
from __future__ import annotations
from collections.abc import Sequence
from scoring import cost_score, proficiency_score
INTERACTIVE = "interactive"
BATCH = "batch"
def capability_gate_reason(
row: dict, *, require_vision: bool = False, require_json_mode: bool = False,
) -> str | None:
"""Same fail-closed rule ``rejection_reason`` uses for vision/json-mode,
pulled out so a pinned-model check can reuse it without re-implementing
it — see dispatcher._check_pinned_capabilities.
Returns a reason string when the gate fails, or None when the row passes.
Reason order: vision(unknown), vision(unsupported), json_mode(unknown),
json_mode(unsupported).
"""
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)"
return None
def rejection_reason(
row: dict,
*,
required_context_tokens: int,
required_tier: int,
latency_tolerance: str,
allowed_access_levels: Sequence[str],
exclude_stale: bool,
exclude_deprecated: bool,
min_tool_proficiency: float | None = None,
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
window cannot be shown to be large enough, and silently truncating
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")
if eff_ctx is None:
return "context(unknown)"
if eff_ctx < required_context_tokens:
return f"context({eff_ctx}<{required_context_tokens})"
tier = row.get("tier")
if tier is None:
return "tier(unknown)"
if tier < required_tier:
return f"tier({tier}<{required_tier})"
availability = row.get("availability")
if exclude_stale and availability == "stale":
return "stale"
if exclude_deprecated and (availability == "deprecated" or row.get("deprecated")):
return "deprecated"
access_level = row.get("access_level", "public")
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.
if latency_tolerance == INTERACTIVE and row.get("latency_class") == "flex":
return "latency_class(flex)"
# 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.
gate = capability_gate_reason(
row, require_vision=require_vision, require_json_mode=require_json_mode,
)
if gate is not None:
return gate
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(
rows: Sequence[dict],
*,
required_context_tokens: int,
required_tier: int,
latency_tolerance: str,
allowed_access_levels: Sequence[str],
exclude_stale: bool,
exclude_deprecated: bool,
min_tool_proficiency: float | None = None,
require_vision: bool = False,
require_json_mode: bool = False,
) -> list[dict]:
"""Apply every hard filter, preserving input order."""
return [
row
for row in rows
if is_eligible(
row,
required_context_tokens=required_context_tokens,
required_tier=required_tier,
latency_tolerance=latency_tolerance,
allowed_access_levels=allowed_access_levels,
exclude_stale=exclude_stale,
exclude_deprecated=exclude_deprecated,
min_tool_proficiency=min_tool_proficiency,
require_vision=require_vision,
require_json_mode=require_json_mode,
)
]
def estimated_cost(
row: dict,
prompt_tokens: int,
completion_tokens: int,
cache_rate: float,
) -> float | None:
"""What this request should cost on this model, from catalog prices.
Replaced a benchmark. The old cost signal came from a fixed 400-token
reference sweep, and it was measured to be WRONG for real traffic: on a
400-token prompt glm-5.2-fast looked 3.2x cheaper than deepseek-v4-flash,
but on a realistic 70k-token prompt deepseek is 5.0x cheaper. Attribution
inverts with prompt size — glm batches beautifully on toy prompts and badly
on real ones — so a fixed-shape benchmark cannot rank models for a workload
of a different shape.
Catalog prices, scaled to THIS request's shape, get the same answer as the
live measurement (7.8x vs 5.0x, same direction). They are also free, need
no sweep, and update whenever the poller runs.
They are not what gets billed — NeuralWatt charges per kWh — but billing is
capped at a multiple of token price, so this tracks the real ordering and
bounds it. Cheap and directionally right beats precise about the wrong
workload.
"""
prompt_price = row.get("cost_per_1m_prompt")
completion_price = row.get("cost_per_1m_completion")
if prompt_price is None or completion_price is None:
return None
# Agent traffic resends the conversation every turn, so most prompt tokens
# hit the provider's prefix cache and are billed at the cached rate.
cached_price = row.get("cost_per_1m_prompt_cached")
if cached_price is None:
cached_price = prompt_price
fresh = prompt_tokens * (1.0 - cache_rate) * prompt_price
cached = prompt_tokens * cache_rate * cached_price
return (fresh + cached + completion_tokens * completion_price) / 1_000_000
def within_budget(row: dict, max_energy_kwh: float | None) -> bool:
"""Whether a candidate's measured energy is inside the per-request ceiling.
Energy rather than dollars because the plan is a subscription with a fixed
kWh quota. Dollars accrue and can be reasoned about after the fact; a quota
is a wall you hit in the middle of a task.
A model with no energy measurement is admitted. Excluding the unmeasured
would mean a newly listed model could never be picked and so could never
acquire a measurement — the same trap the neutral-0.5 default avoids
elsewhere.
"""
if max_energy_kwh is None:
return True
energy = row.get("energy")
return energy is None or energy <= max_energy_kwh
def rank_candidates(
rows: Sequence[dict],
*,
quality_tolerance: float = 0.1,
max_energy_per_request: float | None = None,
prompt_tokens: int = 0,
completion_tokens: int = 500,
cache_rate: float = 0.84,
) -> list[dict]:
"""Order candidates: best quality first, cheapest among equals.
This replaced a weighted blend of cost, eco and proficiency, for reasons
measurement forced:
- **Eco is no longer an objective.** Carbon is still logged per request,
but it is not something this router optimizes; that judgement is made
outside it. Leaving it in meant 20% of every decision optimized an
unstated goal.
- **Cost is a constraint and a tiebreak, not a weight.** Under the blend,
60% of the decision adjudicated differences of fractions of a cent —
all real traffic to date totals $0.07 — and min-max normalization made
"expensive" relative to whoever else happened to be a candidate, so a
model could lose for being 2x a very cheap model when both round to
nothing. A ceiling states the cost mandate as a guarantee instead.
- **Quality is the objective**, which is what the blend obscured: turning
the cost weight from 0.4 all the way to zero changed the winner in only
2 of 6 categories, because the blend was never really steering on
quality at all.
``quality_tolerance`` is the band inside which two proficiency scores are
treated as equal. It is not a preference — it reflects measurement noise.
Proficiency currently rests on 2-3 samples per category, so a gap of 0.05
is indistinguishable from sampling variation, and paying more for it would
be buying noise. Widen it as confidence falls, narrow it as samples
accumulate.
Returns each row plus ``proficiency_score``, ``cost_score`` (reported for
visibility only, no longer part of the decision) and ``composite``, which
is now simply the effective quality after the tolerance band.
"""
affordable = [r for r in rows if within_budget(r, max_energy_per_request)]
# Priced for THIS request rather than for a benchmark, which is the whole
# point: the ordering depends on the workload's shape. A row the catalog
# has no price for keeps whatever measured cost it arrived with rather
# than losing the field — a missing list price is not free.
estimates = []
for r in affordable:
est = estimated_cost(r, prompt_tokens, completion_tokens, cache_rate)
estimates.append(r.get("cost") if est is None else est)
# cost_score is retained purely so callers can still see the spread; it
# does not enter the ordering.
cost_scores = cost_score(estimates)
ranked = []
for row, c_s, est in zip(affordable, cost_scores, estimates):
p_s = proficiency_score(row.get("proficiency"))
ranked.append({**row, "cost": est, "cost_score": c_s,
"proficiency_score": p_s, "composite": p_s})
if not ranked:
return []
# Quality first, but only differences larger than the tolerance count.
# Bucketing by band means a 0.01 edge cannot outrank a 10x cost saving,
# while a real gap (tool_use_agentic spans 0.67) still decides outright.
best = max(r["proficiency_score"] for r in ranked)
def band(r: dict) -> float:
# 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(
key=lambda r: (
band(r),
r["cost"] if r.get("cost") is not None else float("inf"),
r["model_id"],
)
)
return ranked