diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 6651a3c60b72..24d18a1d52ba 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -130,6 +130,62 @@ def get_supported_openai_params(self, model: str) -> list: return base_params + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + """ + Map OpenAI params to GitHub Copilot params. + + GitHub Copilot uses an OpenAI-compatible API and does not understand the + Anthropic-native ``thinking`` parameter. When Claude Code calls the proxy's + ``/v1/messages`` endpoint with ``thinking`` and the request is routed to a + ``github_copilot/claude-*`` model, ``thinking`` must be converted to + ``reasoning_effort`` before being forwarded to the Copilot API. + + ``reasoning_effort`` is written directly to ``optional_params`` rather than + deferred to the parent: ``OpenAIConfig.map_openai_params`` dispatches to the + global ``openAIGPTConfig`` whose supported-params list does not include + ``reasoning_effort`` for Claude models, so a deferred write would be dropped. + """ + if "claude" in model.lower(): + thinking = non_default_params.pop("thinking", None) + else: + thinking = None + existing_reasoning_effort = non_default_params.get( + "reasoning_effort" + ) or optional_params.get("reasoning_effort") + if ( + thinking is not None + and isinstance(thinking, dict) + and thinking.get("type") == "enabled" + and existing_reasoning_effort is None + ): + budget_tokens = thinking.get("budget_tokens") or 0 + if budget_tokens >= 10000: + reasoning_effort = "high" + elif budget_tokens >= 5000: + reasoning_effort = "medium" + elif budget_tokens >= 2000: + reasoning_effort = "low" + else: + reasoning_effort = "minimal" + optional_params["reasoning_effort"] = reasoning_effort + elif ( + "claude" in model.lower() + and non_default_params.get("reasoning_effort") is not None + ): + optional_params["reasoning_effort"] = non_default_params.pop( + "reasoning_effort" + ) + + return super().map_openai_params( + non_default_params, optional_params, model, 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 45ce5d584054..cd51bc2b02ca 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 @@ -433,6 +433,150 @@ def test_get_supported_openai_params_case_insensitive(): assert "reasoning_effort" not in supported_params_35 +def test_map_openai_params_converts_thinking_to_reasoning_effort(): + """ + Test that Anthropic-native ``thinking`` is converted to ``reasoning_effort`` for + GitHub Copilot Claude models. + + GitHub Copilot uses an OpenAI-compatible API and does not understand the + Anthropic ``thinking`` parameter. When Claude Code sends ``thinking`` via the + proxy's ``/v1/messages`` endpoint, litellm must translate it to + ``reasoning_effort`` before forwarding to Copilot. + """ + config = GithubCopilotConfig() + model = "claude-sonnet-4-20250514" + + # budget_tokens < 2000 -> "minimal" + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 1024}}, + optional_params={}, + model=model, + drop_params=False, + ) + assert "thinking" not in optional_params + assert optional_params["reasoning_effort"] == "minimal" + + # 2000 <= budget_tokens < 5000 -> "low" + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 2000}}, + optional_params={}, + model=model, + drop_params=False, + ) + assert "thinking" not in optional_params + assert optional_params["reasoning_effort"] == "low" + + # 5000 <= budget_tokens < 10000 -> "medium" + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 5000}}, + optional_params={}, + model=model, + drop_params=False, + ) + assert "thinking" not in optional_params + assert optional_params["reasoning_effort"] == "medium" + + # budget_tokens >= 10000 -> "high" + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 15000}}, + optional_params={}, + model=model, + drop_params=False, + ) + assert "thinking" not in optional_params + assert optional_params["reasoning_effort"] == "high" + + +def test_map_openai_params_thinking_does_not_overwrite_existing_reasoning_effort(): + """Caller-supplied ``reasoning_effort`` must not be overwritten by the + ``thinking`` translation.""" + config = GithubCopilotConfig() + + optional_params = config.map_openai_params( + non_default_params={ + "thinking": {"type": "enabled", "budget_tokens": 15000}, + "reasoning_effort": "low", + }, + optional_params={}, + model="claude-sonnet-4-20250514", + drop_params=False, + ) + assert "thinking" not in optional_params + assert optional_params["reasoning_effort"] == "low" + + +def test_map_openai_params_disabled_thinking_is_dropped(): + """``thinking`` with ``type=disabled`` should be dropped without producing + ``reasoning_effort``.""" + config = GithubCopilotConfig() + + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "disabled"}}, + optional_params={}, + model="claude-sonnet-4-20250514", + drop_params=False, + ) + assert "thinking" not in optional_params + assert "reasoning_effort" not in optional_params + + +def test_map_openai_params_no_thinking_leaves_params_unchanged(): + """A request without ``thinking`` should not gain a ``reasoning_effort`` value.""" + config = GithubCopilotConfig() + + optional_params = config.map_openai_params( + non_default_params={"temperature": 0.5}, + optional_params={}, + model="claude-sonnet-4-20250514", + drop_params=False, + ) + assert "thinking" not in optional_params + assert "reasoning_effort" not in optional_params + assert optional_params.get("temperature") == 0.5 + + +def test_map_openai_params_thinking_does_not_overwrite_reasoning_effort_in_optional_params(): + """``reasoning_effort`` already in ``optional_params`` must not be overwritten + by the ``thinking`` conversion.""" + config = GithubCopilotConfig() + + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 15000}}, + optional_params={"reasoning_effort": "low"}, + model="claude-sonnet-4-20250514", + drop_params=False, + ) + assert optional_params["reasoning_effort"] == "low" + + +def test_map_openai_params_thinking_not_popped_for_non_claude_model(): + """For non-Claude models, ``thinking`` must not be silently discarded — + it should pass through to the parent's unsupported-param handling.""" + config = GithubCopilotConfig() + + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 15000}}, + optional_params={}, + model="gpt-4o", + drop_params=False, + ) + assert "reasoning_effort" not in optional_params + + +def test_map_openai_params_reasoning_effort_not_promoted_for_non_claude_model(): + """For non-Claude models, ``reasoning_effort`` must not be force-promoted + to ``optional_params``; the parent's supported-params filtering must run.""" + config = GithubCopilotConfig() + + optional_params = config.map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model="gpt-4", + drop_params=True, + ) + assert "reasoning_effort" not in optional_params + + def test_copilot_vision_request_header_with_image(): """Test that Copilot-Vision-Request header is added when messages contain images""" config = GithubCopilotConfig()