ohmyapi commited on
Commit
bdc62c7
·
1 Parent(s): 59f5e44

Deploy emergent2api

Browse files
README.md CHANGED
@@ -7,7 +7,3 @@ sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
-
11
- # Emergent2API v0.2.0
12
-
13
- OpenAI/Anthropic-compatible API gateway backed by Emergent.sh account pool.
 
7
  app_port: 7860
8
  pinned: false
9
  ---
 
 
 
 
emergent2api/app.py CHANGED
@@ -4,15 +4,17 @@ from __future__ import annotations
4
  import json
5
  import logging
6
  import os
 
7
 
8
  from fastapi import FastAPI, Request
9
  from fastapi.middleware.cors import CORSMiddleware
10
- from fastapi.responses import JSONResponse
11
 
12
  from .config import settings
13
  from . import database as db
14
  from .pool import pool
15
  from .routes import openai, anthropic, responses
 
16
 
17
  logging.basicConfig(
18
  level=logging.INFO,
@@ -43,11 +45,16 @@ app.add_middleware(
43
  async def auth_middleware(request: Request, call_next):
44
  path = request.url.path
45
 
46
- # Skip auth for health check and docs
47
- if path in ("/", "/health", "/docs", "/openapi.json", "/redoc"):
 
 
 
 
 
48
  return await call_next(request)
49
 
50
- # Check API key
51
  auth_header = request.headers.get("authorization", "")
52
  api_key = request.headers.get("x-api-key", "")
53
 
@@ -94,6 +101,9 @@ async def shutdown():
94
  app.include_router(openai.router)
95
  app.include_router(anthropic.router)
96
  app.include_router(responses.router)
 
 
 
97
 
98
 
99
  @app.get("/")
@@ -101,7 +111,7 @@ async def root():
101
  count = await pool.count()
102
  return {
103
  "service": "Emergent2API",
104
- "version": "0.2.0",
105
  "backend": settings.backend,
106
  "accounts": count,
107
  "endpoints": {
@@ -111,87 +121,35 @@ async def root():
111
  "POST /v1/responses (OpenAI Response API)",
112
  "GET /v1/models",
113
  ],
114
- "admin": [
115
- "GET /admin/accounts",
116
- "POST /admin/accounts",
117
- "POST /admin/accounts/import",
118
- "POST /admin/accounts/import-jsonl",
119
- "DELETE /admin/accounts/{id}",
120
- "POST /admin/accounts/{id}/toggle",
121
- "POST /admin/accounts/{id}/refresh",
122
- "POST /admin/accounts/refresh-all",
123
- ],
124
  },
125
  }
126
 
127
 
128
- @app.get("/health")
129
- async def health():
130
- count = await pool.count()
131
- return {"status": "ok", "accounts": count}
132
-
133
-
134
- # ---------------------------------------------------------------------------
135
- # Admin endpoints
136
- # ---------------------------------------------------------------------------
137
-
138
- @app.get("/admin/accounts")
139
- async def admin_list_accounts(active_only: bool = False):
140
- """List all accounts or only active ones."""
141
- if active_only:
142
- accounts = await db.get_active_accounts()
143
- else:
144
- accounts = await db.get_all_accounts()
145
- return {
146
- "total": len(accounts),
147
- "accounts": [
148
- {
149
- "id": a["id"],
150
- "email": a["email"],
151
- "balance": a["balance"],
152
- "is_active": a["is_active"],
153
- "last_used": a["last_used"].isoformat() if a["last_used"] else None,
154
- "created_at": a["created_at"].isoformat() if a["created_at"] else None,
155
- }
156
- for a in accounts
157
- ],
158
- }
159
 
160
 
161
- @app.post("/admin/accounts")
162
- async def admin_add_account(request: Request):
163
- """Add an account manually. Body: {email, password, jwt, refresh_token?, user_id?, balance?}"""
164
- body = await request.json()
165
- required = ["email", "password", "jwt"]
166
- for f in required:
167
- if f not in body:
168
- return JSONResponse(status_code=400, content={"error": f"Missing required field: {f}"})
169
- account_id = await db.upsert_account(body)
170
- return {"id": account_id, "email": body["email"], "status": "added"}
171
 
172
 
173
- @app.post("/admin/accounts/import")
174
- async def admin_import_accounts(request: Request):
175
- """Import accounts from JSON body. Body: {accounts: [{email, password, jwt, ...}, ...]}"""
176
- body = await request.json()
177
- accounts = body.get("accounts", [])
178
- imported = 0
179
- errors = []
180
- for acct in accounts:
181
- if "email" in acct and "password" in acct and "jwt" in acct:
182
- try:
183
- await db.upsert_account(acct)
184
- imported += 1
185
- except Exception as e:
186
- errors.append({"email": acct.get("email"), "error": str(e)})
187
- else:
188
- errors.append({"email": acct.get("email", "?"), "error": "missing email/password/jwt"})
189
- return {"imported": imported, "total_submitted": len(accounts), "errors": errors}
190
 
191
 
 
192
  @app.post("/admin/accounts/import-jsonl")
193
  async def admin_import_jsonl(request: Request):
194
- """Import accounts from JSONL text. Body: {jsonl: "line1\\nline2\\n..."} or raw JSONL body."""
195
  content_type = request.headers.get("content-type", "")
196
  if "application/json" in content_type:
197
  body = await request.json()
@@ -220,46 +178,6 @@ async def admin_import_jsonl(request: Request):
220
  return {"imported": imported, "total_lines": len(lines), "errors": errors}
221
 
222
 
223
- @app.delete("/admin/accounts/{account_id}")
224
- async def admin_delete_account(account_id: int):
225
- """Permanently delete an account."""
226
- await db.delete_account(account_id)
227
- return {"id": account_id, "status": "deleted"}
228
-
229
-
230
- @app.post("/admin/accounts/{account_id}/toggle")
231
- async def admin_toggle_account(account_id: int):
232
- """Toggle active/inactive status."""
233
- new_state = await db.toggle_account(account_id)
234
- return {"id": account_id, "is_active": new_state}
235
-
236
-
237
- @app.post("/admin/accounts/{account_id}/refresh")
238
- async def admin_refresh_jwt(account_id: int):
239
- """Refresh JWT for a specific account."""
240
- account = await db.get_account_by_id(account_id)
241
- if not account:
242
- return JSONResponse(status_code=404, content={"error": "Account not found"})
243
- new_jwt = await pool.refresh_jwt(account)
244
- if new_jwt:
245
- return {"id": account_id, "email": account["email"], "status": "refreshed"}
246
- return JSONResponse(status_code=500, content={"error": "JWT refresh failed"})
247
-
248
-
249
- @app.post("/admin/accounts/refresh-all")
250
- async def admin_refresh_all():
251
- """Refresh JWTs for all active accounts."""
252
- accounts = await db.get_active_accounts()
253
- results = {"refreshed": 0, "failed": 0, "total": len(accounts)}
254
- for acct in accounts:
255
- new_jwt = await pool.refresh_jwt(acct)
256
- if new_jwt:
257
- results["refreshed"] += 1
258
- else:
259
- results["failed"] += 1
260
- return results
261
-
262
-
263
  # ---------------------------------------------------------------------------
264
  # Entrypoint
265
  # ---------------------------------------------------------------------------
 
4
  import json
5
  import logging
6
  import os
7
+ import pathlib
8
 
9
  from fastapi import FastAPI, Request
10
  from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import FileResponse, JSONResponse
12
 
13
  from .config import settings
14
  from . import database as db
15
  from .pool import pool
16
  from .routes import openai, anthropic, responses
17
+ from .routes.admin import router as admin_router
18
 
19
  logging.basicConfig(
20
  level=logging.INFO,
 
45
  async def auth_middleware(request: Request, call_next):
46
  path = request.url.path
47
 
48
+ # Skip auth for health, docs, admin panel pages, and admin API (has its own cookie auth)
49
+ if (
50
+ path in ("/", "/health", "/docs", "/openapi.json", "/redoc")
51
+ or path.startswith("/admin")
52
+ or path.startswith("/v1/admin")
53
+ or path.startswith("/static")
54
+ ):
55
  return await call_next(request)
56
 
57
+ # Check API key for /v1/* API routes
58
  auth_header = request.headers.get("authorization", "")
59
  api_key = request.headers.get("x-api-key", "")
60
 
 
101
  app.include_router(openai.router)
102
  app.include_router(anthropic.router)
103
  app.include_router(responses.router)
104
+ app.include_router(admin_router)
105
+
106
+ _STATIC_DIR = pathlib.Path(__file__).parent / "static" / "admin"
107
 
108
 
109
  @app.get("/")
 
111
  count = await pool.count()
112
  return {
113
  "service": "Emergent2API",
114
+ "version": "0.3.0",
115
  "backend": settings.backend,
116
  "accounts": count,
117
  "endpoints": {
 
121
  "POST /v1/responses (OpenAI Response API)",
122
  "GET /v1/models",
123
  ],
124
+ "admin": "/admin/",
 
 
 
 
 
 
 
 
 
125
  },
126
  }
127
 
128
 
129
+ @app.get("/admin")
130
+ @app.get("/admin/")
131
+ async def admin_index():
132
+ return FileResponse(_STATIC_DIR / "login.html")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
 
135
+ @app.get("/admin/{page}")
136
+ async def admin_page(page: str):
137
+ html = _STATIC_DIR / f"{page}.html"
138
+ if html.is_file():
139
+ return FileResponse(html)
140
+ return FileResponse(_STATIC_DIR / "login.html")
 
 
 
 
141
 
142
 
143
+ @app.get("/health")
144
+ async def health():
145
+ count = await pool.count()
146
+ return {"status": "ok", "accounts": count}
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
 
149
+ # Legacy import endpoint (backward compat for CI push)
150
  @app.post("/admin/accounts/import-jsonl")
151
  async def admin_import_jsonl(request: Request):
152
+ """Import accounts from JSONL text. Body: {jsonl: "..."} or raw JSONL body."""
153
  content_type = request.headers.get("content-type", "")
154
  if "application/json" in content_type:
155
  body = await request.json()
 
178
  return {"imported": imported, "total_lines": len(lines), "errors": errors}
179
 
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  # ---------------------------------------------------------------------------
182
  # Entrypoint
183
  # ---------------------------------------------------------------------------
emergent2api/config.py CHANGED
@@ -17,6 +17,7 @@ class Settings:
17
  "postgresql://neondb_owner:npg_cyGIuK58kePT@ep-muddy-sky-adych24h-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require",
18
  ))
19
  port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8000")))
 
20
 
21
  # Supabase constants
22
  supabase_anon_key: str = (
 
17
  "postgresql://neondb_owner:npg_cyGIuK58kePT@ep-muddy-sky-adych24h-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require",
18
  ))
19
  port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8000")))
20
+ admin_password: str = field(default_factory=lambda: os.environ.get("ADMIN_PASSWORD", "bk@3fd3E"))
21
 
22
  # Supabase constants
23
  supabase_anon_key: str = (
emergent2api/database.py CHANGED
@@ -149,6 +149,62 @@ async def toggle_account(account_id: int) -> bool:
149
  return row["is_active"] if row else False
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  async def get_account_count() -> dict[str, int]:
153
  pool = await get_pool()
154
  async with pool.acquire() as conn:
 
149
  return row["is_active"] if row else False
150
 
151
 
152
+ async def batch_delete(account_ids: list[int]) -> int:
153
+ pool = await get_pool()
154
+ async with pool.acquire() as conn:
155
+ result = await conn.execute(
156
+ "DELETE FROM emergent_accounts WHERE id = ANY($1::int[])", account_ids
157
+ )
158
+ return int(result.split()[-1])
159
+
160
+
161
+ async def batch_toggle(account_ids: list[int], active: bool) -> int:
162
+ pool = await get_pool()
163
+ async with pool.acquire() as conn:
164
+ result = await conn.execute(
165
+ "UPDATE emergent_accounts SET is_active = $2, updated_at = NOW() WHERE id = ANY($1::int[])",
166
+ account_ids, active,
167
+ )
168
+ return int(result.split()[-1])
169
+
170
+
171
+ async def delete_inactive() -> int:
172
+ pool = await get_pool()
173
+ async with pool.acquire() as conn:
174
+ result = await conn.execute("DELETE FROM emergent_accounts WHERE is_active = FALSE")
175
+ return int(result.split()[-1])
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Config KV store
180
+ # ---------------------------------------------------------------------------
181
+
182
+ async def get_config(key: str) -> Optional[str]:
183
+ pool = await get_pool()
184
+ async with pool.acquire() as conn:
185
+ return await conn.fetchval(
186
+ "SELECT value FROM emergent_config WHERE key = $1", key
187
+ )
188
+
189
+
190
+ async def set_config(key: str, value: str) -> None:
191
+ pool = await get_pool()
192
+ async with pool.acquire() as conn:
193
+ await conn.execute(
194
+ """INSERT INTO emergent_config (key, value, updated_at)
195
+ VALUES ($1, $2, NOW())
196
+ ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()""",
197
+ key, value,
198
+ )
199
+
200
+
201
+ async def get_all_config() -> dict[str, str]:
202
+ pool = await get_pool()
203
+ async with pool.acquire() as conn:
204
+ rows = await conn.fetch("SELECT key, value FROM emergent_config ORDER BY key")
205
+ return {r["key"]: r["value"] for r in rows}
206
+
207
+
208
  async def get_account_count() -> dict[str, int]:
209
  pool = await get_pool()
210
  async with pool.acquire() as conn:
emergent2api/routes/admin.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin API routes: login, token CRUD, import/export, connectivity test, config."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import json
6
+ import logging
7
+ import secrets
8
+ import zipfile
9
+ from typing import Any
10
+
11
+ from curl_cffi import requests as cffi_requests
12
+ from fastapi import APIRouter, Request, Response
13
+ from fastapi.responses import JSONResponse, StreamingResponse
14
+
15
+ from ..config import settings
16
+ from .. import database as db
17
+ from ..pool import pool
18
+
19
+ logger = logging.getLogger("emergent2api.admin")
20
+
21
+ router = APIRouter(prefix="/v1/admin", tags=["admin"])
22
+
23
+ COOKIE_NAME = "emergent_admin_session"
24
+ _sessions: set[str] = set()
25
+
26
+
27
+ def _check_session(request: Request) -> bool:
28
+ token = request.cookies.get(COOKIE_NAME, "")
29
+ return token in _sessions and bool(token)
30
+
31
+
32
+ def _require_auth(request: Request) -> JSONResponse | None:
33
+ if not _check_session(request):
34
+ return JSONResponse(status_code=401, content={"error": "Not authenticated"})
35
+ return None
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Auth
40
+ # ---------------------------------------------------------------------------
41
+
42
+ @router.post("/login")
43
+ async def login(request: Request):
44
+ body = await request.json()
45
+ pwd = body.get("password", "")
46
+ db_pwd = await db.get_config("admin_password")
47
+ expected = db_pwd or settings.admin_password
48
+ if pwd != expected:
49
+ return JSONResponse(status_code=403, content={"error": "Invalid password"})
50
+ token = secrets.token_urlsafe(32)
51
+ _sessions.add(token)
52
+ resp = JSONResponse(content={"ok": True})
53
+ resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", max_age=86400 * 7)
54
+ return resp
55
+
56
+
57
+ @router.post("/logout")
58
+ async def logout(request: Request):
59
+ token = request.cookies.get(COOKIE_NAME, "")
60
+ _sessions.discard(token)
61
+ resp = JSONResponse(content={"ok": True})
62
+ resp.delete_cookie(COOKIE_NAME)
63
+ return resp
64
+
65
+
66
+ @router.get("/verify")
67
+ async def verify(request: Request):
68
+ if _check_session(request):
69
+ return {"authenticated": True}
70
+ return JSONResponse(status_code=401, content={"authenticated": False})
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Token (account) management
75
+ # ---------------------------------------------------------------------------
76
+
77
+ @router.get("/tokens")
78
+ async def list_tokens(request: Request):
79
+ err = _require_auth(request)
80
+ if err:
81
+ return err
82
+ accounts = await db.get_all_accounts()
83
+ return {
84
+ "total": len(accounts),
85
+ "active": sum(1 for a in accounts if a["is_active"]),
86
+ "tokens": [
87
+ {
88
+ "id": a["id"],
89
+ "email": a["email"],
90
+ "balance": a["balance"],
91
+ "is_active": a["is_active"],
92
+ "last_used": a["last_used"].isoformat() if a["last_used"] else None,
93
+ "created_at": a["created_at"].isoformat() if a["created_at"] else None,
94
+ }
95
+ for a in accounts
96
+ ],
97
+ }
98
+
99
+
100
+ @router.post("/tokens/import")
101
+ async def import_tokens(request: Request):
102
+ """Import accounts from JSONL text: {jsonl: "..."}"""
103
+ err = _require_auth(request)
104
+ if err:
105
+ return err
106
+ body = await request.json()
107
+ raw = body.get("jsonl", "")
108
+ lines = raw.strip().splitlines()
109
+ imported, errors = 0, []
110
+ for i, line in enumerate(lines):
111
+ line = line.strip()
112
+ if not line:
113
+ continue
114
+ try:
115
+ acct = json.loads(line)
116
+ if "email" in acct and "password" in acct and "jwt" in acct:
117
+ await db.upsert_account(acct)
118
+ imported += 1
119
+ else:
120
+ errors.append({"line": i + 1, "error": "missing required fields"})
121
+ except Exception as e:
122
+ errors.append({"line": i + 1, "error": str(e)})
123
+ return {"imported": imported, "errors": errors}
124
+
125
+
126
+ @router.post("/tokens/import-zip")
127
+ async def import_zip(request: Request):
128
+ """Import from zip containing accounts.jsonl or individual .json files."""
129
+ err = _require_auth(request)
130
+ if err:
131
+ return err
132
+ body = await request.body()
133
+ imported, errors = 0, []
134
+ try:
135
+ with zipfile.ZipFile(io.BytesIO(body)) as zf:
136
+ for name in zf.namelist():
137
+ raw = zf.read(name).decode("utf-8", errors="replace")
138
+ if name.endswith(".jsonl") or name == "accounts.jsonl":
139
+ for line in raw.strip().splitlines():
140
+ line = line.strip()
141
+ if not line:
142
+ continue
143
+ try:
144
+ acct = json.loads(line)
145
+ if "email" in acct and "jwt" in acct:
146
+ await db.upsert_account(acct)
147
+ imported += 1
148
+ except Exception as e:
149
+ errors.append({"file": name, "error": str(e)})
150
+ elif name.endswith(".json"):
151
+ try:
152
+ acct = json.loads(raw)
153
+ if "email" in acct and "jwt" in acct:
154
+ await db.upsert_account(acct)
155
+ imported += 1
156
+ except Exception as e:
157
+ errors.append({"file": name, "error": str(e)})
158
+ except Exception as e:
159
+ return JSONResponse(status_code=400, content={"error": f"Invalid zip: {e}"})
160
+ return {"imported": imported, "errors": errors}
161
+
162
+
163
+ @router.get("/tokens/export")
164
+ async def export_tokens(request: Request):
165
+ """Download all accounts as a zip (accounts.jsonl + tokens.txt)."""
166
+ err = _require_auth(request)
167
+ if err:
168
+ return err
169
+ accounts = await db.get_all_accounts()
170
+ buf = io.BytesIO()
171
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
172
+ jsonl_lines = []
173
+ jwt_lines = []
174
+ for a in accounts:
175
+ row = {
176
+ "email": a["email"], "password": a["password"],
177
+ "jwt": a["jwt"], "refresh_token": a.get("refresh_token", ""),
178
+ "user_id": a.get("user_id", ""), "balance": a["balance"],
179
+ "is_active": a["is_active"],
180
+ }
181
+ jsonl_lines.append(json.dumps(row, ensure_ascii=False))
182
+ if a["jwt"]:
183
+ jwt_lines.append(a["jwt"])
184
+ zf.writestr("accounts.jsonl", "\n".join(jsonl_lines) + "\n")
185
+ zf.writestr("tokens.txt", "\n".join(jwt_lines) + "\n")
186
+ buf.seek(0)
187
+ return StreamingResponse(
188
+ buf,
189
+ media_type="application/zip",
190
+ headers={"Content-Disposition": "attachment; filename=emergent_accounts.zip"},
191
+ )
192
+
193
+
194
+ @router.post("/tokens/test")
195
+ async def test_tokens(request: Request):
196
+ """Test connectivity for selected account IDs."""
197
+ err = _require_auth(request)
198
+ if err:
199
+ return err
200
+ body = await request.json()
201
+ ids = body.get("ids", [])
202
+ results = []
203
+ for aid in ids:
204
+ acct = await db.get_account_by_id(aid)
205
+ if not acct:
206
+ results.append({"id": aid, "status": "not_found"})
207
+ continue
208
+ try:
209
+ s = cffi_requests.Session(impersonate="chrome")
210
+ if settings.proxy:
211
+ s.proxies = {"http": settings.proxy, "https": settings.proxy}
212
+ resp = s.post(
213
+ f"{settings.auth_base}/token?grant_type=password",
214
+ json={"email": acct["email"], "password": acct["password"], "gotrue_meta_security": {}},
215
+ headers={
216
+ "Apikey": settings.supabase_anon_key,
217
+ "Authorization": f"Bearer {settings.supabase_anon_key}",
218
+ "X-Client-Info": "supabase-js-web/2.45.4",
219
+ },
220
+ timeout=15,
221
+ )
222
+ if resp.status_code == 200:
223
+ data = resp.json()
224
+ new_jwt = data.get("access_token", "")
225
+ if new_jwt:
226
+ await db.update_jwt(aid, new_jwt, data.get("refresh_token", ""))
227
+ results.append({"id": aid, "email": acct["email"], "status": "ok"})
228
+ else:
229
+ results.append({"id": aid, "email": acct["email"], "status": "failed", "code": resp.status_code})
230
+ except Exception as e:
231
+ results.append({"id": aid, "email": acct["email"], "status": "error", "detail": str(e)})
232
+ return {"results": results}
233
+
234
+
235
+ @router.post("/tokens/refresh")
236
+ async def refresh_tokens(request: Request):
237
+ """Refresh JWT for selected account IDs."""
238
+ err = _require_auth(request)
239
+ if err:
240
+ return err
241
+ body = await request.json()
242
+ ids = body.get("ids", [])
243
+ ok, fail = 0, 0
244
+ for aid in ids:
245
+ acct = await db.get_account_by_id(aid)
246
+ if not acct:
247
+ fail += 1
248
+ continue
249
+ new_jwt = await pool.refresh_jwt(acct)
250
+ if new_jwt:
251
+ ok += 1
252
+ else:
253
+ fail += 1
254
+ return {"refreshed": ok, "failed": fail}
255
+
256
+
257
+ @router.post("/tokens/delete")
258
+ async def delete_tokens(request: Request):
259
+ """Batch delete accounts by IDs."""
260
+ err = _require_auth(request)
261
+ if err:
262
+ return err
263
+ body = await request.json()
264
+ ids = body.get("ids", [])
265
+ if not ids:
266
+ return {"deleted": 0}
267
+ count = await db.batch_delete(ids)
268
+ return {"deleted": count}
269
+
270
+
271
+ @router.post("/tokens/toggle")
272
+ async def toggle_tokens(request: Request):
273
+ """Batch set active status. Body: {ids: [...], active: bool}"""
274
+ err = _require_auth(request)
275
+ if err:
276
+ return err
277
+ body = await request.json()
278
+ ids = body.get("ids", [])
279
+ active = body.get("active", True)
280
+ if not ids:
281
+ return {"updated": 0}
282
+ count = await db.batch_toggle(ids, active)
283
+ return {"updated": count}
284
+
285
+
286
+ @router.post("/tokens/delete-inactive")
287
+ async def delete_inactive(request: Request):
288
+ err = _require_auth(request)
289
+ if err:
290
+ return err
291
+ count = await db.delete_inactive()
292
+ return {"deleted": count}
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Config
297
+ # ---------------------------------------------------------------------------
298
+
299
+ @router.get("/config")
300
+ async def get_config(request: Request):
301
+ err = _require_auth(request)
302
+ if err:
303
+ return err
304
+ db_config = await db.get_all_config()
305
+ return {
306
+ "api_key": db_config.get("api_key", settings.api_key),
307
+ "backend": db_config.get("backend", settings.backend),
308
+ "proxy": db_config.get("proxy", settings.proxy),
309
+ "poll_interval": db_config.get("poll_interval", str(settings.poll_interval)),
310
+ "poll_timeout": db_config.get("poll_timeout", str(settings.poll_timeout)),
311
+ "admin_password": db_config.get("admin_password", settings.admin_password),
312
+ }
313
+
314
+
315
+ @router.post("/config")
316
+ async def update_config(request: Request):
317
+ err = _require_auth(request)
318
+ if err:
319
+ return err
320
+ body = await request.json()
321
+ allowed = {"api_key", "backend", "proxy", "poll_interval", "poll_timeout", "admin_password"}
322
+ saved = []
323
+ for key, value in body.items():
324
+ if key in allowed:
325
+ await db.set_config(key, str(value))
326
+ if key == "api_key":
327
+ settings.api_key = str(value)
328
+ elif key == "backend":
329
+ settings.backend = str(value)
330
+ elif key == "proxy":
331
+ settings.proxy = str(value)
332
+ elif key == "admin_password":
333
+ settings.admin_password = str(value)
334
+ saved.append(key)
335
+ return {"saved": saved}
336
+
337
+
338
+ @router.post("/config/generate-key")
339
+ async def generate_api_key(request: Request):
340
+ err = _require_auth(request)
341
+ if err:
342
+ return err
343
+ new_key = f"sk-{secrets.token_urlsafe(24)}"
344
+ await db.set_config("api_key", new_key)
345
+ settings.api_key = new_key
346
+ return {"api_key": new_key}
emergent2api/static/admin/config.html ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Emergent2API - Config</title>
6
+ <style>
7
+ *{margin:0;padding:0;box-sizing:border-box}
8
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#1a1a1a}
9
+ nav{background:#fff;border-bottom:1px solid #e5e5e5;padding:0 24px;display:flex;align-items:center;height:56px;position:sticky;top:0;z-index:10}
10
+ nav .brand{font-weight:700;font-size:18px;margin-right:32px;color:#4f46e5}
11
+ nav a{text-decoration:none;color:#666;padding:16px 12px;font-size:14px;border-bottom:2px solid transparent;transition:all .2s}
12
+ nav a:hover{color:#1a1a1a}nav a.active{color:#4f46e5;border-bottom-color:#4f46e5;font-weight:600}
13
+ nav .spacer{flex:1}
14
+ nav .logout{color:#ef4444;cursor:pointer;font-size:13px}
15
+ .container{max-width:700px;margin:0 auto;padding:28px 24px}
16
+ .card{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);padding:28px;margin-bottom:20px}
17
+ .card h2{font-size:17px;margin-bottom:18px;padding-bottom:10px;border-bottom:1px solid #f0f0f0}
18
+ .field{margin-bottom:18px}
19
+ .field label{display:block;font-size:13px;font-weight:600;color:#555;margin-bottom:6px}
20
+ .field input,.field select{width:100%;padding:10px 14px;border:1px solid #ddd;border-radius:6px;font-size:14px;outline:none;transition:border .2s}
21
+ .field input:focus,.field select:focus{border-color:#4f46e5}
22
+ .field .hint{font-size:11px;color:#aaa;margin-top:4px}
23
+ .row{display:flex;gap:10px;align-items:end}
24
+ .row .field{flex:1}
25
+ .btn{padding:10px 20px;border:none;border-radius:6px;font-size:14px;cursor:pointer;font-weight:500;transition:all .2s}
26
+ .btn-primary{background:#4f46e5;color:#fff}.btn-primary:hover{background:#4338ca}
27
+ .btn-outline{background:#fff;color:#333;border:1px solid #ddd}.btn-outline:hover{background:#f3f4f6}
28
+ .actions{display:flex;gap:10px;justify-content:flex-end;margin-top:6px}
29
+ .toast{position:fixed;bottom:24px;right:24px;background:#1a1a1a;color:#fff;padding:12px 20px;border-radius:8px;font-size:13px;z-index:30;opacity:0;transition:opacity .3s}
30
+ .toast.show{opacity:1}
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <nav>
35
+ <span class="brand">Emergent2API</span>
36
+ <a href="/admin/token">Token Management</a>
37
+ <a href="/admin/config" class="active">Config</a>
38
+ <a href="/admin/docs">Usage Docs</a>
39
+ <span class="spacer"></span>
40
+ <span class="logout" onclick="logout()">Logout</span>
41
+ </nav>
42
+ <div class="container">
43
+ <div class="card">
44
+ <h2>API Settings</h2>
45
+ <div class="row">
46
+ <div class="field"><label>API Key</label><input id="api_key" placeholder="sk-..."></div>
47
+ <div style="padding-bottom:18px"><span class="btn btn-outline" onclick="genKey()">Generate</span></div>
48
+ </div>
49
+ <div class="field">
50
+ <label>Backend</label>
51
+ <select id="backend"><option value="jobs">Jobs API (no IP restriction)</option><option value="integrations">Integrations API (faster, needs US proxy)</option></select>
52
+ </div>
53
+ <div class="field"><label>Proxy</label><input id="proxy" placeholder="http://user:pass@host:port"><div class="hint">Required for Integrations backend; optional for Jobs</div></div>
54
+ <div class="row">
55
+ <div class="field"><label>Poll Interval (s)</label><input id="poll_interval" type="number" min="1"></div>
56
+ <div class="field"><label>Poll Timeout (s)</label><input id="poll_timeout" type="number" min="10"></div>
57
+ </div>
58
+ </div>
59
+ <div class="card">
60
+ <h2>Admin Settings</h2>
61
+ <div class="field"><label>Admin Password</label><input id="admin_password" type="password"></div>
62
+ </div>
63
+ <div class="actions">
64
+ <span class="btn btn-outline" onclick="load()">Reset</span>
65
+ <span class="btn btn-primary" onclick="save()">Save Changes</span>
66
+ </div>
67
+ </div>
68
+ <div class="toast" id="toast"></div>
69
+ <script>
70
+ const API='/v1/admin';
71
+ function toast(msg,dur=3000){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),dur)}
72
+
73
+ async function api(path,opts={}){
74
+ const r=await fetch(API+path,opts);
75
+ if(r.status===401){window.location.href='/admin/login';return null}
76
+ return r;
77
+ }
78
+
79
+ async function load(){
80
+ const r=await api('/config');if(!r)return;
81
+ const d=await r.json();
82
+ document.getElementById('api_key').value=d.api_key||'';
83
+ document.getElementById('backend').value=d.backend||'jobs';
84
+ document.getElementById('proxy').value=d.proxy||'';
85
+ document.getElementById('poll_interval').value=d.poll_interval||'5';
86
+ document.getElementById('poll_timeout').value=d.poll_timeout||'120';
87
+ document.getElementById('admin_password').value=d.admin_password||'';
88
+ }
89
+
90
+ async function save(){
91
+ const body={
92
+ api_key:document.getElementById('api_key').value,
93
+ backend:document.getElementById('backend').value,
94
+ proxy:document.getElementById('proxy').value,
95
+ poll_interval:document.getElementById('poll_interval').value,
96
+ poll_timeout:document.getElementById('poll_timeout').value,
97
+ admin_password:document.getElementById('admin_password').value,
98
+ };
99
+ const r=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
100
+ if(r){const d=await r.json();toast(`Saved: ${d.saved.join(', ')}`)}
101
+ }
102
+
103
+ async function genKey(){
104
+ const r=await api('/config/generate-key',{method:'POST'});
105
+ if(r){const d=await r.json();document.getElementById('api_key').value=d.api_key;toast('New key generated')}
106
+ }
107
+
108
+ async function logout(){await api('/logout',{method:'POST'});window.location.href='/admin/login'}
109
+ load();
110
+ </script>
111
+ </body>
112
+ </html>
emergent2api/static/admin/docs.html ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Emergent2API - Usage Docs</title>
6
+ <style>
7
+ *{margin:0;padding:0;box-sizing:border-box}
8
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#1a1a1a}
9
+ nav{background:#fff;border-bottom:1px solid #e5e5e5;padding:0 24px;display:flex;align-items:center;height:56px;position:sticky;top:0;z-index:10}
10
+ nav .brand{font-weight:700;font-size:18px;margin-right:32px;color:#4f46e5}
11
+ nav a{text-decoration:none;color:#666;padding:16px 12px;font-size:14px;border-bottom:2px solid transparent;transition:all .2s}
12
+ nav a:hover{color:#1a1a1a}nav a.active{color:#4f46e5;border-bottom-color:#4f46e5;font-weight:600}
13
+ nav .spacer{flex:1}
14
+ nav .logout{color:#ef4444;cursor:pointer;font-size:13px}
15
+ .container{max-width:800px;margin:0 auto;padding:28px 24px}
16
+ .card{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);padding:28px;margin-bottom:20px}
17
+ .card h2{font-size:17px;margin-bottom:14px;color:#4f46e5}
18
+ .card h3{font-size:14px;margin:16px 0 8px;color:#333}
19
+ p{font-size:14px;line-height:1.6;color:#555;margin-bottom:8px}
20
+ pre{background:#1a1a2e;color:#e2e8f0;padding:16px;border-radius:8px;overflow-x:auto;font-size:12px;line-height:1.5;margin:8px 0 14px}
21
+ code{font-family:'SFMono-Regular',Consolas,monospace;font-size:12px}
22
+ .inline-code{background:#f1f5f9;padding:2px 6px;border-radius:4px;color:#4f46e5;font-size:13px}
23
+ table{width:100%;border-collapse:collapse;margin:8px 0 14px;font-size:13px}
24
+ th{text-align:left;padding:8px 12px;background:#f9fafb;color:#888;font-weight:600;border-bottom:1px solid #eee}
25
+ td{padding:8px 12px;border-bottom:1px solid #f3f3f3}
26
+ td code{background:#f1f5f9;padding:1px 4px;border-radius:3px}
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <nav>
31
+ <span class="brand">Emergent2API</span>
32
+ <a href="/admin/token">Token Management</a>
33
+ <a href="/admin/config">Config</a>
34
+ <a href="/admin/docs" class="active">Usage Docs</a>
35
+ <span class="spacer"></span>
36
+ <span class="logout" onclick="logout()">Logout</span>
37
+ </nav>
38
+ <div class="container">
39
+
40
+ <div class="card">
41
+ <h2>Quick Start</h2>
42
+ <p>Emergent2API exposes Emergent.sh accounts as standard OpenAI and Anthropic-compatible API endpoints. Use your configured API key in the <span class="inline-code">Authorization</span> header.</p>
43
+ <pre>Base URL: https://&lt;your-host&gt;/v1
44
+ API Key: sk-6loA0HwMQP1mdhPvI (default, changeable in Config)</pre>
45
+ </div>
46
+
47
+ <div class="card">
48
+ <h2>Available Models</h2>
49
+ <table>
50
+ <tr><th>Model ID</th><th>Description</th></tr>
51
+ <tr><td><code>claude-opus-4-6</code></td><td>Claude Opus 4.6 (full, uncapped)</td></tr>
52
+ <tr><td><code>claude-sonnet-4-5</code></td><td>Claude Sonnet 4.5</td></tr>
53
+ <tr><td><code>claude-sonnet-4-5-thinking</code></td><td>Claude Sonnet 4.5 with extended thinking</td></tr>
54
+ </table>
55
+ </div>
56
+
57
+ <div class="card">
58
+ <h2>API Endpoints</h2>
59
+ <table>
60
+ <tr><th>Method</th><th>Endpoint</th><th>Format</th></tr>
61
+ <tr><td>POST</td><td><code>/v1/chat/completions</code></td><td>OpenAI Chat Completions</td></tr>
62
+ <tr><td>POST</td><td><code>/v1/messages</code></td><td>Anthropic Messages</td></tr>
63
+ <tr><td>POST</td><td><code>/v1/responses</code></td><td>OpenAI Response API</td></tr>
64
+ <tr><td>GET</td><td><code>/v1/models</code></td><td>Model list</td></tr>
65
+ </table>
66
+ </div>
67
+
68
+ <div class="card">
69
+ <h2>Examples</h2>
70
+
71
+ <h3>OpenAI Chat (curl)</h3>
72
+ <pre>curl -X POST https://your-host/v1/chat/completions \
73
+ -H "Authorization: Bearer sk-6loA0HwMQP1mdhPvI" \
74
+ -H "Content-Type: application/json" \
75
+ -d '{
76
+ "model": "claude-opus-4-6",
77
+ "messages": [
78
+ {"role": "user", "content": "Hello!"}
79
+ ],
80
+ "stream": true
81
+ }'</pre>
82
+
83
+ <h3>Anthropic Messages (curl)</h3>
84
+ <pre>curl -X POST https://your-host/v1/messages \
85
+ -H "x-api-key: sk-6loA0HwMQP1mdhPvI" \
86
+ -H "Content-Type: application/json" \
87
+ -H "anthropic-version: 2023-06-01" \
88
+ -d '{
89
+ "model": "claude-opus-4-6",
90
+ "max_tokens": 1024,
91
+ "messages": [
92
+ {"role": "user", "content": "Hello!"}
93
+ ]
94
+ }'</pre>
95
+
96
+ <h3>Python (OpenAI SDK)</h3>
97
+ <pre>from openai import OpenAI
98
+
99
+ client = OpenAI(
100
+ api_key="sk-6loA0HwMQP1mdhPvI",
101
+ base_url="https://your-host/v1"
102
+ )
103
+
104
+ response = client.chat.completions.create(
105
+ model="claude-opus-4-6",
106
+ messages=[{"role": "user", "content": "Hello!"}],
107
+ stream=True
108
+ )
109
+ for chunk in response:
110
+ print(chunk.choices[0].delta.content or "", end="")</pre>
111
+ </div>
112
+
113
+ <div class="card">
114
+ <h2>CherryStudio Configuration</h2>
115
+ <p>1. Open CherryStudio → Settings → Model Provider → Add Custom Provider</p>
116
+ <p>2. Set the following:</p>
117
+ <table>
118
+ <tr><th>Field</th><th>Value</th></tr>
119
+ <tr><td>Base URL</td><td><code>https://your-host/v1</code></td></tr>
120
+ <tr><td>API Key</td><td><code>sk-6loA0HwMQP1mdhPvI</code></td></tr>
121
+ <tr><td>Model</td><td><code>claude-opus-4-6</code></td></tr>
122
+ </table>
123
+ <p>3. Enable "Stream" mode for real-time responses.</p>
124
+ </div>
125
+
126
+ <div class="card">
127
+ <h2>Batch Import via CLI</h2>
128
+ <p>Import accounts from a zip artifact (produced by GitHub Actions):</p>
129
+ <pre>curl -X POST https://your-host/v1/admin/tokens/import-zip \
130
+ -H "Cookie: emergent_admin_session=YOUR_SESSION" \
131
+ -H "Content-Type: application/octet-stream" \
132
+ --data-binary @0316Emergent.zip</pre>
133
+ <p>Or import JSONL text directly:</p>
134
+ <pre>curl -X POST https://your-host/v1/admin/tokens/import \
135
+ -H "Cookie: emergent_admin_session=YOUR_SESSION" \
136
+ -H "Content-Type: application/json" \
137
+ -d '{"jsonl": "{\"email\":\"a@b.com\",\"password\":\"p\",\"jwt\":\"j\"}\n..."}'</pre>
138
+ </div>
139
+
140
+ </div>
141
+ <script>
142
+ async function logout(){await fetch('/v1/admin/logout',{method:'POST'});window.location.href='/admin/login'}
143
+ </script>
144
+ </body>
145
+ </html>
emergent2api/static/admin/login.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Emergent2API - Login</title>
6
+ <style>
7
+ *{margin:0;padding:0;box-sizing:border-box}
8
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
9
+ .card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:40px;width:380px;text-align:center}
10
+ .card h1{font-size:22px;margin-bottom:6px;color:#1a1a1a}
11
+ .card p{color:#888;font-size:14px;margin-bottom:28px}
12
+ input{width:100%;padding:12px 16px;border:1px solid #ddd;border-radius:8px;font-size:15px;outline:none;transition:border .2s}
13
+ input:focus{border-color:#4f46e5}
14
+ button{width:100%;padding:12px;background:#4f46e5;color:#fff;border:none;border-radius:8px;font-size:15px;cursor:pointer;margin-top:16px;transition:background .2s}
15
+ button:hover{background:#4338ca}
16
+ .err{color:#ef4444;font-size:13px;margin-top:12px;display:none}
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div class="card">
21
+ <h1>Emergent2API</h1>
22
+ <p>Admin Panel Login</p>
23
+ <input type="password" id="pwd" placeholder="Admin Password" autofocus>
24
+ <button onclick="doLogin()">Login</button>
25
+ <div class="err" id="err">Invalid password</div>
26
+ </div>
27
+ <script>
28
+ const API='/v1/admin';
29
+ document.getElementById('pwd').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
30
+ async function doLogin(){
31
+ const pwd=document.getElementById('pwd').value;
32
+ try{
33
+ const r=await fetch(API+'/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})});
34
+ if(r.ok){window.location.href='/admin/token'}
35
+ else{document.getElementById('err').style.display='block'}
36
+ }catch(e){document.getElementById('err').style.display='block'}
37
+ }
38
+ </script>
39
+ </body>
40
+ </html>
emergent2api/static/admin/token.html ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Emergent2API - Token Management</title>
6
+ <style>
7
+ *{margin:0;padding:0;box-sizing:border-box}
8
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#1a1a1a}
9
+ nav{background:#fff;border-bottom:1px solid #e5e5e5;padding:0 24px;display:flex;align-items:center;height:56px;position:sticky;top:0;z-index:10}
10
+ nav .brand{font-weight:700;font-size:18px;margin-right:32px;color:#4f46e5}
11
+ nav a{text-decoration:none;color:#666;padding:16px 12px;font-size:14px;border-bottom:2px solid transparent;transition:all .2s}
12
+ nav a:hover{color:#1a1a1a}nav a.active{color:#4f46e5;border-bottom-color:#4f46e5;font-weight:600}
13
+ nav .spacer{flex:1}
14
+ nav .logout{color:#ef4444;cursor:pointer;font-size:13px}
15
+ .container{max-width:1200px;margin:0 auto;padding:20px 24px}
16
+ .toolbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
17
+ .toolbar select,.toolbar input{padding:8px 12px;border:1px solid #ddd;border-radius:6px;font-size:13px;background:#fff}
18
+ .btn{padding:8px 16px;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s}
19
+ .btn-primary{background:#4f46e5;color:#fff}.btn-primary:hover{background:#4338ca}
20
+ .btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
21
+ .btn-warning{background:#f59e0b;color:#fff}.btn-warning:hover{background:#d97706}
22
+ .btn-success{background:#10b981;color:#fff}.btn-success:hover{background:#059669}
23
+ .btn-outline{background:#fff;color:#333;border:1px solid #ddd}.btn-outline:hover{background:#f3f4f6}
24
+ .stats{display:flex;gap:16px;margin-bottom:16px}
25
+ .stat{background:#fff;border-radius:8px;padding:16px 20px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex:1}
26
+ .stat .num{font-size:28px;font-weight:700;color:#4f46e5}.stat .label{font-size:12px;color:#888;margin-top:4px}
27
+ table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.06)}
28
+ th{text-align:left;padding:10px 14px;font-size:12px;color:#888;background:#fafafa;font-weight:600;border-bottom:1px solid #eee}
29
+ td{padding:10px 14px;font-size:13px;border-bottom:1px solid #f3f3f3}
30
+ tr:hover td{background:#fafafe}
31
+ .badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
32
+ .badge-ok{background:#d1fae5;color:#059669}.badge-off{background:#fee2e2;color:#ef4444}
33
+ .modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.4);z-index:20;align-items:center;justify-content:center}
34
+ .modal.open{display:flex}
35
+ .modal-box{background:#fff;border-radius:12px;padding:28px;width:520px;max-height:80vh;overflow-y:auto;box-shadow:0 8px 30px rgba(0,0,0,.15)}
36
+ .modal-box h3{margin-bottom:14px;font-size:17px}
37
+ textarea{width:100%;height:200px;padding:10px;border:1px solid #ddd;border-radius:6px;font-size:12px;font-family:monospace;resize:vertical}
38
+ .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
39
+ input[type="checkbox"]{width:16px;height:16px;cursor:pointer}
40
+ .toast{position:fixed;bottom:24px;right:24px;background:#1a1a1a;color:#fff;padding:12px 20px;border-radius:8px;font-size:13px;z-index:30;opacity:0;transition:opacity .3s}
41
+ .toast.show{opacity:1}
42
+ </style>
43
+ </head>
44
+ <body>
45
+ <nav>
46
+ <span class="brand">Emergent2API</span>
47
+ <a href="/admin/token" class="active">Token Management</a>
48
+ <a href="/admin/config">Config</a>
49
+ <a href="/admin/docs">Usage Docs</a>
50
+ <span class="spacer"></span>
51
+ <span class="logout" onclick="logout()">Logout</span>
52
+ </nav>
53
+ <div class="container">
54
+ <div class="stats">
55
+ <div class="stat"><div class="num" id="s-total">-</div><div class="label">Total Tokens</div></div>
56
+ <div class="stat"><div class="num" id="s-active">-</div><div class="label">Active</div></div>
57
+ <div class="stat"><div class="num" id="s-inactive">-</div><div class="label">Inactive</div></div>
58
+ </div>
59
+ <div class="toolbar">
60
+ <select id="filter" onchange="render()">
61
+ <option value="all">All</option>
62
+ <option value="active">Active</option>
63
+ <option value="inactive">Inactive</option>
64
+ </select>
65
+ <span class="btn btn-outline" onclick="selectAll()">Select All</span>
66
+ <span class="btn btn-outline" onclick="selectNone()">Deselect All</span>
67
+ <span style="flex:1"></span>
68
+ <span class="btn btn-primary" onclick="openImport()">Import</span>
69
+ <span class="btn btn-outline" onclick="doExport()">Export</span>
70
+ <span class="btn btn-success" onclick="batchAction('test')">Test Selected</span>
71
+ <span class="btn btn-warning" onclick="batchAction('refresh')">Refresh JWT</span>
72
+ <span class="btn btn-danger" onclick="batchAction('delete')">Delete Selected</span>
73
+ <span class="btn btn-danger" onclick="deleteInactive()">Delete Inactive</span>
74
+ </div>
75
+ <table>
76
+ <thead><tr>
77
+ <th><input type="checkbox" id="chk-all" onchange="toggleAll(this.checked)"></th>
78
+ <th>ID</th><th>Email</th><th>Balance</th><th>Status</th><th>Last Used</th><th>Created</th><th>Actions</th>
79
+ </tr></thead>
80
+ <tbody id="tbody"></tbody>
81
+ </table>
82
+ </div>
83
+
84
+ <div class="modal" id="modal-import">
85
+ <div class="modal-box">
86
+ <h3>Import Accounts</h3>
87
+ <p style="font-size:13px;color:#888;margin-bottom:10px">Paste JSONL data (one account per line: email, password, jwt required)</p>
88
+ <textarea id="import-text" placeholder='{"email":"...","password":"...","jwt":"..."}'></textarea>
89
+ <p style="font-size:12px;color:#888;margin-top:8px">Or upload a .zip file containing accounts.jsonl:</p>
90
+ <input type="file" id="import-file" accept=".zip" style="margin-top:6px">
91
+ <div class="modal-actions">
92
+ <span class="btn btn-outline" onclick="closeImport()">Cancel</span>
93
+ <span class="btn btn-primary" onclick="doImportText()">Import JSONL</span>
94
+ <span class="btn btn-primary" onclick="doImportZip()">Import Zip</span>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <div class="toast" id="toast"></div>
100
+
101
+ <script>
102
+ const API='/v1/admin';
103
+ let tokens=[];let selected=new Set();
104
+
105
+ async function api(path,opts={}){
106
+ const r=await fetch(API+path,opts);
107
+ if(r.status===401){window.location.href='/admin/login';return null}
108
+ return r;
109
+ }
110
+
111
+ function toast(msg,dur=3000){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),dur)}
112
+
113
+ async function load(){
114
+ const r=await api('/tokens');if(!r)return;
115
+ const d=await r.json();
116
+ tokens=d.tokens||[];
117
+ document.getElementById('s-total').textContent=d.total;
118
+ document.getElementById('s-active').textContent=d.active;
119
+ document.getElementById('s-inactive').textContent=d.total-d.active;
120
+ render();
121
+ }
122
+
123
+ function render(){
124
+ const f=document.getElementById('filter').value;
125
+ const tbody=document.getElementById('tbody');
126
+ let rows=tokens;
127
+ if(f==='active')rows=rows.filter(t=>t.is_active);
128
+ else if(f==='inactive')rows=rows.filter(t=>!t.is_active);
129
+ tbody.innerHTML=rows.map(t=>`<tr>
130
+ <td><input type="checkbox" class="row-chk" data-id="${t.id}" ${selected.has(t.id)?'checked':''} onchange="toggleSel(${t.id},this.checked)"></td>
131
+ <td>${t.id}</td>
132
+ <td style="font-family:monospace;font-size:12px">${t.email}</td>
133
+ <td>${t.balance!=null?'$'+t.balance.toFixed(2):'-'}</td>
134
+ <td><span class="badge ${t.is_active?'badge-ok':'badge-off'}">${t.is_active?'Active':'Inactive'}</span></td>
135
+ <td style="font-size:12px;color:#888">${t.last_used?new Date(t.last_used).toLocaleString():'-'}</td>
136
+ <td style="font-size:12px;color:#888">${t.created_at?new Date(t.created_at).toLocaleDateString():'-'}</td>
137
+ <td>
138
+ <span class="btn btn-outline" style="padding:4px 8px;font-size:11px" onclick="toggleOne(${t.id})">${t.is_active?'Disable':'Enable'}</span>
139
+ </td>
140
+ </tr>`).join('');
141
+ }
142
+
143
+ function toggleSel(id,c){if(c)selected.add(id);else selected.delete(id)}
144
+ function selectAll(){tokens.forEach(t=>selected.add(t.id));render()}
145
+ function selectNone(){selected.clear();render()}
146
+ function toggleAll(c){if(c)selectAll();else selectNone()}
147
+
148
+ async function toggleOne(id){
149
+ await api('/tokens/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id],active:!tokens.find(t=>t.id===id)?.is_active})});
150
+ load();
151
+ }
152
+
153
+ async function batchAction(action){
154
+ const ids=[...selected];
155
+ if(!ids.length){toast('Select accounts first');return}
156
+ if(action==='delete'&&!confirm(`Delete ${ids.length} accounts?`))return;
157
+ const endpoint=`/tokens/${action}`;
158
+ toast(`Processing ${ids.length} accounts...`);
159
+ const r=await api(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
160
+ if(r){const d=await r.json();toast(JSON.stringify(d))}
161
+ selected.clear();load();
162
+ }
163
+
164
+ async function deleteInactive(){
165
+ if(!confirm('Delete all inactive accounts?'))return;
166
+ const r=await api('/tokens/delete-inactive',{method:'POST'});
167
+ if(r){const d=await r.json();toast(`Deleted ${d.deleted} inactive accounts`)}
168
+ load();
169
+ }
170
+
171
+ function openImport(){document.getElementById('modal-import').classList.add('open')}
172
+ function closeImport(){document.getElementById('modal-import').classList.remove('open')}
173
+
174
+ async function doImportText(){
175
+ const text=document.getElementById('import-text').value.trim();
176
+ if(!text){toast('Paste JSONL data first');return}
177
+ const r=await api('/tokens/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jsonl:text})});
178
+ if(r){const d=await r.json();toast(`Imported ${d.imported} accounts`+(d.errors.length?`, ${d.errors.length} errors`:''));closeImport();load()}
179
+ }
180
+
181
+ async function doImportZip(){
182
+ const f=document.getElementById('import-file').files[0];
183
+ if(!f){toast('Select a zip file');return}
184
+ const buf=await f.arrayBuffer();
185
+ const r=await api('/tokens/import-zip',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:buf});
186
+ if(r){const d=await r.json();toast(`Imported ${d.imported} accounts`);closeImport();load()}
187
+ }
188
+
189
+ async function doExport(){
190
+ const r=await api('/tokens/export');
191
+ if(!r)return;
192
+ const blob=await r.blob();
193
+ const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='emergent_accounts.zip';a.click();
194
+ }
195
+
196
+ async function logout(){
197
+ await api('/logout',{method:'POST'});
198
+ window.location.href='/admin/login';
199
+ }
200
+
201
+ load();
202
+ </script>
203
+ </body>
204
+ </html>