hermes: complete Switchyard provider adapters

This commit is contained in:
jenkins 2026-08-11 23:03:13 -03:00
parent 661268a9cc
commit 678db67776
8 changed files with 87 additions and 7 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-input" ai.bstein.dev/config-rev: "20260811-switchyard-responses-adapter"
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

@ -68,6 +68,11 @@ data:
if isinstance(model, str) and model.startswith("route/local/qwen2.5-14b/"): if isinstance(model, str) and model.startswith("route/local/qwen2.5-14b/"):
payload["model"] = "qwen2.5:14b-instruct-q4_0" payload["model"] = "qwen2.5:14b-instruct-q4_0"
changed = True 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"): for key in ("reasoning_effort", "reasoning"):
value = payload.get(key) value = payload.get(key)
if isinstance(value, str) and value.lower() in {"xhigh", "max"}: if isinstance(value, str) and value.lower() in {"xhigh", "max"}:

View File

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

View File

@ -11,7 +11,7 @@ import os
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Iterable
import httpx import httpx
@ -130,6 +130,41 @@ def _validate_payload(payload: Any) -> dict[str, Any]:
return payload 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): class Handler(BaseHTTPRequestHandler):
"""Authenticated streaming proxy; request bodies and tokens are never logged.""" """Authenticated streaming proxy; request bodies and tokens are never logged."""
@ -191,7 +226,9 @@ class Handler(BaseHTTPRequestHandler):
return return
try: 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() token = _access_token()
timeout = httpx.Timeout( timeout = httpx.Timeout(
READ_TIMEOUT_SECONDS, READ_TIMEOUT_SECONDS,
@ -212,6 +249,10 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
return return
if not requested_stream:
self._json(200, _completed_response(response.iter_lines()))
return
# HTTP/1.0 close-delimited streaming avoids buffering a # HTTP/1.0 close-delimited streaming avoids buffering a
# potentially long tool-calling turn in the broker. # potentially long tool-calling turn in the broker.
self.send_response(200) self.send_response(200)

View File

@ -130,7 +130,6 @@ data:
[targets.claude_haiku_low] [targets.claude_haiku_low]
id = "route/claude/haiku/low" id = "route/claude/haiku/low"
llm_client = "claude_low" llm_client = "claude_low"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "low" } }
[targets.claude_sonnet_medium] [targets.claude_sonnet_medium]
id = "route/claude/sonnet/medium" id = "route/claude/sonnet/medium"

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-v5" ai.bstein.dev/config-rev: "20260811-switchyard-authority-v6"
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

@ -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"): with pytest.raises(ValueError, match="non-empty Responses input list"):
module._validate_payload({"model": "gpt-5.6-terra", "input": []}) 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 = tmp_path / ".codex"
auth_dir.mkdir() auth_dir.mkdir()
# The token payload need only prove the broker reads CODEX_HOME directly. # The token payload need only prove the broker reads CODEX_HOME directly.

View File

@ -32,7 +32,10 @@ def test_model_gate_clamps_hosted_only_reasoning_efforts():
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 = b'{"reasoning_effort":"medium","messages":[]}' supported = (
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,6 +56,14 @@ def test_model_gate_translates_switchyard_local_target_alias():
assert routed["reasoning_effort"] == "medium" 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(): def test_model_gate_runs_a_renderer_aware_ariadne_handoff():
"""Wolf cannot reach Ollama until the local image service reports idle.""" """Wolf cannot reach Ollama until the local image service reports idle."""
namespace = _model_gate_namespace() namespace = _model_gate_namespace()