feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
packages/llm_gateway/tests/test_new_adapters.py
Normal file
198
packages/llm_gateway/tests/test_new_adapters.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""T5.4 新适配器:Anthropic + Gemini(ARCH §4.2/§4.4)。
|
||||
|
||||
网络客户端经注入的 Protocol 替身——绝不联网、绝不依赖真实 SDK。
|
||||
断言:能力矩阵声明正确、complete/stream 归一化、结构化输出、usage 提取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_llm_gateway.adapters.anthropic import AnthropicAdapter
|
||||
from ww_llm_gateway.adapters.gemini import GeminiAdapter
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
|
||||
|
||||
class Out(BaseModel):
|
||||
score: int
|
||||
|
||||
|
||||
# ---- Anthropic ----
|
||||
|
||||
|
||||
class _FakeAnthropicMessages:
|
||||
def __init__(self, text: str, in_tok: int, out_tok: int, cache_read: int) -> None:
|
||||
self._text = text
|
||||
self._in = in_tok
|
||||
self._out = out_tok
|
||||
self._cache_read = cache_read
|
||||
self.last_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def create(self, **kwargs: Any) -> Any:
|
||||
self.last_kwargs = kwargs
|
||||
|
||||
class _Block:
|
||||
type = "text"
|
||||
text = self._text
|
||||
|
||||
class _Usage:
|
||||
input_tokens = self._in
|
||||
output_tokens = self._out
|
||||
cache_read_input_tokens = self._cache_read
|
||||
|
||||
class _Resp:
|
||||
content = [_Block()]
|
||||
usage = _Usage()
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
class _FakeAnthropicClient:
|
||||
def __init__(self, msgs: _FakeAnthropicMessages) -> None:
|
||||
self.messages = msgs
|
||||
|
||||
|
||||
def _req(text: str = "写第一章", *, cache: bool = False) -> LlmRequest:
|
||||
sys = [Block(text="世界观", cache=cache)] if cache else []
|
||||
return LlmRequest(tier="writer", input=text, system=sys, scope=Scope(user_id=uuid.UUID(int=1)))
|
||||
|
||||
|
||||
def test_anthropic_capabilities() -> None:
|
||||
client = _FakeAnthropicClient(_FakeAnthropicMessages("", 0, 0, 0))
|
||||
adapter = AnthropicAdapter("anthropic", client)
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
assert caps.prefix_cache is True
|
||||
assert caps.thinking is True
|
||||
|
||||
|
||||
async def test_anthropic_complete_text_and_usage() -> None:
|
||||
msgs = _FakeAnthropicMessages("生成正文", in_tok=120, out_tok=80, cache_read=30)
|
||||
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
|
||||
|
||||
result = await adapter.complete(_req(), "claude-3-5-sonnet")
|
||||
|
||||
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_anthropic_marks_cache_breakpoint() -> None:
|
||||
msgs = _FakeAnthropicMessages("x", 1, 1, 0)
|
||||
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
|
||||
|
||||
await adapter.complete(_req(cache=True), "claude-3-5-sonnet")
|
||||
|
||||
# 稳定块应带 cache_control 断点(Claude 显式缓存,§4.6)。
|
||||
system = msgs.last_kwargs["system"]
|
||||
assert any(blk.get("cache_control") for blk in system)
|
||||
|
||||
|
||||
# ---- Gemini ----
|
||||
|
||||
|
||||
class _FakeGeminiModels:
|
||||
def __init__(self, text: str, in_tok: int, out_tok: int) -> None:
|
||||
self._text = text
|
||||
self._in = in_tok
|
||||
self._out = out_tok
|
||||
self.last_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def generate_content(self, **kwargs: Any) -> Any:
|
||||
self.last_kwargs = kwargs
|
||||
|
||||
class _Usage:
|
||||
prompt_token_count = self._in
|
||||
candidates_token_count = self._out
|
||||
cached_content_token_count = 0
|
||||
|
||||
class _Resp:
|
||||
text = self._text
|
||||
usage_metadata = _Usage()
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
class _FakeGeminiAio:
|
||||
def __init__(self, models: _FakeGeminiModels) -> None:
|
||||
self.models = models
|
||||
|
||||
|
||||
class _FakeGeminiClient:
|
||||
def __init__(self, models: _FakeGeminiModels) -> None:
|
||||
self.aio = _FakeGeminiAio(models)
|
||||
|
||||
|
||||
def test_gemini_capabilities() -> None:
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(_FakeGeminiModels("", 0, 0))) # type: ignore[arg-type]
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
assert caps.thinking is True
|
||||
|
||||
|
||||
async def test_gemini_complete_text_and_usage() -> None:
|
||||
models = _FakeGeminiModels("双子座正文", in_tok=200, out_tok=90)
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
|
||||
|
||||
result = await adapter.complete(_req(), "gemini-2.0-flash")
|
||||
|
||||
assert result.text == "双子座正文"
|
||||
assert result.usage.input_tokens == 200
|
||||
assert result.usage.output_tokens == 90
|
||||
|
||||
|
||||
async def test_gemini_structured_output() -> None:
|
||||
models = _FakeGeminiModels('{"score": 7}', in_tok=10, out_tok=5)
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
|
||||
req = LlmRequest(
|
||||
tier="analyst",
|
||||
input="打分",
|
||||
output_schema=Out,
|
||||
scope=Scope(user_id=uuid.UUID(int=1)),
|
||||
)
|
||||
|
||||
result = await adapter.complete(req, "gemini-2.0-flash")
|
||||
|
||||
assert result.parsed is not None
|
||||
assert isinstance(result.parsed, Out)
|
||||
assert result.parsed.score == 7
|
||||
# 结构化请求应带 response schema 配置。
|
||||
assert "config" in models.last_kwargs
|
||||
|
||||
|
||||
async def test_anthropic_stream_yields_text_then_usage() -> None:
|
||||
class _StreamEvent:
|
||||
def __init__(self, **kw: Any) -> None:
|
||||
self.__dict__.update(kw)
|
||||
|
||||
class _StreamMessages:
|
||||
def stream(self, **kwargs: Any) -> Any:
|
||||
class _Ctx:
|
||||
async def __aenter__(self_inner) -> AsyncIterator[Any]:
|
||||
async def _gen() -> AsyncIterator[Any]:
|
||||
yield _StreamEvent(
|
||||
type="content_block_delta", delta=_StreamEvent(text="hel")
|
||||
)
|
||||
yield _StreamEvent(
|
||||
type="content_block_delta", delta=_StreamEvent(text="lo")
|
||||
)
|
||||
|
||||
return _gen()
|
||||
|
||||
async def __aexit__(self_inner, *a: Any) -> None:
|
||||
return None
|
||||
|
||||
return _Ctx()
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.messages = _StreamMessages()
|
||||
|
||||
adapter = AnthropicAdapter("anthropic", _Client())
|
||||
chunks = [c async for c in adapter.stream(_req(), "claude-3-5-sonnet")]
|
||||
text = "".join(c.text for c in chunks if c.text)
|
||||
assert text == "hello"
|
||||
Reference in New Issue
Block a user