feat: wire the telemetry text cap through settings
`PGW_TELEMETRY_TEXT_CAP` now reaches the emitter on every assembly path. Unset means no truncation, which stays the default: a truncated row is no longer audit evidence and cannot be replayed, and downstreams rely on that today. The flip side — contracts and bids sitting in `llm_calls` indefinitely, multi-tenant — is spelled out in `.env.example` so readers can weigh both. All three `from_settings` paths are wired (chat, embedding, OCR): they write the same table, so capping only chat would leave half of it uncontrolled. `TelemetryEmitter.__init__` now rejects `text_cap <= 0`; it is the single point where the three clients converge, so the direct construction path — a public assembly route the settings guard never sees — is covered too. `0` would otherwise reduce every body to a bare elision marker.
This commit is contained in:
@@ -351,6 +351,97 @@ class TestFactories:
|
||||
assert isinstance(client, GatewayClient)
|
||||
|
||||
|
||||
class TestTelemetryTextCapWiring:
|
||||
"""`PGW_TELEMETRY_TEXT_CAP` 必须走通全部三条 `from_settings` 装配路(issue #12)。
|
||||
|
||||
三条链路写的是**同一张** `llm_calls` 表:只接通 chat,embed 与 OCR 的行就
|
||||
永远不受 cap 约束,同表内一半受控一半不受控——那正是本 issue 要消灭的状态。
|
||||
"""
|
||||
|
||||
_CAP_ENV = dict(_ENV, PGW_TELEMETRY_TEXT_CAP="8")
|
||||
_OCR_CAP_ENV = {
|
||||
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
||||
"OCR__MONKEY__1__API_KEY": "none",
|
||||
"OCR__MONKEY__1__MODEL": "monkey-ocr",
|
||||
"OCR__MONKEY__1__TIMEOUT_S": "120",
|
||||
"LLM_MAX_RETRIES": "3",
|
||||
"LLM_RETRY_BASE_DELAY": "2.0",
|
||||
"LLM_RETRY_MAX_DELAY": "30.0",
|
||||
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
|
||||
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
|
||||
"PGW_CACHE_BACKEND": "none",
|
||||
"PGW_TELEMETRY_BACKEND": "none",
|
||||
"PGW_TELEMETRY_TEXT_CAP": "8",
|
||||
}
|
||||
|
||||
def test_gateway_from_settings_wires_the_cap(self):
|
||||
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
client = GatewayClient.from_settings(settings, telemetry=_MemoryRecorder())
|
||||
assert client._terminal._emitter._text_cap == 8
|
||||
# 对照组: 不设该键时 emitter 拿到的必须是 None,否则 8 可能是硬编码来的
|
||||
unset = GatewayClient.from_settings(
|
||||
GatewaySettings.from_env("LLM", env=_ENV), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert unset._terminal._emitter._text_cap is None
|
||||
|
||||
def test_embedding_from_settings_wires_the_cap(self):
|
||||
from polygateway.config import EmbeddingSettings
|
||||
from polygateway.embedding import EmbeddingClient
|
||||
|
||||
gateway = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
client = EmbeddingClient.from_settings(
|
||||
EmbeddingSettings(gateway=gateway, batch_size=2), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert client._emitter._text_cap == 8
|
||||
unset = EmbeddingClient.from_settings(
|
||||
EmbeddingSettings(gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2),
|
||||
telemetry=_MemoryRecorder(),
|
||||
)
|
||||
assert unset._emitter._text_cap is None
|
||||
|
||||
def test_ocr_from_settings_wires_the_cap(self):
|
||||
from polygateway.config import OcrSettings
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
settings = OcrSettings.from_env("OCR", env=dict(self._OCR_CAP_ENV))
|
||||
client = OcrClient.from_settings(settings, telemetry=_MemoryRecorder())
|
||||
assert client._emitter._text_cap == 8
|
||||
no_cap = dict(self._OCR_CAP_ENV)
|
||||
no_cap.pop("PGW_TELEMETRY_TEXT_CAP")
|
||||
unset = OcrClient.from_settings(
|
||||
OcrSettings.from_env("OCR", env=no_cap), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert unset._emitter._text_cap is None
|
||||
|
||||
async def test_capped_body_reaches_the_recorder_end_to_end(self, monkeypatch):
|
||||
"""装配路通了还不够: 真跑一次 chat,落库的 messages 与 response 确已截断。
|
||||
|
||||
`from_settings` 自建 transport(没有 client_factory 入口),故在装配点
|
||||
换掉该类以接上 MockTransport——洋葱其余各层仍是 `from_settings` 装的真件。
|
||||
"""
|
||||
recorder = _MemoryRecorder()
|
||||
long_text = "甲乙丙丁戊己庚辛壬癸" # 10 字,cap=8 → 略 2 字
|
||||
monkeypatch.setattr(
|
||||
"polygateway.client.OpenAICompatTransport",
|
||||
lambda **kwargs: OpenAICompatTransport(
|
||||
client_factory=lambda source: httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(lambda request: _sse(content=long_text))
|
||||
)
|
||||
),
|
||||
)
|
||||
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
async with GatewayClient.from_settings(settings, telemetry=recorder) as client:
|
||||
await client.chat([{"role": "user", "content": long_text}])
|
||||
row = recorder.rows[-1]
|
||||
assert json.loads(row["messages"])[0]["content"] == "甲乙丙丁戊己庚辛…(略 2 字)"
|
||||
assert row["response"] == "甲乙丙丁戊己庚辛…(略 2 字)"
|
||||
|
||||
def test_non_positive_cap_rejected_on_the_direct_construction_path(self):
|
||||
"""直接构造是库承诺的另一条公共装配路;cap=0 会让每条正文只剩省略标记。"""
|
||||
with pytest.raises(ValueError, match="text_cap"):
|
||||
_client(telemetry=_MemoryRecorder(), text_cap=0)
|
||||
|
||||
|
||||
class TestSharedBackend:
|
||||
async def test_two_clients_share_global_concurrency_gate(self):
|
||||
"""VT R5: 两个逻辑角色显式注入同一 limiter → 共享全局并发闸。"""
|
||||
|
||||
Reference in New Issue
Block a user