mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +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:
|
if self.enabled_providers is not None and provider_str not in self.enabled_providers:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Only short-circuit for providers without native Anthropic Messages
|
# Only short-circuit for providers whose Anthropic Messages agentic loop
|
||||||
# support. Providers that have a BaseAnthropicMessagesConfig (bedrock,
|
# does not run web_search itself. Providers that have a
|
||||||
# vertex_ai, azure_ai, anthropic) already use the agentic loop, which
|
# BaseAnthropicMessagesConfig which handles web search natively (bedrock,
|
||||||
# includes a follow-up LLM call to synthesize the answer from search
|
# vertex_ai, azure_ai, anthropic) already perform the search plus a
|
||||||
# results. Short-circuiting those would skip that synthesis step and
|
# follow-up LLM synthesis step; short-circuiting those would skip that
|
||||||
# return raw search text — a regression for existing users.
|
# 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:
|
try:
|
||||||
provider_enum = LlmProviders(provider_str)
|
provider_enum = LlmProviders(provider_str)
|
||||||
anthropic_config = ProviderConfigManager.get_provider_anthropic_messages_config(
|
anthropic_config = ProviderConfigManager.get_provider_anthropic_messages_config(
|
||||||
model=model, provider=provider_enum
|
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(
|
verbose_logger.debug(
|
||||||
f"WebSearchInterception: Skipping short-circuit for {provider_str} "
|
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
|
return None
|
||||||
except (ValueError, Exception):
|
except (ValueError, Exception):
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,19 @@ class BaseAnthropicMessagesConfig(ABC):
|
||||||
"""
|
"""
|
||||||
return True
|
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(
|
def get_async_streaming_response_iterator(
|
||||||
self,
|
self,
|
||||||
model: str,
|
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,
|
"max_tokens": 16000,
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"supported_endpoints": [
|
"supported_endpoints": [
|
||||||
"/v1/chat/completions"
|
"/v1/chat/completions",
|
||||||
|
"/v1/messages"
|
||||||
],
|
],
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_parallel_function_calling": true,
|
"supports_parallel_function_calling": true,
|
||||||
|
|
@ -19189,7 +19190,8 @@
|
||||||
"max_tokens": 16000,
|
"max_tokens": 16000,
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"supported_endpoints": [
|
"supported_endpoints": [
|
||||||
"/v1/chat/completions"
|
"/v1/chat/completions",
|
||||||
|
"/v1/messages"
|
||||||
],
|
],
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_parallel_function_calling": true,
|
"supports_parallel_function_calling": true,
|
||||||
|
|
@ -19242,7 +19244,8 @@
|
||||||
"max_tokens": 16000,
|
"max_tokens": 16000,
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"supported_endpoints": [
|
"supported_endpoints": [
|
||||||
"/v1/chat/completions"
|
"/v1/chat/completions",
|
||||||
|
"/v1/messages"
|
||||||
],
|
],
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_parallel_function_calling": true,
|
"supports_parallel_function_calling": true,
|
||||||
|
|
|
||||||
|
|
@ -7984,6 +7984,13 @@ class ProviderConfigManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
return DeepSeekAnthropicMessagesConfig()
|
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
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@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