fix(auth): refuse device-code login from worker threads too

/v1/messages runs its handler in an executor thread, where the
running-loop check never fires, so a chatgpt or github_copilot model
still started the interactive device-code login there and the request
hung for up to 15 minutes. The guard now also requires the main thread,
so the login only runs where a human can actually answer it.
This commit is contained in:
mateo-berri 2026-09-03 11:43:18 -07:00
parent 133d0b7072
commit 29baf5a6bd
5 changed files with 43 additions and 6 deletions

View file

@ -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.

View file

@ -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."

View file

@ -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."

View file

@ -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),

View file

@ -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 (