atlas-iac/services/hermes/scripts/cli_lane_routing.py
jenkins 034c8372c7 hermes: enforce quota-aware fail-closed cli-auto provider routing
Based on PR #15 (fix/hermes-result-decomposition-reliability); stacked
on the decomposed cli_lane modules.

- cli_lane_quota: soft-exclude a provider from NEW cli-auto work below
  the remaining-quota threshold (both-below prefers more remaining;
  fetch failure fails open with a metric).
- cli_lane_health: lane now writes provider health (G7) with classified
  failure reasons splitting the capacity conflation (quota/auth/
  rate-limit/transport) and cooldown hysteresis; re-admission only on
  full cooldown expiry, passed quota reset, or fresh success (G4).
- cli_lane_routing: capacity-limited health now excludes a provider
  (G3); cooldown/reset-aware re-admission.
- cli_lane_failover: explicit cli-codex-*/cli-claude-* assignees fail
  closed as transient instead of switching providers (G5); fallback
  depth stays bounded at two hosted providers (G1) with effort
  preserved; Switchyard outages block transient, not capability (G9).
- cli_lane_metrics: route-decision/fallback counters, quota and
  soft-exclusion gauges, pod-local scrape server (G6).
- cli_lane_provider: worker env drops ANTHROPIC_API_KEY, CLAUDE_API_KEY,
  OPENAI_API_KEY, API_SERVER_KEY so no metered path exists (G10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 20:31:05 -03:00

196 lines
7.5 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 _health_number(value: Any) -> float | None:
"""Return a plain numeric health field, rejecting booleans and strings."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def _quota_reset_readmits(
health: dict[str, Any], current: float, reset_timestamp: float | None
) -> bool:
"""A passed provider quota reset ends a capacity cooldown early."""
if health.get("authenticated") is False:
# An authentication failure is not repaired by a quota window reset.
return False
failed_at = _health_number(health.get("failed_at"))
return (
reset_timestamp is not None
and failed_at is not None
and failed_at < reset_timestamp <= current
)
def fresh_unavailable_provider(
now: float | None = None,
quota_resets: dict[str, float] | None = None,
) -> str | None:
"""Return one recently proven-down provider for automatic route exclusion."""
current = time.time() if now is None else now
resets = quota_resets or {}
unavailable: list[str] = []
for provider, path in PROVIDER_HEALTH_PATHS.items():
health = load_json(path)
if health.get("state") not in ("unavailable", "capacity-limited"):
continue
cooldown_until = _health_number(health.get("cooldown_until"))
if cooldown_until is not None:
# Lane-recorded failures re-enter only after the full cooldown has
# elapsed (hysteresis), a quota reset passed, or fresh success
# rewrote the state above.
if current >= cooldown_until or _quota_reset_readmits(
health, current, resets.get(provider)
):
continue
unavailable.append(provider)
continue
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:
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=(),
)