rnilkyway commited on
Commit
c87cd4b
Β·
1 Parent(s): bdb9939

feat: OpenRouter-style provider prefix routing

Browse files

- Add _get_upstream_for_model() for 'provider/model' format parsing
- Update send_upstream_request() to use new routing with api_key injection
- Provider-specific API keys now correctly passed to upstream
- Syntax validated

Files changed (2) hide show
  1. app/config.py +12 -0
  2. app/index.py +696 -12
app/config.py CHANGED
@@ -7,6 +7,18 @@ BLOCKED_IPS = {
7
  "95.5.119.10",
8
  }
9
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  # ── AES decryption ────────────────────────────────────────────────────────────
11
  # Sensitive values are stored as encrypted blobs in the repo.
12
  # Set HF Secret: AES_KEY=<base64 32-byte key>
 
7
  "95.5.119.10",
8
  }
9
 
10
+ # Cloudflare Turnstile for admin login protection
11
+ TURNSTILE_SITE_KEY = _decrypt("enc:v1:Q1ElsRjDMKIbb-7l:Q2ewoEx10wQFL-BXxTp8Xapb815hL5Iy1_H0Yz9zhQX6DTX2dTgiAA==")
12
+ TURNSTILE_SECRET_KEY = _decrypt("enc:v1:5AVf8i4aUlDhzWIW:_yLBFEmdzSl3FRbaNI1t0FiKA7gXWNPlsBYLCBwMh6yvKCv3wGW_Uf6ArVs9wpzUqfvt")
13
+
14
+ # Hardcoded balance endpoint map for well-known providers
15
+ BALANCE_ENDPOINTS = {
16
+ "deepseek": "https://api.deepseek.com/user/balance",
17
+ "openrouter": "https://openrouter.ai/api/v1/credits",
18
+ "openai": None, # usage-based, no public balance
19
+ "anthropic": None, # invoice-based
20
+ }
21
+
22
  # ── AES decryption ────────────────────────────────────────────────────────────
23
  # Sensitive values are stored as encrypted blobs in the repo.
24
  # Set HF Secret: AES_KEY=<base64 32-byte key>
app/index.py CHANGED
@@ -24,6 +24,7 @@ from config import (
24
  AI_GATEWAY_URL, UPSTREAM_14448_URL,
25
  BLOCKED_IPS,
26
  CHATGPT_IMAGE_API_URL,
 
27
  )
28
  from pydantic import BaseModel
29
  import ipaddress
@@ -478,6 +479,43 @@ def normalize_model(model: str) -> str:
478
  return aliases.get(model, model)
479
 
480
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  def parse_upstream_delta(clean: str) -> dict:
482
  if not clean or clean == "[DONE]" or clean.startswith(":"):
483
  return {}
@@ -723,7 +761,7 @@ class _BufferedResponse:
723
  return
724
 
725
 
726
- async def _send_via_curl(url: str, payload: dict):
727
  headers = build_upstream_headers(url)
728
  curl_args = [
729
  "curl", "-sS", "-X", "POST", url,
@@ -732,7 +770,8 @@ async def _send_via_curl(url: str, payload: dict):
732
  "--data", json.dumps(payload),
733
  "-w", "\n__APIARIUM_HTTP_STATUS__:%{http_code}",
734
  ]
735
- auth = headers.get("Authorization")
 
736
  if auth:
737
  curl_args[7:7] = ["-H", f"Authorization: {auth}"]
738
 
@@ -767,9 +806,7 @@ def get_upstream(model: str) -> dict:
767
 
768
  async def send_upstream_request(body: dict, model: str):
769
  """Forward entire request body to upstream, overriding model name."""
770
- upstream = get_upstream(model)
771
- url = upstream["url"]
772
- upstream_model = upstream["model"]
773
  logger.info("Routing model=%s β†’ %s (upstream model: %s)", model, url, upstream_model)
774
 
775
  payload = {**body, "model": upstream_model}
@@ -797,10 +834,10 @@ async def send_upstream_request(body: dict, model: str):
797
  "message": message_text or "user: Hello",
798
  "model": upstream_model,
799
  }
800
- return await _send_via_curl(url, gateway_payload)
801
 
802
  curl_payload = {**payload, "stream": False}
803
- return await _send_via_curl(url, curl_payload)
804
 
805
 
806
  # ── Landing Page ────────────────────────────────────────────────────────────
@@ -824,10 +861,16 @@ async def health():
824
 
825
  @app.get("/v1/models")
826
  async def list_models():
827
- return {
828
- "object": "list",
829
- "data": [{"id": m, "object": "model", "owned_by": "apiarium"} for m in _available_models],
830
- }
 
 
 
 
 
 
831
 
832
 
833
  @app.get("/v1/dashboard")
@@ -1081,7 +1124,40 @@ async def admin_models(request: Request):
1081
  if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1082
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1083
 
1084
- # Build dynamic provider lookup: url -> provider name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1085
  prov_lookup = {}
1086
  for pid, prov in _dynamic_providers.items():
1087
  base_url = prov["base_url"].rstrip("/")
@@ -1333,6 +1409,614 @@ async def admin_delete_provider(request: Request, provider_id: str):
1333
  return {"success": True}
1334
 
1335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
  # ── Image Generation Endpoint ─────────────────────────────────────────────────
1337
 
1338
  @app.post("/v1/images/generations")
 
24
  AI_GATEWAY_URL, UPSTREAM_14448_URL,
25
  BLOCKED_IPS,
26
  CHATGPT_IMAGE_API_URL,
27
+ TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, BALANCE_ENDPOINTS,
28
  )
29
  from pydantic import BaseModel
30
  import ipaddress
 
479
  return aliases.get(model, model)
480
 
481
 
482
+ def parse_provider_prefix(model: str):
483
+ """Parse 'provider_name/model_id' format. Returns (provider_name, model_id) or (None, model) if no prefix."""
484
+ if "/" in model:
485
+ parts = model.split("/", 1)
486
+ return parts[0], parts[1]
487
+ return None, model
488
+
489
+
490
+ def _get_upstream_for_model(model: str):
491
+ """Find upstream URL and API key for a model, handling provider prefix routing."""
492
+ provider_name, model_id = parse_provider_prefix(model)
493
+
494
+ if provider_name:
495
+ # Provider prefix routing: find provider by name, route to its base_url
496
+ for pid, prov in _dynamic_providers.items():
497
+ if prov.get("name") == provider_name:
498
+ base_url = prov["base_url"].rstrip("/")
499
+ chat_url = f"{base_url}/chat/completions"
500
+ keys = prov.get("keys", [])
501
+ api_key = keys[0] if keys else ""
502
+ return chat_url, model_id, api_key
503
+ # Provider not found, fall through to default routing
504
+
505
+ # Default routing: use UPSTREAMS dict
506
+ upstreams = UPSTREAMS.get(model_id, [])
507
+ if not upstreams:
508
+ upstreams = UPSTREAMS.get(model, [])
509
+ if not upstreams:
510
+ # Fallback to default upstream
511
+ return DEFAULT_UPSTREAM_URL, model, ""
512
+
513
+ upstream = upstreams[0]
514
+ url = upstream.get("url", DEFAULT_UPSTREAM_URL)
515
+ api_key = UPSTREAM_API_KEYS.get(url, "")
516
+ return url, model_id, api_key
517
+
518
+
519
  def parse_upstream_delta(clean: str) -> dict:
520
  if not clean or clean == "[DONE]" or clean.startswith(":"):
521
  return {}
 
761
  return
762
 
763
 
764
+ async def _send_via_curl(url: str, payload: dict, api_key: str = ""):
765
  headers = build_upstream_headers(url)
766
  curl_args = [
767
  "curl", "-sS", "-X", "POST", url,
 
770
  "--data", json.dumps(payload),
771
  "-w", "\n__APIARIUM_HTTP_STATUS__:%{http_code}",
772
  ]
773
+ # Use provider-specific api_key if provided, otherwise fall back to URL-based headers
774
+ auth = f"Bearer {api_key}" if api_key else headers.get("Authorization")
775
  if auth:
776
  curl_args[7:7] = ["-H", f"Authorization: {auth}"]
777
 
 
806
 
807
  async def send_upstream_request(body: dict, model: str):
808
  """Forward entire request body to upstream, overriding model name."""
809
+ url, upstream_model, api_key = _get_upstream_for_model(model)
 
 
810
  logger.info("Routing model=%s β†’ %s (upstream model: %s)", model, url, upstream_model)
811
 
812
  payload = {**body, "model": upstream_model}
 
834
  "message": message_text or "user: Hello",
835
  "model": upstream_model,
836
  }
837
+ return await _send_via_curl(url, gateway_payload, api_key)
838
 
839
  curl_payload = {**payload, "stream": False}
840
+ return await _send_via_curl(url, curl_payload, api_key)
841
 
842
 
843
  # ── Landing Page ────────────────────────────────────────────────────────────
 
861
 
862
  @app.get("/v1/models")
863
  async def list_models():
864
+ """List all available models. Dynamic provider models are prefixed (e.g. deepseek/deepseek-chat)."""
865
+ data = [{"id": m, "object": "model", "owned_by": "apiarium"} for m in _available_models]
866
+ # Append prefixed models from dynamic providers
867
+ for pid, prov in _dynamic_providers.items():
868
+ prov_name = prov.get("name", pid)
869
+ for m in prov.get("models", []):
870
+ prefixed = f"{prov_name}/{m}"
871
+ if prefixed not in _available_models:
872
+ data.append({"id": prefixed, "object": "model", "owned_by": prov_name})
873
+ return {"object": "list", "data": data}
874
 
875
 
876
  @app.get("/v1/dashboard")
 
1124
  if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1125
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1126
 
1127
+ models = []
1128
+
1129
+ # Dynamic provider models (prefixed: provider_name/model_id)
1130
+ for pid, prov in _dynamic_providers.items():
1131
+ prov_name = prov.get("name", pid)
1132
+ for m in prov.get("models", []):
1133
+ models.append({
1134
+ "name": f"{prov_name}/{m}",
1135
+ "provider": prov_name,
1136
+ "type": "dynamic",
1137
+ })
1138
+
1139
+ # Built-in models (non-prefixed, from UPSTREAMS but not from dynamic providers)
1140
+ dynamic_models = set()
1141
+ for prov in _dynamic_providers.values():
1142
+ dynamic_models.update(prov.get("models", []))
1143
+
1144
+ for model_id in sorted(UPSTREAMS.keys()):
1145
+ if model_id in dynamic_models:
1146
+ continue # Already listed as prefixed
1147
+ upstreams = UPSTREAMS[model_id]
1148
+ urls = [u["url"] for u in upstreams]
1149
+ models.append({
1150
+ "name": model_id,
1151
+ "provider": "built-in",
1152
+ "type": "built-in",
1153
+ })
1154
+
1155
+ return {"models": models}
1156
+
1157
+
1158
+ # Legacy endpoint for backward compatibility (old /admin/models signature)
1159
+ def _legacy_admin_models_logic():
1160
+ """Old logic kept for reference - no longer used."""
1161
  prov_lookup = {}
1162
  for pid, prov in _dynamic_providers.items():
1163
  base_url = prov["base_url"].rstrip("/")
 
1409
  return {"success": True}
1410
 
1411
 
1412
+ # ── Balance Checker ───────────────────────────────────────────────────────────
1413
+
1414
+ def _normalize_balance(data: dict, provider_name: str) -> dict:
1415
+ """Normalize different balance API response formats into a common shape."""
1416
+ result = {"provider": provider_name, "currency": "USD", "balance": None, "used": None, "total": None, "raw": data}
1417
+
1418
+ # DeepSeek format: {balance_infos: [{currency, total_balance, granted_balance, ...}]}
1419
+ if "balance_infos" in data and isinstance(data["balance_infos"], list) and data["balance_infos"]:
1420
+ info = data["balance_infos"][0]
1421
+ result["currency"] = info.get("currency", "USD")
1422
+ result["total"] = float(info.get("total_balance", 0)) + float(info.get("granted_balance", 0))
1423
+ result["used"] = float(info.get("used_balance", 0)) if "used_balance" in info else None
1424
+ if result["total"] is not None and result["used"] is not None:
1425
+ result["balance"] = result["total"] - result["used"]
1426
+ else:
1427
+ result["balance"] = result["total"]
1428
+ return result
1429
+
1430
+ # OpenRouter format: {data: {total_credits, total_usage}}
1431
+ if "data" in data and isinstance(data["data"], dict):
1432
+ inner = data["data"]
1433
+ result["total"] = float(inner.get("total_credits", 0))
1434
+ result["used"] = float(inner.get("total_usage", 0))
1435
+ if result["total"] is not None and result["used"] is not None:
1436
+ result["balance"] = result["total"] - result["used"]
1437
+ return result
1438
+
1439
+ # Generic: try common keys
1440
+ for k in ("balance", "available", "credit", "remaining"):
1441
+ if k in data and data[k] is not None:
1442
+ result["balance"] = float(data[k])
1443
+ break
1444
+ for k in ("total", "total_credits", "limit"):
1445
+ if k in data and data[k] is not None:
1446
+ result["total"] = float(data[k])
1447
+ break
1448
+ for k in ("used", "usage", "spent"):
1449
+ if k in data and data[k] is not None:
1450
+ result["used"] = float(data[k])
1451
+ break
1452
+
1453
+ return result
1454
+
1455
+
1456
+ async def _check_balance_for_provider(provider_id: str) -> dict:
1457
+ """Check balance for a single dynamic provider."""
1458
+ prov = _dynamic_providers.get(provider_id)
1459
+ if not prov:
1460
+ return {"provider_id": provider_id, "error": "Provider not found", "available": False}
1461
+
1462
+ prov_name = (prov.get("name") or "").lower()
1463
+ keys = prov.get("keys", [])
1464
+ if not keys:
1465
+ return {"provider_id": provider_id, "provider": prov_name, "error": "No API keys configured", "available": False}
1466
+
1467
+ # Find balance endpoint: provider override > hardcoded map > None
1468
+ balance_url = prov.get("balance_endpoint_url") or BALANCE_ENDPOINTS.get(prov_name)
1469
+ if not balance_url:
1470
+ return {"provider_id": provider_id, "provider": prov_name, "error": "Balance endpoint not configured for this provider", "available": False}
1471
+
1472
+ try:
1473
+ async with make_client() as client:
1474
+ resp = await client.get(
1475
+ balance_url,
1476
+ headers={"Authorization": f"Bearer {keys[0]}"},
1477
+ timeout=10.0,
1478
+ )
1479
+ if resp.status_code != 200:
1480
+ return {
1481
+ "provider_id": provider_id,
1482
+ "provider": prov_name,
1483
+ "error": f"HTTP {resp.status_code}",
1484
+ "available": False,
1485
+ }
1486
+ data = resp.json()
1487
+ normalized = _normalize_balance(data, prov_name)
1488
+ normalized["provider_id"] = provider_id
1489
+ normalized["available"] = True
1490
+ normalized["checked_at"] = int(time.time())
1491
+ return normalized
1492
+ except Exception as e:
1493
+ return {"provider_id": provider_id, "provider": prov_name, "error": str(e), "available": False}
1494
+
1495
+
1496
+ @app.get("/admin/balance/check/{provider_id}")
1497
+ async def admin_balance_check(request: Request, provider_id: str):
1498
+ auth = request.headers.get("authorization", "")
1499
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1500
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1501
+ result = await _check_balance_for_provider(provider_id)
1502
+ return result
1503
+
1504
+
1505
+ @app.get("/admin/balance/check-all")
1506
+ async def admin_balance_check_all(request: Request):
1507
+ auth = request.headers.get("authorization", "")
1508
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1509
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1510
+ results = []
1511
+ for pid in list(_dynamic_providers.keys()):
1512
+ r = await _check_balance_for_provider(pid)
1513
+ results.append(r)
1514
+ return {"results": results, "checked_at": int(time.time())}
1515
+
1516
+
1517
+ # ── Provider Key Append/Remove ───────────────────────────────────────────────
1518
+
1519
+ class AppendKeysRequest(BaseModel):
1520
+ keys: list = []
1521
+
1522
+
1523
+ @app.post("/admin/providers/{provider_id}/keys")
1524
+ async def admin_append_provider_keys(request: Request, provider_id: str, body: AppendKeysRequest):
1525
+ auth = request.headers.get("authorization", "")
1526
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1527
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1528
+
1529
+ if provider_id not in _dynamic_providers:
1530
+ raise HTTPException(status_code=404, detail={"error": {"message": "Provider not found"}})
1531
+
1532
+ prov = _dynamic_providers[provider_id]
1533
+ existing = prov.get("keys", [])
1534
+ new_keys = [k for k in body.keys if k and k not in existing]
1535
+ if not new_keys:
1536
+ return {"success": True, "added": 0, "total": len(existing)}
1537
+
1538
+ prov["keys"] = existing + new_keys
1539
+
1540
+ # Re-register in UPSTREAM_API_KEYS (first key as primary)
1541
+ base_url = prov["base_url"].rstrip("/")
1542
+ chat_url = f"{base_url}/chat/completions"
1543
+ UPSTREAM_API_KEYS[chat_url] = prov["keys"][0]
1544
+
1545
+ _save_providers()
1546
+ return {"success": True, "added": len(new_keys), "total": len(prov["keys"])}
1547
+
1548
+
1549
+ @app.delete("/admin/providers/{provider_id}/keys/{idx}")
1550
+ async def admin_remove_provider_key(request: Request, provider_id: str, idx: int):
1551
+ auth = request.headers.get("authorization", "")
1552
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1553
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1554
+
1555
+ if provider_id not in _dynamic_providers:
1556
+ raise HTTPException(status_code=404, detail={"error": {"message": "Provider not found"}})
1557
+
1558
+ prov = _dynamic_providers[provider_id]
1559
+ keys = prov.get("keys", [])
1560
+ if idx < 0 or idx >= len(keys):
1561
+ raise HTTPException(status_code=400, detail={"error": {"message": "Invalid key index"}})
1562
+
1563
+ keys.pop(idx)
1564
+ prov["keys"] = keys
1565
+
1566
+ base_url = prov["base_url"].rstrip("/")
1567
+ chat_url = f"{base_url}/chat/completions"
1568
+ if keys:
1569
+ UPSTREAM_API_KEYS[chat_url] = keys[0]
1570
+ elif chat_url in UPSTREAM_API_KEYS:
1571
+ del UPSTREAM_API_KEYS[chat_url]
1572
+
1573
+ _save_providers()
1574
+ return {"success": True, "remaining": len(keys)}
1575
+
1576
+
1577
+ # ── Turnstile Verification ───────────────────────────────────────────────────
1578
+
1579
+ class TurnstileVerifyRequest(BaseModel):
1580
+ token: str
1581
+
1582
+
1583
+ @app.post("/admin/verify-turnstile")
1584
+ async def admin_verify_turnstile(body: TurnstileVerifyRequest):
1585
+ if not TURNSTILE_SECRET_KEY:
1586
+ return {"success": True, "note": "Turnstile not configured, bypassed"}
1587
+ try:
1588
+ async with make_client() as client:
1589
+ resp = await client.post(
1590
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
1591
+ data={"response": body.token, "secret": TURNSTILE_SECRET_KEY},
1592
+ timeout=10.0,
1593
+ )
1594
+ result = resp.json()
1595
+ return {"success": bool(result.get("success")), "raw": result}
1596
+ except Exception as e:
1597
+ return {"success": False, "error": str(e)}
1598
+
1599
+
1600
+ # ── Multi-Page Admin HTML Routes ─────────────────────────────────────────────
1601
+
1602
+ _ADMIN_NAV = [
1603
+ ("/admin/keys", "API Keys", "πŸ”‘"),
1604
+ ("/admin/providers", "Providers", "πŸ”Œ"),
1605
+ ("/admin/balance", "Balance", "πŸ’°"),
1606
+ ("/admin/models", "Models", "πŸ€–"),
1607
+ ("/admin/settings", "Settings", "βš™οΈ"),
1608
+ ]
1609
+
1610
+
1611
+ def _admin_layout(title: str, active: str, body_html: str, turnstile_site_key: str = "") -> str:
1612
+ """Render shared admin layout with sidebar navigation."""
1613
+ nav_items = ""
1614
+ for href, label, icon in _ADMIN_NAV:
1615
+ active_cls = "bg-amber-500/10 text-amber-400 border-amber-500/30" if href == active else "text-slate-400 border-transparent hover:text-slate-200 hover:border-slate-700"
1616
+ nav_items += f'<a href="{href}" class="flex items-center gap-3 px-4 py-2.5 rounded-lg border text-sm font-medium transition {active_cls}"><span class="text-base">{icon}</span>{label}</a>\n'
1617
+
1618
+ return f"""<!DOCTYPE html>
1619
+ <html lang="en" class="antialiased">
1620
+ <head>
1621
+ <meta charset="UTF-8">
1622
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1623
+ <title>APIarium | {title}</title>
1624
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1625
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1626
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
1627
+ <script src="https://cdn.tailwindcss.com"></script>
1628
+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
1629
+ <script>
1630
+ tailwind.config = {{
1631
+ theme: {{
1632
+ extend: {{
1633
+ fontFamily: {{
1634
+ sans: ['Inter', 'sans-serif'],
1635
+ mono: ['JetBrains Mono', 'monospace'],
1636
+ }},
1637
+ }}
1638
+ }}
1639
+ }}
1640
+ const MASTER_KEY = localStorage.getItem('apiarium_master_key') || '';
1641
+ const TURNSTILE_SITE_KEY = '{turnstile_site_key}';
1642
+ function authHeaders() {{ return {{ 'Authorization': 'Bearer ' + MASTER_KEY, 'Content-Type': 'application/json' }}; }}
1643
+ function checkAuth() {{
1644
+ if (!MASTER_KEY) {{ window.location.href = '/admin/login'; return false; }}
1645
+ return true;
1646
+ }}
1647
+ </script>
1648
+ </head>
1649
+ <body class="bg-slate-950 text-slate-100 min-h-screen">
1650
+ <div class="flex min-h-screen">
1651
+ <aside class="w-64 bg-slate-900/50 border-r border-white/5 p-6 flex flex-col gap-2">
1652
+ <div class="mb-8">
1653
+ <h1 class="text-xl font-bold flex items-center gap-2">🐝 APIarium</h1>
1654
+ <p class="text-xs text-slate-500 mt-1">Admin Console</p>
1655
+ </div>
1656
+ <nav class="flex flex-col gap-1">
1657
+ {nav_items}
1658
+ </nav>
1659
+ <div class="mt-auto pt-6 border-t border-white/5">
1660
+ <a href="/" class="text-sm text-slate-500 hover:text-slate-300">← Back to site</a>
1661
+ <button onclick="localStorage.removeItem('apiarium_master_key'); window.location.href='/admin/login'" class="block mt-2 text-sm text-red-400/70 hover:text-red-400">Logout</button>
1662
+ </div>
1663
+ </aside>
1664
+ <main class="flex-1 p-8 overflow-auto">
1665
+ <h2 class="text-2xl font-bold mb-6">{title}</h2>
1666
+ {body_html}
1667
+ </main>
1668
+ </div>
1669
+ </body>
1670
+ </html>"""
1671
+
1672
+
1673
+ @app.get("/admin")
1674
+ async def admin_root():
1675
+ from fastapi.responses import RedirectResponse
1676
+ return RedirectResponse(url="/admin/keys")
1677
+
1678
+
1679
+ @app.get("/admin/login", response_class=HTMLResponse)
1680
+ async def admin_login_page():
1681
+ html = """<!DOCTYPE html>
1682
+ <html lang="en" class="antialiased">
1683
+ <head>
1684
+ <meta charset="UTF-8">
1685
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1686
+ <title>APIarium | Admin Login</title>
1687
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
1688
+ <script src="https://cdn.tailwindcss.com"></script>
1689
+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
1690
+ </head>
1691
+ <body class="bg-slate-950 text-slate-100 min-h-screen flex items-center justify-center p-4">
1692
+ <div class="w-full max-w-md">
1693
+ <div class="text-center mb-8">
1694
+ <h1 class="text-3xl font-bold">🐝 APIarium</h1>
1695
+ <p class="text-slate-400 mt-2">Admin Login</p>
1696
+ </div>
1697
+ <div class="bg-slate-900/50 border border-white/5 rounded-xl p-8">
1698
+ <label class="block text-xs font-mono text-slate-400 uppercase tracking-wider mb-2">Master Key</label>
1699
+ <input type="password" id="master-key" placeholder="Enter master key" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-3 text-sm font-mono focus:outline-none focus:border-amber-500/50 mb-4">
1700
+ <div id="turnstile-widget" class="flex justify-center mb-4"></div>
1701
+ <div id="login-error" class="text-red-400 text-sm mb-4 hidden"></div>
1702
+ <button id="login-btn" class="w-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black font-semibold px-5 py-3 rounded-lg transition text-sm">
1703
+ Sign in
1704
+ </button>
1705
+ </div>
1706
+ </div>
1707
+ <script>
1708
+ let turnstileToken = null;
1709
+ const TURNSTILE_SITE_KEY = '__TURNSTILE_SITE_KEY__';
1710
+
1711
+ if (TURNSTILE_SITE_KEY) {
1712
+ turnstile.render('#turnstile-widget', {
1713
+ sitekey: TURNSTILE_SITE_KEY,
1714
+ callback: (token) => { turnstileToken = token; }
1715
+ });
1716
+ }
1717
+
1718
+ document.getElementById('login-btn').addEventListener('click', async () => {
1719
+ const errEl = document.getElementById('login-error');
1720
+ errEl.classList.add('hidden');
1721
+ const key = document.getElementById('master-key').value.trim();
1722
+ if (!key) {
1723
+ errEl.textContent = 'Master key required';
1724
+ errEl.classList.remove('hidden');
1725
+ return;
1726
+ }
1727
+ if (TURNSTILE_SITE_KEY && !turnstileToken) {
1728
+ errEl.textContent = 'Please complete the Turnstile challenge';
1729
+ errEl.classList.remove('hidden');
1730
+ return;
1731
+ }
1732
+
1733
+ // Verify turnstile if configured
1734
+ if (TURNSTILE_SITE_KEY) {
1735
+ try {
1736
+ const v = await fetch('/admin/verify-turnstile', {
1737
+ method: 'POST',
1738
+ headers: {'Content-Type': 'application/json'},
1739
+ body: JSON.stringify({token: turnstileToken})
1740
+ }).then(r => r.json());
1741
+ if (!v.success) {
1742
+ errEl.textContent = 'Turnstile verification failed';
1743
+ errEl.classList.remove('hidden');
1744
+ return;
1745
+ }
1746
+ } catch (e) {
1747
+ errEl.textContent = 'Turnstile verification error: ' + e.message;
1748
+ errEl.classList.remove('hidden');
1749
+ return;
1750
+ }
1751
+ }
1752
+
1753
+ // Test master key against /admin/keys
1754
+ try {
1755
+ const r = await fetch('/admin/keys', {headers: {'Authorization': 'Bearer ' + key}});
1756
+ if (r.status === 401) {
1757
+ errEl.textContent = 'Invalid master key';
1758
+ errEl.classList.remove('hidden');
1759
+ return;
1760
+ }
1761
+ if (!r.ok) throw new Error('HTTP ' + r.status);
1762
+ } catch (e) {
1763
+ errEl.textContent = 'Connection error: ' + e.message;
1764
+ errEl.classList.remove('hidden');
1765
+ return;
1766
+ }
1767
+
1768
+ localStorage.setItem('apiarium_master_key', key);
1769
+ window.location.href = '/admin/keys';
1770
+ });
1771
+ </script>
1772
+ </body>
1773
+ </html>"""
1774
+ html = html.replace("__TURNSTILE_SITE_KEY__", TURNSTILE_SITE_KEY or "")
1775
+ return HTMLResponse(content=html)
1776
+
1777
+
1778
+ @app.get("/admin/keys", response_class=HTMLResponse)
1779
+ async def admin_keys_page():
1780
+ body = """
1781
+ <div id="keys-list" class="bg-slate-900/30 border border-white/5 rounded-xl p-6">
1782
+ <div class="text-slate-500 text-sm">Loading...</div>
1783
+ </div>
1784
+ <script>
1785
+ if (!checkAuth()) throw new Error('no auth');
1786
+ (async () => {
1787
+ const r = await fetch('/admin/keys', {headers: authHeaders()}).then(r => r.json());
1788
+ const el = document.getElementById('keys-list');
1789
+ if (!r.keys || r.keys.length === 0) {
1790
+ el.innerHTML = '<div class="text-slate-500 text-sm">No admin-created keys yet.</div>';
1791
+ return;
1792
+ }
1793
+ let html = '<table class="w-full text-sm"><thead><tr class="text-left text-slate-400 text-xs uppercase"><th class="py-2">Key</th><th class="py-2">IP</th><th class="py-2">Label</th><th class="py-2">Created</th></tr></thead><tbody>';
1794
+ r.keys.forEach(k => {
1795
+ html += `<tr class="border-t border-white/5"><td class="py-2 font-mono text-xs">${k.key.slice(0,16)}...</td><td class="py-2">${k.ip||''}</td><td class="py-2">${k.label||''}</td><td class="py-2 text-slate-500">${k.created_at||''}</td></tr>`;
1796
+ });
1797
+ html += '</tbody></table>';
1798
+ el.innerHTML = html;
1799
+ })();
1800
+ </script>
1801
+ """
1802
+ return HTMLResponse(content=_admin_layout("API Keys", "/admin/keys", body, TURNSTILE_SITE_KEY))
1803
+
1804
+
1805
+ @app.get("/admin/providers", response_class=HTMLResponse)
1806
+ async def admin_providers_page():
1807
+ body = """
1808
+ <div class="grid md:grid-cols-2 gap-6">
1809
+ <div class="bg-slate-900/30 border border-white/5 rounded-xl p-6">
1810
+ <h3 class="text-lg font-semibold mb-4">Add New Provider</h3>
1811
+ <form id="add-provider-form" class="flex flex-col gap-3">
1812
+ <input type="text" name="name" placeholder="Provider name (e.g. deepseek)" required class="bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-amber-500/50">
1813
+ <input type="text" name="base_url" placeholder="Base URL (e.g. https://api.deepseek.com/v1)" required class="bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-amber-500/50">
1814
+ <input type="text" name="balance_endpoint_url" placeholder="Balance endpoint URL (optional override)" class="bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-amber-500/50">
1815
+ <textarea name="models" placeholder="Models (one per line)" rows="2" class="bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-sm font-mono focus:outline-none focus:border-amber-500/50 resize-none"></textarea>
1816
+ <textarea name="keys" placeholder="API keys (one per line)" rows="2" class="bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-sm font-mono focus:outline-none focus:border-amber-500/50 resize-none"></textarea>
1817
+ <button type="submit" class="bg-amber-500 hover:bg-amber-400 text-black font-semibold py-2 rounded-lg text-sm">Add Provider</button>
1818
+ <div id="add-result" class="text-sm"></div>
1819
+ </form>
1820
+ </div>
1821
+ <div class="bg-slate-900/30 border border-white/5 rounded-xl p-6">
1822
+ <h3 class="text-lg font-semibold mb-4">Registered Providers</h3>
1823
+ <div id="providers-list">Loading...</div>
1824
+ </div>
1825
+ </div>
1826
+ <script>
1827
+ if (!checkAuth()) throw new Error('no auth');
1828
+ async function loadProviders() {
1829
+ const r = await fetch('/admin/providers', {headers: authHeaders()}).then(r => r.json());
1830
+ const el = document.getElementById('providers-list');
1831
+ if (!r.providers || r.providers.length === 0) {
1832
+ el.innerHTML = '<div class="text-slate-500 text-sm">No dynamic providers yet.</div>';
1833
+ return;
1834
+ }
1835
+ let html = '<div class="flex flex-col gap-3">';
1836
+ r.providers.forEach(p => {
1837
+ const keysCount = (p.keys || []).length;
1838
+ const modelsCount = (p.models || []).length;
1839
+ html += `<div class="bg-black/30 border border-white/5 rounded-lg p-4">
1840
+ <div class="flex items-center justify-between mb-2">
1841
+ <div class="font-semibold">${p.name}</div>
1842
+ <button onclick="deleteProvider('${p.id}')" class="text-red-400/70 hover:text-red-400 text-xs">Delete</button>
1843
+ </div>
1844
+ <div class="text-xs text-slate-500 font-mono truncate mb-2">${p.base_url}</div>
1845
+ <div class="flex gap-3 text-xs text-slate-400">
1846
+ <span>πŸ”‘ ${keysCount} keys</span>
1847
+ <span>πŸ€– ${modelsCount} models</span>
1848
+ </div>
1849
+ <div class="mt-2 flex gap-2">
1850
+ <button onclick="appendKeyPrompt('${p.id}')" class="text-xs bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 px-2 py-1 rounded">+ Add Key</button>
1851
+ <button onclick="checkBalance('${p.id}')" class="text-xs bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 px-2 py-1 rounded">πŸ’° Balance</button>
1852
+ </div>
1853
+ <div id="balance-${p.id}" class="mt-2 text-xs hidden"></div>
1854
+ </div>`;
1855
+ });
1856
+ html += '</div>';
1857
+ el.innerHTML = html;
1858
+ }
1859
+ async function deleteProvider(id) {
1860
+ if (!confirm('Delete this provider?')) return;
1861
+ await fetch('/admin/providers/' + id, {method: 'DELETE', headers: authHeaders()});
1862
+ loadProviders();
1863
+ }
1864
+ async function appendKeyPrompt(id) {
1865
+ const k = prompt('Enter new API key:');
1866
+ if (!k) return;
1867
+ const r = await fetch('/admin/providers/' + id + '/keys', {
1868
+ method: 'POST', headers: authHeaders(),
1869
+ body: JSON.stringify({keys: [k]})
1870
+ }).then(r => r.json());
1871
+ alert(r.success ? `Added ${r.added} key(s)` : 'Failed: ' + JSON.stringify(r));
1872
+ loadProviders();
1873
+ }
1874
+ async function checkBalance(id) {
1875
+ const el = document.getElementById('balance-' + id);
1876
+ el.classList.remove('hidden');
1877
+ el.innerHTML = '<span class="text-slate-400">Checking...</span>';
1878
+ const r = await fetch('/admin/balance/check/' + id, {headers: authHeaders()}).then(r => r.json());
1879
+ if (!r.available) {
1880
+ el.innerHTML = `<span class="text-red-400">${r.error || 'Unavailable'}</span>`;
1881
+ return;
1882
+ }
1883
+ el.innerHTML = `<span class="text-green-400">Balance: ${r.balance ?? '?'} ${r.currency || ''} (total: ${r.total ?? '?'}, used: ${r.used ?? '?'})</span>`;
1884
+ }
1885
+ document.getElementById('add-provider-form').addEventListener('submit', async (e) => {
1886
+ e.preventDefault();
1887
+ const f = e.target;
1888
+ const payload = {
1889
+ name: f.name.value.trim(),
1890
+ base_url: f.base_url.value.trim(),
1891
+ balance_endpoint_url: f.balance_endpoint_url.value.trim() || null,
1892
+ models: f.models.value.split('\n').map(s => s.trim()).filter(Boolean),
1893
+ keys: f.keys.value.split('\n').map(s => s.trim()).filter(Boolean),
1894
+ };
1895
+ const r = await fetch('/admin/providers/import', {
1896
+ method: 'POST', headers: authHeaders(),
1897
+ body: JSON.stringify(payload)
1898
+ });
1899
+ const resEl = document.getElementById('add-result');
1900
+ if (r.ok) {
1901
+ resEl.innerHTML = '<span class="text-green-400">βœ“ Added</span>';
1902
+ f.reset();
1903
+ loadProviders();
1904
+ } else {
1905
+ const j = await r.json().catch(() => ({}));
1906
+ resEl.innerHTML = '<span class="text-red-400">Error: ' + (j.detail?.error?.message || r.status) + '</span>';
1907
+ }
1908
+ });
1909
+ loadProviders();
1910
+ </script>
1911
+ """
1912
+ return HTMLResponse(content=_admin_layout("Providers", "/admin/providers", body, TURNSTILE_SITE_KEY))
1913
+
1914
+
1915
+ @app.get("/admin/balance", response_class=HTMLResponse)
1916
+ async def admin_balance_page():
1917
+ body = """
1918
+ <div class="bg-slate-900/30 border border-white/5 rounded-xl p-6">
1919
+ <div class="flex items-center justify-between mb-4">
1920
+ <h3 class="text-lg font-semibold">Balance Overview</h3>
1921
+ <button id="refresh-all" class="bg-amber-500 hover:bg-amber-400 text-black font-semibold px-4 py-2 rounded-lg text-sm">Refresh All</button>
1922
+ </div>
1923
+ <div id="balance-results"><div class="text-slate-500 text-sm">Click "Refresh All" to check balances.</div></div>
1924
+ </div>
1925
+ <script>
1926
+ if (!checkAuth()) throw new Error('no auth');
1927
+ document.getElementById('refresh-all').addEventListener('click', async () => {
1928
+ const el = document.getElementById('balance-results');
1929
+ el.innerHTML = '<div class="text-slate-400 text-sm">Checking all providers...</div>';
1930
+ const r = await fetch('/admin/balance/check-all', {headers: authHeaders()}).then(r => r.json());
1931
+ if (!r.results || r.results.length === 0) {
1932
+ el.innerHTML = '<div class="text-slate-500 text-sm">No dynamic providers configured.</div>';
1933
+ return;
1934
+ }
1935
+ let html = '<table class="w-full text-sm"><thead><tr class="text-left text-slate-400 text-xs uppercase"><th class="py-2">Provider</th><th class="py-2">Balance</th><th class="py-2">Total</th><th class="py-2">Used</th><th class="py-2">Status</th></tr></thead><tbody>';
1936
+ r.results.forEach(x => {
1937
+ const status = x.available
1938
+ ? '<span class="text-green-400">βœ“ OK</span>'
1939
+ : '<span class="text-red-400">' + (x.error || 'Error') + '</span>';
1940
+ html += `<tr class="border-t border-white/5">
1941
+ <td class="py-2">${x.provider || x.provider_id}</td>
1942
+ <td class="py-2 font-mono">${x.balance ?? 'β€”'} ${x.currency || ''}</td>
1943
+ <td class="py-2 text-slate-400 font-mono">${x.total ?? 'β€”'}</td>
1944
+ <td class="py-2 text-slate-400 font-mono">${x.used ?? 'β€”'}</td>
1945
+ <td class="py-2">${status}</td>
1946
+ </tr>`;
1947
+ });
1948
+ html += '</tbody></table>';
1949
+ el.innerHTML = html;
1950
+ });
1951
+ </script>
1952
+ """
1953
+ return HTMLResponse(content=_admin_layout("Balance", "/admin/balance", body, TURNSTILE_SITE_KEY))
1954
+
1955
+
1956
+ @app.get("/admin/models", response_class=HTMLResponse)
1957
+ async def admin_models_page():
1958
+ body = """
1959
+ <div class="bg-slate-900/30 border border-white/5 rounded-xl p-6">
1960
+ <div class="flex items-center justify-between mb-4">
1961
+ <h3 class="text-lg font-semibold">Available Models</h3>
1962
+ <span class="text-xs text-slate-500">Models are listed with provider prefixes where applicable</span>
1963
+ </div>
1964
+ <div id="models-list">Loading...</div>
1965
+ </div>
1966
+ <script>
1967
+ if (!checkAuth()) throw new Error('no auth');
1968
+ (async () => {
1969
+ const r = await fetch('/admin/models', {headers: authHeaders()}).then(r => r.json());
1970
+ const el = document.getElementById('models-list');
1971
+ const models = r.models || [];
1972
+ if (models.length === 0) {
1973
+ el.innerHTML = '<div class="text-slate-500 text-sm">No models registered.</div>';
1974
+ return;
1975
+ }
1976
+ let html = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">';
1977
+ models.forEach(m => {
1978
+ html += `<div class="bg-black/30 border border-white/5 rounded-lg px-3 py-2 text-xs font-mono">${m}</div>`;
1979
+ });
1980
+ html += '</div>';
1981
+ el.innerHTML = html;
1982
+ })();
1983
+ </script>
1984
+ """
1985
+ return HTMLResponse(content=_admin_layout("Models", "/admin/models", body, TURNSTILE_SITE_KEY))
1986
+
1987
+
1988
+ @app.get("/admin/settings", response_class=HTMLResponse)
1989
+ async def admin_settings_page():
1990
+ body = """
1991
+ <div class="grid md:grid-cols-2 gap-6">
1992
+ <div class="bg-slate-900/30 border border-white/5 rounded-xl p-6">
1993
+ <h3 class="text-lg font-semibold mb-4">System Info</h3>
1994
+ <div class="space-y-2 text-sm">
1995
+ <div class="flex justify-between"><span class="text-slate-400">Turnstile:</span><span id="turnstile-status" class="font-mono">...</span></div>
1996
+ <div class="flex justify-between"><span class="text-slate-400">GitHub Gist:</span><span id="gist-status" class="font-mono">...</span></div>
1997
+ <div class="flex justify-between"><span class="text-slate-400">HF Dataset:</span><span class="font-mono">__HF_DATASET__</span></div>
1998
+ </div>
1999
+ </div>
2000
+ <div class="bg-slate-900/30 border border-white/5 rounded-xl p-6">
2001
+ <h3 class="text-lg font-semibold mb-4">Blocked IPs</h3>
2002
+ <div id="blocked-ips" class="space-y-1 text-sm font-mono text-slate-400">
2003
+ __BLOCKED_IPS_HTML__
2004
+ </div>
2005
+ </div>
2006
+ </div>
2007
+ <script>
2008
+ if (!checkAuth()) throw new Error('no auth');
2009
+ document.getElementById('turnstile-status').textContent = '__TURNSTILE_STATUS__';
2010
+ document.getElementById('gist-status').textContent = '__GIST_STATUS__';
2011
+ </script>
2012
+ """
2013
+ body = body.replace("__HF_DATASET__", HF_DATASET or "not configured")
2014
+ body = body.replace("__BLOCKED_IPS_HTML__", "<br>".join(BLOCKED_IPS) if BLOCKED_IPS else "None")
2015
+ body = body.replace("__TURNSTILE_STATUS__", "βœ“ Configured" if TURNSTILE_SECRET_KEY else "βœ— Disabled")
2016
+ body = body.replace("__GIST_STATUS__", "βœ“ Configured" if (GITHUB_TOKEN and GIST_ID) else "βœ— Disabled")
2017
+ return HTMLResponse(content=_admin_layout("Settings", "/admin/settings", body, TURNSTILE_SITE_KEY))
2018
+
2019
+
2020
  # ── Image Generation Endpoint ─────────────────────────────────────────────────
2021
 
2022
  @app.post("/v1/images/generations")