hermes: complete Switchyard provider adapters
This commit is contained in:
parent
661268a9cc
commit
678db67776
@ -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
|
||||||
|
|||||||
@ -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"}:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user