From 607a9683a4a5d3d8cdb1b2f5463eaa875966d68e Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 5 Mar 2026 14:02:07 +0000 Subject: [PATCH] feat(anthropic): add opt-out flag for default reasoning summary Add `litellm.disable_default_reasoning_summary` flag (default False) and env var `LITELLM_DISABLE_DEFAULT_REASONING_SUMMARY` to allow users to opt out of the automatic `summary="detailed"` injection when routing Anthropic thinking requests to OpenAI's Responses API. Default behavior is preserved (summary="detailed" is always added), but users who don't want to pay for summary tokens can now disable it. https://claude.ai/code/session_01VJU9EwVvgvmeCe3Yu1aULa --- litellm/__init__.py | 1 + .../adapters/handler.py | 19 ++- .../responses_adapters/transformation.py | 9 ++ ...erimental_pass_through_messages_handler.py | 134 +++++++++++++----- .../test_responses_adapters_transformation.py | 32 +++++ 5 files changed, 156 insertions(+), 39 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 57e9cb25f43..ceadb983bdc 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -293,6 +293,7 @@ llm_guard_mode: Literal["all", "key-specific", "request-specific"] = "all" guardrail_name_config_map: Dict[str, GuardrailItem] = {} include_cost_in_streaming_usage: bool = False reasoning_auto_summary: bool = False +disable_default_reasoning_summary: bool = False ### PROMPTS #### from litellm.types.prompts.init_prompts import PromptSpec diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 4935c65f407..a0954d765c1 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -1,3 +1,4 @@ +import os from typing import ( TYPE_CHECKING, Any, @@ -77,22 +78,30 @@ class LiteLLMMessagesToCompletionTransformationHandler: # Prefix model with "responses/" to route to OpenAI Responses API completion_kwargs["model"] = f"responses/{model}" + summary_disabled = ( + litellm.disable_default_reasoning_summary + or os.getenv("LITELLM_DISABLE_DEFAULT_REASONING_SUMMARY", "false").lower() == "true" + ) + reasoning_effort = completion_kwargs.get("reasoning_effort") summary = thinking.get("summary") if isinstance(reasoning_effort, str) and reasoning_effort: reasoning_dict: Dict[str, Any] = {"effort": reasoning_effort} if summary: reasoning_dict["summary"] = summary + elif not summary_disabled: + reasoning_dict["summary"] = "detailed" completion_kwargs["reasoning_effort"] = reasoning_dict elif isinstance(reasoning_effort, dict): if ( - summary - and "summary" not in reasoning_effort + "summary" not in reasoning_effort and "generate_summary" not in reasoning_effort ): - updated_reasoning_effort = dict(reasoning_effort) - updated_reasoning_effort["summary"] = summary - completion_kwargs["reasoning_effort"] = updated_reasoning_effort + effective_summary = summary if summary else ("detailed" if not summary_disabled else None) + if effective_summary: + updated_reasoning_effort = dict(reasoning_effort) + updated_reasoning_effort["summary"] = effective_summary + completion_kwargs["reasoning_effort"] = updated_reasoning_effort @staticmethod def _prepare_completion_kwargs( diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 497809b05ff..ec855acd237 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -6,8 +6,11 @@ path used for OpenAI and Azure models. """ import json +import os from typing import Any, Dict, List, Optional, Union, cast +import litellm + from litellm.types.llms.anthropic import ( AllAnthropicToolsValues, AnthopicMessagesAssistantMessageParam, @@ -241,10 +244,16 @@ class LiteLLMAnthropicToResponsesAPIAdapter: effort = "low" else: effort = "minimal" + summary_disabled = ( + litellm.disable_default_reasoning_summary + or os.getenv("LITELLM_DISABLE_DEFAULT_REASONING_SUMMARY", "false").lower() == "true" + ) result: Dict[str, Any] = {"effort": effort} summary = thinking.get("summary") if summary: result["summary"] = summary + elif not summary_disabled: + result["summary"] = "detailed" return result def translate_request( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 7864c5f59ad..384c4a97c19 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -180,7 +180,8 @@ def test_openai_model_with_thinking_converts_to_reasoning(): assert "reasoning" in call_kwargs, "reasoning should be passed to litellm.responses" # budget_tokens=1024 -> effort="minimal" (< 2000 threshold) - expected_reasoning = {"effort": "minimal"} + # summary="detailed" added by default unless disable_default_reasoning_summary is set + expected_reasoning = {"effort": "minimal", "summary": "detailed"} assert call_kwargs["reasoning"] == expected_reasoning, ( f"reasoning should be {expected_reasoning} for budget_tokens=1024, " f"got {call_kwargs.get('reasoning')}" @@ -225,7 +226,7 @@ class TestThinkingParameterTransformation: class TestThinkingSummaryPreservation: - """Tests for issue #20998: thinking.summary must be preserved when routing to OpenAI Responses API.""" + """Tests for thinking.summary preservation and disable_default_reasoning_summary flag.""" def test_thinking_summary_concise_preserved_for_openai(self): """User-provided summary='concise' should not be replaced with 'detailed'.""" @@ -253,26 +254,98 @@ class TestThinkingSummaryPreservation: ) assert completion_kwargs["reasoning_effort"] == {"effort": "high", "summary": "auto"} - def test_thinking_without_summary_does_not_inject_summary(self): - """When no summary is provided, no summary should be injected (opt-in per OpenAI spec).""" + def test_summary_added_by_default_when_no_user_summary(self): + """When no user summary and flag is off, summary='detailed' is added by default.""" + import litellm from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( LiteLLMMessagesToCompletionTransformationHandler, ) - thinking = {"type": "enabled", "budget_tokens": 5000} - completion_kwargs = {"model": "openai/gpt-5.1", "reasoning_effort": "medium"} - LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( - completion_kwargs, thinking=thinking + original = litellm.disable_default_reasoning_summary + try: + litellm.disable_default_reasoning_summary = False + completion_kwargs = { + "model": "responses/gpt-5.2", + "custom_llm_provider": "openai", + "reasoning_effort": "medium", + } + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( + completion_kwargs, thinking={"type": "enabled", "budget_tokens": 5000} + ) + assert completion_kwargs["reasoning_effort"] == {"effort": "medium", "summary": "detailed"} + finally: + litellm.disable_default_reasoning_summary = original + + def test_summary_excluded_when_disable_flag_set_string_reasoning(self): + """When disable_default_reasoning_summary is True, summary is not added for string reasoning_effort.""" + import litellm + from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( + LiteLLMMessagesToCompletionTransformationHandler, ) - assert completion_kwargs["reasoning_effort"] == {"effort": "medium"} - assert "summary" not in completion_kwargs["reasoning_effort"] + + original = litellm.disable_default_reasoning_summary + try: + litellm.disable_default_reasoning_summary = True + completion_kwargs = { + "model": "responses/gpt-5.2", + "custom_llm_provider": "openai", + "reasoning_effort": "high", + } + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( + completion_kwargs, thinking={"type": "enabled", "budget_tokens": 10000} + ) + assert completion_kwargs["reasoning_effort"] == {"effort": "high"} + assert "summary" not in completion_kwargs["reasoning_effort"] + finally: + litellm.disable_default_reasoning_summary = original + + def test_summary_excluded_when_disable_flag_set_dict_reasoning(self): + """When disable_default_reasoning_summary is True, summary is not injected into dict reasoning_effort.""" + import litellm + from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( + LiteLLMMessagesToCompletionTransformationHandler, + ) + + original = litellm.disable_default_reasoning_summary + try: + litellm.disable_default_reasoning_summary = True + completion_kwargs = { + "model": "responses/gpt-5.2", + "custom_llm_provider": "openai", + "reasoning_effort": {"effort": "medium"}, + } + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( + completion_kwargs, thinking={"type": "enabled", "budget_tokens": 5000} + ) + assert completion_kwargs["reasoning_effort"] == {"effort": "medium"} + assert "summary" not in completion_kwargs["reasoning_effort"] + finally: + litellm.disable_default_reasoning_summary = original + + def test_user_provided_summary_preserved_even_when_flag_off(self): + """When user already set summary in dict reasoning_effort, it's preserved regardless of flag.""" + import litellm + from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( + LiteLLMMessagesToCompletionTransformationHandler, + ) + + original = litellm.disable_default_reasoning_summary + try: + litellm.disable_default_reasoning_summary = False + completion_kwargs = { + "model": "responses/gpt-5.2", + "custom_llm_provider": "openai", + "reasoning_effort": {"effort": "high", "summary": "concise"}, + } + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( + completion_kwargs, thinking={"type": "enabled", "budget_tokens": 10000} + ) + assert completion_kwargs["reasoning_effort"]["summary"] == "concise" + finally: + litellm.disable_default_reasoning_summary = original def test_openai_model_with_thinking_summary_end_to_end(self): - """End-to-end: anthropic_messages_handler should preserve thinking.summary for OpenAI models. - - OpenAI models are routed to litellm.responses(), so we verify the - reasoning dict passed to it contains the user's summary value. - """ + """End-to-end: anthropic_messages_handler should preserve thinking.summary for OpenAI models.""" from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( anthropic_messages_handler, ) @@ -309,16 +382,22 @@ class TestThinkingSummaryPreservation: result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(thinking) assert result == {"effort": "medium", "summary": "concise"} - def test_responses_adapter_no_summary_when_not_provided(self): - """translate_thinking_to_reasoning should not include summary when not provided.""" + def test_responses_adapter_no_summary_when_disabled(self): + """translate_thinking_to_reasoning should not include summary when flag is set and no user summary.""" + import litellm from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( LiteLLMAnthropicToResponsesAPIAdapter, ) - thinking = {"type": "enabled", "budget_tokens": 5000} - result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(thinking) - assert result == {"effort": "medium"} - assert "summary" not in result + original = litellm.disable_default_reasoning_summary + try: + litellm.disable_default_reasoning_summary = True + thinking = {"type": "enabled", "budget_tokens": 5000} + result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(thinking) + assert result == {"effort": "medium"} + assert "summary" not in result + finally: + litellm.disable_default_reasoning_summary = original def test_translate_thinking_for_model_preserves_summary(self): """translate_thinking_for_model should include summary in reasoning_effort dict when user provides it.""" @@ -332,16 +411,3 @@ class TestThinkingSummaryPreservation: model="openai/gpt-5.2", ) assert result == {"reasoning_effort": {"effort": "medium", "summary": "concise"}} - - def test_translate_thinking_for_model_no_summary_when_not_provided(self): - """translate_thinking_for_model should return plain string reasoning_effort when no summary provided.""" - from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( - LiteLLMAnthropicMessagesAdapter, - ) - - thinking = {"type": "enabled", "budget_tokens": 5000} - result = LiteLLMAnthropicMessagesAdapter.translate_thinking_for_model( - thinking=thinking, - model="openai/gpt-5.2", - ) - assert result == {"reasoning_effort": "medium"} diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 252ba230ff7..7f773945520 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -658,6 +658,38 @@ class TestTranslateThinkingToReasoning: result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled"}) assert result == {"effort": "minimal", "summary": "detailed"} + def test_summary_excluded_when_disable_flag_set(self): + """When disable_default_reasoning_summary is True, summary is not included.""" + import litellm + + original = litellm.disable_default_reasoning_summary + try: + litellm.disable_default_reasoning_summary = True + result = _ADAPTER.translate_thinking_to_reasoning( + {"type": "enabled", "budget_tokens": 10000} + ) + assert result == {"effort": "high"} + assert "summary" not in result + finally: + litellm.disable_default_reasoning_summary = original + + def test_summary_excluded_when_env_var_set(self): + """When LITELLM_DISABLE_DEFAULT_REASONING_SUMMARY env var is true, summary is not included.""" + import litellm + + original = litellm.disable_default_reasoning_summary + try: + litellm.disable_default_reasoning_summary = False + os.environ["LITELLM_DISABLE_DEFAULT_REASONING_SUMMARY"] = "true" + result = _ADAPTER.translate_thinking_to_reasoning( + {"type": "enabled", "budget_tokens": 5000} + ) + assert result == {"effort": "medium"} + assert "summary" not in result + finally: + litellm.disable_default_reasoning_summary = original + os.environ.pop("LITELLM_DISABLE_DEFAULT_REASONING_SUMMARY", None) + # --------------------------------------------------------------------------- # translate_request – broader coverage