diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index bfae6d5b7b0f..60100e8c2fdd 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -120,21 +120,26 @@ 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 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) anthropic_config = ProviderConfigManager.get_provider_anthropic_messages_config( model=model, provider=provider_enum ) - if anthropic_config is not None: + 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 has native Anthropic Messages support, using agentic loop)" + "(provider handles web search natively via the agentic loop)" ) return None except (ValueError, Exception): 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/__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..fb3f0a4e1596 --- /dev/null +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -0,0 +1,118 @@ +from typing import Any, Optional + +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, +) + +_MESSAGES_PROXY_API_VERSION = "2026-06-01" + + +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 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 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, + 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. + + 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. + """ + # Always use the Copilot endpoint resolved from the authenticated + # 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: + 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 + + 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" + + headers = self._update_headers_with_anthropic_beta( + headers, optional_params, custom_llm_provider="github_copilot" + ) + + 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`` 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 = (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/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..01787c07d272 --- /dev/null +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -0,0 +1,328 @@ +import os +import sys +from unittest.mock import MagicMock + +import pytest + +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, +) + + +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(): + """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 and no authenticator base -> default Copilot endpoint. + 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" + # Falls back to a single authenticator read, not a hard-coded second one. + config.authenticator.get_api_base.assert_called() + + # 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://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={}, + litellm_params={}, + ) + assert url == "https://api.githubcopilot.com/v1/messages" + + +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/" + + 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.business.githubcopilot.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 = {} + # 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", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base="https://attacker.example.com", + ) + + 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" + # /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" + + +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_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() + + # 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(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 + + +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 + + +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_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"] + + # 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(): + """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