"""网关核心:路由 → 调用适配器 → 记账 → 返回(ARCH §4.1–4.3/§4.8)。 M1 单 provider,无回退/熔断(那是 M5/T5.4)。流式经 `stream()` 归一为 `Delta`。 日志脱敏:只记长度,绝不记原文/api key(不变量、§9.3)。 """ from __future__ import annotations from collections.abc import AsyncIterator, Callable import structlog from ww_shared import AppError, ErrorCode from .adapters.base import ProviderAdapter, ProviderUsage from .ledger import LedgerSink from .pricing import cost_minor from .routing import Route, resolve_route from .types import Delta, LlmRequest, LlmResponse, ServedBy, Tier, Usage log = structlog.get_logger(__name__) def _input_len(req: LlmRequest) -> int: if isinstance(req.input, str): return len(req.input) return sum(len(b.text) for b in req.input) class Gateway: def __init__( self, adapters: dict[str, ProviderAdapter], ledger: LedgerSink, resolver: Callable[[Tier], Route] = resolve_route, ) -> None: self._adapters = adapters self._ledger = ledger self._resolve = resolver def _adapter_for(self, provider: str) -> ProviderAdapter: adapter = self._adapters.get(provider) if adapter is None: raise AppError(ErrorCode.LLM_UNAVAILABLE, f"no adapter for provider {provider!r}") return adapter def _usage(self, route: Route, pu: ProviderUsage) -> Usage: cost, currency = cost_minor(route.provider, route.model, pu.input_tokens, pu.output_tokens) return Usage( provider=route.provider, model=route.model, input_tokens=pu.input_tokens, output_tokens=pu.output_tokens, cache_read_tokens=pu.cache_read_tokens, cost_minor=cost, currency=currency, ) def _log_call(self, req: LlmRequest, usage: Usage, *, stream: bool) -> None: log.info( "llm_call", provider=usage.provider, model=usage.model, tier=req.tier, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, cache_read_tokens=usage.cache_read_tokens, cost_minor=usage.cost_minor, currency=usage.currency, stream=stream, input_chars=_input_len(req), project_id=str(req.scope.project_id) if req.scope.project_id else None, ) async def run(self, req: LlmRequest) -> LlmResponse: route = self._resolve(req.tier) adapter = self._adapter_for(route.provider) result = await adapter.complete(req, route.model) usage = self._usage(route, result.usage) await self._ledger.record(req.scope, usage) self._log_call(req, usage, stream=False) return LlmResponse( text=result.text, parsed=result.parsed, usage=usage, served_by=ServedBy(provider=route.provider, model=route.model), ) async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]: route = self._resolve(req.tier) adapter = self._adapter_for(route.provider) final = ProviderUsage(input_tokens=0, output_tokens=0) async for chunk in adapter.stream(req, route.model): if chunk.text: yield Delta(text=chunk.text) if chunk.usage is not None: final = chunk.usage usage = self._usage(route, final) await self._ledger.record(req.scope, usage) self._log_call(req, usage, stream=True)