fix(oauth): don't eager-resolve tokens during add_deployment cycles

``get_llm_provider_logic.py`` instantiates ``ChatGPTConfig`` /
``GithubCopilotConfig`` and calls ``_get_openai_compatible_provider_info``
at every ``add_deployment`` cycle during proxy startup (30s tick when
``STORE_MODEL_IN_DB=True``). The old code eagerly called
``get_access_token()`` / ``get_api_key()`` through the filesystem
authenticator at that point. With no tokens on disk this falls through
to ``_login_device_code()``, which prints a device prompt to stdout and
polls the IdP for up to 15 minutes — blocking startup and (for OAuth
credentials stored in the DB) duplicating work that ``validate_environment``
is about to do correctly at request time.

Resolution is now a pure metadata pass: pick an ``api_base``, let the
``oauth:<name>`` marker pass through untouched. Actual token resolution
still happens at request time via ``resolve_authenticator`` inside
``validate_environment``, which is where we have the full
``litellm_params`` anyway.

Tests updated to assert ``get_access_token`` / ``get_api_key`` are NOT
called during resolution, and that the ``oauth:`` marker passes through.
This commit is contained in:
Jason Cook 2026-04-23 14:58:20 -04:00
parent 2d70e7ba82
commit f2653df86b
5 changed files with 174 additions and 163 deletions

View file

@ -1,12 +1,10 @@
from typing import Any, List, Optional, Tuple
from litellm.exceptions import AuthenticationError
from litellm.llms.openai.openai import OpenAIConfig
from litellm.types.llms.openai import AllMessageValues
from ..authenticator import Authenticator
from ..common_utils import (
GetAccessTokenError,
ensure_chatgpt_session_id,
get_chatgpt_default_headers,
)
@ -31,17 +29,20 @@ class ChatGPTConfig(OpenAIConfig):
api_key: Optional[str],
custom_llm_provider: str,
) -> Tuple[Optional[str], Optional[str], str]:
# NOTE: we deliberately do NOT call ``get_access_token()`` here.
# ``get_llm_provider`` is the resolution stage and runs at proxy
# startup for every deployment (via ``add_deployment`` cycles).
# Calling the filesystem ``Authenticator.get_access_token()`` with
# no tokens on disk triggers ``_login_device_code()``, which
# prints a device code to stdout and polls OpenAI for up to 15
# minutes — blocking proxy startup and spamming the logs every
# 30s once the background scheduler kicks in. Actual auth
# resolution happens in ``validate_environment`` at request time,
# which does the right thing via ``resolve_authenticator`` and
# the DB-backed cache.
authenticator = resolve_authenticator(api_key, None, self.authenticator)
dynamic_api_base = authenticator.get_api_base()
try:
dynamic_api_key = authenticator.get_access_token()
except GetAccessTokenError as e:
raise AuthenticationError(
model=model,
llm_provider=custom_llm_provider,
message=str(e),
)
return dynamic_api_base, dynamic_api_key, custom_llm_provider
dynamic_api_base = api_base or authenticator.get_api_base()
return dynamic_api_base, api_key, custom_llm_provider
def validate_environment(
self,

View file

@ -1,7 +1,6 @@
from typing import List, Optional, Tuple
from litellm.exceptions import AuthenticationError
from litellm.llms.openai.openai import OpenAIConfig
from litellm.types.llms.openai import AllMessageValues
@ -31,17 +30,17 @@ class GithubCopilotConfig(OpenAIConfig):
api_key: Optional[str],
custom_llm_provider: str,
) -> Tuple[Optional[str], Optional[str], str]:
# Do NOT call ``get_api_key()`` here — see the ChatGPT chat
# transformation for the full rationale. At startup-time proxy
# resolution, triggering the filesystem authenticator with no
# tokens would fire the GitHub device-code flow and block. Actual
# auth resolution happens in ``validate_environment`` at request
# time via ``resolve_authenticator``.
authenticator = resolve_authenticator(api_key, None, self.authenticator)
dynamic_api_base = authenticator.get_api_base() or GITHUB_COPILOT_API_BASE
try:
dynamic_api_key = authenticator.get_api_key()
except GetAPIKeyError as e:
raise AuthenticationError(
model=model,
llm_provider=custom_llm_provider,
message=str(e),
)
return dynamic_api_base, dynamic_api_key, custom_llm_provider
dynamic_api_base = (
api_base or authenticator.get_api_base() or GITHUB_COPILOT_API_BASE
)
return dynamic_api_base, api_key, custom_llm_provider
def _transform_messages(
self,

View file

@ -18,25 +18,31 @@ from litellm.llms.chatgpt.db_authenticator import (
class TestChatTransformationDispatch:
def test_get_openai_compatible_provider_info_uses_db_authenticator(self):
def test_get_openai_compatible_provider_info_does_not_eagerly_fetch_token(self):
"""
At resolution time (startup ``add_deployment`` cycles, every 30s),
the chat transformation's ``_get_openai_compatible_provider_info``
must NOT call ``get_access_token``. The filesystem Authenticator
otherwise triggers ``_login_device_code`` with no tokens on disk,
which prints a device prompt and polls for 15 minutes blocking
proxy startup. Actual token resolution happens at request time in
``validate_environment``.
"""
config = ChatGPTConfig()
fs_auth = MagicMock(spec=Authenticator)
fs_auth.get_access_token.side_effect = AssertionError(
"Filesystem authenticator must not be called for oauth: prefix"
)
fs_auth.get_api_base.side_effect = AssertionError(
"Filesystem authenticator must not be called for oauth: prefix"
"get_access_token must not be called at resolution time"
)
fs_auth.get_api_base.return_value = "https://chatgpt.com/backend-api/codex"
config.authenticator = fs_auth
with (
patch.object(
DBAuthenticator,
"get_api_base",
return_value="https://chatgpt.com/backend-api/codex",
),
patch.object(
DBAuthenticator, "get_access_token", return_value="db-access-token"
# Even the DB-backed authenticator's get_access_token must not
# fire here — resolution is a pure metadata pass.
with patch.object(
DBAuthenticator,
"get_access_token",
side_effect=AssertionError(
"get_access_token must not be called at resolution time"
),
):
base, key, _ = config._get_openai_compatible_provider_info(
@ -45,7 +51,9 @@ class TestChatTransformationDispatch:
api_key=f"{OAUTH_CREDENTIAL_API_KEY_PREFIX}my-creds",
custom_llm_provider="chatgpt",
)
assert key == "db-access-token"
# The raw oauth: marker passes through; validate_environment
# resolves it at request time.
assert key == f"{OAUTH_CREDENTIAL_API_KEY_PREFIX}my-creds"
assert base == "https://chatgpt.com/backend-api/codex"
def test_validate_environment_uses_db_authenticator(self):

View file

@ -22,22 +22,28 @@ def _reset_cache():
class TestChatTransformationDispatch:
def test_oauth_prefix_api_key_uses_db_authenticator(self):
def test_get_openai_compatible_provider_info_does_not_eagerly_fetch_api_key(self):
"""
Symmetric to the ChatGPT fix: the resolution stage must not call
``get_api_key`` on either authenticator. That would otherwise hit
GitHub's ``/copilot_internal/v2/token`` (or trigger a device-code
login if no access token is stored) at every
``add_deployment`` cycle during proxy startup.
"""
config = GithubCopilotConfig()
fs_auth = MagicMock(spec=Authenticator)
fs_auth.get_api_base.side_effect = AssertionError(
"Filesystem authenticator must not be used for oauth: prefix"
)
fs_auth.get_api_key.side_effect = AssertionError(
"Filesystem authenticator must not be used for oauth: prefix"
"get_api_key must not be called at resolution time"
)
fs_auth.get_api_base.return_value = None
config.authenticator = fs_auth
with (
patch.object(
DBAuthenticator, "get_api_base", return_value="https://x.example"
with patch.object(
DBAuthenticator,
"get_api_key",
side_effect=AssertionError(
"get_api_key must not be called at resolution time"
),
patch.object(DBAuthenticator, "get_api_key", return_value="cop-key"),
):
base, key, _ = config._get_openai_compatible_provider_info(
model="gpt-5",
@ -45,22 +51,9 @@ class TestChatTransformationDispatch:
api_key=f"{OAUTH_CREDENTIAL_API_KEY_PREFIX}my-creds",
custom_llm_provider="github_copilot",
)
assert key == "cop-key"
assert base == "https://x.example"
def test_plain_api_key_uses_filesystem_authenticator(self):
config = GithubCopilotConfig()
config.authenticator = MagicMock(spec=Authenticator)
config.authenticator.get_api_base.return_value = None
config.authenticator.get_api_key.return_value = "fs-key"
_, key, _ = config._get_openai_compatible_provider_info(
model="gpt-5",
api_base=None,
api_key="sk-plain",
custom_llm_provider="github_copilot",
)
assert key == "fs-key"
# Passthrough — validate_environment resolves at request time.
assert key == f"{OAUTH_CREDENTIAL_API_KEY_PREFIX}my-creds"
assert base is not None # Falls back to GITHUB_COPILOT_API_BASE
class TestResponsesTransformationDispatch:

View file

@ -19,31 +19,38 @@ import litellm
# Import at the top to make the patch work correctly
import litellm.llms.github_copilot.chat.transformation
from litellm import Choices, Message, ModelResponse, Usage, acompletion, completion
from litellm.exceptions import AuthenticationError
from litellm.llms.github_copilot.authenticator import Authenticator
from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig
from litellm.llms.github_copilot.common_utils import (
APIKeyExpiredError,
GetAccessTokenError,
GetAPIKeyError,
GetDeviceCodeError,
RefreshAPIKeyError,
)
def test_github_copilot_config_get_openai_compatible_provider_info():
"""Test the GitHub Copilot configuration provider info retrieval."""
"""
Test the GitHub Copilot configuration provider info retrieval.
``_get_openai_compatible_provider_info`` runs at proxy startup (every
``add_deployment`` cycle). Calling ``get_api_key()`` eagerly here would
hit GitHub's token endpoint — or worse, kick off the CLI device-code
login with no tokens on disk which blocks startup. Actual API-key
resolution happens at request time in ``validate_environment``.
"""
config = GithubCopilotConfig()
# Mock the authenticator to avoid actual API calls
mock_api_key = "gh.test-key-123456789"
# The authenticator should NOT be asked for an API key at this stage.
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = mock_api_key
# Test with dynamic endpoint
config.authenticator.get_api_base.return_value = "https://api.enterprise.githubcopilot.com"
config.authenticator.get_api_key.side_effect = AssertionError(
"get_api_key must not be called at resolution time"
)
config.authenticator.get_api_base.return_value = (
"https://api.enterprise.githubcopilot.com"
)
# Test with default values
model = "github_copilot/gpt-4"
(
api_base,
@ -56,11 +63,13 @@ def test_github_copilot_config_get_openai_compatible_provider_info():
custom_llm_provider="github_copilot",
)
# Uses the authenticator's configured api_base, passes api_key through.
assert api_base == "https://api.enterprise.githubcopilot.com"
assert dynamic_api_key == mock_api_key
assert dynamic_api_key is None
assert custom_llm_provider == "github_copilot"
# Test fallback to default if no dynamic endpoint
# Falls back to the default GitHub Copilot base when the authenticator
# doesn't supply one.
config.authenticator.get_api_base.return_value = None
(
api_base,
@ -73,22 +82,7 @@ def test_github_copilot_config_get_openai_compatible_provider_info():
custom_llm_provider="github_copilot",
)
assert api_base == "https://api.githubcopilot.com"
# Test with authentication failure
config.authenticator.get_api_key.side_effect = GetAPIKeyError(
message="Failed to get API key",
status_code=401,
)
with pytest.raises(AuthenticationError) as excinfo:
config._get_openai_compatible_provider_info(
model=model,
api_base=None,
api_key=None,
custom_llm_provider="github_copilot",
)
assert "Failed to get API key" in str(excinfo.value)
assert dynamic_api_key is None
@patch("litellm.llms.github_copilot.authenticator.Authenticator.get_api_key")
@ -157,19 +151,25 @@ def test_transform_messages_disable_copilot_system_to_assistant(monkeypatch):
{"role": "system", "content": "System message."},
{"role": "user", "content": "User message."},
]
out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4")
out = config._transform_messages(
[m.copy() for m in messages], model="github_copilot/gpt-4"
)
assert out[0]["role"] == "assistant"
assert out[1]["role"] == "user"
# Case 2: Flag is True (conversion does not happen)
litellm.disable_copilot_system_to_assistant = True
out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4")
out = config._transform_messages(
[m.copy() for m in messages], model="github_copilot/gpt-4"
)
assert out[0]["role"] == "system"
assert out[1]["role"] == "user"
# Case 3: Flag is False again (conversion happens)
litellm.disable_copilot_system_to_assistant = False
out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4")
out = config._transform_messages(
[m.copy() for m in messages], model="github_copilot/gpt-4"
)
assert out[0]["role"] == "assistant"
assert out[1]["role"] == "user"
finally:
@ -180,7 +180,7 @@ def test_transform_messages_disable_copilot_system_to_assistant(monkeypatch):
def test_x_initiator_header_user_request():
"""Test that user-only messages result in X-Initiator: user header"""
config = GithubCopilotConfig()
# Mock the authenticator
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = "gh.test-key-123"
@ -190,7 +190,7 @@ def test_x_initiator_header_user_request():
{"role": "system", "content": "You are an assistant."},
{"role": "user", "content": "Hello!"},
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
@ -200,14 +200,14 @@ def test_x_initiator_header_user_request():
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "user"
def test_x_initiator_header_agent_request_with_assistant():
"""Test that messages with assistant role result in X-Initiator: agent header"""
config = GithubCopilotConfig()
# Mock the authenticator
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = "gh.test-key-123"
@ -217,24 +217,24 @@ def test_x_initiator_header_agent_request_with_assistant():
{"role": "system", "content": "You are an assistant."},
{"role": "assistant", "content": "I can help you."},
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
model="github_copilot/gpt-4",
messages=messages,
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "agent"
def test_x_initiator_header_agent_request_with_tool():
"""Test that messages with tool role result in X-Initiator: agent header"""
config = GithubCopilotConfig()
# Mock the authenticator
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = "gh.test-key-123"
@ -244,25 +244,25 @@ def test_x_initiator_header_agent_request_with_tool():
{"role": "system", "content": "You are an assistant."},
{"role": "tool", "content": "Tool response.", "tool_call_id": "123"},
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
model="github_copilot/gpt-4",
messages=messages,
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "agent"
def test_x_initiator_header_mixed_messages_with_agent_roles():
"""Test that mixed messages with agent roles (assistant/tool) result in X-Initiator: agent header"""
config = GithubCopilotConfig()
# Mock the authenticator
# 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
@ -272,25 +272,25 @@ def test_x_initiator_header_mixed_messages_with_agent_roles():
{"role": "assistant", "content": "Previous response."},
{"role": "user", "content": "Follow up question."},
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
messages=messages,
messages=messages,
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "agent"
def test_x_initiator_header_user_only_messages():
"""Test that user + system only messages result in X-Initiator: user header"""
config = GithubCopilotConfig()
# Mock the authenticator
# 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
@ -300,31 +300,7 @@ def test_x_initiator_header_user_only_messages():
{"role": "user", "content": "Hello"},
{"role": "user", "content": "Follow up question."},
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
messages=messages,
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "user"
def test_x_initiator_header_empty_messages():
"""Test that empty messages result in X-Initiator: user header"""
config = GithubCopilotConfig()
# 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
messages = []
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
@ -334,14 +310,38 @@ def test_x_initiator_header_empty_messages():
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "user"
def test_x_initiator_header_empty_messages():
"""Test that empty messages result in X-Initiator: user header"""
config = GithubCopilotConfig()
# 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
messages = []
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
messages=messages,
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "user"
def test_x_initiator_header_system_only_messages():
"""Test that system-only messages result in X-Initiator: user header"""
config = GithubCopilotConfig()
# Mock the authenticator
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = "gh.test-key-123"
@ -350,7 +350,7 @@ def test_x_initiator_header_system_only_messages():
messages = [
{"role": "system", "content": "You are an assistant."},
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
@ -360,35 +360,37 @@ def test_x_initiator_header_system_only_messages():
api_key=None,
api_base=None,
)
assert headers["X-Initiator"] == "user"
def test_get_supported_openai_params_claude_model():
"""Test that Claude models with extended thinking support have thinking and reasoning parameters."""
config = GithubCopilotConfig()
# Test Claude 4 model supports thinking and reasoning_effort parameters
supported_params = config.get_supported_openai_params("claude-sonnet-4-20250514")
assert "thinking" in supported_params
assert "reasoning_effort" in supported_params
# Test Claude 3-7 model supports thinking and reasoning_effort parameters
supported_params_claude37 = config.get_supported_openai_params("claude-3-7-sonnet-20250219")
supported_params_claude37 = config.get_supported_openai_params(
"claude-3-7-sonnet-20250219"
)
assert "thinking" in supported_params_claude37
assert "reasoning_effort" in supported_params_claude37
# Test Claude 3.5 model does NOT support thinking parameters (no extended thinking)
supported_params_claude35 = config.get_supported_openai_params("claude-3.5-sonnet")
assert "thinking" not in supported_params_claude35
assert "reasoning_effort" not in supported_params_claude35
# Test non-Claude model doesn't include thinking parameters but may include reasoning_effort
supported_params_gpt = config.get_supported_openai_params("gpt-4o")
assert "thinking" not in supported_params_gpt
# gpt-4o should NOT have reasoning_effort (not a reasoning model)
assert "reasoning_effort" not in supported_params_gpt
# Test O-series reasoning models include reasoning_effort but not thinking
supported_params_o3 = config.get_supported_openai_params("o3-mini")
assert "thinking" not in supported_params_o3
@ -399,26 +401,31 @@ def test_get_supported_openai_params_claude_model():
def test_get_supported_openai_params_case_insensitive():
"""Test that Claude model detection is case-insensitive for models with extended thinking."""
config = GithubCopilotConfig()
# Test uppercase Claude 4 model with full model name
supported_params_upper = config.get_supported_openai_params("CLAUDE-SONNET-4-20250514")
supported_params_upper = config.get_supported_openai_params(
"CLAUDE-SONNET-4-20250514"
)
assert "thinking" in supported_params_upper
assert "reasoning_effort" in supported_params_upper
# Test mixed case Claude 3-7 model (has extended thinking) with full model name
supported_params_mixed = config.get_supported_openai_params("Claude-3-7-Sonnet-20250219")
supported_params_mixed = config.get_supported_openai_params(
"Claude-3-7-Sonnet-20250219"
)
assert "thinking" in supported_params_mixed
assert "reasoning_effort" in supported_params_mixed
# Test that Claude 3.5 models don't have thinking support (case insensitive)
supported_params_35 = config.get_supported_openai_params("CLAUDE-3.5-SONNET")
assert "thinking" not in supported_params_35
assert "reasoning_effort" not in supported_params_35
def test_copilot_vision_request_header_with_image():
"""Test that Copilot-Vision-Request header is added when messages contain images"""
config = GithubCopilotConfig()
# Mock the authenticator
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = "gh.test-key-123"
@ -431,12 +438,12 @@ def test_copilot_vision_request_header_with_image():
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abc123"}
}
]
"image_url": {"url": "data:image/jpeg;base64,abc123"},
},
],
}
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4-vision-preview",
@ -446,7 +453,7 @@ def test_copilot_vision_request_header_with_image():
api_key=None,
api_base=None,
)
assert headers["Copilot-Vision-Request"] == "true"
assert headers["X-Initiator"] == "user"
@ -454,7 +461,7 @@ def test_copilot_vision_request_header_with_image():
def test_copilot_vision_request_header_text_only():
"""Test that Copilot-Vision-Request header is not added for text-only messages"""
config = GithubCopilotConfig()
# Mock the authenticator
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = "gh.test-key-123"
@ -463,7 +470,7 @@ def test_copilot_vision_request_header_text_only():
messages = [
{"role": "user", "content": "Just a text message"},
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4",
@ -473,7 +480,7 @@ def test_copilot_vision_request_header_text_only():
api_key=None,
api_base=None,
)
assert "Copilot-Vision-Request" not in headers
assert headers["X-Initiator"] == "user"
@ -481,7 +488,7 @@ def test_copilot_vision_request_header_text_only():
def test_copilot_vision_request_header_with_type_image_url():
"""Test that Copilot-Vision-Request header is added for content with type: image_url"""
config = GithubCopilotConfig()
# Mock the authenticator
config.authenticator = MagicMock()
config.authenticator.get_api_key.return_value = "gh.test-key-123"
@ -492,11 +499,14 @@ def test_copilot_vision_request_header_with_type_image_url():
"role": "user",
"content": [
{"type": "text", "text": "Analyze this image"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}
]
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.jpg"},
},
],
}
]
headers = config.validate_environment(
headers={},
model="github_copilot/gpt-4-vision-preview",
@ -506,6 +516,6 @@ def test_copilot_vision_request_header_with_type_image_url():
api_key=None,
api_base=None,
)
assert headers["Copilot-Vision-Request"] == "true"
assert headers["X-Initiator"] == "user"