mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
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:
parent
ccbb933a95
commit
b94a6d1d21
7 changed files with 674 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
301
tests/fixtures/llm_golden/fallback_ladder.json
vendored
301
tests/fixtures/llm_golden/fallback_ladder.json
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
298
tests/test_llm_typed_policy_refusal.py
Normal file
298
tests/test_llm_typed_policy_refusal.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue