mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(errors): classify OpenRouter privacy-guardrail 404s distinctly (#14943)
OpenRouter returns a 404 with the specific message 'No endpoints available matching your guardrail restrictions and data policy. Configure: https://openrouter.ai/settings/privacy' when a user's account-level privacy setting excludes the only endpoint serving a model (e.g. DeepSeek V4 Pro, which today is hosted only by DeepSeek's own endpoint that may log inputs). Before this change we classified it as model_not_found, which was misleading (the model exists) and triggered provider fallback (useless — the same account setting applies to every OpenRouter call). Now it classifies as a new FailoverReason.provider_policy_blocked with retryable=False, should_fallback=False. The error body already contains the fix URL, so the user still gets actionable guidance.
This commit is contained in:
parent
acdcb167fb
commit
2acc8783d1
2 changed files with 104 additions and 0 deletions
|
|
@ -45,6 +45,7 @@ class FailoverReason(enum.Enum):
|
|||
|
||||
# Model
|
||||
model_not_found = "model_not_found" # 404 or invalid model — fallback to different model
|
||||
provider_policy_blocked = "provider_policy_blocked" # Aggregator (e.g. OpenRouter) blocked the only endpoint due to account data/privacy policy
|
||||
|
||||
# Request format
|
||||
format_error = "format_error" # 400 bad request — abort or strip + retry
|
||||
|
|
@ -194,6 +195,29 @@ _MODEL_NOT_FOUND_PATTERNS = [
|
|||
"unsupported model",
|
||||
]
|
||||
|
||||
# OpenRouter aggregator policy-block patterns.
|
||||
#
|
||||
# When a user's OpenRouter account privacy setting (or a per-request
|
||||
# `provider.data_collection: deny` preference) excludes the only endpoint
|
||||
# serving a model, OpenRouter returns 404 with a *specific* message that is
|
||||
# distinct from "model not found":
|
||||
#
|
||||
# "No endpoints available matching your guardrail restrictions and
|
||||
# data policy. Configure: https://openrouter.ai/settings/privacy"
|
||||
#
|
||||
# We classify this as `provider_policy_blocked` rather than
|
||||
# `model_not_found` because:
|
||||
# - The model *exists* — model_not_found is misleading in logs
|
||||
# - Provider fallback won't help: the account-level setting applies to
|
||||
# every call on the same OpenRouter account
|
||||
# - The error body already contains the fix URL, so the user gets
|
||||
# actionable guidance without us rewriting the message
|
||||
_PROVIDER_POLICY_BLOCKED_PATTERNS = [
|
||||
"no endpoints available matching your guardrail",
|
||||
"no endpoints available matching your data policy",
|
||||
"no endpoints found matching your data policy",
|
||||
]
|
||||
|
||||
# Auth patterns (non-status-code signals)
|
||||
_AUTH_PATTERNS = [
|
||||
"invalid api key",
|
||||
|
|
@ -523,6 +547,17 @@ def _classify_by_status(
|
|||
return _classify_402(error_msg, result_fn)
|
||||
|
||||
if status_code == 404:
|
||||
# OpenRouter policy-block 404 — distinct from "model not found".
|
||||
# The model exists; the user's account privacy setting excludes the
|
||||
# only endpoint serving it. Falling back to another provider won't
|
||||
# help (same account setting applies). The error body already
|
||||
# contains the fix URL, so just surface it.
|
||||
if any(p in error_msg for p in _PROVIDER_POLICY_BLOCKED_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.provider_policy_blocked,
|
||||
retryable=False,
|
||||
should_fallback=False,
|
||||
)
|
||||
if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.model_not_found,
|
||||
|
|
@ -640,6 +675,12 @@ def _classify_400(
|
|||
)
|
||||
|
||||
# Some providers return model-not-found as 400 instead of 404 (e.g. OpenRouter).
|
||||
if any(p in error_msg for p in _PROVIDER_POLICY_BLOCKED_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.provider_policy_blocked,
|
||||
retryable=False,
|
||||
should_fallback=False,
|
||||
)
|
||||
if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.model_not_found,
|
||||
|
|
@ -812,6 +853,15 @@ def _classify_by_message(
|
|||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Provider policy-block (aggregator-side guardrail) — check before
|
||||
# model_not_found so we don't mis-label as a missing model.
|
||||
if any(p in error_msg for p in _PROVIDER_POLICY_BLOCKED_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.provider_policy_blocked,
|
||||
retryable=False,
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
# Model not found patterns
|
||||
if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):
|
||||
return result_fn(
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class TestFailoverReason:
|
|||
"overloaded", "server_error", "timeout",
|
||||
"context_overflow", "payload_too_large",
|
||||
"model_not_found", "format_error",
|
||||
"provider_policy_blocked",
|
||||
"thinking_signature", "long_context_tier", "unknown",
|
||||
}
|
||||
actual = {r.value for r in FailoverReason}
|
||||
|
|
@ -308,6 +309,59 @@ class TestClassifyApiError:
|
|||
assert result.retryable is True
|
||||
assert result.should_fallback is False
|
||||
|
||||
# ── Provider policy-block (OpenRouter privacy/guardrail) ──
|
||||
|
||||
def test_404_openrouter_policy_blocked(self):
|
||||
# Real OpenRouter error when the user's account privacy setting
|
||||
# excludes the only endpoint serving a model (e.g. DeepSeek V4 Pro
|
||||
# which is hosted only by DeepSeek, and their endpoint may log
|
||||
# inputs). Must NOT classify as model_not_found — the model
|
||||
# exists, falling back won't help (same account setting applies),
|
||||
# and the error body already tells the user where to fix it.
|
||||
e = MockAPIError(
|
||||
"No endpoints available matching your guardrail restrictions "
|
||||
"and data policy. Configure: https://openrouter.ai/settings/privacy",
|
||||
status_code=404,
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.provider_policy_blocked
|
||||
assert result.retryable is False
|
||||
assert result.should_fallback is False
|
||||
|
||||
def test_400_openrouter_policy_blocked(self):
|
||||
# Defense-in-depth: if OpenRouter ever returns this as 400 instead
|
||||
# of 404, still classify it distinctly rather than as format_error
|
||||
# or model_not_found.
|
||||
e = MockAPIError(
|
||||
"No endpoints available matching your data policy",
|
||||
status_code=400,
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.provider_policy_blocked
|
||||
assert result.retryable is False
|
||||
assert result.should_fallback is False
|
||||
|
||||
def test_message_only_openrouter_policy_blocked(self):
|
||||
# No status code — classifier should still catch the fingerprint
|
||||
# via the message-pattern fallback.
|
||||
e = Exception(
|
||||
"No endpoints available matching your guardrail restrictions "
|
||||
"and data policy"
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.provider_policy_blocked
|
||||
|
||||
def test_404_model_not_found_still_works(self):
|
||||
# Regression guard: the new policy-block check must not swallow
|
||||
# genuine model_not_found 404s.
|
||||
e = MockAPIError(
|
||||
"openrouter/nonexistent-model is not a valid model ID",
|
||||
status_code=404,
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
assert result.should_fallback is True
|
||||
|
||||
# ── Payload too large ──
|
||||
|
||||
def test_413_payload_too_large(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue