XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

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
OpenHands <openhands@all-hands.dev>
提交于

代码差异

5 个文件 +207 -3
Modified etc/unittest/__main__.py +1 -0
@@ -22,5 +22,6 @@ from .test_gemini import *
22 22 from .test_deepseek_chunk_log import *
23 23 from .test_deepseek_stream import *
24 24 from .test_deepseek_upload import *
25 from .test_auth_retry import *
25 26
26 27 unittest.main()
Modified etc/unittest/mocks.py +40 -1
@@ -2,8 +2,9 @@ from g4f.providers.base_provider import (
2 2 AbstractProvider,
3 3 AsyncProvider,
4 4 AsyncGeneratorProvider,
5 AsyncAuthedProvider,
5 6 )
6 from g4f.providers.response import ImageResponse
7 from g4f.providers.response import AuthResult, ImageResponse
7 8 from g4f.errors import MissingAuthError
8 9
9 10
@@ -98,3 +99,41 @@ class YieldNoneProviderMock(AsyncGeneratorProvider):
98 99 @classmethod
99 100 async def create_async_generator(cls, model, messages, stream, **kwargs):
100 101 yield None
102
103
104 class RetryAuthedProviderMock(AsyncAuthedProvider):
105 """Authed provider that fails once with stale auth, then succeeds.
106
107 Mimics the OpenaiChat behaviour where a cached access token is rejected
108 by the server (MissingAuthError) and has to be re-logged in. The first
109 create_authed call raises; on_auth_async yields a fresh AuthResult and a
110 second create_authed call succeeds.
111 """
112
113 working = True
114 parent = "RetryAuthedProviderMock"
115
116 _api_key = None
117 _headers = None
118 _cookies = None
119 _expires = None
120
121 @classmethod
122 def reset_auth(cls):
123 cls._api_key = None
124 cls._headers = None
125 cls._cookies = None
126 cls._expires = None
127 cls.delete_cache_file()
128
129 @classmethod
130 async def create_authed(cls, model, messages, auth_result, **kwargs):
131 if getattr(auth_result, "api_key", None) != "fresh-token":
132 raise MissingAuthError("Access token is not valid")
133 for message in messages:
134 yield message["content"]
135
136 @classmethod
137 async def on_auth_async(cls, **kwargs):
138 cls._api_key = "fresh-token"
139 yield AuthResult(api_key="fresh-token")
Added etc/unittest/test_auth_retry.py +126 -0
@@ -0,0 +1,126 @@
1 """
2 Unit tests for AsyncAuthedProvider auth-failure retry behaviour.
3
4 Covers the fix for #3515: when a cached access token is rejected
5 (MissingAuthError), the provider must invalidate the stale cache file and
6 clear in-memory auth state so the automatic re-login fetches fresh
7 credentials instead of reusing (and failing on) the rejected ones.
8 """
9
10 from __future__ import annotations
11
12 import json
13 import unittest
14 from unittest.mock import patch
15
16 from g4f.errors import MissingAuthError
17 from g4f.providers.response import AuthResult
18
19 from .mocks import RetryAuthedProviderMock
20
21 DEFAULT_MESSAGES = [{"role": "user", "content": "Hello"}]
22
23
24 class TestAuthRetry(unittest.IsolatedAsyncioTestCase):
25 async def asyncSetUp(self):
26 RetryAuthedProviderMock._api_key = None
27 RetryAuthedProviderMock._headers = None
28 RetryAuthedProviderMock._cookies = None
29 RetryAuthedProviderMock._expires = None
30
31 async def test_stale_cache_is_invalidated_and_retry_succeeds(self):
32 """A rejected cached token triggers reset_auth and a fresh re-login."""
33 provider = RetryAuthedProviderMock
34 cache_file = provider.get_cache_file()
35 cache_file.parent.mkdir(parents=True, exist_ok=True)
36 with cache_file.open("w") as f:
37 json.dump(AuthResult(api_key="stale-token").get_dict(), f)
38 self.assertTrue(cache_file.exists())
39
40 try:
41 reset_calls = []
42 original_reset = provider.reset_auth
43
44 def tracking_reset():
45 reset_calls.append(True)
46 original_reset()
47
48 with patch.object(provider, "reset_auth", tracking_reset):
49 chunks = [
50 chunk
51 async for chunk in provider.create_async_generator(
52 "model", DEFAULT_MESSAGES
53 )
54 ]
55
56 self.assertEqual(chunks, ["Hello"])
57 # reset_auth was invoked to drop the stale credentials.
58 self.assertEqual(reset_calls, [True])
59 # The stale cache file was removed and a fresh one persisted.
60 self.assertTrue(cache_file.exists())
61 with cache_file.open("r") as f:
62 saved = json.load(f)
63 self.assertEqual(saved.get("api_key"), "fresh-token")
64 finally:
65 if cache_file.exists():
66 cache_file.unlink()
67
68 async def test_reset_auth_deletes_cache_file(self):
69 """reset_auth removes the persisted auth cache file."""
70 provider = RetryAuthedProviderMock
71 cache_file = provider.get_cache_file()
72 cache_file.parent.mkdir(parents=True, exist_ok=True)
73 with cache_file.open("w") as f:
74 json.dump(AuthResult(api_key="stale-token").get_dict(), f)
75
76 provider.reset_auth()
77 self.assertFalse(cache_file.exists())
78 # In-memory auth state is cleared too.
79 self.assertIsNone(provider._api_key)
80 self.assertIsNone(provider._expires)
81
82 async def test_missing_cache_triggers_login(self):
83 """With no cache file, a fresh login is performed directly."""
84 provider = RetryAuthedProviderMock
85 cache_file = provider.get_cache_file()
86 if cache_file.exists():
87 cache_file.unlink()
88
89 chunks = [
90 chunk
91 async for chunk in provider.create_async_generator(
92 "model", DEFAULT_MESSAGES
93 )
94 ]
95 self.assertEqual(chunks, ["Hello"])
96
97 async def test_persistent_auth_failure_propagates(self):
98 """If the re-login still fails, the error propagates (no silent loop)."""
99
100 class AlwaysFailingProvider(RetryAuthedProviderMock):
101 parent = "AlwaysFailingProvider"
102
103 @classmethod
104 async def create_authed(cls, model, messages, auth_result, **kwargs):
105 raise MissingAuthError("Access token is not valid")
106 yield # pragma: no cover
107
108 provider = AlwaysFailingProvider
109 cache_file = provider.get_cache_file()
110 if cache_file.exists():
111 cache_file.unlink()
112 try:
113 with self.assertRaises(MissingAuthError):
114 [
115 chunk
116 async for chunk in provider.create_async_generator(
117 "model", DEFAULT_MESSAGES
118 )
119 ]
120 finally:
121 if cache_file.exists():
122 cache_file.unlink()
123
124
125 if __name__ == "__main__":
126 unittest.main()
Modified g4f/Provider/needs_auth/OpenaiChat.py +13 -0
@@ -176,6 +176,19 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
176 176 cls.write_cache_file(cache_file, chunk)
177 177 return
178 178
179 @classmethod
180 def reset_auth(cls):
181 # A rejected access token may still look valid by its local expiry
182 # claim, so login_generator would otherwise reuse the stale token,
183 # cookies and headers. Clear all cached auth state to force a fresh
184 # login, matching the manual workaround of deleting the auth file.
185 cls._api_key = None
186 cls._headers = None
187 cls._cookies = None
188 cls._expires = None
189 cls.request_config = RequestConfig()
190 cls.delete_cache_file()
191
179 192 @classmethod
180 193 async def on_auth_async(cls, proxy: str = None, **kwargs) -> AsyncIterator:
181 194 async for chunk in cls.login_generator(proxy=proxy):
Modified g4f/providers/base_provider.py +27 -2
@@ -443,6 +443,15 @@ class AuthFileMixin:
443 443 / f"auth_{cls.parent if hasattr(cls, 'parent') else cls.__name__}.json"
444 444 )
445 445
446 @classmethod
447 def delete_cache_file(cls):
448 cache_file = cls.get_cache_file()
449 if cache_file.exists():
450 try:
451 cache_file.unlink()
452 except OSError:
453 pass
454
446 455
447 456 class AsyncAuthedProvider(AsyncGeneratorProvider, AuthFileMixin):
448 457 @classmethod
@@ -451,6 +460,19 @@ class AsyncAuthedProvider(AsyncGeneratorProvider, AuthFileMixin):
451 460 raise MissingAuthError(f"API key is required for {cls.__name__}")
452 461 return AuthResult()
453 462
463 @classmethod
464 def reset_auth(cls):
465 """
466 Invalidate cached authentication after an auth failure.
467
468 Removes the persisted auth cache file. Providers that keep auth
469 state in class attributes (e.g. an access token) should override
470 this to clear that in-memory state as well, so the following
471 login performs a fresh authentication instead of reusing the
472 rejected credentials.
473 """
474 cls.delete_cache_file()
475
454 476 @classmethod
455 477 def write_cache_file(cls, cache_file: Path, auth_result: AuthResult = None):
456 478 if auth_result is not None:
@@ -521,8 +543,11 @@ class AsyncAuthedProvider(AsyncGeneratorProvider, AuthFileMixin):
521 543 async for chunk in response:
522 544 yield chunk
523 545 except (MissingAuthError, NoValidHarFileError, CloudflareError):
524 # if cache_file.exists():
525 # cache_file.unlink()
546 # The cached auth is no longer valid (e.g. a revoked or expired
547 # access token). Drop the persisted cache file and any in-memory
548 # auth state so the re-login below fetches fresh credentials
549 # instead of reusing the rejected ones.
550 cls.reset_auth()
526 551 response = cls.on_auth_async(**kwargs)
527 552 async for chunk in response:
528 553 if isinstance(chunk, AuthResult):