"""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))