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.
320 lines
13 KiB
Markdown
320 lines
13 KiB
Markdown
# 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 4–6" (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.
|