dsh-plugin-image-workbench 1.1.5 → 1.1.6

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 (172) 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 +1329 -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/vendor/chatgpt2api/source/.env.example +82 -82
  25. package/vendor/chatgpt2api/source/.python-version +1 -1
  26. package/vendor/chatgpt2api/source/LICENSE +21 -21
  27. package/vendor/chatgpt2api/source/README.md +391 -391
  28. package/vendor/chatgpt2api/source/VERSION +1 -1
  29. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  30. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  31. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  32. package/vendor/chatgpt2api/source/api/app.py +62 -62
  33. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  34. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  35. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  36. package/vendor/chatgpt2api/source/api/support.py +134 -134
  37. package/vendor/chatgpt2api/source/api/system.py +365 -365
  38. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  39. package/vendor/chatgpt2api/source/config.default.json +108 -108
  40. package/vendor/chatgpt2api/source/main.py +9 -9
  41. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  42. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  43. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  44. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  45. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  46. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  47. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  48. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  49. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  50. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  51. package/vendor/chatgpt2api/source/services/config.py +667 -667
  52. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  53. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  54. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  55. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  56. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  57. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  58. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  59. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  60. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  61. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  62. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  63. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  64. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  65. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  66. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  67. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  68. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  69. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  70. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  75. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  76. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  77. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  78. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  79. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  80. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  81. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  82. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  83. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  84. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  85. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  86. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  87. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  88. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  89. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  90. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  91. package/vendor/chatgpt2api/source/uv.lock +904 -904
  92. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  93. package/vendor/chatgpt2api/source/web/components.json +24 -24
  94. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  95. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  96. package/vendor/chatgpt2api/source/web/package.json +58 -58
  97. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  98. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  100. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  107. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  108. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  109. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  112. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  113. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  114. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  115. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  116. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  117. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  118. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  132. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  133. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  134. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  135. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  136. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  137. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  138. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  139. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  140. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  141. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  142. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  143. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  144. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  145. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  146. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  147. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  149. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  150. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  151. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  152. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  153. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  154. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  155. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  156. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  157. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  158. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  159. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  160. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  161. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  162. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  163. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  164. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  165. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  166. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  167. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  168. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  169. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  170. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  171. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  172. 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)