feat: let chat() take a tenant and caller-defined dimensions
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.
This commit is contained in:
@@ -34,7 +34,12 @@ from polygateway.sources import (
|
|||||||
SourceCooldownMemo,
|
SourceCooldownMemo,
|
||||||
)
|
)
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Iterable, Mapping
|
from collections.abc import Awaitable, Iterable, Mapping
|
||||||
@@ -205,12 +210,18 @@ class GatewayClient:
|
|||||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||||
stream: bool = True,
|
stream: bool = True,
|
||||||
overlay: Mapping[str, Any] | None = None,
|
overlay: Mapping[str, Any] | None = None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
meta: Mapping[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||||
|
|
||||||
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||||
参数不影响既有调用点(issue #4)。
|
参数不影响既有调用点(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:
|
if structured is not None and not self._structured_available:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
@@ -221,6 +232,12 @@ class GatewayClient:
|
|||||||
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
|
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
|
||||||
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
|
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
|
||||||
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
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(
|
request = ChatRequest(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -231,6 +248,8 @@ class GatewayClient:
|
|||||||
stream=stream,
|
stream=stream,
|
||||||
overlay=sampling,
|
overlay=sampling,
|
||||||
sampling=sampling,
|
sampling=sampling,
|
||||||
|
tenant_id=dimension_tenant_id,
|
||||||
|
meta=dimensions,
|
||||||
)
|
)
|
||||||
return await self._handler(request)
|
return await self._handler(request)
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,62 @@ class TestSamplingOverlay:
|
|||||||
assert [c["seed"] for c in captured] == [1, 2]
|
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:
|
class TestModelFingerprint:
|
||||||
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
|
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user