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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user