datfid-master / main.py
DrValera's picture
Added nonlinear effects
0985625 verified
Raw
History Blame Contribute Delete
40.4 kB
import os, asyncio, io
import httpx
import pandas as pd
from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form, Body
from fastapi.responses import JSONResponse, Response, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
# --- Secrets set in the PUBLIC Space ---
UPSTREAM_URL = os.environ.get("hf_url", "").rstrip("/") # url to access Private Space
HF_TOKEN = os.environ.get("hf_token") # HF access token to access Private Space
DEMO_FORWARD_URL = os.getenv("DEMO_FORWARD_URL", "").rstrip("/") # url to acces demo space
DATFID_DEMO_TOKEN = os.getenv("DATFID_DEMO_TOKEN", "") # token to access demo space
# This Space's public URL (used to ping self while waiting so HF does not put this Space to sleep). Override with SELF_URL env if different.
SELF_URL = os.getenv("SELF_URL", "https://datfid-org-datfid-master.hf.space").rstrip("/")
# Admin-only endpoints: require this secret (send as X-Admin-Pass header). Secret name in HF: Admin_pass
Admin_pass = os.getenv("Admin_pass", "")
if not HF_TOKEN:
raise RuntimeError("Missing secret 'hf_token' in public Space.")
if not UPSTREAM_URL.startswith("https://"):
raise RuntimeError("Missing/invalid secret 'hf_url' in public Space.")
app = FastAPI(title="DATFID Public Proxy", docs_url="/docs", redoc_url=None)
# Prefer X-Forwarded-For (HF sits behind a proxy)
def client_ip(request: Request):
xff = request.headers.get("x-forwarded-for")
return xff.split(",")[0].strip() if xff else (request.client.host or "0.0.0.0")
limiter = Limiter(key_func=client_ip) # or get_remote_address
GLOBAL_LIMIT = limiter.limit("100/10minute", key_func=lambda: "global:any") # global limit
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
@app.exception_handler(RateLimitExceeded)
async def ratelimit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(status_code=429, content={"detail": "Too many requests, slow down."})
SDK_MAX_BODY_BYTES = int(os.getenv("SDK_MAX_BODY_BYTES", "25000000")) # 25MB default for demo
SDK_MAX_BODY_BYTES_extended = int(os.getenv("SDK_MAX_BODY_BYTES_extended", "125000000")) # 125MB default for prod
# How long to wait for upstream (API) response. modelforecast_ind on many individuals can take 60–90 min; set UPSTREAM_TIMEOUT high (e.g. 5400) or leave default.
UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT", "900")) # 15 min default so long forecast_ind runs don't hit ReadTimeout
# While waiting for a response: every PING_INTERVAL seconds we ping the backend (secure_ping) and, if SELF_URL is set, we also ping ourselves (keep-alive) so this Space stays awake
PING_INTERVAL = float(os.getenv("PING_INTERVAL", "270"))
# Early global body-size guard (runs before routes)
@app.middleware("http")
async def limit_body_size(request: Request, call_next):
cl = request.headers.get("content-length")
# demo routes use the smaller cap, others use extended
path = request.url.path or ""
cap = SDK_MAX_BODY_BYTES if "-demo" in path else SDK_MAX_BODY_BYTES_extended
if cl and int(cl) > cap:
return JSONResponse(
{"detail": f"Payload too large (> {cap} bytes)"},
status_code=413,
)
return await call_next(request)
# CORS for browser
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://datfid.com",
"https://www.datfid.com"
],
# Optional: allow Vercel preview domains
# allow_origin_regex=r"^https:\/\/.*\.vercel\.app$",
allow_methods=["POST", "OPTIONS"],
allow_headers=["Content-Type"], # no Authorization header needed from browser
allow_credentials=False,
max_age=86400,
)
# to ensure we don’t leak hop-by-hop headers
def _filter_resp_headers(headers: dict) -> dict:
# pass through useful headers but strip hop-by-hop
allowed = {"content-disposition"}
return {k: v for k, v in headers.items() if k.lower() in allowed}
async def _ping_upstream_loop(ping_url: str, headers: dict, interval: float, self_url: str = ""):
"""Every `interval` seconds: if self_url set, GET self_url/keep-alive (keep this Space awake), then GET ping_url (backend). Stops when cancelled."""
if interval <= 0:
return
while True:
await asyncio.sleep(interval)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
if self_url:
try:
await client.get(f"{self_url}/keep-alive")
except Exception:
pass
await client.get(ping_url, headers=headers)
except asyncio.CancelledError:
break
except Exception:
pass
def _start_ping_task(ping_url: str, headers: dict, self_url: str = ""):
"""Start background ping task: every PING_INTERVAL seconds ping backend and, if self_url set, also ping self."""
if PING_INTERVAL <= 0:
return None
return asyncio.create_task(_ping_upstream_loop(ping_url, headers, PING_INTERVAL, self_url))
async def _cancel_ping_task(task: asyncio.Task | None):
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def _extract_user_token(req: Request) -> str | None:
"""
Read user's DATFID token from Authorization: Bearer <dt+...>.
We do NOT verify it here; the private Space does that.
"""
auth = req.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return None
return auth.split(" ", 1)[1].strip()
# Chat endpoints: fetch data from URL, parse to table, forward to API
DATA_URL_FETCH_TIMEOUT = 60.0
DATA_URL_MAX_BYTES = 20 * 1024 * 1024 # 20MB
async def _fetch_url_to_records(data_url: str) -> list:
"""Fetch data_url (https only), parse CSV/Excel/JSON to list of dicts. Raises HTTPException on error."""
if not data_url.strip().lower().startswith("https://"):
raise HTTPException(status_code=400, detail="data_url must be an HTTPS URL.")
async with httpx.AsyncClient(timeout=DATA_URL_FETCH_TIMEOUT, follow_redirects=True) as client:
r = await client.get(data_url)
r.raise_for_status()
raw = r.content
content_type = (r.headers.get("content-type") or "").lower()
if len(raw) > DATA_URL_MAX_BYTES:
raise HTTPException(status_code=413, detail=f"Data at URL exceeds {DATA_URL_MAX_BYTES // (1024*1024)}MB limit.")
path_lower = data_url.split("?")[0].lower()
try:
if "json" in content_type or path_lower.endswith(".json"):
df = pd.read_json(io.BytesIO(raw))
elif "spreadsheet" in content_type or "excel" in content_type or path_lower.endswith((".xlsx", ".xls")):
df = pd.read_excel(io.BytesIO(raw))
else:
# CSV or default
df = pd.read_csv(io.BytesIO(raw))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not parse data from URL: {str(e)[:200]}")
for col in df.columns:
if pd.api.types.is_datetime64_any_dtype(df[col]):
df[col] = df[col].astype(str)
return df.to_dict(orient="records")
async def _fetch_url_to_dataframe(data_url: str) -> pd.DataFrame:
"""Fetch data_url (https only), parse CSV/Excel/JSON to DataFrame. Raises HTTPException on error."""
if not data_url.strip().lower().startswith("https://"):
raise HTTPException(status_code=400, detail="data_url must be an HTTPS URL.")
async with httpx.AsyncClient(timeout=DATA_URL_FETCH_TIMEOUT, follow_redirects=True) as client:
r = await client.get(data_url)
r.raise_for_status()
raw = r.content
content_type = (r.headers.get("content-type") or "").lower()
if len(raw) > DATA_URL_MAX_BYTES:
raise HTTPException(status_code=413, detail=f"Data at URL exceeds {DATA_URL_MAX_BYTES // (1024*1024)}MB limit.")
path_lower = data_url.split("?")[0].lower()
try:
if "json" in content_type or path_lower.endswith(".json"):
df = pd.read_json(io.BytesIO(raw))
elif "spreadsheet" in content_type or "excel" in content_type or path_lower.endswith((".xlsx", ".xls")):
df = pd.read_excel(io.BytesIO(raw))
else:
df = pd.read_csv(io.BytesIO(raw))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not parse data from URL: {str(e)[:200]}")
for col in df.columns:
if pd.api.types.is_datetime64_any_dtype(df[col]):
df[col] = df[col].astype(str)
return df
async def _forward(path: str, method: str = "GET", json_body=None, user_token: str | None = None, success_response_override: dict | None = None):
"""
Forward request to the PRIVATE Space:
- 'Authorization: Bearer <HF_TOKEN>' to pass HF private gate
- 'X-API-Key: <dt+...>' so your private app can validate the user token
While waiting, every PING_INTERVAL seconds: secure_ping to backend and, if SELF_URL set, ping self (keep-alive).
"""
url = f"{UPSTREAM_URL}{path}"
headers = {
"Authorization": f"Bearer {HF_TOKEN}",
"Accept": "application/json",
}
if user_token:
headers["X-API-Key"] = user_token
# While waiting: ping backend (secure_ping) and, if SELF_URL set, ping self so this Space stays awake
ping_url = f"{UPSTREAM_URL}/secure-ping/"
ping_headers = {"Authorization": f"Bearer {HF_TOKEN}"}
if user_token:
ping_headers["X-API-Key"] = user_token
timeout = httpx.Timeout(UPSTREAM_TIMEOUT)
r = None
async def do_request():
nonlocal r
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.request(method, url, headers=headers, json=json_body)
request_task = asyncio.create_task(do_request())
wait_sec = PING_INTERVAL if PING_INTERVAL > 0 else 0.0
try:
while not request_task.done():
if wait_sec <= 0:
await request_task
break
ping_sleep = asyncio.create_task(asyncio.sleep(wait_sec))
done, pending = await asyncio.wait(
{request_task, ping_sleep},
return_when=asyncio.FIRST_COMPLETED,
timeout=UPSTREAM_TIMEOUT + 10,
)
for t in pending:
if t is not request_task:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
if request_task in done:
break
async with httpx.AsyncClient(timeout=10.0) as c:
if SELF_URL:
try:
await c.get(f"{SELF_URL}/keep-alive")
except Exception:
pass
try:
await c.get(ping_url, headers=ping_headers)
except Exception:
pass
if not request_task.done():
request_task.cancel()
try:
await request_task
except asyncio.CancelledError:
pass
await request_task
except asyncio.CancelledError:
request_task.cancel()
try:
await request_task
except asyncio.CancelledError:
pass
raise
exc = request_task.exception()
if exc is not None:
raise exc
if r is None:
raise RuntimeError("Upstream request did not complete")
# When caller wants a minimal response on success (e.g. for chat to avoid response size limits)
if success_response_override is not None and r.status_code == 200:
return JSONResponse(status_code=200, content=success_response_override)
ct = r.headers.get("content-type", "")
if "application/json" in ct:
try:
return JSONResponse(status_code=r.status_code, content=r.json())
except Exception:
return JSONResponse(status_code=r.status_code, content={"error": r.text[:500]})
# Fallback: return short text envelope if non-JSON
return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]})
async def _forward_and_get_body(path: str, method: str = "GET", json_body=None, user_token: str | None = None) -> tuple[int, dict | list | None]:
"""Same as _forward but returns (status_code, parsed_json_body) so caller can build a custom response."""
url = f"{UPSTREAM_URL}{path}"
headers = {
"Authorization": f"Bearer {HF_TOKEN}",
"Accept": "application/json",
}
if user_token:
headers["X-API-Key"] = user_token
ping_url = f"{UPSTREAM_URL}/secure-ping/"
ping_headers = {"Authorization": f"Bearer {HF_TOKEN}"}
if user_token:
ping_headers["X-API-Key"] = user_token
timeout = httpx.Timeout(UPSTREAM_TIMEOUT)
r = None
async def do_request():
nonlocal r
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.request(method, url, headers=headers, json=json_body)
request_task = asyncio.create_task(do_request())
wait_sec = PING_INTERVAL if PING_INTERVAL > 0 else 0.0
try:
while not request_task.done():
if wait_sec <= 0:
await request_task
break
ping_sleep = asyncio.create_task(asyncio.sleep(wait_sec))
done, pending = await asyncio.wait(
{request_task, ping_sleep},
return_when=asyncio.FIRST_COMPLETED,
timeout=UPSTREAM_TIMEOUT + 10,
)
for t in pending:
if t is not request_task:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
if request_task in done:
break
async with httpx.AsyncClient(timeout=10.0) as c:
if SELF_URL:
try:
await c.get(f"{SELF_URL}/keep-alive")
except Exception:
pass
try:
await c.get(ping_url, headers=ping_headers)
except Exception:
pass
if not request_task.done():
request_task.cancel()
try:
await request_task
except asyncio.CancelledError:
pass
await request_task
except asyncio.CancelledError:
request_task.cancel()
try:
await request_task
except asyncio.CancelledError:
pass
raise
exc = request_task.exception()
if exc is not None:
raise exc
if r is None:
raise RuntimeError("Upstream request did not complete")
ct = r.headers.get("content-type", "")
if "application/json" in ct:
try:
return (r.status_code, r.json())
except Exception:
return (r.status_code, {"error": r.text[:500]})
return (r.status_code, {"text": r.text[:1000]})
def _round_val(x, ndigits: int = 4):
"""Round numbers to ndigits; leave non-numbers as-is."""
if isinstance(x, (int, float)) and not isinstance(x, bool):
return round(float(x), ndigits)
return x
# Row labels for alpha/beta: Estimate, std.er., t_stat, p_val (backend order = _ROW4)
_FIT_ROW_LABELS = ("Estimate", "std.er.", "t_stat", "p_val")
def _format_fit_table(rows: list, ndigits: int = 4) -> list:
"""
Transform backend table (list of 4 dicts, keys=columns) into chat-friendly format.
Row 0: column names -> rounded values (Estimate). Rows 1,2,3: row label -> value(s).
For single column: [{'col': v0}, {'std.er.': v1}, {'t_stat': v2}, {'p_val': v3}].
For multi-column: row 0 = {col: v}; rows 1,2,3 = {'std.er.': [v,...]}, etc.
"""
if not isinstance(rows, list) or len(rows) != 4:
return rows
col_names = list(rows[0].keys()) if rows[0] else []
out = []
# Row 0: Estimate (column names as keys)
out.append({k: _round_val(rows[0].get(k)) for k in col_names})
# Rows 1,2,3: row name as key
for r in range(1, 4):
label = _FIT_ROW_LABELS[r]
vals = [rows[r].get(k) for k in col_names]
if len(col_names) == 1:
out.append({label: _round_val(vals[0])})
else:
out.append({label: [_round_val(v) for v in vals]})
return out
def _build_modelfit_chat_minimal(body: dict) -> dict:
"""Extract formula, alpha, beta, and Performance subset (col 2, rows 3,4,5) for chat response."""
out = {"ok": True, "formula": body.get("formula")}
alpha = body.get("alpha")
beta = body.get("beta")
out["alpha"] = _format_fit_table(alpha) if isinstance(alpha, list) else alpha
out["beta"] = _format_fit_table(beta) if isinstance(beta, list) else beta
perf = body.get("Performance")
if isinstance(perf, list) and len(perf) >= 5:
keys = list(perf[0].keys()) if perf[0] else []
col_idx = 1 if len(keys) > 1 else 0
col2_name = keys[col_idx] if keys else None
if col2_name is not None:
row_names = ["R2 overall", "MSE", "MAE"]
values = [_round_val(perf[i].get(col2_name)) for i in [2, 3, 4]]
out["Performance_subset"] = {"metric_names": row_names, "values": values}
else:
out["Performance_subset"] = None
else:
out["Performance_subset"] = None
return out
async def _forward_stream(path: str, files=None, data=None, user_token: str | None = None, method: str = "POST"):
url = f"{UPSTREAM_URL}{path}"
headers = {
"Authorization": f"Bearer {HF_TOKEN}",
"Accept": "*/*",
}
if user_token:
headers["X-API-Key"] = user_token
ping_headers = {"Authorization": f"Bearer {HF_TOKEN}"}
if user_token:
ping_headers["X-API-Key"] = user_token
ping_task = _start_ping_task(f"{UPSTREAM_URL}/secure-ping/", ping_headers, SELF_URL)
timeout = httpx.Timeout(UPSTREAM_TIMEOUT)
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
# Don't open the context yet; the iterator must own the context lifetime.
stream_ctx = client.stream(method, url, headers=headers, files=files, data=data)
# Mutable holders we can fill once the stream opens
status_holder = {"code": 200}
media_type_holder = {"ct": "application/octet-stream"}
headers_holder = {}
async def body_iter():
chunk_queue: asyncio.Queue = asyncio.Queue()
stream_done = {"done": False, "exc": None}
async def stream_reader():
try:
async with stream_ctx as resp:
status_holder["code"] = resp.status_code
media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream")
headers_holder.update(_filter_resp_headers(resp.headers))
if resp.status_code >= 400:
chunk = await resp.aread()
await chunk_queue.put(chunk)
await chunk_queue.put(None)
return
async for chunk in resp.aiter_raw():
await chunk_queue.put(chunk)
await chunk_queue.put(None)
except Exception as e:
stream_done["exc"] = e
await chunk_queue.put(None)
finally:
stream_done["done"] = True
reader_task = asyncio.create_task(stream_reader())
try:
while True:
chunk = await chunk_queue.get()
if chunk is None:
if stream_done["exc"]:
raise stream_done["exc"]
break
yield chunk
finally:
reader_task.cancel()
try:
await reader_task
except asyncio.CancelledError:
pass
await _cancel_ping_task(ping_task)
await client.aclose()
response = StreamingResponse(
body_iter(),
status_code=status_holder["code"],
media_type=media_type_holder["ct"],
headers=headers_holder,
)
return response
async def _forward_multipart_json(path: str, files=None, data=None, user_token: str | None = None, method: str = "POST"):
"""POST multipart to upstream and return JSON. While waiting: secure_ping to backend; if SELF_URL set, ping self."""
url = f"{UPSTREAM_URL}{path}"
headers = {
"Authorization": f"Bearer {HF_TOKEN}",
"Accept": "application/json",
}
if user_token:
headers["X-API-Key"] = user_token
ping_url = f"{UPSTREAM_URL}/secure-ping/"
ping_headers = {"Authorization": f"Bearer {HF_TOKEN}"}
if user_token:
ping_headers["X-API-Key"] = user_token
timeout = httpx.Timeout(UPSTREAM_TIMEOUT)
r = None
async def do_request():
nonlocal r
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.request(method, url, headers=headers, files=files, data=data)
request_task = asyncio.create_task(do_request())
wait_sec = PING_INTERVAL if PING_INTERVAL > 0 else 0.0
try:
while not request_task.done():
if wait_sec <= 0:
await request_task
break
ping_sleep = asyncio.create_task(asyncio.sleep(wait_sec))
done, pending = await asyncio.wait(
{request_task, ping_sleep},
return_when=asyncio.FIRST_COMPLETED,
timeout=UPSTREAM_TIMEOUT + 10,
)
for t in pending:
if t is not request_task:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
if request_task in done:
break
async with httpx.AsyncClient(timeout=10.0) as c:
if SELF_URL:
try:
await c.get(f"{SELF_URL}/keep-alive")
except Exception:
pass
try:
await c.get(ping_url, headers=ping_headers)
except Exception:
pass
if not request_task.done():
request_task.cancel()
try:
await request_task
except asyncio.CancelledError:
pass
await request_task
except asyncio.CancelledError:
request_task.cancel()
try:
await request_task
except asyncio.CancelledError:
pass
raise
exc = request_task.exception()
if exc is not None:
raise exc
if r is None:
raise RuntimeError("Upstream request did not complete")
ct = r.headers.get("content-type", "")
if "application/json" in ct:
try:
return JSONResponse(status_code=r.status_code, content=r.json())
except Exception:
return JSONResponse(status_code=r.status_code, content={"error": r.text[:500]})
return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]})
# for demo
async def _forward_demo_stream(path: str, *, files: dict | None, data: dict | None, method: str = "POST"):
if not DEMO_FORWARD_URL or not HF_TOKEN or not DATFID_DEMO_TOKEN:
raise HTTPException(status_code=500, detail="Demo not configured.")
url = DEMO_FORWARD_URL + path
headers = {
# HF private-space gate:
"Authorization": f"Bearer {HF_TOKEN}",
# App-level demo token (checked by the private API):
"X-DATFID-Token": DATFID_DEMO_TOKEN,
"Accept": "*/*",
}
ping_headers = {"Authorization": f"Bearer {HF_TOKEN}", "X-DATFID-Token": DATFID_DEMO_TOKEN}
ping_task = _start_ping_task(f"{DEMO_FORWARD_URL.rstrip('/')}/", ping_headers)
timeout = httpx.Timeout(120.0)
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
stream_ctx = client.stream(method, url, headers=headers, files=files, data=data)
status_holder = {"code": 200}
media_type_holder = {"ct": "application/octet-stream"}
headers_holder = {}
async def body_iter():
chunk_queue: asyncio.Queue = asyncio.Queue()
stream_done = {"done": False, "exc": None}
async def stream_reader():
try:
async with stream_ctx as resp:
status_holder["code"] = resp.status_code
media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream")
headers_holder.update(_filter_resp_headers(resp.headers))
if resp.status_code >= 400:
chunk = await resp.aread()
await chunk_queue.put(chunk)
await chunk_queue.put(None)
return
async for chunk in resp.aiter_raw():
await chunk_queue.put(chunk)
await chunk_queue.put(None)
except Exception as e:
stream_done["exc"] = e
await chunk_queue.put(None)
finally:
stream_done["done"] = True
reader_task = asyncio.create_task(stream_reader())
try:
while True:
chunk = await chunk_queue.get()
if chunk is None:
if stream_done["exc"]:
raise stream_done["exc"]
break
yield chunk
finally:
reader_task.cancel()
try:
await reader_task
except asyncio.CancelledError:
pass
await _cancel_ping_task(ping_task)
await client.aclose()
response = StreamingResponse(
body_iter(),
status_code=status_holder["code"],
media_type=media_type_holder["ct"],
headers=headers_holder,
)
return response
@app.get("/")
async def root(req: Request):
# Forward to private root (private gate still needs HF token)
user_token = _extract_user_token(req) # optional here
return await _forward("/", "GET", user_token=user_token)
@app.get("/keep-alive")
async def keep_alive():
"""
Hit this to keep this Space (datfid_master) and the backend awake.
- External cron (e.g. every 4 min): UptimeRobot, cron-job.org — keeps both spaces awake when idle.
- During a long request (>5 min): the client can call this in a background thread every ~4 min
so HF does not put this Space to sleep while it is waiting for the backend (no response
sent to the client yet). This endpoint also pings the upstream (backend). No auth required.
"""
try:
url = f"{UPSTREAM_URL}/"
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(url, headers={"Authorization": f"Bearer {HF_TOKEN}"})
return JSONResponse(content={"ok": True, "upstream_status": r.status_code})
except Exception as e:
return JSONResponse(status_code=502, content={"ok": False, "error": str(e)[:200]})
@app.get("/secure-ping/")
async def secure_ping(req: Request):
# Require user's DATFID token in Authorization: Bearer <dt+...>
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
return await _forward("/secure-ping/", "GET", user_token=user_token)
def _require_admin_token(request: Request) -> None:
"""Raise 401 if Admin_pass is not set or X-Admin-Pass does not match."""
if not Admin_pass:
raise HTTPException(status_code=503, detail="Admin endpoints not configured (set Admin_pass in secrets).")
token = request.headers.get("X-Admin-Pass", "").strip()
if not token or token != Admin_pass:
raise HTTPException(status_code=401, detail="Invalid or missing X-Admin-Pass.")
@app.get("/admin/verify")
async def admin_verify(req: Request):
"""Verify DATFID token (Authorization: Bearer) and admin secret. Returns 200 + {ok: true}."""
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...).")
_require_admin_token(req)
return JSONResponse(content={"ok": True})
@app.post("/modelfit/")
async def modelfit(req: Request):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
body = await req.json()
return await _forward("/modelfit/", "POST", json_body=body, user_token=user_token)
@app.post("/modelforecast/")
async def modelforecast(req: Request):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
body = await req.json()
return await _forward("/modelforecast/", "POST", json_body=body, user_token=user_token)
@app.post("/modelfit_chat/")
async def modelfit_chat(
req: Request,
data_url: str = Body(..., embed=True),
):
"""
Fetch training data from data_url (HTTPS), infer schema from column order, then call /modelfit/ on the API.
Column order: 1st = id_col, 2nd = time_col, 3rd..second-to-last = current_features, last = y.
On success returns a small informative JSON: formula, alpha, beta, Performance_subset (col 2, rows 3–5).
"""
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
df = await _fetch_url_to_dataframe(data_url)
cols = list(df.columns)
if len(cols) < 3:
raise HTTPException(
status_code=400,
detail="Data must have at least 3 columns (order: id_col, time_col, ...current_features..., y).",
)
id_col = cols[0]
time_col = cols[1]
y = cols[-1]
current_features = cols[2:-1] # empty if exactly 3 columns
payload = {
"df": df.to_dict(orient="records"),
"id_col": id_col,
"time_col": time_col,
"y": y,
"lag_y": None,
"lagged_features": {},
"current_features": current_features,
"filter_by_significance": False,
"dummy_sqrt": False,
"dummy_interact": False,
"dummy_logs": False,
"dummy_yejo": False,
"dummy_asinh": False,
"dummy_logs_interact": False,
"meanvar_test": False,
"signif": 0.05,
}
status, body = await _forward_and_get_body("/modelfit/", "POST", json_body=payload, user_token=user_token)
if status != 200 or not isinstance(body, dict):
return JSONResponse(status_code=status, content=body if isinstance(body, dict) else {"error": str(body)})
minimal = _build_modelfit_chat_minimal(body)
return JSONResponse(status_code=200, content=minimal)
@app.post("/modelforecast_chat/")
async def modelforecast_chat(
req: Request,
data_url: str = Body(..., embed=True),
):
"""Fetch forecast input data from data_url (HTTPS), then call /modelforecast/ on the API. Returns JSON forecast list."""
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
df_records = await _fetch_url_to_records(data_url)
# Backend modelforecast expects the raw JSON array as body (single Body(...) param), not {"df_forecast": [...]}
return await _forward("/modelforecast/", "POST", json_body=df_records, user_token=user_token)
@app.post("/modelfit-file/")
async def modelfit_file(
req: Request,
file: UploadFile = File(...),
id_col: str = Form(...),
time_col: str = Form(...),
y: str = Form(...),
# optional knobs
lag_y: str = Form(""),
lagged_features: str = Form(""), # JSON string or empty
current_features: str = Form(""), # "all" | JSON string | ""
filter_by_significance: str = Form("false"),
dummy_sqrt: str = Form("false"),
dummy_interact: str = Form("false"),
dummy_logs: str = Form("false"),
dummy_yejo: str = Form("false"),
dummy_asinh: str = Form("false"),
dummy_logs_interact: str = Form("false"),
meanvar_test: str = Form("false"),
signif: str = Form("0.05"),
):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
raw = await file.read()
if len(raw) > SDK_MAX_BODY_BYTES_extended:
raise HTTPException(status_code=413, detail="Payload too large.")
files = {
"file": (file.filename, raw, file.content_type or "application/octet-stream"),
}
data = {
"id_col": id_col,
"time_col": time_col,
"y": y,
"lag_y": lag_y,
"lagged_features": lagged_features,
"current_features": current_features,
"filter_by_significance": filter_by_significance,
"dummy_sqrt": dummy_sqrt,
"dummy_interact": dummy_interact,
"dummy_logs": dummy_logs,
"dummy_yejo": dummy_yejo,
"dummy_asinh": dummy_asinh,
"dummy_logs_interact": dummy_logs_interact,
"meanvar_test": meanvar_test,
"signif": signif,
}
return await _forward_stream("/modelfit-file/", files=files, data=data, user_token=user_token, method="POST")
@app.post("/modelforecast-file/")
async def modelforecast_file(
req: Request,
df_forecast: UploadFile = File(...),
):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
raw = await df_forecast.read()
if len(raw) > SDK_MAX_BODY_BYTES_extended:
raise HTTPException(status_code=413, detail="Payload too large.")
files = {
"df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
}
return await _forward_stream("/modelforecast-file/", files=files, data=None, user_token=user_token, method="POST")
@app.post("/modelfit-file-demo/")
@GLOBAL_LIMIT
@limiter.limit("10/10minute") # 10 calls per 10 minutes per IP
async def modelfit_file_demo(
request: Request,
file: UploadFile = File(...),
id_col: str = Form(...),
time_col: str = Form(...),
y: str = Form(...),
# optional knobs
lag_y: str = Form(""),
lagged_features: str = Form(""),
current_features: str = Form(""),
filter_by_significance: str = Form("false"),
meanvar_test: str = Form("false"),
session_id: str = Form(...),
):
# read once, size-guard it, then forward
raw = await file.read()
if len(raw) > SDK_MAX_BODY_BYTES:
raise HTTPException(status_code=413, detail="Payload too large.")
sid = session_id.strip()
if not sid:
raise HTTPException(status_code=400, detail="Missing session_id")
files = {
"file": (file.filename, raw, file.content_type or "application/octet-stream"),
}
data = {
"id_col": id_col,
"time_col": time_col,
"y": y,
"lag_y": lag_y,
"lagged_features": lagged_features,
"current_features": current_features,
"filter_by_significance": filter_by_significance,
"meanvar_test": meanvar_test,
"session_id": sid,
}
# the path goes to the private demo route
return await _forward_demo_stream("/modelfit-file-demo/", files=files, data=data, method="POST")
@app.post("/modelforecast-file-demo/")
@GLOBAL_LIMIT
@limiter.limit("10/10minute")
async def modelforecast_file_demo(
request: Request,
df_forecast: UploadFile = File(...),
session_id: str = Form(...),
):
raw = await df_forecast.read()
if len(raw) > SDK_MAX_BODY_BYTES:
raise HTTPException(status_code=413, detail="Payload too large.")
sid = session_id.strip()
if not sid:
raise HTTPException(status_code=400, detail="Missing session_id")
files = {
"df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
}
data = {
"session_id": sid,
}
return await _forward_demo_stream("/modelforecast-file-demo/", files=files, data=data, method="POST")
@app.get("/health-demo-proxy")
async def health_demo_proxy():
# convenience endpoint to test private-space + demo token hop
try:
return await _forward_demo_stream("/health-demo", files=None, data=None, method="GET")
except HTTPException as e:
# bubble up errors so you can diagnose missing tokens, wrong URL, etc.
raise e
@app.post("/modelfit_ind/")
async def modelfit_ind(req: Request):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
body = await req.json()
return await _forward("/modelfit_ind/", "POST", json_body=body, user_token=user_token)
@app.post("/modelforecast_ind/")
async def modelforecast_ind(req: Request):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
body = await req.json()
return await _forward("/modelforecast_ind/", "POST", json_body=body, user_token=user_token)
@app.post("/modelfit-file_ind/")
async def modelfit_file_ind(
req: Request,
file: UploadFile = File(...),
id_col: str = Form(...),
time_col: str = Form(...),
y: str = Form(...),
lag_y: str = Form(""),
lagged_features: str = Form(""),
current_features: str = Form(""),
filter_by_significance: str = Form("false"),
dummy_sqrt: str = Form("false"),
dummy_interact: str = Form("false"),
dummy_logs: str = Form("false"),
dummy_yejo: str = Form("false"),
dummy_asinh: str = Form("false"),
dummy_logs_interact: str = Form("false"),
meanvar_test: str = Form("false"),
signif: str = Form("0.05"),
):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
raw = await file.read()
if len(raw) > SDK_MAX_BODY_BYTES_extended:
raise HTTPException(status_code=413, detail="Payload too large.")
files = {
"file": (file.filename, raw, file.content_type or "application/octet-stream"),
}
data = {
"id_col": id_col,
"time_col": time_col,
"y": y,
"lag_y": lag_y,
"lagged_features": lagged_features,
"current_features": current_features,
"filter_by_significance": filter_by_significance,
"dummy_sqrt": dummy_sqrt,
"dummy_interact": dummy_interact,
"dummy_logs": dummy_logs,
"dummy_yejo": dummy_yejo,
"dummy_asinh": dummy_asinh,
"dummy_logs_interact": dummy_logs_interact,
"meanvar_test": meanvar_test,
"signif": signif,
}
return await _forward_stream("/modelfit-file_ind/", files=files, data=data, user_token=user_token, method="POST")
@app.post("/modelforecast-file_ind/")
async def modelforecast_file_ind(
req: Request,
df_forecast: UploadFile = File(...),
):
user_token = _extract_user_token(req)
if not user_token:
raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
raw = await df_forecast.read()
if len(raw) > SDK_MAX_BODY_BYTES_extended:
raise HTTPException(status_code=413, detail="Payload too large.")
files = {
"df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
}
return await _forward_stream("/modelforecast-file_ind/", files=files, data=None, user_token=user_token, method="POST")