- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由 - 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本) - LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error) - API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接) - 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页 - M1 E2E:真实 DB + mock 网关零 token 走通闭环
100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""网关核心:路由 → 调用适配器 → 记账 → 返回(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)
|