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.
This commit is contained in:
2026-08-26 00:29:26 -04:00
parent ab1c47ebcc
commit 56acb8f3ac
7 changed files with 146 additions and 30 deletions
+17 -2
View File
@@ -23,7 +23,7 @@ from polygateway.errors import (
SourceNotConfiguredError,
)
from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling
from polygateway.types import ThinkingObservation, canonical_sampling_json, merge_sampling
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
@@ -111,6 +111,9 @@ class _AttemptUsage:
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
@classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -128,11 +131,12 @@ class _AttemptUsage:
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
thinking_observation=response.thinking_observation,
)
class TelemetryEmitter:
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
"""从请求与结果组装 25 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(
self,
@@ -185,6 +189,7 @@ class TelemetryEmitter:
cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens,
thinking_observation=usage.thinking_observation,
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
tenant_id=request.tenant_id,
@@ -214,6 +219,8 @@ class TelemetryEmitter:
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
thinking_observation=response.thinking_observation,
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling),
@@ -247,6 +254,8 @@ class TelemetryEmitter:
cached_prompt_tokens=None,
model_reported=None,
reasoning_tokens=None,
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
thinking_observation=ThinkingObservation.UNKNOWN,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling),
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
@@ -276,6 +285,8 @@ class TelemetryEmitter:
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
# issue #16: 枚举形态进来,取 `.value` 后才下沉(归一化同样在本方法内收口)
thinking_observation: ThinkingObservation,
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None,
meta: Mapping[str, Any],
@@ -328,6 +339,10 @@ class TelemetryEmitter:
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
tenant_id=tenant_id or "",
meta=_canonical_meta_json(meta),
# 取 `.value` 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数
# 编码对子类不保证接受,而遥测写失败只降级成一条 warning——不会当场
# 炸,只会让 Postgres 那一路悄悄少一列数据
thinking_observation=thinking_observation.value,
)
except asyncio.CancelledError:
raise