Files
writer-work-flow/packages/llm_gateway/tests/test_new_adapters.py
Yaojia Wang 765dbdfbd4 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>
2026-06-20 10:39:58 +02:00

199 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""T5.4 新适配器Anthropic + GeminiARCH §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"