XFEstudio/gpt4free
fix(OpenaiChat): invalidate stale auth on token rejection (#3515)
When OpenaiChat's access token is rejected by the server (401/403/429/500, raised as MissingAuthError), the automatic retry in AsyncAuthedProvider.create_async_generator did not invalidate the stale credentials. The persisted auth cache file was left in place (its deletion was commented out) and the in-memory class state (_api_key, _headers, _cookies, _expires, request_config) was not cleared. Because a rejected token can still look valid by its local expiry claim, login_generator reused the stale token/cookies/headers, so the retried request failed again and the second MissingAuthError propagated to the caller - leaving the stale auth_OpenaiChat.json behind and forcing users to delete it manually and restart. Add a reset_auth() hook to AsyncAuthedProvider (deletes the cache file) and call it in the auth-failure except block before re-logging in. OpenaiChat overrides reset_auth() to also clear its cached class state and reset request_config, so the re-login performs a fully fresh authentication - automating the manual workaround from the issue. Adds unit tests in etc/unittest/test_auth_retry.py covering stale-cache invalidation, cache-file deletion, missing-cache login and persistent failure propagation. Co-authored-by: openhands <openhands@all-hands.dev>
bc25822b
代码差异
@@ -22,5 +22,6 @@ from .test_gemini import *
from .test_deepseek_chunk_log import *
from .test_deepseek_stream import *
from .test_deepseek_upload import *
from .test_auth_retry import *
unittest.main()
@@ -2,8 +2,9 @@ from g4f.providers.base_provider import (
AbstractProvider,
AsyncProvider,
AsyncGeneratorProvider,
AsyncAuthedProvider,
)
from g4f.providers.response import ImageResponse
from g4f.providers.response import AuthResult, ImageResponse
from g4f.errors import MissingAuthError
@@ -98,3 +99,41 @@ class YieldNoneProviderMock(AsyncGeneratorProvider):
@classmethod
async def create_async_generator(cls, model, messages, stream, **kwargs):
yield None
class RetryAuthedProviderMock(AsyncAuthedProvider):
"""Authed provider that fails once with stale auth, then succeeds.
Mimics the OpenaiChat behaviour where a cached access token is rejected
by the server (MissingAuthError) and has to be re-logged in. The first
create_authed call raises; on_auth_async yields a fresh AuthResult and a
second create_authed call succeeds.
"""
working = True
parent = "RetryAuthedProviderMock"
_api_key = None
_headers = None
_cookies = None
_expires = None
@classmethod
def reset_auth(cls):
cls._api_key = None
cls._headers = None
cls._cookies = None
cls._expires = None
cls.delete_cache_file()
@classmethod
async def create_authed(cls, model, messages, auth_result, **kwargs):
if getattr(auth_result, "api_key", None) != "fresh-token":
raise MissingAuthError("Access token is not valid")
for message in messages:
yield message["content"]
@classmethod
async def on_auth_async(cls, **kwargs):
cls._api_key = "fresh-token"
yield AuthResult(api_key="fresh-token")
@@ -0,0 +1,126 @@
"""
Unit tests for AsyncAuthedProvider auth-failure retry behaviour.
Covers the fix for #3515: when a cached access token is rejected
(MissingAuthError), the provider must invalidate the stale cache file and
clear in-memory auth state so the automatic re-login fetches fresh
credentials instead of reusing (and failing on) the rejected ones.
"""
from __future__ import annotations
import json
import unittest
from unittest.mock import patch
from g4f.errors import MissingAuthError
from g4f.providers.response import AuthResult
from .mocks import RetryAuthedProviderMock
DEFAULT_MESSAGES = [{"role": "user", "content": "Hello"}]
class TestAuthRetry(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
RetryAuthedProviderMock._api_key = None
RetryAuthedProviderMock._headers = None
RetryAuthedProviderMock._cookies = None
RetryAuthedProviderMock._expires = None
async def test_stale_cache_is_invalidated_and_retry_succeeds(self):
"""A rejected cached token triggers reset_auth and a fresh re-login."""
provider = RetryAuthedProviderMock
cache_file = provider.get_cache_file()
cache_file.parent.mkdir(parents=True, exist_ok=True)
with cache_file.open("w") as f:
json.dump(AuthResult(api_key="stale-token").get_dict(), f)
self.assertTrue(cache_file.exists())
try:
reset_calls = []
original_reset = provider.reset_auth
def tracking_reset():
reset_calls.append(True)
original_reset()
with patch.object(provider, "reset_auth", tracking_reset):
chunks = [
chunk
async for chunk in provider.create_async_generator(
"model", DEFAULT_MESSAGES
)
]
self.assertEqual(chunks, ["Hello"])
# reset_auth was invoked to drop the stale credentials.
self.assertEqual(reset_calls, [True])
# The stale cache file was removed and a fresh one persisted.
self.assertTrue(cache_file.exists())
with cache_file.open("r") as f:
saved = json.load(f)
self.assertEqual(saved.get("api_key"), "fresh-token")
finally:
if cache_file.exists():
cache_file.unlink()
async def test_reset_auth_deletes_cache_file(self):
"""reset_auth removes the persisted auth cache file."""
provider = RetryAuthedProviderMock
cache_file = provider.get_cache_file()
cache_file.parent.mkdir(parents=True, exist_ok=True)
with cache_file.open("w") as f:
json.dump(AuthResult(api_key="stale-token").get_dict(), f)
provider.reset_auth()
self.assertFalse(cache_file.exists())
# In-memory auth state is cleared too.
self.assertIsNone(provider._api_key)
self.assertIsNone(provider._expires)
async def test_missing_cache_triggers_login(self):
"""With no cache file, a fresh login is performed directly."""
provider = RetryAuthedProviderMock
cache_file = provider.get_cache_file()
if cache_file.exists():
cache_file.unlink()
chunks = [
chunk
async for chunk in provider.create_async_generator(
"model", DEFAULT_MESSAGES
)
]
self.assertEqual(chunks, ["Hello"])
async def test_persistent_auth_failure_propagates(self):
"""If the re-login still fails, the error propagates (no silent loop)."""
class AlwaysFailingProvider(RetryAuthedProviderMock):
parent = "AlwaysFailingProvider"
@classmethod
async def create_authed(cls, model, messages, auth_result, **kwargs):
raise MissingAuthError("Access token is not valid")
yield # pragma: no cover
provider = AlwaysFailingProvider
cache_file = provider.get_cache_file()
if cache_file.exists():
cache_file.unlink()
try:
with self.assertRaises(MissingAuthError):
[
chunk
async for chunk in provider.create_async_generator(
"model", DEFAULT_MESSAGES
)
]
finally:
if cache_file.exists():
cache_file.unlink()
if __name__ == "__main__":
unittest.main()
@@ -176,6 +176,19 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
cls.write_cache_file(cache_file, chunk)
return
@classmethod
def reset_auth(cls):
# A rejected access token may still look valid by its local expiry
# claim, so login_generator would otherwise reuse the stale token,
# cookies and headers. Clear all cached auth state to force a fresh
# login, matching the manual workaround of deleting the auth file.
cls._api_key = None
cls._headers = None
cls._cookies = None
cls._expires = None
cls.request_config = RequestConfig()
cls.delete_cache_file()
@classmethod
async def on_auth_async(cls, proxy: str = None, **kwargs) -> AsyncIterator:
async for chunk in cls.login_generator(proxy=proxy):
@@ -443,6 +443,15 @@ class AuthFileMixin:
/ f"auth_{cls.parent if hasattr(cls, 'parent') else cls.__name__}.json"
)
@classmethod
def delete_cache_file(cls):
cache_file = cls.get_cache_file()
if cache_file.exists():
try:
cache_file.unlink()
except OSError:
pass
class AsyncAuthedProvider(AsyncGeneratorProvider, AuthFileMixin):
@classmethod
@@ -451,6 +460,19 @@ class AsyncAuthedProvider(AsyncGeneratorProvider, AuthFileMixin):
raise MissingAuthError(f"API key is required for {cls.__name__}")
return AuthResult()
@classmethod
def reset_auth(cls):
"""
Invalidate cached authentication after an auth failure.
Removes the persisted auth cache file. Providers that keep auth
state in class attributes (e.g. an access token) should override
this to clear that in-memory state as well, so the following
login performs a fresh authentication instead of reusing the
rejected credentials.
"""
cls.delete_cache_file()
@classmethod
def write_cache_file(cls, cache_file: Path, auth_result: AuthResult = None):
if auth_result is not None:
@@ -521,8 +543,11 @@ class AsyncAuthedProvider(AsyncGeneratorProvider, AuthFileMixin):
async for chunk in response:
yield chunk
except (MissingAuthError, NoValidHarFileError, CloudflareError):
# if cache_file.exists():
# cache_file.unlink()
# The cached auth is no longer valid (e.g. a revoked or expired
# access token). Drop the persisted cache file and any in-memory
# auth state so the re-login below fetches fresh credentials
# instead of reusing the rejected ones.
cls.reset_auth()
response = cls.on_auth_async(**kwargs)
async for chunk in response:
if isinstance(chunk, AuthResult):