diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index aea2e94dd..44fb3b822 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: "20260811-switchyard-responses-input" + ai.bstein.dev/config-rev: "20260811-switchyard-responses-adapter" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens diff --git a/services/hermes/model-gate-configmap.yaml b/services/hermes/model-gate-configmap.yaml index 98c6b2fc5..946fe5faa 100644 --- a/services/hermes/model-gate-configmap.yaml +++ b/services/hermes/model-gate-configmap.yaml @@ -68,6 +68,11 @@ data: if isinstance(model, str) and model.startswith("route/local/qwen2.5-14b/"): payload["model"] = "qwen2.5:14b-instruct-q4_0" changed = True + elif not isinstance(model, str) or not model.strip(): + # This gate serves one text model, so an omitted routed target has one + # unambiguous and safe default. + payload["model"] = "qwen2.5:14b-instruct-q4_0" + changed = True for key in ("reasoning_effort", "reasoning"): value = payload.get(key) if isinstance(value, str) and value.lower() in {"xhigh", "max"}: diff --git a/services/hermes/model-gate-deployment.yaml b/services/hermes/model-gate-deployment.yaml index f47c611ae..b1824db62 100644 --- a/services/hermes/model-gate-deployment.yaml +++ b/services/hermes/model-gate-deployment.yaml @@ -15,7 +15,7 @@ spec: template: metadata: annotations: - ai.bstein.dev/config-rev: "20260811-jetson-text-image-handoff" + ai.bstein.dev/config-rev: "20260811-switchyard-model-default" labels: app: hermes-model-gate spec: diff --git a/services/hermes/scripts/codex_broker.py b/services/hermes/scripts/codex_broker.py index 99ff93e71..2cfa61583 100644 --- a/services/hermes/scripts/codex_broker.py +++ b/services/hermes/scripts/codex_broker.py @@ -11,7 +11,7 @@ import os import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import Any +from typing import Any, Iterable import httpx @@ -130,6 +130,41 @@ def _validate_payload(payload: Any) -> dict[str, Any]: return payload +def _completed_response(lines: Iterable[str]) -> dict[str, Any]: + """Collapse a Codex SSE stream for a non-streaming Responses caller.""" + terminal_response: dict[str, Any] | None = None + upstream_error = "" + for line in lines: + if not line.startswith("data:"): + continue + value = line[5:].strip() + if not value or value == "[DONE]": + continue + try: + event = json.loads(value) + except (TypeError, ValueError, json.JSONDecodeError): + continue + if not isinstance(event, dict): + continue + event_type = str(event.get("type") or "") + response = event.get("response") + if event_type in { + "response.completed", + "response.failed", + "response.incomplete", + } and isinstance(response, dict): + terminal_response = response + elif event_type == "error": + error = event.get("error") + if isinstance(error, dict): + upstream_error = str(error.get("message") or error.get("type") or "") + else: + upstream_error = str(error or "") + if terminal_response is not None: + return terminal_response + raise RuntimeError(upstream_error or "Codex stream ended without a terminal response") + + class Handler(BaseHTTPRequestHandler): """Authenticated streaming proxy; request bodies and tokens are never logged.""" @@ -191,7 +226,9 @@ class Handler(BaseHTTPRequestHandler): return try: - payload = _validate_payload(json.loads(self.rfile.read(length))) + payload = json.loads(self.rfile.read(length)) + requested_stream = payload.get("stream") is True + payload = _validate_payload(payload) token = _access_token() timeout = httpx.Timeout( READ_TIMEOUT_SECONDS, @@ -212,6 +249,10 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) return + if not requested_stream: + self._json(200, _completed_response(response.iter_lines())) + return + # HTTP/1.0 close-delimited streaming avoids buffering a # potentially long tool-calling turn in the broker. self.send_response(200) diff --git a/services/hermes/switchyard-configmap.yaml b/services/hermes/switchyard-configmap.yaml index 9542eb18b..f1313d05e 100644 --- a/services/hermes/switchyard-configmap.yaml +++ b/services/hermes/switchyard-configmap.yaml @@ -130,7 +130,6 @@ data: [targets.claude_haiku_low] id = "route/claude/haiku/low" llm_client = "claude_low" - extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "low" } } [targets.claude_sonnet_medium] id = "route/claude/sonnet/medium" diff --git a/services/hermes/switchyard-deployment.yaml b/services/hermes/switchyard-deployment.yaml index 7c8cf2d25..c916db5b6 100644 --- a/services/hermes/switchyard-deployment.yaml +++ b/services/hermes/switchyard-deployment.yaml @@ -19,7 +19,7 @@ spec: labels: app: hermes-switchyard annotations: - ai.bstein.dev/config-rev: "20260811-switchyard-authority-v5" + ai.bstein.dev/config-rev: "20260811-switchyard-authority-v6" prometheus.io/scrape: "true" prometheus.io/port: "9005" prometheus.io/path: /metrics diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 9abe0bc39..92eead708 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -542,6 +542,30 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch): with pytest.raises(ValueError, match="non-empty Responses input list"): module._validate_payload({"model": "gpt-5.6-terra", "input": []}) + completed = { + "id": "resp_test", + "object": "response", + "status": "completed", + "output": [], + } + assert module._completed_response( + [ + "event: response.created", + 'data: {"type":"response.created","response":{}}', + "event: response.completed", + "data: " + + json.dumps({"type": "response.completed", "response": completed}), + "data: [DONE]", + ] + ) == completed + with pytest.raises(RuntimeError, match="provider unavailable"): + module._completed_response( + [ + "event: error", + 'data: {"type":"error","error":{"message":"provider unavailable"}}', + ] + ) + auth_dir = tmp_path / ".codex" auth_dir.mkdir() # The token payload need only prove the broker reads CODEX_HOME directly. diff --git a/testing/tests/test_hermes_model_gate.py b/testing/tests/test_hermes_model_gate.py index 1dccf7dc8..7c03eb735 100644 --- a/testing/tests/test_hermes_model_gate.py +++ b/testing/tests/test_hermes_model_gate.py @@ -32,7 +32,10 @@ def test_model_gate_clamps_hosted_only_reasoning_efforts(): def test_model_gate_preserves_supported_and_non_json_requests(): normalize = _model_gate_namespace()["_normalize_reasoning"] - supported = b'{"reasoning_effort":"medium","messages":[]}' + supported = ( + b'{"model":"qwen2.5:14b-instruct-q4_0",' + b'"reasoning_effort":"medium","messages":[]}' + ) non_json = b"streamed-body" assert normalize(supported) == supported @@ -53,6 +56,14 @@ def test_model_gate_translates_switchyard_local_target_alias(): assert routed["reasoning_effort"] == "medium" +def test_model_gate_supplies_its_single_model_when_switchyard_omits_it(): + normalize = _model_gate_namespace()["_normalize_reasoning"] + + routed = json.loads(normalize(b'{"messages":[{"role":"user","content":"hi"}]}')) + + assert routed["model"] == "qwen2.5:14b-instruct-q4_0" + + def test_model_gate_runs_a_renderer_aware_ariadne_handoff(): """Wolf cannot reach Ollama until the local image service reports idle.""" namespace = _model_gate_namespace()