fix(openai_like): strip cache_control ttl before forwarding /v1/messages to non-Anthropic providers

This commit is contained in:
mateo-berri 2026-08-29 12:48:04 -07:00
parent fa25ff2a2e
commit f4b5449c6a
4 changed files with 195 additions and 2 deletions

View file

@ -1302,6 +1302,39 @@ def flatten_unencrypted_web_search_results_in_anthropic_messages( # mutable-ok:
return [_flatten_web_search_results_in_message(m) for m in messages] # mutable-ok: JSON wire format
def _normalized_cache_control(cache_control: dict) -> dict: # mutable-ok: as sibling sanitizers
cache_type: Final = cache_control.get("type")
return {"type": cache_type if isinstance(cache_type, str) else "ephemeral"} # mutable-ok: JSON wire format
def _normalize_cache_control_value(value: object) -> object:
if isinstance(value, dict):
return normalize_cache_control_in_anthropic_payload(value)
if isinstance(value, list):
return [_normalize_cache_control_value(item) for item in value] # mutable-ok: JSON wire format
return value
def normalize_cache_control_in_anthropic_payload(payload: dict) -> dict: # mutable-ok: as sibling sanitizers
"""
Return a copy of an Anthropic /v1/messages payload with every
``cache_control`` entry reduced to ``{"type": <its type, or "ephemeral">}``,
recursing through message content blocks, system blocks, and tools.
Anthropic itself accepts prompt-caching extensions such as ``ttl``, but
strict non-Anthropic implementations of the Messages API validate the field
literally and reject the whole request (``cache_control.ttl: 1h is not
supported``, ``cache_control.type is required``), which 400s clients like
Claude Code that always send cache hints. Non-dict ``cache_control`` values
are dropped entirely. The caller's payload is never mutated.
"""
return { # mutable-ok: JSON wire format, as sibling sanitizers
key: _normalized_cache_control(value) if key == "cache_control" else _normalize_cache_control_value(value)
for key, value in payload.items()
if key != "cache_control" or isinstance(value, dict)
}
def process_anthropic_headers(headers: httpx.Headers | dict) -> dict:
openai_headers: Final = {}
if "anthropic-ratelimit-requests-limit" in headers:

View file

@ -54,7 +54,10 @@ That's it! The provider will be automatically loaded and available.
"constraints": {
"temperature_max": 1.0,
"temperature_min": 0.0,
"temperature_min_with_n_gt_1": 0.3
"temperature_min_with_n_gt_1": 0.3,
// /v1/messages providers only: keep Anthropic cache_control extensions
// such as ttl instead of stripping them down to {"type": ...}
"cache_control_ttl": true
},
// Optional: Special handling flags

View file

@ -1,11 +1,13 @@
from typing import Any, Final
import litellm
from litellm.llms.anthropic.common_utils import normalize_cache_control_in_anthropic_payload
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.router import GenericLiteLLMParams
DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01"
@ -19,7 +21,9 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig):
``"/v1/messages"``. The inbound Anthropic payload (system, cache_control,
thinking, tools, ...) is forwarded essentially unchanged to
``{api_base}/v1/messages``, so Anthropic-only features that the
Anthropic->OpenAI translation would otherwise drop are preserved. Response
Anthropic->OpenAI translation would otherwise drop are preserved. The one
exception is ``cache_control``, whose Anthropic-only extensions (``ttl``)
are stripped unless ``supports_cache_control_ttl`` says otherwise. Response
parsing and streaming are inherited from the native Anthropic config.
"""
@ -53,6 +57,35 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig):
def should_filter_anthropic_beta_headers(self) -> bool:
return False
def supports_cache_control_ttl(self) -> bool:
return False
def transform_anthropic_messages_request(
self,
model: str,
messages: list[dict], # mutable-ok: matches dict-typed base signature
anthropic_messages_optional_request_params: dict, # mutable-ok: matches dict-typed base signature
litellm_params: GenericLiteLLMParams,
headers: dict, # mutable-ok: matches dict-typed base signature
) -> dict: # mutable-ok: matches dict-typed base signature
"""
Anthropic ignores prompt-caching hints it cannot honor, but strict
non-Anthropic implementations of the Messages API 400 the whole request
on Anthropic-only ``cache_control`` extensions (``cache_control.ttl: 1h
is not supported``), so unless the provider declares ttl support the
hints are reduced to their portable ``{"type": ...}`` core.
"""
request: Final = super().transform_anthropic_messages_request(
model=model,
messages=messages,
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
litellm_params=litellm_params,
headers=headers,
)
if self.supports_cache_control_ttl():
return request
return normalize_cache_control_in_anthropic_payload(request)
def get_complete_url(
self,
api_base: str | None,
@ -91,6 +124,9 @@ class JSONProviderAnthropicMessagesConfig(OpenAILikeAnthropicMessagesConfig):
def should_strip_billing_metadata(self) -> bool:
return True
def supports_cache_control_ttl(self) -> bool:
return bool(self._provider.constraints.get("cache_control_ttl"))
def _resolve_api_key(self, api_key: str | None) -> str | None:
return api_key or get_secret_str(self._provider.api_key_env) or litellm.api_key

View file

@ -317,3 +317,124 @@ def test_json_provider_messages_config_probes_capabilities_under_provider_slug()
)
assert JSONProviderAnthropicMessagesConfig(provider).custom_llm_provider == "exampleprovider"
assert OpenAILikeAnthropicMessagesConfig().custom_llm_provider == "anthropic"
def _cache_control_request_params() -> tuple[list, dict]:
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "write a regex for a US phone number",
"cache_control": {"type": "ephemeral", "ttl": "1h"},
}
],
}
]
optional_params = {
"max_tokens": 256,
"system": [
{
"type": "text",
"text": "You are Claude Code.",
"cache_control": {"type": "ephemeral", "ttl": "5m"},
}
],
"tools": [
{
"name": "lookup",
"input_schema": {"type": "object"},
"cache_control": {"type": "ephemeral", "ttl": "1h"},
}
],
}
return messages, optional_params
def test_request_strips_cache_control_ttl_everywhere(config):
"""Regression: Claude Code always sends ``cache_control: {type: ephemeral,
ttl: 1h}``, and strict non-Anthropic /v1/messages validators 400 the whole
request on the ttl extension (``cache_control.ttl: 1h is not supported``)."""
messages, optional_params = _cache_control_request_params()
payload = config.transform_anthropic_messages_request(
model="some-model",
messages=messages,
anthropic_messages_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert payload["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"}
assert payload["system"][0]["cache_control"] == {"type": "ephemeral"}
assert payload["tools"][0]["cache_control"] == {"type": "ephemeral"}
assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
def test_request_defaults_missing_cache_control_type_and_drops_non_dict(config):
payload = config.transform_anthropic_messages_request(
model="some-model",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "a", "cache_control": {"ttl": "1h"}},
{"type": "text", "text": "b", "cache_control": None},
],
}
],
anthropic_messages_optional_request_params={"max_tokens": 64},
litellm_params=GenericLiteLLMParams(),
headers={},
)
blocks = payload["messages"][0]["content"]
assert blocks[0]["cache_control"] == {"type": "ephemeral"}
assert "cache_control" not in blocks[1]
def test_native_anthropic_config_keeps_cache_control_ttl():
"""Anthropic itself accepts ttl, so the normalization must stay scoped to
the OpenAI-like passthrough and never reach the native Anthropic path."""
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
messages, optional_params = _cache_control_request_params()
payload = AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-sonnet-4-20250514",
messages=messages,
anthropic_messages_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert payload["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
assert payload["system"][0]["cache_control"] == {"type": "ephemeral", "ttl": "5m"}
def test_json_provider_constraint_opts_into_cache_control_ttl():
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
base_data = {"base_url": "https://api.example.com/v1", "api_key_env": "EXAMPLE_API_KEY"}
strict = JSONProviderAnthropicMessagesConfig(SimpleProviderConfig(slug="strictprov", data=base_data))
lenient = JSONProviderAnthropicMessagesConfig(
SimpleProviderConfig(slug="lenientprov", data={**base_data, "constraints": {"cache_control_ttl": True}})
)
def transform(provider_config):
messages, optional_params = _cache_control_request_params()
return provider_config.transform_anthropic_messages_request(
model="some-model",
messages=messages,
anthropic_messages_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert transform(strict)["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"}
assert transform(lenient)["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}