hermes: fail over exhausted Claude routes
All checks were successful
Tests / Declarative: Post Actions passed: 245
All checks were successful
Tests / Declarative: Post Actions passed: 245
This commit is contained in:
parent
9441753b12
commit
bff416477b
@ -38,6 +38,14 @@ REQUIRED_BETAS: Final = (
|
|||||||
"oauth-2025-04-20",
|
"oauth-2025-04-20",
|
||||||
)
|
)
|
||||||
ROUTED_MODEL_PREFIX: Final = "route/claude/"
|
ROUTED_MODEL_PREFIX: Final = "route/claude/"
|
||||||
|
CAPACITY_ERROR_MARKERS: Final = (
|
||||||
|
"extra usage",
|
||||||
|
"plan limits",
|
||||||
|
"usage limit",
|
||||||
|
"rate limit",
|
||||||
|
"credit balance",
|
||||||
|
"claude.ai/settings/usage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _translate_model(body: bytes) -> bytes:
|
def _translate_model(body: bytes) -> bytes:
|
||||||
@ -109,6 +117,16 @@ def _merge_betas(incoming: str | None) -> str:
|
|||||||
return ",".join(values)
|
return ",".join(values)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_upstream_status(status: int, body: bytes) -> int:
|
||||||
|
"""Expose provider capacity exhaustion using Switchyard's retryable status."""
|
||||||
|
if status != 400:
|
||||||
|
return status
|
||||||
|
text = body.decode("utf-8", errors="replace").lower()
|
||||||
|
if any(marker in text for marker in CAPACITY_ERROR_MARKERS):
|
||||||
|
return 429
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
"""Stream Anthropic responses while keeping the OAuth token server-side."""
|
"""Stream Anthropic responses while keeping the OAuth token server-side."""
|
||||||
|
|
||||||
@ -190,6 +208,25 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
content=body or None,
|
content=body or None,
|
||||||
) as response:
|
) as response:
|
||||||
|
if response.status_code >= 400:
|
||||||
|
error_body = response.read()
|
||||||
|
self.send_response(
|
||||||
|
_normalized_upstream_status(
|
||||||
|
response.status_code, error_body
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.send_header(
|
||||||
|
"Content-Type",
|
||||||
|
response.headers.get("Content-Type", "application/json"),
|
||||||
|
)
|
||||||
|
self.send_header("Content-Length", str(len(error_body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
response_started = True
|
||||||
|
self.wfile.write(error_body)
|
||||||
|
return
|
||||||
|
|
||||||
self.send_response(response.status_code)
|
self.send_response(response.status_code)
|
||||||
for name, value in response.headers.items():
|
for name, value in response.headers.items():
|
||||||
if name.lower() in {
|
if name.lower() in {
|
||||||
|
|||||||
@ -22,7 +22,7 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: hermes-switchyard
|
app: hermes-switchyard
|
||||||
annotations:
|
annotations:
|
||||||
ai.bstein.dev/config-rev: "20260811-switchyard-authority-v11"
|
ai.bstein.dev/config-rev: "20260811-switchyard-authority-v12"
|
||||||
prometheus.io/scrape: "true"
|
prometheus.io/scrape: "true"
|
||||||
prometheus.io/port: "9005"
|
prometheus.io/port: "9005"
|
||||||
prometheus.io/path: /metrics
|
prometheus.io/path: /metrics
|
||||||
|
|||||||
@ -24,6 +24,26 @@ def _documents(path: Path) -> list[dict]:
|
|||||||
return [doc for doc in yaml.safe_load_all(path.read_text()) if doc]
|
return [doc for doc in yaml.safe_load_all(path.read_text()) if doc]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_broker_module(name: str, filename: str, monkeypatch):
|
||||||
|
"""Load one broker with its mounted routing-catalog dependency."""
|
||||||
|
catalog_path = HERMES / "scripts" / "routing_catalog.py"
|
||||||
|
catalog_spec = importlib.util.spec_from_file_location(
|
||||||
|
"routing_catalog", catalog_path
|
||||||
|
)
|
||||||
|
assert catalog_spec and catalog_spec.loader
|
||||||
|
catalog = importlib.util.module_from_spec(catalog_spec)
|
||||||
|
catalog_spec.loader.exec_module(catalog)
|
||||||
|
monkeypatch.setitem(sys.modules, "routing_catalog", catalog)
|
||||||
|
monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace())
|
||||||
|
|
||||||
|
broker_path = HERMES / "scripts" / filename
|
||||||
|
spec = importlib.util.spec_from_file_location(name, broker_path)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
def test_chat_config_enables_real_research_compute_and_delegation():
|
def test_chat_config_enables_real_research_compute_and_delegation():
|
||||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||||
@ -488,12 +508,9 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
|||||||
|
|
||||||
def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
|
def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
|
||||||
"""The relay is bounded, stateless, and rejects unapproved models."""
|
"""The relay is bounded, stateless, and rejects unapproved models."""
|
||||||
broker_path = HERMES / "scripts" / "codex_broker.py"
|
module = _load_broker_module(
|
||||||
monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace())
|
"hermes_codex_broker", "codex_broker.py", monkeypatch
|
||||||
spec = importlib.util.spec_from_file_location("hermes_codex_broker", broker_path)
|
)
|
||||||
assert spec and spec.loader
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
monkeypatch.setattr(module, "TOKEN", "relay-secret")
|
monkeypatch.setattr(module, "TOKEN", "relay-secret")
|
||||||
|
|
||||||
assert module._authorized("Bearer relay-secret") is True
|
assert module._authorized("Bearer relay-secret") is True
|
||||||
@ -595,6 +612,30 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
|
|||||||
assert module._access_token().startswith("header.")
|
assert module._access_token().startswith("header.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_broker_exposes_capacity_exhaustion_as_retryable(monkeypatch):
|
||||||
|
"""Subscription exhaustion must cross providers instead of surfacing as 400."""
|
||||||
|
module = _load_broker_module(
|
||||||
|
"hermes_claude_broker", "claude_oauth_broker.py", monkeypatch
|
||||||
|
)
|
||||||
|
exhausted = json.dumps(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"message": (
|
||||||
|
"Third-party apps now draw from your extra usage, not your "
|
||||||
|
"plan limits. Add more at claude.ai/settings/usage."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
malformed = b'{"error":{"message":"invalid tool schema"}}'
|
||||||
|
|
||||||
|
assert module._normalized_upstream_status(400, exhausted) == 429
|
||||||
|
assert module._normalized_upstream_status(400, malformed) == 400
|
||||||
|
assert module._normalized_upstream_status(403, exhausted) == 403
|
||||||
|
|
||||||
|
|
||||||
def test_image_broker_returns_bytes_and_removes_owner_cache(tmp_path: Path, monkeypatch):
|
def test_image_broker_returns_bytes_and_removes_owner_cache(tmp_path: Path, monkeypatch):
|
||||||
"""The broker must not retain a family user's generated image."""
|
"""The broker must not retain a family user's generated image."""
|
||||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user