232 lines
10 KiB
Python
232 lines
10 KiB
Python
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
|
||
|
||
import pytest
|
||
|
||
from polygateway.config import GatewaySettings
|
||
|
||
_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",
|
||
}
|
||
|
||
|
||
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 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_selector_and_quota_full(self):
|
||
s = GatewaySettings.from_env("LLM", env=_env())
|
||
assert s.selector == "round_robin" and s.quota_full == "wait"
|
||
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_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(.env 注释约定入库)
|
||
assert s.breaker.fail_threshold == 16
|
||
|
||
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_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
|