feat: let a source name its reasoning tier, and say so when it contradicts itself

This commit is contained in:
2026-09-05 01:56:24 -04:00
parent ed563b9ca0
commit 603a835f60
4 changed files with 178 additions and 0 deletions
+9
View File
@@ -16,6 +16,15 @@ LLM__QWEN__1__TIMEOUT_S=120
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
# 本键是 REASONING_EFFORT 的语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态
# LLM__QWEN__1__REASONING_EFFORT=low # 本源默认推理档位;缺省=不表态(随模型自己的默认档)
# 八档(封闭词汇): none | auto | minimal | low | medium | high | xhigh | max
# none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度
# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low),
# 不做"后者赢"的静默兜底——两个键说同一件事,矛盾就是配置错误
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
+26
View File
@@ -22,6 +22,7 @@ from loguru import logger
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
Effort,
GlobalLimits,
RetryPolicy,
SourceConfig,
@@ -43,6 +44,10 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
"ENABLE_THINKING": ("enable_thinking", "bool"),
# 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名),
# fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace)
"REASONING_EFFORT": ("reasoning_effort", "effort"),
"EFFORT_FALLBACK": ("effort_fallback", "str"),
"MISSING_DONE": ("missing_done", "str"),
"TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"),
@@ -93,6 +98,8 @@ def _cast(raw: str, kind: str, key: str) -> object:
if lowered in ("0", "false", "no", "off"):
return False
raise ValueError(f"非法布尔值: {raw!r}")
if kind == "effort":
return _to_effort(raw)
if kind == "json":
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
parsed = json.loads(raw)
@@ -104,6 +111,25 @@ def _cast(raw: str, kind: str, key: str) -> object:
raise ValueError(f"配置 {key} 解析失败: {exc}") from exc
def _to_effort(raw: str) -> Effort:
"""把 env 字符串解成 `Effort`;越界时报错并**列出全部八档**。
列全八档不是啰嗦: 档位词汇是封闭的,而下游会照着别处的习惯写
(`lowest`/`off`/`disabled` 都出现过),只说"非法值"等于让人去翻源码。
`strip().lower()` 与 `bool` 分支同一先例: `.env` 里的行尾空格与大写写法
是常态,而档位取值本身没有大小写语义(`Effort` 的值全小写)。
"""
try:
return Effort(raw.strip().lower())
except ValueError:
# 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一
# 件事的英文复述,链上去反而把可操作的那句挤到后面
raise ValueError(
f"非法推理档位 {raw!r};允许: {', '.join(e.value for e in Effort)}"
) from None
def _first(env: Mapping[str, str], *keys: str) -> tuple[str, str] | None:
for key in keys:
raw = env.get(key)
+49
View File
@@ -18,6 +18,9 @@ from loguru import logger
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
_EFFORT_FALLBACK_DOMAIN = frozenset({"error", "nearest"})
"""`SourceConfig.effort_fallback` 的值域: 请求档打空时报错,还是映射到最近的档。"""
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
{
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
@@ -395,6 +398,10 @@ class SourceConfig:
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、
True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。
2026-09-04 起 `enable_thinking` 降级为 `reasoning_effort` 的语法糖
(`True`→`AUTO`、`False`→`NONE`),保留不删是因为它已被三项目消费
(迁移兼容约束,ARCH §5.1)。两个字段说的是同一件事,故矛盾即报错。
"""
name: str
@@ -419,10 +426,25 @@ class SourceConfig:
(加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法;
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。"""
reasoning_effort: Effort | None = None
"""本源默认的推理档位;None = 不表态(与 `Effort.NONE`「要求不推理」不同)。
**追加在末尾**是硬要求: 三项目的测试按位置构造 fake,插在中间会静默错位
(本模块头部 docstring 的字段保序约定)。"""
effort_fallback: str = "error"
"""请求档打空时的处置: `error`(默认,报错)或 `nearest`(映射到最近的档)。
默认报错的理由是钱: 一次静默的 `medium → max` 在 GLM-5.3 上是数倍账单
(P5「严禁默认值掩盖错误」)。值域在此把关而非交给 `resolve_thinking`——
后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。"""
def __post_init__(self) -> None:
self._validate_identity()
self._validate_gates()
self._validate_watchdog()
self._validate_thinking()
self._freeze_extra_body()
def effective_est_tokens(self) -> int:
@@ -460,6 +482,33 @@ class SourceConfig:
):
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
def _validate_thinking(self) -> None:
"""推理两键的值域与互不矛盾(issue #20 设计 §4.2)。
矛盾**报错而非「后者赢」**: `enable_thinking` 与 `reasoning_effort` 表达的是
同一件事,静默取其一等于替下游猜它到底想要哪个,而猜错的代价是账单——
猜成开启就是白花钱,猜成关闭就是拿到一个没推理过的答案。
判据是「二者是否都在说关闭」: `enable_thinking is False` 与
`reasoning_effort is NONE` 必须同真同假。`True` + 某个开启档(如 `low`)
不算矛盾,那只是把同一件事说了两遍,且后者更精确。
"""
if self.effort_fallback not in _EFFORT_FALLBACK_DOMAIN:
raise ValueError(
f"SourceConfig.effort_fallback(EFFORT_FALLBACK)非法值 "
f"{self.effort_fallback!r};允许: {sorted(_EFFORT_FALLBACK_DOMAIN)}"
)
if self.enable_thinking is None or self.reasoning_effort is None:
return
if (self.enable_thinking is False) != (self.reasoning_effort is Effort.NONE):
raise ValueError(
f"{self.name!r} 的 enable_thinking={self.enable_thinking}"
f"reasoning_effort={self.reasoning_effort.value!r} 相互矛盾: "
f"enable_thinking 已是 reasoning_effort 的语法糖"
f"(True={Effort.AUTO.value}、False={Effort.NONE.value})。"
f"请只保留其中一个,或让两者语义一致"
)
def _freeze_extra_body(self) -> None:
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
validated = validate_request_overlay(
+94
View File
@@ -9,6 +9,7 @@ from loguru import logger
from polygateway.client import GatewayClient
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
from polygateway.types import Effort, SourceConfig
_BASE_ENV = {
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
@@ -133,6 +134,99 @@ class TestExtraBodyParsing:
GatewaySettings.from_env("LLM", env=env)
class TestReasoningEffortParsing:
"""源级推理档位两个键的 env 解析(issue #20 Task 4)。"""
def test_effort_key_parsed(self):
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "low"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].reasoning_effort is Effort.LOW
def test_absent_keys_keep_the_source_silent(self):
"""未配置 = 不表态,与 `Effort.NONE`(要求不推理)是两回事;映射默认关闭。"""
src = GatewaySettings.from_env("LLM", env=_env()).sources[0]
assert src.reasoning_effort is None
assert src.effort_fallback == "error"
def test_invalid_effort_lists_vocabulary(self):
"""写错档位的人要的是"那该填什么",故报错必须把八档全摆出来。"""
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "lowest"})
with pytest.raises(ValueError) as exc:
GatewaySettings.from_env("LLM", env=env)
message = str(exc.value)
assert "REASONING_EFFORT" in message
assert all(tier.value in message for tier in Effort)
def test_effort_fallback_parsed(self):
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "nearest"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].effort_fallback == "nearest"
def test_invalid_effort_fallback_rejected(self):
"""`resolve_thinking` 对未知 fallback 值是 fail-closed,不会替配置兜错。"""
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "closest"})
with pytest.raises(ValueError) as exc:
GatewaySettings.from_env("LLM", env=env)
message = str(exc.value)
assert "effort_fallback" in message
assert "nearest" in message and "error" in message
class TestThinkingFlagContradiction:
"""`enable_thinking` 与 `reasoning_effort` 说的是同一件事(设计 §4.2 语法糖)。
矛盾时报错而非「后者赢」: 两个字段表达同一件事时,矛盾是配置错误,
静默取其一等于替下游猜它想要哪个。
"""
def _source(self, enable_thinking, effort):
env = _env(
**{
"LLM__QWEN__1__ENABLE_THINKING": enable_thinking,
"LLM__QWEN__1__REASONING_EFFORT": effort,
}
)
return GatewaySettings.from_env("LLM", env=env).sources[0]
@pytest.mark.parametrize(
("enable_thinking", "effort"),
[("true", "none"), ("false", "low"), ("false", "auto"), ("false", "max")],
)
def test_contradictory_thinking_flags_rejected(self, enable_thinking, effort):
with pytest.raises(ValueError) as exc:
self._source(enable_thinking, effort)
message = str(exc.value)
assert "enable_thinking" in message and "reasoning_effort" in message
@pytest.mark.parametrize(
("enable_thinking", "effort"),
[("false", "none"), ("true", "auto"), ("true", "low")],
)
def test_consistent_flags_allowed(self, enable_thinking, effort):
"""语义一致就放行: `False`+`none` 与 `True`+某个开启档都只是说了两遍。"""
src = self._source(enable_thinking, effort)
assert src.enable_thinking is (enable_thinking == "true")
assert src.reasoning_effort is Effort(effort)
def test_one_sided_declaration_never_trips_the_guard(self):
"""只配一个键是常态(存量源全是这样),不得被矛盾守卫误伤。"""
assert self._source("true", None).reasoning_effort is None
assert self._source(None, "high").enable_thinking is None
def test_contradiction_guarded_on_direct_construction(self):
"""守卫挂在构造期而非 env 解析处: 构造函数全量注入那条装配路同样过闸。"""
base = SourceConfig(
name="s1",
provider="qwen",
base_url="https://gw.example/v1",
api_key="sk-x",
model="qwen-max",
timeout_s=60.0,
)
with pytest.raises(ValueError, match="reasoning_effort"):
dataclasses.replace(base, enable_thinking=True, reasoning_effort=Effort.NONE)
class TestResilienceKeys:
def test_flat_legacy_keys(self):
s = GatewaySettings.from_env("LLM", env=_env())