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>
196 lines
7.5 KiB
Python
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=(),
|
|
)
|