fischer-agentkit/tests/unit/test_prompt_cache_layers.py

361 lines
12 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.

"""U2 / G2 Prompt Cache 双块结构测试。
覆盖 R4-R7, R13:
- R4 stable/volatile 双块结构
- R5 记忆注入从 system_prompt 末尾移到 volatile 层
- R6 跨 provider 统一 cache 策略(Anthropic blocks / OpenAI 字符串)
- R7 多轮 stable 不变(由构造保证)
- R13 配置化(prompt_cache_enable=False 退化)
"""
from __future__ import annotations
from typing import Any
from agentkit.core.react import ReActEngine
class _StubGateway:
"""模拟 LLMGateway,记录最后一次 chat_stream 调用的 messages。"""
def __init__(self, provider_name: str | None = "anthropic"):
self._provider_name = provider_name
self.captured_messages: list[dict[str, Any]] | None = None
def get_provider_name_for_model(self, model: str) -> str | None:
return self._provider_name
async def chat_stream(self, **kwargs):
self.captured_messages = list(kwargs.get("messages", []))
return
yield # makes this an async generator
def _make_engine(
provider_name: str | None = "anthropic", *, cache_enable: bool = True
) -> tuple[ReActEngine, _StubGateway]:
gw = _StubGateway(provider_name=provider_name)
engine = ReActEngine.__new__(ReActEngine)
engine._llm_gateway = gw
engine._prompt_cache_enable = cache_enable
return engine, gw
# ---- R4/R6 Anthropic: stable + volatile → content blocks ----
def test_anthropic_provider_returns_content_blocks_with_cache_control():
engine, _ = _make_engine("anthropic")
result = engine._build_system_message(
stable="You are a helpful assistant.",
volatile="Memory: foo",
model="claude-sonnet-4",
)
assert isinstance(result, list)
assert result[0] == {
"type": "text",
"text": "You are a helpful assistant.",
"cache_control": {"type": "ephemeral"},
}
assert result[1] == {"type": "text", "text": "## 参考信息\nMemory: foo"}
# ---- R5 Anthropic: empty volatile → only stable block ----
def test_anthropic_empty_volatile_returns_only_stable_block():
engine, _ = _make_engine("anthropic")
result = engine._build_system_message(
stable="base prompt",
volatile="",
model="claude",
)
assert isinstance(result, list)
assert len(result) == 1
assert result[0]["text"] == "base prompt"
assert "cache_control" in result[0]
# ---- R6 Non-Anthropic: returns string concat ----
def test_non_anthropic_returns_string_concat():
engine, _ = _make_engine("openai")
result = engine._build_system_message(
stable="base",
volatile="ctx",
model="gpt-4",
)
assert isinstance(result, str)
assert result == "base\n\n## 参考信息\nctx"
def test_unknown_provider_returns_string_concat():
"""provider_name 无法确定时(gateway 返回 None),回退字符串拼接不报错。"""
engine, _ = _make_engine(None)
result = engine._build_system_message(
stable="base",
volatile="ctx",
model="default",
)
assert isinstance(result, str)
assert "## 参考信息" in result
# ---- R13 Config: prompt_cache_enable=False → 退化字符串 ----
def test_prompt_cache_disabled_falls_back_to_string():
engine, _ = _make_engine("anthropic", cache_enable=False)
result = engine._build_system_message(
stable="base",
volatile="ctx",
model="claude",
)
# 即便是 Anthropic,enable=False 时也返回字符串(行为同改动前)
assert isinstance(result, str)
assert "cache_control" not in result
# ---- R4 Edge: no stable + no volatile → None ----
def test_empty_stable_and_volatile_returns_none():
engine, _ = _make_engine("anthropic")
result = engine._build_system_message(stable="", volatile="", model="claude")
assert result is None
# ---- R5 Edge: empty stable + volatile → only volatile block (Anthropic) ----
def test_anthropic_empty_stable_returns_only_volatile_block():
engine, _ = _make_engine("anthropic")
result = engine._build_system_message(
stable="",
volatile="only memory",
model="claude",
)
assert isinstance(result, list)
assert len(result) == 1
assert result[0]["text"] == "## 参考信息\nonly memory"
assert "cache_control" not in result[0] # volatile 块无 cache_control
# ---- Integration: execute_stream uses _build_system_message end-to-end ----
async def test_execute_stream_with_anthropic_uses_content_blocks():
"""execute_stream 把双块 system content 透传给 gateway。"""
from agentkit.tools.base import Tool
class _NoopTool(Tool):
def __init__(self):
super().__init__(name="noop", description="noop")
async def execute(self, **kwargs):
return {}
class _MockGateway:
def __init__(self):
self.captured_messages = None
def get_provider_name_for_model(self, model: str) -> str | None:
return "anthropic"
async def chat_stream(self, **kwargs):
self.captured_messages = list(kwargs.get("messages", []))
# yield one chunk then end
from agentkit.llm.protocol import StreamChunk
yield StreamChunk(content="done", model="claude")
class _MemRetriever:
async def get_context_string(self, **kw):
return "retrieved context"
gw = _MockGateway()
engine = ReActEngine(llm_gateway=gw, prompt_cache_enable=True)
events = []
async for ev in engine.execute_stream(
messages=[{"role": "user", "content": "hi"}],
tools=[],
model="claude",
system_prompt="base",
memory_retriever=_MemRetriever(),
):
events.append(ev)
assert gw.captured_messages is not None
sys_msg = gw.captured_messages[0]
assert sys_msg["role"] == "system"
assert isinstance(sys_msg["content"], list)
assert sys_msg["content"][0]["text"] == "base"
assert "cache_control" in sys_msg["content"][0]
assert sys_msg["content"][1]["text"] == "## 参考信息\nretrieved context"
# ---- Anthropic provider _convert_messages passes through list-type system ----
def test_anthropic_convert_messages_passes_through_list_system_content():
"""AnthropicProvider._convert_messages 应直接透传 list-type system content。"""
from agentkit.llm.providers.anthropic import AnthropicProvider
provider = AnthropicProvider.__new__(AnthropicProvider)
blocks = [
{"type": "text", "text": "stable", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "volatile"},
]
messages = [{"role": "system", "content": blocks}]
system_prompt, anthropic_messages = provider._convert_messages(messages)
assert system_prompt is blocks # same object, transparent passthrough
assert anthropic_messages == []
def test_anthropic_convert_messages_string_system_still_works():
"""传统 string system content 仍能透传(向后兼容)。"""
from agentkit.llm.providers.anthropic import AnthropicProvider
provider = AnthropicProvider.__new__(AnthropicProvider)
messages = [{"role": "system", "content": "old style"}]
system_prompt, _ = provider._convert_messages(messages)
assert system_prompt == "old style"
# ---- U2 cache hit/miss metric emission ----
def test_token_usage_cache_hit_property():
"""U2: TokenUsage.cache_hit 反映 cache_read_input_tokens > 0"""
from agentkit.llm.protocol import TokenUsage
hit_usage = TokenUsage(
prompt_tokens=100,
completion_tokens=50,
cache_read_input_tokens=80,
cache_creation_input_tokens=20,
)
assert hit_usage.cache_hit is True
miss_usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
assert miss_usage.cache_hit is False
assert miss_usage.cache_read_input_tokens == 0
def test_anthropic_provider_parses_cache_tokens_from_response():
"""U2: Anthropic provider 从 API 响应解析 cache_read_input_tokens"""
from agentkit.llm.providers.anthropic import AnthropicProvider
provider = AnthropicProvider.__new__(AnthropicProvider)
provider.api_key = "test"
provider.base_url = "http://test"
# 模拟 Anthropic 响应含 cache 字段
mock_response = {
"content": [{"type": "text", "text": "hello"}],
"model": "claude-sonnet-4",
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 80,
"cache_creation_input_tokens": 20,
},
}
# 直接调用 _parse_response私有方法测试用
usage = provider._parse_response(mock_response, "claude-sonnet-4").usage
assert usage.cache_read_input_tokens == 80
assert usage.cache_creation_input_tokens == 20
assert usage.cache_hit is True
def _build_cache_metric_gateway(response):
"""构造 LLMGateway mock跳过 __init__。
ponytail: 直接装配测试所需的最小属性,避免初始化真实 provider/cache。
"""
from unittest.mock import AsyncMock, MagicMock
from agentkit.llm.gateway import LLMGateway
mock_provider = MagicMock()
mock_provider.chat = AsyncMock(return_value=response)
gw = LLMGateway.__new__(LLMGateway)
gw._providers = {"mock": mock_provider}
gw._config = MagicMock()
gw._config.providers = {} # _calculate_cost 真实迭代返回 0.0
gw._config.fallbacks = {} # _get_models_to_try 返回 [resolved_model]
gw._cache_manager = None
gw._usage_tracker = MagicMock()
gw._resolve_model_alias = lambda m: m
gw._resolve_model = lambda m: (mock_provider, m)
return gw
def test_gateway_emits_prompt_cache_hit_metric_on_cache_read():
"""U2: gateway.chat 在 cache_read_input_tokens > 0 时 emit prompt_cache.hit"""
from unittest.mock import AsyncMock, patch
from agentkit.llm.protocol import LLMResponse, TokenUsage
response = LLMResponse(
content="test",
model="claude",
usage=TokenUsage(
prompt_tokens=100,
completion_tokens=50,
cache_read_input_tokens=80,
),
)
gw = _build_cache_metric_gateway(response)
# patch gateway 模块内的导入绑定名from ... import prompt_cache_hit_counter
with (
patch("agentkit.llm.gateway.prompt_cache_hit_counter") as hit_counter,
patch("agentkit.llm.gateway.prompt_cache_miss_counter") as miss_counter,
patch(
"agentkit.llm.gateway.LLMGateway._record_usage",
new_callable=AsyncMock,
),
):
import asyncio
asyncio.run(gw.chat(messages=[{"role": "user", "content": "hi"}], model="claude"))
# cache_read_input_tokens > 0 → hit counter called
hit_counter().add.assert_called_once()
miss_counter().add.assert_not_called()
def test_gateway_emits_prompt_cache_miss_metric_on_no_cache_read():
"""U2: gateway.chat 在 cache_read_input_tokens == 0 时 emit prompt_cache.miss"""
from unittest.mock import AsyncMock, patch
from agentkit.llm.protocol import LLMResponse, TokenUsage
response = LLMResponse(
content="test",
model="claude",
usage=TokenUsage(prompt_tokens=100, completion_tokens=50), # cache_read=0
)
gw = _build_cache_metric_gateway(response)
with (
patch("agentkit.llm.gateway.prompt_cache_hit_counter") as hit_counter,
patch("agentkit.llm.gateway.prompt_cache_miss_counter") as miss_counter,
patch(
"agentkit.llm.gateway.LLMGateway._record_usage",
new_callable=AsyncMock,
),
):
import asyncio
asyncio.run(gw.chat(messages=[{"role": "user", "content": "hi"}], model="claude"))
# cache_read_input_tokens == 0 → miss counter called
miss_counter().add.assert_called_once()
hit_counter().add.assert_not_called()