2026-08-12 09:40:20 -03:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Bound Switchyard classifier context before forwarding it to local Ollama."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import copy
|
|
|
|
|
import json
|
|
|
|
|
import os
|
2026-08-23 22:13:52 -03:00
|
|
|
import threading
|
2026-08-12 09:40:20 -03:00
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
from typing import Any, Final
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
2026-08-23 22:13:52 -03:00
|
|
|
try:
|
|
|
|
|
from voice_route_preflight import (
|
|
|
|
|
COORDINATOR as VOICE_PREFLIGHT,
|
|
|
|
|
MAX_BODY_BYTES as VOICE_PREFLIGHT_MAX_BODY_BYTES,
|
|
|
|
|
validate_request as validate_voice_preflight_request,
|
|
|
|
|
)
|
|
|
|
|
except ModuleNotFoundError: # Test imports use the repository package path.
|
|
|
|
|
from services.hermes.scripts.voice_route_preflight import (
|
|
|
|
|
COORDINATOR as VOICE_PREFLIGHT,
|
|
|
|
|
MAX_BODY_BYTES as VOICE_PREFLIGHT_MAX_BODY_BYTES,
|
|
|
|
|
validate_request as validate_voice_preflight_request,
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-12 09:40:20 -03:00
|
|
|
|
|
|
|
|
HOST: Final = os.environ.get("HERMES_CLASSIFIER_BROKER_HOST", "0.0.0.0")
|
|
|
|
|
PORT: Final = int(os.environ.get("HERMES_CLASSIFIER_BROKER_PORT", "9008"))
|
2026-08-23 22:13:52 -03:00
|
|
|
VOICE_PORT: Final = int(os.environ.get("HERMES_VOICE_PREFLIGHT_PORT", "9009"))
|
2026-08-12 09:40:20 -03:00
|
|
|
UPSTREAM: Final = os.environ.get(
|
|
|
|
|
"HERMES_CLASSIFIER_BROKER_UPSTREAM",
|
|
|
|
|
"http://ollama.ai.svc.cluster.local:11434",
|
|
|
|
|
).rstrip("/")
|
|
|
|
|
MAX_BODY_BYTES: Final = int(
|
|
|
|
|
os.environ.get("HERMES_CLASSIFIER_BROKER_MAX_BODY", str(32 << 20))
|
|
|
|
|
)
|
|
|
|
|
MAX_SYSTEM_CHARS: Final = int(
|
|
|
|
|
os.environ.get("HERMES_CLASSIFIER_MAX_SYSTEM_CHARS", "6500")
|
|
|
|
|
)
|
|
|
|
|
MAX_CONTEXT_CHARS: Final = int(
|
|
|
|
|
os.environ.get("HERMES_CLASSIFIER_MAX_CONTEXT_CHARS", "7000")
|
|
|
|
|
)
|
|
|
|
|
READ_TIMEOUT_SECONDS: Final = float(
|
|
|
|
|
os.environ.get("HERMES_CLASSIFIER_BROKER_READ_TIMEOUT", "60")
|
|
|
|
|
)
|
|
|
|
|
ALLOWED_PATHS: Final = {"/v1/chat/completions", "/v1/models"}
|
2026-08-23 22:13:52 -03:00
|
|
|
VOICE_PREFLIGHT_PATH: Final = "/voice/route-preflight"
|
2026-08-12 09:40:20 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bounded_text(value: str, limit: int) -> str:
|
|
|
|
|
"""Keep both ends of text because intent and current status often sit apart."""
|
|
|
|
|
if len(value) <= limit:
|
|
|
|
|
return value
|
|
|
|
|
if limit < 80:
|
|
|
|
|
return value[:limit]
|
|
|
|
|
marker = "\n...[classifier context compacted]...\n"
|
|
|
|
|
remaining = limit - len(marker)
|
|
|
|
|
head = remaining // 2
|
|
|
|
|
return f"{value[:head]}{marker}{value[-(remaining - head):]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compact_content(content: Any, limit: int) -> Any:
|
|
|
|
|
"""Remove binary/multimodal payloads and bound text sent to the judge."""
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
return _bounded_text(content, limit)
|
|
|
|
|
if not isinstance(content, list):
|
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
|
compacted: list[Any] = []
|
|
|
|
|
remaining = limit
|
|
|
|
|
for block in content:
|
|
|
|
|
if not isinstance(block, dict):
|
|
|
|
|
continue
|
|
|
|
|
kind = str(block.get("type") or "")
|
|
|
|
|
if kind in {"text", "input_text", "output_text"}:
|
|
|
|
|
key = "text"
|
|
|
|
|
text = str(block.get(key) or "")
|
|
|
|
|
if not text or remaining <= 0:
|
|
|
|
|
continue
|
|
|
|
|
text = _bounded_text(text, remaining)
|
|
|
|
|
compacted.append({**block, key: text})
|
|
|
|
|
remaining -= len(text)
|
|
|
|
|
elif kind in {"image", "image_url", "input_image"}:
|
|
|
|
|
marker = "[image attachment available to the selected worker]"
|
|
|
|
|
if remaining >= len(marker):
|
|
|
|
|
compacted.append({"type": "text", "text": marker})
|
|
|
|
|
remaining -= len(marker)
|
|
|
|
|
return compacted
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compact_message(message: dict[str, Any], limit: int) -> dict[str, Any]:
|
|
|
|
|
"""Copy routing-relevant message metadata while bounding large values."""
|
|
|
|
|
result = copy.deepcopy(message)
|
|
|
|
|
if "content" in result:
|
|
|
|
|
result["content"] = _compact_content(result["content"], limit)
|
2026-08-15 13:35:31 -03:00
|
|
|
# The local judge only classifies the current boundary. OpenAI-compatible
|
|
|
|
|
# Ollama validates assistant tool-call JSON and tool/result pairing before
|
|
|
|
|
# inference, so a bounded or selectively retained transcript can become an
|
|
|
|
|
# invalid conversation even though its text is sufficient for routing.
|
|
|
|
|
# Preserve tool evidence as plain user text and remove protocol metadata.
|
|
|
|
|
had_tool_calls = bool(result.pop("tool_calls", None))
|
|
|
|
|
result.pop("tool_call_id", None)
|
|
|
|
|
result.pop("name", None)
|
|
|
|
|
if result.get("role") == "tool":
|
|
|
|
|
result["role"] = "user"
|
|
|
|
|
content = result.get("content")
|
|
|
|
|
result["content"] = f"[tool evidence]\n{content or ''}"
|
|
|
|
|
elif had_tool_calls and not result.get("content"):
|
|
|
|
|
result["content"] = "[assistant requested an external tool]"
|
2026-08-12 09:40:20 -03:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compact_messages(messages: list[Any]) -> list[Any]:
|
|
|
|
|
"""Keep the routing contract, opening task, and latest decision context."""
|
|
|
|
|
valid = [message for message in messages if isinstance(message, dict)]
|
|
|
|
|
system_indices = [
|
|
|
|
|
index
|
|
|
|
|
for index, message in enumerate(valid)
|
|
|
|
|
if message.get("role") in {"system", "developer"}
|
|
|
|
|
]
|
|
|
|
|
non_system = [index for index in range(len(valid)) if index not in system_indices]
|
|
|
|
|
user_indices = [index for index in non_system if valid[index].get("role") == "user"]
|
|
|
|
|
|
|
|
|
|
opening_user = user_indices[0] if user_indices else None
|
|
|
|
|
latest_user = user_indices[-1] if user_indices else None
|
|
|
|
|
selected = set(system_indices)
|
|
|
|
|
selected.update(non_system[-4:])
|
|
|
|
|
if opening_user is not None:
|
|
|
|
|
selected.add(opening_user)
|
|
|
|
|
if latest_user is not None:
|
|
|
|
|
selected.add(latest_user)
|
|
|
|
|
|
|
|
|
|
other_indices = [
|
|
|
|
|
index
|
|
|
|
|
for index in selected
|
|
|
|
|
if index not in system_indices and index not in {opening_user, latest_user}
|
|
|
|
|
]
|
|
|
|
|
latest_budget = min(3500, MAX_CONTEXT_CHARS)
|
|
|
|
|
opening_budget = min(1200, max(0, MAX_CONTEXT_CHARS - latest_budget))
|
|
|
|
|
other_budget = max(0, MAX_CONTEXT_CHARS - latest_budget - opening_budget)
|
|
|
|
|
other_limit = min(1200, other_budget // max(1, len(other_indices)))
|
|
|
|
|
system_limit = MAX_SYSTEM_CHARS // max(1, len(system_indices))
|
|
|
|
|
|
|
|
|
|
result: list[Any] = []
|
|
|
|
|
for index, message in enumerate(valid):
|
|
|
|
|
if index not in selected:
|
|
|
|
|
continue
|
|
|
|
|
role = str(message.get("role") or "")
|
|
|
|
|
if role in {"system", "developer"}:
|
|
|
|
|
compacted = _compact_message(message, system_limit)
|
|
|
|
|
elif index == latest_user:
|
|
|
|
|
compacted = _compact_message(message, latest_budget)
|
|
|
|
|
elif index == opening_user:
|
|
|
|
|
compacted = _compact_message(message, opening_budget)
|
|
|
|
|
else:
|
|
|
|
|
if other_limit <= 0:
|
|
|
|
|
continue
|
|
|
|
|
compacted = _compact_message(message, other_limit)
|
|
|
|
|
result.append(compacted)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compact_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""Return the same OpenAI request with only classifier input compacted."""
|
|
|
|
|
result = copy.deepcopy(payload)
|
|
|
|
|
messages = result.get("messages")
|
|
|
|
|
if isinstance(messages, list):
|
|
|
|
|
result["messages"] = _compact_messages(messages)
|
|
|
|
|
# The judge never needs tools or binary inputs. Switchyard supplies a
|
|
|
|
|
# response schema separately, and that contract must remain untouched.
|
|
|
|
|
result.pop("tools", None)
|
2026-08-15 13:35:31 -03:00
|
|
|
result.pop("tool_choice", None)
|
|
|
|
|
result.pop("parallel_tool_calls", None)
|
2026-08-12 09:40:20 -03:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
"""Proxy only Switchyard's local judge calls with strict input bounds."""
|
|
|
|
|
|
|
|
|
|
server_version = "HermesClassifierBroker/1"
|
|
|
|
|
|
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def _json(self, status: int, value: dict[str, object]) -> None:
|
|
|
|
|
body = json.dumps(value, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
self.send_response(status)
|
|
|
|
|
self.send_header("Content-Type", "application/json")
|
|
|
|
|
self.send_header("Content-Length", str(len(body)))
|
|
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(body)
|
|
|
|
|
|
2026-08-23 22:13:52 -03:00
|
|
|
def _empty(self, status: int) -> None:
|
|
|
|
|
self.send_response(status)
|
|
|
|
|
self.send_header("Content-Length", "0")
|
|
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
|
|
|
|
|
def _text(self, status: int, body: str, content_type: str) -> None:
|
|
|
|
|
encoded = body.encode("utf-8")
|
|
|
|
|
self.send_response(status)
|
|
|
|
|
self.send_header("Content-Type", content_type)
|
|
|
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(encoded)
|
|
|
|
|
|
2026-08-12 09:40:20 -03:00
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
|
|
|
if self.path == "/health":
|
|
|
|
|
self._json(200, {"ok": True, "upstream": "ollama"})
|
|
|
|
|
return
|
2026-08-23 22:13:52 -03:00
|
|
|
if self.path == "/metrics":
|
|
|
|
|
self._text(200, VOICE_PREFLIGHT.metrics(), "text/plain; version=0.0.4")
|
|
|
|
|
return
|
2026-08-12 09:40:20 -03:00
|
|
|
if self.path not in ALLOWED_PATHS:
|
|
|
|
|
self._json(404, {"error": "not found"})
|
|
|
|
|
return
|
|
|
|
|
self._proxy(None)
|
|
|
|
|
|
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
|
|
|
if self.path not in ALLOWED_PATHS:
|
|
|
|
|
self._json(404, {"error": "not found"})
|
|
|
|
|
return
|
2026-08-23 22:13:52 -03:00
|
|
|
authoritative = self.path == "/v1/chat/completions"
|
|
|
|
|
if authoritative:
|
|
|
|
|
# Mark priority as soon as the authoritative path arrives, before
|
|
|
|
|
# even validating or reading its local body, so an
|
|
|
|
|
# in-flight disposable decode is closed at authoritative arrival.
|
|
|
|
|
VOICE_PREFLIGHT.begin_authoritative()
|
|
|
|
|
try:
|
|
|
|
|
try:
|
|
|
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
|
|
|
except ValueError:
|
|
|
|
|
length = -1
|
|
|
|
|
if length <= 0 or length > MAX_BODY_BYTES:
|
|
|
|
|
self._json(413, {"error": "request too large"})
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(self.rfile.read(length))
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
raise ValueError("request must be a JSON object")
|
|
|
|
|
body = json.dumps(
|
|
|
|
|
compact_payload(payload), separators=(",", ":")
|
|
|
|
|
).encode("utf-8")
|
|
|
|
|
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
|
|
|
self._json(400, {"error": str(exc)})
|
|
|
|
|
return
|
|
|
|
|
print(
|
|
|
|
|
f"classifier-broker request_bytes={length} compacted_bytes={len(body)}",
|
|
|
|
|
flush=True,
|
|
|
|
|
)
|
|
|
|
|
if authoritative:
|
|
|
|
|
self._proxy(body)
|
|
|
|
|
return
|
|
|
|
|
self._proxy(body)
|
|
|
|
|
finally:
|
|
|
|
|
if authoritative:
|
|
|
|
|
VOICE_PREFLIGHT.end_authoritative()
|
|
|
|
|
|
|
|
|
|
def _voice_preflight(self) -> None:
|
|
|
|
|
"""Return one disposable local tier without starting a hosted turn."""
|
2026-08-12 09:40:20 -03:00
|
|
|
try:
|
|
|
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
|
|
|
except ValueError:
|
|
|
|
|
length = -1
|
2026-08-23 22:13:52 -03:00
|
|
|
if length <= 0 or length > VOICE_PREFLIGHT_MAX_BODY_BYTES:
|
2026-08-12 09:40:20 -03:00
|
|
|
self._json(413, {"error": "request too large"})
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(self.rfile.read(length))
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
raise ValueError("request must be a JSON object")
|
2026-08-23 22:13:52 -03:00
|
|
|
turn_id, revision, transcript = validate_voice_preflight_request(payload)
|
2026-08-12 09:40:20 -03:00
|
|
|
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
|
|
|
self._json(400, {"error": str(exc)})
|
|
|
|
|
return
|
2026-08-23 22:13:52 -03:00
|
|
|
|
|
|
|
|
cancel = VOICE_PREFLIGHT.begin(turn_id)
|
|
|
|
|
if cancel is None:
|
|
|
|
|
self._empty(204)
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
tier = VOICE_PREFLIGHT.stream(transcript, cancel)
|
|
|
|
|
finally:
|
|
|
|
|
VOICE_PREFLIGHT.end()
|
|
|
|
|
if not tier:
|
|
|
|
|
self._empty(204)
|
|
|
|
|
return
|
|
|
|
|
self._json(
|
|
|
|
|
200,
|
|
|
|
|
{
|
|
|
|
|
"turn_id": turn_id,
|
|
|
|
|
"revision": revision,
|
|
|
|
|
"tier": tier,
|
|
|
|
|
"target": f"atlas/auto/{tier}",
|
|
|
|
|
"advisory": True,
|
|
|
|
|
},
|
2026-08-12 09:40:20 -03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _proxy(self, body: bytes | None) -> None:
|
|
|
|
|
try:
|
|
|
|
|
timeout = httpx.Timeout(10.0, read=READ_TIMEOUT_SECONDS)
|
|
|
|
|
with httpx.Client(timeout=timeout) as client:
|
|
|
|
|
response = client.request(
|
|
|
|
|
self.command,
|
|
|
|
|
f"{UPSTREAM}{self.path}",
|
|
|
|
|
headers={"Content-Type": "application/json"},
|
|
|
|
|
content=body,
|
|
|
|
|
)
|
|
|
|
|
self.send_response(response.status_code)
|
|
|
|
|
self.send_header(
|
|
|
|
|
"Content-Type", response.headers.get("Content-Type", "application/json")
|
|
|
|
|
)
|
|
|
|
|
self.send_header("Content-Length", str(len(response.content)))
|
|
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(response.content)
|
|
|
|
|
except (httpx.HTTPError, OSError) as exc:
|
|
|
|
|
self._json(503, {"error": f"classifier unavailable: {exc}"})
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 22:13:52 -03:00
|
|
|
class VoiceHandler(Handler):
|
|
|
|
|
"""Expose only advisory, health and telemetry routes to chat tenants."""
|
|
|
|
|
|
|
|
|
|
server_version = "HermesVoicePreflight/1"
|
|
|
|
|
|
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
|
|
|
if self.path == "/health":
|
|
|
|
|
self._json(200, {"ok": True, "upstream": "ollama"})
|
|
|
|
|
return
|
|
|
|
|
if self.path == "/metrics":
|
|
|
|
|
self._text(200, VOICE_PREFLIGHT.metrics(), "text/plain; version=0.0.4")
|
|
|
|
|
return
|
|
|
|
|
self._json(404, {"error": "not found"})
|
|
|
|
|
|
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
|
|
|
if self.path == VOICE_PREFLIGHT_PATH:
|
|
|
|
|
self._voice_preflight()
|
|
|
|
|
return
|
|
|
|
|
self._json(404, {"error": "not found"})
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 09:40:20 -03:00
|
|
|
def main() -> None:
|
2026-08-23 22:13:52 -03:00
|
|
|
voice_server = ThreadingHTTPServer((HOST, VOICE_PORT), VoiceHandler)
|
|
|
|
|
voice_thread = threading.Thread(target=voice_server.serve_forever, daemon=True)
|
|
|
|
|
voice_thread.start()
|
2026-08-12 09:40:20 -03:00
|
|
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
2026-08-23 22:13:52 -03:00
|
|
|
try:
|
|
|
|
|
server.serve_forever()
|
|
|
|
|
finally:
|
|
|
|
|
voice_server.shutdown()
|
|
|
|
|
voice_server.server_close()
|
2026-08-12 09:40:20 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|