fix(chatgpt,github_copilot): refuse device-code login when an event loop is running

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-09-03 17:00:07 +00:00
parent 342e4470c4
commit 9bc2342572
5 changed files with 91 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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