Files
PolyGateway/tests/unit/test_config.py
T
iomgaa 5a025b6e5d style: run the formatter over the issue 14 changes
ruff format only; no semantic change.
2026-08-20 00:44:21 -04:00

793 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
import contextlib
import dataclasses
import pytest
from loguru import logger
from polygateway.client import GatewayClient
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
_BASE_ENV = {
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
"LLM__QWEN__1__API_KEY": "sk-a",
"LLM__QWEN__1__MODEL": "qwen-max",
"LLM__QWEN__1__TIMEOUT_S": "120",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
}
@contextlib.contextmanager
def _captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
yield messages
finally:
logger.remove(sink_id)
def _env(**overrides):
env = dict(_BASE_ENV)
env.update({k: v for k, v in overrides.items() if v is not None})
for k, v in overrides.items():
if v is None:
env.pop(k, None)
return env
class TestSourceAggregation:
def test_single_source_parsed(self):
s = GatewaySettings.from_env("LLM", env=_env())
assert len(s.sources) == 1
src = s.sources[0]
assert src.name == "qwen_1" and src.provider == "qwen"
assert src.base_url == "https://gw-a.example/v1" and src.timeout_s == 120.0
def test_multi_source_and_optional_fields(self):
env = _env(
**{
"LLM__DEEPSEEK__2__BASE_URL": "https://gw-b.example/v1",
"LLM__DEEPSEEK__2__API_KEY": "sk-b",
"LLM__DEEPSEEK__2__MODEL": "deepseek-chat",
"LLM__DEEPSEEK__2__TIMEOUT_S": "90",
"LLM__DEEPSEEK__2__RPM": "60",
"LLM__DEEPSEEK__2__ENABLE_THINKING": "true",
"LLM__DEEPSEEK__2__MISSING_DONE": "salvage",
}
)
s = GatewaySettings.from_env("LLM", env=env)
by_name = {src.name: src for src in s.sources}
assert set(by_name) == {"qwen_1", "deepseek_2"}
ds = by_name["deepseek_2"]
assert ds.rpm == 60 and ds.enable_thinking is True and ds.missing_done == "salvage"
assert by_name["qwen_1"].enable_thinking is None # 未配置 = 三态 None
def test_other_scope_keys_ignored(self):
env = _env(
**{
"OCR__MONKEY__1__BASE_URL": "http://lan/parse",
"OCR__MONKEY__1__API_KEY": "x",
"OCR__MONKEY__1__MODEL": "monkey",
"OCR__MONKEY__1__TIMEOUT_S": "60",
}
)
s = GatewaySettings.from_env("LLM", env=env)
assert len(s.sources) == 1
def test_flat_timeout_is_source_default(self):
env = _env(LLM_TIMEOUT="300", **{"LLM__QWEN__1__TIMEOUT_S": None})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].timeout_s == 300.0
@pytest.mark.parametrize("missing", ["BASE_URL", "API_KEY", "MODEL"])
def test_missing_required_source_field_fails(self, missing):
with pytest.raises(ValueError, match=missing):
GatewaySettings.from_env("LLM", env=_env(**{f"LLM__QWEN__1__{missing}": None}))
def test_unknown_field_fails_loudly(self):
with pytest.raises(ValueError, match="TEMPRATURE"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__QWEN__1__TEMPRATURE": "0.7"}))
def test_no_sources_fails(self):
env = {k: v for k, v in _BASE_ENV.items() if not k.startswith("LLM__")}
with pytest.raises(ValueError, match="源"):
GatewaySettings.from_env("LLM", env=env)
class TestExtraBodyParsing:
"""配置级采样参数的 env 解析(issue #4 Task 2)。"""
def test_json_object_parsed(self):
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"temperature": 0, "seed": 42}'})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].extra_body == {"temperature": 0, "seed": 42}
def test_absent_defaults_to_empty(self):
assert GatewaySettings.from_env("LLM", env=_env()).sources[0].extra_body == {}
def test_invalid_json_fails_loudly(self):
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "{invalid"})
with pytest.raises(ValueError, match="EXTRA_BODY"):
GatewaySettings.from_env("LLM", env=env)
def test_non_object_json_fails(self):
"""数组/标量都不是请求体片段,静默接受会让参数悄悄不生效。"""
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "[1, 2]"})
with pytest.raises(ValueError, match="JSON 对象"):
GatewaySettings.from_env("LLM", env=env)
def test_protected_key_rejected_through_assembly(self):
"""校验确实挂在装配路径上(而非只在 types.py 里孤立存在)。"""
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"model": "sneaky"}'})
with pytest.raises(ValueError, match="model"):
GatewaySettings.from_env("LLM", env=env)
class TestResilienceKeys:
def test_flat_legacy_keys(self):
s = GatewaySettings.from_env("LLM", env=_env())
assert s.retry.max_attempts == 3 and s.retry.backoff_base_s == 2.0
assert s.breaker.fail_threshold == 5 and s.breaker.cooldown_s == 60.0
def test_scope_keys_override_flat(self):
env = _env(**{"LLM__RETRY__MAX_ATTEMPTS": "7", "LLM__BREAKER__COOLDOWN_S": "15"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.retry.max_attempts == 7
assert s.breaker.cooldown_s == 15.0
assert s.breaker.fail_threshold == 5 # 未覆盖的仍取平铺键
def test_missing_retry_config_fails(self):
with pytest.raises(ValueError, match="MAX_RETRIES|MAX_ATTEMPTS"):
GatewaySettings.from_env("LLM", env=_env(LLM_MAX_RETRIES=None))
def test_probe_ttl_derived_when_absent(self):
s = GatewaySettings.from_env("LLM", env=_env())
# 派生规则(M2 补第三项): max(2 × 最大源 timeout, cooldown, 最大源 timeout + 5)
assert s.breaker.probe_ttl_s == max(2 * 120.0, 60.0, 120.0 + 5)
s2 = GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "300"}))
assert s2.breaker.probe_ttl_s == 300.0
def test_probe_ttl_derivation_third_term_wins(self):
# timeout=4、cooldown=2 → max(8, 2, 9) = 9;守卫 9 ≥ 4+5 恰好成立不报错
env = _env(
LLM_CIRCUIT_BREAKER_COOLDOWN="2",
**{"LLM__QWEN__1__TIMEOUT_S": "4"},
)
s = GatewaySettings.from_env("LLM", env=env)
assert s.breaker.probe_ttl_s == 9.0
def test_explicit_probe_ttl_below_guard_rejected(self):
# 守卫: probe_ttl_s ≥ max(timeout_s) + 5(CHS container 语义,M2 设计 §3)
with pytest.raises(ValueError, match="probe"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "45"}))
def test_circuit_open_defaults_to_fail_fast(self):
"""issue #14: 熔断拒绝的处置策略。
缺省**不跟随** quota_full 的 wait——把最坏墙钟从毫秒抬到 stall 窗口
是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游。
"""
assert GatewaySettings.from_env("LLM", env=_env()).circuit_open == "fail_fast"
waiting = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "wait"}))
assert waiting.circuit_open == "wait"
with pytest.raises(ValueError, match="CIRCUIT_OPEN"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "block"}))
def test_selector_and_quota_full(self):
# M2.5: 缺省选源改 health_aware(生产级默认);显式配置者不变
s = GatewaySettings.from_env("LLM", env=_env())
assert s.selector == "health_aware" and s.quota_full == "wait"
assert (
GatewaySettings.from_env("LLM", env=_env(**{"LLM__SELECTOR": "round_robin"})).selector
== "round_robin"
)
s2 = GatewaySettings.from_env(
"LLM", env=_env(**{"LLM__SELECTOR": "least_inflight", "LLM__QUOTA_FULL": "fail_fast"})
)
assert s2.selector == "least_inflight" and s2.quota_full == "fail_fast"
with pytest.raises(ValueError):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__SELECTOR": "random"}))
def test_global_limits(self):
env = _env(**{"LLM__GLOBAL__MAX_CONCURRENCY": "8", "LLM__GLOBAL__RPM": "120"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.global_limits.max_concurrency == 8 and s.global_limits.rpm == 120
assert s.global_limits.tpm == 0
class TestAssemblyGuards:
def test_structured_retries_default_two(self):
# M2.5 迭代 4: 缺省重问 1→2(instructor 缺省 3 的保守版;P6 阶梯死亡 3.2% 实证)
s = GatewaySettings.from_env("LLM", env=_env())
assert s.structured_max_retries == 2
s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
assert s2.structured_max_retries == 0
def test_negative_structured_retries_rejected_with_env_key(self):
"""env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
with pytest.raises(ValueError, match="PGW_STRUCTURED_MAX_RETRIES"):
GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="-1"))
def test_cache_requires_namespace_and_ttl(self):
env = _env(PGW_CACHE_BACKEND="memory")
with pytest.raises(ValueError, match="NAMESPACE"):
GatewaySettings.from_env("LLM", env=env)
env2 = _env(PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="0")
with pytest.raises(ValueError, match="TTL"):
GatewaySettings.from_env("LLM", env=env2)
env3 = _env(PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600")
s = GatewaySettings.from_env("LLM", env=env3)
assert s.cache_namespace == "proj" and s.cache_ttl_s == 3600
def test_redis_cache_requires_url(self):
env = _env(PGW_CACHE_BACKEND="redis", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600")
with pytest.raises(ValueError, match="REDIS_URL"):
GatewaySettings.from_env("LLM", env=env)
def test_sqlite_telemetry_requires_path(self):
env = _env(PGW_TELEMETRY_BACKEND="sqlite")
with pytest.raises(ValueError, match="SQLITE_PATH"):
GatewaySettings.from_env("LLM", env=env)
def test_timeout_must_fit_lease_ttl(self):
env = _env(PGW_LEASE_TTL_S="60", **{"LLM__QWEN__1__TIMEOUT_S": "120"})
with pytest.raises(ValueError, match="租约|lease"):
GatewaySettings.from_env("LLM", env=env)
def test_effective_breaker_threshold_auto_raised(self):
env = _env(**{"LLM__QWEN__1__MAX_CONCURRENCY": "8"})
s = GatewaySettings.from_env("LLM", env=env)
# 有效阈值 = max(配置值 5, 源级并发 8 × 2) = 16(M2.5: 抬升只看源级)
assert s.breaker.fail_threshold == 16
def test_breaker_threshold_not_raised_by_global_concurrency(self):
# M2.5 病灶 2 回归: 全局并发不再抬升阈值(M2 曾 max(5, 100×2)=200 使熔断失灵)
env = _env(**{"LLM__GLOBAL__MAX_CONCURRENCY": "100", "LLM__GLOBAL__RPM": "600"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.breaker.fail_threshold == 5
def test_breaker_rate_channel_defaults_and_overrides(self):
# M2.5 失败率通道参数: 库缺省(韧性参数缺省先例)与显式覆盖
s = GatewaySettings.from_env("LLM", env=_env())
assert s.breaker.min_calls == 10
assert s.breaker.fail_rate == pytest.approx(0.6)
assert s.breaker.window_s == pytest.approx(60.0)
assert s.breaker.max_cooldown_s == pytest.approx(300.0) # max(300, cooldown 60)
env = _env(
**{
"LLM__BREAKER__MIN_CALLS": "20",
"LLM__BREAKER__FAIL_RATE": "0.5",
"LLM__BREAKER__WINDOW_S": "30",
"LLM__BREAKER__MAX_COOLDOWN_S": "600",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "400",
}
)
s2 = GatewaySettings.from_env("LLM", env=env)
assert s2.breaker.min_calls == 20 and s2.breaker.fail_rate == pytest.approx(0.5)
assert s2.breaker.window_s == pytest.approx(30.0)
assert s2.breaker.max_cooldown_s == pytest.approx(600.0)
def test_breaker_max_cooldown_floor_follows_cooldown(self):
# 缺省封顶 = max(300, cooldown): 冷却 400s 时封顶随之 400s
env = _env(**{"LLM_CIRCUIT_BREAKER_COOLDOWN": "400"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.breaker.max_cooldown_s == pytest.approx(400.0)
def test_redis_governance_backend_requires_url(self):
"""M2 解禁 redis 后端: 取 redis 时 REDIS_URL 必在,缺则装配报错。"""
env = _env(
PGW_LIMITER_BACKEND="redis",
PGW_BREAKER_BACKEND="redis",
REDIS_URL="redis://:pw@10.0.0.1:6379/3",
)
s = GatewaySettings.from_env("LLM", env=env)
assert s.limiter_backend == "redis" and s.breaker_backend == "redis"
with pytest.raises(ValueError, match="REDIS_URL"):
GatewaySettings.from_env("LLM", env=_env(PGW_LIMITER_BACKEND="redis"))
def test_postgres_telemetry_requires_dsn_and_strips_driver_suffix(self):
"""M2: telemetry=postgres 需 PGW_TELEMETRY_PG_DSN;SQLAlchemy 风格驱动后缀剥离。"""
with pytest.raises(ValueError, match="PG_DSN"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="postgres"))
env = _env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql+psycopg://u:p@h:5432/polygateway",
)
s = GatewaySettings.from_env("LLM", env=env)
assert s.telemetry_pg_dsn == "postgresql://u:p@h:5432/polygateway"
env2 = _env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u:p@h:5432/polygateway",
)
assert (
GatewaySettings.from_env("LLM", env=env2).telemetry_pg_dsn
== "postgresql://u:p@h:5432/polygateway"
)
def test_telemetry_backend_whitelist(self):
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
def test_cache_backend_whitelist(self):
"""对称于上一条: env 层的域检查保留是为了报错能点出键名,得有测试守着。"""
with pytest.raises(ValueError, match="CACHE_BACKEND"):
GatewaySettings.from_env("LLM", env=_env(PGW_CACHE_BACKEND="rediss"))
def test_pricing_path_optional(self):
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
assert s.pricing_path == "conf/prices.json"
def test_stall_window_must_cover_ttft(self):
"""守卫: stall_window_s ≥ 最大 ttft_timeout_s(防误判卡死,ARCH §7.3)。"""
env = _env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__BACKPRESSURE__STALL_WINDOW_S": "20",
}
)
with pytest.raises(ValueError, match="stall"):
GatewaySettings.from_env("LLM", env=env)
env_ok = dict(env)
env_ok["LLM__BACKPRESSURE__STALL_WINDOW_S"] = "60"
assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0
class TestTelemetrySchemaMode:
"""PGW_TELEMETRY_SCHEMA_MODE 三态(issue #13 设计 §4.1)。
键未设时按后端**不对称**派生: SQLite 是下游自己的本地文件(没有 DBA、
没有迁移工具、没有第二个系统碰它),补列是毫秒级元数据操作,故默认 auto;
PG 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁会阻塞该表其后的所有查询,
而遥测是业务路径上的内联 await,故默认 manual。显式设置两侧都可覆盖——
"可覆盖"正是三态相对两态多出来的那一态,派生本身盖不住它。
"""
def _sqlite_env(self, **overrides):
return _env(
PGW_TELEMETRY_BACKEND="sqlite",
PGW_TELEMETRY_SQLITE_PATH="logs/telemetry.db",
**overrides,
)
def _pg_env(self, **overrides):
return _env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
**overrides,
)
def test_unset_key_derives_auto_for_sqlite(self):
s = GatewaySettings.from_env("LLM", env=self._sqlite_env())
assert s.telemetry_auto_migrate is True
def test_unset_key_derives_manual_for_postgres(self):
s = GatewaySettings.from_env("LLM", env=self._pg_env())
assert s.telemetry_auto_migrate is False
def test_unset_key_derives_manual_for_none_backend(self):
"""backend=none 无 recorder 消费该字段,派生结果必须是 False 而非 sqlite 那档。"""
s = GatewaySettings.from_env("LLM", env=_env())
assert s.telemetry_auto_migrate is False
def test_explicit_manual_overrides_sqlite_default(self):
s = GatewaySettings.from_env(
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="manual")
)
assert s.telemetry_auto_migrate is False
def test_explicit_auto_overrides_postgres_default(self):
s = GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_SCHEMA_MODE="auto"))
assert s.telemetry_auto_migrate is True
def test_invalid_mode_rejected_naming_the_env_key(self):
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_SCHEMA_MODE"):
GatewaySettings.from_env(
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="enabled")
)
class TestTelemetryTextCap:
"""`PGW_TELEMETRY_TEXT_CAP`(issue #12): 二态键,未设即不截断。
与 `PGW_TELEMETRY_SCHEMA_MODE` 的三态不同,这里"未设"本身就是最终答案
(不截断),没有需要按后端派生的第二种缺省,故不走 `_load_choice` 那套。
"""
def test_unset_key_means_no_truncation(self):
"""缺省不截断是人类决策: 截断后的遥测不再是审计证据、无法复现重放。"""
assert GatewaySettings.from_env("LLM", env=_env()).telemetry_text_cap is None
def test_positive_value_is_parsed_as_int(self):
s = GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2000"))
assert s.telemetry_text_cap == 2000
@pytest.mark.parametrize("raw", ["0", "-1"])
def test_non_positive_rejected(self, raw):
"""0 会把每条正文退化成一个省略标记,负数无意义;都不是"不截断"的写法。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP=raw))
def test_non_integer_rejected_naming_the_env_key(self):
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k"))
class TestOcrSettings:
"""M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""
_OCR_ENV = {
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
"OCR__MONKEY__1__API_KEY": "none", # 无鉴权占位惯例
"OCR__MONKEY__1__MODEL": "monkey-ocr",
"OCR__MONKEY__1__TIMEOUT_S": "120",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
}
def test_minimal_ocr_scope(self):
from polygateway.config import OcrSettings
settings = OcrSettings.from_env("OCR", env=dict(self._OCR_ENV))
gw = settings.gateway
assert gw.scope == "ocr" # GatewaySettings 统一小写归一(既有约定)
assert gw.sources[0].name == "monkey_1"
assert gw.sources[0].provider == "monkey"
assert gw.sources[0].trust_env is True
def test_scope_resilience_override(self):
from polygateway.config import OcrSettings
env = dict(self._OCR_ENV)
env["OCR__RETRY__MAX_ATTEMPTS"] = "5"
env["OCR__MONKEY__1__TRUST_ENV"] = "false"
settings = OcrSettings.from_env("OCR", env=env)
assert settings.gateway.retry.max_attempts == 5
assert settings.gateway.sources[0].trust_env is False
def test_missing_base_url_fails(self):
from polygateway.config import OcrSettings
env = {k: v for k, v in self._OCR_ENV.items() if k != "OCR__MONKEY__1__BASE_URL"}
with pytest.raises(ValueError):
OcrSettings.from_env("OCR", env=env)
class TestCrossFieldInvariants:
"""四条跨字段不变量必须在**任何**构造路径上生效(设计 2026-07-29)。
这些约束单看一个字段都合法,组合起来才非法,因此 types.py 各子配置的
__post_init__ 看不见——只能由聚合层 GatewaySettings 把关。守卫若只挂在
from_env 上,from_settings 这条同等官方的装配路(CLAUDE.md §4.5)就能
装出违反不变量的配置,类会存在于自己 docstring 声称不可能的状态。
每条不变量测两侧: 越界必拒、边界值(恰好相等)必过——收紧的是错的组合,
不是所有直接构造。
"""
def _base(self, **overrides) -> GatewaySettings:
return GatewaySettings.from_env("LLM", env=_env(**overrides))
def _with_watchdog(self) -> GatewaySettings:
"""带看门狗的基准: TTFT/inter-token 成对配置才满足 SourceConfig 不变式。"""
return self._base(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__BACKPRESSURE__STALL_WINDOW_S": "300",
}
)
# —— 源超时 ≤ permit 租约 TTL(ARCH §7.3: 防租约先于请求过期,并发悄悄超配额)——
def test_lease_rejects_timeout_above_ttl_on_direct_construction(self):
base = self._base() # 源 timeout_s=120
with pytest.raises(ValueError, match="lease_ttl_s"):
dataclasses.replace(base, lease_ttl_s=1.0)
def test_lease_accepts_timeout_equal_to_ttl(self):
base = self._base()
assert dataclasses.replace(base, lease_ttl_s=120.0).lease_ttl_s == 120.0
# —— stall 窗口 ≥ 最大源 TTFT(ARCH §7.3: 防正常慢首包被误判卡死掐断)——
def test_stall_rejects_window_below_max_ttft_on_direct_construction(self):
base = self._with_watchdog() # 源 ttft_timeout_s=30
narrowed = dataclasses.replace(base.backpressure, stall_window_s=20.0)
with pytest.raises(ValueError, match="stall_window_s"):
dataclasses.replace(base, backpressure=narrowed)
def test_stall_accepts_window_equal_to_max_ttft(self):
base = self._with_watchdog()
exact = dataclasses.replace(base.backpressure, stall_window_s=30.0)
assert dataclasses.replace(base, backpressure=exact).backpressure.stall_window_s == 30.0
# —— 探针租约 ≥ 最慢源超时 + 5(M2 设计 §3: 防半开探针在途即被接管)——
def test_probe_rejects_ttl_below_floor_on_direct_construction(self):
base = self._base() # 最慢 timeout_s=120,故下限 125
shortened = dataclasses.replace(base.breaker, probe_ttl_s=100.0)
with pytest.raises(ValueError, match="probe_ttl_s"):
dataclasses.replace(base, breaker=shortened)
def test_probe_accepts_ttl_at_floor(self):
base = self._base()
at_floor = dataclasses.replace(base.breaker, probe_ttl_s=125.0)
assert dataclasses.replace(base, breaker=at_floor).breaker.probe_ttl_s == 125.0
# —— sources 非空 ——
def test_empty_sources_rejected_with_actionable_message(self):
"""零源装出来的 client 选源必然失败;消息须点明原因,不能泄漏 max() 的内置异常。"""
base = self._base()
with pytest.raises(ValueError) as exc:
dataclasses.replace(base, sources=())
assert "至少一个源" in str(exc.value)
assert "empty sequence" not in str(exc.value)
# —— 装配路径覆盖 ——
def test_factory_cannot_receive_invalid_settings(self):
"""from_settings 这条路吃不到非法配置。
异常实际抛在实参求值(构造 settings)那一刻,而不是工厂内部——这正是
把守卫放构造期换来的性质: 非法实例根本不存在,无需每个工厂各自设防。
"""
base = self._base()
with pytest.raises(ValueError, match="lease_ttl_s"):
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
# —— 推理开关的装配守卫(issue #5)——
def _thinking_sources(self, provider, model, enable_thinking):
base = self._base()
src = dataclasses.replace(
base.sources[0], provider=provider, model=model, enable_thinking=enable_thinking
)
return dataclasses.replace(base, sources=(src,))
def test_model_that_cannot_disable_thinking_fails_at_assembly(self):
"""M2.x 关不掉推理: 配了 false 必须当场炸,而不是装出一个骗人的 client。"""
settings = self._thinking_sources("minimax", "MiniMax-M2.7", False)
with pytest.raises(ValueError, match="MiniMax-M2.7"):
GatewayClient.from_settings(settings)
def test_unknown_thinking_shape_fails_at_assembly(self):
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
settings = self._thinking_sources("openai", "kimi-k3", False)
with pytest.raises(ValueError, match="register_provider"):
GatewayClient.from_settings(settings)
def test_supported_combination_assembles(self):
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
assert GatewayClient.from_settings(settings) is not None
def test_not_taking_a_position_never_trips_the_guard(self):
"""enable_thinking=None(不干预)对任何 provider 都不该被守卫拦下。"""
settings = self._thinking_sources("openai", "kimi-k3", None)
assert GatewayClient.from_settings(settings) is not None
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
base = self._base()
with pytest.raises(ValueError, match="lease_ttl_s"):
OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))
# —— 第二轮(设计 2026-07-30): 后端枚举合法域 ——
@pytest.mark.parametrize(
("field", "bad_value"),
[
("limiter_backend", "rediss"),
("breaker_backend", "sqlite"),
("cache_backend", "postgres"),
("telemetry_backend", "redis"),
("selector", "random"),
("quota_full", "block"),
("circuit_open", "block"),
],
)
def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
"""域外取值此前只有 from_env 拦得住,直接构造会落进 _build_* 的 else 分支。"""
base = self._base()
with pytest.raises(ValueError, match=field):
dataclasses.replace(base, **{field: bad_value})
# —— 条件必填: 取 redis 的后端必须有 redis_url ——
@pytest.mark.parametrize("field", ["limiter_backend", "breaker_backend"])
def test_redis_backend_requires_redis_url(self, field):
"""client.py 的 assert settings.redis_url is not None 依赖的正是这条。"""
base = self._base() # redis_url=None
with pytest.raises(ValueError, match="redis_url"):
dataclasses.replace(base, **{field: "redis"})
def test_redis_cache_requires_redis_url(self):
base = self._base()
with pytest.raises(ValueError, match="redis_url"):
dataclasses.replace(base, cache_backend="redis", cache_namespace="ns", cache_ttl_s=60)
# —— 条件必填: 启用缓存必须有命名空间与正 TTL ——
def test_cache_requires_namespace(self):
"""缺命名空间即失去租户隔离,踩"无缓存毒化"铁律。"""
base = self._base()
with pytest.raises(ValueError, match="cache_namespace"):
dataclasses.replace(base, cache_backend="memory", cache_ttl_s=60)
def test_cache_ttl_must_be_positive(self):
"""from_env 明令禁止的"永不过期"不能从另一条路进来。"""
base = self._base()
with pytest.raises(ValueError, match="cache_ttl_s"):
dataclasses.replace(base, cache_backend="memory", cache_namespace="ns", cache_ttl_s=0)
# —— 条件必填: 遥测后端各自的落点 ——
def test_sqlite_telemetry_requires_path(self):
base = self._base()
with pytest.raises(ValueError, match="telemetry_sqlite_path"):
dataclasses.replace(base, telemetry_backend="sqlite")
def test_postgres_telemetry_requires_dsn(self):
base = self._base()
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
dataclasses.replace(base, telemetry_backend="postgres")
def test_none_backend_forces_auto_migrate_off(self):
"""backend=none 时没有 recorder 消费该字段,True 是自相矛盾的状态(issue #13)。
env 路的派生已给出 False,但直接构造与 dataclasses.replace 这两条同等
官方的装配路仍能把 True 传进来——不变量归位到构造期,三条路才一致。
"""
base = self._base() # telemetry_backend="none"
replaced = dataclasses.replace(base, telemetry_auto_migrate=True)
assert replaced.telemetry_auto_migrate is False
# —— 标量域 ——
def test_non_positive_text_cap_rejected(self):
"""env 路只覆盖 from_env;直接构造与 replace 同样能把 0 传进来(issue #12)。"""
base = self._base()
with pytest.raises(ValueError, match="telemetry_text_cap"):
dataclasses.replace(base, telemetry_text_cap=0)
def test_negative_structured_retries_rejected(self):
base = self._base()
with pytest.raises(ValueError, match="structured_max_retries"):
dataclasses.replace(base, structured_max_retries=-1)
def test_blank_scope_rejected(self):
"""空 scope 会污染遥测与缓存命名空间。"""
base = self._base()
with pytest.raises(ValueError, match="scope"):
dataclasses.replace(base, scope=" ")
# —— 合法组合仍可构造(收紧的是错的那些)——
def test_full_redis_stack_constructible(self):
base = self._base()
settings = dataclasses.replace(
base,
limiter_backend="redis",
breaker_backend="redis",
cache_backend="redis",
cache_namespace="ns",
cache_ttl_s=60,
redis_url="redis://127.0.0.1:6379/3",
)
assert settings.cache_ttl_s == 60 and settings.redis_url is not None
# —— Postgres DSN: 剥 SQLAlchemy 驱动后缀并出声(设计 §5 方案 C)——
def test_sqlalchemy_dsn_suffix_stripped_with_warning(self):
"""asyncpg 不认 `+driver`;库替调用方剥掉,但不静默——日志里看得见。"""
base = self._base()
with _captured_warnings() as warnings:
settings = dataclasses.replace(
base,
telemetry_backend="postgres",
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
)
assert settings.telemetry_pg_dsn == "postgresql://u:s3cret@h/db"
assert any("asyncpg" in m for m in warnings)
def test_dsn_warning_does_not_leak_credentials(self):
"""DSN 带密码,日志只能出现 scheme 段(P5: 敏感信息只走 .env)。"""
base = self._base()
with _captured_warnings() as warnings:
dataclasses.replace(
base,
telemetry_backend="postgres",
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
)
assert warnings and not any("s3cret" in m or "@h/db" in m for m in warnings)
def test_env_path_strips_dsn_without_warning(self):
"""env 路已在 _load_pg_dsn 剥过,不该给三项目的历史 DSN 写法刷噪音。"""
with _captured_warnings() as warnings:
settings = GatewaySettings.from_env(
"LLM",
env=_env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u@h/db",
),
)
assert settings.telemetry_pg_dsn == "postgresql://u@h/db"
assert not warnings
# —— 构造期规范化: env 路一直在做的,构造路也要做(否则两条路产出不同的值)——
@pytest.mark.parametrize("raw", ["LLM", " llm ", " LLM "])
def test_scope_normalized_on_direct_construction(self, raw):
"""scope 直接进 Redis key(pgw:limit:{scope}:…)。
大小写不一致会让同一逻辑 scope 的限流/熔断状态分裂到两套命名空间——
两边各记各的配额与熔断状态,分布式治理静默失效且不报错。
"""
base = self._base()
assert dataclasses.replace(base, scope=raw).scope == "llm"
def test_blank_redis_url_normalized_to_none(self):
"""空串此前只有 env 路归 None,构造路留着它骗过 `is None` 判断。"""
base = self._base()
assert dataclasses.replace(base, redis_url="").redis_url is None
def test_blank_redis_url_still_blocks_redis_backend(self):
"""归 None 后必须落进条件必填,而不是放行到 redis 库去抛连接串天书。"""
base = self._base()
with pytest.raises(ValueError, match="redis_url"):
dataclasses.replace(base, limiter_backend="redis", redis_url="")
def test_blank_pricing_path_normalized_to_none(self):
base = self._base()
assert dataclasses.replace(base, pricing_path="").pricing_path is None
# —— EmbeddingSettings 自身的字段域(此前只有 from_env 校验)——
@pytest.mark.parametrize("bad", [0, -3])
def test_embedding_settings_rejects_non_positive_batch_size(self, bad):
base = self._base()
with pytest.raises(ValueError, match="batch_size"):
EmbeddingSettings(gateway=base, batch_size=bad)
def test_embedding_settings_rejects_non_positive_expected_dim(self):
base = self._base()
with pytest.raises(ValueError, match="expected_dim"):
EmbeddingSettings(gateway=base, batch_size=8, expected_dim=0)
def test_embedding_settings_accepts_valid_values(self):
base = self._base()
settings = EmbeddingSettings(gateway=base, batch_size=8, expected_dim=1024)
assert settings.batch_size == 8 and settings.expected_dim == 1024
# —— 回归护栏: client.py 的 assert 前提确实被保证了 ——
def test_factory_accepts_valid_redis_stack(self):
"""补齐校验后,client.py:262/282/302 的 assert 退回成纯内部不变量声明。"""
base = self._base()
settings = dataclasses.replace(
base,
limiter_backend="redis",
breaker_backend="redis",
redis_url="redis://127.0.0.1:6379/3",
)
client = GatewayClient.from_settings(settings)
assert client is not None