feat: cap telemetry bodies at a configurable length

Chat rows stored full message and response text with no upper bound, so
downstream contracts and tenders lived in llm_calls indefinitely. Add
_cap_text/_cap_messages in the single telemetry exit (_record), applied
after digest_messages and before json.dumps, plus to response/thinking.

Capping is per text, not over the serialized JSON: cutting the whole
string would emit invalid JSON into an unvalidated TEXT column. The cap
builds new dicts and never mutates in place — digest_messages passes
non-list content straight through as the same object, so an in-place cut
would silently poison the caller's messages and the cache key.

text_cap is required on TelemetryEmitter (internal class, three known
construction sites) and defaults to None on the three public clients, so
the default behaviour stays byte-for-byte identical. Settings wiring
lands separately.
This commit is contained in:
2026-08-19 13:39:17 -04:00
parent e0a33ecf93
commit 33ed7ecdfc
9 changed files with 352 additions and 51 deletions
+6 -1
View File
@@ -136,6 +136,7 @@ class GatewayClient:
quota_full: str = "wait",
telemetry: TelemetryRecorder | None = None,
pricing: PricingTable | None = None,
text_cap: int | None = None,
cache: CacheBackend | None = None,
cache_namespace: str | None = None,
cache_ttl_s: int | None = None,
@@ -146,7 +147,11 @@ class GatewayClient:
sleep: Any = asyncio.sleep,
rng: Any = random.random,
) -> None:
emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry is not None else None
emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
if telemetry is not None
else None
)
terminal = RetryMW(
scope=scope,
sources=sources,
+4 -1
View File
@@ -104,6 +104,7 @@ class EmbeddingClient:
quota_full: str = "wait",
telemetry: TelemetryRecorder | None = None,
pricing: PricingTable | None = None,
text_cap: int | None = None,
batch_size: int,
normalize: bool = False,
expected_dim: int | None = None,
@@ -128,7 +129,9 @@ class EmbeddingClient:
self._retry = retry
self._bp = backpressure
self._quota_full = quota_full
self._emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry else None
self._emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
)
self._telemetry = telemetry
self._pricing = pricing
self._batch_size = batch_size
+58 -5
View File
@@ -55,6 +55,44 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
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)。
@@ -96,9 +134,20 @@ class _AttemptUsage:
class TelemetryEmitter:
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
def __init__(
self,
recorder: TelemetryRecorder,
*,
pricing: PricingTable | None = None,
text_cap: int | None,
) -> None:
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
"""
self._recorder = recorder
self._pricing = pricing
self._text_cap = text_cap
async def emit_attempt(
self,
@@ -241,8 +290,12 @@ class TelemetryEmitter:
)
else:
cost = None
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
messages_json = json.dumps(digest_messages(request.messages), ensure_ascii=False)
# 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,
@@ -251,8 +304,8 @@ class TelemetryEmitter:
provider=provider,
source_name=source_name,
messages=messages_json,
response=response_text,
thinking=thinking,
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,
+2 -1
View File
@@ -109,6 +109,7 @@ class OcrClient:
backpressure: BackpressurePolicy,
quota_full: str = "wait",
telemetry: TelemetryRecorder | None = None,
text_cap: int | None = None,
now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random,
@@ -127,7 +128,7 @@ class OcrClient:
self._retry = retry
self._bp = backpressure
self._quota_full = quota_full
self._emitter = TelemetryEmitter(telemetry) if telemetry else None
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
self._telemetry = telemetry
self._memo = SourceCooldownMemo(now=now)
self._now = now