diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index 2022b70..3125a37 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -44,7 +44,7 @@ class TelemetryEmitter: response: LLMResponse | None, error: str | None, ) -> None: - """逐次尝试记录(RetryMW 调用);失败尝试 usage 按 estimated 记 0。""" + """逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。""" await self._record( request=request, call_id=call_id, @@ -55,7 +55,7 @@ class TelemetryEmitter: thinking=response.thinking if response else "", prompt_tokens=response.prompt_tokens if response else 0, completion_tokens=response.completion_tokens if response else 0, - usage_source=response.usage_source if response else "estimated", + usage_source=response.usage_source if response else "unavailable", latency_ms=latency_ms, ttft_ms=response.ttft_ms if response else None, max_inter_token_ms=response.max_inter_token_ms if response else None, @@ -97,7 +97,7 @@ class TelemetryEmitter: thinking="", prompt_tokens=0, completion_tokens=0, - usage_source="estimated", + usage_source="unavailable", latency_ms=latency_ms, ttft_ms=None, max_inter_token_ms=None, @@ -129,6 +129,10 @@ class TelemetryEmitter: # 失败/终态行 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) else: diff --git a/tests/unit/test_ocr_client.py b/tests/unit/test_ocr_client.py index 2759b24..9d9cc54 100644 --- a/tests/unit/test_ocr_client.py +++ b/tests/unit/test_ocr_client.py @@ -394,6 +394,21 @@ class TestTelemetry: assert recorder.rows[1]["error"] is None assert recorder.rows[1]["prompt_tokens"] == 0 + async def test_success_row_stays_measured_and_settles_zero(self): + """OCR 的 0 token 是**事实**而非未知(est_tokens 解耦设计 §3.3 剔出决定)。 + + 三态化不得把 OCR 成功行改成 `unavailable`——那会灌水缺口度量 + `COUNT(*) WHERE usage_source='unavailable'`;settle 恒 0 的差异①同样不动。 + """ + recorder = _MemoryRecorder() + client, limiter, _ = _client([_src(tpm=1000, est_tokens=400)], ["text"], telemetry=recorder) + await client.recognize_text(b"jpg") + row = recorder.rows[0] + assert row["usage_source"] == "measured" + assert row["prompt_tokens"] == 0 and row["completion_tokens"] == 0 + assert row["error"] is None + assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣 + class TestAssembly: _ENV = { diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index eaae1af..59d7e2a 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -9,6 +9,7 @@ import pytest from polygateway.errors import CircuitOpenError, RequestRejectedError from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW +from polygateway.pricing import ModelPrice, PricingTable from polygateway.telemetry.sqlite import SQLiteRecorder from polygateway.types import ChatRequest, LLMResponse, SourceConfig @@ -68,6 +69,10 @@ def _source(): ) +# 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032 +_PRICING = PricingTable({"m": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)}) + + async def _record_minimal(recorder, call_id="c1", **overrides): fields = { "call_id": call_id, @@ -168,7 +173,58 @@ class TestEmitter: ) row = rec.rows[0] assert row["error"].startswith("TransientError") - assert row["response"] == "" and row["usage_source"] == "estimated" + # 失败尝试没有任何用量信息可言 → unavailable(设计 §3.2 #6) + assert row["response"] == "" and row["usage_source"] == "unavailable" + assert row["cost"] is None + + async def test_terminal_failure_row_is_unavailable(self): + rec = _MemoryRecorder() + await TelemetryEmitter(rec, pricing=_PRICING).emit_terminal_failure( + request=_REQ, call_id="cid-t", latency_ms=5, error="cancelled" + ) + row = rec.rows[0] + assert row["usage_source"] == "unavailable" and row["cost"] is None + + @pytest.mark.parametrize(("prompt", "completion"), [(0, 0), (0, 4000)]) + async def test_unavailable_success_row_has_null_cost(self, prompt, completion): + """产生了真实调用但用量不可得 → cost 记 NULL(设计 §3.1 不变式)。 + + 参数第二组是改前兜底写出的 `0/4000` 形态: 那时换算出 0.032 的假金额。 + """ + rec = _MemoryRecorder() + await TelemetryEmitter(rec, pricing=_PRICING).emit_attempt( + request=_REQ, + source=_source(), + call_id="cid-u", + latency_ms=42, + response=_resp( + usage_source="unavailable", prompt_tokens=prompt, completion_tokens=completion + ), + error=None, + ) + assert rec.rows[0]["cost"] is None + + async def test_measured_row_still_priced(self): + """对照组: 同一价格表下 measured 行照常换算,证明 None 不是价格表没接上。""" + rec = _MemoryRecorder() + await TelemetryEmitter(rec, pricing=_PRICING).emit_attempt( + request=_REQ, + source=_source(), + call_id="cid-m", + latency_ms=42, + response=_resp(prompt_tokens=0, completion_tokens=4000), + error=None, + ) + assert rec.rows[0]["cost"] == pytest.approx(0.032) + + async def test_cache_hit_keeps_zero_cost_even_when_unavailable(self): + """缓存命中未产生新调用,0.0 是事实而非未知 → 短路必须排在 cache_hit 之后。""" + rec = _MemoryRecorder() + await TelemetryEmitter(rec, pricing=_PRICING).emit_cache_hit( + request=_REQ, + response=_resp(cache_hit=True, usage_source="unavailable", completion_tokens=4000), + ) + assert rec.rows[0]["cache_hit"] is True and rec.rows[0]["cost"] == 0.0 async def test_multimodal_messages_digested_before_storage(self): rec = _MemoryRecorder()