Skip to content

Commit ff36aeb

Browse files
committed
fix(github_copilot): honor clientside api_key; non-interactive mode
The github_copilot provider ignored a caller-supplied api_key: _get_openai_compatible_provider_info and validate_environment always took the key from the global file-backed Authenticator, so per-request (clientside) credentials injected by a proxy hook were silently replaced by the shared identity (or failed outright when the token dir was unseeded). api_base was already honored. - chat/transformation.py: a passed api_key wins in both _get_openai_compatible_provider_info and validate_environment; the authenticator is only consulted when no key is supplied. - embedding/transformation.py: same one-line precedence fix. - authenticator.py: new GITHUB_COPILOT_NON_INTERACTIVE env - when set, get_access_token fails fast instead of starting the interactive device-code login (verification prompt + minutes-long poll), which would otherwise run inside a server request path or at Router startup (Router._add_deployment resolves the provider key at registration time). Also convert GetAccessTokenError escaping _refresh_api_key into GetAPIKeyError so callers' error handling applies. - chat/transformation.py: in non-interactive mode an unseeded authenticator resolves to api_key=None instead of raising, so deployment registration at Router startup succeeds; per-request clientside credentials arrive later. - tests: passed-key precedence, non-interactive resolution, error conversion, plus a github_copilot flavor of the clientside-credential per-key scoping regression (JuliaHub BerriAI#22864) covering api_key+api_base injection, shared api_base isolation, base_model preservation and key rotation.
1 parent 416cade commit ff36aeb

7 files changed

Lines changed: 244 additions & 11 deletions

File tree

litellm/llms/github_copilot/authenticator.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
GetAPIKeyError,
1616
GetDeviceCodeError,
1717
RefreshAPIKeyError,
18+
github_copilot_non_interactive,
1819
)
1920

2021
# Constants (default values — overridable via environment variables at call time)
@@ -57,6 +58,15 @@ def get_access_token(self) -> str:
5758
except IOError:
5859
verbose_logger.warning("No existing access token found or error reading file")
5960

61+
if github_copilot_non_interactive():
62+
# server deployments must never block a request on the interactive
63+
# device-code login (verification-URL prompt + minutes-long poll)
64+
raise GetAccessTokenError(
65+
message="No GitHub Copilot access token available and interactive "
66+
"device-flow login is disabled (GITHUB_COPILOT_NON_INTERACTIVE is set)",
67+
status_code=401,
68+
)
69+
6070
for attempt in range(3):
6171
verbose_logger.debug(f"Access token acquisition attempt {attempt + 1}/3")
6272
try:
@@ -127,6 +137,14 @@ def get_api_key(self) -> str:
127137
message=f"Failed to refresh API key: {str(e)}",
128138
status_code=401,
129139
)
140+
except GetAccessTokenError as e:
141+
# _refresh_api_key calls get_access_token outside its own retry
142+
# try-block, so this would otherwise escape get_api_key entirely
143+
# and bypass callers' GetAPIKeyError handling
144+
raise GetAPIKeyError(
145+
message=f"Failed to get access token: {str(e)}",
146+
status_code=401,
147+
)
130148

131149
def get_api_base(self) -> Optional[str]:
132150
"""

litellm/llms/github_copilot/chat/transformation.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
DEFAULT_GITHUB_COPILOT_API_BASE,
1717
GetAPIKeyError,
1818
get_copilot_default_headers,
19+
github_copilot_non_interactive,
1920
)
2021

2122

@@ -42,14 +43,25 @@ def _get_openai_compatible_provider_info(
4243
or os.getenv("GITHUB_COPILOT_API_BASE")
4344
or DEFAULT_GITHUB_COPILOT_API_BASE
4445
)
45-
try:
46-
dynamic_api_key = self.authenticator.get_api_key()
47-
except GetAPIKeyError as e:
48-
raise AuthenticationError(
49-
model=model,
50-
llm_provider=custom_llm_provider,
51-
message=str(e),
52-
)
46+
if api_key is not None:
47+
# a caller-supplied (clientside / per-user) credential wins; the
48+
# global file-backed authenticator is only for the local-CLI case
49+
dynamic_api_key: str | None = api_key
50+
else:
51+
try:
52+
dynamic_api_key = self.authenticator.get_api_key()
53+
except GetAPIKeyError as e:
54+
if github_copilot_non_interactive():
55+
# credentials are injected per request; raising here would
56+
# break deployment registration at Router startup
57+
# (Router._add_deployment resolves via get_llm_provider)
58+
dynamic_api_key = None
59+
else:
60+
raise AuthenticationError(
61+
model=model,
62+
llm_provider=custom_llm_provider,
63+
message=str(e),
64+
)
5365
return dynamic_api_base, dynamic_api_key, custom_llm_provider
5466

5567
def _transform_messages(
@@ -95,7 +107,7 @@ def validate_environment(
95107

96108
# Add Copilot-specific headers (editor-version, user-agent, etc.)
97109
try:
98-
copilot_api_key = self.authenticator.get_api_key()
110+
copilot_api_key = api_key or self.authenticator.get_api_key()
99111
copilot_headers = get_copilot_default_headers(copilot_api_key)
100112
validated_headers = {**copilot_headers, **validated_headers}
101113
except GetAPIKeyError:

litellm/llms/github_copilot/common_utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,28 @@
22
Constants for Copilot integration
33
"""
44

5+
import os
56
from typing import Optional, Union
67
from uuid import uuid4
78

89
import httpx
910

1011
from litellm.llms.base_llm.chat.transformation import BaseLLMException
1112

13+
14+
def github_copilot_non_interactive() -> bool:
15+
"""
16+
True when the interactive device-code login must never run (server
17+
deployments where per-user credentials are injected per request as
18+
clientside api_key/api_base instead of coming from the token files).
19+
"""
20+
return os.getenv("GITHUB_COPILOT_NON_INTERACTIVE", "").strip().lower() in (
21+
"1",
22+
"true",
23+
"yes",
24+
)
25+
26+
1227
# Constants
1328
COPILOT_VERSION = "0.26.7"
1429
EDITOR_PLUGIN_VERSION = f"copilot-chat/{COPILOT_VERSION}"

litellm/llms/github_copilot/embedding/transformation.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ def validate_environment(
6060
Validate environment and set up headers for GitHub Copilot API.
6161
"""
6262
try:
63-
# Get GitHub Copilot API key via OAuth
64-
api_key = self.authenticator.get_api_key()
63+
# Get GitHub Copilot API key: a caller-supplied (clientside /
64+
# per-user) credential wins over the file-backed authenticator
65+
api_key = api_key or self.authenticator.get_api_key()
6566

6667
if not api_key:
6768
raise AuthenticationError(

tests/local_testing/test_router_utils.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,73 @@ def mk(key):
518518
assert id_a not in router.clientside_credential_last_used # side dict cleaned
519519

520520

521+
def test_router_clientside_credential_scoping_github_copilot(monkeypatch):
522+
"""
523+
github_copilot flavor of the per-key scoping regression (JuliaHub #22864):
524+
per-user Copilot keys are injected as clientside api_key AND api_base, the
525+
config deployment carries no api_key at all, and two users can share the
526+
same api_base (same Copilot plan) while holding different keys.
527+
"""
528+
# Router._add_deployment resolves the provider key at registration time;
529+
# without this, an unseeded authenticator would start the interactive
530+
# device-code login (and then fail Router init).
531+
monkeypatch.setenv("GITHUB_COPILOT_NON_INTERACTIVE", "1")
532+
base = {
533+
"model_name": "claude-sonnet-4.6",
534+
"litellm_params": {
535+
"model": "github_copilot/claude-sonnet-4.6",
536+
# no api_key: per-user credentials are injected per request
537+
},
538+
"model_info": {"id": "copilot-base-1", "base_model": "claude-sonnet-4-6"},
539+
}
540+
router = Router(model_list=[base])
541+
shared_base = "https://api.individual.githubcopilot.com"
542+
543+
def mk(key):
544+
return router._handle_clientside_credential(
545+
deployment=base,
546+
kwargs={
547+
"api_key": key,
548+
"api_base": shared_base,
549+
"metadata": {"model_group": "claude-sonnet-4.6"},
550+
},
551+
function_name="acompletion",
552+
)
553+
554+
dep_a, dep_b = mk("COPILOT_KEY_A"), mk("COPILOT_KEY_B")
555+
assert len(router.get_model_list()) == 3
556+
557+
# synthetic deployments keep the config model_info (base_model pricing)
558+
# alongside the clientside_credential marker
559+
assert dep_a.model_info.base_model == "claude-sonnet-4-6"
560+
assert dep_a.model_info.clientside_credential is True
561+
562+
def candidate_keys(request_kwargs):
563+
_, healthy = router._common_checks_available_deployment(
564+
model="claude-sonnet-4.6", request_kwargs=request_kwargs
565+
)
566+
return {
567+
d["litellm_params"].get("api_key") for d in healthy
568+
} # config deployment contributes None
569+
570+
# no-key request (e.g. token counting / health check): config only
571+
assert candidate_keys({}) == {None}
572+
# same api_base, different keys: each user sees only their own synthetic
573+
assert candidate_keys({"api_key": "COPILOT_KEY_A", "api_base": shared_base}) == {
574+
None,
575+
"COPILOT_KEY_A",
576+
}
577+
assert candidate_keys({"api_key": "COPILOT_KEY_B", "api_base": shared_base}) == {
578+
None,
579+
"COPILOT_KEY_B",
580+
}
581+
# key rotation (same user, new minted key): old synthetic no longer visible
582+
mk("COPILOT_KEY_A_ROTATED")
583+
assert candidate_keys(
584+
{"api_key": "COPILOT_KEY_A_ROTATED", "api_base": shared_base}
585+
) == {None, "COPILOT_KEY_A_ROTATED"}
586+
587+
521588
def test_router_get_async_openai_model_client():
522589
router = Router(
523590
model_list=[

tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,32 @@ def test_get_access_token_failure(self, authenticator):
103103
authenticator.get_access_token()
104104
assert authenticator._login.call_count == 3
105105

106+
def test_get_access_token_non_interactive(self, authenticator, monkeypatch):
107+
"""Non-interactive mode must fail fast instead of starting the
108+
interactive device-code login when no token file exists."""
109+
monkeypatch.setenv("GITHUB_COPILOT_NON_INTERACTIVE", "1")
110+
with (
111+
patch.object(authenticator, "_login") as mock_login,
112+
patch("builtins.open", side_effect=IOError),
113+
):
114+
with pytest.raises(GetAccessTokenError):
115+
authenticator.get_access_token()
116+
mock_login.assert_not_called()
117+
118+
def test_get_api_key_wraps_access_token_error(self, authenticator):
119+
"""A GetAccessTokenError escaping _refresh_api_key must surface as
120+
GetAPIKeyError so callers' error handling applies."""
121+
with (
122+
patch.object(
123+
authenticator,
124+
"_refresh_api_key",
125+
side_effect=GetAccessTokenError(message="no token", status_code=401),
126+
),
127+
patch("builtins.open", side_effect=IOError),
128+
):
129+
with pytest.raises(GetAPIKeyError):
130+
authenticator.get_api_key()
131+
106132
def test_get_api_key_from_file(self, authenticator):
107133
"""Test retrieving an API key from a file."""
108134
future_time = (datetime.now() + timedelta(hours=1)).timestamp()

tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,100 @@ def test_github_copilot_config_get_openai_compatible_provider_info():
9393
assert "Failed to get API key" in str(excinfo.value)
9494

9595

96+
def test_github_copilot_provider_info_honors_clientside_api_key():
97+
"""A caller-supplied api_key must win over the global authenticator
98+
(per-user Copilot keys are injected as clientside credentials)."""
99+
config = GithubCopilotConfig()
100+
config.authenticator = MagicMock()
101+
config.authenticator.get_api_base.return_value = None
102+
103+
(
104+
api_base,
105+
dynamic_api_key,
106+
custom_llm_provider,
107+
) = config._get_openai_compatible_provider_info(
108+
model="github_copilot/gpt-4",
109+
api_base="https://api.individual.githubcopilot.com",
110+
api_key="user-copilot-key",
111+
custom_llm_provider="github_copilot",
112+
)
113+
114+
assert dynamic_api_key == "user-copilot-key"
115+
assert api_base == "https://api.individual.githubcopilot.com"
116+
config.authenticator.get_api_key.assert_not_called()
117+
118+
# even with an unseeded authenticator (raises), a passed api_key must work
119+
config.authenticator.get_api_key.side_effect = GetAPIKeyError(
120+
message="unseeded",
121+
status_code=401,
122+
)
123+
(
124+
_,
125+
dynamic_api_key,
126+
_,
127+
) = config._get_openai_compatible_provider_info(
128+
model="github_copilot/gpt-4",
129+
api_base=None,
130+
api_key="user-copilot-key",
131+
custom_llm_provider="github_copilot",
132+
)
133+
assert dynamic_api_key == "user-copilot-key"
134+
135+
136+
def test_github_copilot_provider_info_non_interactive_returns_none(monkeypatch):
137+
"""In non-interactive mode an unseeded authenticator must resolve to a
138+
None api_key (deployment registration at Router startup must not fail;
139+
per-request clientside credentials arrive later)."""
140+
monkeypatch.setenv("GITHUB_COPILOT_NON_INTERACTIVE", "1")
141+
config = GithubCopilotConfig()
142+
config.authenticator = MagicMock()
143+
config.authenticator.get_api_base.return_value = None
144+
config.authenticator.get_api_key.side_effect = GetAPIKeyError(
145+
message="unseeded",
146+
status_code=401,
147+
)
148+
149+
(
150+
api_base,
151+
dynamic_api_key,
152+
custom_llm_provider,
153+
) = config._get_openai_compatible_provider_info(
154+
model="github_copilot/gpt-4",
155+
api_base=None,
156+
api_key=None,
157+
custom_llm_provider="github_copilot",
158+
)
159+
160+
assert dynamic_api_key is None
161+
assert api_base == "https://api.githubcopilot.com"
162+
163+
164+
def test_github_copilot_validate_environment_uses_clientside_api_key():
165+
"""validate_environment must build the Copilot default headers from a
166+
passed api_key without consulting the (possibly unseeded) authenticator."""
167+
config = GithubCopilotConfig()
168+
config.authenticator = MagicMock()
169+
config.authenticator.get_api_key.side_effect = GetAPIKeyError(
170+
message="unseeded",
171+
status_code=401,
172+
)
173+
174+
headers = config.validate_environment(
175+
headers={},
176+
model="github_copilot/gpt-4",
177+
messages=[{"role": "user", "content": "hi"}],
178+
optional_params={},
179+
litellm_params={},
180+
api_key="user-copilot-key",
181+
api_base="https://api.individual.githubcopilot.com",
182+
)
183+
184+
assert headers["Authorization"] == "Bearer user-copilot-key"
185+
assert headers["copilot-integration-id"] == "vscode-chat"
186+
assert "editor-version" in headers
187+
config.authenticator.get_api_key.assert_not_called()
188+
189+
96190
@patch("litellm.llms.github_copilot.authenticator.Authenticator.get_api_key")
97191
@patch("litellm.main.openai_chat_completions.completion")
98192
@patch("litellm.llms.openai.openai.OpenAIChatCompletion.completion")

0 commit comments

Comments
 (0)