Skip to content

Commit 4adce80

Browse files
committed
Release v1.2.1
1 parent 8e7fe7d commit 4adce80

15 files changed

Lines changed: 168 additions & 26 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 1.2.1 - 2026-07-27
4+
5+
+ [新增] Grok OAuth 定时巡查接入自动恢复队列;检测到 `xAI CLI OAuth account needs reauthorization` 时直接使用账号现有身份重新授权,并在恢复成功后继续模型探测与外部投递。
6+
+ [修复] OAuth 巡查只将 `invalid_grant`、`invalid_token` 和 `unauthorized_client` 判定为凭据失效;TLS、网络故障、普通 HTTP 错误及未知响应仅记录本次失败,不再误伤正常账号。
7+
+ [修复] `402 personal-team-blocked` 不再触发重复授权,改为按 `60 / 120 / 300` 秒延迟复检 Grok 4.5 权限;探测恢复为有效或限流时同步清除历史错误并恢复账号状态。
8+
+ [修复] OAuth Token 轮换后自动清除旧的重新授权错误,避免已恢复账号继续显示失效。
9+
+ [修复] Device Code consent 的重定向必须实际到达授权完成状态才算成功,避免普通 2xx 或中间重定向被误判为授权完成。
10+
311
## 1.2.0 - 2026-07-25
412

513
+ [新增] Grok 注册支持在同一有头浏览器会话内完成中文资料填写、邮箱确认、Turnstile 与账号创建,并增加页面状态诊断、截图和确认邮件兜底。

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ GPTGrok2API 是一个自托管的 GPT 与 Grok 统一网关,将已接入的订
1717
| 协议兼容 | 提供 Chat Completions、Responses、Images、Anthropic Messages 兼容入口,支持流式与非流式响应、工具调用、搜索和推理强度。 |
1818
| GPT 运行时 | 支持 GPT 文本对话、网页搜索、图片生成、图片编辑、会话复用、文件下载以及 PPT/PSD 等可编辑文件任务。 |
1919
| Grok SSO 运行时 | 内置 Grok 账号池、额度刷新、账号调度、聊天、Responses、Messages、图片生成、图片编辑、视频生成和媒体缓存。 |
20-
| Grok 后台探测与恢复 | 服务启动后自动探测已加入运行池的非禁用账号;明确失效时使用已保存邮箱和密码重新登录、验证并替换 SSO,无需手动操作。 |
21-
| Grok Build OAuth | 支持 Device Code 与公网版 Authorization Code + PKCE 授权、Access/Refresh Token 导入、自动刷新、模型探测以及独立 OAuth 凭据存储;新注册使用公网版 `redirect=cloud-console` 上下文,并在关闭客户端前复用同一个 live `curl_cffi Session` 完成 PKCE,Turnstile 沿用系统打码配置,临时 Cookie 与 OAuth Token 均不写入注册账号文件。 |
20+
| Grok 后台探测与恢复 | 服务启动后自动巡查已加入运行池的非禁用账号;SSO 明确失效时重新登录,OAuth 明确要求重新授权时进入自动恢复队列,网络和未知错误不改变账号有效状态。 |
21+
| Grok Build OAuth | 支持 Device Code 与公网版 Authorization Code + PKCE 授权、Access/Refresh Token 导入、自动刷新、模型探测以及独立 OAuth 凭据存储;新注册使用公网版 `redirect=cloud-console` 上下文,并在关闭客户端前复用同一个 live `curl_cffi Session` 完成 PKCE。Turnstile 沿用系统打码配置,Device Code 只有实际到达授权完成状态才会成功,临时 Cookie 与 OAuth Token 均不写入注册账号文件。 |
2222
| 账号生命周期 | 支持账号导入、分组、标签、额度同步、异常状态识别、限流恢复、无效账号清理、批量操作、代理绑定和运行状态监控。 |
2323
| iCloud Privacy Mail | 内置 sidecar;新接口负责 Apple 登录、2FA 和创建隐私邮箱,旧接口负责登录、2FA 和同步已有邮箱,IMAP App 专用密码负责取验证码。 |
2424
| iCloud 定时创建 | 按 Apple 账号定时创建邮箱;新接口每小时最多 20 个、旧接口每小时最多 5 个,每账号累计 750 个后自动停止。sidecar 使用 Compose 内部网络,不需要独立账号或宿主机端口。 |
@@ -66,6 +66,10 @@ Grok 账号探测随服务启动自动在后台运行,无需在账号管理页
6666

6767
新 SSO 会先加入运行池并再次验证,确认有效后才删除旧 SSO并原子更新本地账号档案。恢复失败时保留旧档案,按 `1 / 2 / 4 / 8 / 16 / 24` 小时退避重试,最长间隔 `24` 小时。账号列表显示探测结果、恢复状态和恢复时间;恢复记录保存在 `data/grok_accounts.json`,调度完成记录保存在 `data/register.json`。
6868

69+
OAuth 账号由同一套定时巡查负责分类和恢复。刷新接口明确返回 `invalid_grant`、`invalid_token`、`unauthorized_client`,或探测结果为 `xAI CLI OAuth account needs reauthorization` 时,账号才会标记为需要重新授权并进入自动恢复队列;授权获得新 Token 后会清除旧错误,再执行 Grok 4.5 探测与已配置的外部投递。
70+
71+
`402 personal-team-blocked` 表示权限或额度尚未生效,不等同于 OAuth 凭据失效,因此不会重复授权,而是按 `60 / 120 / 300` 秒延迟复检。TLS、代理、网络错误、上游普通 HTTP 错误和无法分类的响应仅记录本次失败,保留账号原状态等待下一轮;探测结果恢复为有效或限流时,历史重新授权错误会自动清除。
72+
6973
## 内置 Captcha Solver
7074

7175
本地打码源码已经并入主仓库的 `captcha-solver/`,包含 xAI 实页 `window.turnstile.render()`、callback token 采集和注册代理按请求透传修复。安装主项目后直接创建独立虚拟环境:

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.0
1+
1.2.1

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "chatgpt2api"
3-
version = "1.2.0"
3+
version = "1.2.1"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.13"

‎services/register_service.py‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
_GROK_OAUTH_RECOVERY_STALE_MINUTES = 24 * 60
9292
_GROK_PERMISSION_RETRY_DELAYS_SECONDS = (60, 120, 300)
9393
_GROK_PERMISSION_RETRY_BATCH_SIZE = 3
94+
_XAI_CLI_REAUTHORIZATION_ERROR = "xAI CLI OAuth account needs reauthorization"
9495
_GROK_PENDING_STATUSES = {
9596
"submitting",
9697
"pending_submit",
@@ -314,6 +315,22 @@ def _grok_oauth_probe_recoverable(value: object) -> bool:
314315
)
315316

316317

318+
def _grok_oauth_permission_pending(value: object) -> bool:
319+
probe = value if isinstance(value, dict) else {}
320+
code = _clean_text(probe.get("code")).lower()
321+
return code == "permission-denied" or (
322+
code == "personal-team-blocked" or code.startswith("personal-team-blocked:")
323+
)
324+
325+
326+
def _grok_oauth_reauthorization_required(value: object) -> bool:
327+
item = value if isinstance(value, dict) else {}
328+
return (
329+
_clean_text(item.get("last_error")) == _XAI_CLI_REAUTHORIZATION_ERROR
330+
or _grok_oauth_probe_recoverable(item.get("probe"))
331+
)
332+
333+
317334
def _grok_oauth_authorizable(item: object) -> bool:
318335
"""Return whether an SSO registration record is ready for OAuth."""
319336
if not isinstance(item, dict):
@@ -2283,8 +2300,7 @@ def _recover_grok_oauth_account(
22832300
oauth_account_id = _clean_text(oauth_item.get("id"))
22842301
if not oauth_account_id or _clean_text(oauth_item.get("status")).lower() == "disabled":
22852302
return {"status": "deferred"}
2286-
probe = oauth_item.get("probe") if isinstance(oauth_item.get("probe"), dict) else {}
2287-
if not _grok_oauth_probe_recoverable(probe):
2303+
if not _grok_oauth_reauthorization_required(oauth_item):
22882304
return {"status": "deferred"}
22892305

22902306
now = datetime.now(timezone.utc)
@@ -2387,7 +2403,7 @@ def _grok_oauth_has_inflight_recovery() -> bool:
23872403
return False
23882404
return any(
23892405
_clean_text(item.get("status")).lower() != "disabled"
2390-
and _grok_oauth_probe_recoverable(item.get("probe"))
2406+
and _grok_oauth_reauthorization_required(item)
23912407
and _clean_text(
23922408
item.get("recovery", {}).get("status") if isinstance(item.get("recovery"), dict) else ""
23932409
).lower()
@@ -2405,7 +2421,7 @@ def _grok_oauth_has_due_invalid_recovery() -> bool:
24052421
now = datetime.now(timezone.utc)
24062422
return any(
24072423
_clean_text(item.get("status")).lower() != "disabled"
2408-
and _grok_oauth_probe_recoverable(item.get("probe"))
2424+
and _grok_oauth_reauthorization_required(item)
24092425
and RegisterService._grok_oauth_recovery_due(item, now=now)
24102426
for item in items
24112427
if isinstance(item, dict)
@@ -2660,7 +2676,7 @@ def _run_grok_permission_retry_once(
26602676
if not isinstance(item, dict) or _clean_text(item.get("status")).lower() == "disabled":
26612677
continue
26622678
probe = item.get("probe") if isinstance(item.get("probe"), dict) else {}
2663-
if _clean_text(probe.get("code")).lower() != "permission-denied":
2679+
if not _grok_oauth_permission_pending(probe):
26642680
continue
26652681
probed_at = _parse_datetime(probe.get("at"))
26662682
retry_delay = _grok_permission_retry_delay_seconds(item)
@@ -2696,7 +2712,7 @@ def _run_grok_permission_retry_once(
26962712
1 for item in results if _clean_text(item.get("status")).lower() == "valid"
26972713
)
26982714
summary["pending"] = sum(
2699-
1 for item in results if _clean_text(item.get("code")).lower() == "permission-denied"
2715+
1 for item in results if _grok_oauth_permission_pending(item)
27002716
)
27012717
summary["failed"] = max(0, summary["tested"] - summary["valid"] - summary["pending"])
27022718
selected_by_id = {_clean_text(item.get("id")): item for item in selected}
@@ -2709,7 +2725,7 @@ def _run_grok_permission_retry_once(
27092725
metadata = source.get("metadata") if isinstance(source.get("metadata"), dict) else {}
27102726
retry = metadata.get("permission_retry") if isinstance(metadata.get("permission_retry"), dict) else {}
27112727
status = _clean_text(result.get("status")).lower()
2712-
pending = _clean_text(result.get("code")).lower() == "permission-denied"
2728+
pending = _grok_oauth_permission_pending(result)
27132729
attempts = max(0, _safe_int(retry.get("attempts"))) + (1 if pending else 0)
27142730
next_delay = (
27152731
_GROK_PERMISSION_RETRY_DELAYS_SECONDS[
@@ -2779,7 +2795,7 @@ def _run_grok_oauth_recovery_sweep(
27792795
item
27802796
for item in oauth_items
27812797
if _clean_text(item.get("status")).lower() != "disabled"
2782-
and _grok_oauth_probe_recoverable(item.get("probe"))
2798+
and _grok_oauth_reauthorization_required(item)
27832799
]
27842800
candidates.sort(
27852801
key=lambda item: 0

‎services/register_service_grok2api_test.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,35 @@ def test_invalid_oauth_probe_event_only_wakes_enabled_scheduler(self) -> None:
440440
)
441441
self.assertFalse(service._grok_probe_wake_event.is_set())
442442

443+
def test_oauth_patrol_classifies_reauthorization_and_permission_retry(self) -> None:
444+
self.assertTrue(
445+
register_service_module._grok_oauth_reauthorization_required(
446+
{
447+
"last_error": "xAI CLI OAuth account needs reauthorization",
448+
"probe": {"status": "unknown"},
449+
}
450+
)
451+
)
452+
no_quota = {
453+
"last_error": "No credits",
454+
"probe": {
455+
"status": "invalid",
456+
"code": "personal-team-blocked:spending-limit",
457+
},
458+
}
459+
self.assertFalse(register_service_module._grok_oauth_reauthorization_required(no_quota))
460+
self.assertTrue(register_service_module._grok_oauth_permission_pending(no_quota["probe"]))
461+
self.assertTrue(
462+
register_service_module._grok_oauth_permission_pending(
463+
{"status": "invalid", "code": "permission-denied"}
464+
)
465+
)
466+
self.assertFalse(
467+
register_service_module._grok_oauth_permission_pending(
468+
{"status": "invalid", "code": "invalid_credentials"}
469+
)
470+
)
471+
443472
def test_scheduler_runs_due_invalid_oauth_recovery_without_waiting_for_probe_interval(self) -> None:
444473
with tempfile.TemporaryDirectory() as temp_dir:
445474
service = self._service(temp_dir, probe_scheduler={"enabled": True})

‎services/xai_cli_oauth_service.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1203,7 +1203,7 @@ async def _refresh_account(self, account: dict[str, Any]) -> dict[str, Any]:
12031203
payload = self._json_body(response)
12041204
if response.status_code != 200 or not _clean_text(payload.get("access_token")):
12051205
error = _clean_text(payload.get("error"))
1206-
if error in {"invalid_grant", "invalid_token", "unauthorized_client"} or response.status_code in {400, 401, 403}:
1206+
if error in {"invalid_grant", "invalid_token", "unauthorized_client"}:
12071207
self.store.set_status([account_id], "invalid")
12081208
raise ValidationError("xAI CLI OAuth account needs reauthorization", code="invalid_credentials")
12091209
raise UpstreamError("xAI CLI OAuth token refresh failed", status=502, body=_safe_error_body(response))

‎services/xai_cli_oauth_service_test.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import httpx
1616

17+
from app.platform.errors import UpstreamError, ValidationError
1718
from services.xai_cli_oauth_service import XaiCliOAuthService, _billing_quota
1819
from services.xai_cli_oauth_store import XaiCliOAuthAccountStore
1920

@@ -706,6 +707,20 @@ async def test_refresh_rotates_token_without_returning_credentials(self) -> None
706707
self.assertEqual(raw["refresh_token"], "rotated-refresh")
707708
self.assertEqual(raw["access_token"], access)
708709

710+
async def test_refresh_only_invalidates_explicit_oauth_credential_errors(self) -> None:
711+
transient = self._account(expires_at="2000-01-01T00:00:00+00:00")
712+
transient_response = httpx.Response(403, json={"error": "temporarily_unavailable"})
713+
with patch.object(self.service, "_form_post", new=AsyncMock(return_value=transient_response)):
714+
with self.assertRaises(UpstreamError):
715+
await self.service.refresh_account(str(transient["id"]))
716+
self.assertEqual(self.store.get(str(transient["id"]))["status"], "active")
717+
718+
terminal_response = httpx.Response(400, json={"error": "invalid_grant"})
719+
with patch.object(self.service, "_form_post", new=AsyncMock(return_value=terminal_response)):
720+
with self.assertRaises(ValidationError):
721+
await self.service.refresh_account(str(transient["id"]))
722+
self.assertEqual(self.store.get(str(transient["id"]))["status"], "invalid")
723+
709724
async def test_nonstream_request_records_success_and_never_uses_cookie_headers(self) -> None:
710725
account = self._account()
711726
selected_accounts: list[dict[str, str]] = []

‎services/xai_cli_oauth_store.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,11 @@ def _merge_account(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[s
457457
merged["use_count"] = _non_negative_int(existing.get("use_count"))
458458
merged["fail_count"] = _non_negative_int(existing.get("fail_count"))
459459
merged["last_used_at"] = _clean_text(existing.get("last_used_at"))
460-
merged["last_error"] = _safe_error(existing.get("last_error"), existing)
460+
credentials_rotated = (
461+
bool(_clean_text(incoming.get("access_token")))
462+
and _clean_text(incoming.get("access_token")) != _clean_text(existing.get("access_token"))
463+
)
464+
merged["last_error"] = "" if credentials_rotated else _safe_error(existing.get("last_error"), existing)
461465
merged["updated_at"] = _now()
462466
return merged
463467

@@ -677,6 +681,9 @@ def update_tokens(
677681
item["email"] = _clean_text(email)
678682
if models is not None:
679683
item["models"] = _model_ids(models)
684+
if _normalize_status(item.get("status")) != "disabled":
685+
item["status"] = "active"
686+
item["last_error"] = ""
680687
item["last_refresh_at"] = _now()
681688
item["updated_at"] = _now()
682689
self._save_unlocked(items)
@@ -820,6 +827,10 @@ def update_probe_results(self, results: list[dict[str, Any]]) -> list[dict[str,
820827
item["status"] = "invalid"
821828
elif normalized_status in {"valid", "limited"}:
822829
item["status"] = "active"
830+
if normalized_status in {"valid", "limited"}:
831+
item["last_error"] = ""
832+
elif normalized_status == "invalid":
833+
item["last_error"] = _safe_error(result.get("error"), item)
823834
item["updated_at"] = now
824835
updated_items.append(item)
825836
if updated_items:

‎services/xai_cli_oauth_store_test.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ def test_recovery_state_is_safe_and_resolves_by_job_id(self) -> None:
152152
def test_subject_deduplicates_refresh_token_rotation_and_preserves_identity(self) -> None:
153153
first = self.store.upsert(self._payload("first@example.com", "stable-subject", "one"))
154154
first_created_at = self.store.get(first["item"]["id"])["created_at"]
155+
self.store.record_result(first["item"]["id"], False, "OAuth account needs reauthorization")
155156
second = self.store.upsert(
156157
self._payload("renamed@example.com", "stable-subject", "two", expires_at="2030-01-01T00:00:00+00:00")
157158
)
@@ -163,8 +164,31 @@ def test_subject_deduplicates_refresh_token_rotation_and_preserves_identity(self
163164
self.assertEqual(raw["access_token"], "access-two")
164165
self.assertEqual(raw["refresh_token"], "refresh-two")
165166
self.assertEqual(raw["created_at"], first_created_at)
167+
self.assertEqual(raw["last_error"], "")
166168
self.assertEqual(self.store.count(), 1)
167169

170+
def test_probe_result_replaces_stale_error_with_current_outcome(self) -> None:
171+
saved = self.store.upsert(self._payload("person@example.com", "subject-one", "one"))["item"]
172+
self.store.record_result(saved["id"], False, "OAuth account needs reauthorization")
173+
174+
invalid = self.store.update_probe_result(
175+
saved["id"],
176+
status="invalid",
177+
model="grok-4.5",
178+
http_status=402,
179+
code="personal-team-blocked:spending-limit",
180+
error="No credits",
181+
)
182+
self.assertEqual(invalid["last_error"], "No credits")
183+
184+
valid = self.store.update_probe_result(
185+
saved["id"],
186+
status="valid",
187+
model="grok-4.5",
188+
http_status=200,
189+
)
190+
self.assertEqual(valid["last_error"], "")
191+
168192
def test_round_robin_skips_disabled_accounts_and_honors_exclusions(self) -> None:
169193
first = self.store.upsert(self._payload("one@example.com", "subject-one", "one"))["item"]
170194
second = self.store.upsert(self._payload("two@example.com", "subject-two", "two"))["item"]
@@ -181,6 +205,8 @@ def test_round_robin_skips_disabled_accounts_and_honors_exclusions(self) -> None
181205

182206
def test_update_tokens_rotates_refresh_token_and_status_controls_selection(self) -> None:
183207
saved = self.store.upsert(self._payload("person@example.com", "subject-one", "one"))["item"]
208+
self.store.record_result(saved["id"], False, "OAuth account needs reauthorization")
209+
self.store.set_status([saved["id"]], "invalid")
184210

185211
updated = self.store.update_tokens(
186212
saved["id"],
@@ -196,6 +222,8 @@ def test_update_tokens_rotates_refresh_token_and_status_controls_selection(self)
196222
self.assertEqual(raw["refresh_token"], "refresh-two")
197223
self.assertEqual(raw["id_token"], "id-two")
198224
self.assertEqual(raw["expires_at"], "2032-01-01T00:00:00+00:00")
225+
self.assertEqual(raw["status"], "active")
226+
self.assertEqual(raw["last_error"], "")
199227

200228
self.assertEqual(self.store.set_disabled([saved["id"]], True)["updated"], 1)
201229
self.assertIsNone(self.store.select_next_account())

0 commit comments

Comments
 (0)