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,302 @@
|
||||
"""GatewayClient: 唯一组装层(D1 组装点)+ from_env/from_settings 工厂 + gather_bounded。
|
||||
|
||||
90% 用户三行起步: `client = GatewayClient.from_env(); resp = await client.chat(messages)`。
|
||||
多逻辑角色 = 每角色调一次 `from_env(scope=...)`;共享限流/熔断状态 = 自建
|
||||
后端实例(源表取并集)显式传入多次调用——共享必须显式,禁止隐式全局
|
||||
(VT `evolve_llm = llm` 教训;ARCH §7.7 R5)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
||||
|
||||
from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
from polygateway.config import GatewaySettings
|
||||
from polygateway.middleware.base import compose
|
||||
from polygateway.middleware.cache import CacheMW
|
||||
from polygateway.middleware.retry import RetryMW
|
||||
from polygateway.middleware.structured import StructuredMW
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
||||
from polygateway.providers import get_provider
|
||||
from polygateway.sources import LeastInflightSelector, RoundRobinSelector, SourceCooldownMemo
|
||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||
from polygateway.types import ChatRequest, LLMResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Iterable, Mapping
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from polygateway.ports import (
|
||||
CacheBackend,
|
||||
Middleware,
|
||||
ProviderGate,
|
||||
RateLimiter,
|
||||
SourceSelector,
|
||||
StructuredOutputStrategy,
|
||||
TelemetryRecorder,
|
||||
Transport,
|
||||
)
|
||||
from polygateway.providers import ProviderProfile
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class GatewayClient:
|
||||
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
sources: list[SourceConfig],
|
||||
selector: SourceSelector,
|
||||
limiter: RateLimiter,
|
||||
gate: ProviderGate,
|
||||
transport: Transport,
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
cache: CacheBackend | None = None,
|
||||
cache_namespace: str | None = None,
|
||||
cache_ttl_s: int | None = None,
|
||||
structured_strategy: StructuredOutputStrategy | None = None,
|
||||
structured_escalation: StructuredOutputStrategy | None = None,
|
||||
structured_max_retries: int = 1,
|
||||
now: Any = time.monotonic,
|
||||
sleep: Any = asyncio.sleep,
|
||||
rng: Any = random.random,
|
||||
) -> None:
|
||||
emitter = TelemetryEmitter(telemetry) if telemetry is not None else None
|
||||
terminal = RetryMW(
|
||||
scope=scope,
|
||||
sources=sources,
|
||||
selector=selector,
|
||||
limiter=limiter,
|
||||
gate=gate,
|
||||
transport=transport,
|
||||
retry=retry,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
cooldown_memo=SourceCooldownMemo(now=now),
|
||||
emitter=emitter,
|
||||
now=now,
|
||||
sleep=sleep,
|
||||
rng=rng,
|
||||
)
|
||||
middlewares: list[Middleware] = []
|
||||
if emitter is not None:
|
||||
middlewares.append(TelemetryMW(emitter, now=now))
|
||||
if cache is not None:
|
||||
if cache_namespace is None or cache_ttl_s is None:
|
||||
raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s")
|
||||
# 多源 scope 的 key 身份 = 排序去重的 model 合集;源集合变化 → 一次性冷启动
|
||||
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||
middlewares.append(
|
||||
CacheMW(
|
||||
backend=cache,
|
||||
model_fingerprint=fingerprint,
|
||||
default_namespace=cache_namespace,
|
||||
ttl_s=cache_ttl_s,
|
||||
strategy=structured_strategy,
|
||||
)
|
||||
)
|
||||
if structured_strategy is not None:
|
||||
middlewares.append(
|
||||
StructuredMW(
|
||||
strategy=structured_strategy,
|
||||
max_retries=structured_max_retries,
|
||||
escalation=structured_escalation,
|
||||
)
|
||||
)
|
||||
self._structured_available = structured_strategy is not None
|
||||
self._handler = compose(middlewares, terminal)
|
||||
self._transport = transport
|
||||
self._telemetry = telemetry
|
||||
self._cache = cache
|
||||
self._closed = False
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
cache_namespace: str | None = None,
|
||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||
stream: bool = True,
|
||||
) -> LLMResponse:
|
||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。"""
|
||||
if structured is not None and not self._structured_available:
|
||||
raise ImportError(
|
||||
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
||||
)
|
||||
request = ChatRequest(
|
||||
messages=messages,
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
cache_salt=cache_salt,
|
||||
cache_namespace=cache_namespace,
|
||||
structured=structured,
|
||||
stream=stream,
|
||||
)
|
||||
return await self._handler(request)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""幂等释放: transport 连接池、遥测连接、缓存客户端。"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
transport_aclose = getattr(self._transport, "aclose", None)
|
||||
if transport_aclose is not None:
|
||||
await transport_aclose()
|
||||
telemetry_close = getattr(self._telemetry, "close", None)
|
||||
if telemetry_close is not None:
|
||||
telemetry_close()
|
||||
cache_aclose = getattr(self._cache, "aclose", None)
|
||||
if cache_aclose is not None:
|
||||
await cache_aclose()
|
||||
|
||||
async def __aenter__(self) -> GatewayClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
# —— 工厂 ——
|
||||
|
||||
@classmethod
|
||||
def from_settings(
|
||||
cls,
|
||||
settings: GatewaySettings,
|
||||
*,
|
||||
limiter: RateLimiter | None = None,
|
||||
gate: ProviderGate | None = None,
|
||||
cache: CacheBackend | None = None,
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
registry: Mapping[str, ProviderProfile] | None = None,
|
||||
) -> GatewayClient:
|
||||
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
|
||||
sources = list(settings.sources)
|
||||
profiles = [get_provider(s.provider, registry=registry) for s in sources]
|
||||
strategy, escalation = _build_structured(profiles)
|
||||
return cls(
|
||||
scope=settings.scope,
|
||||
sources=sources,
|
||||
selector=_build_selector(settings.selector),
|
||||
limiter=limiter
|
||||
or InMemoryLimiter(
|
||||
scope=settings.scope,
|
||||
sources={s.name: s for s in sources},
|
||||
global_limits=settings.global_limits,
|
||||
lease_ttl_s=settings.lease_ttl_s,
|
||||
),
|
||||
gate=gate or InMemoryGate(config=settings.breaker),
|
||||
transport=OpenAICompatTransport(registry=registry),
|
||||
retry=settings.retry,
|
||||
backpressure=settings.backpressure,
|
||||
quota_full=settings.quota_full,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(settings),
|
||||
cache=cache if cache is not None else _build_cache(settings),
|
||||
cache_namespace=settings.cache_namespace,
|
||||
cache_ttl_s=settings.cache_ttl_s,
|
||||
structured_strategy=strategy,
|
||||
structured_escalation=escalation,
|
||||
structured_max_retries=settings.structured_max_retries,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
scope: str = "LLM",
|
||||
*,
|
||||
limiter: RateLimiter | None = None,
|
||||
gate: ProviderGate | None = None,
|
||||
cache: CacheBackend | None = None,
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
registry: Mapping[str, ProviderProfile] | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> GatewayClient:
|
||||
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
|
||||
return cls.from_settings(
|
||||
GatewaySettings.from_env(scope, env=env),
|
||||
limiter=limiter,
|
||||
gate=gate,
|
||||
cache=cache,
|
||||
telemetry=telemetry,
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
|
||||
def _build_selector(name: str) -> SourceSelector:
|
||||
return RoundRobinSelector() if name == "round_robin" else LeastInflightSelector()
|
||||
|
||||
|
||||
def _build_cache(settings: GatewaySettings) -> CacheBackend | None:
|
||||
if settings.cache_backend == "none":
|
||||
return None
|
||||
if settings.cache_backend == "memory":
|
||||
return InMemoryCache()
|
||||
from polygateway.backends.redis_cache import RedisCache
|
||||
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
return RedisCache.from_url(settings.redis_url)
|
||||
|
||||
|
||||
def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
||||
if settings.telemetry_backend == "none":
|
||||
return None
|
||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||
|
||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: config 已校验
|
||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
||||
|
||||
|
||||
def _build_structured(
|
||||
profiles: list[ProviderProfile],
|
||||
) -> tuple[StructuredOutputStrategy | None, StructuredOutputStrategy | None]:
|
||||
"""按注册表能力选策略(设计 §5): 全员支持原生 schema 才用 NativeSchema。
|
||||
|
||||
json_repair 缺失(未装 structured extra)→ 返回 (None, None),
|
||||
chat(structured=...) 时显式报缺 extra,绝不静默跳过校验。
|
||||
"""
|
||||
try:
|
||||
from polygateway.structured.json_repair import JsonRepairStrategy
|
||||
from polygateway.structured.native_schema import NativeSchemaStrategy
|
||||
except ImportError:
|
||||
return None, None
|
||||
try:
|
||||
native_all = all(p.supports_native_schema for p in profiles)
|
||||
if native_all:
|
||||
return NativeSchemaStrategy(), NativeSchemaStrategy()
|
||||
return JsonRepairStrategy(), None
|
||||
except ImportError:
|
||||
return None, None
|
||||
|
||||
|
||||
async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> list[_T]:
|
||||
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
|
||||
|
||||
语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
|
||||
"""
|
||||
if concurrency < 1:
|
||||
raise ValueError("concurrency 必须 ≥ 1")
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _run(aw: Awaitable[_T]) -> _T:
|
||||
async with sem:
|
||||
return await aw
|
||||
|
||||
return await asyncio.gather(*(_run(aw) for aw in aws))
|
||||
Reference in New Issue
Block a user