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 走通闭环
This commit is contained in:
51
packages/llm_gateway/ww_llm_gateway/adapters/base.py
Normal file
51
packages/llm_gateway/ww_llm_gateway/adapters/base.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""适配器接口与中间数据形(ARCH §4.2/§4.4)。
|
||||
|
||||
适配器把 `LlmRequest` 翻译成目标厂商请求,并把响应/流/usage 翻译回统一中间形。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from ..types import LlmRequest
|
||||
|
||||
|
||||
class Capabilities(BaseModel):
|
||||
structured_output: bool = False
|
||||
prefix_cache: bool = False
|
||||
thinking: bool = False
|
||||
|
||||
|
||||
class ProviderUsage(BaseModel):
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_read_tokens: int = 0
|
||||
|
||||
|
||||
class ProviderResult(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
text: str
|
||||
usage: ProviderUsage
|
||||
parsed: BaseModel | None = None # output_schema 命中时的结构化结果(§4.4)
|
||||
|
||||
|
||||
class StreamChunk(BaseModel):
|
||||
"""流式块:文本增量(usage=None),或末尾用量块(text="")。"""
|
||||
|
||||
text: str = ""
|
||||
usage: ProviderUsage | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ProviderAdapter(Protocol):
|
||||
provider: str
|
||||
|
||||
def capabilities(self) -> Capabilities: ...
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult: ...
|
||||
|
||||
def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: ...
|
||||
129
packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py
Normal file
129
packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""OpenAI 兼容适配器:一套覆盖 DeepSeek/Kimi/Qwen/GLM/OpenAI(ARCH §4.2)。
|
||||
|
||||
仅 base_url + model + key 不同。注入 `AsyncOpenAI` 客户端以便测试用替身。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, Protocol
|
||||
|
||||
import instructor
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..types import LlmRequest
|
||||
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
|
||||
|
||||
|
||||
class StructuredClient(Protocol):
|
||||
"""instructor 风格的结构化客户端缝(`AsyncInstructor` 即满足此协议)。
|
||||
|
||||
抽成 Protocol 以便测试注入 fake,绝不联网(不变量:测试零真实 LLM)。
|
||||
"""
|
||||
|
||||
async def create_with_completion(
|
||||
self, *, messages: Any, response_model: type[BaseModel], **kwargs: Any
|
||||
) -> tuple[BaseModel, Any]: ...
|
||||
|
||||
|
||||
def _system_text(req: LlmRequest) -> str:
|
||||
return "\n\n".join(b.text for b in req.system)
|
||||
|
||||
|
||||
def _input_text(req: LlmRequest) -> str:
|
||||
if isinstance(req.input, str):
|
||||
return req.input
|
||||
return "\n\n".join(b.text for b in req.input)
|
||||
|
||||
|
||||
def _messages(req: LlmRequest) -> list[ChatCompletionMessageParam]:
|
||||
msgs: list[ChatCompletionMessageParam] = []
|
||||
system = _system_text(req)
|
||||
if system:
|
||||
msgs.append({"role": "system", "content": system})
|
||||
msgs.append({"role": "user", "content": _input_text(req)})
|
||||
return msgs
|
||||
|
||||
|
||||
def _cache_read(usage: Any) -> int:
|
||||
details = getattr(usage, "prompt_tokens_details", None)
|
||||
if details is None:
|
||||
return 0
|
||||
return int(getattr(details, "cached_tokens", 0) or 0)
|
||||
|
||||
|
||||
def _usage_from(usage: Any) -> ProviderUsage:
|
||||
if usage is None:
|
||||
return ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
return ProviderUsage(
|
||||
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
|
||||
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
|
||||
cache_read_tokens=_cache_read(usage),
|
||||
)
|
||||
|
||||
|
||||
class OpenAICompatAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
client: AsyncOpenAI,
|
||||
*,
|
||||
structured_client: StructuredClient | None = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self._client = client
|
||||
# 结构化输出走 instructor(Pydantic 校验 + 重试,锁定栈);可注入便于测试。
|
||||
self._structured_client = structured_client
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True, prefix_cache=True, thinking=False)
|
||||
|
||||
def _structured(self) -> StructuredClient:
|
||||
if self._structured_client is None:
|
||||
# 懒构建:从同一 AsyncOpenAI client patch 出 instructor 客户端。
|
||||
self._structured_client = instructor.from_openai(self._client)
|
||||
return self._structured_client
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
if req.output_schema is not None:
|
||||
return await self._complete_structured(req, model)
|
||||
return await self._complete_text(req, model)
|
||||
|
||||
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
resp = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=_messages(req),
|
||||
max_tokens=req.max_tokens,
|
||||
)
|
||||
text = resp.choices[0].message.content or ""
|
||||
return ProviderResult(text=text, usage=_usage_from(resp.usage))
|
||||
|
||||
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
assert req.output_schema is not None
|
||||
parsed, raw = await self._structured().create_with_completion(
|
||||
messages=_messages(req),
|
||||
response_model=req.output_schema,
|
||||
model=model,
|
||||
max_tokens=req.max_tokens,
|
||||
)
|
||||
usage = _usage_from(getattr(raw, "usage", None))
|
||||
# 文本载体保留校验后的 JSON(便于日志/留痕);程序消费走 parsed。
|
||||
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
stream = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=_messages(req),
|
||||
max_tokens=req.max_tokens,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
async for chunk in stream:
|
||||
if chunk.choices:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta and delta.content:
|
||||
yield StreamChunk(text=delta.content)
|
||||
if getattr(chunk, "usage", None):
|
||||
yield StreamChunk(usage=_usage_from(chunk.usage))
|
||||
Reference in New Issue
Block a user