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
- app/config.py +12 -0
- 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 828 |
-
|
| 829 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|