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.
This commit is contained in:
Executable
+146
@@ -0,0 +1,146 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user