diff --git a/research-wiki/designs/2026-07-31-response-observability-fields-design.md b/research-wiki/designs/2026-07-31-response-observability-fields-design.md index e19cd38..f70f190 100644 --- a/research-wiki/designs/2026-07-31-response-observability-fields-design.md +++ b/research-wiki/designs/2026-07-31-response-observability-fields-design.md @@ -38,7 +38,15 @@ Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核 **选 A2**。`TransportResult` 是库内部流转类型(非三项目消费面),但仍按「新增必带默认值」处理,使 `openai_compat` 之外的构造点零改动;全库该类型仅 2 处构造(`openai_compat.py:354/436`)。 -解析纪律(P5 一切外部输入校验后使用):`cached_tokens` 与 `model` 均来自网关响应,类型不可信。取值走一个防御 helper——非 `int`/非正、非 `str`/空串一律归 `None`,不抛异常(可观测字段缺失绝不能打断主路径)。 +解析纪律(P5 一切外部输入校验后使用):`cached_tokens` 与 `model` 均来自网关响应,类型不可信。取值走防御 helper,不抛异常(可观测字段缺失绝不能打断主路径): + +| 输入 | 结果 | +|---|---| +| `cached_tokens` 为非负 `int`(**含 `0`**) | 如实保留——`0` 是「该源上报了一次真实零命中」,与「未上报」的 `None` 语义不同,这正是本 issue 的核心诉求 | +| `cached_tokens` 为负数 / 非 `int` / `bool` | `None`(`bool` 必须显式排除:`isinstance(True, int)` 在 Python 里为真) | +| `usage` 或 `prompt_tokens_details` 非 dict | `None` | +| `model` 为非空 `str` | 保留 | +| `model` 为非 `str` / 空白串 | `None` | ## 3. 决策 B:缓存命中回放时两字段取什么值 diff --git a/research-wiki/plans/2026-07-31-response-observability-fields.md b/research-wiki/plans/2026-07-31-response-observability-fields.md index 36d324f..f7bad2f 100644 --- a/research-wiki/plans/2026-07-31-response-observability-fields.md +++ b/research-wiki/plans/2026-07-31-response-observability-fields.md @@ -59,7 +59,7 @@ def _coerce_model_reported(value: Any) -> str | None: """响应体 model 字段: 非空 str 才收,其余(含空串/非 str)→ None。""" ``` -`_coerce_cached_tokens` 需容忍:`usage` 为 None、`prompt_tokens_details` 缺失或非 dict、`cached_tokens` 为 `bool`/`str`/负数/浮点。`bool` 必须排除(Python 中 `isinstance(True, int)` 为真)。 +`_coerce_cached_tokens` 需容忍:`usage` 为 None、`prompt_tokens_details` 缺失或非 dict、`cached_tokens` 为 `bool`/`str`/负数/浮点。`bool` 必须排除(Python 中 `isinstance(True, int)` 为真)。**`0` 必须如实保留而非归 None**——真实零命中与未上报是两回事,这是 issue 的核心诉求。 2. 流式路径:`_sse_delta`(`:44-47`)当前只把 `usage` 旁路进 sink。补一条——chunk 里出现 `model` 时写 `usage_sink["model"]`(**首次写入即固定**,后续 chunk 不覆盖,避免末帧异常值污染)。`_stream_once` 的 `TransportResult` 构造(`:354`)填 `cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage"))`、`model_reported=_coerce_model_reported(sink.get("model"))`。 @@ -74,7 +74,9 @@ def _coerce_model_reported(value: Any) -> str | None: | 非流式 usage 含 `prompt_tokens_details.cached_tokens: 128` | `cached_prompt_tokens == 128` | | 流式 usage 帧同上 | 同上 | | 无 `prompt_tokens_details` / usage 帧缺失 | `None` | -| `cached_tokens` 为 `"abc"` / `-1` / `True` / `1.5` | `None`,且**不抛异常** | +| `cached_tokens` 为 `"abc"` / `-1` / `True` / `1.5` / `[]` / dict | `None`,且**不抛异常** | +| `cached_tokens` 为 `0` | `0`(真实零命中,**不得**归 None) | +| `prompt_tokens_details` 非 dict | `None` | | 非流式 body 含 `model: "MiniMax-Text-01-250321"` | `model_reported` 为该串 | | 流式首个含 model 的 chunk 后又出现不同 model | 取**首个** | | body 无 `model` / `model` 为 `""` | `None` |