diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 27a0028ce4a..4ceb7338c0e 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -17,6 +17,8 @@ from ..common_utils import ( get_copilot_default_headers, ) +_REASONING_PARAMS: Final = frozenset({"thinking", "reasoning_effort"}) + class GithubCopilotConfig(OpenAIConfig): def __init__( @@ -114,26 +116,45 @@ class GithubCopilotConfig(OpenAIConfig): """ Get supported OpenAI parameters for GitHub Copilot. - For Claude models that support extended thinking (Claude 4 family and Claude 3-7), includes thinking and reasoning_effort parameters. - For other models, returns standard OpenAI parameters (which may include reasoning_effort for o-series models). + Claude models that support extended thinking accept both thinking and reasoning_effort. + Other reasoning-capable Copilot models (Gemini, Grok, gpt-5 family) accept reasoning_effort only. """ from litellm.utils import supports_reasoning - # Get base OpenAI parameters base_params: Final = super().get_supported_openai_params(model) + normalized: Final = model.lower() - # Add Claude-specific parameters for models that support extended thinking - if "claude" in model.lower() and supports_reasoning( - model=model.lower(), - ): - if "thinking" not in base_params: - base_params.append("thinking") - # reasoning_effort is not included by parent for Claude models, so add it - if "reasoning_effort" not in base_params: - base_params.append("reasoning_effort") + if not supports_reasoning(model=normalized): + return base_params + + # thinking is Anthropic-native; only Claude accepts it + if "claude" in normalized and "thinking" not in base_params: + base_params.append("thinking") + if "reasoning_effort" not in base_params: + base_params.append("reasoning_effort") return base_params + def map_openai_params( + self, + non_default_params: dict, # mutable-ok: matches the BaseConfig override signature + optional_params: dict, # mutable-ok: matches the BaseConfig override signature + model: str, + drop_params: bool, + ) -> dict: # mutable-ok: matches the BaseConfig override signature + # OpenAIConfig routes non-o-series/non-gpt-5 models to OpenAIGPTConfig, whose + # whitelist has neither key, so advertising them alone dropped them (#25666) + supported: Final = frozenset(self.get_supported_openai_params(model)) & _REASONING_PARAMS + reasoning_params: Final = {k: v for k, v in non_default_params.items() if k in supported} + remaining: Final = {k: v for k, v in non_default_params.items() if k not in supported} + + return super().map_openai_params( + non_default_params=remaining, + optional_params={**optional_params, **reasoning_params}, + model=model, + drop_params=drop_params, + ) + def _determine_initiator(self, messages: list[AllMessageValues]) -> str: """ Determine if request is user or agent initiated based on message roles. diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py index f1f1978b06f..aba1b338166 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py @@ -1,7 +1,7 @@ import asyncio import json from datetime import datetime, timedelta -from typing import AsyncGenerator +from typing import AsyncGenerator, Final from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest @@ -978,3 +978,96 @@ def test_openai_handler_repairs_github_copilot_empty_choices( assert result.choices[0].message.content == "Hi there" assert result.choices[0].finish_reason == "stop" mock_request.assert_called_once() + + +def test_map_openai_params_forwards_reasoning_effort_for_claude(): + """Claude reasoning params must survive mapping, not just be advertised (#25666).""" + config: Final = GithubCopilotConfig() + model: Final = "claude-sonnet-4-20250514" + + assert "reasoning_effort" in config.get_supported_openai_params(model) + + mapped: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model=model, + drop_params=False, + ) + assert mapped.get("reasoning_effort") == "high" + + mapped_thinking: Final = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 4096}}, + optional_params={}, + model=model, + drop_params=False, + ) + assert mapped_thinking.get("thinking") == { + "type": "enabled", + "budget_tokens": 4096, + } + + +def test_map_openai_params_does_not_forward_reasoning_for_unsupported_claude(): + """A Claude model without extended thinking must not gain reasoning params.""" + config: Final = GithubCopilotConfig() + model: Final = "claude-3.5-sonnet" + + assert "reasoning_effort" not in config.get_supported_openai_params(model) + + mapped: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model=model, + drop_params=True, + ) + assert "reasoning_effort" not in mapped + + +def test_map_openai_params_preserves_standard_openai_params_for_claude(): + """The Claude passthrough must not regress ordinary OpenAI param mapping.""" + config: Final = GithubCopilotConfig() + + mapped: Final = config.map_openai_params( + non_default_params={"temperature": 0.5, "max_tokens": 128}, + optional_params={}, + model="claude-sonnet-4-20250514", + drop_params=False, + ) + assert mapped.get("temperature") == 0.5 + assert mapped.get("max_tokens") == 128 + + +def test_non_claude_reasoning_model_forwards_reasoning_effort(): + """Reasoning-capable non-Claude Copilot models must also keep reasoning_effort.""" + config: Final = GithubCopilotConfig() + model: Final = "gemini-2.5-pro" + + with patch( + "litellm.utils.supports_reasoning", + return_value=True, + ): + supported: Final = config.get_supported_openai_params(model) + assert "reasoning_effort" in supported + # thinking is Anthropic-native and must stay Claude-only + assert "thinking" not in supported + + mapped: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model=model, + drop_params=False, + ) + assert mapped.get("reasoning_effort") == "high" + + +def test_non_reasoning_model_keeps_reasoning_effort_out(): + """A model the catalog reports as non-reasoning must not gain the param.""" + config: Final = GithubCopilotConfig() + + with patch( + "litellm.utils.supports_reasoning", + return_value=False, + ): + supported: Final = config.get_supported_openai_params("gpt-4o") + assert "reasoning_effort" not in supported + assert "thinking" not in supported