v7next F3.1 lane A: the D09 typed policy-refusal subfamily (D02 entry 4 return)

Re-derived on tip bytes — the five consuming ladder bodies drifted upstream
after the fork (plan_next_wire_retry state machine, request-wire custody), so
the reference deltas were re-applied to the tip structure, never replayed
verbatim (re-prove trap, D15 entry 3).

- llm_attempt owns the contract: PROVIDER_POLICY_REFUSAL /
  ProviderPolicyRefusal / _is_provider_policy_refusal (structural: class or
  declared exact code, never prose); the llm facade re-exports all three.
- The five rung bodies (rows 1706/1749/1751/1759/1760) no longer consume a
  typed refusal: _retry_without_optional_sampling (llm_capability_policy) and
  _retry_without_prompt_cache_parameter / _openrouter_signature_retry_kwargs
  decline to plan on one; in both _create_chat_completion_with_retries twins
  the bounded exception/body state machine raises a refusal instead of
  planning a wire retry on it, and the reroute/strip body-error arms raise it
  instead of absorbing it into the first errored response (each raise path
  discards the pending effort-clamp note, the tip custody rule).
- loop_llm_call.classify_llm_exception classifies the refusal structurally
  before every prose heuristic: kind=provider_policy_refusal,
  retry_same_request=False (D01 entry 7 counterpart). loop_llm_call.py sits
  EXACTLY on its 1600-line ratchet cap, so the insert is net-negative: the
  refusal import rides the existing llm_attempt line-style, and two
  neighbouring call expressions are compacted (formatting only) — the module
  ends at 1599 lines, no giant debt minted.
- Pins: tests/test_llm_typed_policy_refusal.py carried whole (25 passed with
  no adaptation); the two typed_policy_refusal golden cases return to
  fallback_ladder.json (15 -> 17), their expected blocks RE-RECORDED from this
  tree's live code via the suite's own --write entry (append-only diff; the
  15 existing cases were untouched by the re-record, proving no drift), and
  both record the oracle-intended semantics: the refusal RAISES, the
  exception-path case spends exactly one physical send.

(cherry picked from commit 3df0a8f1578ed2d06358911e2ad90a88e7e46e1a)
This commit is contained in:
Ouroboros 2026-08-31 17:50:33 +00:00 • committed by Anton Razzhigaev
parent ccbb933a95
commit b94a6d1d21
7 changed files with 674 additions and 17 deletions

View file

@ -31,6 +31,8 @@ from ouroboros.llm_anthropic import (
_AnthropicLaneMixin, # noqa: F401
)
from ouroboros.llm_attempt import (
PROVIDER_POLICY_REFUSAL, # noqa: F401
ProviderPolicyRefusal, # noqa: F401
_applied_payload_cache_ttl, # noqa: F401
_attempt_request,
_CACHE_TTL_SECONDS, # noqa: F401
@ -38,6 +40,7 @@ from ouroboros.llm_attempt import (
_canonical_candidate_bytes, # noqa: F401
_execute_candidate,
_execute_candidate_async, # noqa: F401
_is_provider_policy_refusal, # noqa: F401
_is_structured_context_overflow_body, # noqa: F401
_is_structured_context_overflow_exception, # noqa: F401
_PayloadCachePolicyMixin, # noqa: F401

View file

@ -43,6 +43,36 @@ _VALID_CACHE_TTLS = frozenset({"5m", "1h"})
_CACHE_TTL_SECONDS = {"5m": 300, "1h": 3600}
PROVIDER_POLICY_REFUSAL = "provider_policy_refusal"
class ProviderPolicyRefusal(RuntimeError):
"""Typed refusal: a policy layer would not let this call reach a provider.
Not a provider failure — nothing upstream answered — so no rung of the
recovery ladder can repair it: dropping a parameter, rerouting the endpoint
or stripping replayed reasoning all re-attempt a call that was refused, and
the caller ends up seeing whatever the re-attempt produced instead of the
refusal. It carries the machine-readable ``code`` so the ladder can classify
it structurally, exactly as the subscription-window refusal is classified in
``loop_llm_call.classify_llm_exception`` — never by matching prose.
A transport that cannot import this class states the same fact by setting
``code`` to :data:`PROVIDER_POLICY_REFUSAL` on its own exception type; a
family of refusals (connection not permitted, egress denied, tenant blocked)
either subclasses this or carries the same code.
"""
code = PROVIDER_POLICY_REFUSAL
def _is_provider_policy_refusal(exc: BaseException) -> bool:
"""Structural test: a typed refusal, by class or by the declared ``code``."""
return isinstance(exc, ProviderPolicyRefusal) or (
str(getattr(exc, "code", "") or "") == PROVIDER_POLICY_REFUSAL
)
def _structured_error_values(payload: Any) -> Set[str]:
if not isinstance(payload, dict):
return set()

View file

@ -18,7 +18,10 @@ import threading
import time
from typing import Any, Dict, Optional, Set
from ouroboros.llm_attempt import _is_structured_context_overflow_exception
from ouroboros.llm_attempt import (
_is_provider_policy_refusal,
_is_structured_context_overflow_exception,
)
from ouroboros.provider_models import normalize_model_identity
@ -451,7 +454,7 @@ class _CapabilityPolicyMixin:
exc: BaseException,
) -> Optional[Dict[str, Any]]:
cls = type(self)
if _is_structured_context_overflow_exception(exc):
if _is_structured_context_overflow_exception(exc) or _is_provider_policy_refusal(exc):
return None
if not cls._parameter_rejection_error(exc):
return None

View file

@ -23,6 +23,7 @@ from ouroboros.llm_attempt import (
_candidate_before_dispatch,
_execute_candidate,
_execute_candidate_async,
_is_provider_policy_refusal,
_is_structured_context_overflow_body,
_is_structured_context_overflow_exception,
_physical_candidate,
@ -52,7 +53,7 @@ class _RecoveryLadderMixin:
exc: BaseException,
) -> Optional[Dict[str, Any]]:
"""Remove only an explicitly rejected cache control or affinity once."""
if _is_structured_context_overflow_exception(exc):
if _is_structured_context_overflow_exception(exc) or _is_provider_policy_refusal(exc):
return None
provider = str(target.get("provider") or "").strip().lower()
extra_body = payload.get("extra_body")
@ -137,7 +138,7 @@ class _RecoveryLadderMixin:
exc: Exception,
) -> Optional[Dict[str, Any]]:
"""Strip replayed reasoning once for a non-overflow OpenRouter 400."""
if _is_structured_context_overflow_exception(exc):
if _is_structured_context_overflow_exception(exc) or _is_provider_policy_refusal(exc):
return None
if not target.get("supports_openrouter_extensions"):
return None
@ -404,6 +405,10 @@ class _RecoveryLadderMixin:
if retry_kwargs is None:
return current_response
else:
if _is_provider_policy_refusal(current_failure):
# A typed refusal is permanent by class (D09): no
# rung may re-attempt the refused call.
raise current_failure
retry_kwargs = plan_next_wire_retry(
current_candidate, error=current_failure,
)
@ -463,7 +468,11 @@ class _RecoveryLadderMixin:
resp = _send(reroute_kwargs)
except UsageAccountingError:
raise
except Exception:
except Exception as exc:
if _is_provider_policy_refusal(exc):
# A refused call is not a provider answer to fall back FROM.
self._pop_effort_clamp_disclosure()
raise
return resp
kwargs = reroute_kwargs
# An encrypted-reasoning 400 delivered in the body (directly, or on the
@ -476,7 +485,10 @@ class _RecoveryLadderMixin:
kwargs = strip_kwargs
except UsageAccountingError:
raise
except Exception:
except Exception as exc:
if _is_provider_policy_refusal(exc):
self._pop_effort_clamp_disclosure()
raise
return resp
return _recover_existing(kwargs, response=resp)
@ -537,6 +549,10 @@ class _RecoveryLadderMixin:
if retry_kwargs is None:
return current_response
else:
if _is_provider_policy_refusal(current_failure):
# A typed refusal is permanent by class (D09): no
# rung may re-attempt the refused call.
raise current_failure
retry_kwargs = plan_next_wire_retry(
current_candidate, error=current_failure,
)
@ -590,7 +606,11 @@ class _RecoveryLadderMixin:
resp = await _send(reroute_kwargs)
except UsageAccountingError:
raise
except Exception:
except Exception as exc:
if _is_provider_policy_refusal(exc):
# A refused call is not a provider answer to fall back FROM.
self._pop_effort_clamp_disclosure()
raise
return resp
kwargs = reroute_kwargs
# An encrypted-reasoning 400 delivered in the body (directly, or on the
@ -603,6 +623,9 @@ class _RecoveryLadderMixin:
kwargs = strip_kwargs
except UsageAccountingError:
raise
except Exception:
except Exception as exc:
if _is_provider_policy_refusal(exc):
self._pop_effort_clamp_disclosure()
raise
return resp
return await _recover_existing(kwargs, response=resp)

View file

@ -27,6 +27,7 @@ from ouroboros.deadline_utils import (
transport_timeout_with_deadline,
)
from ouroboros.llm import LLMClient, LocalContextTooLargeError, add_usage
from ouroboros.llm_attempt import PROVIDER_POLICY_REFUSAL, _is_provider_policy_refusal # typed-refusal contract owner
from ouroboros.observability import new_call_id, new_execution_id, persist_call
from ouroboros.pricing import emit_llm_usage_event, estimate_cost_optional, infer_model_category
from ouroboros.provider_models import provider_for_model
@ -742,19 +743,17 @@ def classify_llm_exception(exc: Exception, safe_error: str = "") -> LlmErrorClas
if str(getattr(exc, "code", "") or "") == SUBSCRIPTION_WINDOW_EXHAUSTED:
reset_at = str(getattr(exc, "reset_at", "") or "")
return LlmErrorClassification(
SUBSCRIPTION_WINDOW_EXHAUSTED,
True,
_exception_status_code(exc),
"",
seconds_until(reset_at),
reset_at,
SUBSCRIPTION_WINDOW_EXHAUSTED, True, _exception_status_code(exc),
"", seconds_until(reset_at), reset_at,
)
status_code = _exception_status_code(exc)
provider_code = _exception_provider_code(exc, safe)
# Typed refusal (llm_attempt.ProviderPolicyRefusal): nothing upstream answered,
# permanent by class — structural, and it outranks every prose heuristic below.
if _is_provider_policy_refusal(exc):
return LlmErrorClassification(PROVIDER_POLICY_REFUSAL, False, status_code, provider_code or PROVIDER_POLICY_REFUSAL)
provider_message = _exception_provider_message(exc, safe)
classification_text = "\n".join(
value for value in (safe, provider_message) if str(value or "").strip()
)
classification_text = "\n".join(v for v in (safe, provider_message) if str(v or "").strip())
low = classification_text.lower()
if provider_code.lower() in _STRUCTURED_CONTEXT_OVERFLOW_CODES:
return LlmErrorClassification("context_overflow", False, status_code, provider_code)

View file

@ -3011,6 +3011,307 @@
],
"unused_script_steps": 0
}
},
{
"id": "fallback.body.typed_policy_refusal_is_not_swallowed",
"route": "a typed policy refusal raised on the reroute resend surfaces to the caller instead of being absorbed into the first errored response",
"spec": {
"env": {
"OPENROUTER_API_KEY": "or-fixture-key"
},
"transport": [
{
"kind": "response",
"body": {
"id": "gen-fixture-429",
"choices": null,
"error": {
"code": 429,
"message": "Provider returned error: rate limit exceeded"
}
}
},
{
"kind": "error",
"code": "provider_policy_refusal",
"message": "temperature reasoning unsupported parameter: connection is not permitted"
}
],
"call": {
"kind": "method",
"name": "chat",
"kwargs": {
"model": "openai/gpt-5.6",
"messages": [
{
"role": "system",
"content": "stable policy prefix"
},
{
"role": "user",
"content": "hi"
},
{
"role": "assistant",
"content": "a",
"reasoning_details": [
{
"type": "reasoning.encrypted",
"data": "rs_a"
}
]
},
{
"role": "user",
"content": "again"
}
]
}
}
},
"expected": {
"raised": {
"type": "FixtureProviderError",
"message": "temperature reasoning unsupported parameter: connection is not permitted"
},
"sends": [
{
"transport": "openai.chat.completions.create",
"client": {
"api_key": "or-fixture-key",
"base_url": "https://openrouter.ai/api/v1",
"default_headers": {
"HTTP-Referer": "https://ouroboros-agent.ai/",
"X-OpenRouter-Title": "Ouroboros"
},
"max_retries": 0,
"http_client": null
},
"payload": {
"extra_body": {
"reasoning": {
"effort": "medium",
"exclude": false
},
"session_id": "ouroboros-session-6b68a9325c7ec50094ba2af61bbc0416"
},
"max_tokens": 65536,
"messages": [
{
"content": "stable policy prefix",
"role": "system"
},
{
"content": "hi",
"role": "user"
},
{
"content": "a",
"reasoning_details": [
{
"data": "rs_a",
"type": "reasoning.encrypted"
}
],
"role": "assistant"
},
{
"content": "again",
"role": "user"
}
],
"model": "openai/gpt-5.6"
},
"payload_sha256": "2784ab41ca25a83ef489f69c3fcfccaa5d0931691bb6eddbbc81d8a5137da765"
},
{
"transport": "openai.chat.completions.create",
"client": {
"api_key": "or-fixture-key",
"base_url": "https://openrouter.ai/api/v1",
"default_headers": {
"HTTP-Referer": "https://ouroboros-agent.ai/",
"X-OpenRouter-Title": "Ouroboros"
},
"max_retries": 0,
"http_client": null
},
"payload": {
"extra_body": {
"reasoning": {
"effort": "medium",
"exclude": false
},
"session_id": "ouroboros-session-a83747a093e9425fe805af8007af41b1"
},
"max_tokens": 65536,
"messages": [
{
"content": "stable policy prefix",
"role": "system"
},
{
"content": "hi",
"role": "user"
},
{
"content": "a",
"role": "assistant"
},
{
"content": "again",
"role": "user"
}
],
"model": "openai/gpt-5.6"
},
"payload_sha256": "af42956164f7e04dfa70f8b64c49de6753cd942a39627cc5b7ebab519ea4357e"
}
],
"physical_attempts": [
{
"source": "llm.chat",
"model": "openai/gpt-5.6",
"provider": "openrouter",
"candidate_raw_sha256": "2784ab41ca25a83ef489f69c3fcfccaa5d0931691bb6eddbbc81d8a5137da765",
"candidate_raw_size_bytes": 405,
"candidate_measurement_kind": "canonical_json_v1",
"states": [
"reserved",
"dispatched",
"settled"
],
"prompt_cache_ttl": "",
"cost_final": true
},
{
"source": "llm.chat",
"model": "openai/gpt-5.6",
"provider": "openrouter",
"candidate_raw_sha256": "af42956164f7e04dfa70f8b64c49de6753cd942a39627cc5b7ebab519ea4357e",
"candidate_raw_size_bytes": 338,
"candidate_measurement_kind": "canonical_json_v1",
"states": [
"reserved",
"dispatched",
"unresolved"
]
}
],
"unused_script_steps": 0
}
},
{
"id": "fallback.exception.typed_policy_refusal_is_never_re_attempted",
"route": "a typed policy refusal on the first send spends no second physical attempt, even though its message names parameters the ladder would otherwise drop",
"spec": {
"env": {
"OPENROUTER_API_KEY": "or-fixture-key"
},
"transport": [
{
"kind": "error",
"code": "provider_policy_refusal",
"message": "temperature reasoning unsupported parameter: connection is not permitted"
},
{
"kind": "response",
"body": {
"id": "gen-fixture-1",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "ok",
"refusal": null,
"annotations": null,
"reasoning": "short rollup"
}
}
],
"usage": {
"prompt_tokens": 120,
"completion_tokens": 8,
"cost": 0.0031,
"prompt_tokens_details": {
"cached_tokens": 40
}
}
}
}
],
"call": {
"kind": "method",
"name": "chat",
"kwargs": {
"model": "x-ai/grok-4.5",
"messages": [
{
"role": "user",
"content": "hi"
}
],
"temperature": 0.7
}
}
},
"expected": {
"raised": {
"type": "FixtureProviderError",
"message": "temperature reasoning unsupported parameter: connection is not permitted"
},
"sends": [
{
"transport": "openai.chat.completions.create",
"client": {
"api_key": "or-fixture-key",
"base_url": "https://openrouter.ai/api/v1",
"default_headers": {
"HTTP-Referer": "https://ouroboros-agent.ai/",
"X-OpenRouter-Title": "Ouroboros"
},
"max_retries": 0,
"http_client": null
},
"payload": {
"extra_body": {
"reasoning": {
"effort": "medium",
"exclude": false
}
},
"max_tokens": 65536,
"messages": [
{
"content": "hi",
"role": "user"
}
],
"model": "x-ai/grok-4.5",
"temperature": 0.7
},
"payload_sha256": "d5a7bfccf7c9b8a4f9e3499dc6e59f1b5db989e38571261419c693845d63c10d"
}
],
"physical_attempts": [
{
"source": "llm.chat",
"model": "x-ai/grok-4.5",
"provider": "openrouter",
"candidate_raw_sha256": "d5a7bfccf7c9b8a4f9e3499dc6e59f1b5db989e38571261419c693845d63c10d",
"candidate_raw_size_bytes": 169,
"candidate_measurement_kind": "canonical_json_v1",
"states": [
"reserved",
"dispatched",
"unresolved"
]
}
],
"unused_script_steps": 1
}
}
]
}

View file

@ -0,0 +1,298 @@
"""A typed policy refusal is never swallowed, repaired, or repeated.
Two seams answer for one fact. The recovery ladder must not consume a refusal
(it has no provider answer to fall back FROM), and the retry loop's classifier
must not call it retryable (repeating an unchanged refused call only re-runs the
refusal). Both read the declared code; neither reads the message.
The refusals here are SYNTHETIC. Nothing is copied from, imported from, or
shaped after any deployment's own refusal type: one subclasses the published
contract, the sibling only sets the declared ``code`` — the shape a transport
that cannot import Ouroboros uses to state the same fact. Neither is recognised
by any word in its message (BIBLE P5: no keyword gates), and the assertions below
would fail if the ladder matched prose instead of the typed fact.
What the fallback must do with one: nothing. A refused call never reached a
provider, so there is no provider answer to fall back FROM — dropping a
parameter, rerouting the endpoint or stripping replayed reasoning would all
re-attempt a call a policy layer declined, and the caller would be handed the
re-attempt's outcome (or the first errored response) instead of the refusal.
"""
from __future__ import annotations
import asyncio
import copy
from typing import Any, Dict, List
import pytest
from ouroboros.llm import PROVIDER_POLICY_REFUSAL, LLMClient, ProviderPolicyRefusal
from ouroboros.loop_llm_call import classify_llm_exception
class NoPermittedConnection(ProviderPolicyRefusal):
"""Fixture refusal: the host policy permits no connection for this call."""
class EgressDeniedByPolicy(RuntimeError):
"""Sibling refusal from a transport that never imports Ouroboros: it states
the same fact with the declared code and nothing else."""
code = PROVIDER_POLICY_REFUSAL
class TenantBlocked(RuntimeError):
"""Sibling refusal whose message is deliberately full of words the recovery
ladder DOES key on for real provider failures (temperature, reasoning,
unsupported parameter, rate limit). A prose matcher would repair it."""
code = PROVIDER_POLICY_REFUSAL
def __init__(self) -> None:
super().__init__(
"temperature reasoning unsupported parameter rate limit "
"no endpoints found overloaded try again"
)
_REFUSALS = [NoPermittedConnection("connection is not permitted"), EgressDeniedByPolicy("denied"),
TenantBlocked()]
_REFUSAL_IDS = ["subclass", "code_only_sibling", "code_only_with_recoverable_prose"]
_BODY_429 = {
"id": "gen-1", "choices": None,
"error": {"code": 429, "message": "rate limit exceeded upstream"},
}
_BODY_400_ENCRYPTED = {
"id": "gen-2", "choices": None,
"error": {"code": 400, "message": "The encrypted content for item rs_a could not be verified"},
}
_BODY_400_PARAM = {
"id": "gen-3", "choices": None,
"error": {"code": 400, "message": "temperature: unsupported parameter for this model"},
}
_REPLAYED_REASONING: List[Dict[str, Any]] = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "a",
"reasoning_details": [{"type": "reasoning.encrypted", "data": "rs_a"}]},
{"role": "user", "content": "again"},
]
class _Resp:
def __init__(self, body: Dict[str, Any]) -> None:
self._body = body
def model_dump(self) -> Dict[str, Any]:
return copy.deepcopy(self._body)
@pytest.fixture
def client(monkeypatch, tmp_path):
monkeypatch.setenv("OPENROUTER_API_KEY", "or-fixture-key")
monkeypatch.setenv("OUROBOROS_DATA_DIR", str(tmp_path))
monkeypatch.setenv("OUROBOROS_SETTINGS_PATH", str(tmp_path / "settings.json"))
import ouroboros.llm_attempt as llm_attempt
# The physical-attempt ledger is exercised by the golden fixtures; here the
# subject is the ladder, so the executor is a pass-through.
monkeypatch.setattr(llm_attempt, "execute_physical_attempt",
lambda _request, send, **_kw: send())
async def _async_execute(_request, send, **_kw):
return await send()
monkeypatch.setattr(llm_attempt, "execute_physical_attempt_async", _async_execute)
return LLMClient(api_key="or-fixture-key")
def _script(steps):
"""A create_fn that walks a scripted list of responses/raises."""
calls: List[Dict[str, Any]] = []
def create(**kwargs):
calls.append(kwargs)
step = steps[len(calls) - 1]
if isinstance(step, BaseException):
raise step
return _Resp(step)
return create, calls
def _async_script(steps):
calls: List[Dict[str, Any]] = []
async def create(**kwargs):
calls.append(kwargs)
step = steps[len(calls) - 1]
if isinstance(step, BaseException):
raise step
return _Resp(step)
return create, calls
def _target() -> Dict[str, Any]:
return {
"provider": "openrouter",
"resolved_model": "openai/gpt-5.6",
"usage_model": "openai/gpt-5.6",
"api_key": "or-fixture-key",
"base_url": "https://openrouter.ai/api/v1",
"default_headers": {},
"supports_openrouter_extensions": True,
"supports_generation_cost": True,
}
def _kwargs() -> Dict[str, Any]:
return {
"model": "openai/gpt-5.6",
"messages": copy.deepcopy(_REPLAYED_REASONING),
"max_tokens": 512,
"temperature": 0.7,
"extra_body": {"reasoning": {"effort": "high", "exclude": False},
"session_id": "ouroboros-session-fixture"},
}
@pytest.mark.parametrize("refusal", _REFUSALS, ids=_REFUSAL_IDS)
@pytest.mark.parametrize("first_body", [_BODY_429, _BODY_400_ENCRYPTED, _BODY_400_PARAM],
ids=["transient_reroute", "encrypted_strip", "parameter_retry"])
def test_body_rung_does_not_swallow_a_typed_refusal(client, refusal, first_body):
"""Every 200-body rung answers a refused resend by returning the FIRST
(errored) response. A typed refusal must surface instead — the caller would
otherwise be told the call was rate-limited/bad-request by a provider that
never saw it."""
create, calls = _script([first_body, refusal])
with pytest.raises(type(refusal)) as excinfo:
client._create_chat_completion_with_retries(create, _kwargs(), _target())
assert excinfo.value is refusal
assert len(calls) == 2 # the rung's one resend, and no more
@pytest.mark.parametrize("refusal", _REFUSALS, ids=_REFUSAL_IDS)
def test_async_body_rung_does_not_swallow_a_typed_refusal(client, refusal):
create, calls = _async_script([_BODY_429, refusal])
with pytest.raises(type(refusal)) as excinfo:
asyncio.run(
client._create_chat_completion_with_retries_async(create, _kwargs(), _target())
)
assert excinfo.value is refusal
assert len(calls) == 2
@pytest.mark.parametrize("refusal", _REFUSALS, ids=_REFUSAL_IDS)
def test_exception_ladder_never_re_attempts_a_refused_call(client, refusal):
"""A refusal on the FIRST send is not a parameter, cache or signature
problem: no rung may spend a second physical attempt on it."""
create, calls = _script([refusal, {"choices": [{"message": {"content": "never"}}]}])
with pytest.raises(type(refusal)) as excinfo:
client._create_chat_completion_with_retries(create, _kwargs(), _target())
assert excinfo.value is refusal
assert len(calls) == 1
@pytest.mark.parametrize("refusal", _REFUSALS, ids=_REFUSAL_IDS)
def test_refusal_surfaces_through_the_public_chat_surface(client, monkeypatch, refusal):
"""End to end: LLMClient.chat hands the refusal to its caller unchanged."""
create, calls = _script([_BODY_429, refusal])
import types
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))
)
monkeypatch.setattr(client, "_get_remote_client", lambda _target: fake_client, raising=False)
# No capability fetch on the payload-build path: this test is about the ladder.
monkeypatch.setattr(client, "_get_supported_parameters", lambda _model: None, raising=False)
with pytest.raises(type(refusal)) as excinfo:
# Replayed reasoning is what makes the 429 body eligible for the
# same-model reroute whose resend the policy layer then refuses.
client.chat(copy.deepcopy(_REPLAYED_REASONING), model="openai/gpt-5.6")
assert excinfo.value is refusal
assert len(calls) == 2
def test_an_ordinary_resend_failure_is_still_absorbed(client):
"""The guard is typed, not a blanket 'never absorb': an ordinary provider
failure on the resend keeps returning the first response, exactly as before."""
create, calls = _script([_BODY_429, RuntimeError("second endpoint also down")])
resp = client._create_chat_completion_with_retries(create, _kwargs(), _target())
assert resp.model_dump()["error"]["code"] == 429
assert len(calls) == 2
def test_a_refusal_carrying_a_foreign_code_is_not_treated_as_a_refusal(client):
"""The code is an exact declared value, not a prefix or a substring scan."""
class OtherCode(RuntimeError):
code = "provider_policy_refusal_v2"
create, calls = _script([_BODY_429, OtherCode("something else")])
resp = client._create_chat_completion_with_retries(create, _kwargs(), _target())
assert resp.model_dump()["error"]["code"] == 429
assert len(calls) == 2
# --- the retry loop's own answer -------------------------------------------
#
# The ladder above declines to REPAIR a refusal; the retry loop must also decline
# to REPEAT it. Both seams read the same typed fact, so the assertions below go
# through `classify_llm_exception` — the function the loop actually consults —
# rather than any marker table it happens to consult on the way.
@pytest.mark.parametrize("refusal", _REFUSALS, ids=_REFUSAL_IDS)
def test_the_retry_loop_classifies_a_typed_refusal_as_permanent(refusal):
"""A refused call is a named, non-retryable class.
Its kind is the declared code itself, so `events.jsonl` names the refusal
instead of laundering it into the catch-all `provider_error`; and
`retry_same_request` is False, so the loop stops rather than spending its
whole attempt budget re-running a call no provider ever saw."""
classification = classify_llm_exception(refusal)
assert classification.kind == PROVIDER_POLICY_REFUSAL
assert classification.retry_same_request is False
assert classification.provider_code == PROVIDER_POLICY_REFUSAL
# Recovery is not a known instant; nothing here may schedule a wake-up.
assert classification.retry_after_sec is None
def test_the_retry_loop_prefers_the_typed_fact_over_recoverable_prose():
"""`TenantBlocked`'s message says "rate limit" — the text the loop keys on
for its retryable class. The typed fact must win: prose from a call that
never reached a provider describes nothing that could heal."""
refusal = TenantBlocked()
assert "rate limit" in str(refusal) # the trap is really in the message
assert classify_llm_exception(refusal).retry_same_request is False
def test_the_retry_loop_does_not_read_a_foreign_code_as_a_refusal():
"""The loop's branch is the same exact-value test the ladder makes, not a
prefix match: a longer code that merely CONTAINS the declared one keeps the
ordinary classification it would have had."""
class OtherCode(RuntimeError):
code = "provider_policy_refusal_v2"
classification = classify_llm_exception(OtherCode("something else"))
assert classification.kind != PROVIDER_POLICY_REFUSAL
assert classification.retry_same_request is True