hermes: normalize vision requests for Codex

This commit is contained in:
jenkins 2026-08-16 06:55:49 -03:00
parent 3469c340c5
commit 5dd13368c6
3 changed files with 83 additions and 3 deletions

View File

@ -185,6 +185,15 @@ data:
tasks when an objective benefits from persistent Codex or Claude Code CLI
execution that survives browser disconnects and can resume after restarts.
Project checkouts may provide nearer repository instructions, but they do
not replace these coordinator-wide rules. Never call `kanban_show` without
a known, non-empty task ID. Ad-hoc inspection and acceptance checks do not
need a synthetic Kanban lookup, and must load a skill only when its workflow
materially applies. Atlas HTTPS Git authentication is already supplied by
the runtime-only `GIT_ASKPASS`; use it without reading or exposing the
credential. Coordinator guidance lives at
`/opt/data/workspace/AGENTS.md` when more detail is needed.
The Jetson classifier is mandatory for AUTO selection. Switchyard may use
local Qwen for bounded low-risk responses and continuity, or spill to a
hosted provider when local capability is insufficient. Do not describe a
@ -296,8 +305,9 @@ data:
## Atlas engineering access
The Atlas organization has private visibility. Its repositories are access-
controlled as either private or Gitea-internal, never public, and are
The Atlas organization has private visibility. Repository visibility is
preserved per project and may be public or private; do not infer a
repository's visibility from the organization setting. Repositories are
canonical at `https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git
authentication is already supplied through `GIT_ASKPASS`. Verify the remote
and cleanly separate pre-existing changes, create a task branch, run the

View File

@ -221,6 +221,32 @@ def _upstream_headers(token: str) -> dict[str, str]:
return headers
def _normalize_input_images(response_input: list[Any]) -> None:
"""Normalize Chat-Completions image parts for the Codex Responses API."""
for item in response_input:
if not isinstance(item, dict) or item.get("type") != "message":
continue
content = item.get("content")
if not isinstance(content, list):
continue
for part in content:
if not isinstance(part, dict) or part.get("type") not in {
"image_url",
"input_image",
}:
continue
image_url = part.get("image_url")
if isinstance(image_url, dict):
detail = image_url.get("detail")
image_url = image_url.get("url")
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")
part["type"] = "input_image"
part["image_url"] = image_url
def _validate_payload(payload: Any) -> dict[str, Any]:
"""Allow only bounded Responses requests for the approved model catalog."""
if not isinstance(payload, dict):
@ -251,6 +277,7 @@ def _validate_payload(payload: Any) -> dict[str, Any]:
payload["input"] = [response_input]
elif not isinstance(response_input, list) or not response_input:
raise ValueError("non-empty Responses input list required")
_normalize_input_images(payload["input"])
# Switchyard uses ``max_output_tokens`` to bound the tiny classifier call,
# but its Responses translation can also copy that internal option onto the
# selected provider request. The first-party subscription Codex endpoint

View File

@ -76,6 +76,7 @@ def test_chat_config_enables_real_research_compute_and_delegation():
def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
configmap = _documents(HERMES / "agent-configmap.yaml")[0]
instructions = configmap["data"]["AGENTS.md"]
soul = configmap["data"]["SOUL.md"]
config = yaml.safe_load(configmap["data"]["config.yaml"])
assert (
@ -89,11 +90,15 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
assert "must never complete, block, unblock, reclaim" in instructions
assert "task's final structured result itself" in instructions
assert "Atlas organization has private visibility" in instructions
assert "private or Gitea-internal, never public" in instructions
assert "may be public or private" in instructions
assert "do not infer a\nrepository's visibility" in instructions
assert "already supplied through `GIT_ASKPASS`" in instructions
assert "Never call `kanban_show` without a known, non-empty task ID" in instructions
assert "bounded ad-hoc inspection and acceptance checks may" in instructions
assert "load implementation or TDD skills" in instructions
assert "Never call `kanban_show` without\na known, non-empty task ID" in soul
assert "must load a skill only when its workflow\nmaterially applies" in soul
assert "runtime-only `GIT_ASKPASS`" in soul
def test_agent_image_completes_parked_kanban_tasks_atomically():
@ -751,6 +756,44 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
assert module._validate_payload(
{"model": "gpt-5.6-terra", "input": response_items}
)["input"] is response_items
image_items = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "What color is this?"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,cHJpdmF0ZQ==",
"detail": "high",
},
},
],
}
]
assert module._validate_payload(
{"model": "gpt-5.6-terra", "input": image_items}
)["input"][0]["content"][1] == {
"type": "input_image",
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
"detail": "high",
}
with pytest.raises(ValueError, match="non-empty Responses image URL"):
module._validate_payload(
{
"model": "gpt-5.6-terra",
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_image", "image_url": {"detail": "high"}}
],
}
],
}
)
routed = module._validate_payload(
{
"model": "route/codex/gpt-5.6-luna/low",