Frame-aware AI fallback module scaffolded under src/phase_z2_ai_fallback/ with master flag ai_fallback_enabled=False; normal-path AI call count remains 0. AI output constrained to builder_options_patch / partial_overrides / slot_mapping_proposal; MDX / frame_id / raw HTML / raw CSS mutations rejected at schema layer. IMP-46 cache gate (cache.py) raises AiFallbackCacheGateError unless visual_check_passed AND user_approved. Step 12 wires AI repair after IMP-30 provisional payload only; Step 17 stays blocked behind IMP-34 / IMP-35 prerequisites. AST isolation guard forbids fallback package from importing Phase Q / Kei / pipeline runtime symbols. Docs IMP-17 / IMP-31 bound to runtime module surface via 11-row structural test pin (test_docs_sync.py) so drift fails CI. Tests: 116 fallback / 161 phase_z2 regression / 526 scoped full sweep all passing. Existing pre-IMP-33 fixture issue in scripts/test_phase_t_* remains untouched (out of scope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
3.4 KiB
Python
93 lines
3.4 KiB
Python
"""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
|