feat: track logical call statistics across governed calls
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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、遥测、缓存、限流/熔断后端。
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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` = 未知。"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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 == [] # 零遥测行
|
||||||
|
|||||||
@@ -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"")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user