diff --git a/litellm/litellm_core_utils/asyncify.py b/litellm/litellm_core_utils/asyncify.py index 695312ee234..17cfb3ef84f 100644 --- a/litellm/litellm_core_utils/asyncify.py +++ b/litellm/litellm_core_utils/asyncify.py @@ -1,5 +1,6 @@ import asyncio import functools +import threading from collections.abc import Awaitable, Callable from typing import Final @@ -76,6 +77,10 @@ def is_event_loop_running() -> bool: return True +def can_block_current_thread() -> bool: + return threading.current_thread() is threading.main_thread() and not is_event_loop_running() + + 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 eb8a8aabdbc..c27bb83437e 100644 --- a/litellm/llms/chatgpt/authenticator.py +++ b/litellm/llms/chatgpt/authenticator.py @@ -9,7 +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.litellm_core_utils.asyncify import can_block_current_thread from litellm.llms.custom_httpx.http_handler import _get_httpx_client from .common_utils import ( @@ -68,11 +68,11 @@ class Authenticator: except RefreshAccessTokenError as exc: verbose_logger.warning("ChatGPT refresh token failed, re-login required: %s", exc) - if is_event_loop_running(): + if not can_block_current_thread(): 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 " + "or a worker thread (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 auth.json into the proxy, ' "or set CHATGPT_TOKEN_DIR to a directory that already holds it." diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 1a51fd88608..7821756bc16 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -7,7 +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.litellm_core_utils.asyncify import can_block_current_thread from litellm.llms.custom_httpx.http_handler import _get_httpx_client from .common_utils import ( @@ -58,11 +58,11 @@ class Authenticator: except OSError: verbose_logger.warning("No existing access token found or error reading file") - if is_event_loop_running(): + if not can_block_current_thread(): 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 " + "or a worker thread (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 access-token file into ' "the proxy, or set GITHUB_COPILOT_TOKEN_DIR to a directory that already holds it." diff --git a/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py b/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py index 6c3c8adc948..861404e9157 100644 --- a/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py +++ b/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py @@ -1,6 +1,7 @@ import base64 import json import time +from concurrent.futures import ThreadPoolExecutor from unittest.mock import MagicMock, mock_open, patch import pytest @@ -109,6 +110,22 @@ class TestChatGPTAuthenticator: mock_login.assert_not_called() mock_wait.assert_not_called() + def test_get_access_token_refuses_device_code_login_in_worker_thread(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 ThreadPoolExecutor(max_workers=1) as pool: + with pytest.raises(GetAccessTokenError) as exc: + pool.submit(authenticator.get_access_token).result() + + assert exc.value.status_code == 401 + assert "worker thread" in str(exc.value) + assert authenticator.auth_file not 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), 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 98914fa4840..a49a4b44b74 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 @@ -1,6 +1,7 @@ import json import os import time +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from unittest.mock import MagicMock, mock_open, patch @@ -103,6 +104,20 @@ class TestGitHubCopilotAuthenticator: assert authenticator.access_token_file not in str(exc.value) mock_login.assert_not_called() + def test_get_access_token_refuses_device_code_login_in_worker_thread(self, authenticator): + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(authenticator, "_login") as mock_login, + ): + with ThreadPoolExecutor(max_workers=1) as pool: + with pytest.raises(GetAccessTokenError) as exc: + pool.submit(authenticator.get_access_token).result() + + assert exc.value.status_code == 401 + assert "worker thread" in str(exc.value) + assert authenticator.access_token_file not 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 (