Files
meida_backend/packages/llm/errors.py
T
xsl 54dc741811 feat(llm): provider-abstract gateway with cost and failure controls
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.
2026-08-14 00:26:39 +08:00

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"