feat: track logical call statistics across governed calls

This commit is contained in:
2026-09-09 10:04:39 -04:00
parent 300ced5dbd
commit 87c261bf73
13 changed files with 650 additions and 10 deletions
+2
View File
@@ -39,6 +39,7 @@ from polygateway.thinking import (
) )
from polygateway.types import ( from polygateway.types import (
EFFORT_ORDER, EFFORT_ORDER,
CallStats,
Effort, Effort,
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
@@ -57,6 +58,7 @@ __all__ = [
"EFFORT_ORDER", "EFFORT_ORDER",
"Effort", "Effort",
"AllSourcesExhausted", "AllSourcesExhausted",
"CallStats",
"CircuitOpenError", "CircuitOpenError",
"EmbeddingClient", "EmbeddingClient",
"EmbeddingResponse", "EmbeddingResponse",
+12 -1
View File
@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import dataclasses
import hashlib import hashlib
import json import json
import random import random
@@ -46,6 +47,7 @@ from polygateway.types import (
Effort, Effort,
LLMResponse, LLMResponse,
TelemetryStatus, TelemetryStatus,
_CallContext,
coerce_effort, coerce_effort,
validate_caller_dimensions, validate_caller_dimensions,
validate_request_overlay, validate_request_overlay,
@@ -289,6 +291,10 @@ class GatewayClient:
self._structured_available = structured_strategy is not None self._structured_available = structured_strategy is not None
self._terminal = terminal # 内部引用: 装配自省/测试用 self._terminal = terminal # 内部引用: 装配自省/测试用
self._handler = compose(middlewares, terminal) self._handler = compose(middlewares, terminal)
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
self._now = now
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
self._emitter = emitter
self._transport = transport self._transport = transport
self._telemetry = telemetry self._telemetry = telemetry
self._cache = cache self._cache = cache
@@ -370,6 +376,8 @@ class GatewayClient:
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)") else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
) )
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay") validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
context = _CallContext(now=self._now)
request = ChatRequest( request = ChatRequest(
messages=messages, messages=messages,
session_id=session_id, session_id=session_id,
@@ -383,8 +391,11 @@ class GatewayClient:
reasoning_effort=effort, reasoning_effort=effort,
tenant_id=dimension_tenant_id, tenant_id=dimension_tenant_id,
meta=dimensions, meta=dimensions,
call_context=context,
) )
return await self._handler(request) response = await self._handler(request)
# 快照在返回前冻结: 故它含缓存命中路径与已完成的内联遥测耗时
return dataclasses.replace(response, call_stats=context.snapshot())
async def aclose(self) -> None: async def aclose(self) -> None:
"""幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。 """幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。
+15 -2
View File
@@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import dataclasses
import math import math
import random import random
import time import time
@@ -47,6 +48,7 @@ from polygateway.types import (
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
TelemetryStatus, TelemetryStatus,
_CallContext,
strip_unsupported_extra_body, strip_unsupported_extra_body,
validate_caller_dimensions, validate_caller_dimensions,
) )
@@ -184,7 +186,11 @@ class EmbeddingClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)" tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
) )
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
context = _CallContext(now=self._now)
if not texts: if not texts:
# 合法零尝试: 返回真实统计(attempts=0),且**不写任何遥测行**
# ——与 cache_hit 不同,不要按"遥测必录"推断它有台账行(设计 §3 M2)
return EmbeddingResponse( return EmbeddingResponse(
vectors=[], vectors=[],
dim=0, dim=0,
@@ -195,6 +201,7 @@ class EmbeddingClient:
latency_ms=0, latency_ms=0,
call_id=str(uuid.uuid4()), call_id=str(uuid.uuid4()),
source_name="", source_name="",
call_stats=context.snapshot(),
) )
if not self._sources: if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0) raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
@@ -207,9 +214,11 @@ class EmbeddingClient:
parent_call_id, parent_call_id,
dimension_tenant_id, dimension_tenant_id,
dimensions, dimensions,
context,
) )
) )
return self._merge(outcomes) # 全批共享同一上下文,故分批是实现细节而非 N 次独立逻辑调用
return dataclasses.replace(self._merge(outcomes), call_stats=context.snapshot())
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)—— # —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
@@ -220,6 +229,7 @@ class EmbeddingClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome: ) -> _BatchOutcome:
fails = 0 fails = 0
reasons: dict[str, str] = {} reasons: dict[str, str] = {}
@@ -232,7 +242,7 @@ class EmbeddingClient:
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt( outcome = await self._attempt(
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta, context
) )
if isinstance(outcome, _BatchOutcome): if isinstance(outcome, _BatchOutcome):
return outcome return outcome
@@ -258,10 +268,13 @@ class EmbeddingClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome | _FailedBatch: ) -> _BatchOutcome | _FailedBatch:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
actual = 0 actual = 0
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
context.register_attempt()
try: try:
result = await self._transport.embed(texts=batch, source=source, call_id=call_id) result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
if self._expected_dim is not None and result.dim != self._expected_dim: if self._expected_dim is not None and result.dim != self._expected_dim:
+7
View File
@@ -208,6 +208,10 @@ class CacheMW:
max_inter_token_ms=None, max_inter_token_ms=None,
call_id=str(uuid.uuid4()), call_id=str(uuid.uuid4()),
structured_data=structured_data, structured_data=structured_data,
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
# 过滤**会放行它**——不覆盖就会有 dict 冒充 `CallStats` 漏给调用方。
# 本次调用的真实统计由公开边界在返回前追加(设计 §3)
call_stats=None,
) )
return LLMResponse(**fields) return LLMResponse(**fields)
except Exception as exc: except Exception as exc:
@@ -230,6 +234,9 @@ class CacheMW:
def _serialize(self, response: LLMResponse) -> str: def _serialize(self, response: LLMResponse) -> str:
data = dataclasses.asdict(response) data = dataclasses.asdict(response)
data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1) data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1)
# 统计描述**本次**调用,存进去再放出来等于向下一个调用方谎称
# 它重试了 N 次;`asdict` 会把 `CallStats` 摊成 dict,故必须显式剔除
data.pop("call_stats", None)
return json.dumps(data, ensure_ascii=False) return json.dumps(data, ensure_ascii=False)
async def _safe_get(self, key: str) -> str | None: async def _safe_get(self, key: str) -> str | None:
+5
View File
@@ -278,6 +278,11 @@ class RetryMW:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
actual = 0 actual = 0
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
# 上下文为 None = 库内现场构造的请求,跳过而不是报错
if request.call_context is not None:
request.call_context.register_attempt()
try: try:
result = await self._transport.complete( result = await self._transport.complete(
messages=request.messages, messages=request.messages,
+23 -5
View File
@@ -40,12 +40,14 @@ from polygateway.middleware.retry import StallClock, _failure_reason, backoff_de
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ports import OutcomeAwareSelector from polygateway.ports import OutcomeAwareSelector
from polygateway.types import ( from polygateway.types import (
CallStats,
ChatRequest, ChatRequest,
LLMResponse, LLMResponse,
OcrLayoutResult, OcrLayoutResult,
OcrTextResult, OcrTextResult,
TelemetryStatus, TelemetryStatus,
Usage, Usage,
_CallContext,
strip_unsupported_extra_body, strip_unsupported_extra_body,
validate_caller_dimensions, validate_caller_dimensions,
) )
@@ -176,7 +178,7 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)" tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
) )
outcome = await self._call( outcome, call_stats = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions "text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
) )
result = outcome.result result = outcome.result
@@ -187,6 +189,7 @@ class OcrClient:
latency_ms=outcome.latency_ms, latency_ms=outcome.latency_ms,
call_id=outcome.call_id, call_id=outcome.call_id,
raw=result.raw, raw=result.raw,
call_stats=call_stats,
) )
async def parse_layout( async def parse_layout(
@@ -206,7 +209,7 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)" tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
) )
outcome = await self._call( outcome, call_stats = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions "layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
) )
result = outcome.result result = outcome.result
@@ -218,6 +221,7 @@ class OcrClient:
latency_ms=outcome.latency_ms, latency_ms=outcome.latency_ms,
call_id=outcome.call_id, call_id=outcome.call_id,
raw=result.raw, raw=result.raw,
call_stats=call_stats,
) )
async def check_health(self) -> dict[str, bool]: async def check_health(self) -> dict[str, bool]:
@@ -242,11 +246,14 @@ class OcrClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
) -> _AttemptOutcome: ) -> tuple[_AttemptOutcome, CallStats]:
if not isinstance(image, bytes): if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)") raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
if not image: if not image:
raise ValueError("image 不能为空") raise ValueError("image 不能为空")
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
context = _CallContext(now=self._now)
if not self._sources: if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0) raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
fails = 0 fails = 0
@@ -260,10 +267,18 @@ class OcrClient:
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt( outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta kind,
image,
*picked,
reasons,
session_id,
parent_call_id,
tenant_id,
meta,
context,
) )
if isinstance(outcome, _AttemptOutcome): if isinstance(outcome, _AttemptOutcome):
return outcome return outcome, context.snapshot()
fails += 1 fails += 1
if fails >= self._retry.max_attempts: if fails >= self._retry.max_attempts:
raise AllSourcesExhausted( raise AllSourcesExhausted(
@@ -287,11 +302,14 @@ class OcrClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
) -> _AttemptOutcome | _FailedAttempt: ) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度: # 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行 # 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
context.register_attempt()
try: try:
result = await self._invoke(kind, image, source, call_id) result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
+107 -2
View File
@@ -8,11 +8,12 @@ import dataclasses
import json import json
import math import math
import re import re
from collections.abc import Mapping import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any, Literal
from loguru import logger from loguru import logger
@@ -274,6 +275,91 @@ class ThinkingObservation(StrEnum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。
与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**,
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。"""
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
"""一行遥测描述的事件形态;旧行 NULL,不回填。
终态行与 attempt 行**不是重复事实**(前者描述逻辑终态,后者描述单次尝试),
故禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数(设计 §6/§8)。"""
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
@dataclass(frozen=True)
class CallStats:
"""一次**公开调用**(而非单次尝试)的统计快照(设计 §3)。
四种响应各平铺三字段会立刻漂移,故收敛成单一对象并由包根导出。
第三方合成响应的 `None` 表示**未知**,不得伪造 0。
"""
logical_call_id: str
"""每次公开调用一个 UUID;重试、结构化重问、embedding 分批共享同一个。
不占用既有 `parent_call_id`(后者是调用方的业务关联,语义不变)。"""
attempts: int
"""准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝。
**不是 HTTP 请求条数**: OCR layout 的 POST + ZIP GET 在同一次 transport
调用内,计 1 次。缓存命中与空输入是合法的零尝试。"""
total_latency_ms: int
"""从输入校验通过到返回/异常传播前的单调时钟快照。
含缓存 IO、退避等待、准入等待、重问、分批与内联记账。
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)。"""
class _CallContext:
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
**每调用一个实例**的单任务对象: chat 重试、结构化重问、embedding 分批
都在同一任务内串行推进,故计数无需锁。**严禁提升为 client 实例属性**
——那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"
VT `evolve_llm = llm` 教训的同一形态)。
"""
__slots__ = ("_attempts", "_now", "_started", "_terminal_claimed", "logical_call_id")
def __init__(self, *, now: Callable[[], float]) -> None:
self.logical_call_id = str(uuid.uuid4())
self._now = now
self._started = now()
self._attempts = 0
self._terminal_claimed = False
def register_attempt(self) -> None:
"""transport 调用**前**登记一次尝试(含免预算 429 与端口本地拒绝)。
登记点在调用前而非成功后: 否则失败与取消的尝试会从计数里消失,
而那正是诊断时最需要看见的那几次。
"""
self._attempts += 1
def snapshot(self) -> CallStats:
"""同步冻结当前快照;**绝不 await**,可多次调用。"""
return CallStats(
logical_call_id=self.logical_call_id,
attempts=self._attempts,
total_latency_ms=int((self._now() - self._started) * 1000),
)
def claim_terminal(self) -> bool:
"""首次 `True`、其后 `False`: 保证每逻辑调用至多写一条终态行。"""
if self._terminal_claimed:
return False
self._terminal_claimed = True
return True
@dataclass(frozen=True) @dataclass(frozen=True)
class LLMResponse: class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。""" """一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -336,6 +422,9 @@ class LLMResponse:
`None` 不是"没推理": 库不表态时也不推定模型自己的默认档——"没看见"不许说成 `None` 不是"没推理": 库不表态时也不推定模型自己的默认档——"没看见"不许说成
"发生了"(同 `thinking_observation` 的 `UNKNOWN` 一脉)。""" "发生了"(同 `thinking_observation` 的 `UNKNOWN` 一脉)。"""
call_stats: CallStats | None = None
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class ChatRequest: class ChatRequest:
@@ -382,6 +471,16 @@ class ChatRequest:
而档位要经能力表校验、要进缓存 key、要落遥测——混进直通层等于放弃这三样, 而档位要经能力表校验、要进缓存 key、要落遥测——混进直通层等于放弃这三样,
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。""" 正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
# —— 库内部逻辑调用上下文(1.3.5;追加在末尾,不扰动既有字段的位置构造)——
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 `logical_call_id` 落 NULL。
`compare=False, repr=False` 不是洁癖: 进 `compare` 会让两个内容相同的请求因
"不是同一次调用"而不相等,进 `repr` 则把库内部件泄进调用方的日志。
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class Usage: class Usage:
@@ -722,6 +821,8 @@ class OcrTextResult:
latency_ms: int latency_ms: int
call_id: str call_id: str
raw: dict[str, Any] raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -739,6 +840,8 @@ class OcrLayoutResult:
latency_ms: int latency_ms: int
call_id: str call_id: str
raw: dict[str, Any] raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -783,3 +886,5 @@ class EmbeddingResponse:
call_id: str call_id: str
source_name: str source_name: str
cost: float | None = None cost: float | None = None
call_stats: CallStats | None = None
"""本次逻辑调用(含全部分批)的统计快照(1.3.5);`None` = 未知。"""
+72
View File
@@ -818,3 +818,75 @@ class TestExplicitCacheMigration:
finally: finally:
for transport in transports: for transport in transports:
await transport.aclose() await transport.aclose()
class TestCallStatsNotPoisoned:
"""缓存不得回放历史统计(1.3.5 设计 §3)。
统计描述**本次**调用;把上次那条存进去再放出来,等于对调用方谎称这次
重试了 N 次、耗了 M 毫秒。
"""
async def test_serialized_payload_carries_no_call_stats_key(self):
from polygateway.types import CallStats
backend = InMemoryCache()
mw = _mw(backend)
stats = CallStats(logical_call_id="lc-1", attempts=3, total_latency_ms=900)
terminal = _Terminal(_resp(call_stats=stats))
await mw(ChatRequest(messages=_MSGS), terminal)
key = build_cache_key("m", _MSGS, "proj", None)
stored = json.loads(await backend.get(key))
assert "call_stats" not in stored # asdict 会把它摊成 dict,必须显式剔除
async def test_historic_dict_never_impersonates_call_stats(self):
"""旧条目里的 `call_stats` dict 会被 `_RESPONSE_FIELDS` 放行,必须显式覆盖。
不覆盖就会有一个 dict 冒充 `CallStats` 从公共 API 漏给调用方,
`resp.call_stats.attempts` 当场 `AttributeError`。
"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
poisoned = {
"content": "legacy",
"thinking": "",
"model": "m",
"provider": "p",
"prompt_tokens": 1,
"completion_tokens": 2,
"latency_ms": 30,
"ttft_ms": 5.0,
"max_inter_token_ms": 2.0,
"cache_hit": False,
"call_id": "orig",
"source_name": "s1",
"usage_source": "measured",
"call_stats": {
"logical_call_id": "stale-lc",
"attempts": 7,
"total_latency_ms": 9999,
},
}
await backend.set(key, json.dumps(poisoned), ttl_s=100)
terminal = _Terminal(_resp())
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
assert hit.call_stats is None # dict 不得冒充 CallStats
async def test_cache_key_is_unchanged_by_the_new_field(self):
"""新增内部字段不得扰动 key 公式,否则存量缓存全量冷启动(黄金值)。"""
from polygateway.types import _CallContext
class _Clock:
def __call__(self):
return 1000.0
ctx = _CallContext(now=_Clock())
bare = build_cache_key("m", _MSGS, "proj", None)
assert bare == build_cache_key("m", _MSGS, "proj", None)
# 带上下文的请求与不带的请求必须落在同一个 key 上
with_ctx = ChatRequest(messages=_MSGS, call_context=ctx)
without = ChatRequest(messages=_MSGS)
assert with_ctx.cache_namespace == without.cache_namespace
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
+137
View File
@@ -1362,3 +1362,140 @@ async def test_synthetic_runtime_protocol_and_legacy_call_signatures():
assert client._transport._clients == {} assert client._transport._clients == {}
finally: finally:
await client.aclose() await client.aclose()
class _StatsClock:
"""确定性单调钟;测试主动推进以断言"哪些区段计入了总耗时""""
def __init__(self, start=1000.0):
self.t = start
def __call__(self):
return self.t
def advance(self, seconds):
self.t += seconds
class _TickingCache:
"""假缓存后端: 每次 IO 推进注入钟。
不推进时钟的替身会让"缓存 IO 计入总耗时"的断言退化成恒等于 0 的空转绿
(计划 §T1 替身构造要求)。
"""
def __init__(self, clock, tick=0.25):
self._clock = clock
self._tick = tick
self._data = {}
async def get(self, key):
self._clock.advance(self._tick)
return self._data.get(key)
async def set(self, key, value, ttl_s):
self._clock.advance(self._tick)
self._data[key] = value
class _TickingRecorder:
"""假 recorder: 写入时推进注入钟,用于断言内联遥测收尾计入总耗时。"""
def __init__(self, clock, tick=0.5):
self._clock = clock
self._tick = tick
self.rows = []
async def record_llm_call(self, **fields):
self._clock.advance(self._tick)
self.rows.append(fields)
class TestLogicalCallStats:
"""一次公开 chat 调用的统计(1.3.5 设计 §3)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_success_reports_one_attempt(self):
async with _client() as client:
resp = await client.chat(self._MSG)
assert resp.call_stats is not None
assert resp.call_stats.attempts == 1
assert resp.call_stats.logical_call_id
async def test_concurrent_calls_do_not_share_counters_or_ids(self):
"""同一 client 并发两路必须各自计数与各自 ID(库铁律「纯 asyncio 中立」)。
上下文若被提升成 client 实例属性,这条就会红——那正是 VT
`evolve_llm = llm` 教训的同一形态。
"""
async with _client() as client:
a, b = await asyncio.gather(client.chat(self._MSG), client.chat(self._MSG))
assert a.call_stats.logical_call_id != b.call_stats.logical_call_id
assert a.call_stats.attempts == b.call_stats.attempts == 1
async def test_cache_hit_is_zero_attempts_with_a_fresh_logical_id(self):
"""命中不产生网关调用 → 0 尝试;且是**新**逻辑调用,不回放历史统计。"""
clock = _StatsClock()
cache = _TickingCache(clock)
async with _client(
cache=cache, cache_namespace="proj", cache_ttl_s=600, now=clock
) as client:
first = await client.chat(self._MSG)
second = await client.chat(self._MSG)
assert first.cache_hit is False and first.call_stats.attempts == 1
assert second.cache_hit is True
assert second.call_stats.attempts == 0
assert second.call_stats.logical_call_id != first.call_stats.logical_call_id
async def test_cache_io_counts_into_total_latency(self):
"""缓存读写是本次调用真实花掉的时间,必须进总耗时(设计 §3)。"""
clock = _StatsClock()
cache = _TickingCache(clock, tick=0.25)
async with _client(
cache=cache, cache_namespace="proj", cache_ttl_s=600, now=clock
) as client:
hit = (await client.chat(self._MSG), await client.chat(self._MSG))[1]
# 命中路径只有一次 get(0.25s),无网关调用
assert hit.call_stats.attempts == 0
assert hit.call_stats.total_latency_ms == 250
async def test_inline_telemetry_teardown_counts_into_total_latency(self):
"""成功响应的快照含返回前已完成的内联遥测耗时(设计 §6)。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=0.5)
async with _client(telemetry=recorder, now=clock) as client:
resp = await client.chat(self._MSG)
assert recorder.rows # 确实写了行,否则本断言空转
assert resp.call_stats.total_latency_ms == 500
async def test_milliseconds_not_seconds(self):
"""毫秒/秒不混用: 1.5s 必须是 1500 而不是 1 或 1.5。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=1.5)
async with _client(telemetry=recorder, now=clock) as client:
resp = await client.chat(self._MSG)
assert resp.call_stats.total_latency_ms == 1500
async def test_stats_work_without_any_telemetry(self):
"""统计生效与否**不由 telemetry 是否启用决定**(设计 §3.5)。"""
async with _client(telemetry=None) as client:
resp = await client.chat(self._MSG)
assert resp.call_stats is not None and resp.call_stats.attempts == 1
async def test_failure_exception_carries_no_stats_attribute(self):
"""本版**不向异常对象附加统计**(设计 §3.1): 第三方可能复用同一异常实例。"""
def reject(request):
return httpx.Response(400, json={"error": {"message": "bad"}})
async with _client(handler=reject) as client:
with pytest.raises(RequestRejectedError) as exc:
await client.chat(self._MSG)
assert hasattr(exc.value, "call_stats") is False
async def test_input_validation_stays_outside_the_stats_boundary(self):
"""校验异常保持原行为,发生在统计边界之外(设计 §3)。"""
async with _client() as client:
with pytest.raises(ValueError, match="meta"):
await client.chat(self._MSG, meta={"BAD-KEY": 1})
+40
View File
@@ -600,3 +600,43 @@ class TestReasonlessTelemetryContract:
assert seen == [{"model": "embed-1", "input": ["text"]}] assert seen == [{"model": "embed-1", "input": ["text"]}]
finally: finally:
await transport.aclose() await transport.aclose()
class TestEmbedLogicalCallStats:
"""分批共享同一逻辑调用(1.3.5 设计 §3/§3.5)。"""
async def test_three_batches_count_three_attempts(self):
"""分批是库的实现细节,但每批都真打了一次网关,故计 3 次尝试。"""
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=2)
resp = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"])
assert resp.call_stats is not None
assert resp.call_stats.attempts == 3
async def test_separate_calls_get_distinct_logical_ids(self):
"""一次公开调用一个 ID: 两次 `embed` 不得共用同一个。
共用就意味着上下文被提升成了 client 实例属性(库铁律禁止的形态)。
"""
client, _ = _embed_client([_src()], ["ok"] * 5, batch_size=2)
first = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"]) # 3 批
second = await client.embed(["x", "y"]) # 1 批
assert first.call_stats.attempts == 3 and second.call_stats.attempts == 1
assert first.call_stats.logical_call_id != second.call_stats.logical_call_id
async def test_retry_within_a_batch_is_counted(self):
client, _ = _embed_client([_src(), _src(name="e2")], [TransientError("t1"), "ok"])
resp = await client.embed(["a"])
assert resp.call_stats.attempts == 2
async def test_empty_input_is_zero_attempts_and_writes_no_telemetry_row(self):
"""合法零尝试: 返回真实统计,且**不写任何遥测行**(设计 §3 M2)。
与 cache_hit 不同——不要按"遥测必录"推断空输入也有台账行。
"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], [], telemetry=rec)
resp = await client.embed([])
assert resp.call_stats is not None
assert resp.call_stats.attempts == 0
assert resp.call_stats.logical_call_id # 真实 ID,不是空串
assert rec.rows == [] # 零遥测行
+31
View File
@@ -615,3 +615,34 @@ class TestReasonlessTelemetryContract:
await getattr(client, method)(b"image") await getattr(client, method)(b"image")
assert len(recorder.rows) == len(script) assert len(recorder.rows) == len(script)
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows) assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
class TestOcrLogicalCallStats:
"""OCR 两个公开方法各自拥有一次逻辑调用(1.3.5 设计 §3/§3.5)。"""
async def test_text_success_counts_one_attempt(self):
client, _, _ = _client([_src()], ["text"])
r = await client.recognize_text(b"jpg")
assert r.call_stats is not None and r.call_stats.attempts == 1
async def test_layout_two_http_calls_count_as_one_attempt(self):
"""POST + ZIP GET 在同一次 transport 调用内,计 **1** 次尝试而非 2。
`attempts` 的语义是"调用 transport 端口的次数",不是 HTTP 请求条数。
"""
client, _, _ = _client([_src()], ["layout"])
r = await client.parse_layout(b"jpg")
assert r.call_stats is not None and r.call_stats.attempts == 1
async def test_retry_counts_every_attempt(self):
client, _, _ = _client([_src(), _src(name="m2")], [TransientError("t1"), "text"])
r = await client.recognize_text(b"jpg")
assert r.call_stats.attempts == 2
async def test_input_validation_stays_outside_the_stats_boundary(self):
"""`image` 类型/空校验先于上下文创建(M1 例外),保持原异常行为。"""
client, _, _ = _client([_src()], [])
with pytest.raises(TypeError):
await client.recognize_text("not-bytes")
with pytest.raises(ValueError):
await client.recognize_text(b"")
+58
View File
@@ -5,6 +5,7 @@
""" """
import asyncio import asyncio
import dataclasses
import pytest import pytest
@@ -776,3 +777,60 @@ class TestRateLimitPushback:
await mw(_REQ) await mw(_REQ)
assert ei.value.reason == "retry_exhausted" assert ei.value.reason == "retry_exhausted"
assert len(transport.calls) == 3 assert len(transport.calls) == 3
class TestLogicalAttemptCounting:
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
登记点若挪到成功之后,失败与取消的尝试就会从计数里消失——而那正是
诊断时最需要看见的几次。
"""
def _ctx(self, clock):
from polygateway.types import _CallContext
return _CallContext(now=clock)
async def test_single_success_counts_one(self):
mw, _, _, _, _, clock = _harness([_src("a")], [_ok()])
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 1
async def test_failed_retries_are_counted(self):
"""两次可重试失败 + 一次成功 = 3 次尝试,不是 1 次。"""
mw, _, _, transport, _, clock = _harness(
[_src("a")], [TransientError("t1"), TransientError("t2"), _ok()]
)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_budget_free_429_still_counts_as_an_attempt(self):
"""429 免的是重试预算,不是"没发生过"——它确实打到了网关。"""
mw, _, _, transport, _, clock = _harness(
[_src("a")],
[
TransientError("t1", status_code=429, retry_after_s=1.0),
TransientError("t2", status_code=429, retry_after_s=1.0),
_ok(),
],
)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_retry_exhausted_counts_every_attempt(self):
mw, _, _, transport, _, clock = _harness(
[_src("a")], [TransientError(str(i)) for i in range(5)], max_attempts=3
)
ctx = self._ctx(clock)
with pytest.raises(AllSourcesExhausted):
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_absent_context_does_not_break_the_call(self):
"""库内现场构造的 `ChatRequest` 没有上下文,不得因此报错(设计 §3.5)。"""
mw, _, _, _, _, _ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.content == "ok" and _REQ.call_context is None
+141
View File
@@ -610,3 +610,144 @@ class TestSourceConfigEffortNormalization:
"""非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。""" """非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。"""
with pytest.raises(ValueError, match="推理档位"): with pytest.raises(ValueError, match="推理档位"):
_make_source(reasoning_effort=3) _make_source(reasoning_effort=3)
class TestCallStatsAndContext:
"""逻辑调用统计内核(1.3.5 设计 §3/§4)。"""
def test_call_stats_is_frozen_snapshot(self):
from polygateway.types import CallStats
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
with pytest.raises(dataclasses.FrozenInstanceError):
stats.attempts = 3
def test_context_counts_attempts_and_freezes_elapsed(self):
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
from polygateway.types import _CallContext
clock = _FakeMonotonic()
ctx = _CallContext(now=clock)
clock.advance(1.5)
ctx.register_attempt()
ctx.register_attempt()
stats = ctx.snapshot()
assert stats.attempts == 2
assert stats.total_latency_ms == 1500 # 秒→毫秒,不混用单位
def test_snapshot_is_repeatable_and_tracks_later_time(self):
from polygateway.types import _CallContext
clock = _FakeMonotonic()
ctx = _CallContext(now=clock)
first = ctx.snapshot()
clock.advance(2.0)
second = ctx.snapshot()
assert first.total_latency_ms == 0 and second.total_latency_ms == 2000
assert first.logical_call_id == second.logical_call_id
def test_each_context_gets_its_own_logical_id(self):
from polygateway.types import _CallContext
clock = _FakeMonotonic()
assert _CallContext(now=clock).logical_call_id != _CallContext(now=clock).logical_call_id
def test_claim_terminal_is_true_once(self):
"""终态去重位: 保证每逻辑调用至多写一条终态行(设计 §6 不变量 I3)。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
assert ctx.claim_terminal() is True
assert ctx.claim_terminal() is False
def test_chat_request_context_does_not_affect_equality_or_repr(self):
"""上下文是库内部件: 进 `compare`/`repr` 会污染既有请求语义与日志。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
bare = ChatRequest(messages=[{"role": "user", "content": "hi"}])
with_ctx = dataclasses.replace(bare, call_context=ctx)
assert with_ctx.call_context is ctx
assert with_ctx == bare
assert "call_context" not in repr(with_ctx)
def test_replace_preserves_the_same_context_reference(self):
"""洋葱各层经 `replace` 派生请求,上下文必须是同一实例而非拷贝。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], call_context=ctx)
derived = dataclasses.replace(req, stream=False)
assert derived.call_context is ctx
def test_four_responses_default_call_stats_to_none(self):
"""第三方合成响应的 `None` 表示未知,不得伪造 0(设计 §3)。"""
from polygateway.types import (
EmbeddingResponse,
OcrLayoutResult,
OcrTextResult,
)
llm = LLMResponse(
content="c",
thinking="",
model="m",
provider="p",
prompt_tokens=1,
completion_tokens=1,
latency_ms=1,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="c1",
)
emb = EmbeddingResponse(
vectors=[],
dim=0,
model="m",
provider="p",
prompt_tokens=0,
usage_source="measured",
latency_ms=0,
call_id="c2",
source_name="s",
)
text = OcrTextResult(
text="", source_name="s", usage=Usage(0, 0), latency_ms=0, call_id="c3", raw={}
)
layout = OcrLayoutResult(
elements=[],
page_sizes=[],
source_name="s",
usage=Usage(0, 0),
latency_ms=0,
call_id="c4",
raw={},
)
assert (llm.call_stats, emb.call_stats, text.call_stats, layout.call_stats) == (
None,
None,
None,
None,
)
def test_call_stats_is_exported_from_package_root(self):
"""四份平铺字段会漂移,故统计以单一对象出现在公共 API(设计 §3)。"""
import polygateway
from polygateway.types import CallStats
assert polygateway.CallStats is CallStats
assert "CallStats" in polygateway.__all__
class _FakeMonotonic:
"""确定性单调钟;不复用 contracts 的 FakeClock 以免 unit 反向依赖契约包。"""
def __init__(self, start: float = 1000.0) -> None:
self.t = start
def __call__(self) -> float:
return self.t
def advance(self, seconds: float) -> None:
self.t += seconds