Spaces:
Running
Running
R.C.M. commited on
Commit ·
67b3d14
1
Parent(s): 53f3964
Remove from hf space
Browse files- Dockerfile +0 -15
- app.py +0 -863
- assets/admins.json +0 -56
- assets/extra_usage.json +0 -7
- assets/payg_config.json +0 -119
- gen/__init__.py +0 -21
- gen/chat.py +0 -361
- gen/common.py +0 -35
- gen/media.py +0 -408
- gen/models.py +0 -10
- gen/override.py +0 -133
- gen/prompt.py +0 -101
- helper/assets.py +0 -80
- helper/keywords.py +0 -274
- helper/payg.py +0 -670
- helper/ratelimit.py +0 -522
- helper/subscriptions.py +0 -436
- models_override.json +0 -39
- payg/__init__.py +0 -5
- payg/account.py +0 -208
- payg/chat.py +0 -292
- payg/common.py +0 -316
- payg/media.py +0 -307
- payg/stripe.py +0 -250
- prod-ca-2021.crt +0 -23
- requirements.txt +0 -8
- status.py +0 -69
Dockerfile
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
FROM python:3.13-slim
|
| 2 |
-
|
| 3 |
-
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
| 4 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 5 |
-
|
| 6 |
-
RUN useradd -m -u 1000 user
|
| 7 |
-
USER user
|
| 8 |
-
ENV PATH="/home/user/.local/bin:$PATH"
|
| 9 |
-
|
| 10 |
-
WORKDIR /app
|
| 11 |
-
|
| 12 |
-
COPY --chown=user start.sh /app/start.sh
|
| 13 |
-
RUN chmod +x /app/start.sh
|
| 14 |
-
|
| 15 |
-
CMD ["/app/start.sh"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
DELETED
|
@@ -1,863 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import time
|
| 3 |
-
import hashlib
|
| 4 |
-
import hmac
|
| 5 |
-
from fastapi import WebSocket, WebSocketDisconnect
|
| 6 |
-
from collections import defaultdict, deque
|
| 7 |
-
import json
|
| 8 |
-
from fastapi import FastAPI, Request, HTTPException, status, Header
|
| 9 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
-
from fastapi.responses import (
|
| 11 |
-
Response,
|
| 12 |
-
JSONResponse,
|
| 13 |
-
StreamingResponse,
|
| 14 |
-
RedirectResponse,
|
| 15 |
-
)
|
| 16 |
-
import httpx
|
| 17 |
-
from bs4 import BeautifulSoup
|
| 18 |
-
from typing import List, Dict, Any
|
| 19 |
-
import asyncio
|
| 20 |
-
import re
|
| 21 |
-
import random
|
| 22 |
-
from urllib.parse import quote
|
| 23 |
-
import base64
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
from helper.subscriptions import (
|
| 26 |
-
fetch_subscription,
|
| 27 |
-
normalize_plan_key,
|
| 28 |
-
TIER_CONFIG,
|
| 29 |
-
PLAN_ORDER,
|
| 30 |
-
resolve_token_identity,
|
| 31 |
-
)
|
| 32 |
-
from typing import Optional
|
| 33 |
-
from helper.keywords import *
|
| 34 |
-
from helper.assets import asset_router
|
| 35 |
-
|
| 36 |
-
from helper.ratelimit import (
|
| 37 |
-
enforce_rate_limit,
|
| 38 |
-
resolve_rate_limit_identity,
|
| 39 |
-
check_audio_rate_limit,
|
| 40 |
-
check_video_rate_limit,
|
| 41 |
-
check_image_rate_limit,
|
| 42 |
-
MAX_CHAT_PROMPT_BYTES,
|
| 43 |
-
MAX_CHAT_PROMPT_CHARS,
|
| 44 |
-
MAX_GROQ_PROMPT_BYTES,
|
| 45 |
-
MAX_GROQ_PROMPT_CHARS,
|
| 46 |
-
MAX_MEDIA_PROMPT_BYTES,
|
| 47 |
-
MAX_MEDIA_PROMPT_CHARS,
|
| 48 |
-
extract_user_text,
|
| 49 |
-
calculate_messages_size,
|
| 50 |
-
normalize_prompt_value,
|
| 51 |
-
enforce_prompt_size,
|
| 52 |
-
resolve_bound_subject,
|
| 53 |
-
get_usage_snapshot_for_subject,
|
| 54 |
-
consume_metric_for_subject,
|
| 55 |
-
get_metric_snapshot_for_subject,
|
| 56 |
-
normalize_forwarded_ip,
|
| 57 |
-
resolve_verify_token_rate_limit_identity,
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
from status import router as status_router
|
| 61 |
-
from gen import router as gen_router
|
| 62 |
-
from payg import router as payg_router
|
| 63 |
-
|
| 64 |
-
app = FastAPI()
|
| 65 |
-
def print_env_for_dotenv():
|
| 66 |
-
import os
|
| 67 |
-
for key, value in os.environ.items():
|
| 68 |
-
safe = str(value).replace('"', '\\"')
|
| 69 |
-
print(f'{key}="{safe}"')
|
| 70 |
-
print_env_for_dotenv()
|
| 71 |
-
LIGHTNING_TRANSFER_TOKEN = os.getenv("LIGHTNING_TRANSFER_TOKEN")
|
| 72 |
-
VERIFY_TOKEN_USAGE_METRIC = "verifyTokenWithEmailDaily"
|
| 73 |
-
VERIFY_TOKEN_FEATURE_NAME = "Token Verification Requests"
|
| 74 |
-
VERIFY_TOKEN_CONTACT_NOTICE = (
|
| 75 |
-
"Need more usage? Contact us at inferenceportai@gmail.com."
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
WEBSOCKET_KEY = os.getenv("WEBSOCKET_KEY")
|
| 79 |
-
|
| 80 |
-
# authentication attempt tracking
|
| 81 |
-
AUTH_ATTEMPTS = defaultdict(lambda: deque())
|
| 82 |
-
|
| 83 |
-
AUTH_WINDOW_SECONDS = 60
|
| 84 |
-
AUTH_MAX_ATTEMPTS = 10
|
| 85 |
-
|
| 86 |
-
app.add_middleware(
|
| 87 |
-
CORSMiddleware,
|
| 88 |
-
allow_origins=["*"],
|
| 89 |
-
allow_methods=["GET", "POST", "PUT", "DELETE", "HEAD"],
|
| 90 |
-
allow_headers=["*"],
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
app.include_router(asset_router)
|
| 94 |
-
app.include_router(status_router)
|
| 95 |
-
app.include_router(gen_router)
|
| 96 |
-
app.include_router(payg_router)
|
| 97 |
-
|
| 98 |
-
TOOL_REGISTRY_ROOT = Path(os.getenv("TOOL_REGISTRY_ROOT", "/tools"))
|
| 99 |
-
TOOL_REGISTRY_INDEX = TOOL_REGISTRY_ROOT / "registry_index.json"
|
| 100 |
-
TOOL_REGISTRY_LOCK = asyncio.Lock()
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def _ensure_tool_registry_root() -> None:
|
| 104 |
-
TOOL_REGISTRY_ROOT.mkdir(parents=True, exist_ok=True)
|
| 105 |
-
if not TOOL_REGISTRY_INDEX.exists():
|
| 106 |
-
TOOL_REGISTRY_INDEX.write_text("{}", encoding="utf-8")
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def _read_tool_registry_index() -> Dict[str, Dict[str, Any]]:
|
| 110 |
-
_ensure_tool_registry_root()
|
| 111 |
-
try:
|
| 112 |
-
raw = TOOL_REGISTRY_INDEX.read_text(encoding="utf-8")
|
| 113 |
-
data = json.loads(raw)
|
| 114 |
-
if isinstance(data, dict):
|
| 115 |
-
return data
|
| 116 |
-
except Exception:
|
| 117 |
-
pass
|
| 118 |
-
return {}
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def _write_tool_registry_index(index: Dict[str, Dict[str, Any]]) -> None:
|
| 122 |
-
_ensure_tool_registry_root()
|
| 123 |
-
TOOL_REGISTRY_INDEX.write_text(json.dumps(index, indent=2), encoding="utf-8")
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
def _sanitize_tool_id(value: str) -> str:
|
| 127 |
-
tool_id = re.sub(r"[^a-zA-Z0-9_-]", "-", str(value or "").strip().lower())
|
| 128 |
-
tool_id = re.sub(r"-{2,}", "-", tool_id).strip("-")
|
| 129 |
-
return tool_id[:80]
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def _sanitize_tool_file_name(value: str) -> str:
|
| 133 |
-
name = Path(value or "tool").name
|
| 134 |
-
name = re.sub(r"[^a-zA-Z0-9._-]", "_", name)
|
| 135 |
-
return name[:120]
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def _normalize_tool_version(value: Any) -> str:
|
| 139 |
-
version = str(value or "0.1.0").strip()
|
| 140 |
-
if re.match(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$", version):
|
| 141 |
-
return version[:64]
|
| 142 |
-
return "0.1.0"
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def _normalize_https_url(value: Any) -> str:
|
| 146 |
-
raw = str(value or "").strip()
|
| 147 |
-
if raw.startswith("https://"):
|
| 148 |
-
return raw[:512]
|
| 149 |
-
return ""
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
def _to_registry_record(manifest: Dict[str, Any], *, now_iso: str) -> Dict[str, Any]:
|
| 153 |
-
tool_id = _sanitize_tool_id(manifest.get("id") or "")
|
| 154 |
-
code_file = _sanitize_tool_file_name(manifest.get("codeFile") or "tool")
|
| 155 |
-
visibility = str(manifest.get("visibility") or "public").strip().lower()
|
| 156 |
-
if visibility not in {"public", "unlisted"}:
|
| 157 |
-
visibility = "public"
|
| 158 |
-
return {
|
| 159 |
-
"id": tool_id,
|
| 160 |
-
"name": str(manifest.get("name") or tool_id),
|
| 161 |
-
"functionality": str(manifest.get("functionality") or ""),
|
| 162 |
-
"version": str(manifest.get("version") or "0.1.0"),
|
| 163 |
-
"releaseNotes": str(manifest.get("releaseNotes") or ""),
|
| 164 |
-
"websiteUrl": str(manifest.get("websiteUrl") or ""),
|
| 165 |
-
"language": str(manifest.get("language") or "javascript"),
|
| 166 |
-
"authorEmail": str(manifest.get("authorEmail") or ""),
|
| 167 |
-
"authorUserId": manifest.get("authorUserId"),
|
| 168 |
-
"codeHash": manifest.get("codeHash"),
|
| 169 |
-
"visibility": visibility,
|
| 170 |
-
"publishedAt": str(manifest.get("createdAt") or now_iso),
|
| 171 |
-
"updatedAt": now_iso,
|
| 172 |
-
"requirements": manifest.get("requirements") or {"runtime": [], "build": []},
|
| 173 |
-
"userInputs": manifest.get("userInputs") if isinstance(manifest.get("userInputs"), list) else [],
|
| 174 |
-
"openai": manifest.get("openai") or {},
|
| 175 |
-
"files": {
|
| 176 |
-
"manifestPath": f"{tool_id}/manifest.json",
|
| 177 |
-
"codePath": f"{tool_id}/{code_file}",
|
| 178 |
-
},
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
async def _require_registry_identity(authorization: Optional[str]) -> Dict[str, Any]:
|
| 183 |
-
if not authorization or not authorization.startswith("Bearer "):
|
| 184 |
-
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
| 185 |
-
token = authorization.split(" ", 1)[1].strip()
|
| 186 |
-
identity = await resolve_token_identity(token)
|
| 187 |
-
if not identity or "error" in identity:
|
| 188 |
-
raise HTTPException(status_code=401, detail="Invalid authorization token")
|
| 189 |
-
return identity
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
@app.get("/tool-registry/tools")
|
| 193 |
-
async def list_tool_registry_tools(q: Optional[str] = None, limit: int = 200):
|
| 194 |
-
index = _read_tool_registry_index()
|
| 195 |
-
rows = []
|
| 196 |
-
query = (q or "").strip().lower()
|
| 197 |
-
max_items = max(1, min(limit, 500))
|
| 198 |
-
for _, record in index.items():
|
| 199 |
-
if not isinstance(record, dict):
|
| 200 |
-
continue
|
| 201 |
-
if record.get("visibility") != "public":
|
| 202 |
-
continue
|
| 203 |
-
if query:
|
| 204 |
-
text = " ".join(
|
| 205 |
-
[
|
| 206 |
-
str(record.get("id") or ""),
|
| 207 |
-
str(record.get("name") or ""),
|
| 208 |
-
str(record.get("functionality") or ""),
|
| 209 |
-
str(record.get("language") or ""),
|
| 210 |
-
]
|
| 211 |
-
).lower()
|
| 212 |
-
if query not in text:
|
| 213 |
-
continue
|
| 214 |
-
rows.append(record)
|
| 215 |
-
rows.sort(key=lambda row: str(row.get("updatedAt") or ""), reverse=True)
|
| 216 |
-
return JSONResponse(status_code=200, content={"tools": rows[:max_items]})
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
@app.get("/tool-registry/my-tools")
|
| 220 |
-
async def list_my_tool_registry_tools(
|
| 221 |
-
authorization: Optional[str] = Header(None),
|
| 222 |
-
limit: int = 500,
|
| 223 |
-
):
|
| 224 |
-
identity = await _require_registry_identity(authorization)
|
| 225 |
-
author_email = str(identity.get("email") or "").strip().lower()
|
| 226 |
-
user_id = str(identity.get("user_id") or "").strip()
|
| 227 |
-
max_items = max(1, min(limit, 1000))
|
| 228 |
-
index = _read_tool_registry_index()
|
| 229 |
-
rows = []
|
| 230 |
-
for _, record in index.items():
|
| 231 |
-
if not isinstance(record, dict):
|
| 232 |
-
continue
|
| 233 |
-
owner_email = str(record.get("authorEmail") or "").strip().lower()
|
| 234 |
-
owner_user_id = str(record.get("authorUserId") or "").strip()
|
| 235 |
-
if (user_id and owner_user_id == user_id) or (author_email and owner_email == author_email):
|
| 236 |
-
rows.append(record)
|
| 237 |
-
rows.sort(key=lambda row: str(row.get("updatedAt") or ""), reverse=True)
|
| 238 |
-
return JSONResponse(status_code=200, content={"tools": rows[:max_items]})
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
@app.get("/tool-registry/tools/{tool_id}")
|
| 242 |
-
async def get_tool_registry_item(tool_id: str):
|
| 243 |
-
normalized_id = _sanitize_tool_id(tool_id)
|
| 244 |
-
if not normalized_id:
|
| 245 |
-
raise HTTPException(status_code=400, detail="Invalid tool id")
|
| 246 |
-
index = _read_tool_registry_index()
|
| 247 |
-
record = index.get(normalized_id)
|
| 248 |
-
if not record:
|
| 249 |
-
raise HTTPException(status_code=404, detail="Tool not found")
|
| 250 |
-
return JSONResponse(status_code=200, content={"tool": record})
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
@app.get("/tool-registry/tools/{tool_id}/source")
|
| 254 |
-
async def get_tool_registry_source(tool_id: str):
|
| 255 |
-
normalized_id = _sanitize_tool_id(tool_id)
|
| 256 |
-
if not normalized_id:
|
| 257 |
-
raise HTTPException(status_code=400, detail="Invalid tool id")
|
| 258 |
-
index = _read_tool_registry_index()
|
| 259 |
-
record = index.get(normalized_id)
|
| 260 |
-
if not record:
|
| 261 |
-
raise HTTPException(status_code=404, detail="Tool not found")
|
| 262 |
-
manifest_path = TOOL_REGISTRY_ROOT / str(record.get("files", {}).get("manifestPath", ""))
|
| 263 |
-
code_path = TOOL_REGISTRY_ROOT / str(record.get("files", {}).get("codePath", ""))
|
| 264 |
-
if not manifest_path.exists() or not code_path.exists():
|
| 265 |
-
raise HTTPException(status_code=404, detail="Tool source files not found")
|
| 266 |
-
try:
|
| 267 |
-
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 268 |
-
except Exception:
|
| 269 |
-
raise HTTPException(status_code=500, detail="Failed to load tool manifest")
|
| 270 |
-
try:
|
| 271 |
-
code = code_path.read_text(encoding="utf-8")
|
| 272 |
-
except Exception:
|
| 273 |
-
raise HTTPException(status_code=500, detail="Failed to load tool code")
|
| 274 |
-
return JSONResponse(status_code=200, content={"manifest": manifest, "code": code})
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
@app.post("/tool-registry/tools")
|
| 278 |
-
async def create_tool_registry_item(
|
| 279 |
-
request: Request,
|
| 280 |
-
authorization: Optional[str] = Header(None),
|
| 281 |
-
):
|
| 282 |
-
identity = await _require_registry_identity(authorization)
|
| 283 |
-
body = await request.json()
|
| 284 |
-
if not isinstance(body, dict):
|
| 285 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 286 |
-
manifest = body.get("manifest")
|
| 287 |
-
code = body.get("code")
|
| 288 |
-
if not isinstance(manifest, dict) or not isinstance(code, str):
|
| 289 |
-
raise HTTPException(status_code=400, detail="Manifest and code are required")
|
| 290 |
-
|
| 291 |
-
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 292 |
-
author_email = str(identity.get("email") or "").strip().lower()
|
| 293 |
-
if not author_email:
|
| 294 |
-
raise HTTPException(status_code=401, detail="Unable to resolve account email")
|
| 295 |
-
|
| 296 |
-
requested_id = _sanitize_tool_id(manifest.get("id") or manifest.get("name") or "")
|
| 297 |
-
if not requested_id:
|
| 298 |
-
requested_id = f"tool-{hashlib.sha256(os.urandom(8)).hexdigest()[:10]}"
|
| 299 |
-
manifest["id"] = requested_id
|
| 300 |
-
manifest["authorEmail"] = author_email
|
| 301 |
-
manifest["authorUserId"] = identity.get("user_id")
|
| 302 |
-
|
| 303 |
-
code_file = _sanitize_tool_file_name(manifest.get("codeFile") or "tool")
|
| 304 |
-
manifest["codeFile"] = code_file
|
| 305 |
-
manifest["codeHash"] = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
| 306 |
-
manifest["version"] = _normalize_tool_version(manifest.get("version"))
|
| 307 |
-
manifest["releaseNotes"] = str(manifest.get("releaseNotes") or "")[:4000]
|
| 308 |
-
manifest["websiteUrl"] = _normalize_https_url(manifest.get("websiteUrl"))
|
| 309 |
-
visibility = str(manifest.get("visibility") or "public").strip().lower()
|
| 310 |
-
if visibility not in {"public", "unlisted"}:
|
| 311 |
-
visibility = "public"
|
| 312 |
-
manifest["visibility"] = visibility
|
| 313 |
-
if not manifest.get("createdAt"):
|
| 314 |
-
manifest["createdAt"] = now_iso
|
| 315 |
-
manifest["updatedAt"] = now_iso
|
| 316 |
-
manifest["published"] = True
|
| 317 |
-
manifest["registry"] = {
|
| 318 |
-
"source": "lightning",
|
| 319 |
-
"uploadedAt": manifest.get("createdAt") or now_iso,
|
| 320 |
-
"updatedAt": now_iso,
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
tool_dir = TOOL_REGISTRY_ROOT / requested_id
|
| 324 |
-
manifest_path = tool_dir / "manifest.json"
|
| 325 |
-
code_path = tool_dir / code_file
|
| 326 |
-
tool_dir.mkdir(parents=True, exist_ok=True)
|
| 327 |
-
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
| 328 |
-
code_path.write_text(code, encoding="utf-8")
|
| 329 |
-
|
| 330 |
-
record = _to_registry_record(manifest, now_iso=now_iso)
|
| 331 |
-
record["authorEmail"] = author_email
|
| 332 |
-
record["authorUserId"] = identity.get("user_id")
|
| 333 |
-
|
| 334 |
-
async with TOOL_REGISTRY_LOCK:
|
| 335 |
-
index = _read_tool_registry_index()
|
| 336 |
-
existing = index.get(requested_id)
|
| 337 |
-
if existing and str(existing.get("authorEmail", "")).strip().lower() != author_email:
|
| 338 |
-
raise HTTPException(status_code=403, detail="Tool id already exists")
|
| 339 |
-
index[requested_id] = record
|
| 340 |
-
_write_tool_registry_index(index)
|
| 341 |
-
|
| 342 |
-
return JSONResponse(status_code=200, content={"tool": record})
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
@app.delete("/tool-registry/tools/{tool_id}")
|
| 346 |
-
async def delete_tool_registry_item(
|
| 347 |
-
tool_id: str,
|
| 348 |
-
authorization: Optional[str] = Header(None),
|
| 349 |
-
):
|
| 350 |
-
identity = await _require_registry_identity(authorization)
|
| 351 |
-
author_email = str(identity.get("email") or "").strip().lower()
|
| 352 |
-
normalized_id = _sanitize_tool_id(tool_id)
|
| 353 |
-
if not normalized_id:
|
| 354 |
-
raise HTTPException(status_code=400, detail="Invalid tool id")
|
| 355 |
-
|
| 356 |
-
async with TOOL_REGISTRY_LOCK:
|
| 357 |
-
index = _read_tool_registry_index()
|
| 358 |
-
record = index.get(normalized_id)
|
| 359 |
-
if not record:
|
| 360 |
-
raise HTTPException(status_code=404, detail="Tool not found")
|
| 361 |
-
owner = str(record.get("authorEmail") or "").strip().lower()
|
| 362 |
-
if owner != author_email:
|
| 363 |
-
raise HTTPException(status_code=403, detail="Only the publisher can delete this tool")
|
| 364 |
-
index.pop(normalized_id, None)
|
| 365 |
-
_write_tool_registry_index(index)
|
| 366 |
-
|
| 367 |
-
tool_dir = TOOL_REGISTRY_ROOT / normalized_id
|
| 368 |
-
if tool_dir.exists():
|
| 369 |
-
for child in sorted(tool_dir.glob("**/*"), reverse=True):
|
| 370 |
-
try:
|
| 371 |
-
if child.is_file():
|
| 372 |
-
child.unlink()
|
| 373 |
-
elif child.is_dir():
|
| 374 |
-
child.rmdir()
|
| 375 |
-
except Exception:
|
| 376 |
-
continue
|
| 377 |
-
try:
|
| 378 |
-
tool_dir.rmdir()
|
| 379 |
-
except Exception:
|
| 380 |
-
pass
|
| 381 |
-
|
| 382 |
-
return JSONResponse(status_code=200, content={"ok": True})
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
@app.put("/tool-registry/tools/{tool_id}")
|
| 386 |
-
async def update_tool_registry_item(
|
| 387 |
-
tool_id: str,
|
| 388 |
-
request: Request,
|
| 389 |
-
authorization: Optional[str] = Header(None),
|
| 390 |
-
):
|
| 391 |
-
identity = await _require_registry_identity(authorization)
|
| 392 |
-
author_email = str(identity.get("email") or "").strip().lower()
|
| 393 |
-
normalized_id = _sanitize_tool_id(tool_id)
|
| 394 |
-
if not normalized_id:
|
| 395 |
-
raise HTTPException(status_code=400, detail="Invalid tool id")
|
| 396 |
-
body = await request.json()
|
| 397 |
-
if not isinstance(body, dict):
|
| 398 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 399 |
-
manifest = body.get("manifest")
|
| 400 |
-
code = body.get("code")
|
| 401 |
-
if not isinstance(manifest, dict):
|
| 402 |
-
raise HTTPException(status_code=400, detail="Manifest is required")
|
| 403 |
-
|
| 404 |
-
async with TOOL_REGISTRY_LOCK:
|
| 405 |
-
index = _read_tool_registry_index()
|
| 406 |
-
record = index.get(normalized_id)
|
| 407 |
-
if not record:
|
| 408 |
-
raise HTTPException(status_code=404, detail="Tool not found")
|
| 409 |
-
owner = str(record.get("authorEmail") or "").strip().lower()
|
| 410 |
-
if owner != author_email:
|
| 411 |
-
raise HTTPException(status_code=403, detail="Only the publisher can edit this tool")
|
| 412 |
-
|
| 413 |
-
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 414 |
-
manifest["id"] = normalized_id
|
| 415 |
-
manifest["authorEmail"] = author_email
|
| 416 |
-
manifest["authorUserId"] = identity.get("user_id")
|
| 417 |
-
manifest["createdAt"] = manifest.get("createdAt") or record.get("publishedAt") or now_iso
|
| 418 |
-
manifest["updatedAt"] = now_iso
|
| 419 |
-
manifest["published"] = True
|
| 420 |
-
manifest["visibility"] = str(manifest.get("visibility") or record.get("visibility") or "public").lower()
|
| 421 |
-
if manifest["visibility"] not in {"public", "unlisted"}:
|
| 422 |
-
manifest["visibility"] = "public"
|
| 423 |
-
manifest["version"] = _normalize_tool_version(manifest.get("version") or record.get("version"))
|
| 424 |
-
manifest["releaseNotes"] = str(manifest.get("releaseNotes") or "")[:4000]
|
| 425 |
-
manifest["websiteUrl"] = _normalize_https_url(manifest.get("websiteUrl"))
|
| 426 |
-
|
| 427 |
-
code_file = _sanitize_tool_file_name(
|
| 428 |
-
manifest.get("codeFile")
|
| 429 |
-
or Path(str(record.get("files", {}).get("codePath", "tool"))).name
|
| 430 |
-
or "tool"
|
| 431 |
-
)
|
| 432 |
-
manifest["codeFile"] = code_file
|
| 433 |
-
|
| 434 |
-
tool_dir = TOOL_REGISTRY_ROOT / normalized_id
|
| 435 |
-
tool_dir.mkdir(parents=True, exist_ok=True)
|
| 436 |
-
manifest_path = tool_dir / "manifest.json"
|
| 437 |
-
code_path = tool_dir / code_file
|
| 438 |
-
if isinstance(code, str):
|
| 439 |
-
manifest["codeHash"] = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
| 440 |
-
code_path.write_text(code, encoding="utf-8")
|
| 441 |
-
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
| 442 |
-
|
| 443 |
-
updated_record = _to_registry_record(manifest, now_iso=now_iso)
|
| 444 |
-
updated_record["authorEmail"] = author_email
|
| 445 |
-
updated_record["authorUserId"] = identity.get("user_id")
|
| 446 |
-
index[normalized_id] = updated_record
|
| 447 |
-
_write_tool_registry_index(index)
|
| 448 |
-
|
| 449 |
-
return JSONResponse(status_code=200, content={"tool": updated_record})
|
| 450 |
-
|
| 451 |
-
def check_ws_auth_rate_limit(ip: str):
|
| 452 |
-
now = time.time()
|
| 453 |
-
q = AUTH_ATTEMPTS[ip]
|
| 454 |
-
|
| 455 |
-
# purge old attempts
|
| 456 |
-
while q and now - q[0] > AUTH_WINDOW_SECONDS:
|
| 457 |
-
q.popleft()
|
| 458 |
-
|
| 459 |
-
if len(q) >= AUTH_MAX_ATTEMPTS:
|
| 460 |
-
return False
|
| 461 |
-
|
| 462 |
-
q.append(now)
|
| 463 |
-
return True
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
@app.get("/")
|
| 467 |
-
async def reroute_to_home():
|
| 468 |
-
return RedirectResponse(
|
| 469 |
-
url="https://inference.js.org", status_code=status.HTTP_308_PERMANENT_REDIRECT
|
| 470 |
-
)
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
OLLAMA_LIBRARY_URL = "https://ollama.com/library"
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
@app.head("/models")
|
| 477 |
-
@app.get("/models")
|
| 478 |
-
async def get_models() -> List[Dict]:
|
| 479 |
-
async with httpx.AsyncClient() as client:
|
| 480 |
-
response = await client.get(OLLAMA_LIBRARY_URL)
|
| 481 |
-
html = response.text
|
| 482 |
-
|
| 483 |
-
soup = BeautifulSoup(html, "html.parser")
|
| 484 |
-
items = soup.select("li[x-test-model]")
|
| 485 |
-
|
| 486 |
-
models = []
|
| 487 |
-
for item in items:
|
| 488 |
-
name = item.select_one("[x-test-model-title] span")
|
| 489 |
-
description = item.select_one("p.max-w-lg")
|
| 490 |
-
sizes = [el.get_text(strip=True) for el in item.select("[x-test-size]")]
|
| 491 |
-
pulls = item.select_one("[x-test-pull-count]")
|
| 492 |
-
tags = [
|
| 493 |
-
t.get_text(strip=True) for t in item.select('span[class*="text-blue-600"]')
|
| 494 |
-
]
|
| 495 |
-
updated = item.select_one("[x-test-updated]")
|
| 496 |
-
link = item.select_one("a")
|
| 497 |
-
|
| 498 |
-
models.append(
|
| 499 |
-
{
|
| 500 |
-
"name": name.get_text(strip=True) if name else "",
|
| 501 |
-
"description": (
|
| 502 |
-
description.get_text(strip=True)
|
| 503 |
-
if description
|
| 504 |
-
else "No description"
|
| 505 |
-
),
|
| 506 |
-
"sizes": sizes,
|
| 507 |
-
"pulls": pulls.get_text(strip=True) if pulls else "Unknown",
|
| 508 |
-
"tags": tags,
|
| 509 |
-
"updated": updated.get_text(strip=True) if updated else "Unknown",
|
| 510 |
-
"link": link.get("href") if link else None,
|
| 511 |
-
}
|
| 512 |
-
)
|
| 513 |
-
|
| 514 |
-
return models
|
| 515 |
-
|
| 516 |
-
@app.get("/subscription")
|
| 517 |
-
async def get_subscription(authorization: Optional[str] = Header(None)):
|
| 518 |
-
if not authorization or not authorization.startswith("Bearer "):
|
| 519 |
-
raise HTTPException(401, "Missing or invalid Authorization header")
|
| 520 |
-
|
| 521 |
-
jwt = authorization.split(" ", 1)[1]
|
| 522 |
-
|
| 523 |
-
result = await fetch_subscription(jwt)
|
| 524 |
-
if "error" in result:
|
| 525 |
-
raise HTTPException(401, result["error"])
|
| 526 |
-
|
| 527 |
-
plan_key = normalize_plan_key(result.get("plan_key"))
|
| 528 |
-
result["plan_key"] = plan_key
|
| 529 |
-
result["plan_name"] = (TIER_CONFIG.get(plan_key) or TIER_CONFIG["free"])["name"]
|
| 530 |
-
return result
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
@app.get("/usage")
|
| 534 |
-
async def get_usage(
|
| 535 |
-
request: Request,
|
| 536 |
-
authorization: Optional[str] = Header(None),
|
| 537 |
-
x_client_id: Optional[str] = Header(None),
|
| 538 |
-
):
|
| 539 |
-
plan_key, subject = await resolve_rate_limit_identity(
|
| 540 |
-
request, authorization, x_client_id
|
| 541 |
-
)
|
| 542 |
-
plan = TIER_CONFIG.get(plan_key) or TIER_CONFIG["free"]
|
| 543 |
-
usage = get_usage_snapshot_for_subject(plan_key, subject)
|
| 544 |
-
return JSONResponse(
|
| 545 |
-
status_code=200,
|
| 546 |
-
content={
|
| 547 |
-
"plan_key": plan_key,
|
| 548 |
-
"plan_name": plan.get("name", "Free Tier"),
|
| 549 |
-
"usage": usage,
|
| 550 |
-
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 551 |
-
},
|
| 552 |
-
)
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
def build_verify_token_usage_response(
|
| 556 |
-
plan_key: str,
|
| 557 |
-
usage: Dict[str, Any],
|
| 558 |
-
*,
|
| 559 |
-
error: Optional[str] = None,
|
| 560 |
-
):
|
| 561 |
-
plan = TIER_CONFIG.get(plan_key) or TIER_CONFIG["free"]
|
| 562 |
-
payload: Dict[str, Any] = {
|
| 563 |
-
"plan_key": plan_key,
|
| 564 |
-
"plan_name": plan.get("name", "Free Tier"),
|
| 565 |
-
"feature_name": VERIFY_TOKEN_FEATURE_NAME,
|
| 566 |
-
"notice": VERIFY_TOKEN_CONTACT_NOTICE,
|
| 567 |
-
"usage": usage,
|
| 568 |
-
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 569 |
-
}
|
| 570 |
-
if error:
|
| 571 |
-
payload["error"] = error
|
| 572 |
-
return payload
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
@app.get("/usage/verify-token-with-email")
|
| 576 |
-
async def get_verify_token_with_email_usage(
|
| 577 |
-
request: Request,
|
| 578 |
-
authorization: Optional[str] = Header(None),
|
| 579 |
-
):
|
| 580 |
-
auth_header = (
|
| 581 |
-
authorization if authorization and authorization.startswith("Bearer ") else None
|
| 582 |
-
)
|
| 583 |
-
request_ip = normalize_forwarded_ip(
|
| 584 |
-
request.headers.get("x-forwarded-for")
|
| 585 |
-
or request.headers.get("x-real-ip")
|
| 586 |
-
or (request.client.host if request.client else None)
|
| 587 |
-
)
|
| 588 |
-
plan_key, subject = await resolve_verify_token_rate_limit_identity(
|
| 589 |
-
auth_header, request_ip
|
| 590 |
-
)
|
| 591 |
-
usage = get_metric_snapshot_for_subject(plan_key, subject, VERIFY_TOKEN_USAGE_METRIC)
|
| 592 |
-
return JSONResponse(
|
| 593 |
-
status_code=200,
|
| 594 |
-
content=build_verify_token_usage_response(plan_key, usage),
|
| 595 |
-
)
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
@app.put("/internal/usage/verify-token-with-email")
|
| 599 |
-
async def put_verify_token_with_email_usage(
|
| 600 |
-
request: Request,
|
| 601 |
-
x_lightning_transfer_token: Optional[str] = Header(None),
|
| 602 |
-
):
|
| 603 |
-
if (
|
| 604 |
-
not LIGHTNING_TRANSFER_TOKEN
|
| 605 |
-
or not x_lightning_transfer_token
|
| 606 |
-
or not hmac.compare_digest(x_lightning_transfer_token, LIGHTNING_TRANSFER_TOKEN)
|
| 607 |
-
):
|
| 608 |
-
raise HTTPException(401, "Invalid transfer token")
|
| 609 |
-
|
| 610 |
-
try:
|
| 611 |
-
body = await request.json()
|
| 612 |
-
except Exception:
|
| 613 |
-
raise HTTPException(400, "Invalid JSON body")
|
| 614 |
-
|
| 615 |
-
auth_header = None
|
| 616 |
-
raw_jwt = body.get("supabase_jwt") if isinstance(body, dict) else None
|
| 617 |
-
raw_ip = body.get("ip") if isinstance(body, dict) else None
|
| 618 |
-
|
| 619 |
-
if isinstance(raw_jwt, str) and raw_jwt.strip():
|
| 620 |
-
auth_header = f"Bearer {raw_jwt.strip()}"
|
| 621 |
-
|
| 622 |
-
plan_key, subject = await resolve_verify_token_rate_limit_identity(
|
| 623 |
-
auth_header, normalize_forwarded_ip(raw_ip)
|
| 624 |
-
)
|
| 625 |
-
|
| 626 |
-
try:
|
| 627 |
-
usage = await consume_metric_for_subject(
|
| 628 |
-
VERIFY_TOKEN_USAGE_METRIC,
|
| 629 |
-
plan_key,
|
| 630 |
-
subject,
|
| 631 |
-
)
|
| 632 |
-
except HTTPException as exc:
|
| 633 |
-
if exc.status_code != 429:
|
| 634 |
-
raise
|
| 635 |
-
|
| 636 |
-
usage = get_metric_snapshot_for_subject(
|
| 637 |
-
plan_key, subject, VERIFY_TOKEN_USAGE_METRIC
|
| 638 |
-
)
|
| 639 |
-
return JSONResponse(
|
| 640 |
-
status_code=429,
|
| 641 |
-
content=build_verify_token_usage_response(
|
| 642 |
-
plan_key,
|
| 643 |
-
usage,
|
| 644 |
-
error="Token Verification Requests limit reached for today.",
|
| 645 |
-
),
|
| 646 |
-
)
|
| 647 |
-
|
| 648 |
-
return JSONResponse(
|
| 649 |
-
status_code=200,
|
| 650 |
-
content=build_verify_token_usage_response(plan_key, usage),
|
| 651 |
-
)
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
@app.get("/tier-config")
|
| 655 |
-
async def tier_config():
|
| 656 |
-
plans = []
|
| 657 |
-
for idx, key in enumerate(PLAN_ORDER):
|
| 658 |
-
plan = TIER_CONFIG.get(key)
|
| 659 |
-
if not plan:
|
| 660 |
-
continue
|
| 661 |
-
plans.append(
|
| 662 |
-
{
|
| 663 |
-
"key": key,
|
| 664 |
-
"name": plan["name"],
|
| 665 |
-
"url": plan["url"],
|
| 666 |
-
"price": plan["price"],
|
| 667 |
-
"limits": plan["limits"],
|
| 668 |
-
"order": idx,
|
| 669 |
-
}
|
| 670 |
-
)
|
| 671 |
-
|
| 672 |
-
return JSONResponse(
|
| 673 |
-
status_code=200,
|
| 674 |
-
content={
|
| 675 |
-
"defaultPlanKey": "free",
|
| 676 |
-
"plans": plans,
|
| 677 |
-
},
|
| 678 |
-
)
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
@app.get("/tiers")
|
| 682 |
-
async def tiers():
|
| 683 |
-
paid_plans = []
|
| 684 |
-
for key in PLAN_ORDER:
|
| 685 |
-
if key == "free":
|
| 686 |
-
continue
|
| 687 |
-
plan = TIER_CONFIG.get(key)
|
| 688 |
-
if not plan:
|
| 689 |
-
continue
|
| 690 |
-
paid_plans.append(
|
| 691 |
-
{
|
| 692 |
-
"key": key,
|
| 693 |
-
"name": plan["name"],
|
| 694 |
-
"url": plan["url"],
|
| 695 |
-
"price": plan["price"],
|
| 696 |
-
"limits": plan["limits"],
|
| 697 |
-
}
|
| 698 |
-
)
|
| 699 |
-
|
| 700 |
-
return JSONResponse(
|
| 701 |
-
status_code=200,
|
| 702 |
-
content=paid_plans,
|
| 703 |
-
)
|
| 704 |
-
|
| 705 |
-
@app.websocket("/ws/chat")
|
| 706 |
-
async def websocket_chat(ws: WebSocket):
|
| 707 |
-
ip = ws.client.host
|
| 708 |
-
|
| 709 |
-
# Accept connection
|
| 710 |
-
await ws.accept()
|
| 711 |
-
|
| 712 |
-
# Basic connection rate limiting
|
| 713 |
-
if not check_ws_auth_rate_limit(ip):
|
| 714 |
-
await ws.close(code=4408)
|
| 715 |
-
return
|
| 716 |
-
|
| 717 |
-
try:
|
| 718 |
-
# Expect an auth message first
|
| 719 |
-
auth_msg = await ws.receive_text()
|
| 720 |
-
try:
|
| 721 |
-
auth_data = json.loads(auth_msg)
|
| 722 |
-
except Exception:
|
| 723 |
-
await ws.close(code=4400)
|
| 724 |
-
return
|
| 725 |
-
|
| 726 |
-
provided_key = auth_data.get("key")
|
| 727 |
-
|
| 728 |
-
if not WEBSOCKET_KEY or provided_key != WEBSOCKET_KEY:
|
| 729 |
-
await ws.close(code=4403)
|
| 730 |
-
return
|
| 731 |
-
|
| 732 |
-
await ws.send_json({
|
| 733 |
-
"type": "auth",
|
| 734 |
-
"status": "ok"
|
| 735 |
-
})
|
| 736 |
-
|
| 737 |
-
internal_url = "http://127.0.0.1:7860/gen/chat/completions"
|
| 738 |
-
|
| 739 |
-
async with httpx.AsyncClient(
|
| 740 |
-
timeout=None,
|
| 741 |
-
follow_redirects=False
|
| 742 |
-
) as client:
|
| 743 |
-
request_counter = 0
|
| 744 |
-
|
| 745 |
-
async def handle_incoming_requests():
|
| 746 |
-
nonlocal request_counter
|
| 747 |
-
while True:
|
| 748 |
-
try:
|
| 749 |
-
msg = await ws.receive_text()
|
| 750 |
-
except WebSocketDisconnect:
|
| 751 |
-
break
|
| 752 |
-
|
| 753 |
-
try:
|
| 754 |
-
data = json.loads(msg)
|
| 755 |
-
except Exception:
|
| 756 |
-
await ws.send_json({"error": "Invalid JSON"})
|
| 757 |
-
continue
|
| 758 |
-
|
| 759 |
-
body = data.get("body")
|
| 760 |
-
headers = data.get("headers") or {}
|
| 761 |
-
|
| 762 |
-
if not body:
|
| 763 |
-
await ws.send_json({"error": "Missing body"})
|
| 764 |
-
continue
|
| 765 |
-
|
| 766 |
-
request_counter += 1
|
| 767 |
-
request_id = request_counter
|
| 768 |
-
|
| 769 |
-
# Process this request concurrently
|
| 770 |
-
asyncio.create_task(process_request(client, request_id, body, headers))
|
| 771 |
-
|
| 772 |
-
async def process_request(client, request_id, body, headers):
|
| 773 |
-
try:
|
| 774 |
-
async with client.stream(
|
| 775 |
-
"POST",
|
| 776 |
-
internal_url,
|
| 777 |
-
json=body,
|
| 778 |
-
headers=headers
|
| 779 |
-
) as response:
|
| 780 |
-
|
| 781 |
-
# Handle upstream errors
|
| 782 |
-
if response.status_code >= 400:
|
| 783 |
-
error_text = ""
|
| 784 |
-
try:
|
| 785 |
-
error_text = (await response.aread()).decode(
|
| 786 |
-
"utf-8", errors="replace"
|
| 787 |
-
)[:500]
|
| 788 |
-
except Exception:
|
| 789 |
-
pass
|
| 790 |
-
|
| 791 |
-
error_payload = json.dumps({
|
| 792 |
-
"error": "Upstream request failed",
|
| 793 |
-
"status": response.status_code,
|
| 794 |
-
"detail": error_text
|
| 795 |
-
})
|
| 796 |
-
try:
|
| 797 |
-
await ws.send_text(f"{request_id}:{error_payload}")
|
| 798 |
-
except RuntimeError:
|
| 799 |
-
return
|
| 800 |
-
return
|
| 801 |
-
|
| 802 |
-
# Stream tokens/lines to the websocket
|
| 803 |
-
async for line in response.aiter_lines():
|
| 804 |
-
if not line:
|
| 805 |
-
continue
|
| 806 |
-
|
| 807 |
-
try:
|
| 808 |
-
await ws.send_text(f"{request_id}:{line}")
|
| 809 |
-
except RuntimeError:
|
| 810 |
-
return
|
| 811 |
-
|
| 812 |
-
except Exception as stream_error:
|
| 813 |
-
try:
|
| 814 |
-
error_payload = json.dumps({
|
| 815 |
-
"error": "Streaming error",
|
| 816 |
-
"detail": str(stream_error)
|
| 817 |
-
})
|
| 818 |
-
await ws.send_text(f"{request_id}:{error_payload}")
|
| 819 |
-
except Exception:
|
| 820 |
-
pass
|
| 821 |
-
|
| 822 |
-
# Start handling incoming requests
|
| 823 |
-
await handle_incoming_requests()
|
| 824 |
-
|
| 825 |
-
except WebSocketDisconnect:
|
| 826 |
-
return
|
| 827 |
-
|
| 828 |
-
except Exception as e:
|
| 829 |
-
try:
|
| 830 |
-
await ws.send_json({
|
| 831 |
-
"error": "Server error",
|
| 832 |
-
"detail": str(e)
|
| 833 |
-
})
|
| 834 |
-
except Exception:
|
| 835 |
-
pass
|
| 836 |
-
|
| 837 |
-
try:
|
| 838 |
-
await ws.close()
|
| 839 |
-
except Exception:
|
| 840 |
-
pass
|
| 841 |
-
|
| 842 |
-
@app.get("/portal")
|
| 843 |
-
@app.post("/portal")
|
| 844 |
-
async def redirect_to_protal(request: Request):
|
| 845 |
-
email = None
|
| 846 |
-
|
| 847 |
-
if request.method == "POST":
|
| 848 |
-
try:
|
| 849 |
-
body = await request.json()
|
| 850 |
-
email = body.get("email")
|
| 851 |
-
except:
|
| 852 |
-
email = None
|
| 853 |
-
|
| 854 |
-
base_url = "https://billing.stripe.com/p/login/5kQdR9aIM3ts4steyabbG00"
|
| 855 |
-
|
| 856 |
-
if not email:
|
| 857 |
-
return RedirectResponse(url=base_url, status_code=status.HTTP_302_FOUND)
|
| 858 |
-
if request.method != "POST":
|
| 859 |
-
return RedirectResponse(
|
| 860 |
-
url=f"{base_url}?prefilled_email={email}", status_code=status.HTTP_302_FOUND
|
| 861 |
-
)
|
| 862 |
-
else:
|
| 863 |
-
return JSONResponse({"redirect_url": (base_url + "?prefilled_email=" + email)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/admins.json
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"sharktidedev@gmail.com": {
|
| 3 |
-
"plan_key": "core",
|
| 4 |
-
"product_name": "InferencePort AI Core",
|
| 5 |
-
"nickname": "Core Plan",
|
| 6 |
-
"subscription_id": "admin-override",
|
| 7 |
-
"status": "active",
|
| 8 |
-
"current_period_end": null,
|
| 9 |
-
"price_id": null
|
| 10 |
-
},
|
| 11 |
-
"hallmax249@gmail.com": {
|
| 12 |
-
"plan_key": "creator",
|
| 13 |
-
"product_name": "InferencePort AI Creator",
|
| 14 |
-
"nickname": "Creator Plan",
|
| 15 |
-
"subscription_id": "admin-override",
|
| 16 |
-
"status": "active",
|
| 17 |
-
"current_period_end": null,
|
| 18 |
-
"price_id": null
|
| 19 |
-
},
|
| 20 |
-
"w81537670@gmail.com": {
|
| 21 |
-
"plan_key": "light",
|
| 22 |
-
"product_name": "InferencePort AI Light",
|
| 23 |
-
"nickname": "Light Plan",
|
| 24 |
-
"subscription_id": "admin-override",
|
| 25 |
-
"status": "active",
|
| 26 |
-
"current_period_end": null,
|
| 27 |
-
"price_id": null
|
| 28 |
-
},
|
| 29 |
-
"jacoblamhome@gmail.com": {
|
| 30 |
-
"plan_key": "light",
|
| 31 |
-
"product_name": "InferencePort AI Light",
|
| 32 |
-
"nickname": "Light Plan",
|
| 33 |
-
"subscription_id": "admin-override",
|
| 34 |
-
"status": "active",
|
| 35 |
-
"current_period_end": null,
|
| 36 |
-
"price_id": null
|
| 37 |
-
},
|
| 38 |
-
"leo.dcam@gmail.com": {
|
| 39 |
-
"plan_key": "light",
|
| 40 |
-
"product_name": "InferencePort AI Light",
|
| 41 |
-
"nickname": "Light Plan",
|
| 42 |
-
"subscription_id": "admin-override",
|
| 43 |
-
"status": "active",
|
| 44 |
-
"current_period_end": null,
|
| 45 |
-
"price_id": null
|
| 46 |
-
},
|
| 47 |
-
"morriszdweck@gmail.com": {
|
| 48 |
-
"plan_key": "core",
|
| 49 |
-
"product_name": "InferencePort AI Core",
|
| 50 |
-
"nickname": "Core Plan",
|
| 51 |
-
"subscription_id": "admin-override",
|
| 52 |
-
"status": "active",
|
| 53 |
-
"current_period_end": null,
|
| 54 |
-
"price_id": null
|
| 55 |
-
}
|
| 56 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/extra_usage.json
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"sharktidedev@gmail.com": {
|
| 3 |
-
"limits": {
|
| 4 |
-
"verifyTokenWithEmailDaily": 500
|
| 5 |
-
}
|
| 6 |
-
}
|
| 7 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/payg_config.json
DELETED
|
@@ -1,119 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"dashboard": {
|
| 3 |
-
"appName": "InferencePort AI",
|
| 4 |
-
"homeUrl": "https://inference.js.org",
|
| 5 |
-
"apiBaseUrl": "https://sharktide-lightning.hf.space",
|
| 6 |
-
"supportEmail": "inferenceportai@gmail.com",
|
| 7 |
-
"hostname": ""
|
| 8 |
-
},
|
| 9 |
-
"supabase": {
|
| 10 |
-
"url": "https://dpixehhdbtzsbckfektd.supabase.co",
|
| 11 |
-
"publishableKey": "sb_publishable_hbqJ9rAlgaeiYRzdVAqY4A_5pwWLSR9",
|
| 12 |
-
"resetRedirectUrl": "https://inference.js.org/reset.html",
|
| 13 |
-
"deletePasswordVerifyEndpoint": "https://dpixehhdbtzsbckfektd.supabase.co/functions/v1/verify_password",
|
| 14 |
-
"deleteAccountEndpoint": "https://dpixehhdbtzsbckfektd.supabase.co/functions/v1/delete_account"
|
| 15 |
-
},
|
| 16 |
-
"billing": {
|
| 17 |
-
"currency": "USD",
|
| 18 |
-
"packs": [
|
| 19 |
-
{
|
| 20 |
-
"id": "pack_25",
|
| 21 |
-
"label": "$25",
|
| 22 |
-
"amountUsd": 25,
|
| 23 |
-
"credits": 25,
|
| 24 |
-
"stripePaymentLink": "https://buy.stripe.com/14AcN5048bZY9MNdu6bbG0c",
|
| 25 |
-
"prod": "prod_UaDtOCJlDvJxvL"
|
| 26 |
-
},
|
| 27 |
-
{
|
| 28 |
-
"id": "pack_50",
|
| 29 |
-
"label": "$50",
|
| 30 |
-
"amountUsd": 50,
|
| 31 |
-
"credits": 50,
|
| 32 |
-
"stripePaymentLink": "https://buy.stripe.com/4gM8wPaIMd422klblYbbG0d",
|
| 33 |
-
"prod": "prod_UaDudJa2YJ2ZIr"
|
| 34 |
-
},
|
| 35 |
-
{
|
| 36 |
-
"id": "pack_100",
|
| 37 |
-
"label": "$100",
|
| 38 |
-
"amountUsd": 100,
|
| 39 |
-
"credits": 125,
|
| 40 |
-
"stripePaymentLink": "https://buy.stripe.com/fZu14ncQUbZYaQRahUbbG0e",
|
| 41 |
-
"prod": "prod_UaDvyEon5kRAID"
|
| 42 |
-
},
|
| 43 |
-
{
|
| 44 |
-
"id": "pack_200",
|
| 45 |
-
"label": "$200",
|
| 46 |
-
"amountUsd": 200,
|
| 47 |
-
"credits": 300,
|
| 48 |
-
"stripePaymentLink": "https://buy.stripe.com/dRm7sL6swd426ABdu6bbG0f",
|
| 49 |
-
"prod": "prod_UaDvUBzDw2lp4F"
|
| 50 |
-
},
|
| 51 |
-
{
|
| 52 |
-
"id": "pack_300",
|
| 53 |
-
"label": "$300",
|
| 54 |
-
"amountUsd": 300,
|
| 55 |
-
"credits": 475,
|
| 56 |
-
"stripePaymentLink": "https://buy.stripe.com/fZu4gzg361lk3op9dQbbG0g",
|
| 57 |
-
"prod": "prod_UaDxh19Z7kthIf"
|
| 58 |
-
},
|
| 59 |
-
{
|
| 60 |
-
"id": "pack_400",
|
| 61 |
-
"label": "$400",
|
| 62 |
-
"amountUsd": 400,
|
| 63 |
-
"credits": 650,
|
| 64 |
-
"stripePaymentLink": "https://buy.stripe.com/6oU9AT3gk7JI7EF89MbbG0h",
|
| 65 |
-
"prod": "prod_UaDxXdUqwo0aIw"
|
| 66 |
-
},
|
| 67 |
-
{
|
| 68 |
-
"id": "pack_500",
|
| 69 |
-
"label": "$500",
|
| 70 |
-
"amountUsd": 500,
|
| 71 |
-
"credits": 800,
|
| 72 |
-
"stripePaymentLink": "https://buy.stripe.com/14A14nbMQ9RQ5wx4XAbbG0i",
|
| 73 |
-
"prod": "prod_UaDyyvIggiDUbQ"
|
| 74 |
-
}
|
| 75 |
-
]
|
| 76 |
-
},
|
| 77 |
-
"pricing": {
|
| 78 |
-
"textCreditPerMillionTokens": 0.75,
|
| 79 |
-
"imageCreditPerImage": 0.02,
|
| 80 |
-
"videoCreditPerSecond": 0.01,
|
| 81 |
-
"audioCreditPerSecond": 0.01,
|
| 82 |
-
"audioDefaultSeconds": 10,
|
| 83 |
-
"textTokenEstimateMultiplier": 1.0
|
| 84 |
-
},
|
| 85 |
-
"models": [
|
| 86 |
-
{
|
| 87 |
-
"id": "lightning-text-v2",
|
| 88 |
-
"type": "text",
|
| 89 |
-
"name": "Lightning Text v2"
|
| 90 |
-
},
|
| 91 |
-
{
|
| 92 |
-
"id": "lightning-image-turbo",
|
| 93 |
-
"type": "image",
|
| 94 |
-
"name": "Lightning Image Turbo"
|
| 95 |
-
},
|
| 96 |
-
{
|
| 97 |
-
"id": "ltx-2-3",
|
| 98 |
-
"type": "video",
|
| 99 |
-
"name": "LTX 2.3"
|
| 100 |
-
},
|
| 101 |
-
{
|
| 102 |
-
"id": "acestep-1-5-turbo",
|
| 103 |
-
"type": "audio",
|
| 104 |
-
"name": "AceStep 1.5 Turbo"
|
| 105 |
-
}
|
| 106 |
-
],
|
| 107 |
-
"notices": [
|
| 108 |
-
{
|
| 109 |
-
"id": "welcome",
|
| 110 |
-
"level": "info",
|
| 111 |
-
"active": true,
|
| 112 |
-
"message": "Welcome to the InferencePort AI developer console."
|
| 113 |
-
}
|
| 114 |
-
],
|
| 115 |
-
"limits": {
|
| 116 |
-
"requestsPerMinutePerIdentity": 120,
|
| 117 |
-
"minimumTextCreditsBalance": 10
|
| 118 |
-
}
|
| 119 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gen/__init__.py
DELETED
|
@@ -1,21 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from fastapi import APIRouter
|
| 3 |
-
|
| 4 |
-
router = APIRouter(prefix="/gen")
|
| 5 |
-
|
| 6 |
-
PKEY = os.getenv("POLLINATIONS_KEY", "")
|
| 7 |
-
PKEY2 = os.getenv("POLLINATIONS2_KEY", "")
|
| 8 |
-
PKEY3 = os.getenv("POLLINATIONS3_KEY", "")
|
| 9 |
-
AIRFORCE_KEY = os.getenv("AIRFORCE")
|
| 10 |
-
AIRFORCE_VIDEO_MODEL = "grok-imagine-video"
|
| 11 |
-
AIRFORCE_API_URL = "https://api.airforce/v1/images/generations"
|
| 12 |
-
WEBSOCKET_KEY = os.getenv("WEBSOCKET_KEY", "")
|
| 13 |
-
|
| 14 |
-
valid_ratios = {"3:2", "2:3", "1:1", "", None}
|
| 15 |
-
ratios = {"3:2", "2:3", "1:1"}
|
| 16 |
-
valid_modes = {"normal", "fun", "", None}
|
| 17 |
-
modes = {"normal", "fun"}
|
| 18 |
-
|
| 19 |
-
MAX_VIDEO_RETRIES = 6
|
| 20 |
-
|
| 21 |
-
from . import media, chat, prompt, models
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gen/chat.py
DELETED
|
@@ -1,361 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import random
|
| 3 |
-
import json
|
| 4 |
-
import httpx
|
| 5 |
-
from typing import Optional
|
| 6 |
-
from fastapi import Request, HTTPException, Header
|
| 7 |
-
from fastapi.responses import JSONResponse, StreamingResponse
|
| 8 |
-
from helper.keywords import *
|
| 9 |
-
from helper.ratelimit import (
|
| 10 |
-
MAX_CHAT_PROMPT_BYTES,
|
| 11 |
-
MAX_CHAT_PROMPT_CHARS,
|
| 12 |
-
MAX_GROQ_PROMPT_BYTES,
|
| 13 |
-
MAX_GROQ_PROMPT_CHARS,
|
| 14 |
-
extract_user_text,
|
| 15 |
-
calculate_messages_size,
|
| 16 |
-
normalize_prompt_value,
|
| 17 |
-
enforce_prompt_size,
|
| 18 |
-
)
|
| 19 |
-
from . import router, WEBSOCKET_KEY
|
| 20 |
-
from .common import check_chat_rate_limit
|
| 21 |
-
from .override import get_override, is_allowed_in, resolve_override_routing
|
| 22 |
-
|
| 23 |
-
MODEL_MAP = {
|
| 24 |
-
"llama-3.1-8b-instant": "Meta Llama 3.1 8B Instant",
|
| 25 |
-
"gpt-4o-mini": "OpenAI GPT 4o Mini",
|
| 26 |
-
"gpt-4.1": "OpenAI GPT 4.1",
|
| 27 |
-
"nemotron-3-super": "NVIDIA Nemotron 3 Super",
|
| 28 |
-
"gpt-oss-120b": "OpenAI GPT-OSS 120B",
|
| 29 |
-
"zai-glm-4.7": "Z.AI GLM 4.7",
|
| 30 |
-
"openai/gpt-oss-120b": "OpenAI GPT-OSS 120B",
|
| 31 |
-
"openai/gpt-oss-20b": "OpenAI GPT-OSS 20B",
|
| 32 |
-
"qwen-3-235b-a22b-instruct-2507": "Qwen3 Instruct",
|
| 33 |
-
"llama-3.3-70b-versatile": "Meta Llama 3.3 70B Versatile",
|
| 34 |
-
"meta-llama/llama-4-scout-17b-16e-instruct": "Meta Llama 4 Scout",
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def route_request(messages: list, body: dict, prompt_text: str) -> dict:
|
| 39 |
-
"""
|
| 40 |
-
Analyze messages and determine the best model/provider.
|
| 41 |
-
Returns dict with keys: model, provider, url, api_key, model_name, is_override.
|
| 42 |
-
If body specifies an override model (from models_override.json), routes there.
|
| 43 |
-
"""
|
| 44 |
-
requested_model = body.get("model")
|
| 45 |
-
override = get_override(requested_model) if requested_model else None
|
| 46 |
-
|
| 47 |
-
if override:
|
| 48 |
-
routing = resolve_override_routing(requested_model)
|
| 49 |
-
if routing and is_allowed_in(requested_model, "gen"):
|
| 50 |
-
return {
|
| 51 |
-
"model": requested_model,
|
| 52 |
-
"model_name": MODEL_MAP.get(requested_model, requested_model),
|
| 53 |
-
"provider": "override",
|
| 54 |
-
"url": routing["api_url"],
|
| 55 |
-
"api_key": routing["api_key"],
|
| 56 |
-
"is_override": True,
|
| 57 |
-
"override_key": requested_model,
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
total_chars, total_bytes = calculate_messages_size(messages)
|
| 61 |
-
|
| 62 |
-
uses_tools = (
|
| 63 |
-
"tools" in body and isinstance(body["tools"], list) and len(body["tools"]) > 0
|
| 64 |
-
) or ("tool_choice" in body and body["tool_choice"] not in [None, "none"])
|
| 65 |
-
|
| 66 |
-
long_context = is_long_context(messages)
|
| 67 |
-
code_present = contains_code(prompt_text)
|
| 68 |
-
math_heavy = is_math_heavy(prompt_text)
|
| 69 |
-
structured_task = is_structured_task(prompt_text)
|
| 70 |
-
multi_q = multiple_questions(prompt_text)
|
| 71 |
-
code_heavy = is_code_heavy(prompt_text, code_present, long_context)
|
| 72 |
-
|
| 73 |
-
score = 0
|
| 74 |
-
|
| 75 |
-
if long_context:
|
| 76 |
-
score += 3
|
| 77 |
-
|
| 78 |
-
if math_heavy:
|
| 79 |
-
score += 3
|
| 80 |
-
|
| 81 |
-
if structured_task:
|
| 82 |
-
score += 2
|
| 83 |
-
|
| 84 |
-
if code_present:
|
| 85 |
-
score += 2
|
| 86 |
-
|
| 87 |
-
if multi_q:
|
| 88 |
-
score += 1
|
| 89 |
-
|
| 90 |
-
for kw in REASONING_KEYWORDS:
|
| 91 |
-
if kw in prompt_text:
|
| 92 |
-
score += 1
|
| 93 |
-
|
| 94 |
-
chosen_model = "llama-3.3-70b-versatile"
|
| 95 |
-
provider = "groq"
|
| 96 |
-
has_images = contains_images(messages)
|
| 97 |
-
|
| 98 |
-
if has_images:
|
| 99 |
-
chosen_model = "gpt-4.1"
|
| 100 |
-
provider = "navy vision"
|
| 101 |
-
else:
|
| 102 |
-
if score > 10:
|
| 103 |
-
score = 10
|
| 104 |
-
if uses_tools:
|
| 105 |
-
if score >= 6:
|
| 106 |
-
chosen_model = "nemotron-3-super"
|
| 107 |
-
provider = "navy"
|
| 108 |
-
elif score >= 4:
|
| 109 |
-
chosen_model = "gpt-oss-120b"
|
| 110 |
-
provider = "cerebras"
|
| 111 |
-
else:
|
| 112 |
-
chosen_model = "openai/gpt-oss-20b"
|
| 113 |
-
provider = "groq"
|
| 114 |
-
|
| 115 |
-
elif code_present:
|
| 116 |
-
|
| 117 |
-
if code_heavy and score >= 6:
|
| 118 |
-
chosen_model = "o3-mini"
|
| 119 |
-
provider = "navy"
|
| 120 |
-
|
| 121 |
-
elif score >= 4:
|
| 122 |
-
chosen_model = "zai-glm-4.7"
|
| 123 |
-
provider = "cerebras"
|
| 124 |
-
|
| 125 |
-
elif score >= 4:
|
| 126 |
-
chosen_model = "openai/gpt-oss-120b"
|
| 127 |
-
provider = "groq"
|
| 128 |
-
|
| 129 |
-
elif score >= 6:
|
| 130 |
-
chosen_model = "sonar"
|
| 131 |
-
provider = "navy"
|
| 132 |
-
|
| 133 |
-
if provider == "groq" and (
|
| 134 |
-
total_chars > MAX_GROQ_PROMPT_CHARS or total_bytes > MAX_GROQ_PROMPT_BYTES
|
| 135 |
-
):
|
| 136 |
-
provider = "cerebras"
|
| 137 |
-
chosen_model = "gpt-oss-120b"
|
| 138 |
-
|
| 139 |
-
print(
|
| 140 |
-
f"""
|
| 141 |
-
[ADVANCED ROUTER]
|
| 142 |
-
Score: {score}
|
| 143 |
-
Uses tools: {uses_tools}
|
| 144 |
-
Long context: {long_context}
|
| 145 |
-
Code present: {code_present}
|
| 146 |
-
Math heavy: {math_heavy}
|
| 147 |
-
Structured: {structured_task}
|
| 148 |
-
Multi-question: {multi_q}
|
| 149 |
-
MULTIMODAL REQUIRED: {has_images}
|
| 150 |
-
→ Selected: {chosen_model} ({provider})
|
| 151 |
-
"""
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
if provider == "groq":
|
| 155 |
-
groq_keys = os.getenv("GROQ_KEY", "")
|
| 156 |
-
groq_keys_list = [k.strip() for k in groq_keys.split(",") if k.strip()]
|
| 157 |
-
if not groq_keys_list:
|
| 158 |
-
raise HTTPException(500, "Missing GROQ_KEY(s)")
|
| 159 |
-
api_key = random.choice(groq_keys_list)
|
| 160 |
-
url = "https://api.groq.com/openai/v1/chat/completions"
|
| 161 |
-
|
| 162 |
-
elif provider == "cerebras":
|
| 163 |
-
cer_keys = os.getenv("CER_KEY", "")
|
| 164 |
-
cer_keys_list = [k.strip() for k in cer_keys.split(",") if k.strip()]
|
| 165 |
-
if not cer_keys_list:
|
| 166 |
-
raise HTTPException(500, "Missing CER_KEY(s)")
|
| 167 |
-
api_key = random.choice(cer_keys_list)
|
| 168 |
-
url = "https://api.cerebras.ai/v1/chat/completions"
|
| 169 |
-
|
| 170 |
-
elif provider == "navy vision":
|
| 171 |
-
navy_keys = os.getenv("NAVY_KEY", "")
|
| 172 |
-
navy_keys_list = [k.strip() for k in navy_keys.split(",") if k.strip()]
|
| 173 |
-
if not navy_keys_list:
|
| 174 |
-
raise HTTPException(500, "Missing NAVY Keys(s)")
|
| 175 |
-
api_key = random.choice(navy_keys_list)
|
| 176 |
-
url = "https://api.navy/v1/chat/completions"
|
| 177 |
-
|
| 178 |
-
elif provider == "navy":
|
| 179 |
-
navy_keys = os.getenv("NAVY_TEXT_ONLY", "")
|
| 180 |
-
navy_keys_list = [k.strip() for k in navy_keys.split(",") if k.strip()]
|
| 181 |
-
if not navy_keys_list:
|
| 182 |
-
raise HTTPException(500, "Missing NAVY TEXT ONLY Keys(s)")
|
| 183 |
-
api_key = random.choice(navy_keys_list)
|
| 184 |
-
url = "https://api.navy/v1/chat/completions"
|
| 185 |
-
|
| 186 |
-
else:
|
| 187 |
-
raise HTTPException(500, "Unknown provider routing error")
|
| 188 |
-
|
| 189 |
-
return {
|
| 190 |
-
"model": chosen_model,
|
| 191 |
-
"model_name": MODEL_MAP.get(chosen_model, chosen_model),
|
| 192 |
-
"provider": provider,
|
| 193 |
-
"url": url,
|
| 194 |
-
"api_key": api_key,
|
| 195 |
-
"is_override": False,
|
| 196 |
-
"override_key": None,
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
@router.post("/chat/completions")
|
| 201 |
-
async def generate_text(
|
| 202 |
-
request: Request,
|
| 203 |
-
authorization: Optional[str] = Header(None),
|
| 204 |
-
x_client_id: Optional[str] = Header(None),
|
| 205 |
-
):
|
| 206 |
-
body = await request.json()
|
| 207 |
-
messages = body.get("messages", [])
|
| 208 |
-
if not isinstance(messages, list) or len(messages) == 0:
|
| 209 |
-
raise HTTPException(400, "messages[] is required")
|
| 210 |
-
|
| 211 |
-
prompt_text = extract_user_text(messages)
|
| 212 |
-
|
| 213 |
-
if request.headers.get("x-payg-internal") != WEBSOCKET_KEY:
|
| 214 |
-
await check_chat_rate_limit(request, authorization, x_client_id)
|
| 215 |
-
|
| 216 |
-
route = route_request(messages, body, prompt_text)
|
| 217 |
-
chosen_model = route["model"]
|
| 218 |
-
provider = route["provider"]
|
| 219 |
-
url = route["url"]
|
| 220 |
-
api_key = route["api_key"]
|
| 221 |
-
|
| 222 |
-
body["model"] = chosen_model
|
| 223 |
-
headers = {"Authorization": f"Bearer {api_key}"}
|
| 224 |
-
|
| 225 |
-
stream = body.get("stream", False)
|
| 226 |
-
fallback_model = "meta-llama/llama-4-scout-17b-16e-instruct"
|
| 227 |
-
fallback_provider = "groq"
|
| 228 |
-
|
| 229 |
-
if stream:
|
| 230 |
-
body["stream"] = True
|
| 231 |
-
|
| 232 |
-
async def stream_primary(client, url, body, headers):
|
| 233 |
-
try:
|
| 234 |
-
async with client.stream("POST", url, json=body, headers=headers) as r:
|
| 235 |
-
|
| 236 |
-
if r.status_code >= 400:
|
| 237 |
-
print("[STREAM FALLBACK] Primary provider failed → switching to Groq fallback")
|
| 238 |
-
async for chunk in stream_fallback(client, body):
|
| 239 |
-
yield chunk
|
| 240 |
-
return
|
| 241 |
-
|
| 242 |
-
async for line in r.aiter_lines():
|
| 243 |
-
if not line:
|
| 244 |
-
yield "\n"
|
| 245 |
-
continue
|
| 246 |
-
if line.startswith("event: error"):
|
| 247 |
-
if line.startswith("data:"):
|
| 248 |
-
try:
|
| 249 |
-
obj = json.loads(line[5:].strip())
|
| 250 |
-
if isinstance(obj, dict) and "error" in obj and isinstance(obj["error"], dict):
|
| 251 |
-
pass
|
| 252 |
-
except:
|
| 253 |
-
pass
|
| 254 |
-
|
| 255 |
-
yield line + "\n"
|
| 256 |
-
|
| 257 |
-
except Exception as e:
|
| 258 |
-
print(f"[STREAM ERROR] {e}")
|
| 259 |
-
async for chunk in stream_fallback(client, body):
|
| 260 |
-
yield chunk
|
| 261 |
-
|
| 262 |
-
async def stream_fallback(client, body):
|
| 263 |
-
fallback_body = {
|
| 264 |
-
"model": fallback_model,
|
| 265 |
-
"messages": body["messages"],
|
| 266 |
-
"stream": True,
|
| 267 |
-
}
|
| 268 |
-
|
| 269 |
-
groq_keys = os.getenv("GROQ_KEY", "")
|
| 270 |
-
groq_keys_list = [k.strip() for k in groq_keys.split(",") if k.strip()]
|
| 271 |
-
fallback_headers = {"Authorization": f"Bearer {random.choice(groq_keys_list)}"}
|
| 272 |
-
|
| 273 |
-
print("[FALLBACK] Starting Groq fallback stream")
|
| 274 |
-
|
| 275 |
-
async with client.stream(
|
| 276 |
-
"POST",
|
| 277 |
-
"https://api.groq.com/openai/v1/chat/completions",
|
| 278 |
-
json=fallback_body,
|
| 279 |
-
headers=fallback_headers,
|
| 280 |
-
) as r:
|
| 281 |
-
|
| 282 |
-
if r.status_code >= 400:
|
| 283 |
-
err = (await r.aread()).decode("utf-8", errors="replace")
|
| 284 |
-
yield f'data: {{"error": "Fallback provider failed: {err[:500]}"}}\n\n'
|
| 285 |
-
return
|
| 286 |
-
|
| 287 |
-
async for line in r.aiter_lines():
|
| 288 |
-
if not line:
|
| 289 |
-
yield "\n"
|
| 290 |
-
continue
|
| 291 |
-
|
| 292 |
-
if not line.startswith("data:"):
|
| 293 |
-
yield f"data: {line}\n\n"
|
| 294 |
-
else:
|
| 295 |
-
yield line + "\n"
|
| 296 |
-
|
| 297 |
-
async def event_generator():
|
| 298 |
-
sent_metadata = False
|
| 299 |
-
|
| 300 |
-
async with httpx.AsyncClient(timeout=None) as client:
|
| 301 |
-
async for chunk in stream_primary(client, url, body, headers):
|
| 302 |
-
|
| 303 |
-
if not sent_metadata:
|
| 304 |
-
meta = {
|
| 305 |
-
"router_metadata": {
|
| 306 |
-
"model_name": route["model_name"]
|
| 307 |
-
}
|
| 308 |
-
}
|
| 309 |
-
yield f"data: {json.dumps(meta)}\n\n"
|
| 310 |
-
sent_metadata = True
|
| 311 |
-
|
| 312 |
-
yield chunk
|
| 313 |
-
|
| 314 |
-
return StreamingResponse(
|
| 315 |
-
event_generator(),
|
| 316 |
-
media_type="text/event-stream",
|
| 317 |
-
headers={
|
| 318 |
-
"Cache-Control": "no-cache",
|
| 319 |
-
"Connection": "keep-alive",
|
| 320 |
-
"X-Accel-Buffering": "no",
|
| 321 |
-
},
|
| 322 |
-
)
|
| 323 |
-
|
| 324 |
-
else:
|
| 325 |
-
async with httpx.AsyncClient(timeout=None) as client:
|
| 326 |
-
r = await client.post(url, json=body, headers=headers)
|
| 327 |
-
if provider == "navy vision" and r.status_code >= 400:
|
| 328 |
-
print("[FALLBACK] Navy vision failed — switching to 17B Groq")
|
| 329 |
-
|
| 330 |
-
groq_keys = os.getenv("GROQ_KEY", "")
|
| 331 |
-
groq_keys_list = [k.strip() for k in groq_keys.split(",") if k.strip()]
|
| 332 |
-
if not groq_keys_list:
|
| 333 |
-
raise HTTPException(500, "Missing GROQ_KEY(s) for fallback")
|
| 334 |
-
|
| 335 |
-
api_key = random.choice(groq_keys_list)
|
| 336 |
-
|
| 337 |
-
fallback_headers = {"Authorization": f"Bearer {api_key}"}
|
| 338 |
-
fallback_body = dict(body)
|
| 339 |
-
fallback_body["model"] = fallback_model
|
| 340 |
-
|
| 341 |
-
r = await client.post(
|
| 342 |
-
"https://api.groq.com/openai/v1/chat/completions",
|
| 343 |
-
json=fallback_body,
|
| 344 |
-
headers=fallback_headers,
|
| 345 |
-
)
|
| 346 |
-
content_type = (r.headers.get("content-type") or "").lower()
|
| 347 |
-
if "application/json" in content_type:
|
| 348 |
-
try:
|
| 349 |
-
payload = r.json()
|
| 350 |
-
except Exception:
|
| 351 |
-
payload = {"error": "Upstream returned invalid JSON"}
|
| 352 |
-
else:
|
| 353 |
-
payload = {
|
| 354 |
-
"error": "Upstream returned non-JSON response",
|
| 355 |
-
"status_code": r.status_code,
|
| 356 |
-
"message": r.text[:1000],
|
| 357 |
-
}
|
| 358 |
-
|
| 359 |
-
return JSONResponse(status_code=r.status_code, content=payload)
|
| 360 |
-
|
| 361 |
-
raise HTTPException(500, "Unknown provider routing error")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gen/common.py
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
import re
|
| 2 |
-
from typing import Optional
|
| 3 |
-
from fastapi import Request, Header
|
| 4 |
-
from helper.keywords import CREATIVE_KEYWORDS, REASONING_KEYWORDS, LIGHTWEIGHT_KEYWORDS
|
| 5 |
-
from helper.ratelimit import enforce_rate_limit
|
| 6 |
-
|
| 7 |
-
def is_cinematic_image_prompt(prompt: str) -> bool:
|
| 8 |
-
for kw in CREATIVE_KEYWORDS:
|
| 9 |
-
if kw in prompt.lower():
|
| 10 |
-
return True
|
| 11 |
-
return False
|
| 12 |
-
|
| 13 |
-
def is_complex_reasoning(prompt: str) -> bool:
|
| 14 |
-
if len(prompt) > 800:
|
| 15 |
-
return True
|
| 16 |
-
for kw in REASONING_KEYWORDS:
|
| 17 |
-
if kw in prompt:
|
| 18 |
-
return True
|
| 19 |
-
if re.search(r"\b(if|therefore|assume|let x|given that)\b", prompt):
|
| 20 |
-
return True
|
| 21 |
-
return False
|
| 22 |
-
|
| 23 |
-
def is_lightweight(prompt: str) -> bool:
|
| 24 |
-
if len(prompt) < 100:
|
| 25 |
-
for kw in LIGHTWEIGHT_KEYWORDS:
|
| 26 |
-
if kw in prompt:
|
| 27 |
-
return True
|
| 28 |
-
return False
|
| 29 |
-
|
| 30 |
-
async def check_chat_rate_limit(
|
| 31 |
-
request: Request,
|
| 32 |
-
authorization: Optional[str],
|
| 33 |
-
client_id: Optional[str] = None,
|
| 34 |
-
):
|
| 35 |
-
return await enforce_rate_limit(request, authorization, "cloudChatDaily", client_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gen/media.py
DELETED
|
@@ -1,408 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import base64
|
| 3 |
-
import httpx
|
| 4 |
-
from urllib.parse import quote
|
| 5 |
-
from fastapi import Request, HTTPException, Header
|
| 6 |
-
from fastapi.responses import Response, JSONResponse
|
| 7 |
-
from typing import Optional
|
| 8 |
-
from helper.assets import save_base64_image, cleanup_image, is_base64_image
|
| 9 |
-
from helper.ratelimit import (
|
| 10 |
-
check_audio_rate_limit,
|
| 11 |
-
check_video_rate_limit,
|
| 12 |
-
check_image_rate_limit,
|
| 13 |
-
MAX_MEDIA_PROMPT_BYTES,
|
| 14 |
-
MAX_MEDIA_PROMPT_CHARS,
|
| 15 |
-
normalize_prompt_value,
|
| 16 |
-
enforce_prompt_size,
|
| 17 |
-
)
|
| 18 |
-
from . import (
|
| 19 |
-
router,
|
| 20 |
-
PKEY,
|
| 21 |
-
PKEY2,
|
| 22 |
-
WEBSOCKET_KEY,
|
| 23 |
-
AIRFORCE_KEY,
|
| 24 |
-
AIRFORCE_VIDEO_MODEL,
|
| 25 |
-
AIRFORCE_API_URL,
|
| 26 |
-
valid_ratios,
|
| 27 |
-
ratios,
|
| 28 |
-
valid_modes,
|
| 29 |
-
modes,
|
| 30 |
-
)
|
| 31 |
-
from .common import is_cinematic_image_prompt
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
@router.post("/image")
|
| 35 |
-
@router.get("/image/{prompt}")
|
| 36 |
-
async def generate_image(
|
| 37 |
-
request: Request,
|
| 38 |
-
prompt: str = None,
|
| 39 |
-
authorization: str = Header(None),
|
| 40 |
-
x_client_id: str = Header(None),
|
| 41 |
-
):
|
| 42 |
-
timeout = httpx.Timeout(300.0, read=300.0)
|
| 43 |
-
|
| 44 |
-
if prompt is None:
|
| 45 |
-
payload = await request.json()
|
| 46 |
-
prompt = payload.get("prompt")
|
| 47 |
-
mode = payload.get("mode")
|
| 48 |
-
image_urls = payload.get("image_urls")
|
| 49 |
-
else:
|
| 50 |
-
mode = request.query_params.get("mode")
|
| 51 |
-
image_urls = request.query_params.getlist("image_urls")
|
| 52 |
-
|
| 53 |
-
prompt = normalize_prompt_value(prompt, "prompt")
|
| 54 |
-
enforce_prompt_size(prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Image prompt")
|
| 55 |
-
if request.headers.get("x-payg-internal") != WEBSOCKET_KEY:
|
| 56 |
-
await check_image_rate_limit(request, authorization, x_client_id)
|
| 57 |
-
|
| 58 |
-
chosen_model = "zimage"
|
| 59 |
-
if is_cinematic_image_prompt(prompt):
|
| 60 |
-
chosen_model = "flux"
|
| 61 |
-
|
| 62 |
-
if isinstance(mode, str):
|
| 63 |
-
m = mode.strip().lower()
|
| 64 |
-
if m == "fantasy":
|
| 65 |
-
chosen_model = "flux"
|
| 66 |
-
elif m == "realistic":
|
| 67 |
-
chosen_model = "zimage"
|
| 68 |
-
|
| 69 |
-
has_input_image = bool(image_urls)
|
| 70 |
-
temp_assets = []
|
| 71 |
-
|
| 72 |
-
if has_input_image:
|
| 73 |
-
chosen_model = "klein"
|
| 74 |
-
|
| 75 |
-
params = {"model": chosen_model, "key": PKEY2}
|
| 76 |
-
|
| 77 |
-
if has_input_image:
|
| 78 |
-
processed = []
|
| 79 |
-
for img in image_urls[:2]:
|
| 80 |
-
if is_base64_image(img):
|
| 81 |
-
image_id = save_base64_image(img)
|
| 82 |
-
temp_assets.append(image_id)
|
| 83 |
-
served = f"{request.base_url}asset-cdn/assets/{image_id}"
|
| 84 |
-
processed.append(served)
|
| 85 |
-
else:
|
| 86 |
-
processed.append(img)
|
| 87 |
-
params["image"] = "|".join(processed)
|
| 88 |
-
|
| 89 |
-
encoded_prompt = quote(prompt, safe="")
|
| 90 |
-
query = "&".join(f"{k}={quote(str(v), safe='')}" for k, v in params.items())
|
| 91 |
-
url = f"https://gen.pollinations.ai/image/{encoded_prompt}?{query}"
|
| 92 |
-
|
| 93 |
-
try:
|
| 94 |
-
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 95 |
-
resp = await client.get(url)
|
| 96 |
-
finally:
|
| 97 |
-
for aid in temp_assets:
|
| 98 |
-
cleanup_image(aid)
|
| 99 |
-
|
| 100 |
-
if resp.status_code != 200:
|
| 101 |
-
raise HTTPException(500, f"Pollinations error: {resp.status_code}")
|
| 102 |
-
|
| 103 |
-
return Response(content=resp.content, media_type="image/jpeg")
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
@router.get("/sfx/{prompt}")
|
| 107 |
-
@router.post("/sfx")
|
| 108 |
-
async def gensfx(
|
| 109 |
-
request: Request,
|
| 110 |
-
prompt: str = None,
|
| 111 |
-
authorization: str = Header(None),
|
| 112 |
-
x_client_id: str = Header(None),
|
| 113 |
-
):
|
| 114 |
-
if prompt is None:
|
| 115 |
-
payload = await request.json()
|
| 116 |
-
prompt = payload.get("prompt")
|
| 117 |
-
|
| 118 |
-
prompt = normalize_prompt_value(prompt, "prompt")
|
| 119 |
-
enforce_prompt_size(prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Audio prompt")
|
| 120 |
-
if request.headers.get("x-payg-internal") != WEBSOCKET_KEY:
|
| 121 |
-
await check_audio_rate_limit(request, authorization, x_client_id)
|
| 122 |
-
|
| 123 |
-
url = f"https://gen.pollinations.ai/audio/{prompt}?model=acestep&key={PKEY}"
|
| 124 |
-
|
| 125 |
-
async with httpx.AsyncClient(timeout=None) as client:
|
| 126 |
-
resp = await client.get(url)
|
| 127 |
-
|
| 128 |
-
if resp.status_code != 200:
|
| 129 |
-
return JSONResponse(
|
| 130 |
-
status_code=resp.status_code,
|
| 131 |
-
content={"success": False, "error": "Upstream music/sfx generation failed"},
|
| 132 |
-
)
|
| 133 |
-
|
| 134 |
-
return Response(resp.content, media_type="audio/mpeg")
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
@router.get("/video/{prompt}")
|
| 138 |
-
@router.post("/video")
|
| 139 |
-
@router.head("/video")
|
| 140 |
-
async def genvideo(request: Request, prompt: str = None, authorization: str = Header(None), x_client_id: str = Header(None)):
|
| 141 |
-
if request.method == "HEAD":
|
| 142 |
-
return Response(
|
| 143 |
-
status_code=200,
|
| 144 |
-
headers={
|
| 145 |
-
"Y-prompt": "string — required. The text prompt used to generate the video.",
|
| 146 |
-
"Y-ratio": "string — optional. Aspect ratio of the output video.",
|
| 147 |
-
"Y-ratio-values": "3:2,2:3,1:1",
|
| 148 |
-
"Y-ratio-default": "3:2",
|
| 149 |
-
"Y-mode": "string — optional. Controls generation style.",
|
| 150 |
-
"Y-mode-values": "normal,fun",
|
| 151 |
-
"Y-mode-default": "normal",
|
| 152 |
-
"Y-duration": "integer — optional. Duration in seconds (1–10).",
|
| 153 |
-
"Y-duration-default": "5",
|
| 154 |
-
"Y-image_urls": "array<string> — optional. Up to 2 image URLs for conditioning.",
|
| 155 |
-
"Y-image_urls-max": "2",
|
| 156 |
-
"Y-response_format": "video/mp4",
|
| 157 |
-
"Y-model": "grok-video",
|
| 158 |
-
},
|
| 159 |
-
)
|
| 160 |
-
|
| 161 |
-
aspectRatio = "3:2"
|
| 162 |
-
inputMode = "normal"
|
| 163 |
-
duration = 5
|
| 164 |
-
image_urls = None
|
| 165 |
-
ratio = None
|
| 166 |
-
mode = None
|
| 167 |
-
|
| 168 |
-
if prompt is None:
|
| 169 |
-
user_body = await request.json()
|
| 170 |
-
prompt = user_body.get("prompt")
|
| 171 |
-
ratio = user_body.get("ratio")
|
| 172 |
-
mode = user_body.get("mode")
|
| 173 |
-
image_urls = user_body.get("image_urls")
|
| 174 |
-
duration = user_body.get("duration", 5)
|
| 175 |
-
|
| 176 |
-
if ratio not in valid_ratios:
|
| 177 |
-
raise HTTPException(
|
| 178 |
-
status_code=400,
|
| 179 |
-
detail=f"Invalid aspect ratio '{ratio}'. Must be one of 3:2, 2:3, or 1:1.",
|
| 180 |
-
)
|
| 181 |
-
if ratio in ratios:
|
| 182 |
-
aspectRatio = ratio
|
| 183 |
-
|
| 184 |
-
if mode not in valid_modes:
|
| 185 |
-
raise HTTPException(
|
| 186 |
-
status_code=400,
|
| 187 |
-
detail=f"Invalid mode '{mode}'. Must be 'normal' or 'fun'.",
|
| 188 |
-
)
|
| 189 |
-
if mode in modes:
|
| 190 |
-
inputMode = mode
|
| 191 |
-
|
| 192 |
-
if image_urls:
|
| 193 |
-
if not isinstance(image_urls, list):
|
| 194 |
-
raise HTTPException(400, "image_urls must be a list")
|
| 195 |
-
if len(image_urls) > 2:
|
| 196 |
-
raise HTTPException(400, "You may provide at most two image URLs")
|
| 197 |
-
|
| 198 |
-
try:
|
| 199 |
-
duration = max(1, min(10, int(duration)))
|
| 200 |
-
except (TypeError, ValueError):
|
| 201 |
-
duration = 5
|
| 202 |
-
|
| 203 |
-
prompt = normalize_prompt_value(prompt, "prompt")
|
| 204 |
-
enforce_prompt_size(
|
| 205 |
-
prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Video prompt"
|
| 206 |
-
)
|
| 207 |
-
if request.headers.get("x-payg-internal") != WEBSOCKET_KEY:
|
| 208 |
-
await check_video_rate_limit(request, authorization, x_client_id)
|
| 209 |
-
|
| 210 |
-
RATIO_MAP = {
|
| 211 |
-
"3:2": "16:9",
|
| 212 |
-
"2:3": "9:16",
|
| 213 |
-
"1:1": "9:16",
|
| 214 |
-
}
|
| 215 |
-
pollinations_ratio = RATIO_MAP.get(aspectRatio, "16:9")
|
| 216 |
-
|
| 217 |
-
encoded_prompt = quote(prompt, safe="")
|
| 218 |
-
params = {
|
| 219 |
-
"model": "ltx-2",
|
| 220 |
-
"duration": duration,
|
| 221 |
-
"aspectRatio": pollinations_ratio,
|
| 222 |
-
"seed": -1,
|
| 223 |
-
}
|
| 224 |
-
|
| 225 |
-
temp_assets = []
|
| 226 |
-
|
| 227 |
-
if image_urls:
|
| 228 |
-
processed_urls = []
|
| 229 |
-
|
| 230 |
-
for img in image_urls[:2]:
|
| 231 |
-
if is_base64_image(img):
|
| 232 |
-
image_id = save_base64_image(img)
|
| 233 |
-
temp_assets.append(image_id)
|
| 234 |
-
|
| 235 |
-
served_url = f"{request.base_url}asset-cdn/assets/{image_id}"
|
| 236 |
-
processed_urls.append(served_url)
|
| 237 |
-
else:
|
| 238 |
-
processed_urls.append(img)
|
| 239 |
-
|
| 240 |
-
params["image"] = "|".join(processed_urls)
|
| 241 |
-
|
| 242 |
-
if inputMode == "fun":
|
| 243 |
-
params["enhance"] = "true"
|
| 244 |
-
|
| 245 |
-
query_string = "&".join(f"{k}={quote(str(v), safe='')}" for k, v in params.items())
|
| 246 |
-
url = f"https://gen.pollinations.ai/video/{encoded_prompt}?{query_string}"
|
| 247 |
-
|
| 248 |
-
print(f"[VIDEO GEN] Pollinations URL: {url}")
|
| 249 |
-
url = url + f"&key={PKEY}"
|
| 250 |
-
resp = None
|
| 251 |
-
try:
|
| 252 |
-
async with httpx.AsyncClient(timeout=600) as client:
|
| 253 |
-
resp = await client.get(url)
|
| 254 |
-
finally:
|
| 255 |
-
for aid in temp_assets:
|
| 256 |
-
cleanup_image(aid)
|
| 257 |
-
if resp is None:
|
| 258 |
-
raise HTTPException(502, "Video generation request failed")
|
| 259 |
-
if resp.status_code != 200:
|
| 260 |
-
body_text = ""
|
| 261 |
-
try:
|
| 262 |
-
body_text = resp.text
|
| 263 |
-
except Exception:
|
| 264 |
-
pass
|
| 265 |
-
return JSONResponse(
|
| 266 |
-
status_code=resp.status_code,
|
| 267 |
-
content={
|
| 268 |
-
"success": False,
|
| 269 |
-
"error": "Upstream video generation failed",
|
| 270 |
-
"status_code": resp.status_code,
|
| 271 |
-
"message": body_text[:1000],
|
| 272 |
-
},
|
| 273 |
-
)
|
| 274 |
-
|
| 275 |
-
if not resp.content:
|
| 276 |
-
raise HTTPException(502, "Pollinations returned empty response")
|
| 277 |
-
|
| 278 |
-
return Response(
|
| 279 |
-
content=resp.content,
|
| 280 |
-
media_type="video/mp4",
|
| 281 |
-
headers={
|
| 282 |
-
"Content-Length": str(len(resp.content)),
|
| 283 |
-
"Accept-Ranges": "bytes",
|
| 284 |
-
},
|
| 285 |
-
)
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
@router.get("/video/airforce/{prompt}")
|
| 289 |
-
@router.post("/video/airforce")
|
| 290 |
-
async def genvideo_airforce(
|
| 291 |
-
request: Request,
|
| 292 |
-
prompt: str = None,
|
| 293 |
-
authorization: str = Header(None),
|
| 294 |
-
x_client_id: str = Header(None),
|
| 295 |
-
):
|
| 296 |
-
if request.method == "HEAD":
|
| 297 |
-
return Response(
|
| 298 |
-
status_code=200,
|
| 299 |
-
headers={
|
| 300 |
-
"Y-prompt": "string — required. The text prompt used to generate the video.",
|
| 301 |
-
"Y-ratio": "string — optional. Aspect ratio of the output video.",
|
| 302 |
-
"Y-ratio-values": "3:2,2:3,1:1",
|
| 303 |
-
"Y-ratio-default": "3:2",
|
| 304 |
-
"Y-mode": "string — optional. Controls generation style.",
|
| 305 |
-
"Y-mode-values": "normal,fun",
|
| 306 |
-
"Y-mode-default": "normal",
|
| 307 |
-
"Y-duration": "integer — optional. Duration in seconds.",
|
| 308 |
-
"Y-duration-default": "5",
|
| 309 |
-
"Y-image_urls": "array<string> — optional. Up to 2 image URLs for conditioning.",
|
| 310 |
-
"Y-image_urls-max": "2",
|
| 311 |
-
"Y-response_format": "video/mp4",
|
| 312 |
-
"Y-model": "grok-imagine-video",
|
| 313 |
-
},
|
| 314 |
-
)
|
| 315 |
-
|
| 316 |
-
aspectRatio = "3:2"
|
| 317 |
-
inputMode = "normal"
|
| 318 |
-
image_urls = None
|
| 319 |
-
ratio = None
|
| 320 |
-
mode = None
|
| 321 |
-
|
| 322 |
-
user_body = {}
|
| 323 |
-
if prompt is None:
|
| 324 |
-
user_body = await request.json()
|
| 325 |
-
prompt = user_body.get("prompt")
|
| 326 |
-
ratio = user_body.get("ratio")
|
| 327 |
-
mode = user_body.get("mode")
|
| 328 |
-
image_urls = user_body.get("image_urls")
|
| 329 |
-
|
| 330 |
-
if ratio not in valid_ratios:
|
| 331 |
-
raise HTTPException(
|
| 332 |
-
status_code=400,
|
| 333 |
-
detail=f"Invalid aspect ratio {ratio}. Must be one of 3:2, 2:3, or 1:1. Default is 3:2",
|
| 334 |
-
)
|
| 335 |
-
if ratio in ratios:
|
| 336 |
-
aspectRatio = ratio
|
| 337 |
-
|
| 338 |
-
if mode not in valid_modes:
|
| 339 |
-
raise HTTPException(
|
| 340 |
-
status_code=400,
|
| 341 |
-
detail=f"Invalid mode {mode}. Must be 'normal' or 'fun'. Default is normal",
|
| 342 |
-
)
|
| 343 |
-
if mode in modes:
|
| 344 |
-
inputMode = mode
|
| 345 |
-
|
| 346 |
-
if image_urls:
|
| 347 |
-
if not isinstance(image_urls, list):
|
| 348 |
-
raise HTTPException(400, "image_urls must be a list")
|
| 349 |
-
|
| 350 |
-
if len(image_urls) > 2:
|
| 351 |
-
raise HTTPException(400, "You may provide at most two image URLs")
|
| 352 |
-
|
| 353 |
-
prompt = normalize_prompt_value(prompt, "prompt")
|
| 354 |
-
enforce_prompt_size(
|
| 355 |
-
prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Video prompt"
|
| 356 |
-
)
|
| 357 |
-
if request.headers.get("x-payg-internal") != WEBSOCKET_KEY:
|
| 358 |
-
await check_video_rate_limit(request, authorization, x_client_id)
|
| 359 |
-
|
| 360 |
-
payload = {
|
| 361 |
-
"model": AIRFORCE_VIDEO_MODEL,
|
| 362 |
-
"prompt": prompt,
|
| 363 |
-
"n": 1,
|
| 364 |
-
"size": "1024x1024",
|
| 365 |
-
"response_format": "b64_json",
|
| 366 |
-
"sse": False,
|
| 367 |
-
"mode": inputMode,
|
| 368 |
-
"aspectRatio": aspectRatio,
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
if image_urls:
|
| 372 |
-
payload["image_urls"] = image_urls
|
| 373 |
-
|
| 374 |
-
async with httpx.AsyncClient(timeout=600) as client:
|
| 375 |
-
resp = await client.post(
|
| 376 |
-
AIRFORCE_API_URL,
|
| 377 |
-
headers={
|
| 378 |
-
"Authorization": f"Bearer {AIRFORCE_KEY}",
|
| 379 |
-
"Content-Type": "application/json",
|
| 380 |
-
},
|
| 381 |
-
json=payload,
|
| 382 |
-
)
|
| 383 |
-
|
| 384 |
-
if resp.status_code != 200:
|
| 385 |
-
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
| 386 |
-
|
| 387 |
-
if not resp.content:
|
| 388 |
-
raise HTTPException(502, "api.airforce returned empty response")
|
| 389 |
-
|
| 390 |
-
try:
|
| 391 |
-
result = resp.json()
|
| 392 |
-
b64_video = result["data"][0]["b64_json"]
|
| 393 |
-
except Exception:
|
| 394 |
-
raise HTTPException(502, f"Invalid api.airforce response: {resp.text[:500]}")
|
| 395 |
-
|
| 396 |
-
if not b64_video:
|
| 397 |
-
raise HTTPException(502, "Airforce returned empty b64_json")
|
| 398 |
-
|
| 399 |
-
video_bytes = base64.b64decode(b64_video)
|
| 400 |
-
|
| 401 |
-
return Response(
|
| 402 |
-
content=video_bytes,
|
| 403 |
-
media_type="video/mp4",
|
| 404 |
-
headers={
|
| 405 |
-
"Content-Length": str(len(video_bytes)),
|
| 406 |
-
"Accept-Ranges": "bytes",
|
| 407 |
-
},
|
| 408 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gen/models.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
from . import router
|
| 2 |
-
from .override import build_all_models
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
@router.get("/models")
|
| 6 |
-
def return_models_openai():
|
| 7 |
-
return {
|
| 8 |
-
"object": "list",
|
| 9 |
-
"data": build_all_models(),
|
| 10 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gen/override.py
DELETED
|
@@ -1,133 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import os
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
from typing import Any, Dict, List, Optional
|
| 5 |
-
|
| 6 |
-
CONFIG_PATH = Path(__file__).resolve().parent.parent / "models_override.json"
|
| 7 |
-
LIGHTNING_CREATED = 1767225600
|
| 8 |
-
|
| 9 |
-
_OVERRIDE_CACHE: Optional[Dict[str, Any]] = None
|
| 10 |
-
_OVERRIDE_MTIME: Optional[float] = None
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def _load_overrides() -> Dict[str, Any]:
|
| 14 |
-
global _OVERRIDE_CACHE, _OVERRIDE_MTIME
|
| 15 |
-
mtime = None
|
| 16 |
-
try:
|
| 17 |
-
mtime = CONFIG_PATH.stat().st_mtime
|
| 18 |
-
except FileNotFoundError:
|
| 19 |
-
pass
|
| 20 |
-
|
| 21 |
-
if _OVERRIDE_CACHE is not None and _OVERRIDE_MTIME == mtime:
|
| 22 |
-
return _OVERRIDE_CACHE
|
| 23 |
-
|
| 24 |
-
data = {"lightning": {}, "overrides": []}
|
| 25 |
-
if mtime is not None:
|
| 26 |
-
try:
|
| 27 |
-
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
| 28 |
-
if isinstance(raw, dict):
|
| 29 |
-
if not isinstance(raw.get("overrides"), list):
|
| 30 |
-
raw["overrides"] = []
|
| 31 |
-
if not isinstance(raw.get("lightning"), dict):
|
| 32 |
-
raw["lightning"] = {}
|
| 33 |
-
data = raw
|
| 34 |
-
except Exception:
|
| 35 |
-
pass
|
| 36 |
-
|
| 37 |
-
_OVERRIDE_CACHE = data
|
| 38 |
-
_OVERRIDE_MTIME = mtime
|
| 39 |
-
return data
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def get_override(model_id: str) -> Optional[Dict[str, Any]]:
|
| 43 |
-
data = _load_overrides()
|
| 44 |
-
for entry in data.get("overrides", []):
|
| 45 |
-
if isinstance(entry, dict) and entry.get("id") == model_id:
|
| 46 |
-
return entry
|
| 47 |
-
return None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def is_allowed_in(model_id: str, context: str) -> bool:
|
| 51 |
-
entry = get_override(model_id)
|
| 52 |
-
if not entry:
|
| 53 |
-
return False
|
| 54 |
-
allowed = entry.get("allowed_in", [])
|
| 55 |
-
return context in allowed
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def resolve_override_routing(model_id: str) -> Optional[Dict[str, Any]]:
|
| 59 |
-
entry = get_override(model_id)
|
| 60 |
-
if not entry:
|
| 61 |
-
return None
|
| 62 |
-
api_url = entry.get("api_url")
|
| 63 |
-
api_key_env = entry.get("api_key_env")
|
| 64 |
-
api_key = os.getenv(api_key_env, "") if api_key_env else ""
|
| 65 |
-
if not api_url or not api_key:
|
| 66 |
-
return None
|
| 67 |
-
return {
|
| 68 |
-
"api_url": api_url,
|
| 69 |
-
"api_key": api_key,
|
| 70 |
-
"pricing_per_million_tokens": float(entry.get("pricing_per_million_tokens", 0)),
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def _ppm_to_per_token(ppm: float) -> str:
|
| 75 |
-
return f"{ppm / 1_000_000:.12f}"
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _build_lightning_entry(config_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 79 |
-
lightning = config_data.get("lightning", {})
|
| 80 |
-
ppm = float(lightning.get("pricing_per_million_tokens", 0.75))
|
| 81 |
-
pt = _ppm_to_per_token(ppm)
|
| 82 |
-
return {
|
| 83 |
-
"id": "lightning",
|
| 84 |
-
"name": lightning.get("name", "Lightning"),
|
| 85 |
-
"created": LIGHTNING_CREATED,
|
| 86 |
-
"context_length": lightning.get("context_length", 128000),
|
| 87 |
-
"max_output_length": lightning.get("max_output_length", 32000),
|
| 88 |
-
"input_modalities": lightning.get("input_modalities", ["text", "image", "file"]),
|
| 89 |
-
"output_modalities": lightning.get("output_modalities", ["text"]),
|
| 90 |
-
"pricing": {
|
| 91 |
-
"prompt": pt,
|
| 92 |
-
"completion": pt,
|
| 93 |
-
"image": "0",
|
| 94 |
-
"request": "0",
|
| 95 |
-
"input_cache_read": "0",
|
| 96 |
-
},
|
| 97 |
-
"supported_features": lightning.get("supported_features", ["tools", "json_mode", "streaming", "structured_outputs"]),
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def _build_override_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
|
| 102 |
-
ppm = float(entry.get("pricing_per_million_tokens", 0))
|
| 103 |
-
pt = _ppm_to_per_token(ppm)
|
| 104 |
-
return {
|
| 105 |
-
"id": entry.get("id", ""),
|
| 106 |
-
"name": entry.get("name", entry.get("id", "")),
|
| 107 |
-
"created": entry.get("created", LIGHTNING_CREATED),
|
| 108 |
-
"context_length": entry.get("context_length", 128000),
|
| 109 |
-
"max_output_length": entry.get("max_output_length", 32000),
|
| 110 |
-
"input_modalities": entry.get("input_modalities", ["text"]),
|
| 111 |
-
"output_modalities": entry.get("output_modalities", ["text"]),
|
| 112 |
-
"pricing": {
|
| 113 |
-
"prompt": pt,
|
| 114 |
-
"completion": pt,
|
| 115 |
-
"image": "0",
|
| 116 |
-
"request": "0",
|
| 117 |
-
"input_cache_read": "0",
|
| 118 |
-
},
|
| 119 |
-
"supported_features": entry.get("supported_features", ["streaming"]),
|
| 120 |
-
"quantization": entry.get("quantization"),
|
| 121 |
-
"description": entry.get("description"),
|
| 122 |
-
"is_ready": True,
|
| 123 |
-
"is_free": ppm == 0,
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def build_all_models() -> List[Dict[str, Any]]:
|
| 128 |
-
data = _load_overrides()
|
| 129 |
-
models = [_build_lightning_entry(data)]
|
| 130 |
-
for entry in data.get("overrides", []):
|
| 131 |
-
if isinstance(entry, dict):
|
| 132 |
-
models.append(_build_override_entry(entry))
|
| 133 |
-
return models
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gen/prompt.py
DELETED
|
@@ -1,101 +0,0 @@
|
|
| 1 |
-
from fastapi import Request, HTTPException
|
| 2 |
-
from fastapi.responses import JSONResponse
|
| 3 |
-
from helper.keywords import *
|
| 4 |
-
from helper.ratelimit import (
|
| 5 |
-
extract_user_text,
|
| 6 |
-
calculate_messages_size,
|
| 7 |
-
MAX_GROQ_PROMPT_BYTES,
|
| 8 |
-
MAX_GROQ_PROMPT_CHARS,
|
| 9 |
-
)
|
| 10 |
-
from . import router
|
| 11 |
-
from .chat import MODEL_MAP
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@router.post("/prompt_analyze")
|
| 15 |
-
async def analyze_prompt(request: Request):
|
| 16 |
-
body = await request.json()
|
| 17 |
-
messages = body.get("prompt", [])
|
| 18 |
-
if not isinstance(messages, list) or len(messages) == 0:
|
| 19 |
-
raise HTTPException(400, "messages[] is required")
|
| 20 |
-
|
| 21 |
-
total_chars, total_bytes = calculate_messages_size(messages)
|
| 22 |
-
prompt_text = extract_user_text(messages)
|
| 23 |
-
|
| 24 |
-
uses_tools = (
|
| 25 |
-
"tools" in body and isinstance(body["tools"], list) and len(body["tools"]) > 0
|
| 26 |
-
) or ("tool_choice" in body and body["tool_choice"] not in [None, "none"])
|
| 27 |
-
|
| 28 |
-
long_context = is_long_context(messages)
|
| 29 |
-
code_present = contains_code(prompt_text)
|
| 30 |
-
math_heavy = is_math_heavy(prompt_text)
|
| 31 |
-
structured_task = is_structured_task(prompt_text)
|
| 32 |
-
multi_q = multiple_questions(prompt_text)
|
| 33 |
-
code_heavy = is_code_heavy(prompt_text, code_present, long_context)
|
| 34 |
-
|
| 35 |
-
score = 0
|
| 36 |
-
|
| 37 |
-
if long_context:
|
| 38 |
-
score += 3
|
| 39 |
-
|
| 40 |
-
if math_heavy:
|
| 41 |
-
score += 3
|
| 42 |
-
|
| 43 |
-
if structured_task:
|
| 44 |
-
score += 2
|
| 45 |
-
|
| 46 |
-
if code_present:
|
| 47 |
-
score += 2
|
| 48 |
-
|
| 49 |
-
if multi_q:
|
| 50 |
-
score += 1
|
| 51 |
-
|
| 52 |
-
for kw in REASONING_KEYWORDS:
|
| 53 |
-
if kw in prompt_text:
|
| 54 |
-
score += 1
|
| 55 |
-
|
| 56 |
-
chosen_model = "llama-3.3-70b-versatile"
|
| 57 |
-
provider = "groq"
|
| 58 |
-
has_images = contains_images(messages)
|
| 59 |
-
|
| 60 |
-
if has_images:
|
| 61 |
-
chosen_model = "gpt-4.1"
|
| 62 |
-
provider = "navy vision"
|
| 63 |
-
else:
|
| 64 |
-
if score > 10:
|
| 65 |
-
score = 10
|
| 66 |
-
if uses_tools:
|
| 67 |
-
if score >= 6:
|
| 68 |
-
chosen_model = "nemotron-3-super"
|
| 69 |
-
provider = "navy"
|
| 70 |
-
elif score >= 4:
|
| 71 |
-
chosen_model = "gpt-oss-120b"
|
| 72 |
-
provider = "cerebras"
|
| 73 |
-
else:
|
| 74 |
-
chosen_model = "openai/gpt-oss-20b"
|
| 75 |
-
provider = "groq"
|
| 76 |
-
|
| 77 |
-
elif code_present:
|
| 78 |
-
|
| 79 |
-
if code_heavy and score >= 6:
|
| 80 |
-
chosen_model = "o3-mini"
|
| 81 |
-
provider = "navy"
|
| 82 |
-
|
| 83 |
-
elif score >= 4:
|
| 84 |
-
chosen_model = "zai-glm-4.7"
|
| 85 |
-
provider = "cerebras"
|
| 86 |
-
|
| 87 |
-
elif score >= 4:
|
| 88 |
-
chosen_model = "openai/gpt-oss-120b"
|
| 89 |
-
provider = "groq"
|
| 90 |
-
|
| 91 |
-
elif score >= 6:
|
| 92 |
-
chosen_model = "sonar"
|
| 93 |
-
provider = "navy"
|
| 94 |
-
|
| 95 |
-
if provider == "groq" and (
|
| 96 |
-
total_chars > MAX_GROQ_PROMPT_CHARS or total_bytes > MAX_GROQ_PROMPT_BYTES
|
| 97 |
-
):
|
| 98 |
-
provider = "cerebras"
|
| 99 |
-
chosen_model = "gpt-oss-120b"
|
| 100 |
-
|
| 101 |
-
return {MODEL_MAP[chosen_model]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
helper/assets.py
DELETED
|
@@ -1,80 +0,0 @@
|
|
| 1 |
-
# app.py
|
| 2 |
-
import base64
|
| 3 |
-
import os
|
| 4 |
-
import uuid
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from typing import Dict
|
| 7 |
-
from PIL import Image
|
| 8 |
-
from io import BytesIO
|
| 9 |
-
|
| 10 |
-
from fastapi import FastAPI, HTTPException, APIRouter
|
| 11 |
-
from fastapi.responses import FileResponse
|
| 12 |
-
|
| 13 |
-
BASE_DIR = Path(__file__).parent
|
| 14 |
-
ASSETS_DIR = BASE_DIR / "cdn-assets"
|
| 15 |
-
ASSETS_DIR.mkdir(exist_ok=True)
|
| 16 |
-
|
| 17 |
-
image_registry: Dict[str, Path] = {}
|
| 18 |
-
asset_router = APIRouter(prefix="/asset-cdn", tags=["asset-cdn"])
|
| 19 |
-
|
| 20 |
-
def save_base64_image(b64_data: str) -> str:
|
| 21 |
-
"""
|
| 22 |
-
Decodes a base64 string, converts the image to PNG, stores it in /cdn-assets and returns the
|
| 23 |
-
unique image_id that can be used in the public URL.
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
if "," in b64_data:
|
| 27 |
-
_, b64_data = b64_data.split(",", 1)
|
| 28 |
-
|
| 29 |
-
try:
|
| 30 |
-
decoded = base64.b64decode(b64_data)
|
| 31 |
-
except (base64.binascii.Error, ValueError) as exc:
|
| 32 |
-
raise ValueError("Invalid base64 data") from exc
|
| 33 |
-
|
| 34 |
-
image = Image.open(BytesIO(decoded))
|
| 35 |
-
|
| 36 |
-
image_id = uuid.uuid4().hex
|
| 37 |
-
file_path = ASSETS_DIR / f"{image_id}.png"
|
| 38 |
-
|
| 39 |
-
image.save(file_path, "PNG")
|
| 40 |
-
|
| 41 |
-
image_registry[image_id] = file_path
|
| 42 |
-
|
| 43 |
-
return image_id
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
@asset_router.get("/assets/{image_id}")
|
| 47 |
-
def get_image(image_id: str):
|
| 48 |
-
"""
|
| 49 |
-
FastAPI route that streams the requested image to the client.
|
| 50 |
-
If the image was cleaned up or never existed, a 404 is returned.
|
| 51 |
-
"""
|
| 52 |
-
file_path = image_registry.get(image_id)
|
| 53 |
-
if not file_path or not file_path.exists():
|
| 54 |
-
raise HTTPException(status_code=404, detail="Image not found")
|
| 55 |
-
|
| 56 |
-
return FileResponse(path=file_path, media_type="image/png")
|
| 57 |
-
|
| 58 |
-
def cleanup_image(image_id: str) -> bool:
|
| 59 |
-
"""
|
| 60 |
-
Deletes the image file and removes it from the registry.
|
| 61 |
-
Returns True if the image existed and was removed, otherwise False.
|
| 62 |
-
"""
|
| 63 |
-
file_path = image_registry.pop(image_id, None)
|
| 64 |
-
if file_path and file_path.exists():
|
| 65 |
-
try:
|
| 66 |
-
file_path.unlink()
|
| 67 |
-
except Exception:
|
| 68 |
-
image_registry[image_id] = file_path
|
| 69 |
-
return False
|
| 70 |
-
return True
|
| 71 |
-
return False
|
| 72 |
-
|
| 73 |
-
def is_base64_image(value: str) -> bool:
|
| 74 |
-
if not isinstance(value, str):
|
| 75 |
-
return False
|
| 76 |
-
if value.startswith("http://") or value.startswith("https://"):
|
| 77 |
-
return False
|
| 78 |
-
if value.startswith("data:image"):
|
| 79 |
-
return True
|
| 80 |
-
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
helper/keywords.py
DELETED
|
@@ -1,274 +0,0 @@
|
|
| 1 |
-
import re
|
| 2 |
-
REASONING_KEYWORDS = [
|
| 3 |
-
# explicit reasoning requests
|
| 4 |
-
"prove", "demonstrate", "derive", "justify", "verify",
|
| 5 |
-
"show that", "walk through", "step by step", "reason through",
|
| 6 |
-
"chain of reasoning", "rigorous", "formal proof",
|
| 7 |
-
|
| 8 |
-
# analysis/comparison
|
| 9 |
-
"analyze", "analysis of", "compare and contrast",
|
| 10 |
-
"evaluate", "critically assess", "explain why",
|
| 11 |
-
"explain how", "what causes", "implications of",
|
| 12 |
-
|
| 13 |
-
# problem solving
|
| 14 |
-
"solve", "solution to", "how would you approach",
|
| 15 |
-
"strategy for", "optimize", "algorithm for",
|
| 16 |
-
|
| 17 |
-
# technical domains
|
| 18 |
-
"theorem", "lemma", "corollary",
|
| 19 |
-
"complexity analysis", "big o", "time complexity",
|
| 20 |
-
"mathematical", "statistical", "probabilistic",
|
| 21 |
-
"model the", "simulate",
|
| 22 |
-
]
|
| 23 |
-
|
| 24 |
-
CODE_KEYWORDS = [
|
| 25 |
-
"await", "async", "print(", "console.log(",
|
| 26 |
-
"code", "ts", "js", "py", ".repy", ".rb",
|
| 27 |
-
"gnu", "gcc", "clang", "clang++", "program",
|
| 28 |
-
"coding", "import", "include", "const", "var",
|
| 29 |
-
"let", "str", "string", "int", "float", "code",
|
| 30 |
-
"bot", "server", "html", "script", "database",
|
| 31 |
-
"db", "sql", "postgre", "app store", "tensorflow",
|
| 32 |
-
"pytorch", "python", "rust", "api",
|
| 33 |
-
]
|
| 34 |
-
|
| 35 |
-
CREATIVE_KEYWORDS = [
|
| 36 |
-
# cinematic cues
|
| 37 |
-
"cinematic", "film still", "movie scene",
|
| 38 |
-
"epic", "dramatic lighting", "moody lighting",
|
| 39 |
-
"volumetric lighting", "depth of field",
|
| 40 |
-
"anamorphic lens", "8k", "4k",
|
| 41 |
-
|
| 42 |
-
# art styles
|
| 43 |
-
"concept art", "digital painting",
|
| 44 |
-
"fantasy art", "sci-fi", "mythical",
|
| 45 |
-
"cyberpunk", "steampunk",
|
| 46 |
-
"baroque", "surreal", "abstract",
|
| 47 |
-
"oil painting", "watercolor",
|
| 48 |
-
|
| 49 |
-
# rendering engines
|
| 50 |
-
"octane render", "unreal engine",
|
| 51 |
-
"ray tracing", "global illumination",
|
| 52 |
-
|
| 53 |
-
# emotional narrative framing
|
| 54 |
-
"emotional portrait", "story scene",
|
| 55 |
-
"hero shot", "dramatic pose",
|
| 56 |
-
]
|
| 57 |
-
|
| 58 |
-
STRUCTURED_KEYWORDS = [
|
| 59 |
-
"return as json",
|
| 60 |
-
"output json",
|
| 61 |
-
"json schema",
|
| 62 |
-
"format as json",
|
| 63 |
-
"structured output",
|
| 64 |
-
"extract entities",
|
| 65 |
-
"extract fields",
|
| 66 |
-
"parse this",
|
| 67 |
-
"convert to table",
|
| 68 |
-
"create a table",
|
| 69 |
-
"categorize into",
|
| 70 |
-
"classify",
|
| 71 |
-
"label the following",
|
| 72 |
-
"taxonomy",
|
| 73 |
-
"generate schema",
|
| 74 |
-
]
|
| 75 |
-
|
| 76 |
-
MATH_PATTERNS = [
|
| 77 |
-
r"\b∫\b", r"\b∑\b", r"\b∂\b",
|
| 78 |
-
r"\bmatrix\b",
|
| 79 |
-
r"\blimit\b",
|
| 80 |
-
r"\bintegral\b",
|
| 81 |
-
r"\bderivative\b",
|
| 82 |
-
r"\bdifferential equation\b",
|
| 83 |
-
r"\blinear algebra\b",
|
| 84 |
-
r"\boptimi[sz]e\b",
|
| 85 |
-
r"\bgradient\b",
|
| 86 |
-
r"\bbackprop\b",
|
| 87 |
-
r"\bproof\b",
|
| 88 |
-
r"\btheorem\b",
|
| 89 |
-
]
|
| 90 |
-
|
| 91 |
-
LIGHTWEIGHT_KEYWORDS = [
|
| 92 |
-
"hello", "hi", "hey",
|
| 93 |
-
"thanks", "thank you",
|
| 94 |
-
"define", "definition of",
|
| 95 |
-
"what is", "who is",
|
| 96 |
-
"quick question",
|
| 97 |
-
"short answer",
|
| 98 |
-
"brief explanation",
|
| 99 |
-
"summarize",
|
| 100 |
-
"paraphrase",
|
| 101 |
-
"rewrite this",
|
| 102 |
-
]
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _message_content_to_text(content):
|
| 106 |
-
if isinstance(content, str):
|
| 107 |
-
return content
|
| 108 |
-
|
| 109 |
-
if isinstance(content, list):
|
| 110 |
-
parts = []
|
| 111 |
-
for item in content:
|
| 112 |
-
if isinstance(item, str):
|
| 113 |
-
parts.append(item)
|
| 114 |
-
elif isinstance(item, dict):
|
| 115 |
-
for key in (
|
| 116 |
-
"text",
|
| 117 |
-
"content",
|
| 118 |
-
"arguments",
|
| 119 |
-
"function_call",
|
| 120 |
-
"function",
|
| 121 |
-
"tool_calls",
|
| 122 |
-
"prompt",
|
| 123 |
-
"input",
|
| 124 |
-
"output",
|
| 125 |
-
"message",
|
| 126 |
-
"description",
|
| 127 |
-
"caption",
|
| 128 |
-
"query",
|
| 129 |
-
"summary",
|
| 130 |
-
):
|
| 131 |
-
nested = item.get(key)
|
| 132 |
-
if nested is None:
|
| 133 |
-
continue
|
| 134 |
-
nested_text = _message_content_to_text(nested)
|
| 135 |
-
if nested_text:
|
| 136 |
-
parts.append(nested_text)
|
| 137 |
-
return " ".join(parts)
|
| 138 |
-
|
| 139 |
-
if isinstance(content, dict):
|
| 140 |
-
parts = []
|
| 141 |
-
for key in (
|
| 142 |
-
"text",
|
| 143 |
-
"content",
|
| 144 |
-
"arguments",
|
| 145 |
-
"function_call",
|
| 146 |
-
"function",
|
| 147 |
-
"tool_calls",
|
| 148 |
-
"prompt",
|
| 149 |
-
"input",
|
| 150 |
-
"output",
|
| 151 |
-
"message",
|
| 152 |
-
"description",
|
| 153 |
-
"caption",
|
| 154 |
-
"query",
|
| 155 |
-
"summary",
|
| 156 |
-
):
|
| 157 |
-
nested = content.get(key)
|
| 158 |
-
if nested is None:
|
| 159 |
-
continue
|
| 160 |
-
nested_text = _message_content_to_text(nested)
|
| 161 |
-
if nested_text:
|
| 162 |
-
parts.append(nested_text)
|
| 163 |
-
return " ".join(parts)
|
| 164 |
-
|
| 165 |
-
return ""
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
def _message_contains_images(content) -> bool:
|
| 169 |
-
if isinstance(content, list):
|
| 170 |
-
for item in content:
|
| 171 |
-
if isinstance(item, dict):
|
| 172 |
-
item_type = str(item.get("type") or "").lower()
|
| 173 |
-
if item_type in {"image", "image_url", "input_image", "image_urls"}:
|
| 174 |
-
return True
|
| 175 |
-
if "image_url" in item:
|
| 176 |
-
return True
|
| 177 |
-
if _message_contains_images(item.get("content")):
|
| 178 |
-
return True
|
| 179 |
-
return False
|
| 180 |
-
|
| 181 |
-
if isinstance(content, dict):
|
| 182 |
-
item_type = str(content.get("type") or "").lower()
|
| 183 |
-
if item_type in {"image", "image_url", "input_image", "image_urls"}:
|
| 184 |
-
return True
|
| 185 |
-
if "image_url" in content:
|
| 186 |
-
return True
|
| 187 |
-
for key in ("content", "message", "input", "output", "prompt", "function_call", "function", "tool_calls"):
|
| 188 |
-
if _message_contains_images(content.get(key)):
|
| 189 |
-
return True
|
| 190 |
-
|
| 191 |
-
return False
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
def is_long_context(messages: list) -> bool:
|
| 195 |
-
total_chars = 0
|
| 196 |
-
for message in messages:
|
| 197 |
-
if not isinstance(message, dict):
|
| 198 |
-
continue
|
| 199 |
-
total_chars += len(_message_content_to_text(message.get("content")))
|
| 200 |
-
return total_chars > 4000
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def contains_code(prompt: str) -> bool:
|
| 204 |
-
if "```" in prompt:
|
| 205 |
-
return True
|
| 206 |
-
for kw in CODE_KEYWORDS:
|
| 207 |
-
if kw in prompt:
|
| 208 |
-
return True
|
| 209 |
-
return False
|
| 210 |
-
|
| 211 |
-
def is_code_heavy(prompt: str, code_present: bool, long_context: bool) -> bool:
|
| 212 |
-
"""
|
| 213 |
-
Determines whether the coding task is substantial enough
|
| 214 |
-
to require a code-optimized or larger model.
|
| 215 |
-
"""
|
| 216 |
-
|
| 217 |
-
if not code_present:
|
| 218 |
-
return False
|
| 219 |
-
|
| 220 |
-
heavy_patterns = [
|
| 221 |
-
r"\brefactor\b",
|
| 222 |
-
r"\boptimi[sz]e\b",
|
| 223 |
-
r"\bdebug\b",
|
| 224 |
-
r"\bfix this\b",
|
| 225 |
-
r"\barchitecture\b",
|
| 226 |
-
r"\bdesign pattern\b",
|
| 227 |
-
r"\bscalable\b",
|
| 228 |
-
r"\bmicroservice\b",
|
| 229 |
-
r"\bmultiple files\b",
|
| 230 |
-
r"\bentire project\b",
|
| 231 |
-
r"\bcodebase\b",
|
| 232 |
-
r"\bperformance\b",
|
| 233 |
-
]
|
| 234 |
-
|
| 235 |
-
for pattern in heavy_patterns:
|
| 236 |
-
if re.search(pattern, prompt):
|
| 237 |
-
return True
|
| 238 |
-
|
| 239 |
-
if prompt.count("```") >= 2:
|
| 240 |
-
return True
|
| 241 |
-
|
| 242 |
-
if long_context:
|
| 243 |
-
return True
|
| 244 |
-
|
| 245 |
-
return False
|
| 246 |
-
|
| 247 |
-
def is_math_heavy(prompt: str) -> bool:
|
| 248 |
-
for pattern in MATH_PATTERNS:
|
| 249 |
-
if re.search(pattern, prompt):
|
| 250 |
-
return True
|
| 251 |
-
return False
|
| 252 |
-
|
| 253 |
-
def is_structured_task(prompt: str) -> bool:
|
| 254 |
-
for kw in STRUCTURED_KEYWORDS:
|
| 255 |
-
if kw in prompt:
|
| 256 |
-
return True
|
| 257 |
-
return False
|
| 258 |
-
|
| 259 |
-
def multiple_questions(prompt: str) -> bool:
|
| 260 |
-
return prompt.count("?") >= 3
|
| 261 |
-
|
| 262 |
-
def contains_images(messages):
|
| 263 |
-
for msg in messages:
|
| 264 |
-
if not isinstance(msg, dict):
|
| 265 |
-
continue
|
| 266 |
-
|
| 267 |
-
content = msg.get("content")
|
| 268 |
-
if _message_contains_images(content):
|
| 269 |
-
return True
|
| 270 |
-
|
| 271 |
-
if "image" in msg or "image_url" in msg:
|
| 272 |
-
return True
|
| 273 |
-
|
| 274 |
-
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
helper/payg.py
DELETED
|
@@ -1,670 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import json
|
| 3 |
-
import math
|
| 4 |
-
import os
|
| 5 |
-
import time
|
| 6 |
-
from collections import defaultdict, deque
|
| 7 |
-
from decimal import Decimal, ROUND_HALF_UP
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
from typing import Any, Dict, Optional
|
| 10 |
-
from psycopg.errors import UniqueViolation
|
| 11 |
-
from fastapi import HTTPException
|
| 12 |
-
|
| 13 |
-
from helper.subscriptions import execute_query, resolve_token_identity
|
| 14 |
-
|
| 15 |
-
CONFIG_PATH = Path(__file__).resolve().parent.parent / "assets" / "payg_config.json"
|
| 16 |
-
CONFIG_LOCK = asyncio.Lock()
|
| 17 |
-
_CONFIG_CACHE: Optional[Dict[str, Any]] = None
|
| 18 |
-
_CONFIG_MTIME: Optional[float] = None
|
| 19 |
-
|
| 20 |
-
SPAM_TRACKER: Dict[str, deque] = defaultdict(deque)
|
| 21 |
-
SPAM_LOCK = asyncio.Lock()
|
| 22 |
-
|
| 23 |
-
CREDIT_SCALE = Decimal("0.0001")
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def _default_config() -> Dict[str, Any]:
|
| 27 |
-
return {
|
| 28 |
-
"dashboard": {
|
| 29 |
-
"appName": "InferencePort AI",
|
| 30 |
-
"homeUrl": "https://inference.js.org",
|
| 31 |
-
"apiBaseUrl": "https://sharktide-lightning.hf.space",
|
| 32 |
-
"supportEmail": "inferenceportai@gmail.com",
|
| 33 |
-
"hostname": "",
|
| 34 |
-
},
|
| 35 |
-
"supabase": {
|
| 36 |
-
"url": "",
|
| 37 |
-
"publishableKey": "",
|
| 38 |
-
"resetRedirectUrl": "https://inference.js.org/reset.html",
|
| 39 |
-
"deletePasswordVerifyEndpoint": "",
|
| 40 |
-
"deleteAccountEndpoint": "",
|
| 41 |
-
},
|
| 42 |
-
"billing": {
|
| 43 |
-
"currency": "USD",
|
| 44 |
-
"packs": [],
|
| 45 |
-
},
|
| 46 |
-
"pricing": {
|
| 47 |
-
"textCreditPerMillionTokens": 0.75,
|
| 48 |
-
"imageCreditPerImage": 0.02,
|
| 49 |
-
"videoCreditPerSecond": 0.01,
|
| 50 |
-
"audioCreditPerSecond": 0.01,
|
| 51 |
-
"audioDefaultSeconds": 10,
|
| 52 |
-
"textTokenEstimateMultiplier": 1.0,
|
| 53 |
-
},
|
| 54 |
-
"models": [],
|
| 55 |
-
"notices": [],
|
| 56 |
-
"limits": {
|
| 57 |
-
"requestsPerMinutePerIdentity": 120,
|
| 58 |
-
"minimumTextCreditsBalance": 10,
|
| 59 |
-
},
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
| 64 |
-
out = dict(base)
|
| 65 |
-
for key, value in override.items():
|
| 66 |
-
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
| 67 |
-
out[key] = _deep_merge(out[key], value)
|
| 68 |
-
else:
|
| 69 |
-
out[key] = value
|
| 70 |
-
return out
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _to_decimal(value: Any, default: str = "0") -> Decimal:
|
| 74 |
-
try:
|
| 75 |
-
return Decimal(str(value))
|
| 76 |
-
except Exception:
|
| 77 |
-
return Decimal(default)
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def quantize_credits(value: Decimal | float | int) -> Decimal:
|
| 81 |
-
if not isinstance(value, Decimal):
|
| 82 |
-
value = _to_decimal(value)
|
| 83 |
-
return value.quantize(CREDIT_SCALE, rounding=ROUND_HALF_UP)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
async def get_payg_config() -> Dict[str, Any]:
|
| 87 |
-
global _CONFIG_CACHE, _CONFIG_MTIME
|
| 88 |
-
|
| 89 |
-
async with CONFIG_LOCK:
|
| 90 |
-
mtime = None
|
| 91 |
-
try:
|
| 92 |
-
mtime = CONFIG_PATH.stat().st_mtime
|
| 93 |
-
except FileNotFoundError:
|
| 94 |
-
pass
|
| 95 |
-
|
| 96 |
-
if _CONFIG_CACHE is not None and _CONFIG_MTIME == mtime:
|
| 97 |
-
return _CONFIG_CACHE
|
| 98 |
-
|
| 99 |
-
config = _default_config()
|
| 100 |
-
|
| 101 |
-
if mtime is not None:
|
| 102 |
-
try:
|
| 103 |
-
file_data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
| 104 |
-
if isinstance(file_data, dict):
|
| 105 |
-
config = _deep_merge(config, file_data)
|
| 106 |
-
except Exception:
|
| 107 |
-
pass
|
| 108 |
-
|
| 109 |
-
packs = []
|
| 110 |
-
for raw_pack in config.get("billing", {}).get("packs", []):
|
| 111 |
-
if not isinstance(raw_pack, dict):
|
| 112 |
-
continue
|
| 113 |
-
credits = _to_decimal(raw_pack.get("credits"), "0")
|
| 114 |
-
amount = _to_decimal(raw_pack.get("amountUsd"), "0")
|
| 115 |
-
if credits <= 0 or amount <= 0:
|
| 116 |
-
continue
|
| 117 |
-
packs.append(
|
| 118 |
-
{
|
| 119 |
-
"id": str(raw_pack.get("id") or "").strip(),
|
| 120 |
-
"label": str(raw_pack.get("label") or "").strip() or f"${amount}",
|
| 121 |
-
"amountUsd": float(amount),
|
| 122 |
-
"credits": float(quantize_credits(credits)),
|
| 123 |
-
"stripePaymentLink": str(raw_pack.get("stripePaymentLink") or "").strip(),
|
| 124 |
-
"prod": str(raw_pack.get("prod") or "").strip(),
|
| 125 |
-
}
|
| 126 |
-
)
|
| 127 |
-
config["billing"]["packs"] = packs
|
| 128 |
-
|
| 129 |
-
_CONFIG_CACHE = config
|
| 130 |
-
_CONFIG_MTIME = mtime
|
| 131 |
-
|
| 132 |
-
return config
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
async def require_payg_identity(authorization: Optional[str]) -> Dict[str, Any]:
|
| 136 |
-
if not authorization or not authorization.startswith("Bearer "):
|
| 137 |
-
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
| 138 |
-
|
| 139 |
-
token = authorization.split(" ", 1)[1].strip()
|
| 140 |
-
identity = await resolve_token_identity(token)
|
| 141 |
-
if not identity:
|
| 142 |
-
raise HTTPException(status_code=401, detail="Invalid authorization token")
|
| 143 |
-
|
| 144 |
-
user_id = identity.get("user_id")
|
| 145 |
-
email = identity.get("email")
|
| 146 |
-
if not user_id or not email:
|
| 147 |
-
raise HTTPException(status_code=401, detail="Unable to resolve account identity")
|
| 148 |
-
|
| 149 |
-
return {
|
| 150 |
-
"token": token,
|
| 151 |
-
"user_id": str(user_id),
|
| 152 |
-
"email": str(email).strip().lower(),
|
| 153 |
-
"auth_type": identity.get("auth_type") or "unknown",
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
async def enforce_payg_spam_limit(subject: str, per_minute_limit: int):
|
| 158 |
-
now = time.time()
|
| 159 |
-
cutoff = now - 60
|
| 160 |
-
|
| 161 |
-
async with SPAM_LOCK:
|
| 162 |
-
bucket = SPAM_TRACKER[subject]
|
| 163 |
-
while bucket and bucket[0] < cutoff:
|
| 164 |
-
bucket.popleft()
|
| 165 |
-
|
| 166 |
-
if len(bucket) >= per_minute_limit:
|
| 167 |
-
raise HTTPException(status_code=429, detail="Too many requests. Please slow down.")
|
| 168 |
-
|
| 169 |
-
bucket.append(now)
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
async def ensure_user_wallet(user_id: str):
|
| 173 |
-
await execute_query(
|
| 174 |
-
"""
|
| 175 |
-
insert into public.user_wallets (user_id, balance_credits, lifetime_credits_purchased)
|
| 176 |
-
values (%s, 0, 0)
|
| 177 |
-
on conflict (user_id) do nothing;
|
| 178 |
-
""",
|
| 179 |
-
(user_id,),
|
| 180 |
-
commit=True,
|
| 181 |
-
)
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
async def get_wallet_snapshot(user_id: str) -> Dict[str, Any]:
|
| 185 |
-
await ensure_user_wallet(user_id)
|
| 186 |
-
row = await execute_query(
|
| 187 |
-
"""
|
| 188 |
-
select
|
| 189 |
-
user_id,
|
| 190 |
-
balance_credits,
|
| 191 |
-
lifetime_credits_purchased,
|
| 192 |
-
stripe_customer_id,
|
| 193 |
-
updated_at
|
| 194 |
-
from public.user_wallets
|
| 195 |
-
where user_id = %s
|
| 196 |
-
limit 1;
|
| 197 |
-
""",
|
| 198 |
-
(user_id,),
|
| 199 |
-
fetchone=True,
|
| 200 |
-
)
|
| 201 |
-
|
| 202 |
-
if not row:
|
| 203 |
-
raise HTTPException(status_code=500, detail="Unable to load wallet")
|
| 204 |
-
|
| 205 |
-
return {
|
| 206 |
-
"user_id": str(row.get("user_id")),
|
| 207 |
-
"balance_credits": float(quantize_credits(_to_decimal(row.get("balance_credits"), "0"))),
|
| 208 |
-
"lifetime_credits_purchased": float(
|
| 209 |
-
quantize_credits(_to_decimal(row.get("lifetime_credits_purchased"), "0"))
|
| 210 |
-
),
|
| 211 |
-
"stripe_customer_id": row.get("stripe_customer_id"),
|
| 212 |
-
"updated_at": row.get("updated_at").isoformat() if hasattr(row.get("updated_at"), "isoformat") else None,
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
async def list_credit_ledger(user_id: str, limit: int = 100) -> list[Dict[str, Any]]:
|
| 217 |
-
safe_limit = max(1, min(limit, 500))
|
| 218 |
-
rows = await execute_query(
|
| 219 |
-
"""
|
| 220 |
-
select
|
| 221 |
-
id,
|
| 222 |
-
delta_credits,
|
| 223 |
-
balance_after,
|
| 224 |
-
entry_type,
|
| 225 |
-
usage_kind,
|
| 226 |
-
units,
|
| 227 |
-
unit_label,
|
| 228 |
-
source,
|
| 229 |
-
source_ref,
|
| 230 |
-
metadata,
|
| 231 |
-
created_at
|
| 232 |
-
from public.credit_ledger
|
| 233 |
-
where user_id = %s
|
| 234 |
-
order by created_at desc
|
| 235 |
-
limit %s;
|
| 236 |
-
""",
|
| 237 |
-
(user_id, safe_limit),
|
| 238 |
-
)
|
| 239 |
-
|
| 240 |
-
result = []
|
| 241 |
-
for row in rows or []:
|
| 242 |
-
result.append(
|
| 243 |
-
{
|
| 244 |
-
"id": row.get("id"),
|
| 245 |
-
"delta_credits": float(quantize_credits(_to_decimal(row.get("delta_credits"), "0"))),
|
| 246 |
-
"balance_after": float(quantize_credits(_to_decimal(row.get("balance_after"), "0"))),
|
| 247 |
-
"entry_type": row.get("entry_type"),
|
| 248 |
-
"usage_kind": row.get("usage_kind"),
|
| 249 |
-
"units": float(row.get("units")) if row.get("units") is not None else None,
|
| 250 |
-
"unit_label": row.get("unit_label"),
|
| 251 |
-
"source": row.get("source"),
|
| 252 |
-
"source_ref": row.get("source_ref"),
|
| 253 |
-
"metadata": row.get("metadata") if isinstance(row.get("metadata"), dict) else None,
|
| 254 |
-
"created_at": row.get("created_at").isoformat() if hasattr(row.get("created_at"), "isoformat") else None,
|
| 255 |
-
}
|
| 256 |
-
)
|
| 257 |
-
|
| 258 |
-
return result
|
| 259 |
-
|
| 260 |
-
async def append_credit_ledger_entry(
|
| 261 |
-
*,
|
| 262 |
-
user_id: str,
|
| 263 |
-
delta_credits: Decimal,
|
| 264 |
-
entry_type: str,
|
| 265 |
-
usage_kind: Optional[str] = None,
|
| 266 |
-
units: Optional[float] = None,
|
| 267 |
-
unit_label: Optional[str] = None,
|
| 268 |
-
source: Optional[str] = None,
|
| 269 |
-
source_ref: Optional[str] = None,
|
| 270 |
-
idempotency_key: Optional[str] = None,
|
| 271 |
-
metadata: Optional[Dict[str, Any]] = None,
|
| 272 |
-
) -> Dict[str, Any]:
|
| 273 |
-
await ensure_user_wallet(user_id)
|
| 274 |
-
|
| 275 |
-
delta_credits = quantize_credits(delta_credits)
|
| 276 |
-
metadata_json = json.dumps(metadata or {})
|
| 277 |
-
|
| 278 |
-
row = None
|
| 279 |
-
|
| 280 |
-
try:
|
| 281 |
-
row = await execute_query(
|
| 282 |
-
"""
|
| 283 |
-
with updated_wallet as (
|
| 284 |
-
update public.user_wallets
|
| 285 |
-
set
|
| 286 |
-
balance_credits = balance_credits + %s,
|
| 287 |
-
lifetime_credits_purchased = case
|
| 288 |
-
when %s > 0 then lifetime_credits_purchased + %s
|
| 289 |
-
else lifetime_credits_purchased
|
| 290 |
-
end,
|
| 291 |
-
updated_at = timezone('utc', now())
|
| 292 |
-
where user_id = %s
|
| 293 |
-
and (balance_credits + %s) >= 0
|
| 294 |
-
returning balance_credits
|
| 295 |
-
),
|
| 296 |
-
inserted as (
|
| 297 |
-
insert into public.credit_ledger (
|
| 298 |
-
user_id,
|
| 299 |
-
delta_credits,
|
| 300 |
-
balance_after,
|
| 301 |
-
entry_type,
|
| 302 |
-
usage_kind,
|
| 303 |
-
units,
|
| 304 |
-
unit_label,
|
| 305 |
-
source,
|
| 306 |
-
source_ref,
|
| 307 |
-
idempotency_key,
|
| 308 |
-
metadata
|
| 309 |
-
)
|
| 310 |
-
select
|
| 311 |
-
%s,
|
| 312 |
-
%s,
|
| 313 |
-
updated_wallet.balance_credits,
|
| 314 |
-
%s,
|
| 315 |
-
%s,
|
| 316 |
-
%s,
|
| 317 |
-
%s,
|
| 318 |
-
%s,
|
| 319 |
-
%s,
|
| 320 |
-
%s,
|
| 321 |
-
%s::jsonb
|
| 322 |
-
from updated_wallet
|
| 323 |
-
returning id, balance_after, created_at
|
| 324 |
-
)
|
| 325 |
-
select id, balance_after, created_at
|
| 326 |
-
from inserted
|
| 327 |
-
limit 1;
|
| 328 |
-
""",
|
| 329 |
-
(
|
| 330 |
-
delta_credits,
|
| 331 |
-
delta_credits,
|
| 332 |
-
delta_credits,
|
| 333 |
-
user_id,
|
| 334 |
-
delta_credits,
|
| 335 |
-
user_id,
|
| 336 |
-
delta_credits,
|
| 337 |
-
entry_type,
|
| 338 |
-
usage_kind,
|
| 339 |
-
units,
|
| 340 |
-
unit_label,
|
| 341 |
-
source,
|
| 342 |
-
source_ref,
|
| 343 |
-
idempotency_key,
|
| 344 |
-
metadata_json,
|
| 345 |
-
),
|
| 346 |
-
fetchone=True,
|
| 347 |
-
commit=True,
|
| 348 |
-
)
|
| 349 |
-
|
| 350 |
-
except UniqueViolation:
|
| 351 |
-
row = None
|
| 352 |
-
|
| 353 |
-
if row:
|
| 354 |
-
return {
|
| 355 |
-
"ledger_id": row.get("id"),
|
| 356 |
-
"balance_after": float(
|
| 357 |
-
quantize_credits(
|
| 358 |
-
_to_decimal(row.get("balance_after"), "0")
|
| 359 |
-
)
|
| 360 |
-
),
|
| 361 |
-
"created_at": (
|
| 362 |
-
row.get("created_at").isoformat()
|
| 363 |
-
if hasattr(row.get("created_at"), "isoformat")
|
| 364 |
-
else None
|
| 365 |
-
),
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
-
if idempotency_key:
|
| 369 |
-
existing = await execute_query(
|
| 370 |
-
"""
|
| 371 |
-
select id, balance_after, created_at
|
| 372 |
-
from public.credit_ledger
|
| 373 |
-
where user_id = %s
|
| 374 |
-
and idempotency_key = %s
|
| 375 |
-
limit 1;
|
| 376 |
-
""",
|
| 377 |
-
(user_id, idempotency_key),
|
| 378 |
-
fetchone=True,
|
| 379 |
-
)
|
| 380 |
-
|
| 381 |
-
if existing:
|
| 382 |
-
return {
|
| 383 |
-
"ledger_id": existing.get("id"),
|
| 384 |
-
"balance_after": float(
|
| 385 |
-
quantize_credits(
|
| 386 |
-
_to_decimal(existing.get("balance_after"), "0")
|
| 387 |
-
)
|
| 388 |
-
),
|
| 389 |
-
"created_at": (
|
| 390 |
-
existing.get("created_at").isoformat()
|
| 391 |
-
if hasattr(existing.get("created_at"), "isoformat")
|
| 392 |
-
else None
|
| 393 |
-
),
|
| 394 |
-
"idempotent_replay": True,
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
raise HTTPException(
|
| 398 |
-
status_code=402,
|
| 399 |
-
detail="Insufficient credits",
|
| 400 |
-
)
|
| 401 |
-
|
| 402 |
-
def _estimate_tokens_from_text(text: str) -> int:
|
| 403 |
-
if not isinstance(text, str):
|
| 404 |
-
return 0
|
| 405 |
-
|
| 406 |
-
stripped = text.strip()
|
| 407 |
-
if not stripped:
|
| 408 |
-
return 0
|
| 409 |
-
|
| 410 |
-
# Rough multilingual token estimate without relying on model-specific tokenizers.
|
| 411 |
-
return max(1, int(math.ceil(len(stripped) / 3.5)))
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def _collect_chat_text_and_image_counts(value: Any) -> tuple[int, int]:
|
| 415 |
-
if value is None:
|
| 416 |
-
return 0, 0
|
| 417 |
-
|
| 418 |
-
if isinstance(value, str):
|
| 419 |
-
return len(value), 0
|
| 420 |
-
|
| 421 |
-
if isinstance(value, list):
|
| 422 |
-
chars = 0
|
| 423 |
-
image_count = 0
|
| 424 |
-
for item in value:
|
| 425 |
-
item_chars, item_images = _collect_chat_text_and_image_counts(item)
|
| 426 |
-
chars += item_chars
|
| 427 |
-
image_count += item_images
|
| 428 |
-
return chars, image_count
|
| 429 |
-
|
| 430 |
-
if isinstance(value, dict):
|
| 431 |
-
chars = 0
|
| 432 |
-
image_count = 0
|
| 433 |
-
item_type = str(value.get("type") or "").lower()
|
| 434 |
-
if item_type in {"image", "image_url", "input_image"}:
|
| 435 |
-
image_count += 1
|
| 436 |
-
|
| 437 |
-
text_value = value.get("text")
|
| 438 |
-
if isinstance(text_value, str):
|
| 439 |
-
chars += len(text_value)
|
| 440 |
-
|
| 441 |
-
if isinstance(value.get("content"), (str, list, dict)):
|
| 442 |
-
nested_chars, nested_images = _collect_chat_text_and_image_counts(value.get("content"))
|
| 443 |
-
chars += nested_chars
|
| 444 |
-
image_count += nested_images
|
| 445 |
-
|
| 446 |
-
if isinstance(value.get("arguments"), str):
|
| 447 |
-
chars += len(value["arguments"])
|
| 448 |
-
|
| 449 |
-
if isinstance(value.get("function_call"), (str, list, dict)):
|
| 450 |
-
nested_chars, nested_images = _collect_chat_text_and_image_counts(value.get("function_call"))
|
| 451 |
-
chars += nested_chars
|
| 452 |
-
image_count += nested_images
|
| 453 |
-
|
| 454 |
-
if isinstance(value.get("function"), (str, list, dict)):
|
| 455 |
-
nested_chars, nested_images = _collect_chat_text_and_image_counts(value.get("function"))
|
| 456 |
-
chars += nested_chars
|
| 457 |
-
image_count += nested_images
|
| 458 |
-
|
| 459 |
-
if isinstance(value.get("tool_calls"), list):
|
| 460 |
-
nested_chars, nested_images = _collect_chat_text_and_image_counts(value.get("tool_calls"))
|
| 461 |
-
chars += nested_chars
|
| 462 |
-
image_count += nested_images
|
| 463 |
-
|
| 464 |
-
if isinstance(value.get("image_url"), dict):
|
| 465 |
-
image_count += 1
|
| 466 |
-
|
| 467 |
-
for key, nested in value.items():
|
| 468 |
-
if key in {
|
| 469 |
-
"content",
|
| 470 |
-
"text",
|
| 471 |
-
"arguments",
|
| 472 |
-
"function",
|
| 473 |
-
"function_call",
|
| 474 |
-
"tool_calls",
|
| 475 |
-
"image_url",
|
| 476 |
-
"role",
|
| 477 |
-
"type",
|
| 478 |
-
"index",
|
| 479 |
-
"id",
|
| 480 |
-
}:
|
| 481 |
-
continue
|
| 482 |
-
|
| 483 |
-
if isinstance(nested, (list, dict)):
|
| 484 |
-
nested_chars, nested_images = _collect_chat_text_and_image_counts(nested)
|
| 485 |
-
chars += nested_chars
|
| 486 |
-
image_count += nested_images
|
| 487 |
-
elif isinstance(nested, str) and key.lower() in {
|
| 488 |
-
"prompt",
|
| 489 |
-
"input",
|
| 490 |
-
"output",
|
| 491 |
-
"message",
|
| 492 |
-
"description",
|
| 493 |
-
"caption",
|
| 494 |
-
"query",
|
| 495 |
-
"summary",
|
| 496 |
-
}:
|
| 497 |
-
chars += len(nested)
|
| 498 |
-
|
| 499 |
-
return chars, image_count
|
| 500 |
-
|
| 501 |
-
return 0, 0
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
def estimate_text_tokens_from_messages(messages: Any) -> int:
|
| 505 |
-
if not isinstance(messages, list):
|
| 506 |
-
return 0
|
| 507 |
-
|
| 508 |
-
chars = 0
|
| 509 |
-
image_count = 0
|
| 510 |
-
|
| 511 |
-
for message in messages:
|
| 512 |
-
message_chars, message_images = _collect_chat_text_and_image_counts(message)
|
| 513 |
-
chars += message_chars
|
| 514 |
-
image_count += message_images
|
| 515 |
-
|
| 516 |
-
estimated = int(math.ceil(chars / 3.5))
|
| 517 |
-
estimated += image_count * 300
|
| 518 |
-
return max(0, estimated)
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
def estimate_text_tokens_from_text(text: Any) -> int:
|
| 522 |
-
if not isinstance(text, str):
|
| 523 |
-
return 0
|
| 524 |
-
return _estimate_tokens_from_text(text)
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
def extract_usage_tokens(payload: Dict[str, Any], fallback_tokens: int) -> int:
|
| 528 |
-
usage = payload.get("usage") if isinstance(payload, dict) else None
|
| 529 |
-
if isinstance(usage, dict):
|
| 530 |
-
total = usage.get("total_tokens")
|
| 531 |
-
try:
|
| 532 |
-
if total is not None:
|
| 533 |
-
total_int = int(total)
|
| 534 |
-
if total_int >= 0:
|
| 535 |
-
return total_int
|
| 536 |
-
except Exception:
|
| 537 |
-
pass
|
| 538 |
-
|
| 539 |
-
prompt_tokens = usage.get("prompt_tokens")
|
| 540 |
-
completion_tokens = usage.get("completion_tokens")
|
| 541 |
-
try:
|
| 542 |
-
if prompt_tokens is not None and completion_tokens is not None:
|
| 543 |
-
prompt_int = int(prompt_tokens)
|
| 544 |
-
completion_int = int(completion_tokens)
|
| 545 |
-
if prompt_int >= 0 and completion_int >= 0:
|
| 546 |
-
return max(0, prompt_int + completion_int)
|
| 547 |
-
except Exception:
|
| 548 |
-
pass
|
| 549 |
-
return max(0, fallback_tokens)
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
def extract_usage_breakdown(
|
| 553 |
-
payload: Dict[str, Any],
|
| 554 |
-
fallback_input_tokens: int,
|
| 555 |
-
fallback_output_tokens: int = 0,
|
| 556 |
-
) -> Dict[str, int]:
|
| 557 |
-
usage = payload.get("usage") if isinstance(payload, dict) else None
|
| 558 |
-
input_tokens = max(0, int(fallback_input_tokens))
|
| 559 |
-
output_tokens = max(0, int(fallback_output_tokens))
|
| 560 |
-
total_tokens = input_tokens + output_tokens
|
| 561 |
-
|
| 562 |
-
if isinstance(usage, dict):
|
| 563 |
-
try:
|
| 564 |
-
prompt_tokens = usage.get("prompt_tokens")
|
| 565 |
-
if prompt_tokens is not None:
|
| 566 |
-
prompt_int = int(prompt_tokens)
|
| 567 |
-
if prompt_int >= 0:
|
| 568 |
-
input_tokens = prompt_int
|
| 569 |
-
except Exception:
|
| 570 |
-
pass
|
| 571 |
-
|
| 572 |
-
try:
|
| 573 |
-
completion_tokens = usage.get("completion_tokens")
|
| 574 |
-
if completion_tokens is not None:
|
| 575 |
-
completion_int = int(completion_tokens)
|
| 576 |
-
if completion_int >= 0:
|
| 577 |
-
output_tokens = completion_int
|
| 578 |
-
except Exception:
|
| 579 |
-
pass
|
| 580 |
-
|
| 581 |
-
try:
|
| 582 |
-
total = usage.get("total_tokens")
|
| 583 |
-
if total is not None:
|
| 584 |
-
total_int = int(total)
|
| 585 |
-
if total_int >= 0:
|
| 586 |
-
total_tokens = total_int
|
| 587 |
-
else:
|
| 588 |
-
total_tokens = input_tokens + output_tokens
|
| 589 |
-
else:
|
| 590 |
-
total_tokens = input_tokens + output_tokens
|
| 591 |
-
except Exception:
|
| 592 |
-
total_tokens = input_tokens + output_tokens
|
| 593 |
-
|
| 594 |
-
if output_tokens <= 0 and total_tokens >= input_tokens:
|
| 595 |
-
output_tokens = max(0, total_tokens - input_tokens)
|
| 596 |
-
|
| 597 |
-
if total_tokens < input_tokens + output_tokens:
|
| 598 |
-
total_tokens = input_tokens + output_tokens
|
| 599 |
-
|
| 600 |
-
return {
|
| 601 |
-
"input_tokens": max(0, input_tokens),
|
| 602 |
-
"output_tokens": max(0, output_tokens),
|
| 603 |
-
"total_tokens": max(0, total_tokens),
|
| 604 |
-
}
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
def calculate_text_credit_cost(total_tokens: int, config: Dict[str, Any]) -> Decimal:
|
| 608 |
-
pricing = config.get("pricing", {})
|
| 609 |
-
per_million = _to_decimal(pricing.get("textCreditPerMillionTokens"), "0.75")
|
| 610 |
-
multiplier = _to_decimal(pricing.get("textTokenEstimateMultiplier"), "1")
|
| 611 |
-
tokens = _to_decimal(total_tokens, "0")
|
| 612 |
-
cost = (tokens / Decimal("1000000")) * per_million * multiplier
|
| 613 |
-
return quantize_credits(cost)
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
def calculate_image_credit_cost(image_count: int, config: Dict[str, Any]) -> Decimal:
|
| 617 |
-
per_image = _to_decimal(config.get("pricing", {}).get("imageCreditPerImage"), "0.02")
|
| 618 |
-
cost = _to_decimal(max(1, image_count)) * per_image
|
| 619 |
-
return quantize_credits(cost)
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
def calculate_video_credit_cost(seconds: int, config: Dict[str, Any]) -> Decimal:
|
| 623 |
-
per_second = _to_decimal(config.get("pricing", {}).get("videoCreditPerSecond"), "0.01")
|
| 624 |
-
cost = _to_decimal(max(1, seconds)) * per_second
|
| 625 |
-
return quantize_credits(cost)
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
def calculate_audio_credit_cost(seconds: int, config: Dict[str, Any]) -> Decimal:
|
| 629 |
-
per_second = _to_decimal(config.get("pricing", {}).get("audioCreditPerSecond"), "0.01")
|
| 630 |
-
cost = _to_decimal(max(1, seconds)) * per_second
|
| 631 |
-
return quantize_credits(cost)
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
def get_pack_by_session_amount(config: Dict[str, Any], amount_total_cents: int) -> Optional[Dict[str, Any]]:
|
| 635 |
-
if amount_total_cents <= 0:
|
| 636 |
-
return None
|
| 637 |
-
amount_usd = Decimal(amount_total_cents) / Decimal("100")
|
| 638 |
-
|
| 639 |
-
for pack in config.get("billing", {}).get("packs", []):
|
| 640 |
-
candidate = _to_decimal(pack.get("amountUsd"), "0")
|
| 641 |
-
if candidate == amount_usd:
|
| 642 |
-
return pack
|
| 643 |
-
|
| 644 |
-
return None
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
def build_usage_summary_from_ledger_rows(rows: list[Dict[str, Any]]) -> Dict[str, Any]:
|
| 648 |
-
summary = {
|
| 649 |
-
"text": {"credits": 0.0, "units": 0.0, "unit_label": "tokens"},
|
| 650 |
-
"image": {"credits": 0.0, "units": 0.0, "unit_label": "images"},
|
| 651 |
-
"video": {"credits": 0.0, "units": 0.0, "unit_label": "seconds"},
|
| 652 |
-
"audio": {"credits": 0.0, "units": 0.0, "unit_label": "seconds"},
|
| 653 |
-
"totalCreditsUsed": 0.0,
|
| 654 |
-
}
|
| 655 |
-
|
| 656 |
-
for row in rows:
|
| 657 |
-
if row.get("entry_type") != "usage":
|
| 658 |
-
continue
|
| 659 |
-
|
| 660 |
-
kind = str(row.get("usage_kind") or "other").lower()
|
| 661 |
-
credits = abs(float(row.get("delta_credits") or 0.0))
|
| 662 |
-
units = float(row.get("units") or 0.0)
|
| 663 |
-
|
| 664 |
-
if kind in summary:
|
| 665 |
-
summary[kind]["credits"] += credits
|
| 666 |
-
summary[kind]["units"] += units
|
| 667 |
-
|
| 668 |
-
summary["totalCreditsUsed"] += credits
|
| 669 |
-
|
| 670 |
-
return summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
helper/ratelimit.py
DELETED
|
@@ -1,522 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import hashlib
|
| 3 |
-
import os
|
| 4 |
-
import re
|
| 5 |
-
import time
|
| 6 |
-
from typing import Any, Dict, List, Optional
|
| 7 |
-
|
| 8 |
-
from fastapi import HTTPException, Request
|
| 9 |
-
|
| 10 |
-
from helper.subscriptions import (
|
| 11 |
-
TIER_CONFIG,
|
| 12 |
-
USAGE_PERIODS,
|
| 13 |
-
fetch_subscription,
|
| 14 |
-
get_effective_limit_for_email,
|
| 15 |
-
normalize_plan_key,
|
| 16 |
-
usage_locks,
|
| 17 |
-
usage_store,
|
| 18 |
-
)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# -------------------------------------------------------------------
|
| 22 |
-
# Configuration / Constants
|
| 23 |
-
# -------------------------------------------------------------------
|
| 24 |
-
|
| 25 |
-
IDENTITY_CACHE_TTL_SECONDS = 60
|
| 26 |
-
|
| 27 |
-
CLIENT_BIND_TTL_SECONDS = int(
|
| 28 |
-
os.getenv("CLIENT_BIND_TTL_SECONDS", str(8 * 24 * 60 * 60))
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
MAX_CLIENT_ID_LENGTH = 128
|
| 32 |
-
|
| 33 |
-
MAX_CHAT_PROMPT_CHARS = int(os.getenv("MAX_CHAT_PROMPT_CHARS", "120000"))
|
| 34 |
-
MAX_CHAT_PROMPT_BYTES = int(os.getenv("MAX_CHAT_PROMPT_BYTES", "500000"))
|
| 35 |
-
|
| 36 |
-
MAX_GROQ_PROMPT_CHARS = int(os.getenv("MAX_GROQ_PROMPT_CHARS", "90000"))
|
| 37 |
-
MAX_GROQ_PROMPT_BYTES = int(os.getenv("MAX_GROQ_PROMPT_BYTES", "350000"))
|
| 38 |
-
|
| 39 |
-
MAX_MEDIA_PROMPT_CHARS = int(os.getenv("MAX_MEDIA_PROMPT_CHARS", "4000"))
|
| 40 |
-
MAX_MEDIA_PROMPT_BYTES = int(os.getenv("MAX_MEDIA_PROMPT_BYTES", "16000"))
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
# -------------------------------------------------------------------
|
| 44 |
-
# In-memory state
|
| 45 |
-
# -------------------------------------------------------------------
|
| 46 |
-
|
| 47 |
-
identity_cache: Dict[str, Dict[str, Any]] = {}
|
| 48 |
-
|
| 49 |
-
client_subject_bindings: Dict[str, Dict[str, Any]] = {}
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
# -------------------------------------------------------------------
|
| 53 |
-
# Public Rate Limit APIs
|
| 54 |
-
# -------------------------------------------------------------------
|
| 55 |
-
|
| 56 |
-
async def resolve_rate_limit_identity(
|
| 57 |
-
request: Request,
|
| 58 |
-
authorization: Optional[str],
|
| 59 |
-
client_id: Optional[str] = None,
|
| 60 |
-
) -> tuple[str, str]:
|
| 61 |
-
now = time.time()
|
| 62 |
-
|
| 63 |
-
normalized_client_id = sanitize_client_id(client_id)
|
| 64 |
-
default_subject = build_default_subject(request, normalized_client_id)
|
| 65 |
-
|
| 66 |
-
if not authorization or not authorization.startswith("Bearer "):
|
| 67 |
-
return "free", resolve_bound_subject(normalized_client_id, default_subject)
|
| 68 |
-
|
| 69 |
-
token = authorization.split(" ", 1)[1].strip()
|
| 70 |
-
if not token:
|
| 71 |
-
return "free", resolve_bound_subject(normalized_client_id, default_subject)
|
| 72 |
-
|
| 73 |
-
cached = identity_cache.get(token)
|
| 74 |
-
if cached and cached.get("expires_at", 0) > now:
|
| 75 |
-
plan_key = cached.get("plan_key", "free")
|
| 76 |
-
subject = cached.get("subject", default_subject)
|
| 77 |
-
|
| 78 |
-
bind_client_subject(normalized_client_id, subject, plan_key)
|
| 79 |
-
return plan_key, subject
|
| 80 |
-
|
| 81 |
-
try:
|
| 82 |
-
sub = await fetch_subscription(token)
|
| 83 |
-
except HTTPException as authfailure:
|
| 84 |
-
raise authfailure
|
| 85 |
-
except Exception:
|
| 86 |
-
return "free", resolve_bound_subject(normalized_client_id, default_subject)
|
| 87 |
-
|
| 88 |
-
if not isinstance(sub, dict) or sub.get("error"):
|
| 89 |
-
return "free", resolve_bound_subject(normalized_client_id, default_subject)
|
| 90 |
-
|
| 91 |
-
email = sub.get("email")
|
| 92 |
-
if isinstance(email, str) and email.strip():
|
| 93 |
-
subject = f"user:{email.strip().lower()}"
|
| 94 |
-
else:
|
| 95 |
-
subject = default_subject
|
| 96 |
-
|
| 97 |
-
plan_key = normalize_plan_key(sub.get("plan_key"))
|
| 98 |
-
|
| 99 |
-
identity_cache[token] = {
|
| 100 |
-
"plan_key": plan_key,
|
| 101 |
-
"subject": subject,
|
| 102 |
-
"expires_at": now + IDENTITY_CACHE_TTL_SECONDS,
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
bind_client_subject(normalized_client_id, subject, plan_key)
|
| 106 |
-
|
| 107 |
-
return plan_key, subject
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
async def enforce_rate_limit(
|
| 111 |
-
request: Request,
|
| 112 |
-
authorization: Optional[str],
|
| 113 |
-
metric: str,
|
| 114 |
-
client_id: Optional[str] = None,
|
| 115 |
-
) -> Dict[str, Optional[int | str]]:
|
| 116 |
-
if metric not in usage_store:
|
| 117 |
-
raise HTTPException(status_code=500, detail=f"Unknown limit metric: {metric}")
|
| 118 |
-
|
| 119 |
-
plan_key, subject = await resolve_rate_limit_identity(
|
| 120 |
-
request, authorization, client_id
|
| 121 |
-
)
|
| 122 |
-
return await consume_metric_for_subject(metric, plan_key, subject)
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
async def resolve_verify_token_rate_limit_identity(
|
| 126 |
-
authorization: Optional[str],
|
| 127 |
-
ip: Optional[str],
|
| 128 |
-
) -> tuple[str, str]:
|
| 129 |
-
if authorization and authorization.startswith("Bearer "):
|
| 130 |
-
token = authorization.split(" ", 1)[1].strip()
|
| 131 |
-
if token:
|
| 132 |
-
try:
|
| 133 |
-
sub = await fetch_subscription(token)
|
| 134 |
-
except HTTPException:
|
| 135 |
-
sub = None
|
| 136 |
-
except Exception:
|
| 137 |
-
sub = None
|
| 138 |
-
|
| 139 |
-
if isinstance(sub, dict) and not sub.get("error"):
|
| 140 |
-
email = sub.get("email")
|
| 141 |
-
if isinstance(email, str) and email.strip():
|
| 142 |
-
return (
|
| 143 |
-
normalize_plan_key(sub.get("plan_key")),
|
| 144 |
-
f"user:{email.strip().lower()}",
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
return "free", build_verify_token_ip_subject(ip)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
async def consume_metric_for_subject(
|
| 151 |
-
metric: str,
|
| 152 |
-
plan_key: str,
|
| 153 |
-
subject: str,
|
| 154 |
-
) -> Dict[str, Optional[int | str]]:
|
| 155 |
-
if metric not in usage_store:
|
| 156 |
-
raise HTTPException(status_code=500, detail=f"Unknown limit metric: {metric}")
|
| 157 |
-
|
| 158 |
-
plan = TIER_CONFIG.get(plan_key) or TIER_CONFIG["free"]
|
| 159 |
-
subject_email = subject[5:] if subject.startswith("user:") else None
|
| 160 |
-
limit = get_effective_limit_for_email(plan_key, metric, subject_email)
|
| 161 |
-
window_key = get_usage_period_key(metric)
|
| 162 |
-
lock = get_usage_lock(metric, subject)
|
| 163 |
-
|
| 164 |
-
async with lock:
|
| 165 |
-
bucket = usage_store[metric]
|
| 166 |
-
entry = bucket.get(subject)
|
| 167 |
-
|
| 168 |
-
if not entry or entry.get("window") != window_key:
|
| 169 |
-
entry = {"window": window_key, "count": 0}
|
| 170 |
-
bucket[subject] = entry
|
| 171 |
-
|
| 172 |
-
if limit is not None and entry["count"] >= int(limit):
|
| 173 |
-
raise HTTPException(
|
| 174 |
-
status_code=429,
|
| 175 |
-
detail=f"{metric} limit reached for {plan.get('name', 'current plan')}",
|
| 176 |
-
)
|
| 177 |
-
|
| 178 |
-
entry["count"] += 1
|
| 179 |
-
remaining = None if limit is None else max(0, int(limit) - entry["count"])
|
| 180 |
-
|
| 181 |
-
return {
|
| 182 |
-
"plan_key": plan_key,
|
| 183 |
-
"limit": limit,
|
| 184 |
-
"remaining": remaining,
|
| 185 |
-
"used": entry["count"],
|
| 186 |
-
"window": window_key,
|
| 187 |
-
"period": USAGE_PERIODS.get(metric, "daily"),
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
async def check_audio_rate_limit(
|
| 192 |
-
request: Request,
|
| 193 |
-
authorization: Optional[str],
|
| 194 |
-
client_id: Optional[str] = None,
|
| 195 |
-
):
|
| 196 |
-
await enforce_rate_limit(request, authorization, "audioWeekly", client_id)
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
async def check_image_rate_limit(
|
| 200 |
-
request: Request,
|
| 201 |
-
authorization: Optional[str],
|
| 202 |
-
client_id: Optional[str] = None,
|
| 203 |
-
):
|
| 204 |
-
await enforce_rate_limit(request, authorization, "imagesDaily", client_id)
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
async def check_video_rate_limit(
|
| 208 |
-
request: Request,
|
| 209 |
-
authorization: Optional[str],
|
| 210 |
-
client_id: Optional[str] = None,
|
| 211 |
-
):
|
| 212 |
-
await enforce_rate_limit(request, authorization, "videosDaily", client_id)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
# -------------------------------------------------------------------
|
| 216 |
-
# Prompt Utilities
|
| 217 |
-
# -------------------------------------------------------------------
|
| 218 |
-
|
| 219 |
-
def normalize_prompt_value(prompt: Optional[str], field_name: str = "prompt") -> str:
|
| 220 |
-
if not isinstance(prompt, str):
|
| 221 |
-
raise HTTPException(status_code=400, detail=f"{field_name} is required")
|
| 222 |
-
|
| 223 |
-
normalized = prompt.strip()
|
| 224 |
-
|
| 225 |
-
if not normalized:
|
| 226 |
-
raise HTTPException(status_code=400, detail=f"{field_name} is required")
|
| 227 |
-
|
| 228 |
-
return normalized
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
def enforce_prompt_size(prompt: str, max_chars: int, max_bytes: int, context: str):
|
| 232 |
-
char_len = len(prompt)
|
| 233 |
-
byte_len = len(prompt.encode("utf-8"))
|
| 234 |
-
|
| 235 |
-
if char_len > max_chars or byte_len > max_bytes:
|
| 236 |
-
raise HTTPException(
|
| 237 |
-
status_code=413,
|
| 238 |
-
detail=(
|
| 239 |
-
f"{context} is too large ({char_len} chars, {byte_len} bytes). "
|
| 240 |
-
f"Max allowed is {max_chars} chars or {max_bytes} bytes."
|
| 241 |
-
),
|
| 242 |
-
)
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def calculate_messages_size(messages: list) -> tuple[int, int]:
|
| 246 |
-
total_chars = 0
|
| 247 |
-
total_bytes = 0
|
| 248 |
-
|
| 249 |
-
for message in messages:
|
| 250 |
-
if not isinstance(message, dict):
|
| 251 |
-
continue
|
| 252 |
-
|
| 253 |
-
text = message_content_to_text(message.get("content"))
|
| 254 |
-
|
| 255 |
-
if not text:
|
| 256 |
-
continue
|
| 257 |
-
|
| 258 |
-
total_chars += len(text)
|
| 259 |
-
total_bytes += len(text.encode("utf-8"))
|
| 260 |
-
|
| 261 |
-
return total_chars, total_bytes
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
def extract_user_text(messages: list) -> str:
|
| 265 |
-
return " ".join(
|
| 266 |
-
message_content_to_text(m.get("content"))
|
| 267 |
-
for m in messages
|
| 268 |
-
if isinstance(m, dict) and m.get("role") == "user"
|
| 269 |
-
).lower()
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
# -------------------------------------------------------------------
|
| 273 |
-
# Usage / Rate Limit Internals
|
| 274 |
-
# -------------------------------------------------------------------
|
| 275 |
-
|
| 276 |
-
def get_usage_period_key(metric: str) -> str:
|
| 277 |
-
now = time.gmtime()
|
| 278 |
-
|
| 279 |
-
period = USAGE_PERIODS.get(metric, "daily")
|
| 280 |
-
|
| 281 |
-
if period == "weekly":
|
| 282 |
-
iso_year, iso_week, _ = time.strftime("%G %V %u", now).split(" ")
|
| 283 |
-
return f"{iso_year}-W{iso_week}"
|
| 284 |
-
|
| 285 |
-
return time.strftime("%Y-%m-%d", now)
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
def get_usage_lock(metric: str, subject: str) -> asyncio.Lock:
|
| 289 |
-
metric_locks = usage_locks.get(metric)
|
| 290 |
-
|
| 291 |
-
if metric_locks is None:
|
| 292 |
-
metric_locks = {}
|
| 293 |
-
usage_locks[metric] = metric_locks
|
| 294 |
-
|
| 295 |
-
lock = metric_locks.get(subject)
|
| 296 |
-
|
| 297 |
-
if lock is None:
|
| 298 |
-
lock = asyncio.Lock()
|
| 299 |
-
metric_locks[subject] = lock
|
| 300 |
-
|
| 301 |
-
return lock
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def get_usage_snapshot_for_subject(
|
| 305 |
-
plan_key: str, subject: str
|
| 306 |
-
) -> Dict[str, Dict[str, Any]]:
|
| 307 |
-
snapshot: Dict[str, Dict[str, Any]] = {}
|
| 308 |
-
|
| 309 |
-
for metric in usage_store.keys():
|
| 310 |
-
snapshot[metric] = get_metric_snapshot_for_subject(plan_key, subject, metric)
|
| 311 |
-
|
| 312 |
-
return snapshot
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
def get_metric_snapshot_for_subject(
|
| 316 |
-
plan_key: str,
|
| 317 |
-
subject: str,
|
| 318 |
-
metric: str,
|
| 319 |
-
) -> Dict[str, Any]:
|
| 320 |
-
subject_email = subject[5:] if subject.startswith("user:") else None
|
| 321 |
-
limit = get_effective_limit_for_email(plan_key, metric, subject_email)
|
| 322 |
-
window_key = get_usage_period_key(metric)
|
| 323 |
-
entry = usage_store[metric].get(subject)
|
| 324 |
-
used = 0
|
| 325 |
-
|
| 326 |
-
if entry and entry.get("window") == window_key:
|
| 327 |
-
used = max(0, int(entry.get("count", 0)))
|
| 328 |
-
|
| 329 |
-
remaining = None if limit is None else max(0, int(limit) - used)
|
| 330 |
-
|
| 331 |
-
return {
|
| 332 |
-
"limit": limit,
|
| 333 |
-
"used": used,
|
| 334 |
-
"remaining": remaining,
|
| 335 |
-
"window": window_key,
|
| 336 |
-
"period": USAGE_PERIODS.get(metric, "daily"),
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
# -------------------------------------------------------------------
|
| 341 |
-
# Identity / Client helpers
|
| 342 |
-
# -------------------------------------------------------------------
|
| 343 |
-
|
| 344 |
-
def sanitize_client_id(raw_client_id: Optional[str]) -> Optional[str]:
|
| 345 |
-
if not isinstance(raw_client_id, str):
|
| 346 |
-
return None
|
| 347 |
-
|
| 348 |
-
trimmed = raw_client_id.strip()
|
| 349 |
-
|
| 350 |
-
if not trimmed or len(trimmed) > MAX_CLIENT_ID_LENGTH:
|
| 351 |
-
return None
|
| 352 |
-
|
| 353 |
-
if not re.match(r"^[A-Za-z0-9._:-]+$", trimmed):
|
| 354 |
-
return None
|
| 355 |
-
|
| 356 |
-
return trimmed
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
def build_default_subject(request: Request, client_id: Optional[str]) -> str:
|
| 360 |
-
if client_id:
|
| 361 |
-
client_hash = hashlib.sha256(client_id.encode("utf-8")).hexdigest()[:24]
|
| 362 |
-
return f"client:{client_hash}"
|
| 363 |
-
|
| 364 |
-
host = request.client.host if request.client else "unknown"
|
| 365 |
-
|
| 366 |
-
user_agent = request.headers.get("user-agent", "")
|
| 367 |
-
|
| 368 |
-
ua_hash = (
|
| 369 |
-
hashlib.sha256(user_agent.encode("utf-8")).hexdigest()[:12]
|
| 370 |
-
if user_agent
|
| 371 |
-
else "noua"
|
| 372 |
-
)
|
| 373 |
-
|
| 374 |
-
return f"anon:{host}:{ua_hash}"
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
def normalize_forwarded_ip(raw_ip: Optional[str]) -> Optional[str]:
|
| 378 |
-
if not isinstance(raw_ip, str):
|
| 379 |
-
return None
|
| 380 |
-
|
| 381 |
-
candidate = raw_ip.split(",", 1)[0].strip()
|
| 382 |
-
|
| 383 |
-
if not candidate:
|
| 384 |
-
return None
|
| 385 |
-
|
| 386 |
-
if candidate.startswith("[") and "]" in candidate:
|
| 387 |
-
candidate = candidate[1 : candidate.index("]")]
|
| 388 |
-
elif candidate.count(":") == 1 and "." in candidate:
|
| 389 |
-
candidate = candidate.rsplit(":", 1)[0]
|
| 390 |
-
|
| 391 |
-
return candidate or None
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
def build_verify_token_ip_subject(ip: Optional[str]) -> str:
|
| 395 |
-
normalized_ip = normalize_forwarded_ip(ip)
|
| 396 |
-
if not normalized_ip:
|
| 397 |
-
return "verify-ip:unknown"
|
| 398 |
-
|
| 399 |
-
ip_hash = hashlib.sha256(normalized_ip.encode("utf-8")).hexdigest()[:24]
|
| 400 |
-
return f"verify-ip:{ip_hash}"
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
def bind_client_subject(client_id: Optional[str], subject: str, plan_key: str):
|
| 404 |
-
if not client_id:
|
| 405 |
-
return
|
| 406 |
-
|
| 407 |
-
client_subject_bindings[client_id] = {
|
| 408 |
-
"subject": subject,
|
| 409 |
-
"plan_key": plan_key,
|
| 410 |
-
"expires_at": time.time() + CLIENT_BIND_TTL_SECONDS,
|
| 411 |
-
}
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def resolve_bound_subject(client_id: Optional[str], fallback_subject: str) -> str:
|
| 415 |
-
if not client_id:
|
| 416 |
-
return fallback_subject
|
| 417 |
-
|
| 418 |
-
bound = client_subject_bindings.get(client_id)
|
| 419 |
-
|
| 420 |
-
if not bound:
|
| 421 |
-
return fallback_subject
|
| 422 |
-
|
| 423 |
-
if bound.get("expires_at", 0) <= time.time():
|
| 424 |
-
client_subject_bindings.pop(client_id, None)
|
| 425 |
-
return fallback_subject
|
| 426 |
-
|
| 427 |
-
return bound.get("subject", fallback_subject)
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
# -------------------------------------------------------------------
|
| 431 |
-
# Message parsing helpers
|
| 432 |
-
# -------------------------------------------------------------------
|
| 433 |
-
|
| 434 |
-
def message_content_to_text(content: Any) -> str:
|
| 435 |
-
if isinstance(content, str):
|
| 436 |
-
return content
|
| 437 |
-
|
| 438 |
-
if isinstance(content, list):
|
| 439 |
-
parts: List[str] = []
|
| 440 |
-
|
| 441 |
-
for item in content:
|
| 442 |
-
if isinstance(item, str):
|
| 443 |
-
parts.append(item)
|
| 444 |
-
continue
|
| 445 |
-
|
| 446 |
-
if isinstance(item, dict):
|
| 447 |
-
text = item.get("text")
|
| 448 |
-
if isinstance(text, str):
|
| 449 |
-
parts.append(text)
|
| 450 |
-
|
| 451 |
-
nested_content = item.get("content")
|
| 452 |
-
if nested_content is not None:
|
| 453 |
-
nested_text = message_content_to_text(nested_content)
|
| 454 |
-
if nested_text:
|
| 455 |
-
parts.append(nested_text)
|
| 456 |
-
|
| 457 |
-
arguments = item.get("arguments")
|
| 458 |
-
if isinstance(arguments, str):
|
| 459 |
-
parts.append(arguments)
|
| 460 |
-
|
| 461 |
-
for key in (
|
| 462 |
-
"function_call",
|
| 463 |
-
"function",
|
| 464 |
-
"tool_calls",
|
| 465 |
-
"prompt",
|
| 466 |
-
"input",
|
| 467 |
-
"output",
|
| 468 |
-
"message",
|
| 469 |
-
"description",
|
| 470 |
-
"caption",
|
| 471 |
-
"query",
|
| 472 |
-
"summary",
|
| 473 |
-
):
|
| 474 |
-
nested_value = item.get(key)
|
| 475 |
-
if nested_value is None:
|
| 476 |
-
continue
|
| 477 |
-
nested_text = message_content_to_text(nested_value)
|
| 478 |
-
if nested_text:
|
| 479 |
-
parts.append(nested_text)
|
| 480 |
-
|
| 481 |
-
return " ".join(parts)
|
| 482 |
-
|
| 483 |
-
if isinstance(content, dict):
|
| 484 |
-
parts: List[str] = []
|
| 485 |
-
|
| 486 |
-
text = content.get("text")
|
| 487 |
-
if isinstance(text, str):
|
| 488 |
-
parts.append(text)
|
| 489 |
-
|
| 490 |
-
nested_content = content.get("content")
|
| 491 |
-
if nested_content is not None:
|
| 492 |
-
nested_text = message_content_to_text(nested_content)
|
| 493 |
-
if nested_text:
|
| 494 |
-
parts.append(nested_text)
|
| 495 |
-
|
| 496 |
-
arguments = content.get("arguments")
|
| 497 |
-
if isinstance(arguments, str):
|
| 498 |
-
parts.append(arguments)
|
| 499 |
-
|
| 500 |
-
for key in (
|
| 501 |
-
"function_call",
|
| 502 |
-
"function",
|
| 503 |
-
"tool_calls",
|
| 504 |
-
"prompt",
|
| 505 |
-
"input",
|
| 506 |
-
"output",
|
| 507 |
-
"message",
|
| 508 |
-
"description",
|
| 509 |
-
"caption",
|
| 510 |
-
"query",
|
| 511 |
-
"summary",
|
| 512 |
-
):
|
| 513 |
-
nested_value = content.get(key)
|
| 514 |
-
if nested_value is None:
|
| 515 |
-
continue
|
| 516 |
-
nested_text = message_content_to_text(nested_value)
|
| 517 |
-
if nested_text:
|
| 518 |
-
parts.append(nested_text)
|
| 519 |
-
|
| 520 |
-
return " ".join(parts)
|
| 521 |
-
|
| 522 |
-
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
helper/subscriptions.py
DELETED
|
@@ -1,436 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import hashlib
|
| 3 |
-
import json
|
| 4 |
-
import os
|
| 5 |
-
from datetime import datetime, timezone
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
import base64
|
| 8 |
-
import psycopg
|
| 9 |
-
from psycopg.rows import dict_row
|
| 10 |
-
from supabase import Client, create_client
|
| 11 |
-
import hmac
|
| 12 |
-
ADMINS_PATH = Path("assets/admins.json")
|
| 13 |
-
EXTRA_USAGE_PATH = Path("assets/extra_usage.json")
|
| 14 |
-
|
| 15 |
-
try:
|
| 16 |
-
ADMIN_EMAIL_MAP = json.loads(ADMINS_PATH.read_text())
|
| 17 |
-
except Exception as e:
|
| 18 |
-
print(f"Failed to load admins.json: {e}")
|
| 19 |
-
ADMIN_EMAIL_MAP = {}
|
| 20 |
-
|
| 21 |
-
try:
|
| 22 |
-
EXTRA_USAGE_EMAIL_MAP = json.loads(EXTRA_USAGE_PATH.read_text())
|
| 23 |
-
except Exception as e:
|
| 24 |
-
print(f"Failed to load extra_usage.json: {e}")
|
| 25 |
-
EXTRA_USAGE_EMAIL_MAP = {}
|
| 26 |
-
|
| 27 |
-
PLAN_ORDER = ["free", "light", "core", "creator", "professional"]
|
| 28 |
-
TIER_CONFIG = {
|
| 29 |
-
"free": {
|
| 30 |
-
"name": "Free Tier",
|
| 31 |
-
"url": "",
|
| 32 |
-
"price": "0.00",
|
| 33 |
-
"limits": {
|
| 34 |
-
"cloudChatDaily": 50,
|
| 35 |
-
"imagesDaily": 10,
|
| 36 |
-
"videosDaily": 3,
|
| 37 |
-
"audioWeekly": 1,
|
| 38 |
-
"verifyTokenWithEmailDaily": 100,
|
| 39 |
-
},
|
| 40 |
-
},
|
| 41 |
-
"light": {
|
| 42 |
-
"name": "InferencePort AI Light",
|
| 43 |
-
"url": "https://buy.stripe.com/bJedR93gk4xw7EFeyabbG08",
|
| 44 |
-
"price": "9.99",
|
| 45 |
-
"limits": {
|
| 46 |
-
"cloudChatDaily": None,
|
| 47 |
-
"imagesDaily": 50,
|
| 48 |
-
"videosDaily": 10,
|
| 49 |
-
"audioWeekly": 5,
|
| 50 |
-
"verifyTokenWithEmailDaily": 100,
|
| 51 |
-
},
|
| 52 |
-
},
|
| 53 |
-
"core": {
|
| 54 |
-
"name": "InferencePort AI Core",
|
| 55 |
-
"url": "https://buy.stripe.com/28E4gzg365BA1gh61EbbG09",
|
| 56 |
-
"price": "15.99",
|
| 57 |
-
"limits": {
|
| 58 |
-
"cloudChatDaily": None,
|
| 59 |
-
"imagesDaily": 150,
|
| 60 |
-
"videosDaily": 30,
|
| 61 |
-
"audioWeekly": 25,
|
| 62 |
-
"verifyTokenWithEmailDaily": 100,
|
| 63 |
-
},
|
| 64 |
-
},
|
| 65 |
-
"creator": {
|
| 66 |
-
"name": "InferencePort AI Creator",
|
| 67 |
-
"url": "https://buy.stripe.com/dRmdR98AE5BA8IJ89MbbG0a",
|
| 68 |
-
"price": "29.99",
|
| 69 |
-
"limits": {
|
| 70 |
-
"cloudChatDaily": None,
|
| 71 |
-
"imagesDaily": 300,
|
| 72 |
-
"videosDaily": 50,
|
| 73 |
-
"audioWeekly": 45,
|
| 74 |
-
"verifyTokenWithEmailDaily": 100,
|
| 75 |
-
},
|
| 76 |
-
},
|
| 77 |
-
"professional": {
|
| 78 |
-
"name": "InferencePort AI Professional",
|
| 79 |
-
"url": "https://buy.stripe.com/14AaEX7wA3ts7EF2PsbbG0b",
|
| 80 |
-
"price": "99.99",
|
| 81 |
-
"limits": {
|
| 82 |
-
"cloudChatDaily": None,
|
| 83 |
-
"imagesDaily": None,
|
| 84 |
-
"videosDaily": None,
|
| 85 |
-
"audioWeekly": 75,
|
| 86 |
-
"verifyTokenWithEmailDaily": 100,
|
| 87 |
-
},
|
| 88 |
-
},
|
| 89 |
-
}
|
| 90 |
-
USAGE_PERIODS = {
|
| 91 |
-
"cloudChatDaily": "daily",
|
| 92 |
-
"imagesDaily": "daily",
|
| 93 |
-
"videosDaily": "daily",
|
| 94 |
-
"audioWeekly": "weekly",
|
| 95 |
-
"verifyTokenWithEmailDaily": "daily",
|
| 96 |
-
}
|
| 97 |
-
usage_store = {
|
| 98 |
-
"cloudChatDaily": {},
|
| 99 |
-
"imagesDaily": {},
|
| 100 |
-
"videosDaily": {},
|
| 101 |
-
"audioWeekly": {},
|
| 102 |
-
"verifyTokenWithEmailDaily": {},
|
| 103 |
-
}
|
| 104 |
-
usage_locks = {
|
| 105 |
-
"cloudChatDaily": {},
|
| 106 |
-
"imagesDaily": {},
|
| 107 |
-
"videosDaily": {},
|
| 108 |
-
"audioWeekly": {},
|
| 109 |
-
"verifyTokenWithEmailDaily": {},
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
conn = None
|
| 113 |
-
conn_lock = asyncio.Lock()
|
| 114 |
-
|
| 115 |
-
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 116 |
-
SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
| 117 |
-
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 118 |
-
POSTGRE_SECRET = os.getenv("POSTGRE_SECRET")
|
| 119 |
-
|
| 120 |
-
async def get_conn():
|
| 121 |
-
global conn
|
| 122 |
-
|
| 123 |
-
if conn is None or conn.closed:
|
| 124 |
-
conn = await psycopg.AsyncConnection.connect(POSTGRE_SECRET, row_factory=dict_row, sslmode="verify-full", sslrootcert="prod-ca-2021.crt")
|
| 125 |
-
|
| 126 |
-
return conn
|
| 127 |
-
|
| 128 |
-
import psycopg
|
| 129 |
-
from psycopg.rows import dict_row
|
| 130 |
-
|
| 131 |
-
async def execute_query(query: str, params=(), *, fetchone=False, commit=False):
|
| 132 |
-
async with conn_lock:
|
| 133 |
-
conn = await get_conn()
|
| 134 |
-
|
| 135 |
-
try:
|
| 136 |
-
async with conn.cursor() as cur:
|
| 137 |
-
await cur.execute(query, params)
|
| 138 |
-
|
| 139 |
-
if cur.description is None:
|
| 140 |
-
result = None if fetchone else []
|
| 141 |
-
else:
|
| 142 |
-
result = await cur.fetchone() if fetchone else await cur.fetchall()
|
| 143 |
-
|
| 144 |
-
if commit:
|
| 145 |
-
await conn.commit()
|
| 146 |
-
|
| 147 |
-
return result
|
| 148 |
-
|
| 149 |
-
except psycopg.OperationalError:
|
| 150 |
-
# reconnect using ASYNC connection
|
| 151 |
-
conn = await psycopg.AsyncConnection.connect(
|
| 152 |
-
POSTGRE_SECRET,
|
| 153 |
-
row_factory=dict_row,
|
| 154 |
-
sslmode="verify-full",
|
| 155 |
-
sslrootcert="prod-ca-2021.crt",
|
| 156 |
-
)
|
| 157 |
-
globals()["conn"] = conn
|
| 158 |
-
|
| 159 |
-
async with conn.cursor() as cur:
|
| 160 |
-
await cur.execute(query, params)
|
| 161 |
-
|
| 162 |
-
if cur.description is None:
|
| 163 |
-
result = None if fetchone else []
|
| 164 |
-
else:
|
| 165 |
-
result = await cur.fetchone() if fetchone else await cur.fetchall()
|
| 166 |
-
|
| 167 |
-
if commit:
|
| 168 |
-
await conn.commit()
|
| 169 |
-
|
| 170 |
-
return result
|
| 171 |
-
|
| 172 |
-
except psycopg.Error as e:
|
| 173 |
-
await conn.rollback()
|
| 174 |
-
raise e
|
| 175 |
-
|
| 176 |
-
def normalize_plan_key(plan_name: str | None) -> str:
|
| 177 |
-
if not plan_name:
|
| 178 |
-
return "free"
|
| 179 |
-
normalized = "".join(ch for ch in str(plan_name).lower() if ch.isalpha())
|
| 180 |
-
if "professional" in normalized:
|
| 181 |
-
return "professional"
|
| 182 |
-
if "creator" in normalized:
|
| 183 |
-
return "creator"
|
| 184 |
-
if "core" in normalized:
|
| 185 |
-
return "core"
|
| 186 |
-
if "light" in normalized:
|
| 187 |
-
return "light"
|
| 188 |
-
return "free"
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def get_effective_limit_for_email(
|
| 192 |
-
plan_key: str,
|
| 193 |
-
metric: str,
|
| 194 |
-
email: str | None = None,
|
| 195 |
-
):
|
| 196 |
-
plan = TIER_CONFIG.get(plan_key) or TIER_CONFIG["free"]
|
| 197 |
-
default_limit = plan.get("limits", {}).get(metric)
|
| 198 |
-
|
| 199 |
-
if not isinstance(email, str) or not email.strip():
|
| 200 |
-
return default_limit
|
| 201 |
-
|
| 202 |
-
entry = EXTRA_USAGE_EMAIL_MAP.get(email.strip().lower())
|
| 203 |
-
if not isinstance(entry, dict):
|
| 204 |
-
return default_limit
|
| 205 |
-
|
| 206 |
-
raw_limits = entry.get("limits") if isinstance(entry.get("limits"), dict) else entry
|
| 207 |
-
override = raw_limits.get(metric) if isinstance(raw_limits, dict) else None
|
| 208 |
-
|
| 209 |
-
if override is None and metric not in raw_limits:
|
| 210 |
-
return default_limit
|
| 211 |
-
|
| 212 |
-
if override is None:
|
| 213 |
-
return None
|
| 214 |
-
|
| 215 |
-
if isinstance(override, (int, float)) and override >= 0:
|
| 216 |
-
return int(override)
|
| 217 |
-
|
| 218 |
-
return default_limit
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
async def fetch_subscription(jwt: str):
|
| 222 |
-
identity = await resolve_token_identity(jwt)
|
| 223 |
-
if "error" in identity:
|
| 224 |
-
return identity
|
| 225 |
-
|
| 226 |
-
email = identity["email"]
|
| 227 |
-
signed_up = identity["signed_up"]
|
| 228 |
-
|
| 229 |
-
if email in ADMIN_EMAIL_MAP:
|
| 230 |
-
admin = ADMIN_EMAIL_MAP[email]
|
| 231 |
-
print(f"[ADMIN OVERRIDE] {email} → forcing plan '{admin['plan_key']}'")
|
| 232 |
-
|
| 233 |
-
subscription_obj = {
|
| 234 |
-
"subscription_id": admin.get("subscription_id", "admin-override"),
|
| 235 |
-
"status": admin.get("status", "active"),
|
| 236 |
-
"current_period_end": None,
|
| 237 |
-
"price_id": None,
|
| 238 |
-
"product_name": admin.get("product_name"),
|
| 239 |
-
"nickname": admin.get("nickname"),
|
| 240 |
-
"plan_key": admin.get("plan_key"),
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
return {
|
| 244 |
-
"email": email,
|
| 245 |
-
"signed_up": signed_up,
|
| 246 |
-
"subscription": [subscription_obj],
|
| 247 |
-
"plan_key": admin["plan_key"],
|
| 248 |
-
"auth_type": identity["auth_type"],
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
rows = await execute_query(
|
| 252 |
-
"""
|
| 253 |
-
with cust as (
|
| 254 |
-
select id
|
| 255 |
-
from stripe.customers
|
| 256 |
-
where email = %s
|
| 257 |
-
),
|
| 258 |
-
subs as (
|
| 259 |
-
select
|
| 260 |
-
s.id as subscription_id,
|
| 261 |
-
s.status,
|
| 262 |
-
s.current_period_end,
|
| 263 |
-
s.items->'data'->0->'price'->>'id' as price_id
|
| 264 |
-
from stripe.subscriptions s
|
| 265 |
-
join cust on s.customer = cust.id
|
| 266 |
-
where s.status in ('active', 'trialing', 'past_due')
|
| 267 |
-
)
|
| 268 |
-
select
|
| 269 |
-
subs.subscription_id,
|
| 270 |
-
subs.status,
|
| 271 |
-
subs.current_period_end,
|
| 272 |
-
subs.price_id,
|
| 273 |
-
prices.nickname,
|
| 274 |
-
prices.product as product_id,
|
| 275 |
-
products.name as product_name
|
| 276 |
-
from subs
|
| 277 |
-
left join stripe.prices prices
|
| 278 |
-
on prices.id = subs.price_id
|
| 279 |
-
left join stripe.products products
|
| 280 |
-
on prices.product = products.id;
|
| 281 |
-
""",
|
| 282 |
-
(email,),
|
| 283 |
-
)
|
| 284 |
-
|
| 285 |
-
if not rows:
|
| 286 |
-
return {
|
| 287 |
-
"email": email,
|
| 288 |
-
"signed_up": signed_up,
|
| 289 |
-
"subscription": None,
|
| 290 |
-
"plan_key": "free",
|
| 291 |
-
"auth_type": identity["auth_type"],
|
| 292 |
-
}
|
| 293 |
-
|
| 294 |
-
subscriptions = []
|
| 295 |
-
preferred_plan_key = "free"
|
| 296 |
-
|
| 297 |
-
for row in rows:
|
| 298 |
-
plan_key = normalize_plan_key(row["product_name"] or row["nickname"])
|
| 299 |
-
|
| 300 |
-
subscriptions.append({
|
| 301 |
-
"subscription_id": row["subscription_id"],
|
| 302 |
-
"status": row["status"],
|
| 303 |
-
"current_period_end": row["current_period_end"],
|
| 304 |
-
"price_id": row["price_id"],
|
| 305 |
-
"product_name": row["product_name"],
|
| 306 |
-
"nickname": row["nickname"],
|
| 307 |
-
"plan_key": plan_key,
|
| 308 |
-
})
|
| 309 |
-
|
| 310 |
-
if row["status"] in ("active", "trialing"):
|
| 311 |
-
preferred_plan_key = plan_key
|
| 312 |
-
|
| 313 |
-
return {
|
| 314 |
-
"email": email,
|
| 315 |
-
"signed_up": signed_up,
|
| 316 |
-
"subscription": subscriptions,
|
| 317 |
-
"plan_key": preferred_plan_key,
|
| 318 |
-
"auth_type": identity["auth_type"],
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
from fastapi import HTTPException
|
| 323 |
-
|
| 324 |
-
async def resolve_token_identity(token: str | None):
|
| 325 |
-
if not token or (token == "public"):
|
| 326 |
-
return None
|
| 327 |
-
|
| 328 |
-
jwt_identity = resolve_jwt_identity(token)
|
| 329 |
-
if jwt_identity is not None:
|
| 330 |
-
return jwt_identity
|
| 331 |
-
|
| 332 |
-
api_identity = await resolve_api_key_identity(token)
|
| 333 |
-
if api_identity is not None:
|
| 334 |
-
if "error" in api_identity:
|
| 335 |
-
raise HTTPException(status_code=401, detail=api_identity["error"])
|
| 336 |
-
return api_identity
|
| 337 |
-
|
| 338 |
-
raise HTTPException(status_code=401, detail="Invalid API key or token")
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
def resolve_jwt_identity(token: str):
|
| 343 |
-
try:
|
| 344 |
-
auth_res = supabase.auth.get_user(token)
|
| 345 |
-
except Exception:
|
| 346 |
-
return None
|
| 347 |
-
|
| 348 |
-
user = getattr(auth_res, "user", None)
|
| 349 |
-
email = getattr(user, "email", None)
|
| 350 |
-
created_at = getattr(user, "created_at", None)
|
| 351 |
-
user_id = getattr(user, "id", None)
|
| 352 |
-
if user is None or not isinstance(email, str) or not email.strip():
|
| 353 |
-
return None
|
| 354 |
-
return {
|
| 355 |
-
"auth_type": "jwt",
|
| 356 |
-
"user_id": user_id,
|
| 357 |
-
"email": email.strip().lower(),
|
| 358 |
-
"signed_up": created_at.isoformat() if hasattr(created_at, "isoformat") else None,
|
| 359 |
-
}
|
| 360 |
-
|
| 361 |
-
def verify_lightning_api_key(secret: str, stored: str) -> bool:
|
| 362 |
-
_, salt_b64, key_b64 = stored.split("$")
|
| 363 |
-
|
| 364 |
-
salt = base64.urlsafe_b64decode(salt_b64 + "==")
|
| 365 |
-
stored_key = base64.urlsafe_b64decode(key_b64 + "==")
|
| 366 |
-
|
| 367 |
-
derived_key = hashlib.scrypt(
|
| 368 |
-
secret.encode("utf-8"),
|
| 369 |
-
salt=salt,
|
| 370 |
-
n=16384, r=8, p=1,
|
| 371 |
-
dklen=32
|
| 372 |
-
)
|
| 373 |
-
|
| 374 |
-
return hmac.compare_digest(derived_key, stored_key)
|
| 375 |
-
|
| 376 |
-
async def resolve_api_key_identity(token: str | None):
|
| 377 |
-
if token is None or token.strip() == "":
|
| 378 |
-
return None
|
| 379 |
-
|
| 380 |
-
prefix = token[:16]
|
| 381 |
-
|
| 382 |
-
row = await execute_query(
|
| 383 |
-
"""
|
| 384 |
-
select
|
| 385 |
-
k.id,
|
| 386 |
-
k.user_id,
|
| 387 |
-
k.name,
|
| 388 |
-
k.expires_at,
|
| 389 |
-
k.revoked_at,
|
| 390 |
-
k.key_hash,
|
| 391 |
-
u.email,
|
| 392 |
-
u.created_at as user_created_at
|
| 393 |
-
from public.lightning_api_keys k
|
| 394 |
-
join auth.users u on u.id = k.user_id
|
| 395 |
-
where k.key_prefix = %s
|
| 396 |
-
limit 1;
|
| 397 |
-
""",
|
| 398 |
-
(prefix,),
|
| 399 |
-
fetchone=True,
|
| 400 |
-
)
|
| 401 |
-
|
| 402 |
-
if not row:
|
| 403 |
-
raise HTTPException(status_code=401, detail="Invalid API key or token")
|
| 404 |
-
|
| 405 |
-
if not verify_lightning_api_key(token, row["key_hash"]):
|
| 406 |
-
raise HTTPException(status_code=401, detail="Invalid API key or token")
|
| 407 |
-
|
| 408 |
-
if row.get("revoked_at") is not None:
|
| 409 |
-
raise HTTPException(status_code=401, detail="Invalid API key or token")
|
| 410 |
-
|
| 411 |
-
expires_at = row.get("expires_at")
|
| 412 |
-
if expires_at is not None and expires_at <= datetime.now(timezone.utc):
|
| 413 |
-
raise HTTPException(status_code=401, detail="Invalid API key or token")
|
| 414 |
-
|
| 415 |
-
email = row.get("email")
|
| 416 |
-
if not isinstance(email, str) or not email.strip():
|
| 417 |
-
raise HTTPException(status_code=401, detail="Invalid API key or token")
|
| 418 |
-
|
| 419 |
-
await execute_query(
|
| 420 |
-
"""
|
| 421 |
-
update public.lightning_api_keys
|
| 422 |
-
set last_used_at = timezone('utc', now())
|
| 423 |
-
where id = %s;
|
| 424 |
-
""",
|
| 425 |
-
(row["id"],),
|
| 426 |
-
commit=True,
|
| 427 |
-
)
|
| 428 |
-
|
| 429 |
-
return {
|
| 430 |
-
"auth_type": "api_key",
|
| 431 |
-
"user_id": str(row["user_id"]) if row["user_id"] is not None else None,
|
| 432 |
-
"email": email.strip().lower(),
|
| 433 |
-
"signed_up": row["user_created_at"].isoformat()
|
| 434 |
-
if hasattr(row["user_created_at"], "isoformat")
|
| 435 |
-
else None,
|
| 436 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
models_override.json
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"lightning": {
|
| 3 |
-
"name": "Lightning",
|
| 4 |
-
"context_length": 128000,
|
| 5 |
-
"max_output_length": 32000,
|
| 6 |
-
"pricing_per_million_tokens": 0.75,
|
| 7 |
-
"input_modalities": ["text", "image", "file"],
|
| 8 |
-
"output_modalities": ["text"],
|
| 9 |
-
"supported_features": ["tools", "json_mode", "streaming", "structured_outputs"]
|
| 10 |
-
},
|
| 11 |
-
"overrides": [
|
| 12 |
-
{
|
| 13 |
-
"id": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
|
| 14 |
-
"name": "NVIDIA: Nemotron 3 Nano Omni 30B A3B Reasoning",
|
| 15 |
-
"api_url": "https://api.aimlapi.com/v1/chat/completions",
|
| 16 |
-
"api_key_env": "AIML_KEY",
|
| 17 |
-
"allowed_in": ["gen", "payg"],
|
| 18 |
-
"context_length": 128000,
|
| 19 |
-
"max_output_length": 32000,
|
| 20 |
-
"input_modalities": ["text"],
|
| 21 |
-
"output_modalities": ["text"],
|
| 22 |
-
"supported_features": ["tools", "json_mode", "streaming"],
|
| 23 |
-
"pricing_per_million_tokens": 0.04
|
| 24 |
-
},
|
| 25 |
-
{
|
| 26 |
-
"id": "baidu/ernie-4-5-0-3b",
|
| 27 |
-
"name": "Baidu: ERNIE 4.5 0 3B",
|
| 28 |
-
"api_url": "https://api.aimlapi.com/v1/chat/completions",
|
| 29 |
-
"api_key_env": "AIML_KEY",
|
| 30 |
-
"allowed_in": ["gen", "payg"],
|
| 31 |
-
"context_length": 128000,
|
| 32 |
-
"max_output_length": 32000,
|
| 33 |
-
"input_modalities": ["text"],
|
| 34 |
-
"output_modalities": ["text"],
|
| 35 |
-
"supported_features": ["tools", "json_mode", "streaming"],
|
| 36 |
-
"pricing_per_million_tokens": 0.04
|
| 37 |
-
}
|
| 38 |
-
]
|
| 39 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
payg/__init__.py
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
| 2 |
-
|
| 3 |
-
router = APIRouter(prefix="/v1")
|
| 4 |
-
|
| 5 |
-
from . import stripe, account, chat, media
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
payg/account.py
DELETED
|
@@ -1,208 +0,0 @@
|
|
| 1 |
-
import time
|
| 2 |
-
import uuid
|
| 3 |
-
from typing import Any, Dict, Optional
|
| 4 |
-
from fastapi import Header, HTTPException, Query, Request
|
| 5 |
-
from fastapi.responses import JSONResponse
|
| 6 |
-
from helper.payg import (
|
| 7 |
-
get_payg_config,
|
| 8 |
-
get_wallet_snapshot,
|
| 9 |
-
list_credit_ledger,
|
| 10 |
-
build_usage_summary_from_ledger_rows,
|
| 11 |
-
require_payg_identity,
|
| 12 |
-
)
|
| 13 |
-
from helper.subscriptions import execute_query
|
| 14 |
-
from . import router
|
| 15 |
-
from .common import (
|
| 16 |
-
build_public_config,
|
| 17 |
-
normalize_lightning_api_key_row,
|
| 18 |
-
build_lightning_api_key_secret,
|
| 19 |
-
build_lightning_api_key_prefix,
|
| 20 |
-
hash_lightning_api_key,
|
| 21 |
-
parse_lightning_api_key_expiry,
|
| 22 |
-
trimmed_string,
|
| 23 |
-
)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
@router.get("/config")
|
| 27 |
-
async def v1_get_public_config():
|
| 28 |
-
config = await get_payg_config()
|
| 29 |
-
return JSONResponse(status_code=200, content=build_public_config(config))
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@router.get("/models")
|
| 33 |
-
async def v1_get_models():
|
| 34 |
-
config = await get_payg_config()
|
| 35 |
-
from gen.override import build_all_models
|
| 36 |
-
return JSONResponse(status_code=200, content={
|
| 37 |
-
"object": "list",
|
| 38 |
-
"data": build_all_models(),
|
| 39 |
-
})
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
@router.get("/me")
|
| 43 |
-
async def v1_get_me(authorization: Optional[str] = Header(None)):
|
| 44 |
-
identity = await require_payg_identity(authorization)
|
| 45 |
-
wallet = await get_wallet_snapshot(identity["user_id"])
|
| 46 |
-
ledger = await list_credit_ledger(identity["user_id"], limit=200)
|
| 47 |
-
usage_summary = build_usage_summary_from_ledger_rows(ledger)
|
| 48 |
-
|
| 49 |
-
return JSONResponse(
|
| 50 |
-
status_code=200,
|
| 51 |
-
content={
|
| 52 |
-
"identity": {
|
| 53 |
-
"email": identity["email"],
|
| 54 |
-
"auth_type": identity["auth_type"],
|
| 55 |
-
"user_id": identity["user_id"],
|
| 56 |
-
},
|
| 57 |
-
"wallet": wallet,
|
| 58 |
-
"usage_summary": usage_summary,
|
| 59 |
-
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 60 |
-
},
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
@router.get("/credits/ledger")
|
| 65 |
-
async def v1_get_credit_ledger(
|
| 66 |
-
authorization: Optional[str] = Header(None),
|
| 67 |
-
limit: int = Query(default=50, ge=1, le=500),
|
| 68 |
-
):
|
| 69 |
-
identity = await require_payg_identity(authorization)
|
| 70 |
-
ledger = await list_credit_ledger(identity["user_id"], limit=limit)
|
| 71 |
-
return {"entries": ledger}
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
@router.get("/lightning-api-keys")
|
| 75 |
-
async def v1_list_lightning_api_keys(authorization: Optional[str] = Header(None)):
|
| 76 |
-
identity = await require_payg_identity(authorization)
|
| 77 |
-
rows = await execute_query(
|
| 78 |
-
"""
|
| 79 |
-
select
|
| 80 |
-
id,
|
| 81 |
-
name,
|
| 82 |
-
key_prefix,
|
| 83 |
-
created_at,
|
| 84 |
-
last_used_at,
|
| 85 |
-
expires_at,
|
| 86 |
-
revoked_at
|
| 87 |
-
from public.lightning_api_keys
|
| 88 |
-
where user_id = %s
|
| 89 |
-
order by created_at desc;
|
| 90 |
-
""",
|
| 91 |
-
(identity["user_id"],),
|
| 92 |
-
)
|
| 93 |
-
|
| 94 |
-
items = []
|
| 95 |
-
for row in rows or []:
|
| 96 |
-
normalized = normalize_lightning_api_key_row(row or {})
|
| 97 |
-
if normalized:
|
| 98 |
-
items.append(normalized)
|
| 99 |
-
|
| 100 |
-
return JSONResponse(status_code=200, content={"items": items})
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
@router.post("/lightning-api-keys")
|
| 104 |
-
async def v1_create_lightning_api_key(
|
| 105 |
-
request: Request,
|
| 106 |
-
authorization: Optional[str] = Header(None),
|
| 107 |
-
):
|
| 108 |
-
identity = await require_payg_identity(authorization)
|
| 109 |
-
|
| 110 |
-
try:
|
| 111 |
-
body = await request.json()
|
| 112 |
-
except Exception:
|
| 113 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 114 |
-
|
| 115 |
-
if not isinstance(body, dict):
|
| 116 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 117 |
-
|
| 118 |
-
name = trimmed_string(body.get("name"))
|
| 119 |
-
if not name:
|
| 120 |
-
raise HTTPException(status_code=400, detail="API key name is required")
|
| 121 |
-
if len(name) > 64:
|
| 122 |
-
raise HTTPException(status_code=400, detail="API key name must be 64 characters or fewer")
|
| 123 |
-
|
| 124 |
-
expires_at = parse_lightning_api_key_expiry(body.get("expiresAt"))
|
| 125 |
-
raw_key = build_lightning_api_key_secret()
|
| 126 |
-
key_hash = hash_lightning_api_key(raw_key)
|
| 127 |
-
key_prefix = build_lightning_api_key_prefix(raw_key)
|
| 128 |
-
|
| 129 |
-
key_id = str(uuid.uuid4())
|
| 130 |
-
|
| 131 |
-
row = await execute_query(
|
| 132 |
-
"""
|
| 133 |
-
insert into public.lightning_api_keys (
|
| 134 |
-
id,
|
| 135 |
-
user_id,
|
| 136 |
-
name,
|
| 137 |
-
key_hash,
|
| 138 |
-
key_prefix,
|
| 139 |
-
expires_at
|
| 140 |
-
)
|
| 141 |
-
values (%s, %s, %s, %s, %s, %s)
|
| 142 |
-
returning
|
| 143 |
-
id,
|
| 144 |
-
name,
|
| 145 |
-
key_prefix,
|
| 146 |
-
created_at,
|
| 147 |
-
last_used_at,
|
| 148 |
-
expires_at,
|
| 149 |
-
revoked_at;
|
| 150 |
-
""",
|
| 151 |
-
(
|
| 152 |
-
key_id,
|
| 153 |
-
identity["user_id"],
|
| 154 |
-
name,
|
| 155 |
-
key_hash,
|
| 156 |
-
key_prefix,
|
| 157 |
-
expires_at,
|
| 158 |
-
),
|
| 159 |
-
fetchone=True,
|
| 160 |
-
commit=True,
|
| 161 |
-
)
|
| 162 |
-
|
| 163 |
-
normalized = normalize_lightning_api_key_row(row or {})
|
| 164 |
-
if not normalized:
|
| 165 |
-
raise HTTPException(status_code=500, detail="Created API key response was invalid")
|
| 166 |
-
|
| 167 |
-
return JSONResponse(status_code=200, content={"apiKey": normalized, "rawKey": raw_key})
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
@router.delete("/lightning-api-keys/{key_id}")
|
| 171 |
-
async def v1_revoke_lightning_api_key(
|
| 172 |
-
key_id: str,
|
| 173 |
-
authorization: Optional[str] = Header(None),
|
| 174 |
-
):
|
| 175 |
-
identity = await require_payg_identity(authorization)
|
| 176 |
-
key_id = trimmed_string(key_id)
|
| 177 |
-
if not key_id:
|
| 178 |
-
raise HTTPException(status_code=400, detail="API key id is required")
|
| 179 |
-
|
| 180 |
-
row = await execute_query(
|
| 181 |
-
"""
|
| 182 |
-
update public.lightning_api_keys
|
| 183 |
-
set revoked_at = timezone('utc', now())
|
| 184 |
-
where id = %s
|
| 185 |
-
and user_id = %s
|
| 186 |
-
and revoked_at is null
|
| 187 |
-
returning
|
| 188 |
-
id,
|
| 189 |
-
name,
|
| 190 |
-
key_prefix,
|
| 191 |
-
created_at,
|
| 192 |
-
last_used_at,
|
| 193 |
-
expires_at,
|
| 194 |
-
revoked_at;
|
| 195 |
-
""",
|
| 196 |
-
(key_id, identity["user_id"]),
|
| 197 |
-
fetchone=True,
|
| 198 |
-
commit=True,
|
| 199 |
-
)
|
| 200 |
-
|
| 201 |
-
if not row:
|
| 202 |
-
raise HTTPException(status_code=404, detail="API key not found or already revoked")
|
| 203 |
-
|
| 204 |
-
normalized = normalize_lightning_api_key_row(row or {})
|
| 205 |
-
if not normalized:
|
| 206 |
-
raise HTTPException(status_code=500, detail="Updated API key response was invalid")
|
| 207 |
-
|
| 208 |
-
return JSONResponse(status_code=200, content={"success": True, "apiKey": normalized})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
payg/chat.py
DELETED
|
@@ -1,292 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import uuid
|
| 3 |
-
from decimal import Decimal, ROUND_HALF_UP
|
| 4 |
-
from typing import Any, Dict, Optional
|
| 5 |
-
import httpx
|
| 6 |
-
from fastapi import Header, HTTPException, Request
|
| 7 |
-
from fastapi.responses import JSONResponse, StreamingResponse
|
| 8 |
-
from helper.payg import (
|
| 9 |
-
append_credit_ledger_entry,
|
| 10 |
-
calculate_text_credit_cost,
|
| 11 |
-
enforce_payg_spam_limit,
|
| 12 |
-
estimate_text_tokens_from_messages,
|
| 13 |
-
estimate_text_tokens_from_text,
|
| 14 |
-
extract_usage_breakdown,
|
| 15 |
-
get_payg_config,
|
| 16 |
-
get_wallet_snapshot,
|
| 17 |
-
require_payg_identity,
|
| 18 |
-
)
|
| 19 |
-
from gen.override import get_override, is_allowed_in
|
| 20 |
-
from . import router
|
| 21 |
-
from .common import (
|
| 22 |
-
INTERNAL_API_BASE,
|
| 23 |
-
count_input_images,
|
| 24 |
-
build_upstream_headers,
|
| 25 |
-
get_minimum_text_balance,
|
| 26 |
-
extract_stream_usage_and_text,
|
| 27 |
-
extract_completion_text_from_payload,
|
| 28 |
-
stringify_uuids,
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def _get_override_pricing(model_id: str) -> Decimal:
|
| 33 |
-
entry = get_override(model_id)
|
| 34 |
-
if entry:
|
| 35 |
-
ppm = entry.get("pricing_per_million_tokens", 0)
|
| 36 |
-
try:
|
| 37 |
-
return Decimal(str(ppm))
|
| 38 |
-
except Exception:
|
| 39 |
-
pass
|
| 40 |
-
return Decimal("0")
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def quantize_override_cost(tokens: int, ppm: Decimal) -> Decimal:
|
| 44 |
-
cost = (Decimal(str(tokens)) / Decimal("1000000")) * ppm
|
| 45 |
-
return cost.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@router.post("/chat/completions")
|
| 49 |
-
async def v1_chat_completions(
|
| 50 |
-
request: Request,
|
| 51 |
-
authorization: Optional[str] = Header(None),
|
| 52 |
-
x_client_id: Optional[str] = Header(None),
|
| 53 |
-
):
|
| 54 |
-
identity = await require_payg_identity(authorization)
|
| 55 |
-
config = await get_payg_config()
|
| 56 |
-
|
| 57 |
-
limit = int(config.get("limits", {}).get("requestsPerMinutePerIdentity", 120))
|
| 58 |
-
await enforce_payg_spam_limit(f"{identity['user_id']}:text", max(1, limit))
|
| 59 |
-
|
| 60 |
-
wallet = await get_wallet_snapshot(identity["user_id"])
|
| 61 |
-
minimum_balance = get_minimum_text_balance(config)
|
| 62 |
-
current_balance = Decimal(str(wallet.get("balance_credits") or 0))
|
| 63 |
-
if current_balance < minimum_balance:
|
| 64 |
-
raise HTTPException(
|
| 65 |
-
status_code=402,
|
| 66 |
-
detail=f"At least ${minimum_balance} in credits is required for text generation.",
|
| 67 |
-
)
|
| 68 |
-
|
| 69 |
-
body = await request.json()
|
| 70 |
-
if not isinstance(body, dict):
|
| 71 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 72 |
-
|
| 73 |
-
requested_model = body.get("model")
|
| 74 |
-
override_entry = get_override(requested_model) if requested_model else None
|
| 75 |
-
is_override = override_entry is not None and is_allowed_in(requested_model, "payg")
|
| 76 |
-
override_ppm = _get_override_pricing(requested_model) if is_override else Decimal("0")
|
| 77 |
-
|
| 78 |
-
estimated_input_tokens = estimate_text_tokens_from_messages(body.get("messages"))
|
| 79 |
-
image_count = count_input_images(body.get("messages"))
|
| 80 |
-
estimated_input_tokens += 15000 * image_count
|
| 81 |
-
|
| 82 |
-
estimated_output_tokens = 0
|
| 83 |
-
max_tokens_value = body.get("max_completion_tokens")
|
| 84 |
-
if max_tokens_value is None:
|
| 85 |
-
max_tokens_value = body.get("max_tokens")
|
| 86 |
-
try:
|
| 87 |
-
if max_tokens_value is not None:
|
| 88 |
-
estimated_output_tokens = max(0, int(max_tokens_value))
|
| 89 |
-
except Exception:
|
| 90 |
-
estimated_output_tokens = 0
|
| 91 |
-
|
| 92 |
-
estimated_total_tokens = estimated_input_tokens + estimated_output_tokens
|
| 93 |
-
|
| 94 |
-
if is_override:
|
| 95 |
-
estimated_total_cost = quantize_override_cost(estimated_total_tokens, override_ppm)
|
| 96 |
-
else:
|
| 97 |
-
estimated_total_cost = calculate_text_credit_cost(estimated_total_tokens, config)
|
| 98 |
-
if estimated_output_tokens <= 0:
|
| 99 |
-
estimated_total_cost = calculate_text_credit_cost(estimated_input_tokens, config)
|
| 100 |
-
|
| 101 |
-
if current_balance < max(minimum_balance, estimated_total_cost):
|
| 102 |
-
raise HTTPException(
|
| 103 |
-
status_code=402,
|
| 104 |
-
detail=(
|
| 105 |
-
"Insufficient credits for this text request. "
|
| 106 |
-
f"Current balance is {current_balance:.2f} credits."
|
| 107 |
-
),
|
| 108 |
-
)
|
| 109 |
-
|
| 110 |
-
stream = bool(body.get("stream"))
|
| 111 |
-
if stream:
|
| 112 |
-
body["stream"] = True
|
| 113 |
-
stream_options = body.get("stream_options")
|
| 114 |
-
if not isinstance(stream_options, dict):
|
| 115 |
-
stream_options = {}
|
| 116 |
-
stream_options["include_usage"] = True
|
| 117 |
-
body["stream_options"] = stream_options
|
| 118 |
-
|
| 119 |
-
async def settle_text_usage(
|
| 120 |
-
*,
|
| 121 |
-
input_tokens: int,
|
| 122 |
-
output_tokens: int,
|
| 123 |
-
source: str,
|
| 124 |
-
payload: Optional[Dict[str, Any]] = None,
|
| 125 |
-
metadata: Optional[Dict[str, Any]] = None,
|
| 126 |
-
) -> Dict[str, Any]:
|
| 127 |
-
input_tokens = max(0, int(input_tokens))
|
| 128 |
-
output_tokens = max(0, int(output_tokens))
|
| 129 |
-
total_tokens = input_tokens + output_tokens
|
| 130 |
-
|
| 131 |
-
if is_override:
|
| 132 |
-
final_cost = quantize_override_cost(total_tokens, override_ppm)
|
| 133 |
-
else:
|
| 134 |
-
final_cost = calculate_text_credit_cost(total_tokens, config)
|
| 135 |
-
|
| 136 |
-
usage_key = f"text:{uuid.uuid4().hex}"
|
| 137 |
-
entry_metadata = {
|
| 138 |
-
"input_tokens": input_tokens,
|
| 139 |
-
"output_tokens": output_tokens,
|
| 140 |
-
"total_tokens": total_tokens,
|
| 141 |
-
"stream": stream,
|
| 142 |
-
"is_override": is_override,
|
| 143 |
-
"override_model": requested_model if is_override else None,
|
| 144 |
-
}
|
| 145 |
-
if isinstance(metadata, dict):
|
| 146 |
-
entry_metadata.update(metadata)
|
| 147 |
-
|
| 148 |
-
ledger_entry = await append_credit_ledger_entry(
|
| 149 |
-
user_id=identity["user_id"],
|
| 150 |
-
delta_credits=-final_cost,
|
| 151 |
-
entry_type="usage",
|
| 152 |
-
usage_kind="text",
|
| 153 |
-
units=float(total_tokens),
|
| 154 |
-
unit_label="tokens",
|
| 155 |
-
source=source,
|
| 156 |
-
source_ref=usage_key,
|
| 157 |
-
idempotency_key=usage_key,
|
| 158 |
-
metadata=entry_metadata,
|
| 159 |
-
)
|
| 160 |
-
ledger_entry = stringify_uuids(ledger_entry)
|
| 161 |
-
|
| 162 |
-
if isinstance(payload, dict):
|
| 163 |
-
payload.setdefault("usage", {})
|
| 164 |
-
payload["usage"]["payg_input_tokens"] = input_tokens
|
| 165 |
-
payload["usage"]["payg_output_tokens"] = output_tokens
|
| 166 |
-
payload["usage"]["payg_total_tokens"] = total_tokens
|
| 167 |
-
payload["usage"]["payg_credits_charged"] = float(final_cost)
|
| 168 |
-
payload["usage"]["payg_ledger_entry"] = stringify_uuids(ledger_entry)
|
| 169 |
-
return payload or {
|
| 170 |
-
"usage": {
|
| 171 |
-
"payg_input_tokens": input_tokens,
|
| 172 |
-
"payg_output_tokens": output_tokens,
|
| 173 |
-
"payg_total_tokens": total_tokens,
|
| 174 |
-
"payg_credits_charged": float(final_cost),
|
| 175 |
-
"payg_ledger_entry": ledger_entry,
|
| 176 |
-
}
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
try:
|
| 180 |
-
if stream:
|
| 181 |
-
async def event_generator():
|
| 182 |
-
completion_text = []
|
| 183 |
-
seen_usage: Optional[Dict[str, Any]] = None
|
| 184 |
-
|
| 185 |
-
try:
|
| 186 |
-
async with httpx.AsyncClient(timeout=None) as client:
|
| 187 |
-
async with client.stream(
|
| 188 |
-
"POST",
|
| 189 |
-
f"{INTERNAL_API_BASE}/gen/chat/completions",
|
| 190 |
-
json=body,
|
| 191 |
-
headers=build_upstream_headers(authorization, x_client_id),
|
| 192 |
-
) as upstream:
|
| 193 |
-
if upstream.status_code >= 400:
|
| 194 |
-
error_text = await upstream.aread()
|
| 195 |
-
error_body = error_text.decode("utf-8", errors="replace")
|
| 196 |
-
yield (
|
| 197 |
-
f'data: {json.dumps({"error": "Upstream request failed", "detail": error_body[:1000]})}\n\n'
|
| 198 |
-
)
|
| 199 |
-
return
|
| 200 |
-
|
| 201 |
-
async for line in upstream.aiter_lines():
|
| 202 |
-
if not line:
|
| 203 |
-
yield "\n"
|
| 204 |
-
continue
|
| 205 |
-
|
| 206 |
-
collected_text, usage, is_done = extract_stream_usage_and_text(line)
|
| 207 |
-
if collected_text:
|
| 208 |
-
completion_text.append(collected_text)
|
| 209 |
-
if usage:
|
| 210 |
-
seen_usage = usage
|
| 211 |
-
|
| 212 |
-
yield line + "\n"
|
| 213 |
-
|
| 214 |
-
if is_done:
|
| 215 |
-
break
|
| 216 |
-
|
| 217 |
-
usage_payload = {"usage": seen_usage} if isinstance(seen_usage, dict) else {}
|
| 218 |
-
breakdown = extract_usage_breakdown(
|
| 219 |
-
usage_payload,
|
| 220 |
-
estimated_input_tokens,
|
| 221 |
-
estimate_text_tokens_from_text("".join(completion_text)),
|
| 222 |
-
)
|
| 223 |
-
final_payload = await settle_text_usage(
|
| 224 |
-
input_tokens=breakdown["input_tokens"],
|
| 225 |
-
output_tokens=breakdown["output_tokens"],
|
| 226 |
-
source="v1_chat_stream",
|
| 227 |
-
metadata={
|
| 228 |
-
"usage_source": "stream",
|
| 229 |
-
"estimated_input_tokens": estimated_input_tokens,
|
| 230 |
-
"estimated_output_tokens": estimated_output_tokens,
|
| 231 |
-
},
|
| 232 |
-
)
|
| 233 |
-
yield f"event: payg-usage\ndata: {json.dumps(final_payload.get('usage', {}))}\n\n"
|
| 234 |
-
except Exception as exc:
|
| 235 |
-
yield f'data: {json.dumps({"error": "Text generation failed", "detail": str(exc)[:500]})}\n\n'
|
| 236 |
-
|
| 237 |
-
return StreamingResponse(
|
| 238 |
-
event_generator(),
|
| 239 |
-
media_type="text/event-stream",
|
| 240 |
-
headers={
|
| 241 |
-
"Cache-Control": "no-cache",
|
| 242 |
-
"Connection": "keep-alive",
|
| 243 |
-
"X-Accel-Buffering": "no",
|
| 244 |
-
},
|
| 245 |
-
)
|
| 246 |
-
|
| 247 |
-
async with httpx.AsyncClient(timeout=120) as client:
|
| 248 |
-
upstream = await client.post(
|
| 249 |
-
f"{INTERNAL_API_BASE}/gen/chat/completions",
|
| 250 |
-
json=body,
|
| 251 |
-
headers=build_upstream_headers(authorization, x_client_id),
|
| 252 |
-
)
|
| 253 |
-
|
| 254 |
-
if upstream.status_code >= 400:
|
| 255 |
-
content_type = (upstream.headers.get("content-type") or "").lower()
|
| 256 |
-
if "application/json" in content_type:
|
| 257 |
-
return JSONResponse(status_code=upstream.status_code, content=upstream.json())
|
| 258 |
-
return JSONResponse(
|
| 259 |
-
status_code=upstream.status_code,
|
| 260 |
-
content={"error": "Upstream request failed", "detail": upstream.text[:1000]},
|
| 261 |
-
)
|
| 262 |
-
|
| 263 |
-
payload = upstream.json()
|
| 264 |
-
breakdown = extract_usage_breakdown(
|
| 265 |
-
payload,
|
| 266 |
-
estimated_input_tokens,
|
| 267 |
-
estimate_text_tokens_from_text(extract_completion_text_from_payload(payload)),
|
| 268 |
-
)
|
| 269 |
-
|
| 270 |
-
if breakdown["output_tokens"] <= 0:
|
| 271 |
-
breakdown["output_tokens"] = estimate_text_tokens_from_text(
|
| 272 |
-
extract_completion_text_from_payload(payload)
|
| 273 |
-
)
|
| 274 |
-
breakdown["total_tokens"] = breakdown["input_tokens"] + breakdown["output_tokens"]
|
| 275 |
-
|
| 276 |
-
final_payload = await settle_text_usage(
|
| 277 |
-
input_tokens=breakdown["input_tokens"],
|
| 278 |
-
output_tokens=breakdown["output_tokens"],
|
| 279 |
-
source="v1_chat",
|
| 280 |
-
payload=payload,
|
| 281 |
-
metadata={
|
| 282 |
-
"usage_source": "non_stream",
|
| 283 |
-
"estimated_input_tokens": estimated_input_tokens,
|
| 284 |
-
"estimated_output_tokens": estimated_output_tokens,
|
| 285 |
-
},
|
| 286 |
-
)
|
| 287 |
-
return JSONResponse(status_code=upstream.status_code, content=final_payload)
|
| 288 |
-
|
| 289 |
-
except HTTPException:
|
| 290 |
-
raise
|
| 291 |
-
except Exception as exc:
|
| 292 |
-
raise HTTPException(status_code=502, detail=f"Text generation failed: {str(exc)[:300]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
payg/common.py
DELETED
|
@@ -1,316 +0,0 @@
|
|
| 1 |
-
import base64
|
| 2 |
-
import hashlib
|
| 3 |
-
import json
|
| 4 |
-
import secrets
|
| 5 |
-
import time
|
| 6 |
-
import uuid
|
| 7 |
-
from datetime import datetime, timezone
|
| 8 |
-
from decimal import Decimal
|
| 9 |
-
from typing import Any, Dict, Optional
|
| 10 |
-
import os
|
| 11 |
-
from fastapi import HTTPException
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
INTERNAL_API_BASE = os.getenv("INTERNAL_API_BASE", "http://127.0.0.1:7860")
|
| 15 |
-
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
| 16 |
-
WEBHOOK_KEY = os.getenv("WEBHOOK_KEY", "")
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def stringify_uuids(obj):
|
| 20 |
-
if isinstance(obj, uuid.UUID):
|
| 21 |
-
return str(obj)
|
| 22 |
-
if isinstance(obj, dict):
|
| 23 |
-
return {k: stringify_uuids(v) for k, v in obj.items()}
|
| 24 |
-
if isinstance(obj, list):
|
| 25 |
-
return [stringify_uuids(v) for v in obj]
|
| 26 |
-
return obj
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def count_input_images(body: Any) -> int:
|
| 30 |
-
if not isinstance(body, dict):
|
| 31 |
-
return 0
|
| 32 |
-
|
| 33 |
-
messages = body.get("messages")
|
| 34 |
-
count = 0
|
| 35 |
-
|
| 36 |
-
if isinstance(messages, list):
|
| 37 |
-
for msg in messages:
|
| 38 |
-
content = msg.get("content")
|
| 39 |
-
|
| 40 |
-
if isinstance(content, list):
|
| 41 |
-
for part in content:
|
| 42 |
-
if not isinstance(part, dict):
|
| 43 |
-
continue
|
| 44 |
-
|
| 45 |
-
t = part.get("type")
|
| 46 |
-
|
| 47 |
-
if t in ("image_url", "image"):
|
| 48 |
-
count += 1
|
| 49 |
-
|
| 50 |
-
if t == "image_urls":
|
| 51 |
-
urls = part.get("image_urls")
|
| 52 |
-
if isinstance(urls, list):
|
| 53 |
-
count += len(urls)
|
| 54 |
-
|
| 55 |
-
elif isinstance(content, dict):
|
| 56 |
-
t = content.get("type")
|
| 57 |
-
|
| 58 |
-
if t in ("image_url", "image"):
|
| 59 |
-
count += 1
|
| 60 |
-
|
| 61 |
-
if t == "image_urls":
|
| 62 |
-
urls = content.get("image_urls")
|
| 63 |
-
if isinstance(urls, list):
|
| 64 |
-
count += len(urls)
|
| 65 |
-
|
| 66 |
-
top_level_urls = body.get("image_urls")
|
| 67 |
-
if isinstance(top_level_urls, list):
|
| 68 |
-
count += len(top_level_urls)
|
| 69 |
-
|
| 70 |
-
return count
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def build_public_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
| 74 |
-
return {
|
| 75 |
-
"dashboard": config.get("dashboard", {}),
|
| 76 |
-
"supabase": config.get("supabase", {}),
|
| 77 |
-
"billing": config.get("billing", {}),
|
| 78 |
-
"pricing": config.get("pricing", {}),
|
| 79 |
-
"models": config.get("models", []),
|
| 80 |
-
"notices": [
|
| 81 |
-
notice
|
| 82 |
-
for notice in config.get("notices", [])
|
| 83 |
-
if isinstance(notice, dict) and notice.get("active") is not False
|
| 84 |
-
],
|
| 85 |
-
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def trimmed_string(value: Any) -> Optional[str]:
|
| 90 |
-
if value is None:
|
| 91 |
-
return None
|
| 92 |
-
|
| 93 |
-
if isinstance(value, uuid.UUID):
|
| 94 |
-
return str(value)
|
| 95 |
-
|
| 96 |
-
if not isinstance(value, str):
|
| 97 |
-
return None
|
| 98 |
-
|
| 99 |
-
trimmed = value.strip()
|
| 100 |
-
return trimmed or None
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def format_iso_datetime(value: Any) -> Optional[str]:
|
| 104 |
-
if value is None:
|
| 105 |
-
return None
|
| 106 |
-
|
| 107 |
-
if isinstance(value, str):
|
| 108 |
-
return (
|
| 109 |
-
value
|
| 110 |
-
.replace("+00:00", "Z")
|
| 111 |
-
.replace("+00", "Z")
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
if hasattr(value, "isoformat"):
|
| 115 |
-
try:
|
| 116 |
-
return value.isoformat().replace("+00:00", "Z")
|
| 117 |
-
except Exception:
|
| 118 |
-
return None
|
| 119 |
-
|
| 120 |
-
return None
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def normalize_lightning_api_key_row(row: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 124 |
-
key_id = trimmed_string(row.get("id"))
|
| 125 |
-
name = trimmed_string(row.get("name"))
|
| 126 |
-
key_prefix = trimmed_string(row.get("key_prefix"))
|
| 127 |
-
created_at = format_iso_datetime(row.get("created_at"))
|
| 128 |
-
if not key_id or not name or not key_prefix or not created_at:
|
| 129 |
-
return None
|
| 130 |
-
|
| 131 |
-
last_used_at = format_iso_datetime(row.get("last_used_at"))
|
| 132 |
-
expires_at = format_iso_datetime(row.get("expires_at"))
|
| 133 |
-
revoked_at = format_iso_datetime(row.get("revoked_at"))
|
| 134 |
-
is_expired = False
|
| 135 |
-
|
| 136 |
-
if revoked_at is None and expires_at:
|
| 137 |
-
try:
|
| 138 |
-
expires_dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
| 139 |
-
is_expired = expires_dt <= datetime.now(timezone.utc)
|
| 140 |
-
except Exception:
|
| 141 |
-
is_expired = False
|
| 142 |
-
|
| 143 |
-
return {
|
| 144 |
-
"id": key_id,
|
| 145 |
-
"name": name,
|
| 146 |
-
"keyPrefix": key_prefix,
|
| 147 |
-
"createdAt": created_at,
|
| 148 |
-
"lastUsedAt": last_used_at,
|
| 149 |
-
"expiresAt": expires_at,
|
| 150 |
-
"revokedAt": revoked_at,
|
| 151 |
-
"isRevoked": revoked_at is not None,
|
| 152 |
-
"isExpired": is_expired,
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
def build_lightning_api_key_secret() -> str:
|
| 157 |
-
secret = base64.urlsafe_b64encode(secrets.token_bytes(24)).decode("ascii").rstrip("=")
|
| 158 |
-
return f"ipa_live_{secret}"
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def build_lightning_api_key_prefix(secret: str) -> str:
|
| 162 |
-
return secret[:16]
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def hash_lightning_api_key(secret: str) -> str:
|
| 166 |
-
salt = secrets.token_bytes(16)
|
| 167 |
-
derived_key = hashlib.scrypt(
|
| 168 |
-
secret.encode("utf-8"),
|
| 169 |
-
salt=salt,
|
| 170 |
-
n=16384,
|
| 171 |
-
r=8,
|
| 172 |
-
p=1,
|
| 173 |
-
dklen=32,
|
| 174 |
-
)
|
| 175 |
-
salt_b64 = base64.urlsafe_b64encode(salt).decode("ascii").rstrip("=")
|
| 176 |
-
key_b64 = base64.urlsafe_b64encode(derived_key).decode("ascii").rstrip("=")
|
| 177 |
-
return f"scrypt${salt_b64}${key_b64}"
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def parse_lightning_api_key_expiry(value: Any) -> Optional[str]:
|
| 181 |
-
trimmed = trimmed_string(value)
|
| 182 |
-
if not trimmed:
|
| 183 |
-
return None
|
| 184 |
-
|
| 185 |
-
normalized = trimmed.replace("Z", "+00:00")
|
| 186 |
-
try:
|
| 187 |
-
parsed = datetime.fromisoformat(normalized)
|
| 188 |
-
except Exception as exc:
|
| 189 |
-
raise HTTPException(
|
| 190 |
-
status_code=400,
|
| 191 |
-
detail="Expiration date must be a valid ISO timestamp",
|
| 192 |
-
) from exc
|
| 193 |
-
|
| 194 |
-
if parsed.tzinfo is None:
|
| 195 |
-
parsed = parsed.replace(tzinfo=timezone.utc)
|
| 196 |
-
|
| 197 |
-
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
def build_upstream_headers(authorization: str, x_client_id: Optional[str]) -> Dict[str, str]:
|
| 201 |
-
headers = {
|
| 202 |
-
"Authorization": authorization,
|
| 203 |
-
"x-payg-internal": WEBHOOK_KEY,
|
| 204 |
-
}
|
| 205 |
-
if x_client_id:
|
| 206 |
-
headers["x-client-id"] = x_client_id
|
| 207 |
-
return headers
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def get_minimum_text_balance(config: Dict[str, Any]) -> Decimal:
|
| 211 |
-
raw_value = config.get("limits", {}).get("minimumTextCreditsBalance", 10)
|
| 212 |
-
try:
|
| 213 |
-
return Decimal(str(raw_value))
|
| 214 |
-
except Exception:
|
| 215 |
-
return Decimal("10")
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def extract_stream_usage_and_text(line: str) -> tuple[str, Optional[Dict[str, Any]], bool]:
|
| 219 |
-
if not isinstance(line, str) or not line.startswith("data:"):
|
| 220 |
-
return "", None, False
|
| 221 |
-
|
| 222 |
-
data = line[5:].strip()
|
| 223 |
-
if not data:
|
| 224 |
-
return "", None, False
|
| 225 |
-
|
| 226 |
-
if data == "[DONE]":
|
| 227 |
-
return "", None, True
|
| 228 |
-
|
| 229 |
-
try:
|
| 230 |
-
payload = json.loads(data)
|
| 231 |
-
except Exception:
|
| 232 |
-
return "", None, False
|
| 233 |
-
|
| 234 |
-
if not isinstance(payload, dict):
|
| 235 |
-
return "", None, False
|
| 236 |
-
|
| 237 |
-
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else None
|
| 238 |
-
collected_text = []
|
| 239 |
-
|
| 240 |
-
choices = payload.get("choices")
|
| 241 |
-
if isinstance(choices, list):
|
| 242 |
-
for choice in choices:
|
| 243 |
-
if not isinstance(choice, dict):
|
| 244 |
-
continue
|
| 245 |
-
|
| 246 |
-
delta = choice.get("delta")
|
| 247 |
-
if isinstance(delta, dict):
|
| 248 |
-
content = delta.get("content")
|
| 249 |
-
if isinstance(content, str):
|
| 250 |
-
collected_text.append(content)
|
| 251 |
-
elif isinstance(content, list):
|
| 252 |
-
for item in content:
|
| 253 |
-
if isinstance(item, str):
|
| 254 |
-
collected_text.append(item)
|
| 255 |
-
elif isinstance(item, dict):
|
| 256 |
-
text = item.get("text")
|
| 257 |
-
if isinstance(text, str):
|
| 258 |
-
collected_text.append(text)
|
| 259 |
-
|
| 260 |
-
message = choice.get("message")
|
| 261 |
-
if isinstance(message, dict):
|
| 262 |
-
content = message.get("content")
|
| 263 |
-
if isinstance(content, str):
|
| 264 |
-
collected_text.append(content)
|
| 265 |
-
elif isinstance(content, list):
|
| 266 |
-
for item in content:
|
| 267 |
-
if isinstance(item, str):
|
| 268 |
-
collected_text.append(item)
|
| 269 |
-
elif isinstance(item, dict):
|
| 270 |
-
text = item.get("text")
|
| 271 |
-
if isinstance(text, str):
|
| 272 |
-
collected_text.append(text)
|
| 273 |
-
|
| 274 |
-
return "".join(collected_text), usage, False
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def extract_completion_text_from_payload(payload: Dict[str, Any]) -> str:
|
| 278 |
-
if not isinstance(payload, dict):
|
| 279 |
-
return ""
|
| 280 |
-
|
| 281 |
-
collected_text = []
|
| 282 |
-
choices = payload.get("choices")
|
| 283 |
-
if isinstance(choices, list):
|
| 284 |
-
for choice in choices:
|
| 285 |
-
if not isinstance(choice, dict):
|
| 286 |
-
continue
|
| 287 |
-
|
| 288 |
-
message = choice.get("message")
|
| 289 |
-
if isinstance(message, dict):
|
| 290 |
-
content = message.get("content")
|
| 291 |
-
if isinstance(content, str):
|
| 292 |
-
collected_text.append(content)
|
| 293 |
-
elif isinstance(content, list):
|
| 294 |
-
for item in content:
|
| 295 |
-
if isinstance(item, str):
|
| 296 |
-
collected_text.append(item)
|
| 297 |
-
elif isinstance(item, dict):
|
| 298 |
-
text = item.get("text")
|
| 299 |
-
if isinstance(text, str):
|
| 300 |
-
collected_text.append(text)
|
| 301 |
-
|
| 302 |
-
delta = choice.get("delta")
|
| 303 |
-
if isinstance(delta, dict):
|
| 304 |
-
content = delta.get("content")
|
| 305 |
-
if isinstance(content, str):
|
| 306 |
-
collected_text.append(content)
|
| 307 |
-
elif isinstance(content, list):
|
| 308 |
-
for item in content:
|
| 309 |
-
if isinstance(item, str):
|
| 310 |
-
collected_text.append(item)
|
| 311 |
-
elif isinstance(item, dict):
|
| 312 |
-
text = item.get("text")
|
| 313 |
-
if isinstance(text, str):
|
| 314 |
-
collected_text.append(text)
|
| 315 |
-
|
| 316 |
-
return "".join(collected_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
payg/media.py
DELETED
|
@@ -1,307 +0,0 @@
|
|
| 1 |
-
import base64
|
| 2 |
-
import json
|
| 3 |
-
import time
|
| 4 |
-
import uuid
|
| 5 |
-
from typing import Optional
|
| 6 |
-
import httpx
|
| 7 |
-
from fastapi import Header, HTTPException, Request
|
| 8 |
-
from fastapi.responses import JSONResponse, Response
|
| 9 |
-
from helper.payg import (
|
| 10 |
-
append_credit_ledger_entry,
|
| 11 |
-
calculate_audio_credit_cost,
|
| 12 |
-
calculate_image_credit_cost,
|
| 13 |
-
calculate_video_credit_cost,
|
| 14 |
-
enforce_payg_spam_limit,
|
| 15 |
-
get_payg_config,
|
| 16 |
-
require_payg_identity,
|
| 17 |
-
)
|
| 18 |
-
from . import router
|
| 19 |
-
from .common import INTERNAL_API_BASE, build_upstream_headers
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
@router.post("/images/generations")
|
| 23 |
-
async def v1_images_generations(
|
| 24 |
-
request: Request,
|
| 25 |
-
authorization: Optional[str] = Header(None),
|
| 26 |
-
x_client_id: Optional[str] = Header(None),
|
| 27 |
-
):
|
| 28 |
-
identity = await require_payg_identity(authorization)
|
| 29 |
-
config = await get_payg_config()
|
| 30 |
-
|
| 31 |
-
limit = int(config.get("limits", {}).get("requestsPerMinutePerIdentity", 120))
|
| 32 |
-
await enforce_payg_spam_limit(f"{identity['user_id']}:image", max(1, limit))
|
| 33 |
-
|
| 34 |
-
body = await request.json()
|
| 35 |
-
if not isinstance(body, dict):
|
| 36 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 37 |
-
|
| 38 |
-
n = int(body.get("n") or 1)
|
| 39 |
-
n = max(1, min(n, 4))
|
| 40 |
-
|
| 41 |
-
cost = calculate_image_credit_cost(n, config)
|
| 42 |
-
usage_key = f"usage:image:{uuid.uuid4().hex}"
|
| 43 |
-
|
| 44 |
-
await append_credit_ledger_entry(
|
| 45 |
-
user_id=identity["user_id"],
|
| 46 |
-
delta_credits=-cost,
|
| 47 |
-
entry_type="usage",
|
| 48 |
-
usage_kind="image",
|
| 49 |
-
units=float(n),
|
| 50 |
-
unit_label="images",
|
| 51 |
-
source="v1_image",
|
| 52 |
-
source_ref=usage_key,
|
| 53 |
-
idempotency_key=usage_key,
|
| 54 |
-
metadata={"count": n},
|
| 55 |
-
)
|
| 56 |
-
|
| 57 |
-
prompt = body.get("prompt")
|
| 58 |
-
if not isinstance(prompt, str) or not prompt.strip():
|
| 59 |
-
raise HTTPException(status_code=400, detail="prompt is required")
|
| 60 |
-
|
| 61 |
-
payload = {
|
| 62 |
-
"prompt": prompt,
|
| 63 |
-
"mode": body.get("mode"),
|
| 64 |
-
"image_urls": body.get("image_urls"),
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
try:
|
| 68 |
-
async with httpx.AsyncClient(timeout=300) as client:
|
| 69 |
-
upstream = await client.post(
|
| 70 |
-
f"{INTERNAL_API_BASE}/gen/image",
|
| 71 |
-
json=payload,
|
| 72 |
-
headers=build_upstream_headers(authorization, x_client_id),
|
| 73 |
-
)
|
| 74 |
-
|
| 75 |
-
if upstream.status_code >= 400:
|
| 76 |
-
await append_credit_ledger_entry(
|
| 77 |
-
user_id=identity["user_id"],
|
| 78 |
-
delta_credits=cost,
|
| 79 |
-
entry_type="adjustment",
|
| 80 |
-
usage_kind="image",
|
| 81 |
-
units=float(n),
|
| 82 |
-
unit_label="images",
|
| 83 |
-
source="v1_image_refund",
|
| 84 |
-
source_ref=usage_key,
|
| 85 |
-
idempotency_key=f"refund:{usage_key}",
|
| 86 |
-
metadata={"reason": "upstream_error"},
|
| 87 |
-
)
|
| 88 |
-
return JSONResponse(
|
| 89 |
-
status_code=upstream.status_code,
|
| 90 |
-
content={"error": "Image generation failed", "detail": upstream.text[:1000]},
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
b64_image = base64.b64encode(upstream.content).decode("ascii")
|
| 94 |
-
return JSONResponse(
|
| 95 |
-
status_code=200,
|
| 96 |
-
content={
|
| 97 |
-
"created": int(time.time()),
|
| 98 |
-
"data": [{"b64_json": b64_image, "mime_type": "image/jpeg"}],
|
| 99 |
-
"usage": {"payg_credits_charged": float(cost), "image_count": n},
|
| 100 |
-
},
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
except Exception as exc:
|
| 104 |
-
await append_credit_ledger_entry(
|
| 105 |
-
user_id=identity["user_id"],
|
| 106 |
-
delta_credits=cost,
|
| 107 |
-
entry_type="adjustment",
|
| 108 |
-
usage_kind="image",
|
| 109 |
-
units=float(n),
|
| 110 |
-
unit_label="images",
|
| 111 |
-
source="v1_image_refund",
|
| 112 |
-
source_ref=usage_key,
|
| 113 |
-
idempotency_key=f"fatal-refund:{usage_key}",
|
| 114 |
-
metadata={"reason": "exception", "detail": str(exc)[:300]},
|
| 115 |
-
)
|
| 116 |
-
raise HTTPException(status_code=502, detail="Image generation failed")
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
@router.post("/videos/generations")
|
| 120 |
-
async def v1_video_generations(
|
| 121 |
-
request: Request,
|
| 122 |
-
authorization: Optional[str] = Header(None),
|
| 123 |
-
x_client_id: Optional[str] = Header(None),
|
| 124 |
-
):
|
| 125 |
-
identity = await require_payg_identity(authorization)
|
| 126 |
-
config = await get_payg_config()
|
| 127 |
-
|
| 128 |
-
limit = int(config.get("limits", {}).get("requestsPerMinutePerIdentity", 120))
|
| 129 |
-
await enforce_payg_spam_limit(f"{identity['user_id']}:video", max(1, limit))
|
| 130 |
-
|
| 131 |
-
body = await request.json()
|
| 132 |
-
if not isinstance(body, dict):
|
| 133 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 134 |
-
|
| 135 |
-
seconds = int(body.get("duration") or 5)
|
| 136 |
-
seconds = max(1, min(seconds, 10))
|
| 137 |
-
|
| 138 |
-
cost = calculate_video_credit_cost(seconds, config)
|
| 139 |
-
usage_key = f"usage:video:{uuid.uuid4().hex}"
|
| 140 |
-
|
| 141 |
-
await append_credit_ledger_entry(
|
| 142 |
-
user_id=identity["user_id"],
|
| 143 |
-
delta_credits=-cost,
|
| 144 |
-
entry_type="usage",
|
| 145 |
-
usage_kind="video",
|
| 146 |
-
units=float(seconds),
|
| 147 |
-
unit_label="seconds",
|
| 148 |
-
source="v1_video",
|
| 149 |
-
source_ref=usage_key,
|
| 150 |
-
idempotency_key=usage_key,
|
| 151 |
-
metadata={"duration_seconds": seconds},
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
payload = {
|
| 155 |
-
"prompt": body.get("prompt"),
|
| 156 |
-
"ratio": body.get("ratio"),
|
| 157 |
-
"mode": body.get("mode"),
|
| 158 |
-
"duration": seconds,
|
| 159 |
-
"image_urls": body.get("image_urls"),
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
try:
|
| 163 |
-
async with httpx.AsyncClient(timeout=600) as client:
|
| 164 |
-
upstream = await client.post(
|
| 165 |
-
f"{INTERNAL_API_BASE}/gen/video",
|
| 166 |
-
json=payload,
|
| 167 |
-
headers=build_upstream_headers(authorization, x_client_id),
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
if upstream.status_code >= 400:
|
| 171 |
-
await append_credit_ledger_entry(
|
| 172 |
-
user_id=identity["user_id"],
|
| 173 |
-
delta_credits=cost,
|
| 174 |
-
entry_type="adjustment",
|
| 175 |
-
usage_kind="video",
|
| 176 |
-
units=float(seconds),
|
| 177 |
-
unit_label="seconds",
|
| 178 |
-
source="v1_video_refund",
|
| 179 |
-
source_ref=usage_key,
|
| 180 |
-
idempotency_key=f"refund:{usage_key}",
|
| 181 |
-
metadata={"reason": "upstream_error"},
|
| 182 |
-
)
|
| 183 |
-
return JSONResponse(
|
| 184 |
-
status_code=upstream.status_code,
|
| 185 |
-
content={"error": "Video generation failed", "detail": upstream.text[:1000]},
|
| 186 |
-
)
|
| 187 |
-
|
| 188 |
-
return Response(
|
| 189 |
-
content=upstream.content,
|
| 190 |
-
media_type="video/mp4",
|
| 191 |
-
headers={
|
| 192 |
-
"X-Payg-Credits-Charged": str(float(cost)),
|
| 193 |
-
"X-Payg-Units": str(seconds),
|
| 194 |
-
"X-Payg-Unit-Label": "seconds",
|
| 195 |
-
},
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
except Exception as exc:
|
| 199 |
-
await append_credit_ledger_entry(
|
| 200 |
-
user_id=identity["user_id"],
|
| 201 |
-
delta_credits=cost,
|
| 202 |
-
entry_type="adjustment",
|
| 203 |
-
usage_kind="video",
|
| 204 |
-
units=float(seconds),
|
| 205 |
-
unit_label="seconds",
|
| 206 |
-
source="v1_video_refund",
|
| 207 |
-
source_ref=usage_key,
|
| 208 |
-
idempotency_key=f"fatal-refund:{usage_key}",
|
| 209 |
-
metadata={"reason": "exception", "detail": str(exc)[:300]},
|
| 210 |
-
)
|
| 211 |
-
raise HTTPException(status_code=502, detail="Video generation failed")
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
@router.post("/audio/generations")
|
| 215 |
-
async def v1_audio_generations(
|
| 216 |
-
request: Request,
|
| 217 |
-
authorization: Optional[str] = Header(None),
|
| 218 |
-
x_client_id: Optional[str] = Header(None),
|
| 219 |
-
):
|
| 220 |
-
identity = await require_payg_identity(authorization)
|
| 221 |
-
config = await get_payg_config()
|
| 222 |
-
|
| 223 |
-
limit = int(config.get("limits", {}).get("requestsPerMinutePerIdentity", 120))
|
| 224 |
-
await enforce_payg_spam_limit(f"{identity['user_id']}:audio", max(1, limit))
|
| 225 |
-
|
| 226 |
-
body = await request.json()
|
| 227 |
-
if not isinstance(body, dict):
|
| 228 |
-
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 229 |
-
|
| 230 |
-
audio_type = "sfx"
|
| 231 |
-
|
| 232 |
-
default_seconds = int(config.get("pricing", {}).get("audioDefaultSeconds", 10))
|
| 233 |
-
seconds = int(body.get("duration_seconds") or default_seconds)
|
| 234 |
-
seconds = max(1, min(seconds, 90))
|
| 235 |
-
|
| 236 |
-
cost = calculate_audio_credit_cost(seconds, config)
|
| 237 |
-
usage_key = f"usage:audio:{uuid.uuid4().hex}"
|
| 238 |
-
|
| 239 |
-
await append_credit_ledger_entry(
|
| 240 |
-
user_id=identity["user_id"],
|
| 241 |
-
delta_credits=-cost,
|
| 242 |
-
entry_type="usage",
|
| 243 |
-
usage_kind="audio",
|
| 244 |
-
units=float(seconds),
|
| 245 |
-
unit_label="seconds",
|
| 246 |
-
source="v1_audio",
|
| 247 |
-
source_ref=usage_key,
|
| 248 |
-
idempotency_key=usage_key,
|
| 249 |
-
metadata={"duration_seconds": seconds, "audio_type": audio_type},
|
| 250 |
-
)
|
| 251 |
-
|
| 252 |
-
prompt = body.get("prompt")
|
| 253 |
-
if not isinstance(prompt, str) or not prompt.strip():
|
| 254 |
-
raise HTTPException(status_code=400, detail="prompt is required")
|
| 255 |
-
|
| 256 |
-
endpoint = "sfx"
|
| 257 |
-
|
| 258 |
-
try:
|
| 259 |
-
async with httpx.AsyncClient(timeout=300) as client:
|
| 260 |
-
upstream = await client.post(
|
| 261 |
-
f"{INTERNAL_API_BASE}/gen/{endpoint}",
|
| 262 |
-
json={"prompt": prompt},
|
| 263 |
-
headers=build_upstream_headers(authorization, x_client_id),
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
-
if upstream.status_code >= 400:
|
| 267 |
-
await append_credit_ledger_entry(
|
| 268 |
-
user_id=identity["user_id"],
|
| 269 |
-
delta_credits=cost,
|
| 270 |
-
entry_type="adjustment",
|
| 271 |
-
usage_kind="audio",
|
| 272 |
-
units=float(seconds),
|
| 273 |
-
unit_label="seconds",
|
| 274 |
-
source="v1_audio_refund",
|
| 275 |
-
source_ref=usage_key,
|
| 276 |
-
idempotency_key=f"refund:{usage_key}",
|
| 277 |
-
metadata={"reason": "upstream_error"},
|
| 278 |
-
)
|
| 279 |
-
return JSONResponse(
|
| 280 |
-
status_code=upstream.status_code,
|
| 281 |
-
content={"error": "Audio generation failed", "detail": upstream.text[:1000]},
|
| 282 |
-
)
|
| 283 |
-
|
| 284 |
-
return Response(
|
| 285 |
-
content=upstream.content,
|
| 286 |
-
media_type="audio/mpeg",
|
| 287 |
-
headers={
|
| 288 |
-
"X-Payg-Credits-Charged": str(float(cost)),
|
| 289 |
-
"X-Payg-Units": str(seconds),
|
| 290 |
-
"X-Payg-Unit-Label": "seconds",
|
| 291 |
-
},
|
| 292 |
-
)
|
| 293 |
-
|
| 294 |
-
except Exception as exc:
|
| 295 |
-
await append_credit_ledger_entry(
|
| 296 |
-
user_id=identity["user_id"],
|
| 297 |
-
delta_credits=cost,
|
| 298 |
-
entry_type="adjustment",
|
| 299 |
-
usage_kind="audio",
|
| 300 |
-
units=float(seconds),
|
| 301 |
-
unit_label="seconds",
|
| 302 |
-
source="v1_audio_refund",
|
| 303 |
-
source_ref=usage_key,
|
| 304 |
-
idempotency_key=f"fatal-refund:{usage_key}",
|
| 305 |
-
metadata={"reason": "exception", "detail": str(exc)[:300]},
|
| 306 |
-
)
|
| 307 |
-
raise HTTPException(status_code=502, detail="Audio generation failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
payg/stripe.py
DELETED
|
@@ -1,250 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import stripe
|
| 3 |
-
from decimal import Decimal
|
| 4 |
-
from fastapi import Request, HTTPException
|
| 5 |
-
from fastapi.responses import JSONResponse
|
| 6 |
-
from helper.payg import (
|
| 7 |
-
append_credit_ledger_entry,
|
| 8 |
-
get_pack_by_session_amount,
|
| 9 |
-
get_payg_config,
|
| 10 |
-
get_wallet_snapshot,
|
| 11 |
-
quantize_credits,
|
| 12 |
-
)
|
| 13 |
-
from helper.subscriptions import execute_query
|
| 14 |
-
from . import router
|
| 15 |
-
|
| 16 |
-
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
| 17 |
-
stripe.api_key = STRIPE_SECRET_KEY
|
| 18 |
-
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
@router.post("/stripe/webhook")
|
| 22 |
-
async def stripe_webhook(request: Request):
|
| 23 |
-
payload = await request.body()
|
| 24 |
-
sig_header = request.headers.get("stripe-signature")
|
| 25 |
-
|
| 26 |
-
try:
|
| 27 |
-
event = stripe.Webhook.construct_event(
|
| 28 |
-
payload=payload,
|
| 29 |
-
sig_header=sig_header,
|
| 30 |
-
secret=STRIPE_WEBHOOK_SECRET,
|
| 31 |
-
)
|
| 32 |
-
except stripe.error.SignatureVerificationError:
|
| 33 |
-
raise HTTPException(status_code=400, detail="Invalid Stripe signature")
|
| 34 |
-
|
| 35 |
-
if event["type"] == "checkout.session.completed":
|
| 36 |
-
session = event["data"]["object"]
|
| 37 |
-
checkout_session_id = session["id"]
|
| 38 |
-
session = session.to_dict()
|
| 39 |
-
|
| 40 |
-
payment_status = (session.get("payment_status") or "").lower()
|
| 41 |
-
mode = session.get("mode")
|
| 42 |
-
|
| 43 |
-
customer_email = (
|
| 44 |
-
(session.get("customer_details") or {}).get("email") or ""
|
| 45 |
-
).strip().lower()
|
| 46 |
-
|
| 47 |
-
if mode != "payment":
|
| 48 |
-
raise HTTPException(
|
| 49 |
-
status_code=400,
|
| 50 |
-
detail="Not a one-time payment session",
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
if payment_status != "paid":
|
| 54 |
-
return JSONResponse({"ok": True, "ignored": "not paid yet"})
|
| 55 |
-
|
| 56 |
-
if not customer_email:
|
| 57 |
-
raise HTTPException(
|
| 58 |
-
status_code=400,
|
| 59 |
-
detail="Missing customer email",
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
config = await get_payg_config()
|
| 63 |
-
|
| 64 |
-
user_row = await execute_query(
|
| 65 |
-
"""
|
| 66 |
-
SELECT id
|
| 67 |
-
FROM auth.users
|
| 68 |
-
WHERE lower(email) = %s
|
| 69 |
-
LIMIT 1;
|
| 70 |
-
""",
|
| 71 |
-
(customer_email,),
|
| 72 |
-
fetchone=True,
|
| 73 |
-
)
|
| 74 |
-
|
| 75 |
-
if not user_row:
|
| 76 |
-
raise HTTPException(
|
| 77 |
-
status_code=404,
|
| 78 |
-
detail="User not found",
|
| 79 |
-
)
|
| 80 |
-
|
| 81 |
-
user_id = str(user_row["id"])
|
| 82 |
-
|
| 83 |
-
line_items = stripe.checkout.Session.list_line_items(
|
| 84 |
-
checkout_session_id,
|
| 85 |
-
limit=100,
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
packs = config.get("billing", {}).get("packs", [])
|
| 89 |
-
|
| 90 |
-
granted_packs = []
|
| 91 |
-
total_credits = Decimal("0")
|
| 92 |
-
|
| 93 |
-
for item in line_items.data:
|
| 94 |
-
product_id = str(item.price.product)
|
| 95 |
-
quantity = int(item.quantity or 1)
|
| 96 |
-
|
| 97 |
-
pack = next(
|
| 98 |
-
(
|
| 99 |
-
p for p in packs
|
| 100 |
-
if str(p.get("prod")) == product_id
|
| 101 |
-
),
|
| 102 |
-
None,
|
| 103 |
-
)
|
| 104 |
-
|
| 105 |
-
if not pack:
|
| 106 |
-
continue
|
| 107 |
-
|
| 108 |
-
pack_credits = Decimal(str(pack.get("credits") or 0))
|
| 109 |
-
|
| 110 |
-
if pack_credits <= 0:
|
| 111 |
-
continue
|
| 112 |
-
|
| 113 |
-
granted = pack_credits * Decimal(str(quantity))
|
| 114 |
-
total_credits += granted
|
| 115 |
-
|
| 116 |
-
granted_packs.append({
|
| 117 |
-
"pack_id": pack.get("id"),
|
| 118 |
-
"product_id": product_id,
|
| 119 |
-
"quantity": quantity,
|
| 120 |
-
"credits": float(granted),
|
| 121 |
-
})
|
| 122 |
-
|
| 123 |
-
if total_credits <= 0:
|
| 124 |
-
raise HTTPException(
|
| 125 |
-
status_code=400,
|
| 126 |
-
detail="No valid packs found in checkout session",
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
idempotency_key = f"stripe_checkout:{checkout_session_id}"
|
| 130 |
-
|
| 131 |
-
entry = await append_credit_ledger_entry(
|
| 132 |
-
user_id=user_id,
|
| 133 |
-
delta_credits=quantize_credits(total_credits),
|
| 134 |
-
entry_type="purchase",
|
| 135 |
-
usage_kind=None,
|
| 136 |
-
units=float(total_credits),
|
| 137 |
-
unit_label="credits",
|
| 138 |
-
source="stripe_checkout_session",
|
| 139 |
-
source_ref=checkout_session_id,
|
| 140 |
-
idempotency_key=idempotency_key,
|
| 141 |
-
metadata={
|
| 142 |
-
"email": customer_email,
|
| 143 |
-
"packs": granted_packs,
|
| 144 |
-
},
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
wallet = await get_wallet_snapshot(user_id)
|
| 148 |
-
|
| 149 |
-
return {
|
| 150 |
-
"ok": True,
|
| 151 |
-
"user_id": user_id,
|
| 152 |
-
"email": customer_email,
|
| 153 |
-
"granted_credits": float(total_credits),
|
| 154 |
-
"packs": granted_packs,
|
| 155 |
-
"wallet": wallet,
|
| 156 |
-
"ledger": entry,
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
return JSONResponse({"ok": True})
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
@router.get("/stripe/reconcile/{session_id}")
|
| 163 |
-
async def reconcile_checkout_session(session_id: str):
|
| 164 |
-
try:
|
| 165 |
-
session = stripe.checkout.Session.retrieve(
|
| 166 |
-
session_id,
|
| 167 |
-
expand=[
|
| 168 |
-
"payment_intent.latest_charge",
|
| 169 |
-
"invoice",
|
| 170 |
-
"subscription.latest_invoice"
|
| 171 |
-
]
|
| 172 |
-
)
|
| 173 |
-
|
| 174 |
-
line_items = stripe.checkout.Session.list_line_items(session_id, limit=1)
|
| 175 |
-
item = line_items.data[0] if line_items.data else None
|
| 176 |
-
|
| 177 |
-
receipt_url = None
|
| 178 |
-
payment_method_info = None
|
| 179 |
-
|
| 180 |
-
def parse_payment_method(charge):
|
| 181 |
-
if not charge or not getattr(charge, "payment_method_details", None):
|
| 182 |
-
return {"type": "unknown", "brand": None, "last4": None}
|
| 183 |
-
pm = charge.payment_method_details
|
| 184 |
-
return {
|
| 185 |
-
"type": pm.type,
|
| 186 |
-
"brand": getattr(pm.card, "brand", None) if pm.type == "card" else None,
|
| 187 |
-
"last4": getattr(pm.card, "last4", None) if pm.type == "card" else None,
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
if session.mode == "payment":
|
| 191 |
-
invoice = session.invoice
|
| 192 |
-
payment_intent = session.payment_intent
|
| 193 |
-
|
| 194 |
-
if invoice and not isinstance(invoice, str):
|
| 195 |
-
receipt_url = invoice.hosted_invoice_url or invoice.invoice_pdf
|
| 196 |
-
|
| 197 |
-
if payment_intent and not isinstance(payment_intent, str):
|
| 198 |
-
charge = getattr(payment_intent, "latest_charge", None)
|
| 199 |
-
if charge and not isinstance(charge, str):
|
| 200 |
-
receipt_url = charge.receipt_url or receipt_url
|
| 201 |
-
payment_method_info = parse_payment_method(charge)
|
| 202 |
-
|
| 203 |
-
elif session.amount_total > 0:
|
| 204 |
-
return {
|
| 205 |
-
"ok": False,
|
| 206 |
-
"status": session.status,
|
| 207 |
-
"payment_status": session.payment_status,
|
| 208 |
-
"message": "Payment not completed yet or still processing."
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
elif session.mode == "subscription":
|
| 212 |
-
subscription = session.subscription
|
| 213 |
-
if subscription and not isinstance(subscription, str) and subscription.latest_invoice:
|
| 214 |
-
invoice = subscription.latest_invoice
|
| 215 |
-
if not isinstance(invoice, str):
|
| 216 |
-
receipt_url = invoice.hosted_invoice_url or invoice.invoice_pdf
|
| 217 |
-
|
| 218 |
-
try:
|
| 219 |
-
full_invoice = stripe.Invoice.retrieve(invoice.id, expand=["latest_charge"])
|
| 220 |
-
charge = getattr(full_invoice, "latest_charge", None)
|
| 221 |
-
if charge and not isinstance(charge, str):
|
| 222 |
-
payment_method_info = parse_payment_method(charge)
|
| 223 |
-
except Exception:
|
| 224 |
-
pass
|
| 225 |
-
|
| 226 |
-
if not payment_method_info:
|
| 227 |
-
payment_method_info = {"type": "subscription", "brand": None, "last4": None}
|
| 228 |
-
|
| 229 |
-
else:
|
| 230 |
-
payment_method_info = {"type": session.mode, "brand": None, "last4": None}
|
| 231 |
-
|
| 232 |
-
return {
|
| 233 |
-
"ok": True,
|
| 234 |
-
"session_id": session.id,
|
| 235 |
-
"mode": session.mode,
|
| 236 |
-
"free_checkout": session.amount_total == 0,
|
| 237 |
-
"amount_total": session.amount_total,
|
| 238 |
-
"currency": session.currency,
|
| 239 |
-
"customer_email": session.customer_details.email if session.customer_details else None,
|
| 240 |
-
"product": {
|
| 241 |
-
"name": item.description if item else None,
|
| 242 |
-
"quantity": item.quantity if item else None,
|
| 243 |
-
"amount_each": item.amount_total if item else (0 if session.amount_total == 0 else None),
|
| 244 |
-
},
|
| 245 |
-
"payment_method": payment_method_info,
|
| 246 |
-
"receipt_url": receipt_url,
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
except Exception as e:
|
| 250 |
-
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
prod-ca-2021.crt
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 1 |
-
-----BEGIN CERTIFICATE-----
|
| 2 |
-
MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
|
| 3 |
-
BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
|
| 4 |
-
dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
|
| 5 |
-
c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
|
| 6 |
-
azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
|
| 7 |
-
YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
|
| 8 |
-
Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
|
| 9 |
-
QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
|
| 10 |
-
DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
|
| 11 |
-
GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
|
| 12 |
-
cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
|
| 13 |
-
O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
|
| 14 |
-
NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
|
| 15 |
-
uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
|
| 16 |
-
aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
|
| 17 |
-
tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
|
| 18 |
-
VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
|
| 19 |
-
jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
|
| 20 |
-
Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
|
| 21 |
-
CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
|
| 22 |
-
o/bKiIz+Fq8=
|
| 23 |
-
-----END CERTIFICATE-----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn[standard]
|
| 3 |
-
httpx
|
| 4 |
-
beautifulsoup4
|
| 5 |
-
supabase
|
| 6 |
-
psycopg[binary]
|
| 7 |
-
pillow
|
| 8 |
-
stripe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status.py
DELETED
|
@@ -1,69 +0,0 @@
|
|
| 1 |
-
# status.py
|
| 2 |
-
from fastapi import APIRouter, Response
|
| 3 |
-
from fastapi.responses import JSONResponse
|
| 4 |
-
|
| 5 |
-
router = APIRouter(prefix="/status")
|
| 6 |
-
|
| 7 |
-
@router.head("/sfx")
|
| 8 |
-
async def head_sfx():
|
| 9 |
-
return Response(
|
| 10 |
-
status_code=200,
|
| 11 |
-
headers={
|
| 12 |
-
"Content-Type": "audio/mpeg",
|
| 13 |
-
"Accept-Ranges": "bytes",
|
| 14 |
-
},
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
@router.head("/image")
|
| 18 |
-
async def head_image():
|
| 19 |
-
return Response(
|
| 20 |
-
status_code=200,
|
| 21 |
-
headers={
|
| 22 |
-
"Content-Type": "image/jpeg",
|
| 23 |
-
"Accept-Ranges": "bytes",
|
| 24 |
-
},
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
@router.head("/video")
|
| 28 |
-
async def head_video():
|
| 29 |
-
return Response(
|
| 30 |
-
status_code=200,
|
| 31 |
-
headers={
|
| 32 |
-
"Content-Type": "video/mp4",
|
| 33 |
-
"Accept-Ranges": "bytes",
|
| 34 |
-
},
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
@router.head("/text")
|
| 38 |
-
async def head_text():
|
| 39 |
-
return Response(
|
| 40 |
-
status_code=200,
|
| 41 |
-
headers={
|
| 42 |
-
"Content-Type": "application/json",
|
| 43 |
-
"Accept-Ranges": "bytes",
|
| 44 |
-
},
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
@router.get("/")
|
| 48 |
-
async def get_status():
|
| 49 |
-
notify = ""
|
| 50 |
-
services = {
|
| 51 |
-
"Video Generation": {"code": 200, "state": "ok", "message": "Running Normally"},
|
| 52 |
-
"Image Generation": {"code": 200, "state": "ok", "message": "Running Normally"},
|
| 53 |
-
"Lightning-Text v2": {"code": 200, "state": "ok", "message": "Running normally"},
|
| 54 |
-
"Music/SFX Generation": {"code": 200, "state": "ok", "message": "Running normally"},
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
overall_state = (
|
| 58 |
-
"ok" if all(s["state"] == "ok" for s in services.values()) else "degraded"
|
| 59 |
-
)
|
| 60 |
-
|
| 61 |
-
return JSONResponse(
|
| 62 |
-
status_code=200,
|
| 63 |
-
content={
|
| 64 |
-
"state": overall_state,
|
| 65 |
-
"services": services,
|
| 66 |
-
"notifications": notify,
|
| 67 |
-
"latest": "2.12.0",
|
| 68 |
-
},
|
| 69 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|