Files
MoviePilot/tests/test_agent_prompt_cache.py
jxxghp 8fe8ea477e fix(agent): preserve cache keys for compatible Responses endpoints (#7236)
Forward the stable anonymized session key through the generic OpenAI-compatible Responses path so gateways can retain cache routing across tool calls.

Refs #7233
2026-10-29 17:17:48 +08:00

288 lines
9.6 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.
"""
验证提示词缓存键的稳定性、隐私边界和缓存用量统计。
"""
import pytest
from langchain_core.messages import AIMessage
from app.agent.llm.helper import LLMHelper
from app.agent.llm.provider import LLMProviderManager
from app.agent.middleware.usage import UsageMiddleware
from app.agent.orchestrator import MoviePilotAgent
from app.chain.message import MessageChain
def test_usage_extracts_normalized_cache_details() -> None:
"""
标准 usage_metadata 应解析缓存读取、写入和未命中 tokens。
"""
usage = UsageMiddleware._extract_usage(
AIMessage(
content="ok",
usage_metadata={
"input_tokens": 1200,
"output_tokens": 100,
"total_tokens": 1300,
"input_token_details": {
"cache_read": 700,
"cache_creation": 0,
"ephemeral_5m_input_tokens": 300,
},
},
)
)
assert usage["cache_usage_available"]
assert usage["cache_read_input_tokens"] == 700
assert usage["cache_write_input_tokens"] == 300
assert usage["uncached_input_tokens"] == 200
assert usage["cache_hit_ratio"] == pytest.approx(700 / 1200)
def test_usage_extracts_deepseek_cache_hit_and_miss_tokens() -> None:
"""
DeepSeek 原始 usage 应保留其显式缓存命中与未命中字段。
"""
usage = UsageMiddleware._extract_usage(
AIMessage(
content="ok",
response_metadata={
"token_usage": {
"prompt_tokens": 1000,
"completion_tokens": 50,
"total_tokens": 1050,
"prompt_cache_hit_tokens": 800,
"prompt_cache_miss_tokens": 200,
}
},
)
)
assert usage["cache_usage_available"]
assert usage["cache_read_input_tokens"] == 800
assert usage["cache_write_input_tokens"] == 0
assert usage["uncached_input_tokens"] == 200
assert usage["cache_hit_ratio"] == pytest.approx(0.8)
def test_session_usage_aggregates_and_formats_cache_statistics() -> None:
"""
会话状态应聚合缓存统计并在状态文本中展示。
"""
agent = MoviePilotAgent(session_id="cache-session", user_id="user-1")
agent._record_usage(
{
"has_usage": True,
"cache_usage_available": True,
"input_tokens": 100,
"output_tokens": 10,
"total_tokens": 110,
"cache_read_input_tokens": 60,
"cache_write_input_tokens": 20,
"uncached_input_tokens": 20,
"cache_hit_ratio": 0.6,
}
)
agent._record_usage(
{
"has_usage": True,
"cache_usage_available": True,
"input_tokens": 50,
"output_tokens": 5,
"total_tokens": 55,
"cache_read_input_tokens": 20,
"cache_write_input_tokens": 0,
"uncached_input_tokens": 30,
"cache_hit_ratio": 0.4,
}
)
status = agent.get_session_status()
status.update({"is_processing": False, "pending_messages": 0})
status_text = MessageChain._format_session_status_text(status)
assert status["total_cache_read_input_tokens"] == 80
assert status["total_cache_write_input_tokens"] == 20
assert status["total_uncached_input_tokens"] == 50
assert status["total_cache_hit_ratio"] == pytest.approx(80 / 150)
assert "当前会话累计缓存: 命中 80 / 写入 20 / 未命中 50 (53.33%)" in status_text
def test_prompt_cache_key_is_stable_and_private() -> None:
"""
提示词缓存键应在同一会话内稳定且不暴露原始标识。
"""
first = MoviePilotAgent(session_id="private-session", user_id="private-user")
second = MoviePilotAgent(session_id="private-session", user_id="private-user")
other = MoviePilotAgent(session_id="other-session", user_id="private-user")
cache_key = first._build_prompt_cache_key()
assert cache_key == second._build_prompt_cache_key()
assert cache_key != other._build_prompt_cache_key()
assert "private-session" not in cache_key
assert "private-user" not in cache_key
def test_openai_prompt_cache_options_only_target_official_endpoints() -> None:
"""
OpenAI 专属缓存参数不得泄露到第三方兼容端点。
"""
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
provider="openai",
base_url="https://api.openai.com/v1",
use_responses_api=False,
prompt_cache_key="cache-key",
default_headers={"User-Agent": "MoviePilot"},
model_kwargs={"extra_body": {"existing": True}},
)
_, compatible_kwargs = LLMHelper._build_openai_prompt_cache_options(
provider="openai",
base_url="https://api.openai.com.example/v1",
use_responses_api=False,
prompt_cache_key="cache-key",
default_headers=None,
model_kwargs={},
)
assert headers == {"User-Agent": "MoviePilot"}
assert kwargs["extra_body"] == {
"existing": True,
"prompt_cache_key": "cache-key",
}
assert compatible_kwargs == {}
def test_xai_chat_completions_uses_stable_conversation_header() -> None:
"""
xAI Chat Completions 应使用官方会话缓存路由请求头。
"""
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
provider="xai",
base_url="https://api.x.ai/v1",
use_responses_api=None,
prompt_cache_key="cache-key",
default_headers=None,
model_kwargs={},
)
assert headers == {"x-grok-conv-id": "cache-key"}
assert kwargs == {}
@pytest.mark.parametrize("provider", ["openai", "chatgpt", "xai", "custom"])
@pytest.mark.parametrize("use_responses_api", [True, False, None])
@pytest.mark.parametrize("cache_key", [" cache-key ", None, " "])
def test_compatible_responses_cache_options(
provider: str, use_responses_api: bool | None, cache_key: str | None,
) -> None:
"""
OpenAI 兼容 Responses 应透传非空缓存键,其余第三方协议保持原合同。
:param provider: 提供商标识,用于区分通用兼容入口与专属适配器
:param use_responses_api: 本次模型实际使用的协议;None 表示未强制指定
:param cache_key: 会话缓存键,空值不得创建额外路由参数
"""
original_headers = {"User-Agent": "MoviePilot"}
original_kwargs = {"extra_body": {"existing": True}}
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
provider=provider,
base_url="https://sub2api.example/v1",
use_responses_api=use_responses_api,
prompt_cache_key=cache_key,
default_headers=original_headers,
model_kwargs=original_kwargs,
)
expected: dict[str, bool | str] = {"existing": True}
if provider == "openai" and use_responses_api is True and cache_key and cache_key.strip():
expected["prompt_cache_key"] = cache_key.strip()
assert headers == original_headers
assert kwargs == {"extra_body": expected}
assert original_kwargs == {"extra_body": {"existing": True}}
def test_prompt_cache_adapter_preserves_control_after_tool_binding() -> None:
"""
Provider 缓存参数应在 Agent 最终绑定工具时仍然存在。
"""
class FakeModel:
"""
模拟通过 bind_tools 再调用 bind 的 LangChain 模型。
"""
def bind(self, **kwargs):
"""
返回最终绑定参数。
"""
return kwargs
def bind_tools(self, tools, **kwargs):
"""
模拟模型的工具绑定流程。
"""
return self.bind(tools=tools, **kwargs)
cached_model_cls = LLMHelper._with_prompt_cache_control(
FakeModel,
{"type": "default"},
)
result = cached_model_cls().bind_tools([{"name": "tool"}])
assert result["tools"] == [{"name": "tool"}]
assert result["cache_control"] == {"type": "default"}
def test_anthropic_cache_control_only_targets_official_endpoint() -> None:
"""
Anthropic 原生缓存控制不得发送到第三方兼容端点。
"""
official = LLMHelper._use_anthropic_prompt_cache(
provider="anthropic",
runtime={
"runtime": "anthropic_compatible",
"base_url": "https://api.anthropic.com/v1",
},
prompt_cache_key="cache-key",
)
compatible = LLMHelper._use_anthropic_prompt_cache(
provider="minimax",
runtime={
"runtime": "anthropic_compatible",
"base_url": "https://api.minimax.io/anthropic/v1",
},
prompt_cache_key="cache-key",
)
assert official
assert not compatible
def test_provider_metadata_declares_prompt_cache_without_model_allowlist() -> None:
"""
Provider 应通过模型能力元数据判断缓存支持,不依赖模型 ID 白名单。
"""
assert LLMProviderManager._metadata_supports_prompt_cache(
{"cost": {"input": 1, "cache_read": 0.1}}
)
assert LLMProviderManager._metadata_supports_prompt_cache(
{"capabilities": {"prompt_cache": True}}
)
assert not LLMProviderManager._metadata_supports_prompt_cache(
{"cost": {"input": 1, "output": 2}}
)
def test_bedrock_model_metadata_candidates_remove_region_prefix() -> None:
"""
Bedrock 跨区域模型应自动回落到基础模型的能力元数据。
"""
candidates = LLMProviderManager._models_dev_model_candidates(
"amazon-bedrock",
"us.vendor.model-version",
)
assert candidates == ("us.vendor.model-version", "vendor.model-version")