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:
@@ -41,6 +41,7 @@ from polygateway.types import (
|
||||
Effort,
|
||||
LLMResponse,
|
||||
TelemetryStatus,
|
||||
coerce_effort,
|
||||
validate_caller_dimensions,
|
||||
validate_request_overlay,
|
||||
)
|
||||
@@ -315,7 +316,7 @@ class GatewayClient:
|
||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||
stream: bool = True,
|
||||
overlay: Mapping[str, Any] | None = None,
|
||||
reasoning_effort: Effort | None = None,
|
||||
reasoning_effort: Effort | str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
@@ -327,7 +328,8 @@ class GatewayClient:
|
||||
|
||||
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
|
||||
与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与
|
||||
`Effort.NONE`("要求不推理")严格区分。
|
||||
`Effort.NONE`("要求不推理")严格区分。裸字符串(`"low"`)也收,在此归一成
|
||||
`Effort`,非法值当场 `ValueError`——与 `SourceConfig` 那条装配路同口径。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||
@@ -348,6 +350,15 @@ class GatewayClient:
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
|
||||
)
|
||||
# 同样必须在洋葱之外归一: 档位一路要被 `is Effort.NONE` 身份比较,裸字符串
|
||||
# 进去会在 transport 的错误路径上抛 `AttributeError`——那不属错误四分类,
|
||||
# 会穿透 `except ThinkingUnsupportedError` 与 RetryMW 的分类捕获(库铁律
|
||||
# 「错误分类驱动」)。归一失败是调用方编程错误,抛裸 ValueError 不进洋葱
|
||||
effort = (
|
||||
None
|
||||
if reasoning_effort is None
|
||||
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
|
||||
)
|
||||
request = ChatRequest(
|
||||
messages=messages,
|
||||
session_id=session_id,
|
||||
@@ -358,7 +369,7 @@ class GatewayClient:
|
||||
stream=stream,
|
||||
overlay=sampling,
|
||||
sampling=sampling,
|
||||
reasoning_effort=reasoning_effort,
|
||||
reasoning_effort=effort,
|
||||
tenant_id=dimension_tenant_id,
|
||||
meta=dimensions,
|
||||
)
|
||||
|
||||
@@ -22,10 +22,10 @@ from loguru import logger
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
BreakerConfig,
|
||||
Effort,
|
||||
GlobalLimits,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
coerce_effort,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -47,6 +47,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
||||
# 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名),
|
||||
# fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace)
|
||||
"REASONING_EFFORT": ("reasoning_effort", "effort"),
|
||||
# 归一化(strip+lower)在 SourceConfig 构造期,与值域校验同处一点,故这里是裸 "str"
|
||||
"EFFORT_FALLBACK": ("effort_fallback", "str"),
|
||||
"MISSING_DONE": ("missing_done", "str"),
|
||||
"TRUST_ENV": ("trust_env", "bool"),
|
||||
@@ -99,7 +100,9 @@ def _cast(raw: str, kind: str, key: str) -> object:
|
||||
return False
|
||||
raise ValueError(f"非法布尔值: {raw!r}")
|
||||
if kind == "effort":
|
||||
return _to_effort(raw)
|
||||
# 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径;
|
||||
# origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上
|
||||
return coerce_effort(raw, origin="")
|
||||
if kind == "json":
|
||||
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
|
||||
parsed = json.loads(raw)
|
||||
@@ -111,25 +114,6 @@ 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)
|
||||
|
||||
@@ -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)非法值 "
|
||||
|
||||
Reference in New Issue
Block a user