mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
* test: migrate local_testing SDK and provider tests out of build_and_test * test: cover sagemaker region, hf template and nova paths end to end Region rows now go through litellm.completion on the sagemaker/ handler that the legacy tests exercised, the prompt template test mocks the Hugging Face tokenizer config instead of switching to custom_prompt_dict, streaming tests assert role, finish reason and body, and Nova single turn and streaming run through litellm.completion with a recorded request body * test: tighten migrated integration tests for local OpenAI routes, timeouts and air-gapped boot Split the catch-all providers migration file by behavior. The fake endpoint route tests now hit the owned upstream directly, as the legacy harness tests did. LM Studio asserts the outbound key, path and stripped model. The embedding timeout test asserts the proxy maps a deployment timeout to a 408 and forwards the read timeout to the provider client, instead of timing out the test client The Azure streaming tool-call test also asserts the second identical stream is served from the Redis cache with the same reassembled tool call The Docker network test always skipped in CI because the security job has no litellm/litellm image and no egress to pull one, and it tested a published image rather than the checkout. It now boots the checkout proxy with every outbound HTTP connection refused and asserts readiness, the bundled cost map fallback and that the cost map host is the only egress attempted * test: tighten the migrated e2e rows for local_testing SDK and provider tests Drop the duplicated Anthropic covers marker and the Anthropic cell wrongly claimed by the Bedrock and Vertex cache tests. Keep the file-wide Anthropic model as it is on main. Send the 2023-06-01 version header to Anthropic through the deployment's extra_headers so the header actually reaches the provider, and mark the creation-token case 1h then 5m like the legacy test did Reuse the Bedrock file's own registration for the pydantic response test, swap the hand-built PNGs for the shared cat.jpg fixture, assert the SageMaker Nova image answer names the cat and that top_logprobs=3 is honored, and register the SageMaker Nova opt-in markers the same way as the other opt-in markers * test: restore lost assertions in migrated test_main tests Restore the role alternation check in test_completion_anthropic_hanging, the embedding and streaming response-header checks, the logit_bias drop check and the gemini system and safety settings checks. Add an azure 429 retry-after test for test_exception_with_headers, trigger the router fallback with mock_timeout like the legacy test, and stop the mock timeout tests sleeping 3s * test: restore lost legacy assertions in migrated vertex ai unit tests * test: tighten migrated cost, streaming and embedding tests and scope the hosted mock guard * test: strengthen migrated anthropic, azure, bedrock and cohere provider tests Assert full request bodies and usage instead of presence checks, restore the dummy-tool and custom_llm_provider assertions the legacy tests made, add the async and per-model variants the legacy parametrization covered, split streamed tool arguments across deltas, and move the Azure chat streaming tests to the azure.py mirror file * test: tighten migrated llms provider tests and cover legacy intents lost in the move Strengthen the migrated fireworks, gemini, mistral, together, openai text completion, openrouter, perplexity and openai error tests so they assert what the legacy tests proved (cost, provider tag, usage-only-on-last-chunk, function_call rebuild, reasoning param, sync/async/stream error fields). Replace the huggingface and jina tests that doubled litellm code with respx end-to-end tests. Add the tests the pre-existing mapped tests never covered: cohere 400 mapping, token-array text completion, hosted_vllm/llamafile/lm_studio env api base embeddings, watsonx embedding auth, ollama ssl_verify client and ollama vision prompt * test: cover residual legacy paths for anthropic tool streams, watsonx async stream, ollama http images and caller async embedding clients * test: restore single request assertions in the deep_infra and azure_ai embedding tests * test: drop a no-op streaming cost flag from the openai streaming cost test * test: cover residual legacy-test lines in caching, main, utils, replicate, vertex and bedrock invoke * test: cover streamed audio assembly, choice membership, untranslated responses events and the stream log guard * test: pin cost rates in unit tests, use sdk for cohere e2e, drop vertex llama e2e * test: send the vertex maps e2e request through the openai sdk * test: drop the local_testing jobs that select no tests after the migrations * test: wait for the streamed cache entry and keep custom Mistral rates test-local * test: keep untouched Mistral test lines as they were --------- Co-authored-by: yuneng <yuneng@berri.ai>
414 lines
17 KiB
Python
414 lines
17 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), the cached count must
|
|
cover the marked block, and the spend row must be billed below the uncached
|
|
price of the cached tokens. 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
|
|
billing check prices the cached tokens rather than prompt_tokens, which
|
|
Vertex reports inclusive of the cached prefix on some calls and exclusive
|
|
of it on others.
|
|
- 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.
|
|
|
|
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 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, ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody, RichMessage, TextBlock, 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 _cold_cache_call(send: Callable[[str], Result[ChatResponse]]) -> ChatResponse | None:
|
|
result: Final = send(_cacheable_prefix())
|
|
match result:
|
|
case UnknownApiError(status_code=400, body=body) if VERTEX_CACHE_REJECTION_MARKER in body:
|
|
return None
|
|
case _:
|
|
return unwrap(result)
|
|
|
|
|
|
def _first_engaged_cold_call(send: Callable[[str], Result[ChatResponse]]) -> ChatResponse | 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.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 _first_cold_call_reads_cache(model: str, send: Callable[[str], Result[ChatResponse]]) -> ChatResponse:
|
|
completion: Final = _first_engaged_cold_call(send)
|
|
assert completion 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"
|
|
)
|
|
assert completion.choices, f"{model}: cached call returned no choices: {completion}"
|
|
usage: Final = completion.usage
|
|
cached: Final = _cached_read_tokens(usage)
|
|
assert usage and usage.prompt_tokens, f"{model}: cached completion carried no prompt_tokens: {usage}"
|
|
assert cached >= CACHED_SHARE_OF_PROMPT * usage.prompt_tokens, (
|
|
f"{model}: only {cached} of {usage.prompt_tokens} prompt tokens were served from the cache; the "
|
|
"cache_control block was not cached whole"
|
|
)
|
|
return completion
|
|
|
|
|
|
def _input_rate(client: PassthroughClient, model: str) -> float:
|
|
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.input_cost_per_token
|
|
|
|
|
|
def _assert_billed_below_uncached_prompt(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}"
|
|
)
|
|
cached: Final = _cached_read_tokens(usage)
|
|
uncached_read_cost: Final = cached * _input_rate(client, model)
|
|
assert row.spend is not None and row.spend < uncached_read_cost, (
|
|
f"{model}: spend {row.spend} is not below the uncached price of the {cached} tokens read from the cache "
|
|
f"({uncached_read_cost}); 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()
|
|
completion: Final = _first_cold_call_reads_cache(
|
|
model, lambda prefix: _cache_chat(client, key, model, prefix, ttl=VERTEX_CACHE_TTL)
|
|
)
|
|
_assert_billed_below_uncached_prompt(client, model, completion)
|
|
|
|
@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)
|
|
)
|