diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index d3440491..e3107a56 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -25,7 +25,7 @@ spec: ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260816-routed-vision-v3" + ai.bstein.dev/config-rev: "20260816-routed-vision-v4" prometheus.io/scrape: "true" prometheus.io/path: /metrics prometheus.io/port: "9010" diff --git a/services/hermes/scripts/codex_broker.py b/services/hermes/scripts/codex_broker.py index bc9c1f02..bc88b26b 100644 --- a/services/hermes/scripts/codex_broker.py +++ b/services/hermes/scripts/codex_broker.py @@ -226,13 +226,14 @@ def _normalize_input_images(response_input: list[Any]) -> None: def image_url_value(part: dict[str, Any]) -> tuple[str | None, str | None]: """Return a Responses URL from URL-object or base64-source forms.""" - raw = part.get("image_url") - if isinstance(raw, str): - return raw, None - candidates = [raw, part.get("source")] - for candidate in candidates: - if not isinstance(candidate, dict): - continue + + def search(candidate: Any, depth: int = 0) -> tuple[str | None, str | None]: + if isinstance(candidate, str): + if candidate.startswith(("data:image/", "https://", "http://")): + return candidate, None + return None, None + if not isinstance(candidate, dict) or depth > 4: + return None, None detail = candidate.get("detail") url = candidate.get("url") if isinstance(url, str) and url.strip(): @@ -249,8 +250,39 @@ def _normalize_input_images(response_input: list[Any]) -> None: return f"data:{media_type};base64,{data}", ( detail if isinstance(detail, str) else None ) + for nested in candidate.values(): + nested_url, nested_detail = search(nested, depth + 1) + if nested_url: + return nested_url, ( + detail if isinstance(detail, str) else nested_detail + ) + return None, None + + for candidate in (part.get("image_url"), part.get("source"), part): + image_url, detail = search(candidate) + if image_url: + return image_url, detail return None, None + def image_shape(part: dict[str, Any]) -> str: + """Describe only structural types when an upstream image is malformed.""" + + def describe(value: Any, depth: int = 0) -> Any: + if depth > 3: + return type(value).__name__ + if isinstance(value, dict): + return { + str(key)[:40]: describe(nested, depth + 1) + for key, nested in list(value.items())[:12] + } + if isinstance(value, list): + return [describe(nested, depth + 1) for nested in value[:4]] + if isinstance(value, str): + return f"str[{len(value)}]" + return type(value).__name__ + + return json.dumps(describe(part), sort_keys=True, separators=(",", ":")) + for item in response_input: if not isinstance(item, dict): continue @@ -268,7 +300,10 @@ def _normalize_input_images(response_input: list[Any]) -> None: if isinstance(detail, str) and detail and "detail" not in part: part["detail"] = detail if not isinstance(image_url, str) or not image_url.strip(): - raise ValueError("non-empty Responses image URL required") + raise ValueError( + "non-empty Responses image URL required; shape=" + + image_shape(part) + ) part["type"] = "input_image" part["image_url"] = image_url part.pop("source", None) diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 78421c4b..696926f9 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -824,7 +824,26 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch): "type": "input_image", "image_url": "data:image/png;base64,cHJpdmF0ZQ==", } - with pytest.raises(ValueError, match="non-empty Responses image URL"): + nested_switchyard_items = [ + { + "role": "user", + "content": [ + { + "type": "input_image", + "image_url": { + "image": { + "original_url": "data:image/png;base64,cHJpdmF0ZQ==" + } + }, + } + ], + } + ] + nested_image = module._validate_payload( + {"model": "gpt-5.6-terra", "input": nested_switchyard_items} + )["input"][0]["content"][0] + assert nested_image["image_url"] == "data:image/png;base64,cHJpdmF0ZQ==" + with pytest.raises(ValueError, match=r"non-empty Responses image URL.*str\[4\]"): module._validate_payload( { "model": "gpt-5.6-terra", @@ -833,7 +852,10 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch): "type": "message", "role": "user", "content": [ - {"type": "input_image", "image_url": {"detail": "high"}} + { + "type": "input_image", + "image_url": {"detail": "high"}, + } ], } ],