- 薄自建 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 走通闭环
103 lines
3.3 KiB
Python
103 lines
3.3 KiB
Python
"""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
|