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:
@@ -34,7 +34,7 @@ from polygateway.sources import (
|
|||||||
RoundRobinSelector,
|
RoundRobinSelector,
|
||||||
SourceCooldownMemo,
|
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.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
@@ -81,16 +81,20 @@ def _guard_thinking(
|
|||||||
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
|
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
|
||||||
"""
|
"""
|
||||||
for source, profile in zip(sources, profiles, strict=True):
|
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(
|
resolve_thinking(
|
||||||
profile,
|
profile,
|
||||||
get_capability(source.model, table=capabilities),
|
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,
|
model=source.model,
|
||||||
|
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
|
||||||
|
# 判死,而它在运行期本来是能映射到最近档跑起来的
|
||||||
|
fallback=source.effort_fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -295,6 +299,7 @@ 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,
|
||||||
|
reasoning_effort: Effort | None = None,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
meta: Mapping[str, Any] | None = None,
|
meta: Mapping[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@@ -304,6 +309,10 @@ class GatewayClient:
|
|||||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||||
参数不影响既有调用点(issue #4)。
|
参数不影响既有调用点(issue #4)。
|
||||||
|
|
||||||
|
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
|
||||||
|
与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与
|
||||||
|
`Effort.NONE`("要求不推理")严格区分。
|
||||||
|
|
||||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||||
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
||||||
@@ -333,6 +342,7 @@ class GatewayClient:
|
|||||||
stream=stream,
|
stream=stream,
|
||||||
overlay=sampling,
|
overlay=sampling,
|
||||||
sampling=sampling,
|
sampling=sampling,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
tenant_id=dimension_tenant_id,
|
tenant_id=dimension_tenant_id,
|
||||||
meta=dimensions,
|
meta=dimensions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -308,6 +308,39 @@ class ThinkingResolution:
|
|||||||
applied_effort: Effort | None
|
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(
|
def resolve_thinking(
|
||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
capability: ThinkingCapability | None,
|
capability: ThinkingCapability | None,
|
||||||
|
|||||||
@@ -325,6 +325,17 @@ class ChatRequest:
|
|||||||
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
|
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
|
||||||
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。"""
|
语义维度,同 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)
|
@dataclass(frozen=True)
|
||||||
class Usage:
|
class Usage:
|
||||||
|
|||||||
@@ -198,6 +198,57 @@ class TestSamplingOverlay:
|
|||||||
assert [c["seed"] for c in captured] == [1, 2]
|
assert [c["seed"] for c in captured] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningEffortPriority:
|
||||||
|
"""三层优先级: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
|
||||||
|
|
||||||
|
一律抓**真实请求体**而非只查 `ChatRequest` 字段: 档位的价值全在发出去的那几个
|
||||||
|
字节上,只断言中间态会让"字段填了但一路没人读"这种缺口继续通过测试——issue #20
|
||||||
|
的 `extra_body` 绕行正是这么长出来的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _capturing_client(self, captured, **overrides):
|
||||||
|
def handler(request):
|
||||||
|
captured.append(json.loads(request.content))
|
||||||
|
return _sse()
|
||||||
|
|
||||||
|
return _client(handler=handler, **overrides)
|
||||||
|
|
||||||
|
def _zhipu(self, **overrides):
|
||||||
|
# glm-5.3 的档位是 low/high/max(能力表已登记),zhipu 的 wire 三样俱全,
|
||||||
|
# 是唯一能同时看清"开启形态"与"档位键"的组合
|
||||||
|
overrides.setdefault("model", "glm-5.3")
|
||||||
|
return _source(provider="zhipu", **overrides)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("provider", "model", "fragment"),
|
||||||
|
[
|
||||||
|
("qwen", "qwen-max", {"enable_thinking": True}),
|
||||||
|
("deepseek", "deepseek-v4-pro", {"thinking": {"type": "enabled"}}),
|
||||||
|
("zhipu", "glm-5.3", {"thinking": {"type": "enabled"}}),
|
||||||
|
("moonshot", "kimi-k3", {"thinking": {"type": "enabled"}}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment):
|
||||||
|
"""存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。
|
||||||
|
|
||||||
|
**只覆盖 `on_base` 自己就说全了"开"的四段**。minimax/openai/anthropic/google
|
||||||
|
的开档旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位——那是
|
||||||
|
设计 §4.2 声明过的**有意变更**(medium 在 GLM/kimi/deepseek 的档位表里根本
|
||||||
|
不存在,是库替下游做的档位判断),不是本门要守的不变量。
|
||||||
|
|
||||||
|
qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`;
|
||||||
|
zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态。
|
||||||
|
"""
|
||||||
|
captured = []
|
||||||
|
source = _source(provider=provider, model=model, enable_thinking=True)
|
||||||
|
async with self._capturing_client(captured, sources=[source]) as client:
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}])
|
||||||
|
body = captured[0]
|
||||||
|
assert {k: body[k] for k in fragment} == fragment
|
||||||
|
# `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档
|
||||||
|
assert "reasoning_effort" not in body
|
||||||
|
|
||||||
|
|
||||||
class _MemoryRecorder:
|
class _MemoryRecorder:
|
||||||
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
|
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from polygateway.thinking import (
|
|||||||
DEFAULT_CAPABILITIES,
|
DEFAULT_CAPABILITIES,
|
||||||
ThinkingCapability,
|
ThinkingCapability,
|
||||||
ThinkingUnsupportedError,
|
ThinkingUnsupportedError,
|
||||||
|
effective_effort,
|
||||||
get_capability,
|
get_capability,
|
||||||
observe_thinking,
|
observe_thinking,
|
||||||
reconcile_thinking,
|
reconcile_thinking,
|
||||||
@@ -616,3 +617,67 @@ class TestCapabilityTierList:
|
|||||||
assert get_capability("MiniMax-M2.7").can_disable is False
|
assert get_capability("MiniMax-M2.7").can_disable is False
|
||||||
assert get_capability("MiniMax-M2.5").can_disable is False
|
assert get_capability("MiniMax-M2.5").can_disable is False
|
||||||
assert get_capability("MiniMax-M3").can_disable is True
|
assert get_capability("MiniMax-M3").can_disable is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveEffort:
|
||||||
|
"""三层优先级的**唯一**判定处(设计 §4.2): 请求级 > 源级 > 语法糖 > 不表态。
|
||||||
|
|
||||||
|
收口成一个纯函数,是因为它此前在装配守卫与 transport 里各写了一份就地转换:
|
||||||
|
两份各自演化的判定,迟早会在"装配期放行、运行期报错"这种最难查的形态上分叉。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_request_beats_source(self):
|
||||||
|
assert (
|
||||||
|
effective_effort(
|
||||||
|
request_effort=Effort.MAX, source_effort=Effort.LOW, enable_thinking=None
|
||||||
|
)
|
||||||
|
is Effort.MAX
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_beats_sugar(self):
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=Effort.HIGH, enable_thinking=None)
|
||||||
|
is Effort.HIGH
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_none_request_does_not_clear_source(self):
|
||||||
|
"""请求级"没表态"绝不能被读成"要求关闭"——那会静默改掉源级的默认档。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=None)
|
||||||
|
is Effort.LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_request_none_tier_is_an_opinion(self):
|
||||||
|
"""`Effort.NONE` 是一次明确的表态,必须压过源级档位而不是被当成缺省。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(
|
||||||
|
request_effort=Effort.NONE, source_effort=Effort.MAX, enable_thinking=None
|
||||||
|
)
|
||||||
|
is Effort.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_thinking_true_is_auto(self):
|
||||||
|
"""`True` → `auto`(开启但不指定强度),而**不是**旧版硬编码的 medium。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=None, enable_thinking=True)
|
||||||
|
is Effort.AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_thinking_false_is_the_none_tier(self):
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=None, enable_thinking=False)
|
||||||
|
is Effort.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sugar_is_the_last_word_only(self):
|
||||||
|
"""语法糖排在最末: 显式配了档位就以档位为准(矛盾组合已被构造期挡下)。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=True)
|
||||||
|
is Effort.LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_absent_is_no_opinion(self):
|
||||||
|
"""三层都不表态 → None(随模型默认),与 `Effort.NONE` 严格区分。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user