feat: warn when the capability table and reality disagree

The M3 evidence sat at 08-02 for twenty-three days while nobody could
tell whether it still held. A declaration that goes stale in silence is
the failure this issue is really about, so the library now compares
what it declared against what it just observed and says so when the two
part ways.

Judgement is separated from logging: reconcile_thinking returns the
warning text, so tests assert on the text instead of parsing logs.
Two cases that look alike are kept apart — a model whose capability is
registered gets a drift warning quoting its evidence, an unregistered
one is never told the table said anything, because it never did.

False x UNKNOWN stays silent on purpose. UNKNOWN cannot falsify
anything, and warning on it would fire on every disabled call M3 makes
over the plain endpoint. A warning that always fires is not a warning.
This commit is contained in:
2026-08-26 00:23:57 -04:00
parent 3e869b9b39
commit 20a4a9ae47
4 changed files with 260 additions and 2 deletions
+31 -2
View File
@@ -14,6 +14,7 @@ import time
from typing import TYPE_CHECKING, Any
import httpx
from loguru import logger
from polygateway.errors import (
PolyGatewayError,
@@ -29,6 +30,7 @@ from polygateway.thinking import (
ThinkingUnsupportedError,
get_capability,
observe_thinking,
reconcile_thinking,
resolve_thinking,
)
from polygateway.transports._http_errors import compose_message, summarize_body
@@ -320,6 +322,9 @@ class OpenAICompatTransport:
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
self._warned_models: set[str] = set()
# 对账告警独立节流,**不复用** `_warned_models`: 那个 set 的语义是"未登记
# 能力已告警过",两件事共用一个开关会互相压制——一方喊过就把另一方静音
self._warned_mismatches: set[tuple[str, bool | None]] = set()
self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {}
@@ -390,8 +395,9 @@ class OpenAICompatTransport:
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
try:
if stream:
return await self._complete_stream(client, url, payload, source, profile)
return await self._complete_once(client, url, payload, source, profile)
result = await self._complete_stream(client, url, payload, source, profile)
else:
result = await self._complete_once(client, url, payload, source, profile)
except StreamLivenessTimeout as exc:
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
except httpx.TimeoutException as exc:
@@ -399,6 +405,29 @@ class OpenAICompatTransport:
except httpx.TransportError as exc:
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
# 此处是唯一同时握有请求方向与响应结果的地方,对账只能落在这里
self._warn_on_thinking_mismatch(source, result)
return result
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
"""声明与观测矛盾即 warning;按 (model, direction) 节流,同组合只喊一次。
节流键必须含方向: 同一模型的开、关两档是两个独立的矛盾,合并键会让先出现
的那一档把另一档永久静音。逐次调用刷屏会把告警变成噪声,噪声等于没有告警。
"""
message = reconcile_thinking(
enable_thinking=source.enable_thinking,
observation=result.thinking_observation,
capability=get_capability(source.model, table=self._capabilities),
model=source.model,
)
if message is None:
return
key = (source.model, source.enable_thinking)
if key in self._warned_mismatches:
return
self._warned_mismatches.add(key)
logger.warning(message)
async def embed(
self, *, texts: list[str], source: SourceConfig, call_id: str