feat: model the thinking switch as shape plus capability (issue #5)

enable_thinking=False was a no-op for minimax and openai sources: both
profiles had empty dicts on each side, so the payload update injected
nothing while the caller believed reasoning had been turned off. A
downstream project was blocked on exactly this.

The root cause is that an empty dict meant two different things -- "no
injection needed" and "we do not know how this provider spells it" --
and that a provider-level table cannot express what turned out to be a
per-model property. Live testing showed MiniMax-M3 can disable
reasoning via reasoning_effort while M2.7 and M2.5 cannot be disabled
at all, which two external registries independently confirm.

So the shape stays at provider level and a capability table joins it at
model level. Unknown, unsupported and no-opinion are now three distinct
values, and resolve_thinking is the single place they meet: it raises at
assembly time when a model cannot honour the request, warns and injects
for unregistered models, and injects silently otherwise. Every registered
capability carries the evidence it was derived from.

enable_thinking also joins the cache fingerprint, since it now really
does change the request body.
This commit is contained in:
2026-08-02 06:20:24 -04:00
parent 89ff916bc8
commit 82f4ec4910
7 changed files with 439 additions and 40 deletions
+46 -12
View File
@@ -25,7 +25,7 @@ from polygateway.middleware.retry import RetryMW
from polygateway.middleware.structured import StructuredMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
from polygateway.pricing import PricingTable
from polygateway.providers import get_provider
from polygateway.providers import get_capability, get_provider, resolve_thinking
from polygateway.sources import (
AdaptivePacer,
HealthAwareSelector,
@@ -51,7 +51,7 @@ if TYPE_CHECKING:
TelemetryRecorder,
Transport,
)
from polygateway.providers import ProviderProfile
from polygateway.providers import ProviderProfile, ThinkingCapability
from polygateway.types import (
BackpressurePolicy,
RetryPolicy,
@@ -61,22 +61,52 @@ if TYPE_CHECKING:
_T = TypeVar("_T")
def _guard_thinking(
sources: list[SourceConfig],
profiles: list[ProviderProfile],
capabilities: Mapping[str, ThinkingCapability] | None,
) -> None:
"""装配期把不可满足的推理开关炸掉,而不是留到运行时(issue #5)。
与 transport 内的同一次判定不是重复: 那里兜的是"构造函数全量注入"这条路
(CLAUDE.md §4.5 的第二条装配路),而工厂路占 90% 场景,配置错误应当在装配期
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
"""
for source, profile in zip(sources, profiles, strict=True):
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
source.enable_thinking,
model=source.model,
)
def _fingerprint_mark(source: SourceConfig) -> str:
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
"""
parts: list[Any] = [source.model, dict(source.extra_body)]
if source.enable_thinking is not None:
parts.append(source.enable_thinking)
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
后重启仍会读到旧缓存(issue #4 设计决策 C)。全源 `extra_body` 皆空时
字面量与历史实现逐字相同,不触发存量缓存冷启动。
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量
缓存冷启动。
"""
fingerprint = ",".join(sorted({s.model for s in sources}))
# 按 (model, extra_body) 而非源名摘要: 语义是"本 scope 会用哪些
# (模型, 解码参数)组合",改源名不该误触全量冷启动
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 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
}
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
)
if marks:
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
@@ -241,11 +271,13 @@ class GatewayClient:
cache: CacheBackend | None = None,
telemetry: TelemetryRecorder | None = None,
registry: Mapping[str, ProviderProfile] | None = None,
capabilities: Mapping[str, ThinkingCapability] | None = None,
rng: Any = random.random,
) -> GatewayClient:
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
sources = list(settings.sources)
profiles = [get_provider(s.provider, registry=registry) for s in sources]
_guard_thinking(sources, profiles, capabilities)
strategy, escalation = _build_structured(profiles)
return cls(
scope=settings.scope,
@@ -253,7 +285,7 @@ class GatewayClient:
selector=_build_selector(settings.selector, rng=rng),
limiter=limiter or _build_limiter(settings, sources),
breaker=breaker or _build_breaker(settings),
transport=OpenAICompatTransport(registry=registry),
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
retry=settings.retry,
backpressure=settings.backpressure,
quota_full=settings.quota_full,
@@ -279,6 +311,7 @@ class GatewayClient:
cache: CacheBackend | None = None,
telemetry: TelemetryRecorder | None = None,
registry: Mapping[str, ProviderProfile] | None = None,
capabilities: Mapping[str, ThinkingCapability] | None = None,
env: Mapping[str, str] | None = None,
) -> GatewayClient:
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
@@ -289,6 +322,7 @@ class GatewayClient:
cache=cache,
telemetry=telemetry,
registry=registry,
capabilities=capabilities,
)