diff --git a/litellm/_logging.py b/litellm/_logging.py index 6a38eea2b12..e5c8d7b6b77 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -11,7 +11,7 @@ from collections.abc import Iterator, Mapping from datetime import datetime from logging import Formatter from types import FrameType -from typing import Final, TextIO +from typing import Final, Protocol, TextIO from urllib.parse import unquote from pydantic import TypeAdapter @@ -936,6 +936,11 @@ else: handler.setFormatter(formatter) + +class WarningLogger(Protocol): + def warning(self, msg: str, /, *args: object) -> None: ... + + verbose_proxy_logger = logging.getLogger("LiteLLM Proxy") # Malformed virtual key rejections log through this child; LevelRoutingStreamHandler # writes its WARNING records to stdout. It has no handler or level of its own. diff --git a/litellm/llms/azure/chat/gpt_reasoning_transformation.py b/litellm/llms/azure/chat/gpt_reasoning_transformation.py index 29305866e23..428813f771c 100644 --- a/litellm/llms/azure/chat/gpt_reasoning_transformation.py +++ b/litellm/llms/azure/chat/gpt_reasoning_transformation.py @@ -4,6 +4,7 @@ from collections.abc import Collection, Mapping from typing import ClassVar, Final import litellm +from litellm._logging import WarningLogger, verbose_logger from litellm.llms.openai.chat.gpt_reasoning_transformation import ( OpenAIGPT5Config, is_gpt_reasoning_series_name, @@ -90,6 +91,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): api_version: str = "", *, declared_reasoning_efforts: Collection[str] | None = None, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict: return OpenAIGPT5Config.map_openai_params( self, @@ -98,6 +100,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): model=model, drop_params=drop_params, declared_reasoning_efforts=declared_reasoning_efforts, + reasoning_drop_logger=reasoning_drop_logger, ) def transform_request( diff --git a/litellm/llms/azure_ai/chat/transformation.py b/litellm/llms/azure_ai/chat/transformation.py index 93ff6d8170a..9c87050fde4 100644 --- a/litellm/llms/azure_ai/chat/transformation.py +++ b/litellm/llms/azure_ai/chat/transformation.py @@ -9,7 +9,7 @@ from httpx import Response from pydantic import TypeAdapter, ValidationError import litellm -from litellm._logging import verbose_logger +from litellm._logging import WarningLogger, verbose_logger from litellm.integrations.anthropic_cache_control_hook import ( AnthropicCacheControlHook, declared_prompt_cache_breakpoint, @@ -203,6 +203,7 @@ class AzureAIStudioConfig(OpenAIConfig): drop_params: bool, *, declared_reasoning_efforts: Collection[str] | None = None, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict[str, object]: # mutable-ok: OpenAIConfig.map_openai_params signature if not azureAIGPT5Config.is_model_gpt_5_model(model): return super().map_openai_params( @@ -217,6 +218,7 @@ class AzureAIStudioConfig(OpenAIConfig): model=model, drop_params=drop_params, declared_reasoning_efforts=declared_reasoning_efforts, + reasoning_drop_logger=reasoning_drop_logger, ) def _supports_stop_reason(self, model: str) -> bool: diff --git a/litellm/llms/chatgpt/chat/transformation.py b/litellm/llms/chatgpt/chat/transformation.py index e8f057b4d3d..9e1e752d8b8 100644 --- a/litellm/llms/chatgpt/chat/transformation.py +++ b/litellm/llms/chatgpt/chat/transformation.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from typing import Any, Final +from litellm._logging import WarningLogger, verbose_logger from litellm.exceptions import AuthenticationError from litellm.llms.openai.openai import OpenAIConfig from litellm.types.llms.openai import AllMessageValues @@ -118,11 +119,15 @@ class ChatGPTConfig(OpenAIConfig): def map_openai_params( self, - non_default_params: dict, - optional_params: dict, + non_default_params: dict[str, object], + optional_params: dict[str, object], model: str, drop_params: bool, - ) -> dict: - optional_params = super().map_openai_params(non_default_params, optional_params, model, drop_params) + *, + reasoning_drop_logger: WarningLogger = verbose_logger, + ) -> dict[str, object]: + optional_params = super().map_openai_params( + non_default_params, optional_params, model, drop_params, reasoning_drop_logger=reasoning_drop_logger + ) optional_params.setdefault("stream", False) return optional_params diff --git a/litellm/llms/openai/chat/gpt_reasoning_transformation.py b/litellm/llms/openai/chat/gpt_reasoning_transformation.py index 8c9551d01c0..ad90dcf9655 100644 --- a/litellm/llms/openai/chat/gpt_reasoning_transformation.py +++ b/litellm/llms/openai/chat/gpt_reasoning_transformation.py @@ -1,11 +1,12 @@ """Support for OpenAI gpt-5 model family.""" import re -from collections.abc import Collection +from collections.abc import Collection, Mapping +from dataclasses import dataclass from typing import ClassVar, Final import litellm -from litellm._logging import verbose_logger +from litellm._logging import WarningLogger, verbose_logger from litellm.utils import ( declared_value_factory, has_catalog_entry, @@ -37,6 +38,44 @@ def refused_reasoning_effort_warning(*, suggest_declared_levels: bool, suggest_b ) +def _effort_level(sent_effort: object) -> str | None: + match sent_effort: + case str(level) | {"effort": str(level)}: + return level + case _: + return None + + +def _sent_effort_level(sent_params: Mapping[str, object]) -> str | None: + match sent_params: + case {"extra_body": {"reasoning_effort": sent_effort}}: + return _effort_level(sent_effort) + case {"reasoning_effort": sent_effort}: + return _effort_level(sent_effort) + case _: + return None + + +@dataclass(slots=True) +class DeferredReasoningDropWarnings: + logger: WarningLogger + held: tuple[tuple[str, tuple[object, ...]], ...] = () + + def warning(self, msg: str, /, *args: object) -> None: + self.held = (*self.held, (msg, args)) + + def log_unless_dropped_effort_sent(self, sent_params: Mapping[str, object]) -> None: + sent_effort: Final = _sent_effort_level(sent_params) + held: Final = self.held + self.held = () + for msg, args in held: + match args: + case ("reasoning_effort", dropped_effort, *_) if dropped_effort == sent_effort: + continue + case _: + self.logger.warning(msg, *args) + + def _catalogue_declares_default_effort() -> bool: """Whether the loaded cost map carries default_reasoning_effort for ANY entry. @@ -330,6 +369,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig): drop_params: bool, *, declared_reasoning_efforts: Collection[str] | None = None, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict: if self.is_model_gpt_5_search_model(model): if "max_tokens" in non_default_params: @@ -364,7 +404,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig): and self.refuses_reasoning_effort_level(model, requested_effort) ): if litellm.drop_params or drop_params: - verbose_logger.warning( + reasoning_drop_logger.warning( refused_reasoning_effort_warning( suggest_declared_levels=declared_reasoning_efforts is not None, suggest_base_model=self.LOOKS_UP_BASE_MODEL diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 495ebf594d8..55442dfb1cf 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -27,7 +27,7 @@ from typing_extensions import overload import litellm from litellm import LlmProviders -from litellm._logging import verbose_logger +from litellm._logging import WarningLogger, verbose_logger from litellm.constants import DEFAULT_MAX_RETRIES from litellm.exceptions import UnsupportedParamsError from litellm.files.types import FileContentStreamingResult @@ -260,6 +260,8 @@ class OpenAIConfig(BaseConfig): optional_params: dict, model: str, drop_params: bool, + *, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict: """ """ if openaiOSeriesConfig.is_model_o_series_model(model=model): @@ -275,6 +277,7 @@ class OpenAIConfig(BaseConfig): optional_params=optional_params, model=model, drop_params=drop_params, + reasoning_drop_logger=reasoning_drop_logger, ) elif litellm.openAIGPTAudioConfig.is_model_gpt_audio_model(model=model): return litellm.openAIGPTAudioConfig.map_openai_params( diff --git a/litellm/main.py b/litellm/main.py index ce71a41d516..2d6cd18162b 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -128,6 +128,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( ) from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header from litellm.litellm_core_utils.reasoning_effort_capability import ( + RequestedReasoningEffort, model_group_from_metadata, reasoning_effort_needs_allowing, resolve_chat_reasoning, @@ -154,7 +155,10 @@ from litellm.llms.bedrock.common_utils import ( from litellm.llms.bedrock_mantle.chat.claude_transformation import bedrock_mantle_chat_config from litellm.llms.cohere.common_utils import CohereModelInfo from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, http2_enabled -from litellm.llms.openai.chat.gpt_reasoning_transformation import OpenAIGPT5Config +from litellm.llms.openai.chat.gpt_reasoning_transformation import ( + DeferredReasoningDropWarnings, + OpenAIGPT5Config, +) from litellm.llms.openai.common_utils import get_openai_credentials from litellm.llms.openai_like.json_loader import JSONProviderRegistry from litellm.llms.vertex_ai.common_utils import ( @@ -225,7 +229,7 @@ from litellm.utils import ( token_counter as token_counter, # noqa: PLC0414 # public re-export ) -from ._logging import verbose_logger +from ._logging import WarningLogger, verbose_logger from .caching.caching import disable_cache, enable_cache, update_cache from .litellm_core_utils.core_helpers import ( as_str_mapping, @@ -5478,6 +5482,51 @@ def _complete_langflow(ctx: CompletionDispatchContext) -> _CompletionDispatchRes ) +def _responses_bridge_optional_params( + optional_params: dict[str, object], # mutable-ok: completion's optional_params, which the Responses bridge takes + summary_alias: object, + requested: RequestedReasoningEffort, + *, + custom_llm_provider: str, + model: str, + base_model: str | None, + api_base: str | None, + deployment_model_info: Mapping[str, object] | None, + drop_params: bool, + additional_drop_params: object, +) -> dict[str, object]: # mutable-ok: completion's optional_params, which the Responses bridge takes + if isinstance(requested, dict): + from litellm.responses.utils import ( + RefusedReasoningFields, + bridge_reasoning, + raise_refused_reasoning_fields, + ) + + bridged_reasoning: Final = bridge_reasoning( + requested=requested, + effort_kept="reasoning_effort" in optional_params, + summary_alias=summary_alias, + model=model, + base_model=base_model, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + deployment_model_info=deployment_model_info, + drop_params=drop_params, + additional_drop_params=additional_drop_params, + ) + if isinstance(bridged_reasoning, RefusedReasoningFields): + raise_refused_reasoning_fields(bridged_reasoning, model, custom_llm_provider) + return {**optional_params, "reasoning_effort": bridged_reasoning} + if summary_alias is None: + return optional_params + kept_effort: Final = optional_params.get("reasoning_effort", requested) + if isinstance(kept_effort, dict): + return {**optional_params, "reasoning_effort": {**kept_effort, "summary": summary_alias}} + if kept_effort is None: + return {**optional_params, "reasoning_effort": {"summary": summary_alias}} + return {**optional_params, "reasoning_effort": {"effort": kept_effort, "summary": summary_alias}} + + def _complete_databricks_agent(ctx: CompletionDispatchContext) -> _CompletionDispatchResult: from litellm.llms.databricks.agent.transformation import DatabricksAgentConfig @@ -5830,6 +5879,7 @@ def completion( merge_with_first_system=True, ) + reasoning_drop_warnings: Final = DeferredReasoningDropWarnings(logger=verbose_logger) try: if base_url is not None: api_base = base_url @@ -6085,14 +6135,22 @@ def completion( mapping_args: Final = MappingProxyType({**optional_param_args, "reasoning_effort": provider_reasoning_effort}) mapping_special_params: Final = MappingProxyType(dict(provider_params)) - def optional_params_for_model_info(deployment_info: Mapping[str, object] | None) -> dict[str, object]: - return get_optional_params(**mapping_args, model_info=deployment_info, **mapping_special_params) + def optional_params_for_model_info( + deployment_info: Mapping[str, object] | None, + reasoning_drop_logger: WarningLogger = verbose_logger, + ) -> dict[str, object]: + return get_optional_params( + **mapping_args, + model_info=deployment_info, + _litellm_reasoning_drop_logger=reasoning_drop_logger, + **mapping_special_params, + ) validated_model_info: Final = ( _MODEL_INFO_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else None ) deployment_base_model: Final = base_model if isinstance(base_model, str) else None - optional_params = optional_params_for_model_info(validated_model_info) + optional_params = optional_params_for_model_info(validated_model_info, reasoning_drop_warnings) fakes_stream: Final = partial( _completion_fakes_stream, model, @@ -6289,6 +6347,7 @@ def completion( ) if mock_response is not None or mock_tool_calls or mock_timeout: kwargs.pop("mock_timeout", None) # remove for any fallbacks triggered + reasoning_drop_warnings.log_unless_dropped_effort_sent(optional_params) return mock_completion( model, messages, @@ -6337,41 +6396,19 @@ def completion( from litellm.completion_extras import responses_api_bridge optional_params, rs_val = strip_reasoning_summary_aliases_from_optional_params(optional_params) - - if isinstance(resolved_reasoning_effort, dict): - from litellm.responses.utils import ( - RefusedReasoningFields, - bridge_reasoning, - raise_refused_reasoning_fields, - ) - - bridged_reasoning: Final = bridge_reasoning( - requested=resolved_reasoning_effort, - effort_kept="reasoning_effort" in optional_params, - summary_alias=rs_val, - model=model, - base_model=base_model if isinstance(base_model, str) else None, - custom_llm_provider=custom_llm_provider, - api_base=api_base, - deployment_model_info=typed_deployment_model_info, - drop_params=drop_unsupported_reasoning, - additional_drop_params=routing_kwargs.get("additional_drop_params"), - ) - if isinstance(bridged_reasoning, RefusedReasoningFields): - raise_refused_reasoning_fields(bridged_reasoning, model, custom_llm_provider) - optional_params["reasoning_effort"] = bridged_reasoning - elif rs_val is not None: - eff: Final = optional_params.get("reasoning_effort", resolved_reasoning_effort) - if isinstance(eff, dict): - optional_params["reasoning_effort"] = {**eff, "summary": rs_val} - elif eff is not None: - optional_params["reasoning_effort"] = { - "effort": eff, - "summary": rs_val, - } - else: - optional_params["reasoning_effort"] = {"summary": rs_val} - + bridge_optional_params: Final = _responses_bridge_optional_params( + optional_params, + rs_val, + resolved_reasoning_effort, + custom_llm_provider=custom_llm_provider, + model=model, + base_model=deployment_base_model, + api_base=api_base, + deployment_model_info=typed_deployment_model_info, + drop_params=drop_unsupported_reasoning, + additional_drop_params=routing_kwargs.get("additional_drop_params"), + ) + reasoning_drop_warnings.log_unless_dropped_effort_sent(bridge_optional_params) return responses_api_bridge.completion( # pyright: ignore[reportReturnType] # bridge returns a coroutine on the acompletion path; awaited by the async caller model=model, messages=messages, @@ -6381,7 +6418,7 @@ def completion( api_base=api_base, acompletion=acompletion, logging_obj=logging, - optional_params=optional_params, + optional_params=bridge_optional_params, litellm_params=litellm_params, timeout=timeout, client=client, # pass AsyncOpenAI, OpenAI client @@ -6390,7 +6427,9 @@ def completion( stream=stream, cache=kwargs.get("cache"), ) - elif (custom_llm_provider == "openai" and OpenAIGPT5Config.is_model_gpt_5_model(model)) or ( + + reasoning_drop_warnings.log_unless_dropped_effort_sent(optional_params) + if (custom_llm_provider == "openai" and OpenAIGPT5Config.is_model_gpt_5_model(model)) or ( custom_llm_provider == "azure" and litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(_azure_detection_model) ): @@ -6656,6 +6695,7 @@ def completion( raise LiteLLMUnknownProvider(model=model, custom_llm_provider=custom_llm_provider) return response except Exception as e: + reasoning_drop_warnings.log_unless_dropped_effort_sent(MappingProxyType({})) ## Map to OpenAI Exception raise exception_type( model=model, diff --git a/litellm/utils.py b/litellm/utils.py index c6ade2603c4..156247897c3 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -516,7 +516,7 @@ from litellm.secret_managers.main import ( resolve_api_key_oidc_reference, ) -from ._logging import is_debugging_on, verbose_logger +from ._logging import WarningLogger, is_debugging_on, verbose_logger from .caching.caching import ( AzureBlobCache, Cache, @@ -5441,6 +5441,42 @@ def _declared_reasoning_efforts(model_info: Mapping[str, object] | None) -> tupl return deployment_reasoning_effort_levels(model_info) or () +def _map_params_with_provider_config( + provider_config: BaseConfig, + *, + custom_llm_provider: str, + non_default_params: dict[str, object], # mutable-ok: map_openai_params takes the caller's dicts + optional_params: dict[str, object], # mutable-ok: map_openai_params fills and returns the caller's dict + model: str, + drop_params: bool, + model_info: Mapping[str, object] | None, + reasoning_drop_logger: WarningLogger, +) -> dict[str, object]: # mutable-ok: get_optional_params keeps building on the mapped params + if custom_llm_provider == "azure_ai" and isinstance(provider_config, litellm.AzureAIStudioConfig): + return provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + declared_reasoning_efforts=_declared_reasoning_efforts(model_info), + reasoning_drop_logger=reasoning_drop_logger, + ) + if isinstance(provider_config, (litellm.ChatGPTConfig, litellm.GithubCopilotConfig)): + return provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + reasoning_drop_logger=reasoning_drop_logger, + ) + return provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + ) + + _DROPPED_REASONING_PARAMS: Final = ("reasoning_effort", "thinking") _DROP_UNREAD_OUTPUT_CONFIG_WARNING: Final = ( @@ -5530,6 +5566,7 @@ def get_optional_params( prompt_cache_key: str | None = None, base_model: str | None = None, model_info: Mapping[str, object] | None = None, + _litellm_reasoning_drop_logger: WarningLogger = verbose_logger, **kwargs: object, ): drop_params = normalize_drop_params(drop_params) # rebind-ok: config and DB deployments pass "true" as a string @@ -5541,6 +5578,7 @@ def get_optional_params( # OpenAI param. passed_params.pop("base_model", None) passed_params.pop("model_info", None) + passed_params.pop("_litellm_reasoning_drop_logger", None) provider_config: BaseConfig | None = None if custom_llm_provider is not None and custom_llm_provider in [provider.value for provider in LlmProviders]: provider_config = ProviderConfigManager.get_provider_chat_config( @@ -6035,6 +6073,7 @@ def get_optional_params( model=model, drop_params=bool(drop_params), declared_reasoning_efforts=_declared_reasoning_efforts(model_info), + reasoning_drop_logger=_litellm_reasoning_drop_logger, ) if litellm.OpenAIGPT5Config.is_model_gpt_5_model(model=model) else litellm.OpenAIConfig().map_openai_params( @@ -6067,6 +6106,7 @@ def get_optional_params( model=_azure_detection_model, drop_params=bool(drop_params), declared_reasoning_efforts=_declared_reasoning_efforts(model_info), + reasoning_drop_logger=_litellm_reasoning_drop_logger, ) else: verbose_logger.debug( @@ -6088,21 +6128,15 @@ def get_optional_params( drop_params=bool(drop_params), ) elif provider_config is not None: - optional_params = ( - provider_config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=model, - drop_params=bool(drop_params), - declared_reasoning_efforts=_declared_reasoning_efforts(model_info), - ) - if custom_llm_provider == "azure_ai" and isinstance(provider_config, litellm.AzureAIStudioConfig) - else provider_config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=model, - drop_params=bool(drop_params), - ) + optional_params = _map_params_with_provider_config( + provider_config, + custom_llm_provider=custom_llm_provider, + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=bool(drop_params), + model_info=model_info, + reasoning_drop_logger=_litellm_reasoning_drop_logger, ) else: # assume passing in params for openai-like api optional_params = litellm.OpenAILikeChatConfig().map_openai_params( @@ -11734,8 +11768,8 @@ def reasoning_effort_with_summary_alias( def strip_reasoning_summary_aliases_from_optional_params( - optional_params: dict, -) -> tuple[dict, object | None]: + optional_params: Mapping[str, object], +) -> tuple[dict[str, object], object | None]: """Copy optional_params; remove reasoningSummary aliases from top-level and extra_body.""" op: Final = dict(optional_params) rs_val = op.pop("reasoningSummary", None) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index c4879587038..577d4b4b11d 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -2451,6 +2451,261 @@ def test_openai_responses_bridge_drops_a_reasoning_effort_dict_for_a_model_witho assert "reasoning" not in request_body +_AZURE_TEST_API_BASE: Final = "https://example.openai.azure.com" +_UNLISTED_AZURE_GPT_5_MODEL: Final = "azure/gpt-5.6-litellm-unlisted" +_FUNCTION_TOOL: Final = {"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}} + + +@dataclass(frozen=True, slots=True) +class AzureUpstreamRoutes: + chat: respx.Route + responses: respx.Route + + def sent_reasoning_effort(self) -> object: + if self.responses.called: + return json.loads(self.responses.calls[0].request.content).get("reasoning", {}).get("effort") + return json.loads(self.chat.calls[0].request.content).get("reasoning_effort") + + +@pytest.fixture +def azure_upstream_routes( + local_model_cost_map: None, + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + openai_api_response: dict[str, object], +) -> AzureUpstreamRoutes: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.delenv("LITELLM_REASONING_AUTO_SUMMARY", raising=False) + return AzureUpstreamRoutes( + chat=respx_mock.post(url__regex=r".*/chat/completions.*").respond(json=openai_api_response), + responses=respx_mock.post(url__regex=r".*/responses.*").respond( + json={ + "id": "resp_test", + "object": "response", + "created_at": 1734366691, + "status": "completed", + "model": "gpt-5.6-litellm-unlisted", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Done.", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + ), + ) + + +@pytest.mark.parametrize( + ("request_params", "expected_sent_effort"), + [ + pytest.param({"reasoning_effort": "none", "reasoningSummary": "auto"}, "none", id="summary-alias"), + pytest.param( + {"reasoning_effort": "none", "extra_body": {"reasoningSummary": "auto"}}, + "none", + id="summary-alias-in-extra-body", + ), + pytest.param( + {"reasoning_effort": {"effort": "none", "summary": "auto"}, "tools": [_FUNCTION_TOOL]}, + None, + id="dict-with-summary-and-tool", + ), + pytest.param( + {"reasoning_effort": {"effort": "none"}, "reasoningSummary": "auto"}, None, id="dict-with-summary-alias" + ), + pytest.param({"reasoning_effort": "none"}, None, id="chat"), + pytest.param({"reasoning_effort": "none", "tools": [_FUNCTION_TOOL]}, None, id="bridged-without-summary"), + pytest.param({"reasoning_effort": {"effort": "none", "summary": "auto"}}, None, id="dict-with-summary-on-chat"), + pytest.param( + {"reasoning_effort": "none", "extra_body": {"reasoning_effort": "none"}}, + "none", + id="effort-in-extra-body", + ), + pytest.param( + {"reasoning_effort": "none", "tools": [_FUNCTION_TOOL], "extra_body": {"reasoning_effort": "none"}}, + "none", + id="effort-in-extra-body-bridged", + ), + pytest.param( + { + "reasoning_effort": "none", + "tools": [_FUNCTION_TOOL], + "extra_body": {"reasoning_effort": {"effort": "none", "summary": "auto"}}, + }, + "none", + id="effort-dict-in-extra-body-bridged", + ), + pytest.param( + { + "reasoning_effort": "none", + "reasoningSummary": "auto", + "extra_body": {"reasoning_effort": {"summary": "detailed"}}, + }, + None, + id="summary-only-dict-in-extra-body-bridged", + ), + pytest.param( + {"reasoning_effort": "none", "extra_body": {"reasoning_effort": "low"}}, + "low", + id="other-effort-in-extra-body", + ), + pytest.param( + {"reasoning_effort": "none", "tools": [_FUNCTION_TOOL], "extra_body": {"reasoning_effort": "low"}}, + "low", + id="other-effort-in-extra-body-bridged", + ), + ], +) +def test_reasoning_effort_drop_warning_fires_unless_the_provider_gets_the_dropped_effort( + request_params: dict[str, object], + expected_sent_effort: str | None, + azure_upstream_routes: AzureUpstreamRoutes, + caplog: pytest.LogCaptureFixture, +): + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + api_version="2025-04-01-preview", + drop_params=True, + **request_params, + ) + + sent_effort: Final = azure_upstream_routes.sent_reasoning_effort() + drop_warnings: Final = [r.getMessage() for r in caplog.records if "Dropping reasoning_effort" in r.getMessage()] + assert sent_effort == expected_sent_effort + assert len(drop_warnings) == (0 if sent_effort == "none" else 1) + + +@pytest.mark.parametrize( + "request_params", + [ + pytest.param( + {"provider_affinity_header": "x-session-affinity", "litellm_session_id": "a\nb"}, + id="rejected-before-dispatch", + ), + pytest.param({}, id="rejected-by-the-provider"), + ], +) +def test_reasoning_effort_drop_warning_fires_once_when_the_request_fails( + request_params: dict[str, object], + azure_upstream_routes: AzureUpstreamRoutes, + caplog: pytest.LogCaptureFixture, +): + azure_upstream_routes.chat.respond(400, json={"error": {"message": "refused", "type": "invalid_request_error"}}) + with caplog.at_level(logging.WARNING, logger="LiteLLM"), pytest.raises(litellm.BadRequestError): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + api_version="2025-04-01-preview", + drop_params=True, + reasoning_effort="none", + **request_params, + ) + + assert len([r for r in caplog.records if "Dropping reasoning_effort" in r.getMessage()]) == 1 + + +@pytest.fixture +def github_copilot_access_token(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "access-token").write_text("token") + + +@pytest.mark.parametrize( + ("model", "api_base", "refused_effort", "model_info"), + [ + pytest.param("openai/gpt-5.6-litellm-unlisted", "https://api.openai.com/v1", "xhigh", None, id="openai"), + pytest.param( + "azure_ai/gpt-5.6-litellm-unlisted", + "https://example.services.ai.azure.com/openai/v1", + "xhigh", + None, + id="azure_ai", + ), + pytest.param( + "chatgpt/gpt-5.5", + "https://chatgpt.example.com/backend-api/codex", + "minimal", + {"reasoning_effort_levels": ["minimal", "low", "medium", "high"]}, + id="chatgpt-declared-level", + ), + pytest.param( + "github_copilot/gpt-5.5", + "https://api.githubcopilot.com", + "minimal", + {"reasoning_effort_levels": ["minimal", "low", "medium", "high"]}, + id="github-copilot-declared-level", + ), + ], +) +def test_reasoning_effort_drop_warning_stays_quiet_when_the_summary_alias_restores_the_effort( + model: str, + api_base: str, + refused_effort: str, + model_info: dict[str, object] | None, + azure_upstream_routes: AzureUpstreamRoutes, + github_copilot_access_token: None, + caplog: pytest.LogCaptureFixture, +): + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + litellm.completion( + model=model, + messages=[{"role": "user", "content": "hello"}], + api_base=api_base, + api_key="fake-key", + drop_params=True, + reasoning_effort=refused_effort, + reasoningSummary="auto", + model_info=model_info, + ) + + assert azure_upstream_routes.sent_reasoning_effort() == refused_effort + assert not [r for r in caplog.records if "Dropping reasoning_effort" in r.getMessage()] + + +def test_a_request_param_named_like_the_drop_holder_still_reaches_the_provider( + azure_upstream_routes: AzureUpstreamRoutes, +): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + api_version="2025-04-01-preview", + drop_params=True, + reasoning_drop_logger="sent by the caller", + ) + + sent_body: Final = json.loads(azure_upstream_routes.chat.calls[0].request.content) + assert sent_body["reasoning_drop_logger"] == "sent by the caller" + + +def test_mock_response_keeps_the_reasoning_effort_drop_warning( + local_model_cost_map: None, caplog: pytest.LogCaptureFixture +): + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + reasoning_effort="none", + drop_params=True, + mock_response="hi", + ) + + assert any("Dropping reasoning_effort='none'" in r.getMessage() for r in caplog.records) + + @pytest.mark.parametrize( "drop_params, expected_reasoning", [