"""全部端口协议与治理快照类型(M1 设计 §4;最内层,只依赖 types/errors/标准库)。 决策逻辑与状态存储分离(D3): 中间件只面向这里的 Protocol, 后端(memory/redis)各自实现同一契约并共用一套契约测试。 时间量纲一律**秒**(CHS Redis 实现内部的毫秒换算是后端私事,不进契约)。 """ from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import StrEnum from typing import Any, Protocol, runtime_checkable from .types import ( ChatRequest, EmbeddingTransportResult, LLMResponse, OcrLayoutResult, OcrLayoutTransportResult, OcrTextResult, OcrTextTransportResult, SourceConfig, SourceStats, TransportResult, ) CallNext = Callable[[ChatRequest], Awaitable[LLMResponse]] @runtime_checkable class Middleware(Protocol): """洋葱层: 包裹 call_next,层与层正交(D1)。""" async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse: ... @runtime_checkable class Transport(Protocol): """一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。""" async def complete( self, *, messages: list[dict[str, Any]], source: SourceConfig, stream: bool, overlay: dict[str, Any], call_id: str, ) -> TransportResult: ... @runtime_checkable class EmbeddingTransport(Protocol): """一次原始 embedding 调用的协议细节(M2 §7);不含任何治理。""" async def embed( self, *, texts: list[str], source: SourceConfig, call_id: str ) -> EmbeddingTransportResult: ... @runtime_checkable class OcrTextPort(Protocol): """OCR 文本转录端口(D9;对应 MonkeyOCR POST /ocr/text)。""" async def recognize_text(self, image: bytes) -> OcrTextResult: ... @runtime_checkable class OcrLayoutPort(Protocol): """OCR 版面解析端口(D9;对应 MonkeyOCR POST /parse → GET ZIP)。""" async def parse_layout(self, image: bytes) -> OcrLayoutResult: ... @runtime_checkable class OcrTransport(Protocol): """一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。 check_health 是探测不是调用: 2xx → True,异常 → False, CancelledError 穿透;由消费方(启动门)决定成败语义。 """ async def recognize_text( self, *, image: bytes, source: SourceConfig, call_id: str ) -> OcrTextTransportResult: ... async def parse_layout( self, *, image: bytes, source: SourceConfig, call_id: str ) -> OcrLayoutTransportResult: ... async def check_health(self, *, source: SourceConfig) -> bool: ... @runtime_checkable class Permit(Protocol): """限流入场许可;settle/release 均幂等,finally 中必然执行。""" async def release(self) -> None: ... async def settle(self, actual_tokens: int) -> None: ... @runtime_checkable class RateLimiter(Protocol): """限流契约(CHS limiter.py 形态): 并发/RPM/TPM × 全局/单源 六道闸。""" async def try_acquire(self, source_key: str, est_tokens: int) -> Permit | None: ... async def acquire(self, source_key: str, est_tokens: int) -> Permit: ... async def source_stats(self, source_key: str) -> SourceStats: ... async def mark_progress(self) -> None: ... async def progress_age_s(self) -> float: ... class GateState(StrEnum): """熔断状态机三态。""" CLOSED = "closed" OPEN = "open" HALF_OPEN = "half_open" @dataclass(frozen=True) class GateDecision: """一次源准入决定,同时是结果写回所需的 fencing token(CHS ports.py:405)。""" source_name: str allowed: bool state: GateState epoch: int is_probe: bool probe_owner: str | None retry_after_s: float def __post_init__(self) -> None: # 校验逐条移植 CHS ports.py:405-440,保证快照内部一致 self._check_bounds() self._check_admission() self._check_probe() def _check_bounds(self) -> None: if not self.source_name.strip(): raise ValueError("source_name 不能为空") if self.epoch < 0: raise ValueError("epoch 不能为负") if self.retry_after_s < 0: raise ValueError("retry_after_s 不能为负") def _check_admission(self) -> None: if self.allowed and self.state is GateState.OPEN: raise ValueError("OPEN 状态不得准入") if self.allowed and self.state is GateState.HALF_OPEN and not self.is_probe: raise ValueError("HALF_OPEN 准入必须是探针") def _check_probe(self) -> None: if self.is_probe: probe_valid = ( self.allowed and self.state is GateState.HALF_OPEN and self.probe_owner is not None and bool(self.probe_owner.strip()) ) if not probe_valid: raise ValueError("探针决定必须同时包含准入、HALF_OPEN 与 owner") elif self.probe_owner is not None: raise ValueError("非探针决定不得携带 probe_owner") @dataclass(frozen=True) class GateUpdate: """门控写回快照;applied=False 表示 fencing 已拒绝迟到结果(CHS ports.py:442)。""" applied: bool state: GateState epoch: int failure_count: int retry_after_s: float def __post_init__(self) -> None: if self.epoch < 0 or self.failure_count < 0 or self.retry_after_s < 0: raise ValueError("门控更新快照不能包含负数") @runtime_checkable class ProviderGate(Protocol): """熔断契约(CHS provider_gate.py 形态): 半开单探针租约 + epoch fencing 在契约内。""" async def try_enter(self, source_name: str, owner: str) -> GateDecision: ... async def record_success( self, entry: GateDecision, *, count_attempt: bool = True ) -> GateUpdate: ... async def record_failure( self, entry: GateDecision, reason: str, force_open: bool ) -> GateUpdate: ... async def release_probe(self, entry: GateDecision) -> GateUpdate: ... async def retry_after_s(self, sources: tuple[str, ...]) -> float: ... @runtime_checkable class CacheBackend(Protocol): """响应缓存后端: 笨 KV;key 公式归 CacheMW(算法一份)。""" async def get(self, key: str) -> str | None: ... async def set(self, key: str, value: str, ttl_s: int) -> None: ... @runtime_checkable class SourceSelector(Protocol): """选源策略(CHS selector.py 形态): 返回本次尝试的候选顺序。""" def order( self, sources: list[SourceConfig], stats: dict[str, SourceStats] ) -> list[SourceConfig]: ... @runtime_checkable class OutcomeAwareSelector(Protocol): """可选选源器扩展(M2.5 设计 §3.2): 消费尝试结果以维护健康视图。 RetryMW 构造时 isinstance 判定一次;非本 Protocol 的选源器不受影响。 喂数口径: 真实成功 ok=True;Transient/SourceDead/429 ok=False; ResultInvalid 与"网关健康拒坏请求"不喂(坏结果 ≠ 坏服务)。 """ def record_outcome(self, source_name: str, ok: bool) -> None: ... def health(self, source_name: str) -> float: ... @runtime_checkable class StructuredOutputStrategy(Protocol): """结构化输出策略(D7/D14): 请求侧叠加 + 响应侧解析。""" def request_overlay(self, schema: dict[str, Any] | None) -> dict[str, Any]: ... def parse(self, text: str) -> Any: ... @runtime_checkable class TelemetryRecorder(Protocol): """遥测后端;20 字段冻结(M1 设计 §4.4 + issue #3),唯一调用点是 TelemetryEmitter。 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。 """ async def record_llm_call( self, *, call_id: str, parent_call_id: str | None, session_id: str | None, model: str, provider: str, source_name: str, messages: str, response: str, thinking: str, prompt_tokens: int, completion_tokens: int, usage_source: str, latency_ms: int, ttft_ms: float | None, max_inter_token_ms: float | None, cache_hit: bool, error: str | None, cost: float | None, cached_prompt_tokens: int | None, model_reported: str | None, sampling: str | None, reasoning_tokens: int | None, ) -> None: ...