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>
199 lines
6.1 KiB
Python
199 lines
6.1 KiB
Python
"""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"
|