feat: add gateway client with env-driven assembly
Includes config aggregation for multi-source env keys, from_env and from_settings factories with explicit shared-backend injection, gather_bounded, top-level exports, tightened import-linter layers with the gate removed from the Makefile, and the finalized .env.example.
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"""配置聚合与装配期校验(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"})
|
||||
_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 的完整装配配置;构造经 from_env 聚合并通过全部守卫。"""
|
||||
|
||||
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
|
||||
redis_url: str | None
|
||||
structured_max_retries: int
|
||||
lease_ttl_s: float
|
||||
|
||||
@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, "round_robin"),
|
||||
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
|
||||
**_load_pgw(env),
|
||||
)
|
||||
_guard_lease(settings)
|
||||
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)——三项目 .env 注释的手动约定入库(设计 §9 行 4)
|
||||
concurrency = global_limits.max_concurrency or max(
|
||||
(s.max_concurrency for s in sources), default=0
|
||||
)
|
||||
if concurrency > 0:
|
||||
threshold = max(threshold, concurrency * 2)
|
||||
probe = _first(env, f"{scope}__BREAKER__PROBE_TTL_S")
|
||||
if probe is not None:
|
||||
probe_ttl_s = float(_cast(probe[1], "float", probe[0]))
|
||||
else:
|
||||
# 派生规则: 探针租约须撑过一次最慢调用,且不短于冷却期
|
||||
probe_ttl_s = max(2 * max(s.timeout_s for s in sources), cooldown_s)
|
||||
return BreakerConfig(fail_threshold=threshold, cooldown_s=cooldown_s, probe_ttl_s=probe_ttl_s)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
if "redis" in (limiter_backend, breaker_backend):
|
||||
raise ValueError("限流/熔断 Redis 后端在 M2 交付;M1 仅支持 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", "none"):
|
||||
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}(postgres 在 M2)")
|
||||
redis_url = env.get("REDIS_URL") or None
|
||||
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,
|
||||
"redis_url": redis_url,
|
||||
"structured_max_retries": _load_structured_retries(env),
|
||||
"lease_ttl_s": _load_lease_ttl(env),
|
||||
}
|
||||
|
||||
|
||||
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 1
|
||||
if value < 0:
|
||||
raise ValueError("PGW_STRUCTURED_MAX_RETRIES 不能为负")
|
||||
return value
|
||||
|
||||
|
||||
def _guard_lease(settings: GatewaySettings) -> None:
|
||||
"""装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。"""
|
||||
slowest = max(s.timeout_s for s in settings.sources)
|
||||
if slowest > settings.lease_ttl_s:
|
||||
raise ValueError(
|
||||
f"源最大 timeout_s({slowest})超过 permit 租约 TTL({settings.lease_ttl_s});"
|
||||
f"调大 PGW_LEASE_TTL_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
|
||||
Reference in New Issue
Block a user