feat: accept sampling overlay on chat() and per-source extra_body

Priority is structured injection > per-call overlay > per-source config,
and extra_body now takes part in the cache fingerprint.
This commit is contained in:
2026-07-31 21:17:49 -04:00
parent 152fa264ed
commit 6bb64ca938
4 changed files with 167 additions and 5 deletions
+42 -5
View File
@@ -9,6 +9,8 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import random
import time
from typing import TYPE_CHECKING, Any, Literal, TypeVar
@@ -32,7 +34,7 @@ from polygateway.sources import (
SourceCooldownMemo,
)
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ChatRequest, LLMResponse
from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay
if TYPE_CHECKING:
from collections.abc import Awaitable, Iterable, Mapping
@@ -59,6 +61,29 @@ if TYPE_CHECKING:
_T = TypeVar("_T")
def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
后重启仍会读到旧缓存(issue #4 设计决策 C)。全源 `extra_body` 皆空时
字面量与历史实现逐字相同,不触发存量缓存冷启动。
"""
fingerprint = ",".join(sorted({s.model for s in sources}))
# 按 (model, extra_body) 而非源名摘要: 语义是"本 scope 会用哪些
# (模型, 解码参数)组合",改源名不该误触全量冷启动
marks = sorted(
{
json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False)
for s in sources
if s.extra_body
}
)
if marks:
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
fingerprint = f"{fingerprint}|{digest}"
return fingerprint
class GatewayClient:
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
@@ -113,12 +138,11 @@ class GatewayClient:
if cache is not None:
if cache_namespace is None or cache_ttl_s is None:
raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s")
# 多源 scope 的 key 身份 = 排序去重的 model 合集;源集合变化 → 一次性冷启动
fingerprint = ",".join(sorted({s.model for s in sources}))
# 多源 scope 的 key 身份;源集合或其 extra_body 变化 → 一次性冷启动
middlewares.append(
CacheMW(
backend=cache,
model_fingerprint=fingerprint,
model_fingerprint=build_model_fingerprint(sources),
default_namespace=cache_namespace,
ttl_s=cache_ttl_s,
strategy=structured_strategy,
@@ -150,12 +174,23 @@ class GatewayClient:
cache_namespace: str | None = None,
structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True,
overlay: Mapping[str, Any] | None = None,
) -> LLMResponse:
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。"""
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
参数不影响既有调用点(issue #4)。
"""
if structured is not None and not self._structured_available:
raise ImportError(
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
)
# 进洋葱之前校验并拷贝: 保护键/不可序列化值在此收口(否则会在 CacheMW
# 的降级 try 之外抛裸 TypeError);拷贝防调用方复用同一 dict 逐次改 seed
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
request = ChatRequest(
messages=messages,
session_id=session_id,
@@ -164,6 +199,8 @@ class GatewayClient:
cache_namespace=cache_namespace,
structured=structured,
stream=stream,
overlay=sampling,
sampling=sampling,
)
return await self._handler(request)