hermes: preserve Switchyard response content
All checks were successful
Tests / Declarative: Post Actions passed: 244

This commit is contained in:
jenkins 2026-08-11 23:12:43 -03:00
parent 678db67776
commit c8409b8f3a
8 changed files with 43 additions and 23 deletions

View File

@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers 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/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/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/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens

View File

@ -73,16 +73,13 @@ data:
# unambiguous and safe default. # unambiguous and safe default.
payload["model"] = "qwen2.5:14b-instruct-q4_0" payload["model"] = "qwen2.5:14b-instruct-q4_0"
changed = True 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) value = payload.get(key)
if isinstance(value, str) and value.lower() in {"xhigh", "max"}: if value is not None:
payload[key] = "high" payload.pop(key, None)
changed = True 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: if not changed:
return body return body
return json.dumps(payload, separators=(",", ":")).encode("utf-8") return json.dumps(payload, separators=(",", ":")).encode("utf-8")

View File

@ -15,7 +15,7 @@ spec:
template: template:
metadata: metadata:
annotations: annotations:
ai.bstein.dev/config-rev: "20260811-switchyard-model-default" ai.bstein.dev/config-rev: "20260811-switchyard-model-fields"
labels: labels:
app: hermes-model-gate app: hermes-model-gate
spec: spec:

View File

@ -133,6 +133,7 @@ def _validate_payload(payload: Any) -> dict[str, Any]:
def _completed_response(lines: Iterable[str]) -> dict[str, Any]: def _completed_response(lines: Iterable[str]) -> dict[str, Any]:
"""Collapse a Codex SSE stream for a non-streaming Responses caller.""" """Collapse a Codex SSE stream for a non-streaming Responses caller."""
terminal_response: dict[str, Any] | None = None terminal_response: dict[str, Any] | None = None
output_items: dict[int, dict[str, Any]] = {}
upstream_error = "" upstream_error = ""
for line in lines: for line in lines:
if not line.startswith("data:"): if not line.startswith("data:"):
@ -148,6 +149,11 @@ def _completed_response(lines: Iterable[str]) -> dict[str, Any]:
continue continue
event_type = str(event.get("type") or "") event_type = str(event.get("type") or "")
response = event.get("response") 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 { if event_type in {
"response.completed", "response.completed",
"response.failed", "response.failed",
@ -161,6 +167,13 @@ def _completed_response(lines: Iterable[str]) -> dict[str, Any]:
else: else:
upstream_error = str(error or "") upstream_error = str(error or "")
if terminal_response is not None: 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 return terminal_response
raise RuntimeError(upstream_error or "Codex stream ended without a terminal response") raise RuntimeError(upstream_error or "Codex stream ended without a terminal response")

View File

@ -85,12 +85,10 @@ data:
[targets.local_qwen_low] [targets.local_qwen_low]
id = "route/local/qwen2.5-14b/low" id = "route/local/qwen2.5-14b/low"
llm_client = "local_low" llm_client = "local_low"
extra_body = { reasoning_effort = "low" }
[targets.local_qwen_medium] [targets.local_qwen_medium]
id = "route/local/qwen2.5-14b/medium" id = "route/local/qwen2.5-14b/medium"
llm_client = "local_medium" llm_client = "local_medium"
extra_body = { reasoning_effort = "medium" }
[targets.codex_luna_low] [targets.codex_luna_low]
id = "route/codex/luna/low" id = "route/codex/luna/low"

View File

@ -19,7 +19,7 @@ spec:
labels: labels:
app: hermes-switchyard app: hermes-switchyard
annotations: 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/scrape: "true"
prometheus.io/port: "9005" prometheus.io/port: "9005"
prometheus.io/path: /metrics prometheus.io/path: /metrics

View File

@ -548,16 +548,31 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
"status": "completed", "status": "completed",
"output": [], "output": [],
} }
completed_item = {
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "done"}],
}
assert module._completed_response( assert module._completed_response(
[ [
"event: response.created", "event: response.created",
'data: {"type":"response.created","response":{}}', '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", "event: response.completed",
"data: " "data: "
+ json.dumps({"type": "response.completed", "response": completed}), + json.dumps({"type": "response.completed", "response": completed}),
"data: [DONE]", "data: [DONE]",
] ]
) == completed )["output"] == [completed_item]
with pytest.raises(RuntimeError, match="provider unavailable"): with pytest.raises(RuntimeError, match="provider unavailable"):
module._completed_response( module._completed_response(
[ [

View File

@ -18,24 +18,21 @@ def _model_gate_namespace() -> dict:
return namespace 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"] normalize = _model_gate_namespace()["_normalize_reasoning"]
top_level = json.loads(normalize(b'{"reasoning_effort":"xhigh"}')) top_level = json.loads(normalize(b'{"reasoning_effort":"xhigh"}'))
string_reasoning = json.loads(normalize(b'{"reasoning":"max"}')) string_reasoning = json.loads(normalize(b'{"reasoning":"max"}'))
nested = json.loads(normalize(b'{"reasoning":{"effort":"xhigh"}}')) nested = json.loads(normalize(b'{"reasoning":{"effort":"xhigh"}}'))
assert top_level["reasoning_effort"] == "high" assert "reasoning_effort" not in top_level
assert string_reasoning["reasoning"] == "high" assert "reasoning" not in string_reasoning
assert nested["reasoning"]["effort"] == "high" assert "reasoning" not in nested
def test_model_gate_preserves_supported_and_non_json_requests(): def test_model_gate_preserves_supported_and_non_json_requests():
normalize = _model_gate_namespace()["_normalize_reasoning"] normalize = _model_gate_namespace()["_normalize_reasoning"]
supported = ( supported = b'{"model":"qwen2.5:14b-instruct-q4_0","messages":[]}'
b'{"model":"qwen2.5:14b-instruct-q4_0",'
b'"reasoning_effort":"medium","messages":[]}'
)
non_json = b"streamed-body" non_json = b"streamed-body"
assert normalize(supported) == supported 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["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(): def test_model_gate_supplies_its_single_model_when_switchyard_omits_it():