litellm/tests/e2e/llm_translation/test_cache_control.py
yuneng-jiang 81a3802a92
test(e2e): assert the Vertex cache-creation billing contract in the prompt caching test (#47027)
The cache-creating call now reports cache_creation_input_tokens on top of
Vertex's own prompt count and bills the write at the input rate, as Google
does. The old assertions assumed neither, so the test went red on every run.
Check the cache write, the discounted read, and that a second identical call
reuses the cache instead.
2026-10-16 18:51:38 -07:00

613 lines
25 KiB
Python

"""Live e2e: provider-specific /chat/completions features take real effect.
Each case asserts the feature actually happened, not just a 200. Coverage matrix
(register-on-demand deployments, deleted on teardown):
- Bedrock (anthropic claude-haiku-4-5): prompt caching. A large cacheable prefix
marked with ``cache_control`` is sent twice; the second call must report
cache-read usage tokens > 0. service_tier is out of scope for Bedrock; AWS
Bedrock does not expose an OpenAI-style request service tier, so that cell is
intentionally not covered here.
- Vertex (gemini-2.5-flash): explicit context caching via ``cache_control``
with a 5-minute ttl. litellm builds the Vertex cache before the generate
call, so a never-seen prefix must come back cached on its very first call
(Gemini's implicit caching cannot hit a cold prefix). That call bills the
cache write as ``cache_creation_input_tokens`` on top of Vertex's own
prompt count, the cached count must cover the marked block, the spend row
must carry the write at the input rate and the read below it, and an
identical second call must read the cache without building a new one.
Vertex's cache create is nondeterministic:
identical bodies come back 200 or with the minimum-token 400 ("The cached
content is of 1 tokens"), in failure bursts of 45 seconds and more, so up to
eight never-seen prefixes are tried with a pause after each rejection. The
checks hold whether Vertex reports its prompt count inclusive of the cached
prefix or exclusive of it, which it has done both ways.
- Anthropic (claude-haiku-4-5, direct): the same ``cache_control`` prefix over
the OpenAI-compatible route; the second call must report cache-read tokens > 0.
- OpenAI (gpt-5.6): automatic prompt caching needs no request marker, so the
cacheable prefix goes out as a plain system string with a ``prompt_cache_key``
and the second call must report ``prompt_tokens_details.cached_tokens`` > 0.
- OpenAI (gpt-5.6) in explicit cache mode: OpenAI caches nothing unless a
breakpoint reaches it, so a ``cache_control`` mark on a system part, a string
system message, a user part or a tool result must write the cache on the
first call and read it on the second.
service_tier lives in test_provider_features_e2e.py.
The provider-native cache_control request shape is not expressible with the
shared ``ChatBody`` (whose content is a plain string), so the cacheable body is
built from the typed content blocks shared in ``models.py``.
"""
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from typing import Final
import pytest
from pydantic import BaseModel, ConfigDict
from e2e_config import unique_marker
from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta
from e2e_http import Result, UnknownApiError, unwrap
from lifecycle import ResourceManager
from models import (
CacheControl,
ChatAssistantTurn,
ChatBody,
ChatMessage,
ChatResponse,
ChatTool,
ChatToolFunction,
ChatToolResultTurn,
CustomPricing,
LiteLLMParamsBody,
PromptCacheOptions,
RichMessage,
TextBlock,
TextContentPart,
ToolCall,
ToolCallFunction,
Usage,
)
from passthrough_client import PassthroughClient
import os
pytestmark = [pytest.mark.e2e, pytest.mark.provider_live]
BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"
VERTEX_MODEL = "vertex_ai/gemini-2.5-flash"
ANTHROPIC_MODEL = "anthropic/claude-haiku-4-5-20251001"
OPENAI_MODEL = "openai/gpt-5.6"
VERTEX_CACHE_TTL: Final = "300s"
VERTEX_COLD_CALL_ATTEMPTS: Final = 8
VERTEX_COLD_CALL_PAUSE_SECONDS: Final = 15.0
VERTEX_MINIMUM_CACHED_TOKENS: Final = 1024
CACHED_SHARE_OF_PROMPT: Final = 0.9
VERTEX_CACHE_REJECTION_MARKER: Final = "minimum token count to start explicit caching"
class CacheChatBody(BaseModel):
model_config = ConfigDict(frozen=True)
model: str
messages: list[RichMessage]
max_tokens: int = 64
cache: dict[str, bool] = {"no-cache": True}
def _cacheable_prefix() -> str:
"""A prefix long enough to clear provider minimum cacheable sizes (Haiku is
2048 tokens), unique per run so the first call writes and the second reads."""
marker = unique_marker()
body = " ".join(
f"Cacheable reference paragraph {index} for run {marker}." for index in range(600)
)
return f"{body}\nEnd of reference material {marker}."
def _cached_read_tokens(usage: Usage | None) -> int:
"""Cache-read tokens however the provider reports them: Anthropic-style
``cache_read_input_tokens`` or OpenAI-style ``prompt_tokens_details.cached_tokens``."""
if usage is None:
return 0
if usage.cache_read_input_tokens:
return usage.cache_read_input_tokens
if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens:
return usage.prompt_tokens_details.cached_tokens
return 0
def _cache_chat(
client: PassthroughClient, key: str, model: str, prefix: str, ttl: str | None = None
) -> Result[ChatResponse]:
body = CacheChatBody(
model=model,
messages=[
RichMessage(
role="system",
content=[TextBlock(text=prefix, cache_control=CacheControl(ttl=ttl))],
),
RichMessage(role="user", content=[TextBlock(text="Reply with one word.")]),
],
)
return client.proxy.transport.post(
"/chat/completions",
headers=client.proxy.transport.bearer(key),
json=body,
response_type=ChatResponse,
)
def _plain_cache_chat(
client: PassthroughClient, key: str, model: str, prefix: str, cache_key: str
) -> Result[ChatResponse]:
"""The same cacheable prefix as a plain system string, for providers that cache
automatically and take no per-block marker (OpenAI)."""
return client.proxy.chat(
key,
ChatBody(
model=model,
messages=[
ChatMessage(role="system", content=prefix),
ChatMessage(role="user", content="Reply with one word."),
],
max_tokens=64,
prompt_cache_key=cache_key,
),
)
def _assert_cache_read_on_second_call(
model: str, send: Callable[[str], Result[ChatResponse]]
) -> None:
prefix = _cacheable_prefix()
first = unwrap(send(prefix))
assert first.choices, f"{model}: first cache-priming call returned no choices: {first}"
deadline = time.monotonic() + 30.0
while True:
second = unwrap(send(prefix))
read_tokens = _cached_read_tokens(second.usage)
if read_tokens > 0 or time.monotonic() >= deadline:
break
time.sleep(3.0)
assert read_tokens > 0, (
f"{model}: second identical call reported no cache-read tokens "
f"({second.usage}); prompt caching did not take effect"
)
def _cache_write_tokens(usage: Usage | None) -> int:
if usage is None or usage.prompt_tokens_details is None:
return 0
return usage.prompt_tokens_details.cache_write_tokens or 0
def _assert_openai_writes_then_reads_cache(client: PassthroughClient, key: str, body: ChatBody) -> None:
first: Final = unwrap(client.proxy.chat(key, body))
assert _cache_write_tokens(first.usage) > 0, (
f"{body.model}: the first call on a never-seen prefix wrote no cache ({first.usage}), "
"so the cache_control mark never reached OpenAI as a breakpoint"
)
deadline: Final = time.monotonic() + 30.0
while True:
second = unwrap(client.proxy.chat(key, body))
read_tokens = _cached_read_tokens(second.usage)
if read_tokens > 0 or time.monotonic() >= deadline:
break
time.sleep(3.0)
assert read_tokens > 0, f"{body.model}: the identical second call read no cache ({second.usage})"
@dataclass(frozen=True, slots=True)
class EngagedColdCall:
prefix: str
completion: ChatResponse
def _cold_cache_call(send: Callable[[str], Result[ChatResponse]]) -> EngagedColdCall | None:
prefix: Final = _cacheable_prefix()
result: Final = send(prefix)
match result:
case UnknownApiError(status_code=400, body=body) if VERTEX_CACHE_REJECTION_MARKER in body:
return None
case _:
return EngagedColdCall(prefix=prefix, completion=unwrap(result))
def _first_engaged_cold_call(send: Callable[[str], Result[ChatResponse]]) -> EngagedColdCall | None:
for attempt in range(1, VERTEX_COLD_CALL_ATTEMPTS + 1):
candidate = _cold_cache_call(send)
if candidate is not None and _cached_read_tokens(candidate.completion.usage) >= VERTEX_MINIMUM_CACHED_TOKENS:
return candidate
if attempt < VERTEX_COLD_CALL_ATTEMPTS:
print(
f"cache_control: vertex did not engage the cache on cold attempt {attempt}/{VERTEX_COLD_CALL_ATTEMPTS}; "
f"pausing {VERTEX_COLD_CALL_PAUSE_SECONDS}s before the next never-seen prefix",
flush=True,
)
time.sleep(VERTEX_COLD_CALL_PAUSE_SECONDS)
return None
def _cache_creation_tokens(usage: Usage | None) -> int:
if usage is None:
return 0
return usage.cache_creation_input_tokens or 0
def _first_cold_call_creates_and_reads_cache(
model: str, send: Callable[[str], Result[ChatResponse]]
) -> EngagedColdCall:
engaged: Final = _first_engaged_cold_call(send)
assert engaged is not None, (
f"{model}: {VERTEX_COLD_CALL_ATTEMPTS} never-seen prompts marked with cache_control, spread over "
f"{VERTEX_COLD_CALL_PAUSE_SECONDS * (VERTEX_COLD_CALL_ATTEMPTS - 1):.0f}s, were each either rejected by "
f"Vertex's minimum-token check or served with fewer than {VERTEX_MINIMUM_CACHED_TOKENS} cached tokens on "
"their first call; explicit context caching did not engage"
)
completion: Final = engaged.completion
assert completion.choices, f"{model}: cached call returned no choices: {completion}"
usage: Final = completion.usage
assert usage and usage.prompt_tokens, f"{model}: cached completion carried no prompt_tokens: {usage}"
created: Final = _cache_creation_tokens(usage)
assert created > 0, (
f"{model}: the call that built the Vertex cache reported no cache_creation_input_tokens ({usage}); "
"the cache write went unbilled"
)
generate_prompt_tokens: Final = usage.prompt_tokens - created
assert generate_prompt_tokens > 0, (
f"{model}: prompt_tokens {usage.prompt_tokens} does not cover the {created} cache-creation tokens plus "
"the generate call's own prompt"
)
cached: Final = _cached_read_tokens(usage)
assert cached >= CACHED_SHARE_OF_PROMPT * generate_prompt_tokens, (
f"{model}: only {cached} of the generate call's {generate_prompt_tokens} prompt tokens were served from "
"the cache; the cache_control block was not cached whole"
)
return engaged
def _assert_second_call_reuses_cache(model: str, send: Callable[[str], Result[ChatResponse]], prefix: str) -> None:
usage: Final = unwrap(send(prefix)).usage
assert _cached_read_tokens(usage) > 0, f"{model}: the identical second call read no cache ({usage})"
assert _cache_creation_tokens(usage) == 0, (
f"{model}: the identical second call built and billed a new cache ({usage}) instead of reusing the first one"
)
def _pricing(client: PassthroughClient, model: str) -> CustomPricing:
entry: Final = next((row for row in client.proxy.model_info() if row.model_name == model), None)
assert entry and entry.model_info.input_cost_per_token, f"/model/info resolved no input rate for {model}"
return entry.model_info
def _assert_billed_for_creation_and_discounted_read(
client: PassthroughClient, model: str, completion: ChatResponse
) -> None:
assert completion.id, f"{model}: cached completion carried no id to find its spend row by"
usage: Final = completion.usage
assert usage and usage.prompt_tokens, f"{model}: cached completion carried no prompt_tokens: {usage}"
rows: Final = client.proxy.poll_logs_for_request_id(completion.id, predicate=lambda rs: (rs[0].spend or 0) > 0)
assert rows, f"{model}: no costed /spend/logs row for request {completion.id}"
row: Final = rows[0]
assert row.prompt_tokens == usage.prompt_tokens, (
f"{model}: spend row prompt_tokens {row.prompt_tokens} != response prompt_tokens {usage.prompt_tokens}"
)
assert row.spend is not None, f"{model}: spend row carried no spend: {row}"
pricing: Final = _pricing(client, model)
input_rate: Final = pricing.input_cost_per_token or 0
created: Final = _cache_creation_tokens(usage)
cached: Final = _cached_read_tokens(usage)
# Google bills cache creation at the standard input price (read 2026-10-16):
# https://docs.cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-overview
creation_cost: Final = created * input_rate
assert row.spend >= creation_cost, (
f"{model}: spend {row.spend} is below the {creation_cost} the {created} cache-creation tokens cost at the "
"input rate; the cache write was not billed"
)
read_rate: Final = pricing.cache_read_input_token_cost
assert read_rate is not None and read_rate < input_rate, (
f"/model/info advertises no discounted cache-read rate for {model}: {pricing}"
)
undiscounted_cost: Final = pricing.token_cost(
created + max(usage.prompt_tokens - created, cached), usage.completion_tokens or 0
)
half_the_read_discount: Final = cached * (input_rate - read_rate) / 2
assert row.spend < undiscounted_cost - half_the_read_discount, (
f"{model}: spend {row.spend} saved less than half the cache-read discount on its {cached} cached tokens "
f"against {undiscounted_cost}, the price of creating the cache and reading it uncached; cache-read pricing "
"was not applied"
)
class TestCacheControl:
@pytest.mark.covers(
"llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.BEDROCK,),
models=(BEDROCK_MODEL,),
capabilities=(Capability.PROMPT_CACHING,),
mode=Mode.NONSTREAM,
)
)
def test_bedrock_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-bedrock-cache-{unique_marker()}"
model_id = client.proxy.create_model(
model,
LiteLLMParamsBody(model=BEDROCK_MODEL, aws_region_name="us-east-1"),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key = resources.key()
_assert_cache_read_on_second_call(model, lambda prefix: _cache_chat(client, key, model, prefix))
@pytest.mark.covers(
"llm.chat_completions.vertex.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.VERTEX_AI,),
models=(VERTEX_MODEL,),
capabilities=(Capability.PROMPT_CACHING,),
mode=Mode.NONSTREAM,
)
)
def test_vertex_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-vertex-cache-{unique_marker()}"
model_id = client.proxy.create_model(
model,
LiteLLMParamsBody(
model=VERTEX_MODEL,
vertex_project=os.environ.get("VERTEXAI_PROJECT"),
vertex_location="us-central1",
vertex_credentials=os.environ.get("VERTEXAI_CREDENTIALS"),
),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key = resources.key()
send: Final = partial(_cache_chat, client, key, model, ttl=VERTEX_CACHE_TTL)
engaged: Final = _first_cold_call_creates_and_reads_cache(model, send)
_assert_billed_for_creation_and_discounted_read(client, model, engaged.completion)
_assert_second_call_reuses_cache(model, send, engaged.prefix)
@pytest.mark.covers(
"llm.chat_completions.anthropic.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.ANTHROPIC,),
models=(ANTHROPIC_MODEL,),
capabilities=(Capability.PROMPT_CACHING,),
mode=Mode.NONSTREAM,
)
)
def test_anthropic_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-anthropic-cache-{unique_marker()}"
model_id = client.proxy.create_model(
model,
LiteLLMParamsBody(model=ANTHROPIC_MODEL, api_key="os.environ/ANTHROPIC_API_KEY"),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key = resources.key()
_assert_cache_read_on_second_call(model, lambda prefix: _cache_chat(client, key, model, prefix))
@pytest.mark.covers("llm.chat_completions.anthropic.prompt_cache_5m.nonstream.works")
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.ANTHROPIC,),
models=(ANTHROPIC_MODEL,),
capabilities=(Capability.PROMPT_CACHING,),
mode=Mode.NONSTREAM,
)
)
def test_anthropic_prompt_caching_reads_cache_with_version_header(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model: Final = f"e2e-anthropic-cache-version-{unique_marker()}"
model_id: Final = client.proxy.create_model(
model,
LiteLLMParamsBody(
model=ANTHROPIC_MODEL,
api_key="os.environ/ANTHROPIC_API_KEY",
extra_headers={"anthropic-version": "2023-06-01"},
),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key: Final = resources.key()
_assert_cache_read_on_second_call(model, lambda prefix: _cache_chat(client, key, model, prefix))
@pytest.mark.covers("llm.chat_completions.anthropic.prompt_cache_5m.nonstream.works")
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.ANTHROPIC,),
models=(ANTHROPIC_MODEL,),
capabilities=(Capability.PROMPT_CACHING,),
mode=Mode.NONSTREAM,
)
)
def test_anthropic_prompt_caching_reports_creation_tokens(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model: Final = f"e2e-anthropic-cache-create-{unique_marker()}"
model_id: Final = client.proxy.create_model(
model,
LiteLLMParamsBody(model=ANTHROPIC_MODEL, api_key="os.environ/ANTHROPIC_API_KEY"),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key: Final = resources.key()
body: Final = CacheChatBody(
model=model,
messages=[
RichMessage(
role="system",
content=[TextBlock(text=_cacheable_prefix(), cache_control=CacheControl(ttl="1h"))],
),
RichMessage(
role="user",
content=[TextBlock(text="Reply with one word.", cache_control=CacheControl(ttl="5m"))],
),
],
)
response: Final = unwrap(
client.proxy.transport.post(
"/chat/completions",
headers=client.proxy.transport.bearer(key),
json=body,
response_type=ChatResponse,
)
)
assert response.usage and response.usage.cache_creation_input_tokens, (
f"{model}: a never-seen prefix marked 1h then 5m reported no cache-creation tokens ({response.usage})"
)
@pytest.mark.covers(
"llm.chat_completions.openai.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.OPENAI,),
models=(OPENAI_MODEL,),
capabilities=(Capability.PROMPT_CACHING,),
mode=Mode.NONSTREAM,
)
)
def test_openai_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-openai-cache-{unique_marker()}"
model_id = client.proxy.create_model(
model,
LiteLLMParamsBody(model=OPENAI_MODEL, api_key="os.environ/OPENAI_API_KEY"),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key = resources.key()
cache_key = f"e2e-openai-cache-{unique_marker()}"
_assert_cache_read_on_second_call(
model, lambda prefix: _plain_cache_chat(client, key, model, prefix, cache_key)
)
@pytest.mark.covers("llm.chat_completions.openai.prompt_cache_5m.nonstream.works")
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.OPENAI,),
models=(OPENAI_MODEL,),
capabilities=(Capability.PROMPT_CACHING,),
mode=Mode.NONSTREAM,
)
)
class TestCacheControlOnOpenAIExplicitCaching:
@pytest.fixture
def model(self, client: PassthroughClient, resources: ResourceManager) -> str:
name: Final = f"e2e-openai-explicit-cache-{unique_marker()}"
model_id: Final = client.proxy.create_model(
name,
LiteLLMParamsBody(
model=OPENAI_MODEL,
api_key="os.environ/OPENAI_API_KEY",
prompt_cache_options=PromptCacheOptions(mode="explicit"),
),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
return name
def test_cache_control_on_a_system_text_part(self, client: PassthroughClient, scoped_key: str, model: str) -> None:
body: Final = ChatBody(
model=model,
max_tokens=64,
messages=[
ChatMessage(
role="system",
content=[TextContentPart(text=_cacheable_prefix(), cache_control=CacheControl())],
),
ChatMessage(role="user", content="Reply with one word."),
],
)
_assert_openai_writes_then_reads_cache(client, scoped_key, body)
def test_cache_control_on_a_string_system_message(
self, client: PassthroughClient, scoped_key: str, model: str
) -> None:
body: Final = ChatBody(
model=model,
max_tokens=64,
messages=[
ChatMessage(role="system", content=_cacheable_prefix(), cache_control=CacheControl()),
ChatMessage(role="user", content="Reply with one word."),
],
)
_assert_openai_writes_then_reads_cache(client, scoped_key, body)
def test_cache_control_on_a_user_text_part(self, client: PassthroughClient, scoped_key: str, model: str) -> None:
body: Final = ChatBody(
model=model,
max_tokens=64,
messages=[
ChatMessage(
role="user",
content=[
TextContentPart(text=_cacheable_prefix(), cache_control=CacheControl()),
TextContentPart(text="Reply with one word."),
],
),
],
)
_assert_openai_writes_then_reads_cache(client, scoped_key, body)
def test_cache_control_on_a_tool_result(self, client: PassthroughClient, scoped_key: str, model: str) -> None:
body: Final = ChatBody(
model=model,
max_tokens=64,
reasoning_effort="none",
tools=[ChatTool(function=ChatToolFunction(name="fetch_report", parameters={"type": "object"}))],
messages=[
ChatMessage(role="user", content="Fetch the report and summarize it in one sentence."),
ChatAssistantTurn(
tool_calls=[
ToolCall(
id="call_1", type="function", function=ToolCallFunction(name="fetch_report", arguments="{}")
)
]
),
ChatToolResultTurn(
tool_call_id="call_1",
content=[TextContentPart(text=_cacheable_prefix(), cache_control=CacheControl())],
),
],
)
_assert_openai_writes_then_reads_cache(client, scoped_key, body)