mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
feat(github_copilot): route /v1/messages to Copilot native Anthropic endpoint (#31802)
* 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. * 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. * style(github_copilot): apply black formatting Apply Black formatting to the GitHub Copilot Anthropic Messages tests. * 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. * 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. * 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. * 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. * 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. * 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. --------- Co-authored-by: ririnto <ririnto@kakao.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
99c65ea6dd
commit
0b0fd6a4d1
8 changed files with 485 additions and 11 deletions
|
|
@ -120,21 +120,26 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
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):
|
||||
|
|
|
|||
|
|
@ -114,6 +114,19 @@ class BaseAnthropicMessagesConfig(ABC):
|
|||
"""
|
||||
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,
|
||||
|
|
|
|||
0
litellm/llms/github_copilot/messages/__init__.py
Normal file
0
litellm/llms/github_copilot/messages/__init__.py
Normal file
118
litellm/llms/github_copilot/messages/transformation.py
Normal file
118
litellm/llms/github_copilot/messages/transformation.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7984,6 +7984,13 @@ class ProviderConfigManager:
|
|||
)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue