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:
Mateo Wang 2026-07-01 17:16:21 -07:00 • committed by GitHub
parent 99c65ea6dd
commit 0b0fd6a4d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 485 additions and 11 deletions

View file

@ -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):

View file

@ -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,

View 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

View file

@ -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,

View file

@ -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

View file

@ -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