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:
18
packages/llm_gateway/tests/conftest.py
Normal file
18
packages/llm_gateway/tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""网关单测公用 fixtures。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from ww_llm_gateway.types import LlmRequest, Scope
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scope() -> Scope:
|
||||
return Scope(user_id=uuid.UUID(int=1), project_id=uuid.UUID(int=2))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def req(scope: Scope) -> LlmRequest:
|
||||
return LlmRequest(tier="writer", input="写第 1 章", scope=scope)
|
||||
56
packages/llm_gateway/tests/fakes.py
Normal file
56
packages/llm_gateway/tests/fakes.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""网关单测替身(mock provider + 内存 ledger)——不碰真实 API / DB。
|
||||
|
||||
放在独立模块(非 conftest)以便测试用绝对导入 `from fakes import ...`;
|
||||
本目录无 __init__.py(避免与顶层 tests 包同名冲突),故走 pytest 路径注入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from ww_llm_gateway.adapters.base import (
|
||||
Capabilities,
|
||||
ProviderResult,
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from ww_llm_gateway.routing import Route
|
||||
from ww_llm_gateway.types import LlmRequest, Scope, Tier, Usage
|
||||
|
||||
|
||||
class FakeAdapter:
|
||||
provider = "deepseek"
|
||||
|
||||
def __init__(self, text: str = "hello world", deltas: list[str] | None = None) -> None:
|
||||
self.text = text
|
||||
self.deltas = deltas if deltas is not None else ["hel", "lo ", "world"]
|
||||
self.complete_calls = 0
|
||||
self.stream_calls = 0
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True, prefix_cache=True)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
self.complete_calls += 1
|
||||
return ProviderResult(
|
||||
text=self.text,
|
||||
usage=ProviderUsage(input_tokens=100, output_tokens=50),
|
||||
)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
self.stream_calls += 1
|
||||
for d in self.deltas:
|
||||
yield StreamChunk(text=d)
|
||||
yield StreamChunk(usage=ProviderUsage(input_tokens=100, output_tokens=50))
|
||||
|
||||
|
||||
class FakeLedger:
|
||||
def __init__(self) -> None:
|
||||
self.records: list[Usage] = []
|
||||
|
||||
async def record(self, scope: Scope, usage: Usage) -> None:
|
||||
self.records.append(usage)
|
||||
|
||||
|
||||
def fake_route(tier: Tier) -> Route:
|
||||
return Route(provider="deepseek", model="deepseek-chat")
|
||||
82
packages/llm_gateway/tests/test_gateway.py
Normal file
82
packages/llm_gateway/tests/test_gateway.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""T1.1 网关核心单测:run / stream / 记账 / 路由 / 错误。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fakes import FakeAdapter, FakeLedger, fake_route
|
||||
from ww_llm_gateway.gateway import Gateway
|
||||
from ww_llm_gateway.routing import resolve_route
|
||||
from ww_llm_gateway.types import LlmRequest, Scope
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
async def test_run_returns_text_usage_and_served_by(req: LlmRequest) -> None:
|
||||
adapter = FakeAdapter(text="第一章正文")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway({"deepseek": adapter}, ledger, resolver=fake_route)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text == "第一章正文"
|
||||
assert resp.served_by.provider == "deepseek"
|
||||
assert resp.served_by.fell_back is False
|
||||
assert resp.usage.input_tokens == 100
|
||||
assert resp.usage.output_tokens == 50
|
||||
assert adapter.complete_calls == 1
|
||||
|
||||
|
||||
async def test_run_writes_exactly_one_ledger_record(req: LlmRequest) -> None:
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
|
||||
|
||||
await gw.run(req)
|
||||
|
||||
assert len(ledger.records) == 1
|
||||
assert ledger.records[0].provider == "deepseek"
|
||||
assert ledger.records[0].model == "deepseek-chat"
|
||||
|
||||
|
||||
async def test_run_computes_cost_from_pricing(req: LlmRequest) -> None:
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
# ceil(100/1e6*27 + 50/1e6*110) == 1 cent
|
||||
assert resp.usage.cost_minor == 1
|
||||
assert resp.usage.currency == "USD"
|
||||
|
||||
|
||||
async def test_stream_yields_deltas_and_records_once(req: LlmRequest) -> None:
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway({"deepseek": FakeAdapter(deltas=["a", "b", "c"])}, ledger, resolver=fake_route)
|
||||
|
||||
collected = [d.text async for d in gw.stream(req)]
|
||||
|
||||
assert "".join(collected) == "abc"
|
||||
assert len(ledger.records) == 1
|
||||
assert ledger.records[0].output_tokens == 50
|
||||
|
||||
|
||||
async def test_unknown_provider_raises_llm_unavailable(req: LlmRequest) -> None:
|
||||
gw = Gateway({}, FakeLedger(), resolver=fake_route)
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await gw.run(req)
|
||||
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
|
||||
|
||||
def test_resolve_route_parses_tier_defaults() -> None:
|
||||
route = resolve_route("writer")
|
||||
assert route.provider == "deepseek"
|
||||
assert route.model == "deepseek-chat"
|
||||
|
||||
|
||||
def test_agent_passes_only_tier_never_model() -> None:
|
||||
# 不变量 ②:LlmRequest 无 model 字段,只有 tier
|
||||
req = LlmRequest(tier="analyst", input="x", scope=Scope(user_id=uuid.UUID(int=9)))
|
||||
assert "model" not in LlmRequest.model_fields
|
||||
assert req.tier == "analyst"
|
||||
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
|
||||
123
packages/llm_gateway/tests/test_structured_output.py
Normal file
123
packages/llm_gateway/tests/test_structured_output.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""T2.1 结构化输出接线单测:output_schema → instructor → LlmResponse.parsed。
|
||||
|
||||
注入 fake instructor client(返回 (parsed, raw_completion)),不联网。
|
||||
覆盖:① 带 schema → parsed 是实例且字段正确 + 仍记 1 条 ledger;
|
||||
② 无 schema → parsed is None 且纯文本路径不变。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
from ww_llm_gateway.gateway import Gateway
|
||||
from ww_llm_gateway.routing import Route
|
||||
from ww_llm_gateway.types import LlmRequest, Scope, Tier
|
||||
|
||||
|
||||
class _Out(BaseModel):
|
||||
label: str
|
||||
score: int
|
||||
|
||||
|
||||
def _req(**kw: Any) -> LlmRequest:
|
||||
kw.setdefault("tier", "analyst")
|
||||
return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw)
|
||||
|
||||
|
||||
def _route(_tier: Tier) -> Route:
|
||||
return Route(provider="deepseek", model="deepseek-chat")
|
||||
|
||||
|
||||
class _FakeStructured:
|
||||
"""模拟 instructor.AsyncInstructor:create_with_completion 返回 (parsed, raw)。"""
|
||||
|
||||
def __init__(self, parsed: BaseModel, raw: Any) -> None:
|
||||
self._parsed = parsed
|
||||
self._raw = raw
|
||||
self.calls = 0
|
||||
self.last_response_model: Any = None
|
||||
|
||||
async def create_with_completion(
|
||||
self, *, messages: Any, response_model: Any, **kw: Any
|
||||
) -> tuple[BaseModel, Any]:
|
||||
self.calls += 1
|
||||
self.last_response_model = response_model
|
||||
return self._parsed, self._raw
|
||||
|
||||
|
||||
def _raw_with_usage() -> Any:
|
||||
return SimpleNamespace(
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=42,
|
||||
completion_tokens=7,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=5),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _client() -> AsyncOpenAI:
|
||||
return cast(AsyncOpenAI, SimpleNamespace(chat=SimpleNamespace(completions=None)))
|
||||
|
||||
|
||||
async def test_complete_with_schema_returns_parsed_instance() -> None:
|
||||
structured = _FakeStructured(_Out(label="ok", score=9), _raw_with_usage())
|
||||
adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured)
|
||||
|
||||
result = await adapter.complete(_req(input="x", output_schema=_Out), "deepseek-chat")
|
||||
|
||||
assert isinstance(result.parsed, _Out)
|
||||
assert result.parsed.label == "ok"
|
||||
assert result.parsed.score == 9
|
||||
assert structured.last_response_model is _Out
|
||||
# usage 从 raw completion 提取
|
||||
assert result.usage.input_tokens == 42
|
||||
assert result.usage.output_tokens == 7
|
||||
assert result.usage.cache_read_tokens == 5
|
||||
|
||||
|
||||
async def test_complete_without_schema_keeps_parsed_none() -> None:
|
||||
response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="纯文本"))],
|
||||
usage=SimpleNamespace(prompt_tokens=3, completion_tokens=2, prompt_tokens_details=None),
|
||||
)
|
||||
|
||||
class _Completions:
|
||||
async def create(self, **kw: Any) -> Any:
|
||||
return response
|
||||
|
||||
client = cast(
|
||||
AsyncOpenAI,
|
||||
SimpleNamespace(chat=SimpleNamespace(completions=_Completions())),
|
||||
)
|
||||
adapter = OpenAICompatAdapter("deepseek", client)
|
||||
|
||||
result = await adapter.complete(_req(input="x"), "deepseek-chat")
|
||||
|
||||
assert result.parsed is None
|
||||
assert result.text == "纯文本"
|
||||
|
||||
|
||||
class _FakeLedger:
|
||||
def __init__(self) -> None:
|
||||
self.records: list[Any] = []
|
||||
|
||||
async def record(self, scope: Any, usage: Any) -> None:
|
||||
self.records.append(usage)
|
||||
|
||||
|
||||
async def test_gateway_run_passes_parsed_through_and_records_once() -> None:
|
||||
structured = _FakeStructured(_Out(label="hit", score=1), _raw_with_usage())
|
||||
adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured)
|
||||
ledger = _FakeLedger()
|
||||
gw = Gateway({"deepseek": adapter}, ledger, resolver=_route)
|
||||
|
||||
resp = await gw.run(_req(input="x", output_schema=_Out))
|
||||
|
||||
assert isinstance(resp.parsed, _Out)
|
||||
assert resp.parsed.label == "hit"
|
||||
assert len(ledger.records) == 1
|
||||
Reference in New Issue
Block a user