atlas-iac/services/hermes/scripts/cli_lane_routing.py

162 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Switchyard route selection and provider-health exclusion for CLI workers."""
from __future__ import annotations
import json
import re
import time
import urllib.error
import urllib.request
from typing import Any, Callable
from cli_lane_config import (
EFFORTS,
PROVIDER_AUTH_FAILURE_MAX_AGE_SECONDS,
PROVIDER_HEALTH_MAX_AGE_SECONDS,
PROVIDER_HEALTH_PATHS,
SWITCHYARD_URL,
Route,
)
from cli_lane_files import load_json
def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
"""Parse external lane overrides while leaving cli-auto fully automatic."""
value = str(assignee or "").strip().lower()
if value == "cli-auto":
return None, None
match = re.fullmatch(r"cli-(codex|claude)-(low|medium|high|xhigh)", value)
if not match:
raise ValueError(f"unsupported external lane assignee: {assignee}")
return match.group(1), match.group(2)
def fresh_unavailable_provider(now: float | None = None) -> str | None:
"""Return one recently proven-down provider for automatic route exclusion."""
current = time.time() if now is None else now
unavailable: list[str] = []
for provider, path in PROVIDER_HEALTH_PATHS.items():
health = load_json(path)
try:
age = current - path.stat().st_mtime
except OSError:
continue
max_age = (
PROVIDER_AUTH_FAILURE_MAX_AGE_SECONDS
if health.get("authenticated") is False
else PROVIDER_HEALTH_MAX_AGE_SECONDS
)
if (
0 <= age <= max_age
and health.get("state") == "unavailable"
):
unavailable.append(provider)
# If both providers are down, keep the normal attempt/fallback path so the
# card records authoritative current errors instead of trusting snapshots.
return unavailable[0] if len(unavailable) == 1 else None
def _decode_worker_target(value: str) -> tuple[str, str, str]:
"""Decode the selected model header emitted by a worker decision route."""
parts = value.split("/", 3)
if len(parts) != 4 or parts[0] != "worker":
raise RuntimeError(f"invalid Switchyard worker target: {value}")
provider, model, effort = parts[1:]
if provider not in {"codex", "claude"} or effort not in EFFORTS:
raise RuntimeError(f"unsupported Switchyard worker target: {value}")
return provider, model, effort
def select_route(
prompt: str,
assignee: str,
*,
exclude_provider: str | None = None,
exclude_reason: str | None = None,
switchyard_url: str = SWITCHYARD_URL,
open_request: Callable[..., Any] = urllib.request.urlopen,
) -> Route:
"""Ask Switchyard to select one native CLI worker at this boundary."""
started = time.monotonic()
manual_provider, manual_effort = parse_assignee(assignee)
if manual_provider and manual_effort:
route_id = f"atlas/worker/manual/{manual_provider}/{manual_effort}"
source = "switchyard-manual"
else:
route_id = "atlas/worker/auto/maximum"
source = "switchyard-classifier"
context = prompt
if exclude_provider:
reason = exclude_reason or "failed or exhausted capacity at this boundary"
context += (
f"\n\nRouting constraint: the {exclude_provider} provider {reason}. "
"Do not select it."
)
payload = json.dumps(
{
"model": route_id,
"messages": [{"role": "user", "content": context}],
"stream": False,
"max_tokens": 1,
}
).encode("utf-8")
request = urllib.request.Request(
switchyard_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with open_request(request, timeout=60) as response:
selected = str(response.headers.get("x-model-router-selected-model") or "")
rationale = str(response.headers.get("x-model-router-rationale") or "")
response_body = response.read()
except (OSError, urllib.error.URLError) as exc:
raise RuntimeError(f"Switchyard worker routing failed: {exc}") from exc
provider, model, effort = _decode_worker_target(selected)
# Switchyard preserves the stable tier target in the selection header and
# top-level response model. The worker broker's assistant content contains
# the steward-resolved provider model required by the native CLI.
try:
response_document = json.loads(response_body)
resolved_target = str(response_document["choices"][0]["message"]["content"] or "")
resolved_provider, resolved_model, resolved_effort = _decode_worker_target(resolved_target)
required_prefix = "gpt-" if provider == "codex" else "claude-"
if (
resolved_provider == provider
and resolved_effort == effort
and resolved_model.startswith(required_prefix)
):
model = resolved_model
except (AttributeError, IndexError, KeyError, RuntimeError, TypeError, ValueError, json.JSONDecodeError):
pass
if exclude_provider and provider == exclude_provider:
alternate = "claude" if provider == "codex" else "codex"
guarded = select_route(
prompt,
f"cli-{alternate}-{effort}",
switchyard_url=switchyard_url,
open_request=open_request,
)
return Route(
provider=guarded.provider,
model=guarded.model,
effort=guarded.effort,
profile=guarded.profile,
classifier=f"{source}-health-guard",
reason=(
f"Switchyard selected excluded {exclude_provider}; preserved "
f"its {effort} effort on healthy-provider route. {guarded.reason}"
),
latency_ms=int((time.monotonic() - started) * 1000),
fallback_chain=(),
)
return Route(
provider=provider,
model=model,
effort=effort,
profile=f"{provider}-{effort}",
classifier=source,
reason=rationale or f"Switchyard selected {selected}",
latency_ms=int((time.monotonic() - started) * 1000),
fallback_chain=(),
)