From e98610e3813e2774b3fedefffa35029640a6f1d7 Mon Sep 17 00:00:00 2001 From: ririnto Date: Sat, 16 May 2026 19:00:28 +0900 Subject: [PATCH 1/9] feat(github_copilot): route /v1/messages to Copilot native Anthropic endpoint Add a GitHub Copilot Anthropic Messages transformation that routes supported Claude models through the native /v1/messages endpoint. This covers request URL construction, default headers, and supported model metadata. --- .../llms/github_copilot/messages/__init__.py | 0 .../github_copilot/messages/transformation.py | 81 ++++++++++++ ...odel_prices_and_context_window_backup.json | 9 +- litellm/utils.py | 7 ++ .../llms/github_copilot/messages/__init__.py | 0 ..._github_copilot_messages_transformation.py | 117 ++++++++++++++++++ 6 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 litellm/llms/github_copilot/messages/__init__.py create mode 100644 litellm/llms/github_copilot/messages/transformation.py create mode 100644 tests/test_litellm/llms/github_copilot/messages/__init__.py create mode 100644 tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py diff --git a/litellm/llms/github_copilot/messages/__init__.py b/litellm/llms/github_copilot/messages/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py new file mode 100644 index 000000000000..a30a81422302 --- /dev/null +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -0,0 +1,81 @@ +from typing import Any, List, Optional, Tuple + +from litellm.exceptions import AuthenticationError +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) + +from ..authenticator import Authenticator +from ..common_utils import ( + DEFAULT_GITHUB_COPILOT_API_BASE, + GetAPIKeyError, + get_copilot_default_headers, +) + + +class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): + """ + GitHub Copilot implementation of Anthropic messages API. + Routes requests to Copilot's /v1/messages endpoint with appropriate authentication and headers. + """ + + def __init__(self) -> None: + super().__init__() + self.authenticator = Authenticator() + + def validate_anthropic_messages_environment( + self, + headers: dict, + model: str, + messages: List[Any], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> Tuple[dict, Optional[str]]: + """ + Validate environment for GitHub Copilot and add Copilot-specific headers. + """ + # Get Copilot auth credentials + dynamic_api_base = ( + api_base + or self.authenticator.get_api_base() + or DEFAULT_GITHUB_COPILOT_API_BASE + ) + try: + dynamic_api_key = self.authenticator.get_api_key() + except GetAPIKeyError as e: + raise AuthenticationError( + model=model, + llm_provider="github_copilot", + message=str(e), + ) + + # Merge Copilot headers with provided headers + copilot_headers = get_copilot_default_headers(dynamic_api_key) + for key, value in copilot_headers.items(): + if key not in headers: + headers[key] = value + + # Set Anthropic version for messages API + if "anthropic-version" not in headers: + headers["anthropic-version"] = "2023-06-01" + + return headers, dynamic_api_base + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + """ + Return the complete URL for GitHub Copilot /v1/messages endpoint. + """ + api_base = api_base or DEFAULT_GITHUB_COPILOT_API_BASE + if not api_base.endswith("/v1/messages"): + api_base = f"{api_base}/v1/messages" + return api_base diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b16e1015255e..c9dae4d9a473 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -19176,7 +19176,8 @@ "max_tokens": 16000, "mode": "chat", "supported_endpoints": [ - "/v1/chat/completions" + "/v1/chat/completions", + "/v1/messages" ], "supports_function_calling": true, "supports_parallel_function_calling": true, @@ -19189,7 +19190,8 @@ "max_tokens": 16000, "mode": "chat", "supported_endpoints": [ - "/v1/chat/completions" + "/v1/chat/completions", + "/v1/messages" ], "supports_function_calling": true, "supports_parallel_function_calling": true, @@ -19242,7 +19244,8 @@ "max_tokens": 16000, "mode": "chat", "supported_endpoints": [ - "/v1/chat/completions" + "/v1/chat/completions", + "/v1/messages" ], "supports_function_calling": true, "supports_parallel_function_calling": true, diff --git a/litellm/utils.py b/litellm/utils.py index 45ce5332f1d0..26d3ae32739a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7984,6 +7984,13 @@ def _get_provider_anthropic_messages_config_cached( ) return DeepSeekAnthropicMessagesConfig() + elif litellm.LlmProviders.GITHUB_COPILOT == provider: + if "claude" in model_lower: + from litellm.llms.github_copilot.messages.transformation import ( + GithubCopilotAnthropicMessagesConfig, + ) + + return GithubCopilotAnthropicMessagesConfig() return None @staticmethod diff --git a/tests/test_litellm/llms/github_copilot/messages/__init__.py b/tests/test_litellm/llms/github_copilot/messages/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py new file mode 100644 index 000000000000..78bd5a8b34d9 --- /dev/null +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -0,0 +1,117 @@ +import os +import sys +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +from litellm.llms.github_copilot.messages.transformation import ( + GithubCopilotAnthropicMessagesConfig, +) +from litellm.llms.github_copilot.common_utils import GetAPIKeyError + + +def test_github_copilot_anthropic_messages_config_init(): + """Test GithubCopilotAnthropicMessagesConfig initialization.""" + config = GithubCopilotAnthropicMessagesConfig() + assert config is not None + assert hasattr(config, "authenticator") + + +def test_github_copilot_anthropic_messages_get_complete_url(): + """Test URL construction for GitHub Copilot messages endpoint.""" + config = GithubCopilotAnthropicMessagesConfig() + + # Test with default api_base + url = config.get_complete_url( + api_base=None, + api_key=None, + model="github_copilot/claude-haiku-4.5", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.githubcopilot.com/v1/messages" + + # Test with custom api_base + url = config.get_complete_url( + api_base="https://custom.api.com", + api_key=None, + model="github_copilot/claude-haiku-4.5", + optional_params={}, + litellm_params={}, + ) + assert url == "https://custom.api.com/v1/messages" + + # Test with api_base already ending with /v1/messages + url = config.get_complete_url( + api_base="https://custom.api.com/v1/messages", + api_key=None, + model="github_copilot/claude-haiku-4.5", + optional_params={}, + litellm_params={}, + ) + assert url == "https://custom.api.com/v1/messages" + + +def test_github_copilot_anthropic_messages_validate_environment(): + """Test environment validation and header injection.""" + config = GithubCopilotAnthropicMessagesConfig() + + # Mock the authenticator + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "gh.test-key-123" + config.authenticator.get_api_base.return_value = None + + headers = {} + validated_headers, api_base = config.validate_anthropic_messages_environment( + headers=headers, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + # Check that Copilot headers were added + assert "copilot-integration-id" in validated_headers + assert validated_headers["copilot-integration-id"] == "vscode-chat" + assert "Authorization" in validated_headers + assert "anthropic-version" in validated_headers + assert validated_headers["anthropic-version"] == "2023-06-01" + assert api_base == "https://api.githubcopilot.com" + + +def test_github_copilot_anthropic_messages_validate_environment_auth_error(): + """Test error handling when authentication fails.""" + config = GithubCopilotAnthropicMessagesConfig() + + # Mock the authenticator to raise an error + config.authenticator = MagicMock() + config.authenticator.get_api_key.side_effect = GetAPIKeyError( + status_code=401, message="No valid API key found" + ) + + with pytest.raises(Exception): # AuthenticationError + config.validate_anthropic_messages_environment( + headers={}, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + +def test_github_copilot_anthropic_messages_supported_params(): + """Test supported parameters list.""" + config = GithubCopilotAnthropicMessagesConfig() + params = config.get_supported_anthropic_messages_params("github_copilot/claude-haiku-4.5") + + # Should inherit from AnthropicMessagesConfig + assert "messages" in params + assert "model" in params + assert "max_tokens" in params + assert "thinking" in params From 538c80181bd907b65b0b04a72f7f6783d9add207 Mon Sep 17 00:00:00 2001 From: ririnto Date: Sat, 16 May 2026 19:42:58 +0900 Subject: [PATCH 2/9] fix(github_copilot): address PR review feedback Tighten the Anthropic Messages environment validation and web search interception behavior after review feedback. Avoid treating non-web-search requests as web-search-only paths. --- .../websearch_interception/handler.py | 24 ++++++--- .../github_copilot/messages/transformation.py | 30 ++++++++--- ..._github_copilot_messages_transformation.py | 53 +++++++++++++++---- 3 files changed, 80 insertions(+), 27 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index bfae6d5b7b0f..e360927ab3c4 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -126,17 +126,25 @@ async def try_short_circuit_search( # includes a follow-up LLM call to synthesize the answer from search # results. Short-circuiting those would skip that synthesis step and # return raw search text — a regression for existing users. + # + # github_copilot is the exception: it has a BaseAnthropicMessagesConfig + # (added for thinking passthrough), but Copilot does not handle + # web_search tools natively, so we still need the short-circuit for + # web-search-only requests against Copilot. try: provider_enum = LlmProviders(provider_str) - anthropic_config = ProviderConfigManager.get_provider_anthropic_messages_config( - model=model, provider=provider_enum - ) - if anthropic_config is not None: - verbose_logger.debug( - f"WebSearchInterception: Skipping short-circuit for {provider_str} " - "(provider has native Anthropic Messages support, using agentic loop)" + if provider_enum != LlmProviders.GITHUB_COPILOT: + anthropic_config = ( + ProviderConfigManager.get_provider_anthropic_messages_config( + model=model, provider=provider_enum + ) ) - return None + if anthropic_config is not None: + verbose_logger.debug( + f"WebSearchInterception: Skipping short-circuit for {provider_str} " + "(provider has native Anthropic Messages support, using agentic loop)" + ) + return None except (ValueError, Exception): pass # unknown provider enum → safe to short-circuit diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index a30a81422302..17b72a52b49a 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -35,12 +35,15 @@ def validate_anthropic_messages_environment( ) -> Tuple[dict, Optional[str]]: """ Validate environment for GitHub Copilot and add Copilot-specific headers. + + The caller-supplied ``api_base`` is intentionally ignored. Routing this + request anywhere other than the authenticated Copilot endpoint would + leak the Copilot bearer token to a caller-controlled URL. """ - # Get Copilot auth credentials + # Always use the Copilot endpoint resolved from the authenticated + # session, never the caller-supplied api_base. dynamic_api_base = ( - api_base - or self.authenticator.get_api_base() - or DEFAULT_GITHUB_COPILOT_API_BASE + self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE ) try: dynamic_api_key = self.authenticator.get_api_key() @@ -61,6 +64,12 @@ def validate_anthropic_messages_environment( if "anthropic-version" not in headers: headers["anthropic-version"] = "2023-06-01" + # Auto-inject anthropic-beta headers for advanced features + # (context_management, tool_search, output_format, speed) + headers = self._update_headers_with_anthropic_beta( + headers, optional_params, custom_llm_provider="github_copilot" + ) + return headers, dynamic_api_base def get_complete_url( @@ -74,8 +83,13 @@ def get_complete_url( ) -> str: """ Return the complete URL for GitHub Copilot /v1/messages endpoint. + + The caller-supplied ``api_base`` is intentionally ignored to avoid + leaking the Copilot bearer token to a caller-controlled URL. """ - api_base = api_base or DEFAULT_GITHUB_COPILOT_API_BASE - if not api_base.endswith("/v1/messages"): - api_base = f"{api_base}/v1/messages" - return api_base + resolved = ( + self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE + ) + if not resolved.endswith("/v1/messages"): + resolved = f"{resolved}/v1/messages" + return resolved diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 78bd5a8b34d9..38e8a6bdfe13 100644 --- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -6,10 +6,11 @@ sys.path.insert(0, os.path.abspath("../..")) +from litellm.exceptions import AuthenticationError +from litellm.llms.github_copilot.common_utils import GetAPIKeyError from litellm.llms.github_copilot.messages.transformation import ( GithubCopilotAnthropicMessagesConfig, ) -from litellm.llms.github_copilot.common_utils import GetAPIKeyError def test_github_copilot_anthropic_messages_config_init(): @@ -20,10 +21,12 @@ def test_github_copilot_anthropic_messages_config_init(): def test_github_copilot_anthropic_messages_get_complete_url(): - """Test URL construction for GitHub Copilot messages endpoint.""" + """URL is always resolved from the authenticator, never the caller.""" config = GithubCopilotAnthropicMessagesConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_base.return_value = None - # Test with default api_base + # No api_base supplied -> default Copilot endpoint url = config.get_complete_url( api_base=None, api_key=None, @@ -33,25 +36,28 @@ def test_github_copilot_anthropic_messages_get_complete_url(): ) assert url == "https://api.githubcopilot.com/v1/messages" - # Test with custom api_base + # Caller-supplied api_base must be ignored (token-exfiltration guard). url = config.get_complete_url( - api_base="https://custom.api.com", + api_base="https://attacker.example.com", api_key=None, model="github_copilot/claude-haiku-4.5", optional_params={}, litellm_params={}, ) - assert url == "https://custom.api.com/v1/messages" + assert url == "https://api.githubcopilot.com/v1/messages" - # Test with api_base already ending with /v1/messages + # Authenticator-provided base is honored (e.g. business/enterprise tenants). + config.authenticator.get_api_base.return_value = ( + "https://api.business.githubcopilot.com" + ) url = config.get_complete_url( - api_base="https://custom.api.com/v1/messages", + api_base="https://attacker.example.com", api_key=None, model="github_copilot/claude-haiku-4.5", optional_params={}, litellm_params={}, ) - assert url == "https://custom.api.com/v1/messages" + assert url == "https://api.business.githubcopilot.com/v1/messages" def test_github_copilot_anthropic_messages_validate_environment(): @@ -64,6 +70,7 @@ def test_github_copilot_anthropic_messages_validate_environment(): config.authenticator.get_api_base.return_value = None headers = {} + # Pass a hostile api_base to confirm it is ignored. validated_headers, api_base = config.validate_anthropic_messages_environment( headers=headers, model="github_copilot/claude-haiku-4.5", @@ -71,7 +78,7 @@ def test_github_copilot_anthropic_messages_validate_environment(): optional_params={}, litellm_params={}, api_key=None, - api_base=None, + api_base="https://attacker.example.com", ) # Check that Copilot headers were added @@ -80,9 +87,33 @@ def test_github_copilot_anthropic_messages_validate_environment(): assert "Authorization" in validated_headers assert "anthropic-version" in validated_headers assert validated_headers["anthropic-version"] == "2023-06-01" + # api_base must come from the authenticator, never the caller. assert api_base == "https://api.githubcopilot.com" +def test_github_copilot_anthropic_messages_validate_environment_injects_beta_headers(): + """Anthropic-beta headers must be auto-injected for advanced features + (context_management, output_format, etc.) — matches the parent + AnthropicMessagesConfig contract.""" + config = GithubCopilotAnthropicMessagesConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "gh.test-key" + config.authenticator.get_api_base.return_value = None + + validated_headers, _ = config.validate_anthropic_messages_environment( + headers={}, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"output_format": {"type": "json_object"}}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + assert "anthropic-beta" in validated_headers + assert "structured-outputs-2025-11-13" in validated_headers["anthropic-beta"] + + def test_github_copilot_anthropic_messages_validate_environment_auth_error(): """Test error handling when authentication fails.""" config = GithubCopilotAnthropicMessagesConfig() @@ -93,7 +124,7 @@ def test_github_copilot_anthropic_messages_validate_environment_auth_error(): status_code=401, message="No valid API key found" ) - with pytest.raises(Exception): # AuthenticationError + with pytest.raises(AuthenticationError): config.validate_anthropic_messages_environment( headers={}, model="github_copilot/claude-haiku-4.5", From c1ffdcaa9c7a904d3d2372809eea490768b41a1e Mon Sep 17 00:00:00 2001 From: ririnto Date: Sat, 16 May 2026 19:49:02 +0900 Subject: [PATCH 3/9] style(github_copilot): apply black formatting Apply Black formatting to the GitHub Copilot Anthropic Messages tests. --- litellm/llms/github_copilot/messages/transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index 17b72a52b49a..036544a0cd93 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -87,9 +87,7 @@ def get_complete_url( The caller-supplied ``api_base`` is intentionally ignored to avoid leaking the Copilot bearer token to a caller-controlled URL. """ - resolved = ( - self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE - ) + resolved = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE if not resolved.endswith("/v1/messages"): resolved = f"{resolved}/v1/messages" return resolved From 88ef1365b2099cd4024a5d3819c9d37dfda2401d Mon Sep 17 00:00:00 2001 From: ririnto Date: Sat, 16 May 2026 23:51:39 +0900 Subject: [PATCH 4/9] test(github_copilot): cover ProviderConfigManager dispatch for Anthropic Messages Add coverage for ProviderConfigManager dispatch when GitHub Copilot models use the Anthropic Messages API, including non-Anthropic models returning no config. --- ..._github_copilot_messages_transformation.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 38e8a6bdfe13..d4bd01e40926 100644 --- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -139,10 +139,40 @@ def test_github_copilot_anthropic_messages_validate_environment_auth_error(): def test_github_copilot_anthropic_messages_supported_params(): """Test supported parameters list.""" config = GithubCopilotAnthropicMessagesConfig() - params = config.get_supported_anthropic_messages_params("github_copilot/claude-haiku-4.5") + params = config.get_supported_anthropic_messages_params( + "github_copilot/claude-haiku-4.5" + ) # Should inherit from AnthropicMessagesConfig assert "messages" in params assert "model" in params assert "max_tokens" in params assert "thinking" in params + + +def test_provider_config_manager_dispatches_claude_to_copilot_messages_config(): + """ProviderConfigManager must return the Copilot Anthropic Messages config + for Claude models served via github_copilot.""" + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="github_copilot/claude-haiku-4.5", + provider=LlmProviders.GITHUB_COPILOT, + ) + + assert isinstance(config, GithubCopilotAnthropicMessagesConfig) + + +def test_provider_config_manager_skips_non_claude_copilot_models(): + """Non-Claude github_copilot models (e.g. gpt-*) must not be routed through + the Anthropic Messages dispatch.""" + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="github_copilot/gpt-5-mini", + provider=LlmProviders.GITHUB_COPILOT, + ) + + assert config is None From 518b0a123715aed6cdfede98ed9c4d06936e8005 Mon Sep 17 00:00:00 2001 From: ririnto Date: Thu, 11 Jun 2026 04:34:22 +0900 Subject: [PATCH 5/9] fix(github_copilot): apply messages-proxy intent header to /v1/messages Set the messages-proxy interaction header for GitHub Copilot Anthropic Messages requests so /v1/messages uses the expected Copilot intent. --- .../github_copilot/messages/transformation.py | 9 ++- ..._github_copilot_messages_transformation.py | 55 ++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index 036544a0cd93..35ee168ef545 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -12,6 +12,8 @@ get_copilot_default_headers, ) +_MESSAGES_PROXY_API_VERSION = "2026-06-01" + class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): """ @@ -60,12 +62,13 @@ def validate_anthropic_messages_environment( if key not in headers: headers[key] = value - # Set Anthropic version for messages API + headers["openai-intent"] = "messages-proxy" + headers["x-interaction-type"] = "messages-proxy" + headers["x-github-api-version"] = _MESSAGES_PROXY_API_VERSION + if "anthropic-version" not in headers: headers["anthropic-version"] = "2023-06-01" - # Auto-inject anthropic-beta headers for advanced features - # (context_management, tool_search, output_format, speed) headers = self._update_headers_with_anthropic_beta( headers, optional_params, custom_llm_provider="github_copilot" ) diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index d4bd01e40926..9e5cc51bb1ef 100644 --- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -81,13 +81,16 @@ def test_github_copilot_anthropic_messages_validate_environment(): api_base="https://attacker.example.com", ) - # Check that Copilot headers were added assert "copilot-integration-id" in validated_headers assert validated_headers["copilot-integration-id"] == "vscode-chat" assert "Authorization" in validated_headers assert "anthropic-version" in validated_headers assert validated_headers["anthropic-version"] == "2023-06-01" - # api_base must come from the authenticator, never the caller. + # /v1/messages must use the messages-proxy intent so the Copilot backend + # enables Anthropic-native features (context_management, thinking, etc.). + assert validated_headers["openai-intent"] == "messages-proxy" + assert validated_headers["x-interaction-type"] == "messages-proxy" + assert validated_headers["x-github-api-version"] == "2026-06-01" assert api_base == "https://api.githubcopilot.com" @@ -114,6 +117,54 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_beta_hea assert "structured-outputs-2025-11-13" in validated_headers["anthropic-beta"] +def test_github_copilot_anthropic_messages_validate_environment_preserves_caller_anthropic_version(): + """Caller-supplied anthropic-version must be forwarded verbatim.""" + config = GithubCopilotAnthropicMessagesConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "gh.test-key" + config.authenticator.get_api_base.return_value = None + + validated_headers, _ = config.validate_anthropic_messages_environment( + headers={"anthropic-version": "2024-10-22"}, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + assert validated_headers["anthropic-version"] == "2024-10-22" + + +def test_github_copilot_anthropic_messages_validate_environment_injects_context_management_beta(): + """context_management in optional_params must trigger the corresponding + anthropic-beta header so the Copilot backend accepts the field.""" + config = GithubCopilotAnthropicMessagesConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "gh.test-key" + config.authenticator.get_api_base.return_value = None + + validated_headers, _ = config.validate_anthropic_messages_environment( + headers={}, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "context_management": { + "edits": [{"type": "clear_tool_uses_20250919"}] + } + }, + litellm_params={}, + api_key=None, + api_base=None, + ) + + assert validated_headers["openai-intent"] == "messages-proxy" + assert validated_headers["x-interaction-type"] == "messages-proxy" + assert "anthropic-beta" in validated_headers + assert "context-management-2025-06-27" in validated_headers["anthropic-beta"] + + def test_github_copilot_anthropic_messages_validate_environment_auth_error(): """Test error handling when authentication fails.""" config = GithubCopilotAnthropicMessagesConfig() From 0e0eff97ee05a1f1ce11794cf14083963b67d6d1 Mon Sep 17 00:00:00 2001 From: ririnto Date: Sat, 20 Jun 2026 21:14:31 +0900 Subject: [PATCH 6/9] fix(github_copilot): use modern generic annotations Replace legacy typing generics in the GitHub Copilot Anthropic Messages transformation so the strict Ruff budget gate stays within its ceiling. --- litellm/integrations/websearch_interception/handler.py | 6 ++---- litellm/llms/github_copilot/messages/transformation.py | 10 ++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index e360927ab3c4..ef2fd1354456 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -134,10 +134,8 @@ async def try_short_circuit_search( try: provider_enum = LlmProviders(provider_str) if provider_enum != LlmProviders.GITHUB_COPILOT: - anthropic_config = ( - ProviderConfigManager.get_provider_anthropic_messages_config( - model=model, provider=provider_enum - ) + anthropic_config = ProviderConfigManager.get_provider_anthropic_messages_config( + model=model, provider=provider_enum ) if anthropic_config is not None: verbose_logger.debug( diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index 35ee168ef545..60b0e6d54f14 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, Tuple +from typing import Any, Optional from litellm.exceptions import AuthenticationError from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( @@ -29,12 +29,12 @@ def validate_anthropic_messages_environment( self, headers: dict, model: str, - messages: List[Any], + messages: list[Any], optional_params: dict, litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, - ) -> Tuple[dict, Optional[str]]: + ) -> tuple[dict, Optional[str]]: """ Validate environment for GitHub Copilot and add Copilot-specific headers. @@ -44,9 +44,7 @@ def validate_anthropic_messages_environment( """ # Always use the Copilot endpoint resolved from the authenticated # session, never the caller-supplied api_base. - dynamic_api_base = ( - self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE - ) + dynamic_api_base = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE try: dynamic_api_key = self.authenticator.get_api_key() except GetAPIKeyError as e: From ada5fcc9a17779ad53accd200e61139bd146b99b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 1 Jul 2026 04:30:27 +0000 Subject: [PATCH 7/9] refactor(github_copilot): decouple web-search short-circuit and harden /v1/messages URL Address review feedback on the Copilot native Anthropic messages path. Replace the hardcoded LlmProviders.GITHUB_COPILOT check in the web-search interception handler with a handles_web_search_natively() method on BaseAnthropicMessagesConfig (default True), overridden to False in GithubCopilotAnthropicMessagesConfig. Provider-specific behavior now lives in llms/ and the handler stays provider-agnostic, so a future provider in the same situation needs no carve-out here. In get_complete_url, reuse the already-resolved api_base returned by validate_anthropic_messages_environment instead of reading the authenticator a second time, removing redundant I/O and the mid-request inconsistency window. The caller-supplied base is still discarded in validate, which is the security boundary. Normalize a trailing slash on the base in both methods so a tenant-specific host never yields a double-slash //v1/messages URL. --- .../websearch_interception/handler.py | 37 ++++---- .../anthropic_messages/transformation.py | 13 +++ .../github_copilot/messages/transformation.py | 24 ++++-- ..._github_copilot_messages_transformation.py | 86 +++++++++++++++++-- 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index ef2fd1354456..60100e8c2fdd 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -120,29 +120,28 @@ async def try_short_circuit_search( if self.enabled_providers is not None and provider_str not in self.enabled_providers: return None - # Only short-circuit for providers without native Anthropic Messages - # support. Providers that have a BaseAnthropicMessagesConfig (bedrock, - # vertex_ai, azure_ai, anthropic) already use the agentic loop, which - # includes a follow-up LLM call to synthesize the answer from search - # results. Short-circuiting those would skip that synthesis step and - # return raw search text — a regression for existing users. + # Only short-circuit for providers whose Anthropic Messages agentic loop + # does not run web_search itself. Providers that have a + # BaseAnthropicMessagesConfig which handles web search natively (bedrock, + # vertex_ai, azure_ai, anthropic) already perform the search plus a + # follow-up LLM synthesis step; short-circuiting those would skip that + # synthesis and return raw search text — a regression for existing users. # - # github_copilot is the exception: it has a BaseAnthropicMessagesConfig - # (added for thinking passthrough), but Copilot does not handle - # web_search tools natively, so we still need the short-circuit for - # web-search-only requests against Copilot. + # github_copilot has a BaseAnthropicMessagesConfig (added for thinking + # passthrough) but does not handle web_search natively, so its config + # returns handles_web_search_natively() == False and we still short-circuit + # web-search-only requests against it. try: provider_enum = LlmProviders(provider_str) - if provider_enum != LlmProviders.GITHUB_COPILOT: - anthropic_config = ProviderConfigManager.get_provider_anthropic_messages_config( - model=model, provider=provider_enum + anthropic_config = ProviderConfigManager.get_provider_anthropic_messages_config( + model=model, provider=provider_enum + ) + if anthropic_config is not None and anthropic_config.handles_web_search_natively(): + verbose_logger.debug( + f"WebSearchInterception: Skipping short-circuit for {provider_str} " + "(provider handles web search natively via the agentic loop)" ) - if anthropic_config is not None: - verbose_logger.debug( - f"WebSearchInterception: Skipping short-circuit for {provider_str} " - "(provider has native Anthropic Messages support, using agentic loop)" - ) - return None + return None except (ValueError, Exception): pass # unknown provider enum → safe to short-circuit diff --git a/litellm/llms/base_llm/anthropic_messages/transformation.py b/litellm/llms/base_llm/anthropic_messages/transformation.py index 966995bc571f..448c1d070096 100644 --- a/litellm/llms/base_llm/anthropic_messages/transformation.py +++ b/litellm/llms/base_llm/anthropic_messages/transformation.py @@ -114,6 +114,19 @@ def should_filter_anthropic_beta_headers(self) -> bool: """ return True + def handles_web_search_natively(self) -> bool: + """ + Whether the upstream this config routes to executes ``web_search`` tools + itself as part of its Anthropic Messages agentic loop. + + The web-search interception handler short-circuits web-search-only + requests (running the search itself and returning synthetic results) only + for providers that do NOT. Providers whose agentic loop already performs + the search plus a follow-up synthesis step (bedrock, vertex_ai, ...) + return True so those requests flow through untouched. + """ + return True + def get_async_streaming_response_iterator( self, model: str, diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index 60b0e6d54f14..f787a01ff62f 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -25,6 +25,14 @@ def __init__(self) -> None: super().__init__() self.authenticator = Authenticator() + def handles_web_search_natively(self) -> bool: + """ + Copilot's /v1/messages endpoint does not execute ``web_search`` tools, so + the interception handler must short-circuit web-search-only requests + instead of routing them here. + """ + return False + def validate_anthropic_messages_environment( self, headers: dict, @@ -43,8 +51,10 @@ def validate_anthropic_messages_environment( leak the Copilot bearer token to a caller-controlled URL. """ # Always use the Copilot endpoint resolved from the authenticated - # session, never the caller-supplied api_base. - dynamic_api_base = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE + # session, never the caller-supplied api_base. rstrip so a + # tenant-specific base with a trailing slash does not yield a + # double-slash URL once "/v1/messages" is appended downstream. + dynamic_api_base = (self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE).rstrip("/") try: dynamic_api_key = self.authenticator.get_api_key() except GetAPIKeyError as e: @@ -85,10 +95,14 @@ def get_complete_url( """ Return the complete URL for GitHub Copilot /v1/messages endpoint. - The caller-supplied ``api_base`` is intentionally ignored to avoid - leaking the Copilot bearer token to a caller-controlled URL. + ``api_base`` here is the value already resolved by + ``validate_anthropic_messages_environment`` (the authenticated Copilot + host), not the raw caller-supplied base — that one is discarded there to + avoid leaking the Copilot bearer token to a caller-controlled URL. We + reuse it to avoid a second authenticator read, falling back to a fresh + resolution only if it was not provided. """ - resolved = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE + resolved = (api_base or self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE).rstrip("/") if not resolved.endswith("/v1/messages"): resolved = f"{resolved}/v1/messages" return resolved diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 9e5cc51bb1ef..6c74955a4b9d 100644 --- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -21,12 +21,17 @@ def test_github_copilot_anthropic_messages_config_init(): def test_github_copilot_anthropic_messages_get_complete_url(): - """URL is always resolved from the authenticator, never the caller.""" + """get_complete_url builds the /v1/messages URL from the base it is handed. + + In the request flow that ``api_base`` is the value already resolved by + validate_anthropic_messages_environment (the authenticated Copilot host); the + caller-supplied base is discarded there, not here (see the validate tests). + """ config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() config.authenticator.get_api_base.return_value = None - # No api_base supplied -> default Copilot endpoint + # No api_base supplied and no authenticator base -> default Copilot endpoint. url = config.get_complete_url( api_base=None, api_key=None, @@ -35,10 +40,34 @@ def test_github_copilot_anthropic_messages_get_complete_url(): litellm_params={}, ) assert url == "https://api.githubcopilot.com/v1/messages" + # Falls back to a single authenticator read, not a hard-coded second one. + config.authenticator.get_api_base.assert_called() - # Caller-supplied api_base must be ignored (token-exfiltration guard). + # The resolved (validated) base passed in is reused verbatim; no extra read. + config.authenticator.get_api_base.reset_mock() url = config.get_complete_url( - api_base="https://attacker.example.com", + api_base="https://api.business.githubcopilot.com", + api_key=None, + model="github_copilot/claude-haiku-4.5", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.business.githubcopilot.com/v1/messages" + config.authenticator.get_api_base.assert_not_called() + + # A trailing slash on the base must not produce a double-slash URL. + url = config.get_complete_url( + api_base="https://api.business.githubcopilot.com/", + api_key=None, + model="github_copilot/claude-haiku-4.5", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.business.githubcopilot.com/v1/messages" + + # An already-complete /v1/messages base is left untouched. + url = config.get_complete_url( + api_base="https://api.githubcopilot.com/v1/messages", api_key=None, model="github_copilot/claude-haiku-4.5", optional_params={}, @@ -46,12 +75,18 @@ def test_github_copilot_anthropic_messages_get_complete_url(): ) assert url == "https://api.githubcopilot.com/v1/messages" - # Authenticator-provided base is honored (e.g. business/enterprise tenants). + +def test_github_copilot_anthropic_messages_get_complete_url_normalizes_authenticator_trailing_slash(): + """A tenant base with a trailing slash from the authenticator fallback must + not yield a double-slash URL.""" + config = GithubCopilotAnthropicMessagesConfig() + config.authenticator = MagicMock() config.authenticator.get_api_base.return_value = ( - "https://api.business.githubcopilot.com" + "https://api.business.githubcopilot.com/" ) + url = config.get_complete_url( - api_base="https://attacker.example.com", + api_base=None, api_key=None, model="github_copilot/claude-haiku-4.5", optional_params={}, @@ -227,3 +262,40 @@ def test_provider_config_manager_skips_non_claude_copilot_models(): ) assert config is None + + +def test_github_copilot_anthropic_messages_validate_environment_normalizes_trailing_slash(): + """A tenant base with a trailing slash from the authenticator must be + normalized so the URL built downstream has no double slash.""" + config = GithubCopilotAnthropicMessagesConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "gh.test-key" + config.authenticator.get_api_base.return_value = ( + "https://api.business.githubcopilot.com/" + ) + + _, api_base = config.validate_anthropic_messages_environment( + headers={}, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base="https://attacker.example.com", + ) + + assert api_base == "https://api.business.githubcopilot.com" + + +def test_github_copilot_config_does_not_handle_web_search_natively(): + """Copilot's /v1/messages does not run web_search, so its config must report + handles_web_search_natively() == False. This is what keeps the web-search + interception handler short-circuiting Copilot instead of routing to it, even + though Copilot now has a BaseAnthropicMessagesConfig. The base Anthropic + config (bedrock/vertex/anthropic path) must report True.""" + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + assert GithubCopilotAnthropicMessagesConfig().handles_web_search_natively() is False + assert AnthropicMessagesConfig().handles_web_search_natively() is True From 210bc94cb73728ddd80e010264f3a5bbe50e167b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 04:45:06 +0000 Subject: [PATCH 8/9] fix(github_copilot): forward anthropic-beta headers on /v1/messages The Copilot config inherited should_filter_anthropic_beta_headers()==True from BaseAnthropicMessagesConfig, so update_headers_with_filtered_beta stripped every anthropic-beta value after validate_anthropic_messages_environment injected them (github_copilot has no mapping in anthropic_beta_headers_config.json). That silently disabled header-gated features like context_management and structured outputs on the native passthrough. Override the hook to False, matching OpenAILikeAnthropicMessagesConfig. --- .../github_copilot/messages/transformation.py | 10 +++++ ..._github_copilot_messages_transformation.py | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index f787a01ff62f..fb3f0a4e1596 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -33,6 +33,16 @@ def handles_web_search_natively(self) -> bool: """ return False + def should_filter_anthropic_beta_headers(self) -> bool: + """ + Copilot's /v1/messages is a native Anthropic Messages passthrough, so + ``anthropic-beta`` values injected by ``_update_headers_with_anthropic_beta`` + (context_management, structured outputs, ...) must reach the upstream + verbatim. The default provider-scoped filter would drop them because + github_copilot has no entry in ``anthropic_beta_headers_config.json``. + """ + return False + def validate_anthropic_messages_environment( self, headers: dict, diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 6c74955a4b9d..7168eefc588f 100644 --- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -287,6 +287,43 @@ def test_github_copilot_anthropic_messages_validate_environment_normalizes_trail assert api_base == "https://api.business.githubcopilot.com" +def test_github_copilot_config_disables_anthropic_beta_filtering(): + """Copilot's /v1/messages is a native Anthropic passthrough, so injected + anthropic-beta values (context_management, structured outputs, ...) must be + forwarded verbatim. The default provider-scoped filter would drop them + because github_copilot has no entry in the beta headers config; a regression + here would silently disable header-gated Anthropic features for Copilot.""" + from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + config = GithubCopilotAnthropicMessagesConfig() + assert config.should_filter_anthropic_beta_headers() is False + assert AnthropicMessagesConfig().should_filter_anthropic_beta_headers() is True + + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "gh.test-key" + config.authenticator.get_api_base.return_value = None + + headers, _ = config.validate_anthropic_messages_environment( + headers={}, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]} + }, + litellm_params={}, + api_key=None, + api_base=None, + ) + + assert "context-management-2025-06-27" in headers["anthropic-beta"] + if config.should_filter_anthropic_beta_headers(): + headers = update_headers_with_filtered_beta(headers=dict(headers), provider="github_copilot") + assert "context-management-2025-06-27" in headers.get("anthropic-beta", "") + + def test_github_copilot_config_does_not_handle_web_search_natively(): """Copilot's /v1/messages does not run web_search, so its config must report handles_web_search_natively() == False. This is what keeps the web-search From 54bff776da18970e65e88cad134f590a0142730a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:44:52 +0000 Subject: [PATCH 9/9] test(github_copilot): remove dead branch in beta-header regression test The anthropic-beta filtering test guarded the fix with an if branch on should_filter_anthropic_beta_headers(), which is always False, so the branch was unreachable. Replace it with a direct assertion that running the provider-scoped filter for github_copilot strips every beta value, proving why the override is load-bearing and catching a regression that flips it back on. --- ..._github_copilot_messages_transformation.py | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 7168eefc588f..01787c07d272 100644 --- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -81,9 +81,7 @@ def test_github_copilot_anthropic_messages_get_complete_url_normalizes_authentic not yield a double-slash URL.""" config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() - config.authenticator.get_api_base.return_value = ( - "https://api.business.githubcopilot.com/" - ) + config.authenticator.get_api_base.return_value = "https://api.business.githubcopilot.com/" url = config.get_complete_url( api_base=None, @@ -184,11 +182,7 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_context_ headers={}, model="github_copilot/claude-haiku-4.5", messages=[{"role": "user", "content": "Hello"}], - optional_params={ - "context_management": { - "edits": [{"type": "clear_tool_uses_20250919"}] - } - }, + optional_params={"context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]}}, litellm_params={}, api_key=None, api_base=None, @@ -206,9 +200,7 @@ def test_github_copilot_anthropic_messages_validate_environment_auth_error(): # Mock the authenticator to raise an error config.authenticator = MagicMock() - config.authenticator.get_api_key.side_effect = GetAPIKeyError( - status_code=401, message="No valid API key found" - ) + config.authenticator.get_api_key.side_effect = GetAPIKeyError(status_code=401, message="No valid API key found") with pytest.raises(AuthenticationError): config.validate_anthropic_messages_environment( @@ -225,9 +217,7 @@ def test_github_copilot_anthropic_messages_validate_environment_auth_error(): def test_github_copilot_anthropic_messages_supported_params(): """Test supported parameters list.""" config = GithubCopilotAnthropicMessagesConfig() - params = config.get_supported_anthropic_messages_params( - "github_copilot/claude-haiku-4.5" - ) + params = config.get_supported_anthropic_messages_params("github_copilot/claude-haiku-4.5") # Should inherit from AnthropicMessagesConfig assert "messages" in params @@ -270,9 +260,7 @@ def test_github_copilot_anthropic_messages_validate_environment_normalizes_trail config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key" - config.authenticator.get_api_base.return_value = ( - "https://api.business.githubcopilot.com/" - ) + config.authenticator.get_api_base.return_value = "https://api.business.githubcopilot.com/" _, api_base = config.validate_anthropic_messages_environment( headers={}, @@ -310,18 +298,20 @@ def test_github_copilot_config_disables_anthropic_beta_filtering(): headers={}, model="github_copilot/claude-haiku-4.5", messages=[{"role": "user", "content": "Hello"}], - optional_params={ - "context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]} - }, + optional_params={"context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]}}, litellm_params={}, api_key=None, api_base=None, ) assert "context-management-2025-06-27" in headers["anthropic-beta"] - if config.should_filter_anthropic_beta_headers(): - headers = update_headers_with_filtered_beta(headers=dict(headers), provider="github_copilot") - assert "context-management-2025-06-27" in headers.get("anthropic-beta", "") + + # The override is load-bearing: had the config opted into the provider-scoped + # filter, the handler would have run it and dropped every value, since + # github_copilot has no mapping. Prove that here so a regression that flips + # should_filter back on is caught as the silent feature breakage it causes. + stripped = update_headers_with_filtered_beta(headers=dict(headers), provider="github_copilot") + assert "anthropic-beta" not in stripped def test_github_copilot_config_does_not_handle_web_search_natively():