From 4be2b4f287f3f3b6cbf4a129f40c3ad999883b19 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 17 Aug 2026 09:43:51 -0400 Subject: [PATCH] feat: let chat() take a tenant and caller-defined dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation runs before the request enters the onion: every failure inside it is downgraded to a warning by the telemetry layer, so validating in there would not validate anything. The dimensions stay out of the cache key — cache_namespace already carries tenant isolation, and folding meta in would cold-start every existing entry. --- src/polygateway/client.py | 21 ++++++++++++++- tests/unit/test_client.py | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 2520f00..e1413b4 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -34,7 +34,12 @@ from polygateway.sources import ( SourceCooldownMemo, ) from polygateway.transports.openai_compat import OpenAICompatTransport -from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay +from polygateway.types import ( + ChatRequest, + LLMResponse, + validate_caller_dimensions, + validate_request_overlay, +) if TYPE_CHECKING: from collections.abc import Awaitable, Iterable, Mapping @@ -205,12 +210,18 @@ class GatewayClient: structured: type[BaseModel] | Literal["json"] | None = None, stream: bool = True, overlay: Mapping[str, Any] | None = None, + tenant_id: str | None = None, + meta: Mapping[str, Any] | None = None, ) -> LLMResponse: """一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。 `overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级 高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only 参数不影响既有调用点(issue #4)。 + + `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key** + (租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇 + (可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。 """ if structured is not None and not self._structured_available: raise ImportError( @@ -221,6 +232,12 @@ class GatewayClient: # 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入, # 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E) sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)") + # 同理必须在洋葱之外: 洋葱内的一切失败都被遥测层降级成 warning(库铁律 + # 「遥测写失败降级不冒泡」),校验放里面等于没有校验——非法维度会变成 + # 静默丢失的遥测行,而调用照常发出(issue #11 §4.2) + dimension_tenant_id, dimensions = validate_caller_dimensions( + tenant_id, meta, origin="chat(tenant_id=..., meta=...)" + ) request = ChatRequest( messages=messages, session_id=session_id, @@ -231,6 +248,8 @@ class GatewayClient: stream=stream, overlay=sampling, sampling=sampling, + tenant_id=dimension_tenant_id, + meta=dimensions, ) return await self._handler(request) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index c34a244..d123285 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -184,6 +184,62 @@ class TestSamplingOverlay: assert [c["seed"] for c in captured] == [1, 2] +class _MemoryRecorder: + """收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。""" + + def __init__(self): + self.rows = [] + + async def record_llm_call(self, **fields): + self.rows.append(fields) + + +class TestCallerDimensions: + """调用方自定义维度进遥测(issue #11 Task 4)。""" + + _MSG = [{"role": "user", "content": "hi"}] + + async def test_dimensions_reach_telemetry_row(self): + recorder = _MemoryRecorder() + async with _client(telemetry=recorder) as client: + await client.chat(self._MSG, tenant_id="t1", meta={"batch": "b-42"}) + row = recorder.rows[-1] + assert row["tenant_id"] == "t1" + assert json.loads(row["meta"]) == {"batch": "b-42"} + + async def test_default_path_writes_sentinels(self): + """不传两参数时落哨兵值而非 NULL(§4.4: NULL 在 RLS 下是永久不可见的黑洞)。""" + recorder = _MemoryRecorder() + async with _client(telemetry=recorder) as client: + await client.chat(self._MSG) + row = recorder.rows[-1] + assert row["tenant_id"] == "" and row["meta"] == "{}" + + async def test_invalid_meta_key_rejected_before_any_telemetry(self): + """校验早于遥测(§4.2 核心承诺): 放进洋葱就会被降级成 warning 而调用照常发出。""" + recorder = _MemoryRecorder() + async with _client(telemetry=recorder) as client: + with pytest.raises(ValueError, match="meta"): + await client.chat(self._MSG, meta={"BAD-KEY": 1}) + assert recorder.rows == [] + + async def test_non_finite_float_rejected_before_any_telemetry(self): + """nan 产出的是 PG 拒收的非法 JSON;放行等于把调用方 bug 变成静默丢遥测(§6)。""" + recorder = _MemoryRecorder() + async with _client(telemetry=recorder) as client: + with pytest.raises(ValueError, match="nan"): + await client.chat(self._MSG, meta={"k": float("nan")}) + assert recorder.rows == [] + + async def test_meta_does_not_enter_cache_key(self): + """仅 meta 不同必须仍命中缓存(F1): 进 key 会让存量缓存全量冷启动且不报错。""" + client = _client(cache=InMemoryCache(), cache_namespace="proj", cache_ttl_s=3600) + async with client: + first = await client.chat(self._MSG, meta={"batch": "b-1"}) + second = await client.chat(self._MSG, meta={"batch": "b-2"}) + assert first.cache_hit is False and second.cache_hit is True + + class TestModelFingerprint: """配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""