Files
PolyGateway/src/polygateway/ports.py
T
iomgaa 56acb8f3ac feat: record the reasoning verdict in telemetry
This issue surfaced only because someone ran a slow suite that is
excluded by default and had not been run for eighteen days. As a column
it becomes a query: which model stopped being observable, and when.

The emitter unwraps the enum to a plain str at the single _record exit.
asyncpg makes no promise about encoding a str subclass, and a telemetry
write that fails is downgraded to one warning — it would not crash, it
would just quietly cost the Postgres path a column. Normalising at the
emitter follows what tenant_id, meta and sampling already do.

The column is appended last in COLUMNS and in both DDLs. An existing
table can only take ALTER at the end, so putting it anywhere else
forks the physical column order between a freshly built database and a
backfilled one.
2026-08-26 00:29:26 -04:00

306 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""全部端口协议与治理快照类型(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,
TelemetryStatus,
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 TelemetryStatusProvider(Protocol):
"""可自述可写状态的遥测后端;`TelemetryRecorder` 的**可选**伴生端口(issue #15)。
与 `TelemetryRecorder` 分开而不是给它加成员,是因为后者是 `@runtime_checkable`
而运行时检查按属性存在性做: 加一个属性会让所有只实现 `record_llm_call` 的
实现**当场不再是** `TelemetryRecorder`,下游若有同款 isinstance 断言,升级即断
(设计 §3.3)。消费方一律先 isinstance 再取值,取不到就当没有状态可报。
"""
@property
def telemetry_status(self) -> TelemetryStatus: ...
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;25 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受,
遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
"""
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,
tenant_id: str,
meta: str,
thinking_observation: str,
) -> None: ...