feat: let a source name its reasoning tier, and say so when it contradicts itself
This commit is contained in:
@@ -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__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
|
||||||
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
|
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
|
||||||
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
|
# 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__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
||||||
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
||||||
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from loguru import logger
|
|||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
|
Effort,
|
||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
@@ -43,6 +44,10 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
|||||||
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
|
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
|
||||||
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
|
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
|
||||||
"ENABLE_THINKING": ("enable_thinking", "bool"),
|
"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"),
|
"MISSING_DONE": ("missing_done", "str"),
|
||||||
"TRUST_ENV": ("trust_env", "bool"),
|
"TRUST_ENV": ("trust_env", "bool"),
|
||||||
"EXTRA_BODY": ("extra_body", "json"),
|
"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"):
|
if lowered in ("0", "false", "no", "off"):
|
||||||
return False
|
return False
|
||||||
raise ValueError(f"非法布尔值: {raw!r}")
|
raise ValueError(f"非法布尔值: {raw!r}")
|
||||||
|
if kind == "effort":
|
||||||
|
return _to_effort(raw)
|
||||||
if kind == "json":
|
if kind == "json":
|
||||||
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
|
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
@@ -104,6 +111,25 @@ def _cast(raw: str, kind: str, key: str) -> object:
|
|||||||
raise ValueError(f"配置 {key} 解析失败: {exc}") from exc
|
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:
|
def _first(env: Mapping[str, str], *keys: str) -> tuple[str, str] | None:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
raw = env.get(key)
|
raw = env.get(key)
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ from loguru import logger
|
|||||||
|
|
||||||
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
||||||
|
|
||||||
|
_EFFORT_FALLBACK_DOMAIN = frozenset({"error", "nearest"})
|
||||||
|
"""`SourceConfig.effort_fallback` 的值域: 请求档打空时报错,还是映射到最近的档。"""
|
||||||
|
|
||||||
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
|
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
|
||||||
{
|
{
|
||||||
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
|
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
|
||||||
@@ -395,6 +398,10 @@ class SourceConfig:
|
|||||||
|
|
||||||
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、
|
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、
|
||||||
True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。
|
True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。
|
||||||
|
|
||||||
|
2026-09-04 起 `enable_thinking` 降级为 `reasoning_effort` 的语法糖
|
||||||
|
(`True`→`AUTO`、`False`→`NONE`),保留不删是因为它已被三项目消费
|
||||||
|
(迁移兼容约束,ARCH §5.1)。两个字段说的是同一件事,故矛盾即报错。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
@@ -419,10 +426,25 @@ class SourceConfig:
|
|||||||
(加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法;
|
(加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法;
|
||||||
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。"""
|
要可变副本用 `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:
|
def __post_init__(self) -> None:
|
||||||
self._validate_identity()
|
self._validate_identity()
|
||||||
self._validate_gates()
|
self._validate_gates()
|
||||||
self._validate_watchdog()
|
self._validate_watchdog()
|
||||||
|
self._validate_thinking()
|
||||||
self._freeze_extra_body()
|
self._freeze_extra_body()
|
||||||
|
|
||||||
def effective_est_tokens(self) -> int:
|
def effective_est_tokens(self) -> int:
|
||||||
@@ -460,6 +482,33 @@ class SourceConfig:
|
|||||||
):
|
):
|
||||||
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
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:
|
def _freeze_extra_body(self) -> None:
|
||||||
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
|
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
|
||||||
validated = validate_request_overlay(
|
validated = validate_request_overlay(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from loguru import logger
|
|||||||
from polygateway.client import GatewayClient
|
from polygateway.client import GatewayClient
|
||||||
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
||||||
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
|
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
|
||||||
|
from polygateway.types import Effort, SourceConfig
|
||||||
|
|
||||||
_BASE_ENV = {
|
_BASE_ENV = {
|
||||||
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
||||||
@@ -133,6 +134,99 @@ class TestExtraBodyParsing:
|
|||||||
GatewaySettings.from_env("LLM", env=env)
|
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:
|
class TestResilienceKeys:
|
||||||
def test_flat_legacy_keys(self):
|
def test_flat_legacy_keys(self):
|
||||||
s = GatewaySettings.from_env("LLM", env=_env())
|
s = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
|||||||
Reference in New Issue
Block a user