diff --git a/.env.example b/.env.example index 923d3d9..a2d183e 100644 --- a/.env.example +++ b/.env.example @@ -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 对象串) diff --git a/src/polygateway/config.py b/src/polygateway/config.py index 24062be..de8cbee 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -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) diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 0234ad2..9e29754 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -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( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 338f1b1..f8d1a3f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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())