From 325df8d62aaaaa8d079ed2269b781dcdfcd0202a Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 10 Mar 2026 09:45:28 +0530 Subject: [PATCH 1/4] Fix logging tests --- litellm/litellm_core_utils/redact_messages.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index ad68f3851a8..ddeb24d04a6 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -123,6 +123,16 @@ def perform_redaction(model_call_details: dict, result): elif isinstance(_result, litellm.EmbeddingResponse): if hasattr(_result, "data") and _result.data is not None: _result.data = [] + elif isinstance(_result, dict) and "choices" in _result: + # ModelResponse.model_dump() returns dict - redact choices in place + if isinstance(_result.get("choices"), list) and len(_result["choices"]) > 0: + choice = _result["choices"][0] + if isinstance(choice, dict) and "message" in choice: + msg = choice["message"] + if isinstance(msg, dict) and "content" in msg: + msg["content"] = "redacted-by-litellm" + if isinstance(msg, dict) and "audio" in msg: + msg["audio"] = None else: return {"text": "redacted-by-litellm"} return _result From 56be0a651f2d355be8ea9a0a1f2f71b2dc56cf80 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 10 Mar 2026 09:50:37 +0530 Subject: [PATCH 2/4] fix: add charity_engine to provider_endpoints_support.json Made-with: Cursor --- provider_endpoints_support.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index b1d4d5a1164..0b3f87fbe09 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -458,6 +458,24 @@ "interactions": true } }, + "charity_engine": { + "display_name": "Charity Engine (`charity_engine`)", + "url": "https://docs.litellm.ai/docs/providers/charity_engine", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false, + "interactions": false + } + }, "chutes": { "display_name": "Chutes (`chutes`)", "endpoints": { From 2cb47727b62d63417d41032aeefb7728df1a3442 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Mon, 9 Mar 2026 20:56:27 -0700 Subject: [PATCH 3/4] fix: forward recognized OpenAI params from kwargs in completion() (#23224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any param in DEFAULT_CHAT_COMPLETION_PARAM_VALUES that arrives via completion(**kwargs) is now automatically forwarded to get_optional_params(), even if it's not a named parameter of completion(). Previously, get_non_default_completion_params() excluded params in OPENAI_CHAT_COMPLETION_PARAMS (assuming they'd be forwarded via the named-param path), while optional_param_args only contained explicitly named params. Params like 'store' that were in the known-params list but not named params fell through both paths and were silently dropped. The fix adds a 7-line loop after building optional_param_args that forwards any kwargs present in DEFAULT_CHAT_COMPLETION_PARAM_VALUES. This means new OpenAI params only need to be added to the constants dict — no boilerplate changes to 3+ function signatures required. Fixes #23087 Co-authored-by: Cursor Agent --- litellm/main.py | 8 + .../llms/openai/chat/test_store_param.py | 188 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 tests/test_litellm/llms/openai/chat/test_store_param.py diff --git a/litellm/main.py b/litellm/main.py index 364519e1fe3..e23baadb79a 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -65,6 +65,7 @@ if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging from litellm.constants import ( + DEFAULT_CHAT_COMPLETION_PARAM_VALUES, DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT, DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT, ) @@ -1487,6 +1488,13 @@ def completion( # type: ignore # noqa: PLR0915 "service_tier": service_tier, "allowed_openai_params": kwargs.get("allowed_openai_params"), } + for k, v in kwargs.items(): + if ( + k in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + and k not in optional_param_args + and v is not None + ): + optional_param_args[k] = v optional_params = get_optional_params( **optional_param_args, **non_default_params ) diff --git a/tests/test_litellm/llms/openai/chat/test_store_param.py b/tests/test_litellm/llms/openai/chat/test_store_param.py new file mode 100644 index 00000000000..0fd4799dae5 --- /dev/null +++ b/tests/test_litellm/llms/openai/chat/test_store_param.py @@ -0,0 +1,188 @@ +""" +Tests for the `store` parameter being correctly forwarded to OpenAI. + +Related issue: https://github.com/BerriAI/litellm/issues/23087 + +The `store` parameter was listed in OPENAI_CHAT_COMPLETION_PARAMS and +DEFAULT_CHAT_COMPLETION_PARAM_VALUES but was silently dropped because +get_non_default_completion_params() excluded it (as a "known" param) +while optional_param_args didn't include it (not a named param of +completion()). The fix adds a safety net in completion() that forwards +any kwargs present in DEFAULT_CHAT_COMPLETION_PARAM_VALUES that aren't +already in optional_param_args. +""" + +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../../..")) + +from litellm.constants import DEFAULT_CHAT_COMPLETION_PARAM_VALUES +from litellm.utils import get_non_default_completion_params, get_optional_params + + +class TestStoreParamForwarding: + """Tests that `store` flows through the parameter processing pipeline.""" + + def test_store_true_forwarded_for_openai(self): + """should forward store=True for OpenAI models via kwargs""" + result = get_optional_params( + model="gpt-5.1", + custom_llm_provider="openai", + store=True, + ) + assert result.get("store") is True + + def test_store_false_forwarded_for_openai(self): + """should forward store=False for OpenAI models""" + result = get_optional_params( + model="gpt-4o", + custom_llm_provider="openai", + store=False, + ) + assert result.get("store") is False + + def test_store_none_not_forwarded(self): + """should not include store when it is None (default)""" + result = get_optional_params( + model="gpt-4o", + custom_llm_provider="openai", + ) + assert "store" not in result + + def test_store_with_gpt5_models(self): + """should forward store=True for GPT-5 family models""" + for model in ["gpt-5.1", "gpt-5.2"]: + result = get_optional_params( + model=model, + custom_llm_provider="openai", + store=True, + ) + assert result.get("store") is True, f"store not forwarded for {model}" + + def test_store_in_supported_params(self): + """should list store as a supported OpenAI param""" + from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig + + config = OpenAIGPTConfig() + for model in ["gpt-4o", "gpt-5.1", "gpt-5.2"]: + supported = config.get_supported_openai_params(model) + assert "store" in supported, f"store not in supported params for {model}" + + def test_store_in_transform_request(self): + """should include store in the final transformed request body""" + from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig + + config = OpenAIGPTConfig() + messages = [{"role": "user", "content": "Hello"}] + optional_params = {"store": True} + result = config.transform_request( + model="gpt-5.1", + messages=messages, + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + assert result.get("store") is True + + def test_store_true_with_metadata(self): + """should forward both store and metadata when both are set""" + from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig + + config = OpenAIGPTConfig() + messages = [{"role": "user", "content": "Hello"}] + optional_params = {"store": True, "metadata": {"key": "value"}} + result = config.transform_request( + model="gpt-5.1", + messages=messages, + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + assert result.get("store") is True + assert result.get("metadata") == {"key": "value"} + + +class TestDefaultParamValuesSafetyNet: + """Tests that any param in DEFAULT_CHAT_COMPLETION_PARAM_VALUES flows + through completion() even without being a named parameter.""" + + def test_known_openai_param_excluded_from_non_default(self): + """should confirm get_non_default_completion_params excludes known OpenAI params""" + kwargs = {"store": True, "temperature": 0.5} + non_default = get_non_default_completion_params(kwargs=kwargs) + assert "store" not in non_default + assert "temperature" not in non_default + + def test_unknown_param_included_in_non_default(self): + """should pass through unknown provider-specific params""" + kwargs = {"my_custom_provider_param": "foo"} + non_default = get_non_default_completion_params(kwargs=kwargs) + assert non_default.get("my_custom_provider_param") == "foo" + + def test_safety_net_forwards_recognized_kwargs(self): + """should forward kwargs in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + that are not already in optional_param_args""" + optional_param_args = { + "model": "gpt-5.1", + "custom_llm_provider": "openai", + "temperature": 0.7, + } + kwargs = {"store": True, "metadata": {"key": "value"}} + for k, v in kwargs.items(): + if ( + k in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + and k not in optional_param_args + and v is not None + ): + optional_param_args[k] = v + + assert optional_param_args["store"] is True + assert optional_param_args["metadata"] == {"key": "value"} + assert optional_param_args["temperature"] == 0.7 + + def test_safety_net_does_not_override_existing(self): + """should not override a param that's already in optional_param_args""" + optional_param_args = { + "model": "gpt-5.1", + "custom_llm_provider": "openai", + "temperature": 0.7, + } + kwargs = {"temperature": 0.9} + for k, v in kwargs.items(): + if ( + k in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + and k not in optional_param_args + and v is not None + ): + optional_param_args[k] = v + + assert optional_param_args["temperature"] == 0.7 + + def test_safety_net_skips_none_values(self): + """should not forward params with None value (the default)""" + optional_param_args = {"model": "gpt-5.1", "custom_llm_provider": "openai"} + kwargs = {"store": None} + for k, v in kwargs.items(): + if ( + k in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + and k not in optional_param_args + and v is not None + ): + optional_param_args[k] = v + + assert "store" not in optional_param_args + + def test_safety_net_forwards_falsy_non_none(self): + """should forward store=False (falsy but not None)""" + optional_param_args = {"model": "gpt-5.1", "custom_llm_provider": "openai"} + kwargs = {"store": False} + for k, v in kwargs.items(): + if ( + k in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + and k not in optional_param_args + and v is not None + ): + optional_param_args[k] = v + + assert optional_param_args.get("store") is False From 7542845e8db7cceffbbe3d0ada8f5360ec469800 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 10 Mar 2026 09:53:19 +0530 Subject: [PATCH 4/4] Revert "fix: strip empty text content blocks in /v1/messages endpoint (#23097)" This reverts commit 2c738cc939c408cd0e85772bd503297c7d363197. --- litellm/llms/custom_httpx/llm_http_handler.py | 60 ----- ...est_v1_messages_empty_text_sanitization.py | 247 ------------------ 2 files changed, 307 deletions(-) delete mode 100644 tests/test_litellm/llms/anthropic/test_v1_messages_empty_text_sanitization.py diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 6a5d669cad2..1cef3e9ce15 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -152,59 +152,6 @@ else: LiteLLMLoggingObj = Any -def _sanitize_anthropic_messages_empty_text_blocks( - messages: List[Dict], -) -> List[Dict]: - """ - Strip empty text content blocks from Anthropic-format messages. - - Claude's API returns assistant messages with ``{"type": "text", "text": ""}`` - alongside ``tool_use`` blocks, but rejects them when sent back in subsequent - requests. This helper removes those empty text blocks so the /v1/messages - native path doesn't forward them as-is. - - - If a content list contains a mix of empty text blocks and other blocks - (e.g. tool_use), the empty text blocks are removed. - - If *all* blocks in a content list are empty text, the content is replaced - with a single non-empty placeholder to avoid sending an empty array. - - Ref: https://github.com/BerriAI/litellm/issues/22930 - """ - sanitized: List[Dict] = [] - for message in messages: - content = message.get("content") - if not isinstance(content, list): - sanitized.append(message) - continue - - filtered = [ - block - for block in content - if not ( - isinstance(block, dict) - and block.get("type") == "text" - and not block.get("text", "").strip() - ) - ] - - if filtered == content: - # Nothing was removed — keep original message as-is. - sanitized.append(message) - elif filtered: - # Some empty text blocks removed, but other content remains. - new_message = message.copy() - new_message["content"] = filtered - sanitized.append(new_message) - else: - # All blocks were empty text blocks. Replace with a placeholder - # so we don't send an empty content array. - new_message = message.copy() - new_message["content"] = [{"type": "text", "text": "..."}] - sanitized.append(new_message) - - return sanitized - - class BaseLLMHTTPHandler: async def _make_common_async_call( self, @@ -1958,13 +1905,6 @@ class BaseLLMHTTPHandler: anthropic_messages_optional_request_params, path ) - # Sanitize empty text content blocks from messages before forwarding. - # Claude's API returns assistant messages with empty text blocks - # ({"type": "text", "text": ""}) alongside tool_use blocks, but rejects - # them when sent back. Strip these to prevent 400 errors. - # Ref: https://github.com/BerriAI/litellm/issues/22930 - messages = _sanitize_anthropic_messages_empty_text_blocks(messages) - # Prepare request body request_body = anthropic_messages_provider_config.transform_anthropic_messages_request( model=model, diff --git a/tests/test_litellm/llms/anthropic/test_v1_messages_empty_text_sanitization.py b/tests/test_litellm/llms/anthropic/test_v1_messages_empty_text_sanitization.py deleted file mode 100644 index b397b5a484e..00000000000 --- a/tests/test_litellm/llms/anthropic/test_v1_messages_empty_text_sanitization.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -Test empty text content block sanitization for the /v1/messages native path. - -The Anthropic API returns assistant messages with empty text blocks -({"type": "text", "text": ""}) alongside tool_use blocks, but rejects -them when sent back. The /v1/messages endpoint must strip these before -forwarding to providers. - -Ref: https://github.com/BerriAI/litellm/issues/22930 -""" - -import pytest - -from litellm.llms.custom_httpx.llm_http_handler import ( - _sanitize_anthropic_messages_empty_text_blocks, -) - - -class TestSanitizeAnthropicMessagesEmptyTextBlocks: - """Unit tests for _sanitize_anthropic_messages_empty_text_blocks.""" - - def test_strips_empty_text_alongside_tool_use(self): - """ - The most common case from the bug report: an assistant message - containing an empty text block next to a tool_use block. - """ - messages = [ - {"role": "user", "content": "Run the command."}, - { - "role": "assistant", - "content": [ - {"type": "text", "text": ""}, - { - "type": "tool_use", - "id": "toolu_xxx", - "name": "Bash", - "input": {"command": "ls"}, - }, - ], - }, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert len(result) == 2 - assert result[0] == messages[0] # user message unchanged - # assistant content should only have the tool_use block - assert len(result[1]["content"]) == 1 - assert result[1]["content"][0]["type"] == "tool_use" - - def test_preserves_nonempty_text_blocks(self): - """Non-empty text blocks must not be removed.""" - messages = [ - { - "role": "assistant", - "content": [ - {"type": "text", "text": "Let me check that."}, - { - "type": "tool_use", - "id": "toolu_yyy", - "name": "Bash", - "input": {"command": "pwd"}, - }, - ], - }, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert len(result[0]["content"]) == 2 - assert result[0]["content"][0] == {"type": "text", "text": "Let me check that."} - - def test_whitespace_only_text_block_stripped(self): - """Whitespace-only text blocks should also be stripped.""" - messages = [ - { - "role": "assistant", - "content": [ - {"type": "text", "text": " \n\t "}, - { - "type": "tool_use", - "id": "toolu_zzz", - "name": "Bash", - "input": {}, - }, - ], - }, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert len(result[0]["content"]) == 1 - assert result[0]["content"][0]["type"] == "tool_use" - - def test_all_empty_text_blocks_replaced_with_placeholder(self): - """ - If all content blocks are empty text, replace with a placeholder - to avoid sending an empty content array. - """ - messages = [ - { - "role": "assistant", - "content": [ - {"type": "text", "text": ""}, - ], - }, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert len(result[0]["content"]) == 1 - assert result[0]["content"][0]["type"] == "text" - assert result[0]["content"][0]["text"].strip() # must be non-empty - - def test_string_content_untouched(self): - """Messages with string content should pass through unchanged.""" - messages = [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hi there!"}, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert result == messages - - def test_no_content_key_untouched(self): - """Messages without a content key should pass through.""" - messages = [ - {"role": "user", "content": "Hello"}, - {"role": "assistant"}, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert result == messages - - def test_user_message_content_list_also_sanitized(self): - """ - Empty text blocks should be stripped from user messages too, - not just assistant messages. - """ - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": ""}, - {"type": "text", "text": "actual question"}, - ], - }, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert len(result[0]["content"]) == 1 - assert result[0]["content"][0]["text"] == "actual question" - - def test_tool_result_content_blocks_untouched(self): - """ - tool_result content blocks should not be affected — only - {"type": "text", "text": ""} blocks are stripped. - """ - messages = [ - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_xxx", - "content": "", - }, - ], - }, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - assert result == messages - - def test_multiple_messages_mixed(self): - """End-to-end scenario with multiple messages, some needing sanitization.""" - messages = [ - {"role": "user", "content": "Run ls"}, - { - "role": "assistant", - "content": [ - {"type": "text", "text": ""}, - { - "type": "tool_use", - "id": "toolu_1", - "name": "Bash", - "input": {"command": "ls"}, - }, - ], - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_1", - "content": "file1.txt\nfile2.txt", - }, - ], - }, - { - "role": "assistant", - "content": [ - {"type": "text", "text": "Here are the files:"}, - ], - }, - ] - - result = _sanitize_anthropic_messages_empty_text_blocks(messages) - - # First message: string content, unchanged - assert result[0] == messages[0] - # Second message: empty text stripped, only tool_use remains - assert len(result[1]["content"]) == 1 - assert result[1]["content"][0]["type"] == "tool_use" - # Third message: tool_result, unchanged - assert result[2] == messages[2] - # Fourth message: non-empty text, unchanged - assert result[3] == messages[3] - - def test_does_not_mutate_original_messages(self): - """The function should not modify the input list or its dicts.""" - original_content = [ - {"type": "text", "text": ""}, - { - "type": "tool_use", - "id": "toolu_1", - "name": "Bash", - "input": {}, - }, - ] - messages = [ - { - "role": "assistant", - "content": original_content, - }, - ] - - _sanitize_anthropic_messages_empty_text_blocks(messages) - - # Original message content should be unchanged - assert len(messages[0]["content"]) == 2 - assert messages[0]["content"][0] == {"type": "text", "text": ""}