Files
PolyGateway/src/polygateway/config.py
T
iomgaa f76a89b1a1 fix(config): 装配守卫挪进 __post_init__,任何构造路径都生效
_guard_lease 与 _guard_stall 原先只写在 GatewaySettings.from_env 里,
而 CLAUDE.md §4.5 规定装配有两条官方路径。结果是走 from_settings() 能装出
一个违反类不变量的 settings —— 这个类的 docstring 声称「构造经 from_env 聚合
并通过全部守卫」,但它可以合法地存在于自己声称不可能的状态。

守卫挪进 __post_init__,与同族的 SourceConfig 一致。放构造期而不是在每个工厂里
各加一行:三个 client(Gateway/Ocr/Embedding)各有两个工厂,共六个入口,
挂构造期是一处,挂工厂是六处要保持同步——那正是「每个调用方各维护一份副本」
的毛病,只是挪进了库里。OcrSettings 与 EmbeddingSettings 都包着一个
GatewaySettings,因此一并覆盖。

两个守卫的报错文案改为点字段名,环境变量键降为补充信息。守卫现在每次构造都跑,
而走 from_settings 的调用方从没设过那些键,让他「调大 PGW_LEASE_TTL_S」
是句没法执行的建议。

行为收紧:直接构造或 dataclasses.replace 出非法组合,现在构造期就抛 ValueError,
而不是留到运行时表现为租约先于请求过期、或正常慢首包被误判为卡死。
经 from_env 装配的调用方不受影响——那条路本来就跑这两个守卫。

测试:新增 TestGuardsRunOnEveryConstruction 四条(先失败 3 条后全过)。
既有 447 passed / 34 skipped 全部保持,无回归;ruff check、ruff format --check、
lint-imports 三门均通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:32:03 -04:00

458 lines
19 KiB
Python

"""配置聚合与装配期校验(M1 设计 §8;ARCH §9)。
键族: 多源 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`、scope 韧性键、平铺 LLM_*
简写(单 scope 迁移零改名)、PGW_* 装配键。缺关键配置直接报错,严禁默认
值兜底;scope 键与平铺键并存时 scope 键优先。
实现说明: 多源键族是动态键名,pydantic-settings 的静态字段模型无法表达,
故用其底层 python-dotenv 读 `.env` 并与 os.environ 合并(环境变量优先),
fail-loud 校验语义与 pydantic-settings 一致。
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
from dotenv import dotenv_values
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
GlobalLimits,
RetryPolicy,
SourceConfig,
)
if TYPE_CHECKING:
from collections.abc import Mapping
# FIELD → (SourceConfig 属性, 类型);CHS config.py:95-104 全集 + M1 新增
_SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"BASE_URL": ("base_url", "str"),
"API_KEY": ("api_key", "str"),
"MODEL": ("model", "str"),
"MAX_CONCURRENCY": ("max_concurrency", "int"),
"RPM": ("rpm", "int"),
"TPM": ("tpm", "int"),
"EST_TOKENS": ("est_tokens", "int"),
"TIMEOUT_S": ("timeout_s", "float"),
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
"ENABLE_THINKING": ("enable_thinking", "bool"),
"MISSING_DONE": ("missing_done", "str"),
"TRUST_ENV": ("trust_env", "bool"),
}
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
_DEFAULT_STALL_WINDOW_S = 300.0
_DEFAULT_POLL_INTERVAL_S = 0.05
_DEFAULT_LEASE_TTL_S = 1500.0 # CHS _DEFAULT_LEASE_TTL_MS 同源
def _cast(raw: str, kind: str, key: str) -> object:
try:
if kind == "int":
return int(raw)
if kind == "float":
return float(raw)
if kind == "bool":
lowered = raw.strip().lower()
if lowered in ("1", "true", "yes", "on"):
return True
if lowered in ("0", "false", "no", "off"):
return False
raise ValueError(f"非法布尔值: {raw!r}")
return raw
except ValueError as exc:
raise ValueError(f"配置 {key} 解析失败: {exc}") from exc
def _first(env: Mapping[str, str], *keys: str) -> tuple[str, str] | None:
for key in keys:
raw = env.get(key)
if raw is not None and raw != "":
return key, raw
return None
def _require(env: Mapping[str, str], *keys: str) -> tuple[str, str]:
found = _first(env, *keys)
if found is None:
raise ValueError(f"缺关键配置: 需设置 {' 或 '.join(keys)}")
return found
@dataclass(frozen=True)
class GatewaySettings:
"""一个 scope 的完整装配配置;**任何**构造路径都通过全部守卫。
守卫在 `__post_init__` 里跑,而不是在 `from_env` 里。原因是装配有两条官方路径
(CLAUDE.md §4.5: `from_env()` 与 `from_settings()`),守卫只挂在其中一条上的话,
另一条就能装出违反本类不变量的配置 —— 类会存在于它自己声称不可能的状态。
放构造期还有一个好处: 三个 client(Gateway/Ocr/Embedding)各有两个工厂,
共六个入口,挂在这里是一处,挂在工厂里是六处要保持同步。
与 `SourceConfig.__post_init__` 的做法一致。
"""
scope: str
sources: tuple[SourceConfig, ...]
global_limits: GlobalLimits
retry: RetryPolicy
breaker: BreakerConfig
backpressure: BackpressurePolicy
selector: str
quota_full: str
limiter_backend: str
breaker_backend: str
cache_backend: str
cache_namespace: str | None
cache_ttl_s: int | None
telemetry_backend: str
telemetry_sqlite_path: str | None
telemetry_pg_dsn: str | None
redis_url: str | None
pricing_path: str | None
structured_max_retries: int
lease_ttl_s: float
def __post_init__(self) -> None:
_guard_lease(self)
_guard_stall(self)
@classmethod
def from_env(
cls,
scope: str = "LLM",
env: Mapping[str, str] | None = None,
*,
env_file: str = ".env",
) -> GatewaySettings:
"""聚合 env(缺省 .env + os.environ,后者优先)并执行装配守卫。"""
if env is None:
env = {
k: v for k, v in {**dotenv_values(env_file), **os.environ}.items() if v is not None
}
scope_u = scope.upper()
sources = _load_sources(scope_u, env)
global_limits = _load_global_limits(scope_u, env)
retry = _load_retry(scope_u, env)
breaker = _load_breaker(scope_u, env, sources, global_limits)
settings = cls(
scope=scope_u.lower(),
sources=tuple(sources),
global_limits=global_limits,
retry=retry,
breaker=breaker,
backpressure=_load_backpressure(scope_u, env),
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
**_load_pgw(env),
)
# 守卫已在 __post_init__ 里跑过,这里不再重复调用。
return settings
def _load_sources(scope: str, env: Mapping[str, str]) -> list[SourceConfig]:
specs: dict[tuple[str, str], dict[str, object]] = {}
for key, raw in env.items():
parts = key.split("__")
if len(parts) != 4 or parts[0] != scope or parts[1] in _RESERVED_SEGMENTS:
continue
_, provider, index, field = parts
if field not in _SOURCE_FIELDS:
raise ValueError(f"未知源配置字段 {field}(键 {key});允许: {sorted(_SOURCE_FIELDS)}")
attr, kind = _SOURCE_FIELDS[field]
specs.setdefault((provider.lower(), index), {})[attr] = _cast(raw, kind, key)
if not specs:
raise ValueError(f"scope {scope} 未配置任何源({scope}__{{PROVIDER}}__{{N}}__{{FIELD}})")
return [
_build_source(scope, provider, index, spec, env)
for (provider, index), spec in sorted(specs.items())
]
def _build_source(
scope: str, provider: str, index: str, spec: dict[str, object], env: Mapping[str, str]
) -> SourceConfig:
prefix = f"{scope}__{provider.upper()}__{index}"
for field in ("BASE_URL", "API_KEY", "MODEL"):
attr, _ = _SOURCE_FIELDS[field]
if attr not in spec:
raise ValueError(f"缺关键配置: {prefix}__{field}")
if "timeout_s" not in spec:
key, raw = _require(env, f"{prefix}__TIMEOUT_S", "LLM_TIMEOUT")
spec["timeout_s"] = _cast(raw, "float", key)
# 平铺看门狗键作为缺省(成对才生效,遵守 SourceConfig 不变式)
if "ttft_timeout_s" not in spec and "inter_token_timeout_s" not in spec:
flat = _first(env, "LLM_TTFT_TIMEOUT")
flat_inter = _first(env, "LLM_INTER_TOKEN_TIMEOUT")
if flat and flat_inter:
spec["ttft_timeout_s"] = _cast(flat[1], "float", flat[0])
spec["inter_token_timeout_s"] = _cast(flat_inter[1], "float", flat_inter[0])
return SourceConfig(name=f"{provider}_{index}", provider=provider, **spec)
def _load_global_limits(scope: str, env: Mapping[str, str]) -> GlobalLimits:
def read(field: str) -> int:
found = _first(env, f"{scope}__GLOBAL__{field}")
return int(_cast(found[1], "int", found[0])) if found else 0
return GlobalLimits(max_concurrency=read("MAX_CONCURRENCY"), rpm=read("RPM"), tpm=read("TPM"))
def _load_retry(scope: str, env: Mapping[str, str]) -> RetryPolicy:
key_a, attempts = _require(env, f"{scope}__RETRY__MAX_ATTEMPTS", "LLM_MAX_RETRIES")
key_b, base = _require(env, f"{scope}__RETRY__BACKOFF_BASE_S", "LLM_RETRY_BASE_DELAY")
key_m, max_d = _require(env, f"{scope}__RETRY__BACKOFF_MAX_S", "LLM_RETRY_MAX_DELAY")
return RetryPolicy(
max_attempts=int(_cast(attempts, "int", key_a)),
backoff_base_s=float(_cast(base, "float", key_b)),
backoff_max_s=float(_cast(max_d, "float", key_m)),
)
def _load_breaker(
scope: str, env: Mapping[str, str], sources: list[SourceConfig], global_limits: GlobalLimits
) -> BreakerConfig:
key_t, thr = _require(env, f"{scope}__BREAKER__FAIL_THRESHOLD", "LLM_CIRCUIT_BREAKER_THRESHOLD")
key_c, cool = _require(env, f"{scope}__BREAKER__COOLDOWN_S", "LLM_CIRCUIT_BREAKER_COOLDOWN")
threshold = int(_cast(thr, "int", key_t))
cooldown_s = float(_cast(cool, "float", key_c))
# 有效阈值 = max(配置值, 源级并发×2)。M2.5 修正: 只看源级并发——M2 曾用
# 全局并发抬升(SOAK 100→阈值 200)使熔断失灵(病灶 2,设计 2026-07-21-m25)
concurrency = max((s.max_concurrency for s in sources), default=0)
if concurrency > 0:
threshold = max(threshold, concurrency * 2)
slowest = max(s.timeout_s for s in sources)
probe_floor = slowest + 5.0 # CHS container.py:274-275: 最慢调用 + 清理宽限
probe = _first(env, f"{scope}__BREAKER__PROBE_TTL_S")
if probe is not None:
probe_ttl_s = float(_cast(probe[1], "float", probe[0]))
# 装配守卫(M2 设计 §3): 探针租约必须撑过一次最慢调用,否则半开探针在途即被接管
if probe_ttl_s < probe_floor:
raise ValueError(
f"probe_ttl_s({probe_ttl_s})须 ≥ 最大源 timeout_s + 5({probe_floor});"
f"调大 {probe[0]} 或调小源超时"
)
else:
# 派生规则: 探针租约须撑过一次最慢调用,且不短于冷却期(第三项保证守卫恒成立)
probe_ttl_s = max(2 * slowest, cooldown_s, probe_floor)
# M2.5 失败率通道参数(可选键,库缺省——韧性参数缺省先例同 backpressure)
raw_min = env.get(f"{scope}__BREAKER__MIN_CALLS")
min_calls = int(_cast(raw_min, "int", f"{scope}__BREAKER__MIN_CALLS")) if raw_min else 10
fail_rate = _opt_float(env, f"{scope}__BREAKER__FAIL_RATE", 0.6)
window_s = _opt_float(env, f"{scope}__BREAKER__WINDOW_S", 60.0)
max_cooldown_s = _opt_float(env, f"{scope}__BREAKER__MAX_COOLDOWN_S", max(300.0, cooldown_s))
return BreakerConfig(
fail_threshold=threshold,
cooldown_s=cooldown_s,
probe_ttl_s=probe_ttl_s,
min_calls=min_calls,
fail_rate=fail_rate,
window_s=window_s,
max_cooldown_s=max_cooldown_s,
)
def _opt_float(env: Mapping[str, str], key: str, default: float) -> float:
"""可选韧性参数: 缺省用库值,显式配置则解析(坏值 fail-loud)。"""
raw = env.get(key)
if raw is None or raw == "":
return default
return float(_cast(raw, "float", key))
def _load_backpressure(scope: str, env: Mapping[str, str]) -> BackpressurePolicy:
stall = _first(env, f"{scope}__BACKPRESSURE__STALL_WINDOW_S")
poll = _first(env, f"{scope}__BACKPRESSURE__POLL_INTERVAL_S")
return BackpressurePolicy(
stall_window_s=float(_cast(stall[1], "float", stall[0]))
if stall
else _DEFAULT_STALL_WINDOW_S,
poll_interval_s=float(_cast(poll[1], "float", poll[0]))
if poll
else _DEFAULT_POLL_INTERVAL_S,
)
def _load_choice(env: Mapping[str, str], key: str, allowed: frozenset[str], default: str) -> str:
found = _first(env, key)
value = found[1] if found else default
if value not in allowed:
raise ValueError(f"配置 {key} 非法值 {value!r};允许: {sorted(allowed)}")
return value
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
limiter_backend = _load_choice(
env, "PGW_LIMITER_BACKEND", frozenset({"memory", "redis"}), "memory"
)
breaker_backend = _load_choice(
env, "PGW_BREAKER_BACKEND", frozenset({"memory", "redis"}), "memory"
)
_, cache_backend = _require(env, "PGW_CACHE_BACKEND")
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
if cache_backend not in ("redis", "memory", "none"):
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
if telemetry_backend not in ("sqlite", "postgres", "none"):
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
redis_url = env.get("REDIS_URL") or None
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
raise ValueError("缺关键配置: 限流/熔断后端取 redis 需设置 REDIS_URL")
return {
"limiter_backend": limiter_backend,
"breaker_backend": breaker_backend,
"cache_backend": cache_backend,
**_load_cache_keys(env, cache_backend, redis_url),
"telemetry_backend": telemetry_backend,
"telemetry_sqlite_path": _require(env, "PGW_TELEMETRY_SQLITE_PATH")[1]
if telemetry_backend == "sqlite"
else None,
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
"redis_url": redis_url,
"pricing_path": env.get("PGW_PRICING_PATH") or None,
"structured_max_retries": _load_structured_retries(env),
"lease_ttl_s": _load_lease_ttl(env),
}
def _load_pg_dsn(env: Mapping[str, str]) -> str:
"""读取 Postgres DSN 并剥 SQLAlchemy 风格驱动后缀(asyncpg 不认 `+driver`)。"""
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
scheme, sep, rest = dsn.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}"
def _load_cache_keys(
env: Mapping[str, str], cache_backend: str, redis_url: str | None
) -> dict[str, object]:
if cache_backend == "none":
return {"cache_namespace": None, "cache_ttl_s": None}
_, namespace = _require(env, "PGW_CACHE_NAMESPACE")
key, ttl_raw = _require(env, "PGW_CACHE_TTL_S")
ttl = int(_cast(ttl_raw, "int", key))
if ttl <= 0:
raise ValueError("PGW_CACHE_TTL_S(TTL)必须 > 0,禁止永不过期")
if cache_backend == "redis" and redis_url is None:
raise ValueError("缺关键配置: PGW_CACHE_BACKEND=redis 需设置 REDIS_URL")
return {"cache_namespace": namespace, "cache_ttl_s": ttl}
def _load_structured_retries(env: Mapping[str, str]) -> int:
found = _first(env, "PGW_STRUCTURED_MAX_RETRIES")
value = int(_cast(found[1], "int", found[0])) if found else 2 # M2.5 迭代4: 1→2
if value < 0:
raise ValueError("PGW_STRUCTURED_MAX_RETRIES 不能为负")
return value
def _guard_lease(settings: GatewaySettings) -> None:
"""装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。
文案点字段名而不是只点环境变量键: 守卫在每次构造时都跑,而走 `from_settings`
的调用方从没设过那些键,让他去"调大 PGW_LEASE_TTL_S"是句没法执行的建议。
环境变量键作为补充信息附在后面,给走 `from_env` 的人用。
"""
slowest = max(s.timeout_s for s in settings.sources)
if slowest > settings.lease_ttl_s:
raise ValueError(
f"源最大 timeout_s({slowest})超过 permit 租约 TTL(lease_ttl_s="
f"{settings.lease_ttl_s});调大 lease_ttl_s 或调小源的 timeout_s"
f"(走 from_env 时对应的键是 PGW_LEASE_TTL_S 与 {{SCOPE}}__{{PROVIDER}}__{{N}}__TIMEOUT_S)"
)
def _guard_stall(settings: GatewaySettings) -> None:
"""装配守卫: stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死(ARCH §7.3)。
文案点字段名的理由同 `_guard_lease`。
"""
ttfts = [s.ttft_timeout_s for s in settings.sources if s.ttft_timeout_s is not None]
if ttfts and settings.backpressure.stall_window_s < max(ttfts):
raise ValueError(
f"backpressure.stall_window_s({settings.backpressure.stall_window_s})须 ≥ 最大源 "
f"ttft_timeout_s({max(ttfts)});调大 stall_window_s 或调小 ttft_timeout_s"
f"(走 from_env 时对应的键是 {{SCOPE}}__BACKPRESSURE__STALL_WINDOW_S 与 "
f"{{SCOPE}}__{{PROVIDER}}__{{N}}__TTFT_TIMEOUT_S)"
)
def _load_lease_ttl(env: Mapping[str, str]) -> float:
found = _first(env, "PGW_LEASE_TTL_S")
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
@dataclass(frozen=True)
class EmbeddingSettings:
"""Embedding scope 装配配置(M2 §7): 复用 GatewaySettings + embedding 专用键。
专用键不进 GatewaySettings(LLM scope 不受影响): `{SCOPE}__BATCH_SIZE`
必填(分批是行为关键,不设默认)、`{SCOPE}__NORMALIZE`/`{SCOPE}__EXPECTED_DIM`
可选。cache/structured 键对 embedding 无意义,装配时忽略。
"""
gateway: GatewaySettings
batch_size: int
normalize: bool = False
expected_dim: int | None = None
@classmethod
def from_env(
cls,
scope: str = "EMBED",
env: Mapping[str, str] | None = None,
*,
env_file: str = ".env",
) -> EmbeddingSettings:
if env is None:
env = {
k: v for k, v in {**dotenv_values(env_file), **os.environ}.items() if v is not None
}
scope_u = scope.upper()
gateway = GatewaySettings.from_env(scope_u, env=env)
key, raw = _require(env, f"{scope_u}__BATCH_SIZE")
batch_size = int(_cast(raw, "int", key))
if batch_size < 1:
raise ValueError(f"{scope_u}__BATCH_SIZE 必须 ≥ 1")
norm = _first(env, f"{scope_u}__NORMALIZE")
dim = _first(env, f"{scope_u}__EXPECTED_DIM")
expected_dim = int(_cast(dim[1], "int", dim[0])) if dim else None
if expected_dim is not None and expected_dim < 1:
raise ValueError(f"{scope_u}__EXPECTED_DIM 必须 ≥ 1")
return cls(
gateway=gateway,
batch_size=batch_size,
normalize=bool(_cast(norm[1], "bool", norm[0])) if norm else False,
expected_dim=expected_dim,
)
@dataclass(frozen=True)
class OcrSettings:
"""OCR scope 装配配置(M3 设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。
cache/structured/pricing 键对 OCR 无意义,装配时忽略;TPM 闸不启用
(tpm=0)、看门狗键不配(非流式)由源键缺省自然达成。api_key 惯例填
"none"(MonkeyOCR 无鉴权,SourceConfig 非空校验用占位)。
"""
gateway: GatewaySettings
@classmethod
def from_env(
cls,
scope: str = "OCR",
env: Mapping[str, str] | None = None,
*,
env_file: str = ".env",
) -> OcrSettings:
if env is None:
env = {
k: v for k, v in {**dotenv_values(env_file), **os.environ}.items() if v is not None
}
return cls(gateway=GatewaySettings.from_env(scope.upper(), env=env))