fix: exchange GitHub access token for inference token in embedding/responses

The embedding and responses transformations were passing the raw GitHub
access token (ghu_xxx) as the Authorization header instead of exchanging
it for a short-lived Copilot inference token. Also fix api_base
precedence so caller-provided values win, update authenticator docstring
and error message to mention both SDK and proxy auth paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Wittenborn 2026-03-30 06:41:56 -05:00
parent a0efd2bdf5
commit 788da36fea
5 changed files with 56 additions and 27 deletions

View file

@ -60,13 +60,16 @@ class Authenticator:
def get_access_token(self) -> str:
"""
Login to Copilot with retry 3 times.
Return the GitHub access token.
In credential mode, returns the injected token directly.
In file-based mode (SDK), reads from the access-token file on disk.
Returns:
str: The GitHub access token.
Raises:
GetAccessTokenError: If unable to obtain an access token after retries.
GetAccessTokenError: If no access token is available.
"""
if self._injected_access_token is not None:
return self._injected_access_token
@ -82,7 +85,8 @@ class Authenticator:
raise GetAccessTokenError(
message=(
"No GitHub Copilot access token configured. "
"Use a named credential via the LiteLLM proxy or UI before making requests. "
"Run `litellm --login github_copilot` (SDK) or create a named credential "
"via the LiteLLM proxy UI. "
"See: https://docs.litellm.ai/docs/providers/github_copilot"
),
status_code=401,

View file

@ -64,10 +64,11 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig):
message="GitHub Copilot API key is required. Please authenticate via OAuth Device Flow.",
)
# api_key at this point is already the resolved copilot inference
# token (exchanged by the provider info step or main.py).
# Just use it directly — no re-authentication needed.
default_headers = get_copilot_default_headers(api_key)
# api_key is the GitHub access token (ghu_xxx). Exchange it for
# a short-lived Copilot inference token (cached at module level).
authenticator = Authenticator(access_token=api_key)
inference_token = authenticator.get_api_key()
default_headers = get_copilot_default_headers(inference_token)
# Merge with existing headers (user's extra_headers take priority)
merged_headers = {**default_headers, **headers}
@ -99,7 +100,8 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig):
"""
# Use provided api_base or fall back to credential-resolved base or default
if api_key:
api_base = Authenticator(access_token=api_key).get_api_base() or api_base or GITHUB_COPILOT_API_BASE
derived_api_base = Authenticator(access_token=api_key).get_api_base()
api_base = api_base or derived_api_base or GITHUB_COPILOT_API_BASE
else:
api_base = api_base or GITHUB_COPILOT_API_BASE

View file

@ -106,7 +106,9 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig):
if isinstance(litellm_params, dict):
_api_key = litellm_params.get("api_key")
else:
_api_key = getattr(litellm_params, "api_key", None) if litellm_params else None
_api_key = (
getattr(litellm_params, "api_key", None) if litellm_params else None
)
if not _api_key:
raise AuthenticationError(
model=model,
@ -114,10 +116,11 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig):
message="GitHub Copilot API key is required. Please authenticate via OAuth Device Flow.",
)
# api_key at this point is already the resolved copilot inference
# token (exchanged by _get_openai_compatible_provider_info or
# main.py). Just use it directly — no re-authentication needed.
default_headers = get_copilot_default_headers(_api_key)
# _api_key is the GitHub access token (ghu_xxx). Exchange it for
# a short-lived Copilot inference token (cached at module level).
authenticator = Authenticator(access_token=_api_key)
inference_token = authenticator.get_api_key()
default_headers = get_copilot_default_headers(inference_token)
# Merge with existing headers (user's extra_headers take priority)
merged_headers = {**default_headers, **headers}
@ -168,9 +171,15 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig):
added in the future by detecting account type.
"""
# Use provided api_base or fall back to credential-resolved base or default
_api_key = litellm_params.get("api_key") if isinstance(litellm_params, dict) else None
_api_key = (
litellm_params.get("api_key") if isinstance(litellm_params, dict) else None
)
if _api_key:
api_base = api_base or Authenticator(access_token=_api_key).get_api_base() or GITHUB_COPILOT_API_BASE
api_base = (
api_base
or Authenticator(access_token=_api_key).get_api_base()
or GITHUB_COPILOT_API_BASE
)
else:
api_base = api_base or GITHUB_COPILOT_API_BASE

View file

@ -10,8 +10,13 @@ from litellm.exceptions import AuthenticationError
from litellm.llms.github_copilot.embedding.transformation import GithubCopilotEmbeddingConfig
from litellm.llms.github_copilot.common_utils import GetAPIKeyError
def test_github_copilot_embedding_config_validate_environment():
@patch("litellm.llms.github_copilot.embedding.transformation.Authenticator")
def test_github_copilot_embedding_config_validate_environment(mock_authenticator_class):
"""Test the GitHub Copilot embedding configuration environment validation."""
mock_auth_instance = MagicMock()
mock_auth_instance.get_api_key.return_value = "copilot-inference-token"
mock_authenticator_class.return_value = mock_auth_instance
config = GithubCopilotEmbeddingConfig()
model = "github_copilot/text-embedding-3-small"
@ -24,8 +29,9 @@ def test_github_copilot_embedding_config_validate_environment():
api_key="gh-access-token",
)
# api_key is used directly — no re-exchange in validate_environment
assert validated_headers["Authorization"] == "Bearer gh-access-token"
# Access token is exchanged for inference token
mock_authenticator_class.assert_called_with(access_token="gh-access-token")
assert validated_headers["Authorization"] == "Bearer copilot-inference-token"
assert validated_headers["copilot-integration-id"] == "vscode-chat"
assert validated_headers["editor-version"] == "vscode/1.95.0"
assert "x-request-id" in validated_headers
@ -42,9 +48,6 @@ def test_github_copilot_embedding_config_validate_environment():
)
assert "required" in str(excinfo.value).lower()
# Auth failures now happen upstream (in _get_openai_compatible_provider_info
# or main.py), not in validate_environment. No re-exchange test needed.
@patch("litellm.llms.github_copilot.embedding.transformation.Authenticator")
def test_github_copilot_embedding_config_get_complete_url(mock_authenticator_class):
"""Test the GitHub Copilot embedding configuration URL generation."""

View file

@ -82,16 +82,22 @@ class TestGithubCopilotResponsesAPITransformation:
"Should handle trailing slash"
)
def test_validate_environment_default_headers(self):
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_validate_environment_default_headers(self, mock_authenticator_class):
"""Test that validate_environment generates correct default headers"""
mock_auth_instance = MagicMock()
mock_auth_instance.get_api_key.return_value = "copilot-inference-token"
mock_authenticator_class.return_value = mock_auth_instance
config = GithubCopilotResponsesAPIConfig()
headers = config.validate_environment(
headers={}, model="gpt-5.1-codex", litellm_params={"api_key": "gh-access-token"}
)
# api_key is used directly — no re-exchange in validate_environment
assert headers["Authorization"] == "Bearer gh-access-token"
# Access token is exchanged for inference token
mock_authenticator_class.assert_called_with(access_token="gh-access-token")
assert headers["Authorization"] == "Bearer copilot-inference-token"
assert headers["content-type"] == "application/json"
assert headers["copilot-integration-id"] == "vscode-chat"
assert headers["editor-version"] == "vscode/1.95.0"
@ -101,8 +107,13 @@ class TestGithubCopilotResponsesAPITransformation:
assert headers["x-github-api-version"] == "2025-04-01"
assert "x-request-id" in headers
def test_validate_environment_user_headers_override(self):
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_validate_environment_user_headers_override(self, mock_authenticator_class):
"""Test that user-provided headers override default headers"""
mock_auth_instance = MagicMock()
mock_auth_instance.get_api_key.return_value = "copilot-inference-token"
mock_authenticator_class.return_value = mock_auth_instance
config = GithubCopilotResponsesAPIConfig()
custom_headers = {
@ -118,8 +129,8 @@ class TestGithubCopilotResponsesAPITransformation:
assert headers["editor-version"] == "custom/2.0.0"
# Custom header should be preserved
assert headers["custom-header"] == "custom-value"
# api_key used directly
assert headers["Authorization"] == "Bearer gh-access-token"
# Access token exchanged for inference token
assert headers["Authorization"] == "Bearer copilot-inference-token"
def test_get_initiator_with_assistant_role(self):
"""Test _get_initiator returns 'agent' for assistant role"""