Files
hermes-agent/tests/agent/test_error_classifier_observed.py
kshitijk4poor 1f79eb6450 test: pin the wire every provider receives, real error bodies, and the prompt-cache prefix contract
Three regression nets, each built from production evidence:

- tests/agent/transports/test_provider_wire_snapshot.py + tests/fixtures/provider_wire_snapshot.json:
  the request every registered provider is sent (51 providers x reasoning unset/high/off), built
  through the production assembly path (build_api_request: cache decoration, sanitization,
  transport kwargs, Codex preflight, middleware). A shared-code edit that changes what any
  provider receives fails naming the provider and the first diverging key; a new provider fails
  until its wire is recorded. Network and DNS are blocked so capability probes (Ollama
  /api/show, LM Studio, catalogs) record their offline answer, not a live server's.
  Mutation check: +1 on the shared timeout fails 38 providers.

- tests/agent/test_error_classifier_observed.py: error bodies Hermes actually received (relay
  traces and agent.log, scrubbed), raised as the real openai/httpx exception types. Two known
  gaps are run-time xfails that flip to passing when their fix lands: #49769 (OpenRouter
  "can only afford N" 402 classified as terminal billing) and #85005 ("not a multimodal model"
  not recognised as an image rejection).

- tests/agent/test_prompt_cache_prefix_contract.py: a real AIAgent on a caching route against
  the recording fake provider; every request must re-send the previous request's content and
  only append. Pins a live cache bust as a run-time xfail: the #81867 skill-scaffold split is
  applied only while the skill message is inside the last-N marker window, so request 3 of a
  skill session re-sends the scaffold as a plain string and re-writes it (~20k tokens on call 2
  of skill-invoked sessions in agent.log).
2026-10-07 00:59:51 +05:30

94 lines
5.6 KiB
Python

"""Provider error bodies observed in real sessions, through the real SDK exception types.
Every row is a body Hermes actually received (relay traces + agent.log, scrubbed of request ids
and account data), raised as the exception class the OpenAI SDK / httpx raises for it, so a
classifier change is checked against what providers really send rather than hand-written
strings. Add a row whenever a new production body shows up in a bug report or trace.
The last two tests are bodies main still mishandles; each is a run-time xfail keyed on the current
wrong outcome, so it turns into a passing regression test the moment its fix lands:
* #49769 — OpenRouter's "can only afford N tokens" 402 is recoverable by lowering max_tokens,
but the main loop classifies it as terminal billing and abandons the provider.
* #85005 — "<model> is not a multimodal model" (vLLM/text-only endpoints) is not recognised as
an image rejection, so the image is never stripped and every retry fails identically.
"""
from __future__ import annotations
import httpx
import openai
import pytest
from agent.error_classifier import FailoverReason, classify_api_error
from agent.message_sanitization import _looks_like_image_content_rejection
from tests.e2e.core._pending_fixes import known_failure
_REQUEST = httpx.Request("POST", "https://example.invalid/v1/chat/completions")
def _status(cls: type, code: int, body: dict) -> Exception:
return cls(f"Error code: {code} - {body}", response=httpx.Response(code, request=_REQUEST, json=body), body=body)
AFFORD_402 = {"error": {"message": (
"This request requires more credits, or fewer max_tokens. You requested up to 128000 tokens, but can only "
"afford 81664. To increase, visit https://openrouter.ai/settings/credits and upgrade to a paid account"),
"code": 402}}
NOT_MULTIMODAL_400 = {"error": {"message": "glm-5.2-fp8 is not a multimodal model", "type": "BadRequestError",
"param": None, "code": 400}}
# (id, provider, exception, expected reason, retryable, should_fallback)
OBSERVED = [
("connection-error", "custom", openai.APIConnectionError(request=_REQUEST), FailoverReason.timeout, True, False),
("broken-pipe", "nous", httpx.ReadError("[Errno 32] Broken pipe", request=_REQUEST),
FailoverReason.timeout, True, False),
("peer-closed-mid-body", "custom", httpx.RemoteProtocolError(
"peer closed connection without sending complete message body (incomplete chunked read)", request=_REQUEST),
FailoverReason.timeout, True, False),
("request-timeout", "nous", openai.APITimeoutError(request=_REQUEST), FailoverReason.timeout, True, False),
("nous-404-no-credits", "nous", _status(openai.NotFoundError, 404, {"status": 404, "message": (
"Model 'anthropic/claude-opus-5' requires available credits. Your account balance is too low to use paid "
"models — add credits at https://portal.nousresearch.com or pick a free model.")}),
FailoverReason.billing, False, True),
("nous-401-invalid-key", "nous", _status(openai.AuthenticationError, 401, {"status": 401, "message": (
"Your API key is invalid, blocked or out of funds. Please go visit the portal to sort that out: "
"https://portal.nousresearch.com ")}),
FailoverReason.auth, False, True),
("cloudflare-502", "nous", _status(openai.InternalServerError, 502, {
"type": "https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/"
"error-502/", "title": "Error 502: Bad gateway", "status": 502,
"detail": "The origin web server returned an invalid or incomplete response to Cloudflare.",
"error_code": 502, "error_name": "origin_bad_gateway", "error_category": "origin"}),
FailoverReason.server_error, True, False),
("nous-500-unexpected", "nous", _status(openai.InternalServerError, 500, {"status": 500, "message": (
"Something unexpected happened while processing your request. Please try again in a moment, or contact us "
"if the issue persists.")}),
FailoverReason.server_error, True, False),
("router-no-workers", "custom", _status(openai.InternalServerError, 500, {"error": {
"message": "No available workers (all circuits open or unhealthy)"}}),
FailoverReason.server_error, True, False),
]
@pytest.mark.parametrize("provider,error,reason,retryable,fallback", [r[1:] for r in OBSERVED],
ids=[r[0] for r in OBSERVED])
def test_observed_body_routes_to_its_recovery(provider, error, reason, retryable, fallback):
result = classify_api_error(error, provider=provider, model="test-model")
assert (result.reason, result.retryable, result.should_fallback) == (reason, retryable, fallback)
def test_affordable_402_is_retried_on_the_same_provider():
result = classify_api_error(_status(openai.APIStatusError, 402, AFFORD_402), provider="openrouter",
model="anthropic/claude-opus-5.5")
outcome = f"reason={result.reason.name} retryable={result.retryable}"
with known_failure(r"reason=billing retryable=False", "#49769: affordable-tokens 402 treated as terminal billing"):
assert result.retryable and result.reason != FailoverReason.billing, (
f"402 'can only afford N' must be retried on this provider, not abandoned as billing: {outcome}")
def test_not_a_multimodal_model_is_an_image_rejection():
body = str(_status(openai.BadRequestError, 400, NOT_MULTIMODAL_400))
with known_failure(r"not recognised", "#85005: text-only endpoint image rejection not recognised"):
assert _looks_like_image_content_rejection(body), f"image rejection not recognised: {body[:120]}"