"""IMP-33 u4 — AI fallback Anthropic client (fallback path only). Wraps ``anthropic.Anthropic.messages.create`` with the timeout / retry / backoff / budget / circuit-breaker policy locked in u1 ``Settings``. NO inline policy literals: every knob is sourced from ``src.config.settings``. Transient errors (timeout / connection / 429 / 5xx) are retried with capped exponential backoff + jitter; all other errors propagate without retry. PZ-1 invariant: this module is fallback-path only and MUST NOT be imported on the normal pipeline path. """ from __future__ import annotations import json import random import time from dataclasses import dataclass from typing import Any import anthropic from src.config import settings from src.phase_z2_ai_fallback.schema import AiFallbackProposal _TRANSIENT_ERRORS: tuple[type[BaseException], ...] = ( anthropic.APITimeoutError, anthropic.APIConnectionError, anthropic.RateLimitError, anthropic.InternalServerError, ) # Output cap is an Anthropic API requirement, not a policy knob (u1). _MAX_OUTPUT_TOKENS = 4096 class AiFallbackBudgetExceeded(RuntimeError): """Per-run AI call budget (u1 ai_fallback_budget_per_run) exhausted.""" class AiFallbackCircuitOpen(RuntimeError): """Circuit breaker tripped (u1 ai_fallback_circuit_breaker_threshold).""" @dataclass class AiFallbackClient: """Stateful per-run fallback client (budget + circuit accounting).""" client: Any = None _calls: int = 0 _consecutive_failures: int = 0 def __post_init__(self) -> None: if self.client is None: self.client = anthropic.Anthropic( api_key=settings.anthropic_api_key, timeout=settings.ai_fallback_timeout_s, ) def request_proposal(self, prompt: dict[str, str]) -> AiFallbackProposal: if self._calls >= settings.ai_fallback_budget_per_run: raise AiFallbackBudgetExceeded( f"per-run budget {settings.ai_fallback_budget_per_run} exhausted" ) if self._consecutive_failures >= settings.ai_fallback_circuit_breaker_threshold: raise AiFallbackCircuitOpen( f"circuit open after {self._consecutive_failures} consecutive failures" ) self._calls += 1 last_error: BaseException | None = None for attempt in range(settings.ai_fallback_max_retries + 1): try: response = self.client.messages.create( model=settings.ai_fallback_model, max_tokens=_MAX_OUTPUT_TOKENS, system=prompt["system"], messages=[{"role": "user", "content": prompt["user"]}], ) text = "".join( block.text for block in response.content if hasattr(block, "text") ) self._consecutive_failures = 0 return AiFallbackProposal.model_validate(json.loads(text)) except _TRANSIENT_ERRORS as err: last_error = err if attempt >= settings.ai_fallback_max_retries: break base = settings.ai_fallback_backoff_base_s * (2 ** attempt) delay = min(settings.ai_fallback_backoff_cap_s, base) delay += random.uniform(0, delay * settings.ai_fallback_backoff_jitter) time.sleep(delay) self._consecutive_failures += 1 assert last_error is not None raise last_error