Files
hermes-agent/tests/agent/test_account_policy_block.py
teknium1 2c948e6aa2 fix(agent): an upstream account ban relayed in a 200 stream fails once instead of retrying as an outage
OpenRouter relays OpenAI's "this user has been blocked for a previous
policy violation" as HTTP 200 + an SSE error event. The SDK raises a
status-less APIError that no classifier rule matched, so it landed in
the retryable `unknown` bucket: every retry was re-sent (3 streamed
requests by default), a configured fallback only engaged after the
ladder, and the user was told the provider "looks temporarily
unavailable. Wait a minute and send /retry".

- error_classifier: match the ban phrase status-agnostically in
  _provider_special_cases -> provider_policy_blocked (non-retryable,
  fallback, no credential rotation: every key on a banned account is
  banned; a 403 variant is not a bad key).
- turn_failure_copy: provider_policy_blocked chat copy now covers an
  account block as well as data/privacy settings; add its cause gloss
  so cron and subagent notices explain it instead of printing raw text.
- cron: provider_policy_blocked action (retrying won't help; pin another
  model).
- docs: FAQ entry for the reply; auto-recovery ladder exclusion list.
2026-09-23 10:40:08 -07:00

82 lines
3.4 KiB
Python

"""An upstream account ban relayed as HTTP 200 + an SSE ``error`` event is permanent.
OpenRouter relays OpenAI's "this user has been blocked for a previous policy violation"
inside a 200 stream; the SDK raises a status-less ``APIError``. Classified ``unknown`` it
was retried ``api_max_retries`` times and the user was told the provider "looks temporarily
unavailable" — advice that can never work for a banned account.
"""
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import httpx
import openai
import pytest
import run_agent
from agent.error_classifier import FailoverReason, classify_api_error
BAN = (
"Policy Violation: this user has been blocked for a previous policy violation. "
"Learn more: https://platform.openai.com/docs/guides/safety-best-practices"
)
@pytest.mark.parametrize("status", [None, 403])
def test_account_ban_is_a_permanent_policy_block_not_transient_or_auth(status):
request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
if status is None: # what openai/_streaming.py raises for a 200 stream carrying {"error": ...}
err = openai.APIError(BAN, request, body={"message": BAN, "code": 403})
else:
err = openai.PermissionDeniedError(
BAN, response=httpx.Response(status, request=request), body={"error": {"message": BAN}}
)
result = classify_api_error(err, provider="openrouter", model="openai/gpt-4.1-nano")
assert result.reason == FailoverReason.provider_policy_blocked
assert result.retryable is False
assert result.should_fallback is True
# Every key on a banned account is banned: rotating the pool only burns credentials.
assert result.should_rotate_credential is False
def test_streamed_account_ban_fails_once_without_transient_advice():
streamed = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("content-length", 0))) or b"{}")
if not self.path.endswith("/chat/completions"):
self.send_response(404)
self.end_headers()
return
if body.get("stream"):
streamed.append(body)
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.end_headers()
self.wfile.write(f"data: {json.dumps({'error': {'code': 403, 'message': BAN}})}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
agent = run_agent.AIAgent(
api_key="test-key", base_url=f"http://127.0.0.1:{server.server_address[1]}/v1",
model="m", provider="custom", quiet_mode=True, skip_context_files=True,
skip_memory=True, enabled_toolsets=[], max_iterations=1,
)
result = agent.run_conversation("ping", conversation_history=[], task_id="t")
finally:
server.shutdown()
server.server_close()
assert len(streamed) == 1, f"a permanent ban was re-sent {len(streamed)} times"
assert result["failed"] is True
assert result["failure_retryable"] is False
assert "temporarily unavailable" not in result["final_response"]
assert BAN in result["final_response"]