Repository navigation
Conversation
1069567 to
470443a
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
3484c32 to
a7f619b
Compare
Greptile SummaryThis PR fixes
Confidence Score: 4/5Safe to merge with one fix: the hardcoded model-name string matching in get_gemini_context_caching_min_tokens should be replaced with a single conservative default. The object/dict dual-path fixes are well-tested and the logic is correct. The one concern is get_gemini_context_caching_min_tokens: when get_model_info returns a dict that lacks cache_creation_min_tokens (possible for any model variant not yet in the JSON), the function guesses by matching substrings like gemini-2.5 or gemini-3. A wrong guess causes either a live Gemini 400 (floor too low) or silently skipped caching (floor too high), and future model names may not match the expected substrings at all. The rest of the PR is correct and well-covered by mocked unit tests. litellm/llms/vertex_ai/context_caching/transformation.py — the fallback string-matching branches in get_gemini_context_caching_min_tokens
|
| Filename | Overview |
|---|---|
| litellm/llms/vertex_ai/context_caching/transformation.py | Adds TTL normalization (h/m/s → seconds), message-level cache_control extraction, and a per-model min-token lookup; the fallback in get_gemini_context_caching_min_tokens uses hardcoded model-name string matching that conflicts with the project rule requiring model-specific flags to live only in model_prices_and_context_window.json |
| litellm/responses/litellm_completion_transformation/transformation.py | Adds _normalize_responses_api_object_to_dict helper and wires it through content/input-item paths so that cache_control is preserved from Pydantic models and custom objects; cache_control=None is now correctly excluded from downstream dicts |
| litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py | Replaces the single MINIMUM_PROMPT_CACHE_TOKEN_COUNT constant with per-model dynamic lookup via get_gemini_context_caching_min_tokens; both sync and async paths updated consistently |
| litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py | Extends _copy_cache_control_to_target to accept Gemini models and fixes the non-dict fallback branch to use setattr instead of a bogus cast+dict-access |
| litellm/utils.py | Updates is_cached_message to handle non-dict message/content objects via getattr fallbacks, and adds min_token_count override parameter to is_prompt_caching_valid_prompt |
| litellm/types/llms/openai.py | Adds optional cache_control field to GenericChatCompletionMessage TypedDict so the type system recognises the newly propagated field |
| model_prices_and_context_window.json | Adds cache_creation_min_tokens to all known Gemini model entries across providers; values are 2048 for Gemini 2.5 variants and 2048/4096 for Gemini 3 family |
| tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py | New unit-test file covering TTL normalization, per-model min-token lookup, object-based cache_control extraction, and end-to-end transformation; all tests are mocked with no real network calls |
| tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py | Existing context-caching tests; patches is_prompt_caching_valid_prompt globally to bypass token counting, keeping tests isolated from the new dynamic min-token logic |
Reviews (5): Last reviewed commit: "fix(responses): guard against None cache..." | Re-trigger Greptile
c417828 to
fd57816
Compare
|
@greptile-apps review |
b69b197 to
59b25a5
Compare
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
|
@greptile-apps review |
|
@greptile review again |
| elif item.get("type") == "input_image": | ||
| image_block = dict( | ||
| LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item) | ||
| ) | ||
| if "cache_control" in item: | ||
| image_block["cache_control"] = item["cache_control"] | ||
| image_block = { | ||
| **LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item), | ||
| **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), | ||
| } | ||
| content_list.append(image_block) | ||
| else: | ||
| # Skip text blocks with None text to avoid downstream errors | ||
| text_value = item.get("text") | ||
| if text_value is None: | ||
| continue | ||
| content_block: dict[str, Any] = { | ||
| "type": LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type( | ||
| item.get("type") or "text" | ||
| ), | ||
| "text": text_value, | ||
| **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), | ||
| } |
There was a problem hiding this comment.
model_dump() / dict() include None-valued fields, so when a Pydantic content block has cache_control: Optional[...] = None, the serialized dict contains "cache_control": None. The "cache_control" in item guard then evaluates to True and None is forwarded as the cache_control value on both the image and text paths, which can produce a provider-side validation error (e.g., Anthropic rejects a null cache_control). The plain-object fallback path in _normalize_responses_api_object_to_dict already guards against None; these two call-sites need the same protection.
| elif item.get("type") == "input_image": | |
| image_block = { | |
| **LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item), | |
| **({"cache_control": item["cache_control"]} if item.get("cache_control") is not None else {}), | |
| } | |
| content_list.append(image_block) | |
| else: | |
| # Skip text blocks with None text to avoid downstream errors | |
| text_value = item.get("text") | |
| if text_value is None: | |
| continue | |
| content_block: dict[str, Any] = { | |
| "type": LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type( | |
| item.get("type") or "text" | |
| ), | |
| "text": text_value, | |
| **({"cache_control": item["cache_control"]} if item.get("cache_control") is not None else {}), | |
| } |
d303add to
56059e5
Compare
56059e5 to
0fcaadf
Compare
…ken requirements to prevent 400s
8ef138c to
a6661b3
Compare
a6661b3 to
4a307e6
Compare
… and normalize Anthropic ttl units
…Google's live cachedContents API
|
Superseded by #41938, the same commits on an internal branch so CircleCI can run the llm_translation and local_testing suites. Thanks for the contribution! |
TLDR
Problem this solves:
/v1/messagesnever gets prompt caching/v1/messagesbridge droppedcache_controlfor every non-Claude target1hor5m; Gemini only accepts3600sHow it solves it:
/v1/messageskeepscache_controlwhen the target model is a Gemini model<seconds>sformUser Flow
Before: a developer running Claude Code against a Gemini model through the gateway pays for the whole system prompt on every turn, and a short cached block on Gemini 2.5 Pro fails outright
ANTHROPIC_BASE_URL=https://litellm-domainandANTHROPIC_MODEL=gemini-25-flash, then ask a question"cache_control": {"type": "ephemeral"}usagecounts every prompt token asinput_tokensand carries nocache_read_input_tokens, and the next turn re-sends the whole system prompt at full price/costand see0 cache read, 0 cache writeand "no prompt caching reported by the API""ttl": "5m"to POST https://litellm-domain/v1/messages and gets the same uncached usage back; GET https://generativelanguage.googleapis.com/v1beta/cachedContents on their Gemini key lists no cache for itcache_controlto Gemini 2.5 Pro through POST https://litellm-domain/v1/chat/completions or POST https://litellm-domain/v1/responsesCached content is too small. total_token_count=1507, min_total_token_count=2048After: the same turns read the system prompt from Gemini's cache, the ttl lands as sent, and the short block on 2.5 Pro goes through as a plain request
ANTHROPIC_BASE_URL=https://litellm-domainandANTHROPIC_MODEL=gemini-25-flash, then ask a question"cache_control": {"type": "ephemeral"}usagenow carries the system prompt ascache_read_input_tokensand only the new text asinput_tokens/costand see31.7k cache readand "48% of input tokens from cache", at a lower dollar figure than before"ttl": "5m"request comes back withcache_read_input_tokens, and GET https://generativelanguage.googleapis.com/v1beta/cachedContents lists a cache whoseexpireTimeis five minutes after itscreateTimeusageRelevant issues
Ref #29810, Ref #29166
Affected release
Linear ticket
Resolves LIT-5373
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Both legs ran the same way and differ only in the commit the proxy was booted from. Each proxy ran
python litellm/proxy/proxy_cli.py --config config.yaml --num_workers 2 --detailed_debug(2 uvicorn workers, no Redis, no database) withLITELLM_LOCAL_MODEL_COST_MAP=Trueso each side reads the cost map at its own commit. Before is the merge base 5611607 on port 44911, After is the tip 2303379 on port 43175. Every request went to Google's real Gemini API through thegemini/provider and cost real moneyPayloads. The
/v1/messagesones carry a 27,823-character generated handbook as the system block (about 5.2k tokens, above every Gemini minimum);messages-ttl-5m.jsonis the same file with"ttl": "5m". The 2.5 Pro ones carry an 8k-character slice of it (1507 tokens, under 2.5 Pro's 2048 minimum)Claude Code v2.1.278 was launched interactively in tmux against each proxy with
env -u ANTHROPIC_API_KEY ANTHROPIC_BASE_URL=http://localhost:<port> ANTHROPIC_AUTH_TOKEN=sk-lit5373-qa ANTHROPIC_MODEL=gemini-25-flash ANTHROPIC_SMALL_FAST_MODEL=gemini-25-flash claude, given the same two prompts (Reply with one word: hello, thenworld), then/costBefore (5611607)
/v1/messages
for i in 1 2; do curl -s http://localhost:44911/v1/messages -H "Authorization: Bearer sk-lit5373-qa" -H "Content-Type: application/json" -d @messages-ttl-1h.json | jq -c .usage; done{"input_tokens":5213,"output_tokens":118}then{"input_tokens":5213,"output_tokens":252}: the whole system block is billed as input both times, no cache fields@messages-ttl-5m.json{"input_tokens":5213,"output_tokens":252}twice, no cache fields/v1/chat/completions
curl -s -w "\n%{http_code}\n" http://localhost:44911/v1/chat/completions -H "Authorization: Bearer sk-lit5373-qa" -H "Content-Type: application/json" -d @chat-25pro.json{"error": {"message": "litellm.BadRequestError: GeminiException BadRequestError - {\n \"error\": {\n \"code\": 400,\n \"message\": \"Cached content is too small. total_token_count=1507, min_total_token_count=2048\",\n \"status\": \"INVALID_ARGUMENT\"\n }\n}\n. Received Model Group=gemini-2.5-pro\nAvailable Model Group Fallbacks=None", "type": "invalid_request_error", "param": null, "code": "400"}}/v1/responses
curl -s -w "\n%{http_code}\n" http://localhost:44911/v1/responses -H "Authorization: Bearer sk-lit5373-qa" -H "Content-Type: application/json" -d @responses-25pro.json"Cached content is too small. total_token_count=1507, min_total_token_count=2048"Claude Code
Reply with one word: hello, thenworld/costreadsgemini-25-flash: 67.2k input, 257 output, 0 cache read, 0 cache write ($0.3426)and0% of input tokens from cache · no prompt caching reported by the APIAfter (2303379)
/v1/messages
for i in 1 2; do curl -s http://localhost:43175/v1/messages -H "Authorization: Bearer sk-lit5373-qa" -H "Content-Type: application/json" -d @messages-ttl-1h.json | jq -c .usage; done{"input_tokens":11,"output_tokens":112,"cache_read_input_tokens":5204}then{"input_tokens":11,"output_tokens":252,"cache_read_input_tokens":5204}: the system block is read from Gemini's cache, only the question is billed as input@messages-ttl-5m.json{"input_tokens":11,"output_tokens":156,"cache_read_input_tokens":5204}then{"input_tokens":11,"output_tokens":252,"cache_read_input_tokens":5204}curl -s https://generativelanguage.googleapis.com/v1beta/cachedContents -H "x-goog-api-key: $GEMINI_API_KEY" | jq -c '.cachedContents[] | {name, model, createTime, expireTime, tokens: .usageMetadata.totalTokenCount}'1hrequest's cache expires an hour after creation and the5mone five minutes after, so the Anthropic units reached Google as3600sand300s(the5mrow is the one created by a repeat of that request at 07:30, after the first 5m cache had expired):{"name":"cachedContents/94pjl84ov0n65s1w59f5f8lt05qiwh1ba3fis4x0","model":"models/gemini-3.8-flash","createTime":"2026-09-19T07:22:22.489285Z","expireTime":"2026-09-19T08:22:22.235822630Z","tokens":5204}{"name":"cachedContents/5g1w1gdpd0yw6m1xffqio7mljxzpmto8daxnmbly","model":"models/gemini-3.8-flash","createTime":"2026-09-19T07:30:03.221259Z","expireTime":"2026-09-19T07:35:03.001718991Z","tokens":5204}{"name":"cachedContents/go1dh5afd0v1artc4tgw8j7p28iunn0c7mjw3n50","model":"models/gemini-2.5-flash","createTime":"2026-09-19T07:23:48.496788Z","expireTime":"2026-09-19T08:23:47.974610795Z","tokens":15837}(Claude Code's own cache from the case below; it sends no ttl, so Google's default hour applies)/v1/chat/completions
curl -s -w "\n%{http_code}\n" http://localhost:43175/v1/chat/completions -H "Authorization: Bearer sk-lit5373-qa" -H "Content-Type: application/json" -d @chat-25pro.json"finish_reason": "stop", contentThis operations handbook describes how various gateway features like billing, routing, and caching are configured, how they evaluate requests, and their consistent approach to error handling and metrics.usage:{"completion_tokens": 966, "prompt_tokens": 1516, "total_tokens": 2482, "completion_tokens_details": {"reasoning_tokens": 933, "text_tokens": 33}, "prompt_tokens_details": {"text_tokens": 1516}}, no cache fields because the block is under 2.5 Pro's minimum and was sent uncached/v1/responses
curl -s -w "\n%{http_code}\n" http://localhost:43175/v1/responses -H "Authorization: Bearer sk-lit5373-qa" -H "Content-Type: application/json" -d @responses-25pro.json"status": "completed", output textThis operations handbook describes how various gateway features are configured and how they consistently process requests, handle errors, and export metrics.usage:{"input_tokens": 1516, "input_tokens_details": {"cached_tokens": 0, "text_tokens": 1516}, "output_tokens": 1362, "output_tokens_details": {"reasoning_tokens": 1338, "text_tokens": 24}, "total_tokens": 2878}Claude Code
Reply with one word: hello, thenworld/costreadsgemini-25-flash: 35.6k input, 280 output, 31.7k cache read, 0 cache write ($0.2008)andPrompt cache (main): 2 requests · 48% of input tokens from cacheObservations from the run, none caused by this PR:
cache_read_input_tokens, nevercache_creation_input_tokens; pre-existingType
🐛 Bug Fix
Caveats (if any)
Low
48hreaches Google as172800s<seconds>sform was never capped before eitherprompt_cache_min_tokenson Gemini 3.x models makes the proxy skip 1024-4095-token blocks Google would cache/v1/messagesis by model namegeminithat routes elsewhere carriescache_controlthrough, the same wayclaudealiases already doFinal Attestation