diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 44fb3b822..824527879 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-adapter" + ai.bstein.dev/config-rev: "20260811-switchyard-responses-output" 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 946fe5faa..bf4efd0df 100644 --- a/services/hermes/model-gate-configmap.yaml +++ b/services/hermes/model-gate-configmap.yaml @@ -73,16 +73,13 @@ data: # unambiguous and safe default. payload["model"] = "qwen2.5:14b-instruct-q4_0" changed = True - for key in ("reasoning_effort", "reasoning"): + # This non-thinking Qwen build rejects hosted-provider reasoning fields. + # Effort remains a Switchyard routing concern, not an Ollama request. + for key in ("reasoning_effort", "reasoning", "thinking", "output_config"): value = payload.get(key) - if isinstance(value, str) and value.lower() in {"xhigh", "max"}: - payload[key] = "high" + if value is not None: + payload.pop(key, None) changed = True - elif isinstance(value, dict): - effort = value.get("effort") - if isinstance(effort, str) and effort.lower() in {"xhigh", "max"}: - value["effort"] = "high" - changed = True if not changed: return body return json.dumps(payload, separators=(",", ":")).encode("utf-8") diff --git a/services/hermes/model-gate-deployment.yaml b/services/hermes/model-gate-deployment.yaml index b1824db62..cc747e073 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-switchyard-model-default" + ai.bstein.dev/config-rev: "20260811-switchyard-model-fields" labels: app: hermes-model-gate spec: diff --git a/services/hermes/scripts/codex_broker.py b/services/hermes/scripts/codex_broker.py index 2cfa61583..0f810bcd4 100644 --- a/services/hermes/scripts/codex_broker.py +++ b/services/hermes/scripts/codex_broker.py @@ -133,6 +133,7 @@ def _validate_payload(payload: Any) -> dict[str, Any]: 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 + output_items: dict[int, dict[str, Any]] = {} upstream_error = "" for line in lines: if not line.startswith("data:"): @@ -148,6 +149,11 @@ def _completed_response(lines: Iterable[str]) -> dict[str, Any]: continue event_type = str(event.get("type") or "") response = event.get("response") + if event_type == "response.output_item.done": + item = event.get("item") + output_index = event.get("output_index") + if isinstance(item, dict) and isinstance(output_index, int): + output_items[output_index] = item if event_type in { "response.completed", "response.failed", @@ -161,6 +167,13 @@ def _completed_response(lines: Iterable[str]) -> dict[str, Any]: else: upstream_error = str(error or "") if terminal_response is not None: + # The subscription Codex endpoint streams complete output items but + # currently leaves the terminal response's output array empty. Public + # Responses clients, including Switchyard, expect those items there. + if output_items and not terminal_response.get("output"): + terminal_response["output"] = [ + output_items[index] for index in sorted(output_items) + ] return terminal_response raise RuntimeError(upstream_error or "Codex stream ended without a terminal response") diff --git a/services/hermes/switchyard-configmap.yaml b/services/hermes/switchyard-configmap.yaml index f1313d05e..b953dfcb1 100644 --- a/services/hermes/switchyard-configmap.yaml +++ b/services/hermes/switchyard-configmap.yaml @@ -85,12 +85,10 @@ data: [targets.local_qwen_low] id = "route/local/qwen2.5-14b/low" llm_client = "local_low" - extra_body = { reasoning_effort = "low" } [targets.local_qwen_medium] id = "route/local/qwen2.5-14b/medium" llm_client = "local_medium" - extra_body = { reasoning_effort = "medium" } [targets.codex_luna_low] id = "route/codex/luna/low" diff --git a/services/hermes/switchyard-deployment.yaml b/services/hermes/switchyard-deployment.yaml index c916db5b6..71bf2c117 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-v6" + ai.bstein.dev/config-rev: "20260811-switchyard-authority-v7" 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 92eead708..9fcee3d6d 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -548,16 +548,31 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch): "status": "completed", "output": [], } + completed_item = { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "done"}], + } assert module._completed_response( [ "event: response.created", 'data: {"type":"response.created","response":{}}', + "event: response.output_item.done", + "data: " + + json.dumps( + { + "type": "response.output_item.done", + "output_index": 0, + "item": completed_item, + } + ), "event: response.completed", "data: " + json.dumps({"type": "response.completed", "response": completed}), "data: [DONE]", ] - ) == completed + )["output"] == [completed_item] with pytest.raises(RuntimeError, match="provider unavailable"): module._completed_response( [ diff --git a/testing/tests/test_hermes_model_gate.py b/testing/tests/test_hermes_model_gate.py index 7c03eb735..01ab10d47 100644 --- a/testing/tests/test_hermes_model_gate.py +++ b/testing/tests/test_hermes_model_gate.py @@ -18,24 +18,21 @@ def _model_gate_namespace() -> dict: return namespace -def test_model_gate_clamps_hosted_only_reasoning_efforts(): +def test_model_gate_removes_hosted_only_reasoning_fields(): normalize = _model_gate_namespace()["_normalize_reasoning"] top_level = json.loads(normalize(b'{"reasoning_effort":"xhigh"}')) string_reasoning = json.loads(normalize(b'{"reasoning":"max"}')) nested = json.loads(normalize(b'{"reasoning":{"effort":"xhigh"}}')) - assert top_level["reasoning_effort"] == "high" - assert string_reasoning["reasoning"] == "high" - assert nested["reasoning"]["effort"] == "high" + assert "reasoning_effort" not in top_level + assert "reasoning" not in string_reasoning + assert "reasoning" not in nested def test_model_gate_preserves_supported_and_non_json_requests(): normalize = _model_gate_namespace()["_normalize_reasoning"] - supported = ( - b'{"model":"qwen2.5:14b-instruct-q4_0",' - b'"reasoning_effort":"medium","messages":[]}' - ) + supported = b'{"model":"qwen2.5:14b-instruct-q4_0","messages":[]}' non_json = b"streamed-body" assert normalize(supported) == supported @@ -53,7 +50,7 @@ def test_model_gate_translates_switchyard_local_target_alias(): ) assert routed["model"] == "qwen2.5:14b-instruct-q4_0" - assert routed["reasoning_effort"] == "medium" + assert "reasoning_effort" not in routed def test_model_gate_supplies_its_single_model_when_switchyard_omits_it():