diff --git a/litellm/llms/chatgpt/chat/transformation.py b/litellm/llms/chatgpt/chat/transformation.py index 472ab3dea45..7fb6d8a000f 100644 --- a/litellm/llms/chatgpt/chat/transformation.py +++ b/litellm/llms/chatgpt/chat/transformation.py @@ -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, diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 4aff1025056..a899201603c 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -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, diff --git a/tests/test_litellm/llms/chatgpt/chat/test_chatgpt_oauth_credential_dispatch.py b/tests/test_litellm/llms/chatgpt/chat/test_chatgpt_oauth_credential_dispatch.py index 499d5270a0d..f8c6c6b2cba 100644 --- a/tests/test_litellm/llms/chatgpt/chat/test_chatgpt_oauth_credential_dispatch.py +++ b/tests/test_litellm/llms/chatgpt/chat/test_chatgpt_oauth_credential_dispatch.py @@ -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): diff --git a/tests/test_litellm/llms/github_copilot/test_copilot_oauth_dispatch.py b/tests/test_litellm/llms/github_copilot/test_copilot_oauth_dispatch.py index aadb023df4a..636467c0b37 100644 --- a/tests/test_litellm/llms/github_copilot/test_copilot_oauth_dispatch.py +++ b/tests/test_litellm/llms/github_copilot/test_copilot_oauth_dispatch.py @@ -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: diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py index a1b6ff7c509..26fae512d99 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py @@ -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"