feat: add frozen core types and error taxonomy
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""错误四分类与 scope 级不可用语义(M1 设计 §3;ARCH §6)。
|
||||
|
||||
分类决定治理行为(重试/换源/熔断计数),库内禁止绕过分类做 ad-hoc 判断。
|
||||
构造形态承 CHS `app/domain/errors.py` 的 ProviderError 一族。
|
||||
"""
|
||||
|
||||
SCOPE_REASONS = frozenset(
|
||||
{"circuit_open", "retry_exhausted", "stalled", "quota_exhausted", "no_sources"}
|
||||
)
|
||||
SOURCE_REASONS = frozenset(
|
||||
{"network_error", "timeout", "rate_limited", "source_dead", "circuit_open", "cooldown"}
|
||||
)
|
||||
|
||||
|
||||
class PolyGatewayError(Exception):
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
source_name: str | None = None,
|
||||
status_code: int | None = None,
|
||||
operation: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.source_name = source_name
|
||||
self.status_code = status_code
|
||||
self.operation = operation
|
||||
|
||||
|
||||
class TransientError(PolyGatewayError):
|
||||
"""瞬时错误(超时/5xx/429/网络抖动/SSE 异常): 退避后可重试、可换源、计熔断。"""
|
||||
|
||||
def __init__(self, message: str, *, retry_after_s: float | None = None, **kwargs) -> None:
|
||||
super().__init__(message, **kwargs)
|
||||
self.retry_after_s = retry_after_s
|
||||
|
||||
|
||||
class SourceDeadError(PolyGatewayError):
|
||||
"""源死亡(401/403/欠费): 不重试,立即换源,该源 force_open。"""
|
||||
|
||||
|
||||
class RequestRejectedError(PolyGatewayError):
|
||||
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。"""
|
||||
|
||||
|
||||
class ResultInvalidError(PolyGatewayError):
|
||||
"""坏结果 ≠ 坏服务: 调用成功但内容不可解析;熔断记成功,不入 transport 重试。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
raw_text: str = "",
|
||||
repair_error: str | None = None,
|
||||
validation_errors: tuple[str, ...] = (),
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(message, **kwargs)
|
||||
self.raw_text = raw_text
|
||||
self.repair_error = repair_error
|
||||
self.validation_errors = tuple(validation_errors)
|
||||
|
||||
|
||||
class GatewayUnavailableError(PolyGatewayError):
|
||||
"""scope 级暂时不可用;业务侧 catch 本类做延期重投(CHS arq 模式)。
|
||||
|
||||
`retry_after_s` 非可选(0 = 可立即重试),承 CHS ProviderUnavailableError。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
reason: str,
|
||||
retry_after_s: float,
|
||||
per_source_reasons: dict[str, str] | None = None,
|
||||
source_name: str | None = None,
|
||||
) -> None:
|
||||
if not scope.strip():
|
||||
raise ValueError("scope 不能为空")
|
||||
if reason not in SCOPE_REASONS:
|
||||
raise ValueError(f"未知 scope 级 reason: {reason!r}(允许: {sorted(SCOPE_REASONS)})")
|
||||
if retry_after_s < 0:
|
||||
raise ValueError("retry_after_s 不能为负")
|
||||
reasons = dict(per_source_reasons or {})
|
||||
for src, src_reason in reasons.items():
|
||||
if src_reason not in SOURCE_REASONS:
|
||||
raise ValueError(f"源 {src!r} 的 reason 非法: {src_reason!r}(允许: {sorted(SOURCE_REASONS)})")
|
||||
super().__init__(f"{scope.lower()} 网关暂时不可用: {reason}", source_name=source_name)
|
||||
self.scope = scope.lower()
|
||||
self.reason = reason
|
||||
self.retry_after_s = retry_after_s
|
||||
self.per_source_reasons = reasons
|
||||
|
||||
|
||||
class CircuitOpenError(GatewayUnavailableError):
|
||||
"""全部候选源被熔断门拒绝;reason 恒为 circuit_open。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
retry_after_s: float,
|
||||
per_source_reasons: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
scope=scope,
|
||||
reason="circuit_open",
|
||||
retry_after_s=retry_after_s,
|
||||
per_source_reasons=per_source_reasons,
|
||||
)
|
||||
|
||||
|
||||
class AllSourcesExhausted(GatewayUnavailableError): # noqa: N818 — ARCH §6.1 冻结的公共名
|
||||
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
|
||||
|
||||
|
||||
class GovernanceBackendError(PolyGatewayError):
|
||||
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""
|
||||
@@ -0,0 +1,186 @@
|
||||
"""核心冻结类型(M1 设计 §2;最内层,禁止 import 任何实现)。
|
||||
|
||||
`LLMResponse` 前 11 个字段与三参考项目逐字保序——它们的测试按位置构造
|
||||
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMResponse:
|
||||
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
||||
|
||||
content: str
|
||||
thinking: str
|
||||
model: str
|
||||
provider: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
latency_ms: int
|
||||
ttft_ms: float | None
|
||||
max_inter_token_ms: float | None
|
||||
cache_hit: bool
|
||||
call_id: str
|
||||
# —— 库新增(只增不删,必带默认值;迁移兼容硬约束)——
|
||||
source_name: str = ""
|
||||
cost: float | None = None
|
||||
usage_source: str = "measured"
|
||||
structured_data: Any | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatRequest:
|
||||
"""洋葱内部流转的不可变请求;中间件用 dataclasses.replace 派生,禁止原地修改。"""
|
||||
|
||||
messages: list[dict[str, Any]]
|
||||
session_id: str | None = None
|
||||
parent_call_id: str | None = None
|
||||
cache_salt: str | None = None
|
||||
cache_namespace: str | None = None
|
||||
structured: Any | None = None
|
||||
stream: bool = True
|
||||
overlay: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Usage:
|
||||
"""token 用量;OCR 等无计费调用填 0。"""
|
||||
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
usage_source: str = "measured"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceStats:
|
||||
"""限流后端回读的单源即时指标(CHS ports.py 同款)。"""
|
||||
|
||||
inflight: int
|
||||
rpm_used: int
|
||||
tpm_used: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportResult:
|
||||
"""transport 单次原始调用的产物;治理字段由 RetryMW 补齐为 LLMResponse。"""
|
||||
|
||||
content: str
|
||||
thinking: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
usage_source: str
|
||||
ttft_ms: float | None
|
||||
max_inter_token_ms: float | None
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceConfig:
|
||||
"""单个模型源的完整配置(CHS config.py 超集;不变式在构造期报错)。
|
||||
|
||||
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、
|
||||
True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。
|
||||
"""
|
||||
|
||||
name: str
|
||||
provider: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
timeout_s: float
|
||||
max_concurrency: int = 0
|
||||
rpm: int = 0
|
||||
tpm: int = 0
|
||||
est_tokens: int = 0
|
||||
ttft_timeout_s: float | None = None
|
||||
inter_token_timeout_s: float | None = None
|
||||
enable_thinking: bool | None = None
|
||||
missing_done: str = "retry"
|
||||
trust_env: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_identity()
|
||||
self._validate_gates()
|
||||
self._validate_watchdog()
|
||||
|
||||
def _validate_identity(self) -> None:
|
||||
for attr in ("name", "provider", "base_url", "api_key", "model"):
|
||||
if not getattr(self, attr).strip():
|
||||
raise ValueError(f"SourceConfig.{attr} 不能为空")
|
||||
if self.missing_done not in _MISSING_DONE_DOMAIN:
|
||||
raise ValueError(f"missing_done 必须是 {sorted(_MISSING_DONE_DOMAIN)}: {self.missing_done!r}")
|
||||
|
||||
def _validate_gates(self) -> None:
|
||||
if self.timeout_s <= 0:
|
||||
raise ValueError("timeout_s 必须 > 0")
|
||||
for attr in ("max_concurrency", "rpm", "tpm", "est_tokens"):
|
||||
if getattr(self, attr) < 0:
|
||||
raise ValueError(f"SourceConfig.{attr} 不能为负(0 表示不启用)")
|
||||
if self.tpm > 0 and self.est_tokens <= 0:
|
||||
raise ValueError("启用 TPM 闸时 est_tokens 必须 > 0(入场预扣依据)")
|
||||
|
||||
def _validate_watchdog(self) -> None:
|
||||
# CHS config.py:66-82: 流式看门狗成对配置且 0 < inter < ttft < timeout_s
|
||||
if (self.ttft_timeout_s is None) != (self.inter_token_timeout_s is None):
|
||||
raise ValueError("ttft_timeout_s 与 inter_token_timeout_s 必须同时设置或同时缺省")
|
||||
if self.ttft_timeout_s is not None and not (
|
||||
0 < self.inter_token_timeout_s < self.ttft_timeout_s < self.timeout_s
|
||||
):
|
||||
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryPolicy:
|
||||
"""重试策略;max_attempts = 总尝试次数(含首次,M1 设计 §2.3 统一语义)。"""
|
||||
|
||||
max_attempts: int
|
||||
backoff_base_s: float
|
||||
backoff_max_s: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_attempts < 1:
|
||||
raise ValueError("max_attempts 必须 ≥ 1(含首次尝试)")
|
||||
if self.backoff_base_s <= 0 or self.backoff_max_s < self.backoff_base_s:
|
||||
raise ValueError("退避参数要求 0 < backoff_base_s ≤ backoff_max_s")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BreakerConfig:
|
||||
"""熔断配置;probe_ttl_s 是半开探针租约时长(持有者死亡后自动回收)。"""
|
||||
|
||||
fail_threshold: int
|
||||
cooldown_s: float
|
||||
probe_ttl_s: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.fail_threshold < 1 or self.cooldown_s <= 0 or self.probe_ttl_s <= 0:
|
||||
raise ValueError("熔断配置要求 fail_threshold ≥ 1 且 cooldown_s/probe_ttl_s > 0")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackpressurePolicy:
|
||||
"""背压配置;M1 仅使用 poll_interval_s,stall 判定 M2 启用。"""
|
||||
|
||||
stall_window_s: float
|
||||
poll_interval_s: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.stall_window_s <= 0 or self.poll_interval_s <= 0:
|
||||
raise ValueError("背压参数必须 > 0")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GlobalLimits:
|
||||
"""scope 级全局限额;0 表示该闸不启用。"""
|
||||
|
||||
max_concurrency: int
|
||||
rpm: int
|
||||
tpm: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_concurrency < 0 or self.rpm < 0 or self.tpm < 0:
|
||||
raise ValueError("全局限额不能为负(0 表示不启用)")
|
||||
Reference in New Issue
Block a user