DeepSeek (OpenAI-compatible) as primary with a 智谱 GLM slot that is a real routing path but raises until the 算法备案 model list is confirmed. Adds scene routing, failover, a per-provider circuit breaker, structured output with retry-on-validation-failure, prompts loaded by id+version from prompts/, and cost attribution written to ob_ai_call for every attempt -- successes, retries and failures alike. Two controls exist because they are cheap now and expensive later: a cache-prefix validator that rejects timestamps, uuids and nicknames in the cached system prefix (a volatile prefix silently multiplies input cost), and a hard platform-wide daily budget that rejects non-critical scenes instead of merely alerting, since a retry storm outruns any human on-call.
147 lines
4.1 KiB
Python
Executable File
147 lines
4.1 KiB
Python
Executable File
"""LLM gateway error taxonomy.
|
|
|
|
``retryable`` means "worth failing over to the backup provider and counting
|
|
towards the circuit breaker". A malformed request is *not* retryable — sending
|
|
it to the backup would just burn money twice.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
class LLMError(Exception):
|
|
"""Base class for every gateway-level failure."""
|
|
|
|
code = "llm_error"
|
|
|
|
|
|
class ProviderError(LLMError):
|
|
"""A single provider call failed."""
|
|
|
|
code = "llm_provider_error"
|
|
retryable = True
|
|
|
|
def __init__(
|
|
self,
|
|
provider: str,
|
|
message: str,
|
|
*,
|
|
status_code: int | None = None,
|
|
) -> None:
|
|
self.provider = provider
|
|
self.status_code = status_code
|
|
super().__init__(f"{provider}: {message}")
|
|
|
|
|
|
class ProviderTimeoutError(ProviderError):
|
|
code = "llm_timeout"
|
|
retryable = True
|
|
|
|
|
|
class ProviderRateLimitedError(ProviderError):
|
|
code = "llm_rate_limited"
|
|
retryable = True
|
|
|
|
|
|
class ProviderServerError(ProviderError):
|
|
"""5xx from the provider."""
|
|
|
|
code = "llm_server_error"
|
|
retryable = True
|
|
|
|
|
|
class ProviderAuthError(ProviderError):
|
|
"""401/403 — the key for this provider is wrong; the backup may still work."""
|
|
|
|
code = "llm_auth_error"
|
|
retryable = True
|
|
|
|
|
|
class ProviderBadRequestError(ProviderError):
|
|
"""4xx that the backup provider would reject too. Do not fail over."""
|
|
|
|
code = "llm_bad_request"
|
|
retryable = False
|
|
|
|
|
|
class ProviderNotImplementedError(ProviderError, NotImplementedError):
|
|
"""Structural placeholder for a provider slot that has no implementation yet."""
|
|
|
|
code = "llm_provider_not_implemented"
|
|
retryable = True
|
|
|
|
|
|
class CircuitOpenError(ProviderError):
|
|
"""The breaker for this provider is open; the call was not attempted."""
|
|
|
|
code = "llm_circuit_open"
|
|
retryable = True
|
|
|
|
|
|
class StructuredOutputError(LLMError):
|
|
"""The model could not produce output matching the requested schema."""
|
|
|
|
code = "llm_structured_output_failed"
|
|
|
|
def __init__(self, provider: str, schema_name: str, attempts: int, detail: str) -> None:
|
|
self.provider = provider
|
|
self.schema_name = schema_name
|
|
self.attempts = attempts
|
|
super().__init__(
|
|
f"{provider}: failed to satisfy schema {schema_name} "
|
|
f"after {attempts} attempts: {detail}"
|
|
)
|
|
|
|
|
|
class AllProvidersFailedError(LLMError):
|
|
"""Every provider in the scene's routing chain failed."""
|
|
|
|
code = "llm_all_providers_failed"
|
|
|
|
def __init__(self, scene: str, failures: list[Exception]) -> None:
|
|
self.scene = scene
|
|
self.failures = failures
|
|
detail = "; ".join(f"{type(e).__name__}: {e}" for e in failures) or "no providers routed"
|
|
super().__init__(f"scene={scene} all providers failed: {detail}")
|
|
|
|
|
|
class CachePrefixViolationError(LLMError):
|
|
"""The cacheable system prefix contains volatile tokens.
|
|
|
|
架构文档 §7.2 缓存前缀纪律 / TD-04: 系统提示词含时间戳、随机 ID、用户昵称
|
|
会让 prompt 缓存永远不命中,输入成本翻数倍。
|
|
"""
|
|
|
|
code = "llm_prompt_cache_violation"
|
|
|
|
def __init__(self, prompt_ref: str, violations: list[str]) -> None:
|
|
self.prompt_ref = prompt_ref
|
|
self.violations = violations
|
|
super().__init__(f"{prompt_ref}: volatile tokens in cached prefix: {', '.join(violations)}")
|
|
|
|
|
|
class DailyBudgetExceededError(LLMError):
|
|
"""Platform-wide daily AI spend crossed the hard threshold.
|
|
|
|
架构文档 §13 三道成本闸门. Non-critical scenes are rejected/degraded here
|
|
rather than merely alerted — an alert does not stop a retry storm.
|
|
"""
|
|
|
|
code = "llm_daily_budget_exceeded"
|
|
|
|
def __init__(self, spent_cents: str, budget_cents: str, scene: str) -> None:
|
|
self.spent_cents = spent_cents
|
|
self.budget_cents = budget_cents
|
|
self.scene = scene
|
|
super().__init__(
|
|
f"daily AI budget exhausted (spent={spent_cents} budget={budget_cents}); "
|
|
f"non-critical scene '{scene}' rejected"
|
|
)
|
|
|
|
|
|
class PromptNotFoundError(LLMError):
|
|
code = "llm_prompt_not_found"
|
|
|
|
|
|
class SceneNotRoutedError(LLMError):
|
|
code = "llm_scene_not_routed"
|