dsh-plugin-image-workbench 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1371 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/server/static/index.html +15 -0
  25. package/vendor/chatgpt2api/source/.env.example +82 -82
  26. package/vendor/chatgpt2api/source/.python-version +1 -1
  27. package/vendor/chatgpt2api/source/LICENSE +21 -21
  28. package/vendor/chatgpt2api/source/README.md +391 -391
  29. package/vendor/chatgpt2api/source/VERSION +1 -1
  30. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  31. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  32. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  33. package/vendor/chatgpt2api/source/api/app.py +62 -62
  34. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  35. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  36. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  37. package/vendor/chatgpt2api/source/api/support.py +134 -134
  38. package/vendor/chatgpt2api/source/api/system.py +365 -365
  39. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  40. package/vendor/chatgpt2api/source/config.default.json +108 -108
  41. package/vendor/chatgpt2api/source/main.py +9 -9
  42. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  43. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  44. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  45. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  46. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  47. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  48. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  49. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  50. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  51. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  52. package/vendor/chatgpt2api/source/services/config.py +667 -667
  53. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  54. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  55. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  56. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  57. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  58. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  59. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  60. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  61. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  62. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  63. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  64. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  65. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  66. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  67. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  68. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  69. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  70. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  75. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  76. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  77. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  78. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  79. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  80. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  81. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  82. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  83. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  84. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  85. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  86. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  87. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  88. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  89. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  90. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  91. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  92. package/vendor/chatgpt2api/source/uv.lock +904 -904
  93. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  94. package/vendor/chatgpt2api/source/web/components.json +24 -24
  95. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  96. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  97. package/vendor/chatgpt2api/source/web/package.json +58 -58
  98. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  100. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  107. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  108. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  109. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  112. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  113. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  114. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  115. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  116. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  117. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  118. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  133. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  134. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  135. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  136. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  137. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  138. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  139. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  140. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  141. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  142. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  143. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  144. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  145. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  146. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  147. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  149. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  150. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  151. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  152. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  153. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  154. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  155. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  156. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  157. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  158. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  159. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  160. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  161. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  162. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  163. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  164. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  165. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  166. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  167. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  168. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  169. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  170. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  171. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  172. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  173. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,531 +1,531 @@
1
- """Sub2API integration for browsing and importing ChatGPT OAuth accounts from a sub2api admin."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import threading
7
- import time
8
- import uuid
9
- from datetime import datetime, timezone
10
- from pathlib import Path
11
- from threading import Lock
12
-
13
- from curl_cffi.requests import Session
14
-
15
- from services.account_service import account_service
16
- from services.config import DATA_DIR
17
-
18
-
19
- SUB2API_CONFIG_FILE = DATA_DIR / "sub2api_config.json"
20
-
21
- # Cached JWT per server to avoid re-login on every list/import call.
22
- # Token lifetime on sub2api defaults to 24h; we refresh 5 min before expiry.
23
- _TOKEN_REFRESH_SKEW = 5 * 60
24
-
25
-
26
- def _new_id() -> str:
27
- return uuid.uuid4().hex[:12]
28
-
29
-
30
- def _now_iso() -> str:
31
- return datetime.now(timezone.utc).isoformat()
32
-
33
-
34
- def _clean(value: object) -> str:
35
- return str(value or "").strip()
36
-
37
-
38
- def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
39
- if not isinstance(raw, dict):
40
- return None
41
- status = _clean(raw.get("status")) or "failed"
42
- if fail_unfinished and status in {"pending", "running"}:
43
- status = "failed"
44
- return {
45
- "job_id": _clean(raw.get("job_id")) or uuid.uuid4().hex,
46
- "status": status,
47
- "created_at": _clean(raw.get("created_at")) or _now_iso(),
48
- "updated_at": _clean(raw.get("updated_at")) or _clean(raw.get("created_at")) or _now_iso(),
49
- "total": int(raw.get("total") or 0),
50
- "completed": int(raw.get("completed") or 0),
51
- "added": int(raw.get("added") or 0),
52
- "skipped": int(raw.get("skipped") or 0),
53
- "refreshed": int(raw.get("refreshed") or 0),
54
- "failed": int(raw.get("failed") or 0),
55
- "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
56
- }
57
-
58
-
59
- def _normalize_server(raw: dict) -> dict:
60
- return {
61
- "id": _clean(raw.get("id")) or _new_id(),
62
- "name": _clean(raw.get("name")),
63
- "base_url": _clean(raw.get("base_url")),
64
- "email": _clean(raw.get("email")),
65
- "password": _clean(raw.get("password")),
66
- "api_key": _clean(raw.get("api_key")),
67
- "group_id": _clean(raw.get("group_id")),
68
- "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
69
- }
70
-
71
-
72
- class Sub2APIConfig:
73
- def __init__(self, store_file: Path):
74
- self._store_file = store_file
75
- self._lock = Lock()
76
- self._servers: list[dict] = self._load()
77
-
78
- def _load(self) -> list[dict]:
79
- if not self._store_file.exists():
80
- return []
81
- try:
82
- raw = json.loads(self._store_file.read_text(encoding="utf-8"))
83
- if isinstance(raw, list):
84
- return [_normalize_server(item) for item in raw if isinstance(item, dict)]
85
- except Exception:
86
- pass
87
- return []
88
-
89
- def _save(self) -> None:
90
- self._store_file.parent.mkdir(parents=True, exist_ok=True)
91
- self._store_file.write_text(
92
- json.dumps(self._servers, ensure_ascii=False, indent=2) + "\n",
93
- encoding="utf-8",
94
- )
95
-
96
- def list_servers(self) -> list[dict]:
97
- with self._lock:
98
- return [dict(server) for server in self._servers]
99
-
100
- def get_server(self, server_id: str) -> dict | None:
101
- with self._lock:
102
- for server in self._servers:
103
- if server["id"] == server_id:
104
- return dict(server)
105
- return None
106
-
107
- def add_server(
108
- self,
109
- *,
110
- name: str,
111
- base_url: str,
112
- email: str,
113
- password: str,
114
- api_key: str,
115
- group_id: str = "",
116
- ) -> dict:
117
- server = _normalize_server({
118
- "id": _new_id(),
119
- "name": name,
120
- "base_url": base_url,
121
- "email": email,
122
- "password": password,
123
- "api_key": api_key,
124
- "group_id": group_id,
125
- })
126
- with self._lock:
127
- self._servers.append(server)
128
- self._save()
129
- _token_cache.pop(server["id"], None)
130
- return dict(server)
131
-
132
- def update_server(self, server_id: str, updates: dict) -> dict | None:
133
- with self._lock:
134
- for index, server in enumerate(self._servers):
135
- if server["id"] != server_id:
136
- continue
137
- merged = {**server, **{k: v for k, v in updates.items() if v is not None}, "id": server_id}
138
- self._servers[index] = _normalize_server(merged)
139
- self._save()
140
- result = dict(self._servers[index])
141
- break
142
- else:
143
- return None
144
- _token_cache.pop(server_id, None)
145
- return result
146
-
147
- def delete_server(self, server_id: str) -> bool:
148
- with self._lock:
149
- before = len(self._servers)
150
- self._servers = [server for server in self._servers if server["id"] != server_id]
151
- removed = len(self._servers) < before
152
- if removed:
153
- self._save()
154
- if removed:
155
- _token_cache.pop(server_id, None)
156
- return removed
157
-
158
- def set_import_job(self, server_id: str, import_job: dict | None) -> dict | None:
159
- with self._lock:
160
- for index, server in enumerate(self._servers):
161
- if server["id"] != server_id:
162
- continue
163
- next_server = dict(server)
164
- next_server["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
165
- self._servers[index] = next_server
166
- self._save()
167
- return dict(next_server)
168
- return None
169
-
170
- def get_import_job(self, server_id: str) -> dict | None:
171
- with self._lock:
172
- for server in self._servers:
173
- if server["id"] == server_id:
174
- job = server.get("import_job")
175
- return dict(job) if isinstance(job, dict) else None
176
- return None
177
-
178
-
179
- # Per-server cached access token: {server_id: (jwt, expires_at_epoch)}
180
- _token_cache: dict[str, tuple[str, float]] = {}
181
- _token_cache_lock = Lock()
182
-
183
-
184
- def _login(base_url: str, email: str, password: str) -> tuple[str, float]:
185
- url = f"{base_url.rstrip('/')}/api/v1/auth/login"
186
- session = Session(verify=True)
187
- try:
188
- response = session.post(
189
- url,
190
- json={"email": email, "password": password},
191
- headers={"Accept": "application/json", "Content-Type": "application/json"},
192
- timeout=30,
193
- )
194
- if not response.ok:
195
- raise RuntimeError(f"sub2api login failed: HTTP {response.status_code} {response.text[:200]}")
196
- payload = response.json()
197
- finally:
198
- session.close()
199
-
200
- body = _unwrap_envelope(payload)
201
- if not isinstance(body, dict):
202
- raise RuntimeError("sub2api login payload is invalid")
203
-
204
- token = _clean(body.get("access_token"))
205
- if not token:
206
- raise RuntimeError("sub2api login did not return access_token")
207
-
208
- expires_in = int(body.get("expires_in") or 3600)
209
- expires_at = time.time() + max(60, expires_in) - _TOKEN_REFRESH_SKEW
210
- return token, expires_at
211
-
212
-
213
- def _auth_headers(server: dict) -> dict[str, str]:
214
- api_key = _clean(server.get("api_key"))
215
- if api_key:
216
- return {"x-api-key": api_key, "Accept": "application/json"}
217
-
218
- email = _clean(server.get("email"))
219
- password = _clean(server.get("password"))
220
- if not email or not password:
221
- raise RuntimeError("sub2api server requires email+password or api_key")
222
-
223
- server_id = _clean(server.get("id"))
224
- base_url = _clean(server.get("base_url"))
225
-
226
- with _token_cache_lock:
227
- cached = _token_cache.get(server_id)
228
- if cached and cached[1] > time.time():
229
- return {"Authorization": f"Bearer {cached[0]}", "Accept": "application/json"}
230
-
231
- token, expires_at = _login(base_url, email, password)
232
- with _token_cache_lock:
233
- _token_cache[server_id] = (token, expires_at)
234
- return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
235
-
236
-
237
- def _extract_access_token(credentials: object) -> str:
238
- if not isinstance(credentials, dict):
239
- return ""
240
- for key in ("access_token", "accessToken", "token"):
241
- value = _clean(credentials.get(key))
242
- if value:
243
- return value
244
- return ""
245
-
246
-
247
- def _unwrap_envelope(payload: object) -> object:
248
- """Peel sub2api's `{code, message, data}` envelope, returning the inner `data` field
249
- when present. Also handles unwrapped responses from older/alt versions."""
250
- if isinstance(payload, dict) and "data" in payload and "code" in payload:
251
- return payload.get("data")
252
- return payload
253
-
254
-
255
- def _extract_paged_items(payload: object) -> tuple[list, int]:
256
- """Return (items, total) from a paginated sub2api response.
257
-
258
- Handles both the wrapped shape `{code,data:{items,total,...}}` and a few looser
259
- variants (`{data:[...]}`, `[...]`, `{items:[...],total:N}`)."""
260
- inner = _unwrap_envelope(payload)
261
- if isinstance(inner, list):
262
- return inner, len(inner)
263
- if isinstance(inner, dict):
264
- for key in ("items", "data", "list"):
265
- value = inner.get(key)
266
- if isinstance(value, list):
267
- return value, int(inner.get("total") or len(value))
268
- return [], 0
269
-
270
-
271
- def list_remote_accounts(server: dict) -> list[dict]:
272
- """Return a flat list of OpenAI OAuth accounts from a sub2api server."""
273
- base_url = _clean(server.get("base_url"))
274
- if not base_url:
275
- return []
276
-
277
- headers = _auth_headers(server)
278
- group_id = _clean(server.get("group_id"))
279
-
280
- session = Session(verify=True)
281
- items: list[dict] = []
282
- try:
283
- page = 1
284
- while True:
285
- params: dict[str, object] = {
286
- "platform": "openai",
287
- "type": "oauth",
288
- "page": page,
289
- "page_size": 200,
290
- }
291
- if group_id:
292
- params["group"] = group_id
293
- response = session.get(
294
- f"{base_url.rstrip('/')}/api/v1/admin/accounts",
295
- headers=headers,
296
- params=params,
297
- timeout=30,
298
- )
299
- if not response.ok:
300
- raise RuntimeError(f"sub2api list failed: HTTP {response.status_code} {response.text[:200]}")
301
- payload = response.json()
302
-
303
- data, total = _extract_paged_items(payload)
304
- if not data:
305
- break
306
-
307
- for account in data:
308
- if not isinstance(account, dict):
309
- continue
310
- credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
311
- account_id = account.get("id")
312
- if account_id is None:
313
- continue
314
- items.append({
315
- "id": str(account_id),
316
- "name": _clean(account.get("name")),
317
- "email": _clean(credentials.get("email")) or _clean(account.get("name")),
318
- "plan_type": _clean(credentials.get("plan_type")),
319
- "status": _clean(account.get("status")),
320
- "expires_at": _clean(credentials.get("expires_at")),
321
- "has_refresh_token": bool(_clean(credentials.get("refresh_token"))),
322
- })
323
-
324
- if page * 200 >= total or len(data) < 200:
325
- break
326
- page += 1
327
- finally:
328
- session.close()
329
-
330
- return items
331
-
332
-
333
- def list_remote_groups(server: dict) -> list[dict]:
334
- """Return OpenAI account groups from a sub2api server."""
335
- base_url = _clean(server.get("base_url"))
336
- if not base_url:
337
- return []
338
-
339
- headers = _auth_headers(server)
340
-
341
- session = Session(verify=True)
342
- items: list[dict] = []
343
- try:
344
- page = 1
345
- while True:
346
- response = session.get(
347
- f"{base_url.rstrip('/')}/api/v1/admin/groups",
348
- headers=headers,
349
- params={
350
- "page": page,
351
- "page_size": 200,
352
- },
353
- timeout=30,
354
- )
355
- if not response.ok:
356
- raise RuntimeError(f"sub2api groups failed: HTTP {response.status_code} {response.text[:200]}")
357
- payload = response.json()
358
-
359
- data, total = _extract_paged_items(payload)
360
- if not data:
361
- break
362
-
363
- for group in data:
364
- if not isinstance(group, dict):
365
- continue
366
- group_id = group.get("id")
367
- if group_id is None:
368
- continue
369
- items.append({
370
- "id": str(group_id),
371
- "name": _clean(group.get("name")),
372
- "description": _clean(group.get("description")),
373
- "platform": _clean(group.get("platform")),
374
- "status": _clean(group.get("status")),
375
- "account_count": int(group.get("account_count") or 0),
376
- "active_account_count": int(group.get("active_account_count") or 0),
377
- })
378
-
379
- if page * 200 >= total or len(data) < 200:
380
- break
381
- page += 1
382
- finally:
383
- session.close()
384
-
385
- return items
386
-
387
-
388
- def _fetch_access_tokens_for_accounts(server: dict, account_ids: list[str]) -> tuple[list[str], list[dict]]:
389
- """Return exported access tokens and per-account errors from sub2api."""
390
- base_url = _clean(server.get("base_url"))
391
- headers = _auth_headers(server)
392
- ids = [_clean(item) for item in account_ids if _clean(item)]
393
- if not ids:
394
- return [], []
395
-
396
- session = Session(verify=True)
397
- try:
398
- response = session.get(
399
- f"{base_url.rstrip('/')}/api/v1/admin/accounts/data",
400
- headers=headers,
401
- params={"ids": ",".join(ids), "timezone": "Asia/Shanghai"},
402
- timeout=30,
403
- )
404
- if not response.ok:
405
- raise RuntimeError(f"HTTP {response.status_code}")
406
- payload = response.json()
407
- finally:
408
- session.close()
409
-
410
- data = _unwrap_envelope(payload)
411
- accounts = data.get("accounts") if isinstance(data, dict) else None
412
- if not isinstance(accounts, list):
413
- raise RuntimeError("invalid export payload")
414
-
415
- tokens: list[str] = []
416
- errors: list[dict] = []
417
- for account in accounts:
418
- if not isinstance(account, dict):
419
- continue
420
- credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
421
- token = _extract_access_token(credentials)
422
- account_id = _clean(account.get("id")) or _clean(credentials.get("chatgpt_account_id")) or _clean(account.get("name"))
423
- if token:
424
- tokens.append(token)
425
- else:
426
- errors.append({"name": account_id or _clean(account.get("name")) or "unknown", "error": "missing access_token"})
427
-
428
- if len(accounts) < len(ids):
429
- errors.append({"name": ",".join(ids), "error": f"exported {len(accounts)}/{len(ids)} accounts"})
430
-
431
- return tokens, errors
432
-
433
-
434
- class Sub2APIImportService:
435
- def __init__(self, sub2api_config: Sub2APIConfig):
436
- self._config = sub2api_config
437
-
438
- def start_import(self, server: dict, account_ids: list[str]) -> dict:
439
- ids = [_clean(item) for item in account_ids if _clean(item)]
440
- if not ids:
441
- raise ValueError("account ids is required")
442
-
443
- server_id = _clean(server.get("id"))
444
- job = {
445
- "job_id": uuid.uuid4().hex,
446
- "status": "pending",
447
- "created_at": _now_iso(),
448
- "updated_at": _now_iso(),
449
- "total": len(ids),
450
- "completed": 0,
451
- "added": 0,
452
- "skipped": 0,
453
- "refreshed": 0,
454
- "failed": 0,
455
- "errors": [],
456
- }
457
- saved = self._config.set_import_job(server_id, job)
458
- if saved is None:
459
- raise ValueError("server not found")
460
-
461
- thread = threading.Thread(
462
- target=self._run_import,
463
- args=(server_id, server, ids),
464
- name=f"sub2api-import-{server_id}",
465
- daemon=True,
466
- )
467
- thread.start()
468
- return dict(saved.get("import_job") or job)
469
-
470
- def _update_job(self, server_id: str, **updates) -> None:
471
- current = self._config.get_import_job(server_id)
472
- if current is None:
473
- return
474
- next_job = {**current, **updates, "updated_at": _now_iso()}
475
- self._config.set_import_job(server_id, next_job)
476
-
477
- def _append_error(self, server_id: str, account_id: str, message: str) -> None:
478
- current = self._config.get_import_job(server_id)
479
- if current is None:
480
- return
481
- errors = list(current.get("errors") or [])
482
- errors.append({"name": account_id, "error": message})
483
- self._update_job(server_id, errors=errors, failed=len(errors))
484
-
485
- def _run_import(self, server_id: str, server: dict, account_ids: list[str]) -> None:
486
- self._update_job(server_id, status="running")
487
-
488
- try:
489
- tokens, errors = _fetch_access_tokens_for_accounts(server, account_ids)
490
- except Exception as exc:
491
- message = str(exc) or "unknown error"
492
- for account_id in account_ids:
493
- self._append_error(server_id, account_id, message)
494
- tokens = []
495
- else:
496
- for error in errors:
497
- self._append_error(server_id, _clean(error.get("name")), _clean(error.get("error")) or "unknown error")
498
-
499
- current = self._config.get_import_job(server_id) or {}
500
- self._update_job(
501
- server_id,
502
- completed=len(account_ids),
503
- failed=len(current.get("errors") or []),
504
- )
505
-
506
- if not tokens:
507
- current = self._config.get_import_job(server_id) or {}
508
- self._update_job(
509
- server_id,
510
- status="failed",
511
- completed=int(current.get("total") or 0),
512
- failed=len(current.get("errors") or []),
513
- )
514
- return
515
-
516
- add_result = account_service.add_accounts(tokens, source_type="codex")
517
- refresh_result = account_service.refresh_accounts(tokens)
518
- current = self._config.get_import_job(server_id) or {}
519
- self._update_job(
520
- server_id,
521
- status="completed",
522
- completed=len(account_ids),
523
- added=int(add_result.get("added") or 0),
524
- skipped=int(add_result.get("skipped") or 0),
525
- refreshed=int(refresh_result.get("refreshed") or 0),
526
- failed=len(current.get("errors") or []),
527
- )
528
-
529
-
530
- sub2api_config = Sub2APIConfig(SUB2API_CONFIG_FILE)
531
- sub2api_import_service = Sub2APIImportService(sub2api_config)
1
+ """Sub2API integration for browsing and importing ChatGPT OAuth accounts from a sub2api admin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from threading import Lock
12
+
13
+ from curl_cffi.requests import Session
14
+
15
+ from services.account_service import account_service
16
+ from services.config import DATA_DIR
17
+
18
+
19
+ SUB2API_CONFIG_FILE = DATA_DIR / "sub2api_config.json"
20
+
21
+ # Cached JWT per server to avoid re-login on every list/import call.
22
+ # Token lifetime on sub2api defaults to 24h; we refresh 5 min before expiry.
23
+ _TOKEN_REFRESH_SKEW = 5 * 60
24
+
25
+
26
+ def _new_id() -> str:
27
+ return uuid.uuid4().hex[:12]
28
+
29
+
30
+ def _now_iso() -> str:
31
+ return datetime.now(timezone.utc).isoformat()
32
+
33
+
34
+ def _clean(value: object) -> str:
35
+ return str(value or "").strip()
36
+
37
+
38
+ def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
39
+ if not isinstance(raw, dict):
40
+ return None
41
+ status = _clean(raw.get("status")) or "failed"
42
+ if fail_unfinished and status in {"pending", "running"}:
43
+ status = "failed"
44
+ return {
45
+ "job_id": _clean(raw.get("job_id")) or uuid.uuid4().hex,
46
+ "status": status,
47
+ "created_at": _clean(raw.get("created_at")) or _now_iso(),
48
+ "updated_at": _clean(raw.get("updated_at")) or _clean(raw.get("created_at")) or _now_iso(),
49
+ "total": int(raw.get("total") or 0),
50
+ "completed": int(raw.get("completed") or 0),
51
+ "added": int(raw.get("added") or 0),
52
+ "skipped": int(raw.get("skipped") or 0),
53
+ "refreshed": int(raw.get("refreshed") or 0),
54
+ "failed": int(raw.get("failed") or 0),
55
+ "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
56
+ }
57
+
58
+
59
+ def _normalize_server(raw: dict) -> dict:
60
+ return {
61
+ "id": _clean(raw.get("id")) or _new_id(),
62
+ "name": _clean(raw.get("name")),
63
+ "base_url": _clean(raw.get("base_url")),
64
+ "email": _clean(raw.get("email")),
65
+ "password": _clean(raw.get("password")),
66
+ "api_key": _clean(raw.get("api_key")),
67
+ "group_id": _clean(raw.get("group_id")),
68
+ "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
69
+ }
70
+
71
+
72
+ class Sub2APIConfig:
73
+ def __init__(self, store_file: Path):
74
+ self._store_file = store_file
75
+ self._lock = Lock()
76
+ self._servers: list[dict] = self._load()
77
+
78
+ def _load(self) -> list[dict]:
79
+ if not self._store_file.exists():
80
+ return []
81
+ try:
82
+ raw = json.loads(self._store_file.read_text(encoding="utf-8"))
83
+ if isinstance(raw, list):
84
+ return [_normalize_server(item) for item in raw if isinstance(item, dict)]
85
+ except Exception:
86
+ pass
87
+ return []
88
+
89
+ def _save(self) -> None:
90
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
91
+ self._store_file.write_text(
92
+ json.dumps(self._servers, ensure_ascii=False, indent=2) + "\n",
93
+ encoding="utf-8",
94
+ )
95
+
96
+ def list_servers(self) -> list[dict]:
97
+ with self._lock:
98
+ return [dict(server) for server in self._servers]
99
+
100
+ def get_server(self, server_id: str) -> dict | None:
101
+ with self._lock:
102
+ for server in self._servers:
103
+ if server["id"] == server_id:
104
+ return dict(server)
105
+ return None
106
+
107
+ def add_server(
108
+ self,
109
+ *,
110
+ name: str,
111
+ base_url: str,
112
+ email: str,
113
+ password: str,
114
+ api_key: str,
115
+ group_id: str = "",
116
+ ) -> dict:
117
+ server = _normalize_server({
118
+ "id": _new_id(),
119
+ "name": name,
120
+ "base_url": base_url,
121
+ "email": email,
122
+ "password": password,
123
+ "api_key": api_key,
124
+ "group_id": group_id,
125
+ })
126
+ with self._lock:
127
+ self._servers.append(server)
128
+ self._save()
129
+ _token_cache.pop(server["id"], None)
130
+ return dict(server)
131
+
132
+ def update_server(self, server_id: str, updates: dict) -> dict | None:
133
+ with self._lock:
134
+ for index, server in enumerate(self._servers):
135
+ if server["id"] != server_id:
136
+ continue
137
+ merged = {**server, **{k: v for k, v in updates.items() if v is not None}, "id": server_id}
138
+ self._servers[index] = _normalize_server(merged)
139
+ self._save()
140
+ result = dict(self._servers[index])
141
+ break
142
+ else:
143
+ return None
144
+ _token_cache.pop(server_id, None)
145
+ return result
146
+
147
+ def delete_server(self, server_id: str) -> bool:
148
+ with self._lock:
149
+ before = len(self._servers)
150
+ self._servers = [server for server in self._servers if server["id"] != server_id]
151
+ removed = len(self._servers) < before
152
+ if removed:
153
+ self._save()
154
+ if removed:
155
+ _token_cache.pop(server_id, None)
156
+ return removed
157
+
158
+ def set_import_job(self, server_id: str, import_job: dict | None) -> dict | None:
159
+ with self._lock:
160
+ for index, server in enumerate(self._servers):
161
+ if server["id"] != server_id:
162
+ continue
163
+ next_server = dict(server)
164
+ next_server["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
165
+ self._servers[index] = next_server
166
+ self._save()
167
+ return dict(next_server)
168
+ return None
169
+
170
+ def get_import_job(self, server_id: str) -> dict | None:
171
+ with self._lock:
172
+ for server in self._servers:
173
+ if server["id"] == server_id:
174
+ job = server.get("import_job")
175
+ return dict(job) if isinstance(job, dict) else None
176
+ return None
177
+
178
+
179
+ # Per-server cached access token: {server_id: (jwt, expires_at_epoch)}
180
+ _token_cache: dict[str, tuple[str, float]] = {}
181
+ _token_cache_lock = Lock()
182
+
183
+
184
+ def _login(base_url: str, email: str, password: str) -> tuple[str, float]:
185
+ url = f"{base_url.rstrip('/')}/api/v1/auth/login"
186
+ session = Session(verify=True)
187
+ try:
188
+ response = session.post(
189
+ url,
190
+ json={"email": email, "password": password},
191
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
192
+ timeout=30,
193
+ )
194
+ if not response.ok:
195
+ raise RuntimeError(f"sub2api login failed: HTTP {response.status_code} {response.text[:200]}")
196
+ payload = response.json()
197
+ finally:
198
+ session.close()
199
+
200
+ body = _unwrap_envelope(payload)
201
+ if not isinstance(body, dict):
202
+ raise RuntimeError("sub2api login payload is invalid")
203
+
204
+ token = _clean(body.get("access_token"))
205
+ if not token:
206
+ raise RuntimeError("sub2api login did not return access_token")
207
+
208
+ expires_in = int(body.get("expires_in") or 3600)
209
+ expires_at = time.time() + max(60, expires_in) - _TOKEN_REFRESH_SKEW
210
+ return token, expires_at
211
+
212
+
213
+ def _auth_headers(server: dict) -> dict[str, str]:
214
+ api_key = _clean(server.get("api_key"))
215
+ if api_key:
216
+ return {"x-api-key": api_key, "Accept": "application/json"}
217
+
218
+ email = _clean(server.get("email"))
219
+ password = _clean(server.get("password"))
220
+ if not email or not password:
221
+ raise RuntimeError("sub2api server requires email+password or api_key")
222
+
223
+ server_id = _clean(server.get("id"))
224
+ base_url = _clean(server.get("base_url"))
225
+
226
+ with _token_cache_lock:
227
+ cached = _token_cache.get(server_id)
228
+ if cached and cached[1] > time.time():
229
+ return {"Authorization": f"Bearer {cached[0]}", "Accept": "application/json"}
230
+
231
+ token, expires_at = _login(base_url, email, password)
232
+ with _token_cache_lock:
233
+ _token_cache[server_id] = (token, expires_at)
234
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
235
+
236
+
237
+ def _extract_access_token(credentials: object) -> str:
238
+ if not isinstance(credentials, dict):
239
+ return ""
240
+ for key in ("access_token", "accessToken", "token"):
241
+ value = _clean(credentials.get(key))
242
+ if value:
243
+ return value
244
+ return ""
245
+
246
+
247
+ def _unwrap_envelope(payload: object) -> object:
248
+ """Peel sub2api's `{code, message, data}` envelope, returning the inner `data` field
249
+ when present. Also handles unwrapped responses from older/alt versions."""
250
+ if isinstance(payload, dict) and "data" in payload and "code" in payload:
251
+ return payload.get("data")
252
+ return payload
253
+
254
+
255
+ def _extract_paged_items(payload: object) -> tuple[list, int]:
256
+ """Return (items, total) from a paginated sub2api response.
257
+
258
+ Handles both the wrapped shape `{code,data:{items,total,...}}` and a few looser
259
+ variants (`{data:[...]}`, `[...]`, `{items:[...],total:N}`)."""
260
+ inner = _unwrap_envelope(payload)
261
+ if isinstance(inner, list):
262
+ return inner, len(inner)
263
+ if isinstance(inner, dict):
264
+ for key in ("items", "data", "list"):
265
+ value = inner.get(key)
266
+ if isinstance(value, list):
267
+ return value, int(inner.get("total") or len(value))
268
+ return [], 0
269
+
270
+
271
+ def list_remote_accounts(server: dict) -> list[dict]:
272
+ """Return a flat list of OpenAI OAuth accounts from a sub2api server."""
273
+ base_url = _clean(server.get("base_url"))
274
+ if not base_url:
275
+ return []
276
+
277
+ headers = _auth_headers(server)
278
+ group_id = _clean(server.get("group_id"))
279
+
280
+ session = Session(verify=True)
281
+ items: list[dict] = []
282
+ try:
283
+ page = 1
284
+ while True:
285
+ params: dict[str, object] = {
286
+ "platform": "openai",
287
+ "type": "oauth",
288
+ "page": page,
289
+ "page_size": 200,
290
+ }
291
+ if group_id:
292
+ params["group"] = group_id
293
+ response = session.get(
294
+ f"{base_url.rstrip('/')}/api/v1/admin/accounts",
295
+ headers=headers,
296
+ params=params,
297
+ timeout=30,
298
+ )
299
+ if not response.ok:
300
+ raise RuntimeError(f"sub2api list failed: HTTP {response.status_code} {response.text[:200]}")
301
+ payload = response.json()
302
+
303
+ data, total = _extract_paged_items(payload)
304
+ if not data:
305
+ break
306
+
307
+ for account in data:
308
+ if not isinstance(account, dict):
309
+ continue
310
+ credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
311
+ account_id = account.get("id")
312
+ if account_id is None:
313
+ continue
314
+ items.append({
315
+ "id": str(account_id),
316
+ "name": _clean(account.get("name")),
317
+ "email": _clean(credentials.get("email")) or _clean(account.get("name")),
318
+ "plan_type": _clean(credentials.get("plan_type")),
319
+ "status": _clean(account.get("status")),
320
+ "expires_at": _clean(credentials.get("expires_at")),
321
+ "has_refresh_token": bool(_clean(credentials.get("refresh_token"))),
322
+ })
323
+
324
+ if page * 200 >= total or len(data) < 200:
325
+ break
326
+ page += 1
327
+ finally:
328
+ session.close()
329
+
330
+ return items
331
+
332
+
333
+ def list_remote_groups(server: dict) -> list[dict]:
334
+ """Return OpenAI account groups from a sub2api server."""
335
+ base_url = _clean(server.get("base_url"))
336
+ if not base_url:
337
+ return []
338
+
339
+ headers = _auth_headers(server)
340
+
341
+ session = Session(verify=True)
342
+ items: list[dict] = []
343
+ try:
344
+ page = 1
345
+ while True:
346
+ response = session.get(
347
+ f"{base_url.rstrip('/')}/api/v1/admin/groups",
348
+ headers=headers,
349
+ params={
350
+ "page": page,
351
+ "page_size": 200,
352
+ },
353
+ timeout=30,
354
+ )
355
+ if not response.ok:
356
+ raise RuntimeError(f"sub2api groups failed: HTTP {response.status_code} {response.text[:200]}")
357
+ payload = response.json()
358
+
359
+ data, total = _extract_paged_items(payload)
360
+ if not data:
361
+ break
362
+
363
+ for group in data:
364
+ if not isinstance(group, dict):
365
+ continue
366
+ group_id = group.get("id")
367
+ if group_id is None:
368
+ continue
369
+ items.append({
370
+ "id": str(group_id),
371
+ "name": _clean(group.get("name")),
372
+ "description": _clean(group.get("description")),
373
+ "platform": _clean(group.get("platform")),
374
+ "status": _clean(group.get("status")),
375
+ "account_count": int(group.get("account_count") or 0),
376
+ "active_account_count": int(group.get("active_account_count") or 0),
377
+ })
378
+
379
+ if page * 200 >= total or len(data) < 200:
380
+ break
381
+ page += 1
382
+ finally:
383
+ session.close()
384
+
385
+ return items
386
+
387
+
388
+ def _fetch_access_tokens_for_accounts(server: dict, account_ids: list[str]) -> tuple[list[str], list[dict]]:
389
+ """Return exported access tokens and per-account errors from sub2api."""
390
+ base_url = _clean(server.get("base_url"))
391
+ headers = _auth_headers(server)
392
+ ids = [_clean(item) for item in account_ids if _clean(item)]
393
+ if not ids:
394
+ return [], []
395
+
396
+ session = Session(verify=True)
397
+ try:
398
+ response = session.get(
399
+ f"{base_url.rstrip('/')}/api/v1/admin/accounts/data",
400
+ headers=headers,
401
+ params={"ids": ",".join(ids), "timezone": "Asia/Shanghai"},
402
+ timeout=30,
403
+ )
404
+ if not response.ok:
405
+ raise RuntimeError(f"HTTP {response.status_code}")
406
+ payload = response.json()
407
+ finally:
408
+ session.close()
409
+
410
+ data = _unwrap_envelope(payload)
411
+ accounts = data.get("accounts") if isinstance(data, dict) else None
412
+ if not isinstance(accounts, list):
413
+ raise RuntimeError("invalid export payload")
414
+
415
+ tokens: list[str] = []
416
+ errors: list[dict] = []
417
+ for account in accounts:
418
+ if not isinstance(account, dict):
419
+ continue
420
+ credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
421
+ token = _extract_access_token(credentials)
422
+ account_id = _clean(account.get("id")) or _clean(credentials.get("chatgpt_account_id")) or _clean(account.get("name"))
423
+ if token:
424
+ tokens.append(token)
425
+ else:
426
+ errors.append({"name": account_id or _clean(account.get("name")) or "unknown", "error": "missing access_token"})
427
+
428
+ if len(accounts) < len(ids):
429
+ errors.append({"name": ",".join(ids), "error": f"exported {len(accounts)}/{len(ids)} accounts"})
430
+
431
+ return tokens, errors
432
+
433
+
434
+ class Sub2APIImportService:
435
+ def __init__(self, sub2api_config: Sub2APIConfig):
436
+ self._config = sub2api_config
437
+
438
+ def start_import(self, server: dict, account_ids: list[str]) -> dict:
439
+ ids = [_clean(item) for item in account_ids if _clean(item)]
440
+ if not ids:
441
+ raise ValueError("account ids is required")
442
+
443
+ server_id = _clean(server.get("id"))
444
+ job = {
445
+ "job_id": uuid.uuid4().hex,
446
+ "status": "pending",
447
+ "created_at": _now_iso(),
448
+ "updated_at": _now_iso(),
449
+ "total": len(ids),
450
+ "completed": 0,
451
+ "added": 0,
452
+ "skipped": 0,
453
+ "refreshed": 0,
454
+ "failed": 0,
455
+ "errors": [],
456
+ }
457
+ saved = self._config.set_import_job(server_id, job)
458
+ if saved is None:
459
+ raise ValueError("server not found")
460
+
461
+ thread = threading.Thread(
462
+ target=self._run_import,
463
+ args=(server_id, server, ids),
464
+ name=f"sub2api-import-{server_id}",
465
+ daemon=True,
466
+ )
467
+ thread.start()
468
+ return dict(saved.get("import_job") or job)
469
+
470
+ def _update_job(self, server_id: str, **updates) -> None:
471
+ current = self._config.get_import_job(server_id)
472
+ if current is None:
473
+ return
474
+ next_job = {**current, **updates, "updated_at": _now_iso()}
475
+ self._config.set_import_job(server_id, next_job)
476
+
477
+ def _append_error(self, server_id: str, account_id: str, message: str) -> None:
478
+ current = self._config.get_import_job(server_id)
479
+ if current is None:
480
+ return
481
+ errors = list(current.get("errors") or [])
482
+ errors.append({"name": account_id, "error": message})
483
+ self._update_job(server_id, errors=errors, failed=len(errors))
484
+
485
+ def _run_import(self, server_id: str, server: dict, account_ids: list[str]) -> None:
486
+ self._update_job(server_id, status="running")
487
+
488
+ try:
489
+ tokens, errors = _fetch_access_tokens_for_accounts(server, account_ids)
490
+ except Exception as exc:
491
+ message = str(exc) or "unknown error"
492
+ for account_id in account_ids:
493
+ self._append_error(server_id, account_id, message)
494
+ tokens = []
495
+ else:
496
+ for error in errors:
497
+ self._append_error(server_id, _clean(error.get("name")), _clean(error.get("error")) or "unknown error")
498
+
499
+ current = self._config.get_import_job(server_id) or {}
500
+ self._update_job(
501
+ server_id,
502
+ completed=len(account_ids),
503
+ failed=len(current.get("errors") or []),
504
+ )
505
+
506
+ if not tokens:
507
+ current = self._config.get_import_job(server_id) or {}
508
+ self._update_job(
509
+ server_id,
510
+ status="failed",
511
+ completed=int(current.get("total") or 0),
512
+ failed=len(current.get("errors") or []),
513
+ )
514
+ return
515
+
516
+ add_result = account_service.add_accounts(tokens, source_type="codex")
517
+ refresh_result = account_service.refresh_accounts(tokens)
518
+ current = self._config.get_import_job(server_id) or {}
519
+ self._update_job(
520
+ server_id,
521
+ status="completed",
522
+ completed=len(account_ids),
523
+ added=int(add_result.get("added") or 0),
524
+ skipped=int(add_result.get("skipped") or 0),
525
+ refreshed=int(refresh_result.get("refreshed") or 0),
526
+ failed=len(current.get("errors") or []),
527
+ )
528
+
529
+
530
+ sub2api_config = Sub2APIConfig(SUB2API_CONFIG_FILE)
531
+ sub2api_import_service = Sub2APIImportService(sub2api_config)