diff --git a/litellm/litellm_core_utils/asyncify.py b/litellm/litellm_core_utils/asyncify.py index b58e707b8f8..bd21974029e 100644 --- a/litellm/litellm_core_utils/asyncify.py +++ b/litellm/litellm_core_utils/asyncify.py @@ -68,6 +68,14 @@ def asyncify( return wrapper +def is_event_loop_running() -> bool: + try: + _ = asyncio.get_running_loop() + except RuntimeError: + return False + return True + + def run_async_function(async_function, *args, **kwargs): """ Helper utility to run an async function in a sync context. diff --git a/litellm/llms/chatgpt/authenticator.py b/litellm/llms/chatgpt/authenticator.py index 563826c2b93..1b5850d8d42 100644 --- a/litellm/llms/chatgpt/authenticator.py +++ b/litellm/llms/chatgpt/authenticator.py @@ -9,6 +9,7 @@ import httpx from pydantic import JsonValue, TypeAdapter, ValidationError from litellm._logging import verbose_logger +from litellm.litellm_core_utils.asyncify import is_event_loop_running from litellm.llms.custom_httpx.http_handler import _get_httpx_client from .common_utils import ( @@ -66,6 +67,18 @@ class Authenticator: except RefreshAccessTokenError as exc: verbose_logger.warning("ChatGPT refresh token failed, re-login required: %s", exc) + if is_event_loop_running(): + raise GetAccessTokenError( + message=( + "ChatGPT device-code login needs a human and cannot run inside a running event loop " + "(for example the LiteLLM proxy). Log in once outside the proxy with " + '`python -c "from litellm.llms.chatgpt.authenticator import Authenticator; ' + 'Authenticator().get_access_token()"` and mount the resulting ' + f"{self.auth_file} into the proxy, or set CHATGPT_TOKEN_DIR to a directory that already holds it." + ), + status_code=401, + ) + cooldown_remaining: Final = self._get_device_code_cooldown_remaining(auth_data) if cooldown_remaining > 0: token: Final = self._wait_for_access_token(cooldown_remaining) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 80fd4f755e7..269eaaa88b6 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -7,6 +7,7 @@ from typing import Any, Final import httpx from litellm._logging import verbose_logger +from litellm.litellm_core_utils.asyncify import is_event_loop_running from litellm.llms.custom_httpx.http_handler import _get_httpx_client from .common_utils import ( @@ -57,6 +58,19 @@ class Authenticator: except OSError: verbose_logger.warning("No existing access token found or error reading file") + if is_event_loop_running(): + raise GetAccessTokenError( + message=( + "GitHub Copilot device-code login needs a human and cannot run inside a running event loop " + "(for example the LiteLLM proxy). Log in once outside the proxy with " + '`python -c "from litellm.llms.github_copilot.authenticator import Authenticator; ' + 'Authenticator().get_access_token()"` and mount the resulting ' + f"{self.access_token_file} into the proxy, or set GITHUB_COPILOT_TOKEN_DIR to a directory " + "that already holds it." + ), + status_code=401, + ) + for attempt in range(3): verbose_logger.debug("Access token acquisition attempt %s/3", attempt + 1) try: diff --git a/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py b/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py index a9ced2afcf9..be54d33d18d 100644 --- a/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py +++ b/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py @@ -6,6 +6,7 @@ from unittest.mock import mock_open, patch import pytest from litellm.llms.chatgpt.authenticator import Authenticator +from litellm.llms.chatgpt.common_utils import GetAccessTokenError def _make_jwt(payload: dict) -> str: @@ -54,6 +55,47 @@ class TestChatGPTAuthenticator: token = authenticator.get_access_token() assert token == "token-new" + @pytest.mark.asyncio + async def test_get_access_token_refuses_device_code_login_in_event_loop(self, authenticator): + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(authenticator, "_login_device_code") as mock_login, + patch.object(authenticator, "_wait_for_access_token") as mock_wait, + ): + with pytest.raises(GetAccessTokenError) as exc: + authenticator.get_access_token() + + assert exc.value.status_code == 401 + assert "event loop" in str(exc.value) + mock_login.assert_not_called() + mock_wait.assert_not_called() + + @pytest.mark.asyncio + async def test_get_access_token_refuses_cooldown_wait_in_event_loop(self, authenticator): + auth_data = json.dumps({"device_code_requested_at": time.time()}) + + with ( + patch("builtins.open", mock_open(read_data=auth_data)), + patch.object(authenticator, "_login_device_code") as mock_login, + patch.object(authenticator, "_wait_for_access_token") as mock_wait, + ): + with pytest.raises(GetAccessTokenError) as exc: + authenticator.get_access_token() + + assert exc.value.status_code == 401 + assert "event loop" in str(exc.value) + mock_login.assert_not_called() + mock_wait.assert_not_called() + + def test_get_access_token_device_code_login_without_event_loop(self, authenticator): + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(authenticator, "_login_device_code", return_value={"access_token": "tok"}), + ): + token = authenticator.get_access_token() + + assert token == "tok" + def test_get_account_id_from_id_token(self, authenticator): id_token = _make_jwt( {"https://api.openai.com/auth": {"chatgpt_account_id": "acct-123"}} diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py index 6c846a90c71..3fd0487b3ac 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py @@ -89,6 +89,19 @@ class TestGitHubCopilotAuthenticator: assert token == mock_token authenticator._login.assert_called_once() + @pytest.mark.asyncio + async def test_get_access_token_refuses_device_code_login_in_event_loop(self, authenticator): + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(authenticator, "_login") as mock_login, + ): + with pytest.raises(GetAccessTokenError) as exc: + authenticator.get_access_token() + + assert exc.value.status_code == 401 + assert "event loop" in str(exc.value) + mock_login.assert_not_called() + def test_get_access_token_failure(self, authenticator): """Test that an exception is raised after multiple login failures.""" with ( @@ -305,5 +318,5 @@ class TestGitHubCopilotAuthenticator: patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ patch.object(authenticator, "get_access_token", return_value="access-tok"): authenticator._refresh_api_key() - assert mock_client.get.call_args[0][0] == custom_url + assert mock_client.get.call_args[0][0] == custom_url