Files
6krrt/capabilities.py
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

104 lines
4.5 KiB
Python

"""Detect request-side capability requirements from an OpenAI-format body.
Like ``routing.py``, ``scoring.py`` and ``tiering.py``, this module is free of
I/O: the request body comes in as a dict and capabilities come out as a value
object. ``dispatcher.py`` owns the parsing of the HTTP request into that dict.
Detection is read from the request body, not inferred by a classifier. This
mirrors the existing ``tools_present`` / ``min_tool_proficiency`` philosophy in
``routing.py``: a local classifier cannot reliably identify agentic or visual
work — asked to label unambiguous tool-use prompts it got 1-2 of 6, and vision
is the same class of problem. But the request states both exactly, in the
``tools`` array and in an ``image_url`` part, and reading them is free. When
the capability matters, it is a hard filter (a model that cannot be trusted
with tools that exist is not a candidate), never a weighted preference.
``has_reasoning_request`` is the one flag that is informational only: it is
recorded so dispatcher can observe it, but never fed to a routing filter.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
# OpenAI response_format.types that require the model to support JSON mode.
JSON_MODE_TYPES = frozenset({"json_object", "json_schema"})
@dataclass(frozen=True)
class RequestCapabilities:
has_images: bool = False
require_json_mode: bool = False
tools_present: bool = False
has_reasoning_request: bool = False
def detect_capabilities(body: dict[str, Any]) -> RequestCapabilities:
"""Read the capability requirements stated by an OpenAI-format request body.
``has_images`` scans ALL messages, not just the last: vision is a property
of the whole conversation, and an image in an earlier turn still has to be
understood by whoever answers the latest one. A message whose ``content`` is
a list contributes True if any part is a dict whose ``type`` is
``image_url``. String content and non-image parts never trigger it; a raw
base64 string sitting in a text part is not an image_url and must not be
matched.
``require_json_mode`` reads ``response_format.type`` against
``JSON_MODE_TYPES``. ``text`` and an absent ``response_format`` read False.
``tools_present`` is truthy exactly when the body carries a ``tools`` array.
``has_reasoning_request`` is True if the body carries either a
``reasoning_effort`` key or a ``reasoning`` key. This flag is recorded for
observation only and is never consumed by a routing filter.
"""
has_images = _any_message_has_images(body.get("messages") or [])
response_format = body.get("response_format")
require_json_mode = (
isinstance(response_format, dict)
and response_format.get("type") in JSON_MODE_TYPES
)
return RequestCapabilities(
has_images=has_images,
require_json_mode=require_json_mode,
tools_present=bool(body.get("tools")),
has_reasoning_request="reasoning_effort" in body or "reasoning" in body,
)
def iter_image_url_values(messages: list[Any]) -> Iterator[str]:
"""Every image_url part's URL/data-URI string, across all messages.
Yields exactly one string per image_url-typed part, even when no URL can
be resolved from it (empty string). A part that declares itself an image
but carries no readable value must still count toward "this conversation
has an image" and still fail the "every part is a safe data: URI" check
-- silently skipping it, as an earlier version of this function did,
dropped it out of both checks: a degenerate part could then dodge the
vision-capability gate AND slip past the SSRF guard that only holds when
every yielded value is verified to start with ``data:``.
"""
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 not isinstance(value, str):
# Bare url key (alternative shape), or nothing resolvable.
value = part.get("url") if isinstance(part.get("url"), str) else ""
yield value
def _any_message_has_images(messages: list[Any]) -> bool:
return any(True for _ in iter_image_url_values(messages))