Files
writer-work-flow/packages/llm_gateway/tests/test_openai_adapter.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

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