Skip to content

Commit f8e7e15

Browse files
authored
fix(cache): handle string content in is_cached_message (#17853)
Fixes #17821 The `is_cached_message` function crashed with TypeError when message content was a string instead of a list of content blocks. Changes: - Add explicit `isinstance(content, list)` check before iteration - Add `isinstance(content_item, dict)` check inside loop to skip non-dict items - Use `.get()` for safer nested dict access - Follow same pattern as `extract_ttl_from_cached_messages` (same module) Tests: - Add TestIsCachedMessage class with 9 test cases covering: - String content (the reported bug) - None content - Missing content key - Empty list content - List with/without cache_control - Mixed content types (strings + dicts) - Wrong cache_control type
1 parent 78012ad commit f8e7e15

File tree

2 files changed

+124
-18
lines changed

2 files changed

+124
-18
lines changed

litellm/utils.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -793,10 +793,8 @@ def function_setup( # noqa: PLR0915
793793
or call_type == CallTypes.transcription.value
794794
):
795795
_file_obj: FileTypes = args[1] if len(args) > 1 else kwargs["file"]
796-
file_checksum = (
797-
litellm.litellm_core_utils.audio_utils.utils.get_audio_file_content_hash(
798-
file_obj=_file_obj
799-
)
796+
file_checksum = litellm.litellm_core_utils.audio_utils.utils.get_audio_file_content_hash(
797+
file_obj=_file_obj
800798
)
801799
if "metadata" in kwargs:
802800
kwargs["metadata"]["file_checksum"] = file_checksum
@@ -2903,7 +2901,7 @@ def _check_valid_arg(supported_params: Optional[list]):
29032901
non_default_params=non_default_params,
29042902
optional_params={},
29052903
model=model,
2906-
drop_params=drop_params if drop_params is not None else False
2904+
drop_params=drop_params if drop_params is not None else False,
29072905
)
29082906
elif custom_llm_provider == "infinity":
29092907
supported_params = get_supported_openai_params(
@@ -5063,7 +5061,9 @@ def _get_model_info_helper( # noqa: PLR0915
50635061
"output_cost_per_video_per_second", None
50645062
),
50655063
output_cost_per_image=_model_info.get("output_cost_per_image", None),
5066-
output_cost_per_image_token=_model_info.get("output_cost_per_image_token", None),
5064+
output_cost_per_image_token=_model_info.get(
5065+
"output_cost_per_image_token", None
5066+
),
50675067
output_vector_size=_model_info.get("output_vector_size", None),
50685068
citation_cost_per_token=_model_info.get(
50695069
"citation_cost_per_token", None
@@ -6719,7 +6719,9 @@ def _get_base_model_from_metadata(model_call_details=None):
67196719
return _base_model
67206720
metadata = litellm_params.get("metadata", {})
67216721

6722-
base_model_from_metadata = _get_base_model_from_litellm_call_metadata(metadata=metadata)
6722+
base_model_from_metadata = _get_base_model_from_litellm_call_metadata(
6723+
metadata=metadata
6724+
)
67236725
if base_model_from_metadata is not None:
67246726
return base_model_from_metadata
67256727

@@ -6808,14 +6810,22 @@ def is_cached_message(message: AllMessageValues) -> bool:
68086810
"""
68096811
if "content" not in message:
68106812
return False
6811-
if message["content"] is None or isinstance(message["content"], str):
6813+
6814+
content = message["content"]
6815+
6816+
# Handle non-list content types (None, str, etc.)
6817+
if not isinstance(content, list):
68126818
return False
68136819

6814-
for content in message["content"]:
6820+
for content_item in content:
6821+
# Ensure content_item is a dictionary before accessing keys
6822+
if not isinstance(content_item, dict):
6823+
continue
6824+
68156825
if (
6816-
content["type"] == "text"
6817-
and content.get("cache_control") is not None
6818-
and content["cache_control"]["type"] == "ephemeral" # type: ignore
6826+
content_item.get("type") == "text"
6827+
and content_item.get("cache_control") is not None
6828+
and content_item.get("cache_control", {}).get("type") == "ephemeral"
68196829
):
68206830
return True
68216831

@@ -7475,8 +7485,11 @@ def get_provider_responses_api_config(
74757485
# Note: GPT models (gpt-3.5, gpt-4, gpt-5, etc.) support temperature parameter
74767486
# O-series models (o1, o3) do not contain "gpt" and have different parameter restrictions
74777487
is_gpt_model = model and "gpt" in model.lower()
7478-
is_o_series = model and ("o_series" in model.lower() or (supports_reasoning(model) and not is_gpt_model))
7479-
7488+
is_o_series = model and (
7489+
"o_series" in model.lower()
7490+
or (supports_reasoning(model) and not is_gpt_model)
7491+
)
7492+
74807493
if is_o_series:
74817494
return litellm.AzureOpenAIOSeriesResponsesAPIConfig()
74827495
else:
@@ -7495,10 +7508,10 @@ def get_provider_skills_api_config(
74957508
) -> Optional["BaseSkillsAPIConfig"]:
74967509
"""
74977510
Get provider-specific Skills API configuration
7498-
7511+
74997512
Args:
75007513
provider: The LLM provider
7501-
7514+
75027515
Returns:
75037516
Provider-specific Skills API config or None
75047517
"""
@@ -8197,7 +8210,9 @@ def get_non_default_transcription_params(kwargs: dict) -> dict:
81978210
return non_default_params
81988211

81998212

8200-
def add_openai_metadata(metadata: Optional[Mapping[str, Any]]) -> Optional[Dict[str, str]]:
8213+
def add_openai_metadata(
8214+
metadata: Optional[Mapping[str, Any]],
8215+
) -> Optional[Dict[str, str]]:
82018216
"""
82028217
Add metadata to openai optional parameters, excluding hidden params.
82038218
@@ -8231,6 +8246,7 @@ def add_openai_metadata(metadata: Optional[Mapping[str, Any]]) -> Optional[Dict[
82318246

82328247
return visible_metadata.copy()
82338248

8249+
82348250
def get_requester_metadata(metadata: dict):
82358251
if not metadata:
82368252
return None
@@ -8247,6 +8263,7 @@ def get_requester_metadata(metadata: dict):
82478263

82488264
return None
82498265

8266+
82508267
def return_raw_request(endpoint: CallTypes, kwargs: dict) -> RawRequestTypedDict:
82518268
"""
82528269
Return the json str of the request

tests/test_litellm/test_utils.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
TextCompletionStreamWrapper,
2424
get_llm_provider,
2525
get_optional_params_image_gen,
26+
is_cached_message,
2627
)
2728

2829
# Adds the parent directory to the system path
@@ -846,6 +847,7 @@ def test_check_provider_match():
846847
model_info = {"litellm_provider": "bedrock"}
847848
assert litellm.utils._check_provider_match(model_info, "openai") is False
848849

850+
849851
def test_get_provider_rerank_config():
850852
"""
851853
Test the get_provider_rerank_config function for various providers
@@ -854,9 +856,12 @@ def test_get_provider_rerank_config():
854856
from litellm.utils import LlmProviders, ProviderConfigManager
855857

856858
# Test for hosted_vllm provider
857-
config = ProviderConfigManager.get_provider_rerank_config("my_model", LlmProviders.HOSTED_VLLM, 'http://localhost', [])
859+
config = ProviderConfigManager.get_provider_rerank_config(
860+
"my_model", LlmProviders.HOSTED_VLLM, "http://localhost", []
861+
)
858862
assert isinstance(config, HostedVLLMRerankConfig)
859863

864+
860865
# Models that should be skipped during testing
861866
OLD_PROVIDERS = ["aleph_alpha", "palm"]
862867
SKIP_MODELS = [
@@ -2513,3 +2518,87 @@ def test_get_valid_models_with_cli_pattern(self):
25132518
assert "headers" in call_kwargs
25142519
headers = call_kwargs["headers"]
25152520
assert headers.get("Authorization") == "Bearer sk-test-cli-key-123"
2521+
2522+
2523+
class TestIsCachedMessage:
2524+
"""Test is_cached_message function for context caching detection.
2525+
2526+
Fixes GitHub issue #17821 - TypeError when content is string instead of list.
2527+
"""
2528+
2529+
def test_string_content_returns_false(self):
2530+
"""String content should return False without crashing."""
2531+
message = {"role": "user", "content": "Hello world"}
2532+
assert is_cached_message(message) is False
2533+
2534+
def test_none_content_returns_false(self):
2535+
"""None content should return False."""
2536+
message = {"role": "user", "content": None}
2537+
assert is_cached_message(message) is False
2538+
2539+
def test_missing_content_returns_false(self):
2540+
"""Message without content key should return False."""
2541+
message = {"role": "user"}
2542+
assert is_cached_message(message) is False
2543+
2544+
def test_list_content_without_cache_control_returns_false(self):
2545+
"""List content without cache_control should return False."""
2546+
message = {"role": "user", "content": [{"type": "text", "text": "Hello"}]}
2547+
assert is_cached_message(message) is False
2548+
2549+
def test_list_content_with_cache_control_returns_true(self):
2550+
"""List content with cache_control ephemeral should return True."""
2551+
message = {
2552+
"role": "user",
2553+
"content": [
2554+
{
2555+
"type": "text",
2556+
"text": "Hello",
2557+
"cache_control": {"type": "ephemeral"},
2558+
}
2559+
],
2560+
}
2561+
assert is_cached_message(message) is True
2562+
2563+
def test_list_with_non_dict_items_skips_them(self):
2564+
"""List content with non-dict items should skip them gracefully."""
2565+
message = {
2566+
"role": "user",
2567+
"content": ["string_item", 123, {"type": "text", "text": "Hello"}],
2568+
}
2569+
assert is_cached_message(message) is False
2570+
2571+
def test_list_with_mixed_items_finds_cached(self):
2572+
"""Mixed content list should find cached item."""
2573+
message = {
2574+
"role": "user",
2575+
"content": [
2576+
"string_item",
2577+
{"type": "image", "url": "..."},
2578+
{
2579+
"type": "text",
2580+
"text": "cached",
2581+
"cache_control": {"type": "ephemeral"},
2582+
},
2583+
],
2584+
}
2585+
assert is_cached_message(message) is True
2586+
2587+
def test_wrong_cache_control_type_returns_false(self):
2588+
"""Non-ephemeral cache_control type should return False."""
2589+
message = {
2590+
"role": "user",
2591+
"content": [
2592+
{
2593+
"type": "text",
2594+
"text": "Hello",
2595+
"cache_control": {"type": "permanent"},
2596+
}
2597+
],
2598+
}
2599+
assert is_cached_message(message) is False
2600+
2601+
def test_empty_list_content_returns_false(self):
2602+
"""Empty list content should return False."""
2603+
message = {"role": "user", "content": []}
2604+
assert is_cached_message(message) is False

0 commit comments

Comments
 (0)