Files
writer-work-flow/packages/llm_gateway/ww_llm_gateway/gateway.py
Yaojia Wang b523b4fd21 feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 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 走通闭环
2026-06-18 11:38:28 +02:00

100 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""网关核心:路由 → 调用适配器 → 记账 → 返回ARCH §4.14.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)