fix: read a tier the way every config path actually spells it

Both public assembly paths took the tier on trust: a bare "none" from
JSON or a hand-built SourceConfig stayed a str, and `is Effort.NONE`
then read it as a contradiction and crashed on `.value` while wording
the error -- the caller got an AttributeError where a ValueError was
promised, and on the request side that unclassified exception walked
straight through the transport's ThinkingUnsupportedError catch and the
retry classifier.

Normalize at the two entrances instead, matching what the .env path has
always done, and let EFFORT_FALLBACK be spelled with the same freedom as
its neighbour.
This commit is contained in:
2026-09-05 04:07:06 -04:00
parent 701a8a6841
commit 1a35d515d9
6 changed files with 173 additions and 26 deletions
+61 -2
View File
@@ -217,6 +217,42 @@ EFFORT_ORDER: tuple[Effort, ...] = (
"""
def coerce_effort(raw: Any, *, origin: str) -> Effort:
"""把外部传入的档位**归一**成 `Effort`;非法值报 `ValueError` 并列全八档。
存在的理由是"归一化点必须在入口":库内一律用 `is Effort.NONE` 做身份比较
(枚举成员唯一,`is` 比 `==` 更能表达"就是这一档"),而 `Effort` 是 `StrEnum`
——下游从 JSON/配置/命令行读出来的天然是裸字符串,`"none" is Effort.NONE`
恒为假。不在入口归一,身份比较就会在**错误路径上**误判(把一致的配置判成
矛盾),随后拼错误文案时再 `.value` 抛 `AttributeError`,连承诺的 `ValueError`
都拿不到(2026-09-05 独立验证实测)。
故裸字符串**接受并归一**而非拒收: 拒收会把 `.env` 之外的两条装配路(工厂 /
构造函数全量注入,CLAUDE.md §4.5)口径劈成两半,而 `.env` 那条早已是"解析即
归一"。`strip().lower()` 与 `config._to_effort` 同口径,理由同样是配置里的
行尾空格与大写写法是常态,而档位取值本身没有大小写语义。
`origin` 指回具体的配置项或调用点: 档位在源级、请求级两处都能配,只说
"非法档位"要人自己去找是哪一处填错了。传空串表示调用方自己会补上下文
(`config._cast` 的 `配置 X 解析失败` 已经说了是哪个 env 键)。
"""
if isinstance(raw, Effort):
return raw
prefix = f"{origin}: " if origin else ""
listed = ", ".join(e.value for e in Effort)
if isinstance(raw, str):
try:
return Effort(raw.strip().lower())
except ValueError:
# 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一
# 件事的英文复述,链上去反而把可操作的那句挤到后面
raise ValueError(f"{prefix}非法推理档位 {raw!r};允许: {listed}") from None
raise ValueError(
f"{prefix}推理档位必须是 Effort 或其字面量字符串,"
f"收到 {type(raw).__name__}: {raw!r};允许: {listed}"
)
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
@@ -440,7 +476,11 @@ class SourceConfig:
reasoning_effort: Effort | None = None
"""本源默认的推理档位;None = 不表态(与 `Effort.NONE`「要求不推理」不同)。
**追加在末尾**是硬要求: 三项目的测试按位置构造 fake,插在中间会静默错位
裸字符串(`"low"`、`" LOW "`)也收,构造期由 `coerce_effort` 归一成 `Effort`,
非法值当场 `ValueError` 并列出八档;**构造完成后本字段一定是 `Effort`**,库内
的 `is Effort.NONE` 身份比较依赖这条不变式。
**追加在末尾**是硬要求:三项目的测试按位置构造 fake,插在中间会静默错位
(本模块头部 docstring 的字段保序约定)。"""
effort_fallback: str = "error"
@@ -494,7 +534,12 @@ class SourceConfig:
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
def _validate_thinking(self) -> None:
"""推理两键的值域与互不矛盾(issue #20 设计 §4.2)。
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
归一化必须先于下面的矛盾判定: 判据用的是 `is Effort.NONE`,而本类是公共
入口,`reasoning_effort="none"` 这种裸字符串写法(从 JSON/配置读出来的
常态)会让它误判成矛盾,再拼文案时 `.value` 直接 `AttributeError`。同一
理由也适用于下游读侧——归一化后库内一律是 `Effort`,`is` 比较才安全。
矛盾**报错而非「后者赢」**: `enable_thinking` 与 `reasoning_effort` 表达的是
同一件事,静默取其一等于替下游猜它到底想要哪个,而猜错的代价是账单——
@@ -504,6 +549,20 @@ class SourceConfig:
`reasoning_effort is NONE` 必须同真同假。`True` + 某个开启档(如 `low`)
不算矛盾,那只是把同一件事说了两遍,且后者更精确。
"""
if self.reasoning_effort is not None:
# frozen dataclass 改字段走 object.__setattr__(同款先例: _freeze_extra_body)
object.__setattr__(
self,
"reasoning_effort",
coerce_effort(
self.reasoning_effort, origin=f"SourceConfig({self.name}).reasoning_effort"
),
)
if isinstance(self.effort_fallback, str):
# 与相邻的 `REASONING_EFFORT` 同口径: `.env` 里的行尾空格与大写写法是
# 常态,而 `nearest`/`error` 本身没有大小写语义。归一化放在值域校验的
# 同一处(而不是 env 解析处),三条配置路一并覆盖
object.__setattr__(self, "effort_fallback", self.effort_fallback.strip().lower())
if self.effort_fallback not in _EFFORT_FALLBACK_DOMAIN:
raise ValueError(
f"SourceConfig.effort_fallback(EFFORT_FALLBACK)非法值 "