Files
writer-work-flow/packages/llm_gateway/tests/test_new_adapters.py

244 lines
7.8 KiB
Python
Raw Permalink 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_structured_output_threads_usage() -> None:
# 结构化输出必须记真实 usageCR-H3instructor 的 create_with_completion 回
# (parsed, raw)raw.usage 带真实 token 数网关据此记账§4.8)。
class _RawUsage:
input_tokens = 120
output_tokens = 80
cache_read_input_tokens = 30
class _Raw:
usage = _RawUsage()
class _FakeStructured:
def __init__(self) -> None:
self.last_kwargs: dict[str, Any] = {}
async def create_with_completion(
self, *, response_model: type[BaseModel], **kwargs: Any
) -> tuple[BaseModel, Any]:
self.last_kwargs = kwargs
return response_model(score=7), _Raw()
# 修前路径调 `.create`(仅回 parsed——保留它令 RED 是值不符0!=120
# 而非 AttributeError坐实旧代码硬编码零 usage 的缺陷。
async def create(self, *, response_model: type[BaseModel], **kwargs: Any) -> BaseModel:
return response_model(score=7)
fake = _FakeStructured()
client = _FakeAnthropicClient(_FakeAnthropicMessages("", 0, 0, 0))
adapter = AnthropicAdapter("anthropic", client, structured_client=fake)
req = LlmRequest(
tier="analyst",
input="打分",
output_schema=Out,
scope=Scope(user_id=uuid.UUID(int=1)),
)
result = await adapter.complete(req, "claude-3-5-sonnet")
assert isinstance(result.parsed, Out)
assert result.parsed.score == 7
assert result.usage.input_tokens == 120
assert result.usage.output_tokens == 80
assert result.usage.cache_read_tokens == 30
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"