"""TelemetryMW + TelemetryEmitter: 遥测调用点收敛为单一 helper(铁律)。 Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制 15 参调用的教训)。分工: RetryMW 经 Emitter 逐次记录每次尝试;TelemetryMW (最外层)只记尝试层看不见的事件——缓存命中、scope 级失败、取消; RequestRejected/ResultInvalid 已被尝试层记录,最外层放行不重复记。 """ from __future__ import annotations import asyncio import json import time import uuid from dataclasses import dataclass from typing import TYPE_CHECKING from loguru import logger from polygateway.errors import ( GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError, ) from polygateway.middleware.cache import digest_messages from polygateway.types import canonical_sampling_json, merge_sampling if TYPE_CHECKING: from collections.abc import Callable, Mapping from typing import Any from polygateway.ports import CallNext, TelemetryRecorder from polygateway.pricing import PricingTable from polygateway.types import ChatRequest, LLMResponse, SourceConfig def _canonical_meta_json(meta: Mapping[str, Any]) -> str: """把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。 `sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重; `ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计。 `allow_nan=False` 是**第二道闸**(主防线是 `types.validate_caller_dimensions` 在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法 JSON。这道闸真正的价值在 **SQLite 侧**——PG 的 JSONB 本来就会拒收 `NaN`,而 SQLite 的 `meta` 是 TEXT 列**不做任何 JSON 校验**,没有这道闸就会把 `NaN` 这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析。 注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级 `try` 内被求值,异常会被那里的 `except Exception` 接住 → 落 warning、整行 遥测丢弃。即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"。 """ if not meta: return "{}" return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False) def _cap_text(text: str, cap: int | None) -> str: """超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。""" if cap is None or len(text) <= cap: return text return f"{text[:cap]}…(略 {len(text) - cap} 字)" def _cap_part(part: Any, cap: int) -> Any: """多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。""" if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str): return {**part, "text": _cap_text(part["text"], cap)} return part def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]: """对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。 非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。 **只产出新对象,严禁就地修改**: `digest_messages` 对 content 非 list 的消息是 原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url 的 part 同理。 就地改它会一并污染调用方持有的 messages、后续重试尝试的请求体与缓存写入的 key, 且全程无任何报错。 """ if cap is None: return messages capped: list[dict[str, Any]] = [] for msg in messages: content = msg.get("content") if isinstance(content, str): capped.append({**msg, "content": _cap_text(content, cap)}) elif isinstance(content, list): capped.append({**msg, "content": [_cap_part(part, cap) for part in content]}) else: capped.append(msg) return capped @dataclass(frozen=True) class _AttemptUsage: """一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。 存在的理由是把 `emit_attempt` 里逐字段重复的 `X if response else Y` 收敛为 一处判定——十处三元把该方法推到圈复杂度 C,而它们表达的是同一件事。 """ response_text: str = "" thinking: str = "" prompt_tokens: int = 0 completion_tokens: int = 0 usage_source: str = "unavailable" ttft_ms: float | None = None max_inter_token_ms: float | None = None cached_prompt_tokens: int | None = None model_reported: str | None = None reasoning_tokens: int | None = None @classmethod def of(cls, response: LLMResponse | None) -> _AttemptUsage: """从响应取用量;`None`(失败尝试)返回全默认视图。""" if response is None: return cls() return cls( response_text=response.content, thinking=response.thinking, prompt_tokens=response.prompt_tokens, completion_tokens=response.completion_tokens, usage_source=response.usage_source, ttft_ms=response.ttft_ms, max_inter_token_ms=response.max_inter_token_ms, cached_prompt_tokens=response.cached_prompt_tokens, model_reported=response.model_reported, reasoning_tokens=response.reasoning_tokens, ) class TelemetryEmitter: """从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。""" def __init__( self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None, text_cap: int | None, ) -> None: """`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。 本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。 同理,值域校验也放在这一处: 三个 Client 的 `text_cap` 全部汇流到这里, `GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一 条公共装配路——`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。 """ if text_cap is not None and text_cap <= 0: raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}") self._recorder = recorder self._pricing = pricing self._text_cap = text_cap async def emit_attempt( self, *, request: ChatRequest, source: SourceConfig, call_id: str, latency_ms: int, response: LLMResponse | None, error: str | None, ) -> None: """逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。""" usage = _AttemptUsage.of(response) await self._record( request=request, call_id=call_id, model=source.model, provider=source.provider, source_name=source.name, response_text=usage.response_text, thinking=usage.thinking, prompt_tokens=usage.prompt_tokens, completion_tokens=usage.completion_tokens, usage_source=usage.usage_source, latency_ms=latency_ms, ttft_ms=usage.ttft_ms, max_inter_token_ms=usage.max_inter_token_ms, cache_hit=False, error=error, cached_prompt_tokens=usage.cached_prompt_tokens, model_reported=usage.model_reported, reasoning_tokens=usage.reasoning_tokens, # 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D) sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)), tenant_id=request.tenant_id, meta=request.meta, ) async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None: """缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。""" await self._record( request=request, call_id=response.call_id, model=response.model, provider=response.provider, source_name=response.source_name, response_text=response.content, thinking=response.thinking, prompt_tokens=response.prompt_tokens, completion_tokens=response.completion_tokens, usage_source=response.usage_source, latency_ms=0, ttft_ms=None, max_inter_token_ms=None, cache_hit=True, error=None, # 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。 # 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。 cached_prompt_tokens=response.cached_prompt_tokens, model_reported=response.model_reported, reasoning_tokens=response.reasoning_tokens, # 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损: # sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同 sampling=canonical_sampling_json(request.sampling), # 与上面的 model/prompt_tokens 相反,维度读 request 而非 response: # 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用 # 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3) tenant_id=request.tenant_id, meta=request.meta, ) async def emit_terminal_failure( self, *, request: ChatRequest, call_id: str, latency_ms: int, error: str ) -> None: """scope 级失败/取消记录: 无具体源,溯源字段置空标记。""" await self._record( request=request, call_id=call_id, model="", provider="", source_name="", response_text="", thinking="", prompt_tokens=0, completion_tokens=0, usage_source="unavailable", latency_ms=latency_ms, ttft_ms=None, max_inter_token_ms=None, cache_hit=False, error=error, cached_prompt_tokens=None, model_reported=None, reasoning_tokens=None, # 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D) sampling=canonical_sampling_json(request.sampling), # 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的 tenant_id=request.tenant_id, meta=request.meta, ) async def _record( self, *, request: ChatRequest, call_id: str, model: str, provider: str, source_name: str, response_text: 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, cached_prompt_tokens: int | None, model_reported: str | None, sampling: str | None, reasoning_tokens: int | None, # issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库) tenant_id: str | None, meta: Mapping[str, Any], ) -> None: try: # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); # 失败/终态行 None;未注入价格表 = 恒 None(M1 现状) if cache_hit: cost: float | None = 0.0 elif usage_source == "unavailable": # 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。 # 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知 cost = None elif error is None and model and self._pricing is not None: cost = self._pricing.cost( model, prompt_tokens, completion_tokens, cached_prompt_tokens ) else: cost = None # messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12); # 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12) messages_json = json.dumps( _cap_messages(digest_messages(request.messages), self._text_cap), ensure_ascii=False, ) await self._recorder.record_llm_call( call_id=call_id, parent_call_id=request.parent_call_id, session_id=request.session_id, model=model, provider=provider, source_name=source_name, messages=messages_json, response=_cap_text(response_text, self._text_cap), thinking=_cap_text(thinking, self._text_cap), prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, usage_source=usage_source, latency_ms=latency_ms, ttft_ms=ttft_ms, max_inter_token_ms=max_inter_token_ms, cache_hit=cache_hit, error=error, cost=cost, cached_prompt_tokens=cached_prompt_tokens, model_reported=model_reported, sampling=sampling, reasoning_tokens=reasoning_tokens, # 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下 # 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行 tenant_id=tenant_id or "", meta=_canonical_meta_json(meta), ) except asyncio.CancelledError: raise except Exception as exc: logger.warning("遥测记录失败(降级不冒泡): {}", exc) class TelemetryMW: """洋葱最外层: 观测尝试层看不见的路径,任何路径都留痕(遥测必录)。""" def __init__( self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic ) -> None: self._emitter = emitter self._now = now async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse: started = self._now() try: response = await call_next(request) except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError) as exc: await self._emitter.emit_terminal_failure( request=request, call_id=str(uuid.uuid4()), latency_ms=int((self._now() - started) * 1000), error=str(exc), ) raise except asyncio.CancelledError: # 尽力而为: 取消也留痕(§5.1 约定④);随后立即重抛 await self._emitter.emit_terminal_failure( request=request, call_id=str(uuid.uuid4()), latency_ms=int((self._now() - started) * 1000), error="cancelled", ) raise if response.cache_hit: await self._emitter.emit_cache_hit(request=request, response=response) return response