Skip to content

Commit ce0e9ec

Browse files
committed
Release v1.0.5
1 parent 71313a2 commit ce0e9ec

20 files changed

Lines changed: 497 additions & 87 deletions

‎CHANGELOG.md‎

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

3+
## 1.0.5 - 2026-07-19
4+
5+
+ [修复] OpenAI 注册默认切回 `PlatformRegistrar`,对齐上游最新 authorize/passwordless signup 链路,保留传统密码流程作为显式回退。
6+
+ [新增] OpenAI 注册成功后可自动上传到 CPA / CLIProxyAPI,生成官方 `codex-邮箱.json` OAuth 文件,并与 Sub2API 独立投递。
7+
+ [新增] 注册中心“外部同步”增加 OpenAI CPA 开关和连接选择,上传失败不影响本地账号保存。
8+
+ [修复] Outlook Token IMAP 取码复用已认证连接,并对 `authenticated but not connected` 等瞬时断线进行退避重试。
9+
+ [安全] CPA/Sub2API 投递错误继续隐藏 access token、refresh token、id token 和密码,CPA Codex 文件不写入注册密码。
10+
+ [文档] 更新 macOS、Ubuntu/Linux、NovaApi 与 CPA 自动投递和错误日志说明,补充 `codex-*.json` 验收步骤。
11+
312
## 1.0.4 - 2026-07-19
413

514
+ [新增] 将本地 Captcha Solver 完整源码并入主仓库 `captcha-solver/`,安装 GPTGrok2API 后不再需要额外克隆 `xai-grok-mass`。

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ GPTGrok2API 是一个自托管的 GPT 与 Grok 统一网关,将已接入的订
2626
| 本地 Captcha Solver | 源码内置于 `captcha-solver/`,使用 CloakBrowser/Chromium 处理 Grok Turnstile 实页校验,支持注册代理按请求透传,不需要额外克隆第二个仓库。 |
2727
| Checkout 提链 | 注册 Checkout 仅保留 UPI 最终支付链接提取;IN Checkout、Provider、Approve 共享同一 sticky 出口,VN Promotion 使用独立代理持续轮换重试。 |
2828
| 代理与稳定出口 | 支持全局代理、账号代理、代理配置、代理组、节点并发限制、故障反馈、备用出口、WARP、Privoxy、FlareSolverr 和 Clearance 刷新。 |
29-
| 外部系统接入 | 支持从 Sub2API、远程 CPA、本地 CPA 和 Access Token 导入账号;服务器部署提供 `gptgrok2api` Docker 网络别名。 |
29+
| 外部系统接入 | 支持从 Sub2API、远程 CPA、本地 CPA 和 Access Token 导入账号;OpenAI/Grok 注册后可自动投递 NovaApi 和 CPA;服务器部署提供 `gptgrok2api` Docker 网络别名。 |
3030
| 管理控制台 | 提供概览、GPT/Grok 账号管理、Grok Runtime、iCloud 邮箱、注册任务、Checkout 任务、代理管理、日志、实时监控、图片管理、调试中心和系统设置。 |
3131
| 存储与备份 | 账号数据支持 JSON、SQLite、PostgreSQL 和 Git 后端;图片支持本地与 WebDAV;备份支持本地归档和 R2。 |
3232
| 运维与发布 | 支持 Docker Compose、WARP 编排、内置 sidecar、Nginx HTTPS、运行日志、指标趋势、健康检查和 GitHub Releases 更新源。 |

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.4
1+
1.0.5

‎docs/AUTO_UPLOAD_SUB2API_CPA.md‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
| 注册目标 | 自动投递目标 | 投递内容 | 必要条件 |
88
| --- | --- | --- | --- |
99
| OpenAI | NovaApi(后台显示为 Sub2API) | OpenAI OAuth 账号 | 开启“注册成功后同步到 Sub2API” |
10+
| OpenAI | CPA / CLIProxyAPI | `codex-邮箱.json` OAuth 文件 | 开启“注册成功后上传到 CPA” |
1011
| Grok | NovaApi(后台显示为 Sub2API) | xAI OAuth 账号 | “注册后自动协议授权”成功,并开启“上传到 Sub2API” |
1112
| Grok | CPA / CLIProxyAPI | `xai-邮箱.json` OAuth 文件 | “注册后自动协议授权”成功,并开启“上传到 CPA” |
1213

1314
重要区别:
1415

15-
- OpenAI 注册目前只支持自动同步到 NovaApi,不会自动上传到 CPA。
16+
- OpenAI 的 NovaApi 和 CPA 两个目标可以同时开启,程序会分别投递。
1617
- Grok 注册成功只代表拿到 Grok 登录态;还要完成 xAI Device Code OAuth,才有可投递到 NovaApi/CPA 的 OAuth 凭据。
1718
- 所有远程投递都是 best-effort:远端失败时,本地注册结果和本地 OAuth 凭据仍会保留。
1819
- Grok 的 NovaApi 和 CPA 两个目标可以同时开启,程序会分别投递,一个失败不会阻止另一个。
@@ -206,11 +207,11 @@ https://nova.example.com
206207

207208
两台服务器之间也可以使用内网 IP,但要限制防火墙来源。不要把 PostgreSQL、Redis 或 NovaApi 管理接口无保护地开放到互联网。
208209

209-
## 5. OpenAI 注册后自动同步到 NovaApi
210+
## 5. OpenAI 注册后自动同步到 NovaApi 和 CPA
210211

211212
1. 进入“注册中心”。
212213
2. 注册目标选择 `OpenAI`。
213-
3. 展开“Sub2API 同步”。
214+
3. 展开“外部同步”。
214215
4. 开启“注册成功后同步到 Sub2API”。
215216
5. 选择刚添加的“作者 NovaApi”连接。
216217
6. 选择远端分组方式。
@@ -236,6 +237,21 @@ https://nova.example.com
236237
Sub2API 同步未成功,本地账号已保留
237238
```
238239

240+
需要同时上传 CPA 时:
241+
242+
1. 在同一个“外部同步”区域开启“注册成功后上传到 CPA”。
243+
2. 选择已保存的 CPA 连接。
244+
3. 保存后使用数量 `1`、并发 `1` 测试。
245+
246+
成功时日志包含:
247+
248+
```text
249+
正在上传账号到 CPA
250+
已上传到 CPA:主 CPA / codex-user@example.com.json
251+
```
252+
253+
CPA 文件使用 CLIProxyAPI 官方 Codex OAuth 格式,包含 `type: codex`、`email`、`account_id`、`access_token`、`refresh_token`、`id_token`、`expired` 和 `last_refresh`,不会写入注册密码。
254+
239255
## 6. 添加 CPA / CLIProxyAPI 连接
240256

241257
CPA 连接必须提供 CLIProxyAPI 兼容的远程管理接口:
@@ -314,7 +330,12 @@ xai-user@example.com.json
314330

315331
### 8.2 CPA
316332

317-
在 CPA/CLIProxyAPI 后台确认新增 `xai-*.json` 文件,或通过其管理接口检查:
333+
在 CPA/CLIProxyAPI 后台确认新增认证文件:
334+
335+
- OpenAI:`codex-*.json`。
336+
- Grok:`xai-*.json`。
337+
338+
也可以通过其管理接口检查:
318339

319340
```bash
320341
curl -sS http://127.0.0.1:8317/v0/management/auth-files \
@@ -365,8 +386,9 @@ docker inspect sub2api --format '{{.Config.Image}}'
365386

366387
- 确认注册目标确实是 OpenAI。
367388
- 确认“注册成功后同步到 Sub2API”已开启。
389+
- 如目标是 CPA,确认“注册成功后上传到 CPA”已开启并已选择 CPA 连接。
368390
- 检查是否选择连接和远端分组。
369-
- 查看注册日志中是否出现 OAuth refresh token 提取失败或 Sub2API HTTP 错误。
391+
- 查看注册日志中是否出现 OAuth refresh token 提取失败、Sub2API HTTP 错误或 CPA HTTP 错误。
370392

371393
同步失败不会回滚本地账号,需要修好连接后重新执行同步或重新测试一个账号。
372394

‎docs/BEGINNER_UBUNTU_SETUP.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ sudo systemctl restart captcha-solver
618618

619619
本系统支持:
620620

621-
- OpenAI 注册成功后自动同步到 NovaApi。
621+
- OpenAI 注册成功后自动同步到 NovaApi,并可同时上传到 CPA。
622622
- Grok 完成 xAI OAuth 协议授权后,同时或分别上传到 NovaApi 和 CPA。
623623

624624
Sub2API 必须优先使用项目作者魔改版 [AuuCoder/NovaApi](https://github.com/AuuCoder/NovaApi)。该仓库默认 Compose 仍可能引用普通上游镜像,因此不能只克隆仓库后直接启动。
@@ -627,7 +627,7 @@ Sub2API 必须优先使用项目作者魔改版 [AuuCoder/NovaApi](https://githu
627627

628628
- [自动上传到 NovaApi(Sub2API)与 CPA 配置手册](AUTO_UPLOAD_SUB2API_CPA.md)
629629

630-
首次必须使用数量 `1`、并发 `1` 验证。确认 NovaApi 中出现对应 `openai/xai` OAuth 账号、CPA 中出现 `xai-*.json` 后,再增加并发。
630+
首次必须使用数量 `1`、并发 `1` 验证。确认 NovaApi 中出现对应 `openai/xai` OAuth 账号、CPA 中出现 OpenAI 的 `codex-*.json` 或 Grok 的 `xai-*.json` 后,再增加并发。
631631

632632
## 19. 配置 Nginx 和 HTTPS
633633

@@ -906,7 +906,7 @@ sudo systemctl daemon-reload
906906
- [ ] 首个注册任务使用数量 `1`、并发 `1`。
907907
- [ ] 单任务成功后再增加并发。
908908
- [ ] 如启用 NovaApi 自动投递,实际镜像为作者魔改版且目标分组出现测试账号。
909-
- [ ] 如启用 CPA 自动投递,CPA 中出现测试账号对应的 `xai-*.json`。
909+
- [ ] 如启用 CPA 自动投递,CPA 中出现 OpenAI 的 `codex-*.json` 或 Grok 的 `xai-*.json`。
910910

911911
## 28. 快速命令表
912912

‎docs/TROUBLESHOOTING_LOG_EXAMPLES.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,11 +353,15 @@ docker inspect sub2api --format '{{.Config.Image}}'
353353
日志示例:
354354

355355
```text
356+
CPA 上传 OpenAI OAuth 文件失败(HTTP 401)
357+
CPA 上传 OpenAI OAuth 文件失败(HTTP 404)
356358
CPA 上传 xAI OAuth 文件失败(HTTP 401)
357359
CPA 上传 xAI OAuth 文件失败(HTTP 404)
358360
CPA 上传 xAI OAuth 文件请求失败
359361
```
360362

363+
OpenAI 会上传 `codex-*.json`,Grok 会上传 `xai-*.json`;两者使用同一个 CPA 管理接口。
364+
361365
判断:
362366

363367
- `401/403`:管理密钥错误。

‎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.0.4"
3+
version = "1.0.5"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.13"

‎services/account_service.py‎

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1711,6 +1711,45 @@ def refresh_accounts(
17111711

17121712
return result
17131713

1714+
def build_export_item(self, account: dict) -> dict[str, str] | None:
1715+
access_token = str(account.get("access_token") or "").strip()
1716+
refresh_token = str(account.get("refresh_token") or "").strip()
1717+
id_token = str(account.get("id_token") or "").strip()
1718+
if not access_token or not refresh_token or not id_token:
1719+
return None
1720+
1721+
access_payload = self._decode_jwt_payload(access_token)
1722+
id_payload = self._decode_jwt_payload(id_token)
1723+
auth_claim = access_payload.get("https://api.openai.com/auth")
1724+
auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
1725+
profile_claim = access_payload.get("https://api.openai.com/profile")
1726+
profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
1727+
1728+
email = (
1729+
str(account.get("email") or "").strip()
1730+
or str(profile_claim.get("email") or "").strip()
1731+
or str(id_payload.get("email") or "").strip()
1732+
)
1733+
account_id = (
1734+
str(account.get("account_id") or "").strip()
1735+
or str(auth_claim.get("chatgpt_account_id") or "").strip()
1736+
or str(account.get("user_id") or "").strip()
1737+
)
1738+
item = {
1739+
"type": str(account.get("export_type") or "codex"),
1740+
"email": email,
1741+
"account_id": account_id,
1742+
"access_token": access_token,
1743+
"refresh_token": refresh_token,
1744+
"id_token": id_token,
1745+
"expired": self._timestamp_to_iso(access_payload.get("exp")),
1746+
"last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
1747+
}
1748+
password = str(account.get("password") or "").strip()
1749+
if password:
1750+
item["password"] = password
1751+
return item
1752+
17141753
def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
17151754
target_tokens = set(token for token in (access_tokens or []) if token)
17161755
with self._lock:
@@ -1720,46 +1759,7 @@ def build_export_items(self, access_tokens: list[str] | None = None) -> list[dic
17201759
if not target_tokens or str(item.get("access_token") or "") in target_tokens
17211760
]
17221761

1723-
items: list[dict[str, str]] = []
1724-
for account in accounts:
1725-
access_token = str(account.get("access_token") or "").strip()
1726-
refresh_token = str(account.get("refresh_token") or "").strip()
1727-
id_token = str(account.get("id_token") or "").strip()
1728-
if not access_token or not refresh_token or not id_token:
1729-
continue
1730-
1731-
access_payload = self._decode_jwt_payload(access_token)
1732-
id_payload = self._decode_jwt_payload(id_token)
1733-
auth_claim = access_payload.get("https://api.openai.com/auth")
1734-
auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
1735-
profile_claim = access_payload.get("https://api.openai.com/profile")
1736-
profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
1737-
1738-
email = (
1739-
str(account.get("email") or "").strip()
1740-
or str(profile_claim.get("email") or "").strip()
1741-
or str(id_payload.get("email") or "").strip()
1742-
)
1743-
account_id = (
1744-
str(account.get("account_id") or "").strip()
1745-
or str(auth_claim.get("chatgpt_account_id") or "").strip()
1746-
or str(account.get("user_id") or "").strip()
1747-
)
1748-
item = {
1749-
"type": str(account.get("export_type") or "codex"),
1750-
"email": email,
1751-
"account_id": account_id,
1752-
"access_token": access_token,
1753-
"refresh_token": refresh_token,
1754-
"id_token": id_token,
1755-
"expired": self._timestamp_to_iso(access_payload.get("exp")),
1756-
"last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
1757-
}
1758-
password = str(account.get("password") or "").strip()
1759-
if password:
1760-
item["password"] = password
1761-
items.append(item)
1762-
return items
1762+
return [item for account in accounts if (item := self.build_export_item(account)) is not None]
17631763

17641764
def get_stats(self) -> dict:
17651765
with self._lock:

‎services/cpa_service.py‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121

2222
CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
23+
DEFAULT_CPA_DELIVERY_CONFIG = {"enabled": False, "pool_id": ""}
2324

2425

2526
def _new_id() -> str:
@@ -68,6 +69,17 @@ def _management_headers(secret_key: str) -> dict[str, str]:
6869
}
6970

7071

72+
def normalize_cpa_delivery_config(raw: object) -> dict[str, object]:
73+
source = raw if isinstance(raw, dict) else {}
74+
enabled = source.get("enabled")
75+
if isinstance(enabled, str):
76+
enabled = enabled.strip().lower() in {"1", "true", "yes", "on"}
77+
return {
78+
"enabled": bool(enabled),
79+
"pool_id": str(source.get("pool_id") or "").strip(),
80+
}
81+
82+
7183
class CPAConfig:
7284
def __init__(self, store_file: Path):
7385
self._store_file = store_file
@@ -245,16 +257,28 @@ def _xai_auth_file_payload(account: dict) -> dict:
245257
return payload
246258

247259

248-
def upload_xai_oauth_file(pool: dict, account: dict) -> dict:
249-
"""Upload one xAI OAuth JSON file through CLIProxyAPI's management API."""
260+
def _codex_auth_file_name(account: dict) -> str:
261+
identity = str(account.get("email") or account.get("account_id") or "oauth").strip()
262+
safe_identity = re.sub(r"[^A-Za-z0-9@._-]+", "-", identity).strip("-._")[:120] or "oauth"
263+
return f"codex-{safe_identity}.json"
264+
265+
266+
def _codex_auth_file_payload(account: dict) -> dict:
267+
payload = account_service.build_export_item(account)
268+
if payload is None:
269+
raise CPAUploadError("本地 OpenAI 账号缺少完整 OAuth 凭据,无法上传")
270+
payload.pop("password", None)
271+
payload["type"] = "codex"
272+
return payload
273+
274+
275+
def _upload_auth_file(pool: dict, file_name: str, payload: dict, provider_label: str) -> dict:
250276
pool_id = str(pool.get("id") or "").strip()
251277
base_url = str(pool.get("base_url") or "").strip()
252278
secret_key = str(pool.get("secret_key") or "").strip()
253279
if not pool_id or not base_url or not secret_key:
254280
raise CPAUploadError("CPA 连接不完整,请重新保存连接")
255281

256-
file_name = _xai_auth_file_name(account)
257-
payload = _xai_auth_file_payload(account)
258282
session = Session(**proxy_settings.build_session_kwargs(verify=True))
259283
try:
260284
response = session.post(
@@ -270,11 +294,11 @@ def upload_xai_oauth_file(pool: dict, account: dict) -> dict:
270294
timeout=30,
271295
)
272296
if not response.ok:
273-
raise CPAUploadError(f"CPA 上传 xAI OAuth 文件失败(HTTP {response.status_code})")
297+
raise CPAUploadError(f"CPA 上传 {provider_label} OAuth 文件失败(HTTP {response.status_code})")
274298
except CPAUploadError:
275299
raise
276300
except Exception as exc:
277-
raise CPAUploadError("CPA 上传 xAI OAuth 文件请求失败") from exc
301+
raise CPAUploadError(f"CPA 上传 {provider_label} OAuth 文件请求失败") from exc
278302
finally:
279303
session.close()
280304

@@ -286,6 +310,20 @@ def upload_xai_oauth_file(pool: dict, account: dict) -> dict:
286310
}
287311

288312

313+
def upload_xai_oauth_file(pool: dict, account: dict) -> dict:
314+
"""Upload one xAI OAuth JSON file through CLIProxyAPI's management API."""
315+
file_name = _xai_auth_file_name(account)
316+
payload = _xai_auth_file_payload(account)
317+
return _upload_auth_file(pool, file_name, payload, "xAI")
318+
319+
320+
def upload_openai_oauth_file(pool: dict, account: dict) -> dict:
321+
"""Upload one OpenAI Codex OAuth JSON file through CLIProxyAPI's management API."""
322+
file_name = _codex_auth_file_name(account)
323+
payload = _codex_auth_file_payload(account)
324+
return _upload_auth_file(pool, file_name, payload, "OpenAI")
325+
326+
289327
class CPAImportService:
290328
def __init__(self, cpa_config: CPAConfig):
291329
self._config = cpa_config

‎services/cpa_service_test.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,38 @@ def test_upload_error_does_not_expose_credentials_or_response_body(self) -> None
6767
self.assertNotIn("access-must-not-leak", message)
6868
self.assertNotIn("refresh-must-not-leak", message)
6969

70+
def test_upload_openai_uses_cliproxyapi_codex_contract(self) -> None:
71+
session = MagicMock()
72+
session.post.return_value = _Response(200)
73+
with patch("services.cpa_service.Session", return_value=session):
74+
result = cpa_service.upload_openai_oauth_file(self._pool(), self._account())
75+
76+
self.assertTrue(result["ok"])
77+
self.assertEqual(result["file_name"], "codex-person@example.test.json")
78+
file_name, content, content_type = session.post.call_args.kwargs["files"]["file"]
79+
self.assertEqual(file_name, "codex-person@example.test.json")
80+
self.assertEqual(content_type, "application/json")
81+
payload = json.loads(content)
82+
self.assertEqual(payload["type"], "codex")
83+
self.assertEqual(payload["email"], "person@example.test")
84+
self.assertEqual(payload["access_token"], "access-must-not-leak")
85+
self.assertEqual(payload["refresh_token"], "refresh-must-not-leak")
86+
self.assertEqual(payload["id_token"], "id-must-not-leak")
87+
self.assertNotIn("password", payload)
88+
89+
def test_upload_openai_requires_complete_oauth_credentials(self) -> None:
90+
account = self._account()
91+
account["id_token"] = ""
92+
93+
with self.assertRaisesRegex(cpa_service.CPAUploadError, "缺少完整 OAuth 凭据"):
94+
cpa_service.upload_openai_oauth_file(self._pool(), account)
95+
96+
def test_normalize_cpa_delivery_config(self) -> None:
97+
self.assertEqual(
98+
cpa_service.normalize_cpa_delivery_config({"enabled": "true", "pool_id": " pool-one "}),
99+
{"enabled": True, "pool_id": "pool-one"},
100+
)
101+
70102

71103
if __name__ == "__main__":
72104
unittest.main()

0 commit comments

Comments
 (0)