Files
6krrt/code_reviews/capability-gate-followups.md
adlee-was-taken 7d3d961cb0 fix: a degenerate image_url part silently dropped out of two fail-closed checks
Reviewing PR #3's iter_image_url_values (which correctly collapsed four
traversals into one, per the capability-gate-followups plan): a part typed
image_url but carrying neither a nested image_url.url nor a bare url key
now yields nothing at all instead of an empty string, so it vanished from
every derived check instead of failing them. Confirmed live before this
fix: has_images read False (dodging the vision gate) and
_local_vision_data_uris_ok read True (an SSRF guard passing vacuously on
zero yielded items) for exactly this shape -- both regressions from what
the four original hand-rolled loops did.

Now every image_url-typed part yields exactly one string, "" when nothing
resolves, which fails startswith("data:") and still counts toward
any()/sum() -- matching pre-PR behavior. Two regression tests added.

Also renames bugs_to_fix/ to code_reviews/, since this is the second report
that's gone in there and "bugs to fix" undersold what the first one turned
into -- and adds a review report for PR #3 alongside the plan it was
implementing.
2026-08-23 13:49:21 -04:00

320 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Capability-gate follow-ups
Source: `/code-review medium` on `neuralwatt-router-service`, 2026-08-23, run
against the capability-gate / local-vision-fallback work
(`4ce9959`..`c4ebc4b`..`cff36f8`). 8 findings came back; 2 are fixed
(`69d3a8b`: `local_vision.enabled` now ships/defaults `true` as a documented
core feature, and the `provider/model` prefix is now resolved against the
catalog for a pin, not only for `auto`). This tracks the remaining 6.
No timeline attached — pick items up in any order except where a dependency
is called out below. #3 and #6 touch the same two functions and share a
root cause, so doing them together avoids editing the same lines twice.
---
## 1. Local vision fallback ignores `require_json_mode`
**File:** `dispatcher.py`, fallback trigger at `chat_completions` (~line
1785-1794); the check function is `_run_local_vision` (~line 1478).
**Problem.** The fallback fires on `caps.has_images and
cfg.local_vision.enabled` alone:
```python
if decision.selected is None:
if (
caps.has_images
and cfg.local_vision.enabled
):
fallback = _run_local_vision(messages, cfg.local_vision)
```
It never checks `caps.require_json_mode`. If a request carries both an
image and `response_format: {type: json_object}`, and JSON mode — not
vision — is what excluded every candidate, the fallback still fires.
`_local_vision_response` wraps the local model's free-form text as a normal
completion with no JSON-mode enforcement, silently breaking the caller's
`json_object` contract and returning 200 instead of the 422 that would have
correctly named the missing capability.
**Fix.** Local vision only knows how to answer with prose, so it must not
be attempted when JSON mode was requested:
```python
if decision.selected is None:
if (
caps.has_images
and not caps.require_json_mode
and cfg.local_vision.enabled
):
fallback = _run_local_vision(messages, cfg.local_vision)
```
When both are true, this now falls through to the existing 422, which
already lists `caps.require_json_mode` in `limits` (line ~1813-1816) — no
change needed there.
**Test to add** (`tests/test_chat_completions.py`, alongside
`test_local_fallback_refuses_a_remote_image_url` and the other
`local_vision` tests): an image + `response_format: json_object` request,
with `local_vision.enabled = True` and no cloud vision candidate, must
still 422 and must not reach the fake local-vision `fake_post`.
---
## 2. Pinned-model capability gate ignores `require_vision`/`require_json_mode` config
**File:** `dispatcher.py`, `_check_pinned_capabilities` (line 1622) and its
call site (line 1844-1845).
**Problem.** `route()` only applies the vision/json-mode filter when the
corresponding config flag is on:
```python
require_vision=cfg.routing.require_vision if req.has_images else False,
require_json_mode=(
cfg.routing.require_json_mode if req.require_json_mode else False
),
```
(`dispatcher.py` ~line 610-613, feeding `routing.rejection_reason`.)
`_check_pinned_capabilities` has no such condition — it checks
`caps.has_images` / `caps.require_json_mode` directly, with no reference to
`cfg.routing.require_vision` / `require_json_mode` at all. If an operator
sets `routing.require_vision: false` (accepting the occasional provider
400 in exchange for not fail-closing on an unconfirmed flag), `auto`
traffic honors that, but a pin with an image still gets an unconditional
422. Same request shape, different outcome, depending only on whether the
client said `auto` or a real model id.
**Fix.** Covered by the refactor in #6 below — once
`_check_pinned_capabilities` calls the same shared helper `route()` uses,
threading the two config flags through is one line each. If #6 is deferred,
the standalone fix is:
```python
if cfg.routing.require_vision and caps.has_images and (row is None or row["supports_vision"] != 1):
...
if cfg.routing.require_json_mode and caps.require_json_mode and (row is None or row["supports_json_mode"] != 1):
...
```
**Test to add:** with `cfg.routing.require_vision` monkeypatched `False`,
a pin to a non-vision model with an image must dispatch (200), not 422.
---
## 3. `opencode.json` advertises image support `deepseek-v4-flash` doesn't have
**File:** `opencode.json`, the `deepseek-v4-flash` model entry (line ~39-51).
**Problem.** Confirmed against the live catalog:
```
$ sqlite3 router.db "SELECT model_id, supports_vision FROM models WHERE model_id='deepseek-v4-flash';"
deepseek-v4-flash|0
```
but the opencode entry declares:
```json
"deepseek-v4-flash": {
"modalities": { "input": ["text", "image"] }
}
```
copied from the other pins, all of which really do support vision
(`gemma-4-31b`, `kimi-k2.7-code`, `kimi-k3`, `qwen3.6-35b` all read
`supports_vision = 1`). opencode's UI trusts this and lets a user attach an
image to the deepseek pin; the request then either 422s (once #2 above is
fixed) or, today, gets forwarded and fails some other way — either way it's
a late, confusing failure on a model the client's own config advertised as
supporting images, instead of the image-attach control being disabled for
that one pin.
**Fix.** Drop the `modalities` block from the `deepseek-v4-flash` entry (or
set `"input": ["text"]`). No code change; this is a static config edit.
**Verification.** Since the catalog's vision support can change if
NeuralWatt updates the model, this is a fact worth re-checking rather than
assuming — re-run the query above before editing if it's been a while, and
consider whether `poller.py` should be the source of truth for
`opencode.json`'s modality lists instead of a hand-maintained copy (out of
scope here; noting it so it doesn't get re-discovered from scratch next
time this file needs an update).
---
## 4. README's "Four hard filters" heading undercounts
**File:** `README.md`, line 376.
**Problem.**
```
Four hard filters are applied **before** scoring (not weighted — outright disqualification):
```
immediately precedes a 6-item list (context, tier, latency/access,
tool-proficiency, vision, json-mode), and the paragraph right after it
already says "Filters 46" (line 388). Leftover from the vision/json-mode
filters this diff added without updating the count above them.
**Fix.** One-word change: `Four``Six`.
---
## 5. `_check_pinned_capabilities` duplicates the centralized gate logic
**File:** `dispatcher.py` (`_check_pinned_capabilities`, line 1622) vs.
`routing.py` (`rejection_reason`, line 36, vision/json-mode block at
line 104-121).
**Problem.** `routing.rejection_reason` already centralizes the fail-closed
vision/json-mode rule (unknown flag → reject, `False` flag → reject, `True`
→ pass), used by every routed (`auto`) request. `_check_pinned_capabilities`
re-implements the same rule with its own raw SQL and a hardcoded pair of
`if`s for exactly these two flags. Two independent copies of one rule is
how #2 above happened, and it's how a third gated capability (audio, say)
would have to be added in `capabilities.py`, `routing.py` (the dataclass
*and* `rejection_reason`), `TaskRequest`, both `route()` call sites,
`config.py`/`config.yaml`, *and* a new `if` block here — six-plus places
for one flag, free to drift.
**Fix.** Extract the capability-flag check out of `rejection_reason` into
its own function in `routing.py`, since it's the one self-contained piece
of that rule (unlike context/tier/tool-proficiency, a pin deliberately
skips those — "a client pinned to one model gets none of the filtering or
ranking below" is intentional, so this must NOT become "call
`rejection_reason` for pins too"):
```python
# routing.py
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."""
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
```
`rejection_reason` calls it in place of its inline block (behavior
unchanged — same checks, same order, same reason strings, so
`tests/test_routing.py`'s existing assertions on those strings still hold).
`_check_pinned_capabilities` becomes:
```python
def _check_pinned_capabilities(model_id: str, caps) -> None:
conn = _db()
try:
row = conn.execute(
"SELECT supports_vision, supports_json_mode FROM models "
"WHERE model_id = ? AND provider = 'neuralwatt'",
(model_id,),
).fetchone()
finally:
conn.close()
reason = capability_gate_reason(
dict(row) if row else {},
require_vision=cfg.routing.require_vision and caps.has_images,
require_json_mode=cfg.routing.require_json_mode and caps.require_json_mode,
)
if reason is not None:
capability = "vision" if reason.startswith("vision") else "json mode"
raise HTTPException(422, f"{model_id} does not support {capability}...")
```
This is the same refactor that fixes #2 (config-flag conditioning) — do
them as one change, not two.
**Test to add:** `tests/test_routing.py` already covers
`rejection_reason`'s vision/json-mode branches; add a direct unit test for
`capability_gate_reason` (or just keep testing it through
`rejection_reason`, since that's now a thin wrapper — either is fine, pick
whichever the existing test file's style favors). The existing
`test_a_pinned_non_vision_model_with_an_image_is_a_clear_422` and the two
prefix-resolution tests added in `69d3a8b` should keep passing unmodified;
that's the regression check that the refactor didn't change behavior for
the default (`require_vision: true`) config.
---
## 6. Image-part traversal reimplemented four times
**Files:** `dispatcher.py``_count_images` (line 1405),
`_image_payload_bytes` (line 1424), `_local_vision_data_uris_ok`
(line 1450); `capabilities.py``_any_message_has_images` (line 71).
**Problem.** All four walk the same shape (`messages` → dict with a
`content` list → parts where `part["type"] == "image_url"`) with slightly
different unwrapping of the `image_url` value (dict-with-`url`-key vs.
bare string) between them. A future change to how images are represented
in the request body has to be applied in four places; missing one silently
breaks detection, the byte budget, or the SSRF data-URI guard while the
others keep working. `_run_local_vision` also calls `_count_images` and
`_image_payload_bytes` twice each — once for the budget check, again to
log on the failure path — doubling the scan for no reason.
**Fix.** One shared generator that yields each image part once, with the
`image_url` value already unwrapped to a string:
```python
# capabilities.py, since dispatcher.py already imports from it
def iter_image_url_values(messages: list[Any]) -> Iterator[str]:
"""Every image_url part's URL/data-URI string, across all messages."""
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 not isinstance(part, dict) or part.get("type") != "image_url":
continue
value = part.get("image_url")
if isinstance(value, dict):
value = value.get("url", "")
if isinstance(value, str):
yield value
```
Then:
- `_any_message_has_images``any(True for _ in iter_image_url_values(messages))`
- `_count_images``sum(1 for _ in iter_image_url_values(messages))`
- `_image_payload_bytes``sum(len(v) for v in iter_image_url_values(messages))`
- `_local_vision_data_uris_ok``all(v.startswith("data:") for v in iter_image_url_values(messages))`
And in `_run_local_vision`, compute `_count_images(messages)` and
`_image_payload_bytes(messages)` once each and reuse the value in both the
condition and the log call, rather than recomputing for the log line.
**Test to add:** none new — this is a pure refactor behind the same four
call sites, all of which already have test coverage
(`test_local_fallback_refuses_a_remote_image_url`,
`test_the_two_pass_reroute_passes_image_capability`, and the capability
tests in `tests/test_capabilities.py`). Running the existing suite green is
the verification.
---
## Suggested order
1. **#1** (json-mode-blind fallback) — standalone, quick, clear correctness fix.
2. **#5 + #2 together** (extract `capability_gate_reason`, thread config
flags through the pinned check) — one refactor fixes both.
3. **#6** (shared image-part iterator) — touches the same file as #2/#5;
convenient to do in the same sitting but not dependent on it.
4. **#3** (`opencode.json` modality) and **#4** (README count) — trivial,
do whenever, in any order.