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:
102
packages/llm_gateway/tests/test_openai_adapter.py
Normal file
102
packages/llm_gateway/tests/test_openai_adapter.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""OpenAI 兼容适配器单测:用替身 client 验证消息翻译 + usage 映射(不联网)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter, _messages
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
|
||||
|
||||
def _req(**kw: Any) -> LlmRequest:
|
||||
kw.setdefault("tier", "writer")
|
||||
return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw)
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, chunks: list[Any]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[Any]:
|
||||
for c in self._chunks:
|
||||
yield c
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
def __init__(self, response: Any, stream_chunks: list[Any]) -> None:
|
||||
self._response = response
|
||||
self._stream_chunks = stream_chunks
|
||||
self.last_messages: Any = None
|
||||
|
||||
async def create(self, **kw: Any) -> Any:
|
||||
self.last_messages = kw.get("messages")
|
||||
if kw.get("stream"):
|
||||
return _FakeStream(self._stream_chunks)
|
||||
return self._response
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, completions: _FakeCompletions) -> None:
|
||||
self.chat = SimpleNamespace(completions=completions)
|
||||
|
||||
|
||||
def test_messages_put_system_first_then_user() -> None:
|
||||
req = _req(input="正文", system=[Block(text="世界观硬规则", cache=True)])
|
||||
msgs = _messages(req)
|
||||
assert msgs[0]["role"] == "system"
|
||||
assert msgs[0]["content"] == "世界观硬规则"
|
||||
assert msgs[1]["role"] == "user"
|
||||
assert msgs[1]["content"] == "正文"
|
||||
|
||||
|
||||
async def test_complete_maps_text_and_usage() -> None:
|
||||
response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="草稿"))],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=120,
|
||||
completion_tokens=80,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=30),
|
||||
),
|
||||
)
|
||||
completions = _FakeCompletions(response, [])
|
||||
adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, _FakeClient(completions)))
|
||||
|
||||
result = await adapter.complete(_req(input="x"), "deepseek-chat")
|
||||
|
||||
assert result.text == "草稿"
|
||||
assert result.usage.input_tokens == 120
|
||||
assert result.usage.output_tokens == 80
|
||||
assert result.usage.cache_read_tokens == 30
|
||||
|
||||
|
||||
async def test_stream_yields_text_then_final_usage() -> None:
|
||||
chunks = [
|
||||
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="他"))], usage=None),
|
||||
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="说"))], usage=None),
|
||||
SimpleNamespace(
|
||||
choices=[],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=2,
|
||||
prompt_tokens_details=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
completions = _FakeCompletions(None, chunks)
|
||||
adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, _FakeClient(completions)))
|
||||
|
||||
texts: list[str] = []
|
||||
usage_seen = None
|
||||
async for ch in adapter.stream(_req(input="x"), "deepseek-chat"):
|
||||
if ch.text:
|
||||
texts.append(ch.text)
|
||||
if ch.usage is not None:
|
||||
usage_seen = ch.usage
|
||||
|
||||
assert "".join(texts) == "他说"
|
||||
assert usage_seen is not None
|
||||
assert usage_seen.output_tokens == 2
|
||||
Reference in New Issue
Block a user