ohmyapi commited on
Commit
dcef2ab
·
1 Parent(s): 36d5e18

Update emergent2api v0.2.0: auth_token, admin endpoints, account management

Browse files

- Default auth_token: sk-6loA0HwMQP1mdhPvI
- Add JSONL import, toggle, delete, JWT refresh endpoints
- Update app_version to 3.5.0
- Add python-multipart dependency

Made-with: Cursor

emergent2api/__init__.py CHANGED
@@ -0,0 +1 @@
 
 
1
+
emergent2api/app.py CHANGED
@@ -1,6 +1,7 @@
1
  """Emergent2API — OpenAI/Anthropic-compatible API gateway for Emergent.sh accounts."""
2
  from __future__ import annotations
3
 
 
4
  import logging
5
  import os
6
 
@@ -100,17 +101,27 @@ async def root():
100
  count = await pool.count()
101
  return {
102
  "service": "Emergent2API",
103
- "version": "0.1.0",
104
  "backend": settings.backend,
105
  "accounts": count,
106
- "endpoints": [
107
- "POST /v1/chat/completions (OpenAI)",
108
- "POST /v1/messages (Anthropic)",
109
- "POST /v1/responses (OpenAI Response API)",
110
- "GET /v1/models",
111
- "GET /admin/accounts",
112
- "POST /admin/accounts",
113
- ],
 
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
 
@@ -125,8 +136,12 @@ async def health():
125
  # ---------------------------------------------------------------------------
126
 
127
  @app.get("/admin/accounts")
128
- async def admin_list_accounts():
129
- accounts = await db.get_active_accounts()
 
 
 
 
130
  return {
131
  "total": len(accounts),
132
  "accounts": [
@@ -148,27 +163,101 @@ async def admin_add_account(request: Request):
148
  """Add an account manually. Body: {email, password, jwt, refresh_token?, user_id?, balance?}"""
149
  body = await request.json()
150
  required = ["email", "password", "jwt"]
151
- for field in required:
152
- if field not in body:
153
- return JSONResponse(
154
- status_code=400,
155
- content={"error": f"Missing required field: {field}"},
156
- )
157
  account_id = await db.upsert_account(body)
158
  return {"id": account_id, "email": body["email"], "status": "added"}
159
 
160
 
161
  @app.post("/admin/accounts/import")
162
  async def admin_import_accounts(request: Request):
163
- """Import accounts from JSONL. Body: {accounts: [{email, password, jwt, ...}, ...]}"""
164
  body = await request.json()
165
  accounts = body.get("accounts", [])
166
  imported = 0
 
167
  for acct in accounts:
168
  if "email" in acct and "password" in acct and "jwt" in acct:
169
- await db.upsert_account(acct)
170
- imported += 1
171
- return {"imported": imported, "total_submitted": len(accounts)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
 
174
  # ---------------------------------------------------------------------------
 
1
  """Emergent2API — OpenAI/Anthropic-compatible API gateway for Emergent.sh accounts."""
2
  from __future__ import annotations
3
 
4
+ import json
5
  import logging
6
  import os
7
 
 
101
  count = await pool.count()
102
  return {
103
  "service": "Emergent2API",
104
+ "version": "0.2.0",
105
  "backend": settings.backend,
106
  "accounts": count,
107
+ "endpoints": {
108
+ "api": [
109
+ "POST /v1/chat/completions (OpenAI)",
110
+ "POST /v1/messages (Anthropic)",
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
 
 
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": [
 
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()
198
+ raw_text = body.get("jsonl", "")
199
+ else:
200
+ raw_bytes = await request.body()
201
+ raw_text = raw_bytes.decode("utf-8", errors="replace")
202
+ lines = raw_text.strip().splitlines()
203
+ imported = 0
204
+ errors = []
205
+ for i, line in enumerate(lines):
206
+ line = line.strip()
207
+ if not line:
208
+ continue
209
+ try:
210
+ acct = json.loads(line)
211
+ if "email" in acct and "password" in acct and "jwt" in acct:
212
+ await db.upsert_account(acct)
213
+ imported += 1
214
+ else:
215
+ errors.append({"line": i + 1, "error": "missing email/password/jwt"})
216
+ except json.JSONDecodeError:
217
+ errors.append({"line": i + 1, "error": "invalid JSON"})
218
+ except Exception as e:
219
+ errors.append({"line": i + 1, "error": str(e)})
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
  # ---------------------------------------------------------------------------
emergent2api/backends/__init__.py CHANGED
@@ -0,0 +1 @@
 
 
1
+
emergent2api/backends/jobs.py CHANGED
@@ -1,4 +1,10 @@
1
- """Jobs API backend: submit task + poll trajectory for responses."""
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
4
  import asyncio
@@ -15,6 +21,19 @@ from .base import EmergentBackend
15
 
16
  logger = logging.getLogger("emergent2api.jobs")
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  class JobsBackend(EmergentBackend):
20
  """Wraps Emergent's Jobs API (submit-queue + trajectory polling) into a chat interface."""
@@ -28,11 +47,8 @@ class JobsBackend(EmergentBackend):
28
  stream: bool = False,
29
  ) -> AsyncGenerator[dict[str, Any], None]:
30
  jwt = account["jwt"]
31
-
32
- # Flatten messages into a single task prompt
33
  task = self._messages_to_task(messages)
34
 
35
- # Submit job
36
  ref_id = await asyncio.to_thread(
37
  self._submit_job, jwt, task, model, thinking
38
  )
@@ -42,66 +58,69 @@ class JobsBackend(EmergentBackend):
42
 
43
  logger.info(f"Job submitted: ref_id={ref_id}, model={model}")
44
 
45
- # Poll trajectory for results
46
  full_text = ""
47
  full_thinking = ""
48
- seen_items: set[str] = set()
49
- start_time = time.time()
50
- empty_polls = 0
 
51
 
52
- while (time.time() - start_time) < settings.poll_timeout:
53
- await asyncio.sleep(settings.poll_interval)
54
 
 
55
  result = await asyncio.to_thread(self._poll_trajectory, jwt, ref_id)
56
  if result is None:
57
- empty_polls += 1
58
- if empty_polls > 5:
59
- continue
60
  continue
61
 
62
  data_items = result.get("data", [])
63
- job_status = result.get("status", "")
64
 
65
- new_content = False
 
 
66
  for item in data_items:
67
- item_id = str(item.get("id", item.get("request_id", "")))
68
- if item_id in seen_items:
69
- continue
70
- seen_items.add(item_id)
71
-
72
- payload = item.get("traj_payload", {})
73
  reasoning = payload.get("reasoning_content", "")
74
- thought = payload.get("thought", "")
75
-
76
- if reasoning and reasoning != full_thinking:
77
- delta = reasoning[len(full_thinking):]
78
- if delta:
79
- full_thinking = reasoning
80
- new_content = True
81
- if stream:
82
- yield {"type": "thinking", "content": delta}
83
-
84
- if thought and thought != full_text:
85
- delta = thought[len(full_text):]
86
- if delta:
87
- full_text = thought
88
- new_content = True
89
- if stream:
90
- yield {"type": "text", "content": delta}
91
-
92
- # Check for completion
93
- if job_status in ("completed", "failed", "cancelled"):
94
- break
95
- if not new_content and data_items:
96
- last_payload = data_items[-1].get("traj_payload", {})
97
- if last_payload.get("status") in ("completed", "done"):
 
 
 
 
 
98
  break
99
- final_thought = last_payload.get("thought", "")
100
- if final_thought and final_thought == full_text and len(seen_items) > 1:
101
- # No new content after seeing items — likely done
102
- empty_polls += 1
103
- if empty_polls > 8:
104
- break
105
 
106
  yield {
107
  "type": "done",
@@ -109,6 +128,15 @@ class JobsBackend(EmergentBackend):
109
  "thinking": full_thinking,
110
  }
111
 
 
 
 
 
 
 
 
 
 
112
  def _submit_job(
113
  self,
114
  jwt: str,
 
1
+ """Jobs API backend: submit task + poll trajectory for responses.
2
+
3
+ Streaming uses the "longest text" approach: each poll retrieves trajectory
4
+ items and picks the longest text across multiple possible fields. Delta text
5
+ (new chars since last poll) is yielded to the caller. End-of-stream is
6
+ detected via a double-confirmation mechanism (stable content for N cycles).
7
+ """
8
  from __future__ import annotations
9
 
10
  import asyncio
 
21
 
22
  logger = logging.getLogger("emergent2api.jobs")
23
 
24
+ _TEXT_KEYS = ("thought", "output", "message", "content", "text", "response")
25
+
26
+
27
+ def _extract_text_from_item(item: dict) -> str:
28
+ """Try multiple fields to extract the longest text from a trajectory item."""
29
+ payload = item.get("traj_payload") or item
30
+ best = ""
31
+ for key in _TEXT_KEYS:
32
+ val = payload.get(key)
33
+ if val and isinstance(val, str) and len(val) > len(best):
34
+ best = val
35
+ return best
36
+
37
 
38
  class JobsBackend(EmergentBackend):
39
  """Wraps Emergent's Jobs API (submit-queue + trajectory polling) into a chat interface."""
 
47
  stream: bool = False,
48
  ) -> AsyncGenerator[dict[str, Any], None]:
49
  jwt = account["jwt"]
 
 
50
  task = self._messages_to_task(messages)
51
 
 
52
  ref_id = await asyncio.to_thread(
53
  self._submit_job, jwt, task, model, thinking
54
  )
 
58
 
59
  logger.info(f"Job submitted: ref_id={ref_id}, model={model}")
60
 
 
61
  full_text = ""
62
  full_thinking = ""
63
+ consecutive_unchanged = 0
64
+ confirmed_end = False
65
+ has_received_anything = False
66
+ max_wait_cycles = 400
67
 
68
+ # Initial wait to let the container spin up
69
+ await asyncio.sleep(8.0)
70
 
71
+ for _ in range(max_wait_cycles):
72
  result = await asyncio.to_thread(self._poll_trajectory, jwt, ref_id)
73
  if result is None:
74
+ await self._dynamic_sleep(consecutive_unchanged)
75
+ consecutive_unchanged += 1
 
76
  continue
77
 
78
  data_items = result.get("data", [])
 
79
 
80
+ # Find the longest text across all items (main content)
81
+ current_full_text = ""
82
+ current_thinking = ""
83
  for item in data_items:
84
+ text = _extract_text_from_item(item)
85
+ if len(text) > len(current_full_text):
86
+ current_full_text = text
87
+ payload = item.get("traj_payload") or {}
 
 
88
  reasoning = payload.get("reasoning_content", "")
89
+ if reasoning and len(reasoning) > len(current_thinking):
90
+ current_thinking = reasoning
91
+
92
+ # Yield thinking delta
93
+ if current_thinking and len(current_thinking) > len(full_thinking):
94
+ delta = current_thinking[len(full_thinking):]
95
+ full_thinking = current_thinking
96
+ if stream and delta:
97
+ yield {"type": "thinking", "content": delta}
98
+
99
+ # Yield text delta
100
+ if len(current_full_text) > len(full_text):
101
+ delta = current_full_text[len(full_text):]
102
+ full_text = current_full_text
103
+ consecutive_unchanged = 0
104
+ confirmed_end = False
105
+ has_received_anything = True
106
+ if stream and delta:
107
+ yield {"type": "text", "content": delta}
108
+ else:
109
+ consecutive_unchanged += 1
110
+
111
+ if has_received_anything and full_text and consecutive_unchanged >= 6:
112
+ if not confirmed_end:
113
+ logger.debug("Content stable, confirming end...")
114
+ confirmed_end = True
115
+ await asyncio.sleep(5.0)
116
+ continue
117
+ logger.info("Stream completed (double-confirmed stable)")
118
  break
119
+
120
+ await self._dynamic_sleep(consecutive_unchanged)
121
+
122
+ if not has_received_anything:
123
+ logger.warning("Stream finished with NO content")
 
124
 
125
  yield {
126
  "type": "done",
 
128
  "thinking": full_thinking,
129
  }
130
 
131
+ @staticmethod
132
+ async def _dynamic_sleep(consecutive_unchanged: int) -> None:
133
+ if consecutive_unchanged == 0:
134
+ await asyncio.sleep(1.0)
135
+ elif consecutive_unchanged < 3:
136
+ await asyncio.sleep(2.0)
137
+ else:
138
+ await asyncio.sleep(3.0)
139
+
140
  def _submit_job(
141
  self,
142
  jwt: str,
emergent2api/config.py CHANGED
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
7
 
8
  @dataclass
9
  class Settings:
10
- api_key: str = field(default_factory=lambda: os.environ.get("EMERGENT2API_KEY", "sk-emergent2api"))
11
  backend: str = field(default_factory=lambda: os.environ.get("EMERGENT_BACKEND", "jobs"))
12
  proxy: str = field(default_factory=lambda: os.environ.get("EMERGENT_PROXY", ""))
13
  poll_interval: float = field(default_factory=lambda: float(os.environ.get("EMERGENT_POLL_INTERVAL", "0.8")))
@@ -28,7 +28,7 @@ class Settings:
28
  auth_base: str = "https://auth.emergent.sh/auth/v1"
29
  api_base: str = "https://api.emergent.sh"
30
  integrations_base: str = "https://integrations.emergentagent.com/llm"
31
- app_version: str = "1.1.28"
32
 
33
 
34
  settings = Settings()
 
7
 
8
  @dataclass
9
  class Settings:
10
+ api_key: str = field(default_factory=lambda: os.environ.get("EMERGENT2API_KEY", "sk-6loA0HwMQP1mdhPvI"))
11
  backend: str = field(default_factory=lambda: os.environ.get("EMERGENT_BACKEND", "jobs"))
12
  proxy: str = field(default_factory=lambda: os.environ.get("EMERGENT_PROXY", ""))
13
  poll_interval: float = field(default_factory=lambda: float(os.environ.get("EMERGENT_POLL_INTERVAL", "0.8")))
 
28
  auth_base: str = "https://auth.emergent.sh/auth/v1"
29
  api_base: str = "https://api.emergent.sh"
30
  integrations_base: str = "https://integrations.emergentagent.com/llm"
31
+ app_version: str = "3.5.0"
32
 
33
 
34
  settings = Settings()
emergent2api/database.py CHANGED
@@ -118,6 +118,37 @@ async def update_jwt(account_id: int, jwt: str, refresh_token: str = "") -> None
118
  )
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  async def get_account_count() -> dict[str, int]:
122
  pool = await get_pool()
123
  async with pool.acquire() as conn:
 
118
  )
119
 
120
 
121
+ async def get_all_accounts() -> list[dict[str, Any]]:
122
+ pool = await get_pool()
123
+ async with pool.acquire() as conn:
124
+ rows = await conn.fetch("SELECT * FROM emergent_accounts ORDER BY id ASC")
125
+ return [dict(r) for r in rows]
126
+
127
+
128
+ async def get_account_by_id(account_id: int) -> Optional[dict[str, Any]]:
129
+ pool = await get_pool()
130
+ async with pool.acquire() as conn:
131
+ row = await conn.fetchrow("SELECT * FROM emergent_accounts WHERE id = $1", account_id)
132
+ return dict(row) if row else None
133
+
134
+
135
+ async def delete_account(account_id: int) -> None:
136
+ pool = await get_pool()
137
+ async with pool.acquire() as conn:
138
+ await conn.execute("DELETE FROM emergent_accounts WHERE id = $1", account_id)
139
+
140
+
141
+ async def toggle_account(account_id: int) -> bool:
142
+ """Toggle active status and return the new state."""
143
+ pool = await get_pool()
144
+ async with pool.acquire() as conn:
145
+ row = await conn.fetchrow(
146
+ "UPDATE emergent_accounts SET is_active = NOT is_active, updated_at = NOW() WHERE id = $1 RETURNING is_active",
147
+ account_id,
148
+ )
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:
emergent2api/routes/__init__.py CHANGED
@@ -0,0 +1 @@
 
 
1
+
requirements.txt CHANGED
@@ -1,6 +1,8 @@
1
  fastapi>=0.115
2
  uvicorn[standard]>=0.30
 
3
  pydantic-settings>=2.0
4
  asyncpg>=0.29
5
  httpx>=0.27
6
  curl_cffi>=0.7
 
 
1
  fastapi>=0.115
2
  uvicorn[standard]>=0.30
3
+ pydantic>=2.0
4
  pydantic-settings>=2.0
5
  asyncpg>=0.29
6
  httpx>=0.27
7
  curl_cffi>=0.7
8
+ python-multipart>=0.0.7