feat: let one call ask for a different tier than its source defaults to

The three-layer priority (call > source > enable_thinking sugar > silence)
now lives in one pure function, thinking.effective_effort(). The assembly
guard and the request hot path used to each carry their own inline copy of
the sugar conversion; two copies of the same judgement drift into the worst
shape there is - passes at assembly, raises at runtime.

The guard now also honours effort_fallback, so a source that opted into
nearest is no longer sentenced at assembly for a tier it could have mapped.
This commit is contained in:
2026-09-05 02:15:25 -04:00
parent 603a835f60
commit 1f13eb18ab
5 changed files with 177 additions and 7 deletions
+17 -7
View File
@@ -34,7 +34,7 @@ from polygateway.sources import (
RoundRobinSelector,
SourceCooldownMemo,
)
from polygateway.thinking import get_capability, resolve_thinking
from polygateway.thinking import effective_effort, get_capability, resolve_thinking
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
ChatRequest,
@@ -81,16 +81,20 @@ def _guard_thinking(
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
"""
for source, profile in zip(sources, profiles, strict=True):
# `enable_thinking` 的档位语法糖(True → auto,False → none,None 不表态);
# 两个调用点各自就地转换是过渡形态,T5 起由 thinking.effective_effort()
# 统一收口并接上源级/请求级档位(设计 §4.2)
enabled = source.enable_thinking
effort = None if enabled is None else (Effort.AUTO if enabled else Effort.NONE)
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
effort,
# 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级
# 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工)
effective_effort(
request_effort=None,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
),
model=source.model,
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
# 判死,而它在运行期本来是能映射到最近档跑起来的
fallback=source.effort_fallback,
)
@@ -295,6 +299,7 @@ class GatewayClient:
structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True,
overlay: Mapping[str, Any] | None = None,
reasoning_effort: Effort | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> LLMResponse:
@@ -304,6 +309,10 @@ class GatewayClient:
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
参数不影响既有调用点(issue #4)。
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与
`Effort.NONE`("要求不推理")严格区分。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
@@ -333,6 +342,7 @@ class GatewayClient:
stream=stream,
overlay=sampling,
sampling=sampling,
reasoning_effort=reasoning_effort,
tenant_id=dimension_tenant_id,
meta=dimensions,
)
+33
View File
@@ -308,6 +308,39 @@ class ThinkingResolution:
applied_effort: Effort | None
def effective_effort(
*,
request_effort: Effort | None,
source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""求本次生效的档位: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
**收口成一个纯函数**是本函数存在的全部理由: 装配守卫(`client._guard_thinking`)
与请求热路径(`openai_compat._build_payload`)必须给出**同一个**判定,两处各写
一份就地转换迟早会分叉,而分叉的形态是"装配期放行、运行期报错"——最难查的那种。
**一律用 `is None` 判有没有表态,不靠真值性**: `Effort.NONE`(要求不推理)与
`enable_thinking=False` 都是**表态**而非缺省,`x or y` 式的回落会把后者当成没配
从而跳到下一层——那正是本次要消灭的静默失效。
语法糖排在最末且 `True → AUTO`(开启但不指定强度,不依赖能力表),不是旧版那个
硬编码的 `medium`: 那是库替下游做的档位判断,而 `medium` 在 GLM/kimi/deepseek 的
档位表里根本不存在(设计 §4.2 声明过的有意变更)。
同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾,已由
`SourceConfig.__post_init__` 在构造期报错,故这里不再判——两个字段说同一件事时,
矛盾是配置错误,不是优先级问题。
"""
if request_effort is not None:
return request_effort
if source_effort is not None:
return source_effort
if enable_thinking is None:
return None
return Effort.AUTO if enable_thinking else Effort.NONE
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
+11
View File
@@ -325,6 +325,17 @@ class ChatRequest:
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。"""
# —— 请求级推理档位(issue #20;追加在末尾,不扰动既有字段的位置构造)——
reasoning_effort: Effort | None = None
"""本次调用要求的推理档位,压过源级默认(设计 §4.2 的最高优先级层)。
`None` 是**不表态**(随源级配置),与 `Effort.NONE`("要求不推理")严格区分:
把前者读成后者会让一次没写档位的调用悄悄关掉源上配好的推理。
独立成字段而非塞进 `overlay`: `overlay` 是采样参数的直通层,库不解释其内容,
而档位要经能力表校验、要进缓存 key、要落遥测——混进直通层等于放弃这三样,
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
@dataclass(frozen=True)
class Usage: