dsh-plugin-image-workbench 0.0.0-stage → 1.1.5
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.
- package/LICENSE +21 -0
- package/README.en.md +256 -0
- package/README.md +256 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +641 -0
- package/install.mjs +387 -0
- package/package.json +57 -4
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +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)
|
|
File without changes
|