DrValera commited on
Commit
ba3b28e
·
verified ·
1 Parent(s): c0873c4

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +374 -358
main.py CHANGED
@@ -1,358 +1,374 @@
1
- import os, httpx
2
- from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form
3
- from fastapi.responses import JSONResponse, Response, StreamingResponse
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from slowapi import Limiter
6
- from slowapi.errors import RateLimitExceeded
7
- from slowapi.middleware import SlowAPIMiddleware
8
-
9
- # --- Secrets set in the PUBLIC Space ---
10
- UPSTREAM_URL = os.environ.get("hf_url", "").rstrip("/") # url to access Private Space
11
- HF_TOKEN = os.environ.get("hf_token") # HF access token to access Private Space
12
- DEMO_FORWARD_URL = os.getenv("DEMO_FORWARD_URL", "").rstrip("/") # url to acces demo space
13
- DATFID_DEMO_TOKEN = os.getenv("DATFID_DEMO_TOKEN", "") # token to access demo space
14
-
15
- if not HF_TOKEN:
16
- raise RuntimeError("Missing secret 'hf_token' in public Space.")
17
- if not UPSTREAM_URL.startswith("https://"):
18
- raise RuntimeError("Missing/invalid secret 'hf_url' in public Space.")
19
-
20
- app = FastAPI(title="DATFID Public Proxy", docs_url="/docs", redoc_url=None)
21
-
22
- # Prefer X-Forwarded-For (HF sits behind a proxy)
23
- def client_ip(request: Request):
24
- xff = request.headers.get("x-forwarded-for")
25
- return xff.split(",")[0].strip() if xff else (request.client.host or "0.0.0.0")
26
-
27
- limiter = Limiter(key_func=client_ip) # or get_remote_address
28
- GLOBAL_LIMIT = limiter.limit("100/10minute", key_func=lambda: "global:any") # global limit
29
-
30
- app.state.limiter = limiter
31
- app.add_middleware(SlowAPIMiddleware)
32
-
33
- @app.exception_handler(RateLimitExceeded)
34
- async def ratelimit_handler(request: Request, exc: RateLimitExceeded):
35
- return JSONResponse(status_code=429, content={"detail": "Too many requests, slow down."})
36
-
37
- SDK_MAX_BODY_BYTES = int(os.getenv("SDK_MAX_BODY_BYTES", "25000000")) # 25MB default for demo
38
- SDK_MAX_BODY_BYTES_extended = int(os.getenv("SDK_MAX_BODY_BYTES_extended", "125000000")) # 125MB default for prod
39
-
40
- # Early global body-size guard (runs before routes)
41
- @app.middleware("http")
42
- async def limit_body_size(request: Request, call_next):
43
- cl = request.headers.get("content-length")
44
-
45
- # demo routes use the smaller cap, others use extended
46
- path = request.url.path or ""
47
- cap = SDK_MAX_BODY_BYTES if "-demo" in path else SDK_MAX_BODY_BYTES_extended
48
-
49
- if cl and int(cl) > cap:
50
- return JSONResponse(
51
- {"detail": f"Payload too large (> {cap} bytes)"},
52
- status_code=413,
53
- )
54
- return await call_next(request)
55
-
56
- # CORS for browser
57
- app.add_middleware(
58
- CORSMiddleware,
59
- allow_origins=[
60
- "https://datfid.com",
61
- "https://www.datfid.com"
62
- ],
63
- # Optional: allow Vercel preview domains
64
- # allow_origin_regex=r"^https:\/\/.*\.vercel\.app$",
65
- allow_methods=["POST", "OPTIONS"],
66
- allow_headers=["Content-Type"], # no Authorization header needed from browser
67
- allow_credentials=False,
68
- max_age=86400,
69
- )
70
-
71
- # to ensure we don’t leak hop-by-hop headers
72
- def _filter_resp_headers(headers: dict) -> dict:
73
- # pass through useful headers but strip hop-by-hop
74
- allowed = {"content-disposition"}
75
- return {k: v for k, v in headers.items() if k.lower() in allowed}
76
-
77
- def _extract_user_token(req: Request) -> str | None:
78
- """
79
- Read user's DATFID token from Authorization: Bearer <dt+...>.
80
- We do NOT verify it here; the private Space does that.
81
- """
82
- auth = req.headers.get("authorization", "")
83
- if not auth.lower().startswith("bearer "):
84
- return None
85
- return auth.split(" ", 1)[1].strip()
86
-
87
- async def _forward(path: str, method: str = "GET", json_body=None, user_token: str | None = None):
88
- """
89
- Forward request to the PRIVATE Space:
90
- - 'Authorization: Bearer <HF_TOKEN>' to pass HF private gate
91
- - 'X-API-Key: <dt+...>' so your private app can validate the user token
92
- """
93
- url = f"{UPSTREAM_URL}{path}"
94
- headers = {
95
- "Authorization": f"Bearer {HF_TOKEN}",
96
- "Accept": "application/json",
97
- }
98
- if user_token:
99
- headers["X-API-Key"] = user_token
100
-
101
- timeout = httpx.Timeout(600.0)
102
- async with httpx.AsyncClient(timeout=timeout) as client:
103
- r = await client.request(method, url, headers=headers, json=json_body)
104
-
105
- ct = r.headers.get("content-type", "")
106
-
107
- if "application/json" in ct:
108
- try:
109
- return JSONResponse(status_code=r.status_code, content=r.json())
110
- except Exception:
111
- return JSONResponse(status_code=r.status_code, content={"error": r.text[:500]})
112
- # Fallback: return short text envelope if non-JSON
113
- return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]})
114
-
115
- async def _forward_stream(path: str, files=None, data=None, user_token: str | None = None, method: str = "POST"):
116
- url = f"{UPSTREAM_URL}{path}"
117
- headers = {
118
- "Authorization": f"Bearer {HF_TOKEN}",
119
- "Accept": "*/*",
120
- }
121
- if user_token:
122
- headers["X-API-Key"] = user_token
123
-
124
- timeout = httpx.Timeout(600.0)
125
-
126
- client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
127
-
128
- # Don't open the context yet; the iterator must own the context lifetime.
129
- stream_ctx = client.stream(method, url, headers=headers, files=files, data=data)
130
-
131
- # Mutable holders we can fill once the stream opens
132
- status_holder = {"code": 200}
133
- media_type_holder = {"ct": "application/octet-stream"}
134
- headers_holder = {}
135
-
136
- async def body_iter():
137
- try:
138
- async with stream_ctx as resp:
139
- status_holder["code"] = resp.status_code
140
- media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream")
141
- headers_holder.update(_filter_resp_headers(resp.headers))
142
-
143
- # If upstream already returned an error, buffer it (short text/json)
144
- if resp.status_code >= 400:
145
- # Buffer entire payload and yield once
146
- chunk = await resp.aread()
147
- yield chunk
148
- return
149
-
150
- async for chunk in resp.aiter_raw():
151
- yield chunk
152
- finally:
153
- await client.aclose()
154
-
155
- response = StreamingResponse(
156
- body_iter(),
157
- status_code=status_holder["code"],
158
- media_type=media_type_holder["ct"],
159
- headers=headers_holder,
160
- )
161
- return response
162
-
163
-
164
- # for demo
165
- async def _forward_demo_stream(path: str, *, files: dict | None, data: dict | None, method: str = "POST"):
166
- if not DEMO_FORWARD_URL or not HF_TOKEN or not DATFID_DEMO_TOKEN:
167
- raise HTTPException(status_code=500, detail="Demo not configured.")
168
-
169
- url = DEMO_FORWARD_URL + path
170
- headers = {
171
- # HF private-space gate:
172
- "Authorization": f"Bearer {HF_TOKEN}",
173
- # App-level demo token (checked by the private API):
174
- "X-DATFID-Token": DATFID_DEMO_TOKEN,
175
- "Accept": "*/*",
176
- }
177
-
178
- timeout = httpx.Timeout(120.0)
179
- client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
180
- stream_ctx = client.stream(method, url, headers=headers, files=files, data=data)
181
-
182
- status_holder = {"code": 200}
183
- media_type_holder = {"ct": "application/octet-stream"}
184
- headers_holder = {}
185
-
186
- async def body_iter():
187
- try:
188
- async with stream_ctx as resp:
189
- status_holder["code"] = resp.status_code
190
- media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream")
191
- headers_holder.update(_filter_resp_headers(resp.headers))
192
-
193
- if resp.status_code >= 400:
194
- chunk = await resp.aread()
195
- yield chunk
196
- return
197
-
198
- async for chunk in resp.aiter_raw():
199
- yield chunk
200
- finally:
201
- await client.aclose()
202
-
203
- response = StreamingResponse(
204
- body_iter(),
205
- status_code=status_holder["code"],
206
- media_type=media_type_holder["ct"],
207
- headers=headers_holder,
208
- )
209
- return response
210
-
211
- @app.get("/")
212
- async def root(req: Request):
213
- # Forward to private root (private gate still needs HF token)
214
- user_token = _extract_user_token(req) # optional here
215
- return await _forward("/", "GET", user_token=user_token)
216
-
217
- @app.get("/secure-ping/")
218
- async def secure_ping(req: Request):
219
- # Require user's DATFID token in Authorization: Bearer <dt+...>
220
- user_token = _extract_user_token(req)
221
- if not user_token:
222
- raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
223
- return await _forward("/secure-ping/", "GET", user_token=user_token)
224
-
225
- @app.post("/modelfit/")
226
- async def modelfit(req: Request):
227
- user_token = _extract_user_token(req)
228
- if not user_token:
229
- raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
230
- body = await req.json()
231
- return await _forward("/modelfit/", "POST", json_body=body, user_token=user_token)
232
-
233
- @app.post("/modelforecast/")
234
- async def modelforecast(req: Request):
235
- user_token = _extract_user_token(req)
236
- if not user_token:
237
- raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
238
- body = await req.json()
239
- return await _forward("/modelforecast/", "POST", json_body=body, user_token=user_token)
240
-
241
- @app.post("/modelfit-file/")
242
- async def modelfit_file(
243
- req: Request,
244
- file: UploadFile = File(...),
245
- id_col: str = Form(...),
246
- time_col: str = Form(...),
247
- y: str = Form(...),
248
- # optional knobs
249
- lag_y: str = Form(""),
250
- lagged_features: str = Form(""), # JSON string or empty
251
- current_features: str = Form(""), # "all" | JSON string | ""
252
- filter_by_significance: str = Form("false"),
253
- meanvar_test: str = Form("false"),
254
- ):
255
- user_token = _extract_user_token(req)
256
- if not user_token:
257
- raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
258
-
259
- raw = await file.read()
260
- if len(raw) > SDK_MAX_BODY_BYTES_extended:
261
- raise HTTPException(status_code=413, detail="Payload too large.")
262
-
263
- files = {
264
- "file": (file.filename, raw, file.content_type or "application/octet-stream"),
265
- }
266
- data = {
267
- "id_col": id_col,
268
- "time_col": time_col,
269
- "y": y,
270
- "lag_y": lag_y,
271
- "lagged_features": lagged_features,
272
- "current_features": current_features,
273
- "filter_by_significance": filter_by_significance,
274
- "meanvar_test": meanvar_test,
275
- }
276
- return await _forward_stream("/modelfit-file/", files=files, data=data, user_token=user_token, method="POST")
277
-
278
- @app.post("/modelforecast-file/")
279
- async def modelforecast_file(
280
- req: Request,
281
- df_forecast: UploadFile = File(...),
282
- ):
283
- user_token = _extract_user_token(req)
284
- if not user_token:
285
- raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
286
-
287
- raw = await df_forecast.read()
288
- if len(raw) > SDK_MAX_BODY_BYTES_extended:
289
- raise HTTPException(status_code=413, detail="Payload too large.")
290
-
291
- files = {
292
- "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
293
- }
294
- return await _forward_stream("/modelforecast-file/", files=files, data=None, user_token=user_token, method="POST")
295
-
296
- @app.post("/modelfit-file-demo/")
297
- @GLOBAL_LIMIT
298
- @limiter.limit("10/10minute") # 10 calls per 10 minutes per IP
299
- async def modelfit_file_demo(
300
- request: Request,
301
- file: UploadFile = File(...),
302
- id_col: str = Form(...),
303
- time_col: str = Form(...),
304
- y: str = Form(...),
305
- # optional knobs
306
- lag_y: str = Form(""),
307
- lagged_features: str = Form(""),
308
- current_features: str = Form(""),
309
- filter_by_significance: str = Form("false"),
310
- meanvar_test: str = Form("false"),
311
- ):
312
-
313
- # read once, size-guard it, then forward
314
- raw = await file.read()
315
- if len(raw) > SDK_MAX_BODY_BYTES:
316
- raise HTTPException(status_code=413, detail="Payload too large.")
317
-
318
- files = {
319
- "file": (file.filename, raw, file.content_type or "application/octet-stream"),
320
- }
321
- data = {
322
- "id_col": id_col,
323
- "time_col": time_col,
324
- "y": y,
325
- "lag_y": lag_y,
326
- "lagged_features": lagged_features,
327
- "current_features": current_features,
328
- "filter_by_significance": filter_by_significance,
329
- "meanvar_test": meanvar_test,
330
- }
331
- # the path goes to the private demo route
332
- return await _forward_demo_stream("/modelfit-file-demo/", files=files, data=data, method="POST")
333
-
334
- @app.post("/modelforecast-file-demo/")
335
- @GLOBAL_LIMIT
336
- @limiter.limit("10/10minute")
337
- async def modelforecast_file_demo(
338
- request: Request,
339
- df_forecast: UploadFile = File(...),
340
- ):
341
-
342
- raw = await df_forecast.read()
343
- if len(raw) > SDK_MAX_BODY_BYTES:
344
- raise HTTPException(status_code=413, detail="Payload too large.")
345
-
346
- files = {
347
- "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
348
- }
349
- return await _forward_demo_stream("/modelforecast-file-demo/", files=files, data=None, method="POST")
350
-
351
- @app.get("/health-demo-proxy")
352
- async def health_demo_proxy():
353
- # convenience endpoint to test private-space + demo token hop
354
- try:
355
- return await _forward_demo_stream("/health-demo", files=None, data=None, method="GET")
356
- except HTTPException as e:
357
- # bubble up errors so you can diagnose missing tokens, wrong URL, etc.
358
- raise e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, httpx
2
+ from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form
3
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from slowapi import Limiter
6
+ from slowapi.errors import RateLimitExceeded
7
+ from slowapi.middleware import SlowAPIMiddleware
8
+
9
+ # --- Secrets set in the PUBLIC Space ---
10
+ UPSTREAM_URL = os.environ.get("hf_url", "").rstrip("/") # url to access Private Space
11
+ HF_TOKEN = os.environ.get("hf_token") # HF access token to access Private Space
12
+ DEMO_FORWARD_URL = os.getenv("DEMO_FORWARD_URL", "").rstrip("/") # url to acces demo space
13
+ DATFID_DEMO_TOKEN = os.getenv("DATFID_DEMO_TOKEN", "") # token to access demo space
14
+
15
+ if not HF_TOKEN:
16
+ raise RuntimeError("Missing secret 'hf_token' in public Space.")
17
+ if not UPSTREAM_URL.startswith("https://"):
18
+ raise RuntimeError("Missing/invalid secret 'hf_url' in public Space.")
19
+
20
+ app = FastAPI(title="DATFID Public Proxy", docs_url="/docs", redoc_url=None)
21
+
22
+ # Prefer X-Forwarded-For (HF sits behind a proxy)
23
+ def client_ip(request: Request):
24
+ xff = request.headers.get("x-forwarded-for")
25
+ return xff.split(",")[0].strip() if xff else (request.client.host or "0.0.0.0")
26
+
27
+ limiter = Limiter(key_func=client_ip) # or get_remote_address
28
+ GLOBAL_LIMIT = limiter.limit("100/10minute", key_func=lambda: "global:any") # global limit
29
+
30
+ app.state.limiter = limiter
31
+ app.add_middleware(SlowAPIMiddleware)
32
+
33
+ @app.exception_handler(RateLimitExceeded)
34
+ async def ratelimit_handler(request: Request, exc: RateLimitExceeded):
35
+ return JSONResponse(status_code=429, content={"detail": "Too many requests, slow down."})
36
+
37
+ SDK_MAX_BODY_BYTES = int(os.getenv("SDK_MAX_BODY_BYTES", "25000000")) # 25MB default for demo
38
+ SDK_MAX_BODY_BYTES_extended = int(os.getenv("SDK_MAX_BODY_BYTES_extended", "125000000")) # 125MB default for prod
39
+
40
+ # Early global body-size guard (runs before routes)
41
+ @app.middleware("http")
42
+ async def limit_body_size(request: Request, call_next):
43
+ cl = request.headers.get("content-length")
44
+
45
+ # demo routes use the smaller cap, others use extended
46
+ path = request.url.path or ""
47
+ cap = SDK_MAX_BODY_BYTES if "-demo" in path else SDK_MAX_BODY_BYTES_extended
48
+
49
+ if cl and int(cl) > cap:
50
+ return JSONResponse(
51
+ {"detail": f"Payload too large (> {cap} bytes)"},
52
+ status_code=413,
53
+ )
54
+ return await call_next(request)
55
+
56
+ # CORS for browser
57
+ app.add_middleware(
58
+ CORSMiddleware,
59
+ allow_origins=[
60
+ "https://datfid.com",
61
+ "https://www.datfid.com"
62
+ ],
63
+ # Optional: allow Vercel preview domains
64
+ # allow_origin_regex=r"^https:\/\/.*\.vercel\.app$",
65
+ allow_methods=["POST", "OPTIONS"],
66
+ allow_headers=["Content-Type"], # no Authorization header needed from browser
67
+ allow_credentials=False,
68
+ max_age=86400,
69
+ )
70
+
71
+ # to ensure we don’t leak hop-by-hop headers
72
+ def _filter_resp_headers(headers: dict) -> dict:
73
+ # pass through useful headers but strip hop-by-hop
74
+ allowed = {"content-disposition"}
75
+ return {k: v for k, v in headers.items() if k.lower() in allowed}
76
+
77
+ def _extract_user_token(req: Request) -> str | None:
78
+ """
79
+ Read user's DATFID token from Authorization: Bearer <dt+...>.
80
+ We do NOT verify it here; the private Space does that.
81
+ """
82
+ auth = req.headers.get("authorization", "")
83
+ if not auth.lower().startswith("bearer "):
84
+ return None
85
+ return auth.split(" ", 1)[1].strip()
86
+
87
+ async def _forward(path: str, method: str = "GET", json_body=None, user_token: str | None = None):
88
+ """
89
+ Forward request to the PRIVATE Space:
90
+ - 'Authorization: Bearer <HF_TOKEN>' to pass HF private gate
91
+ - 'X-API-Key: <dt+...>' so your private app can validate the user token
92
+ """
93
+ url = f"{UPSTREAM_URL}{path}"
94
+ headers = {
95
+ "Authorization": f"Bearer {HF_TOKEN}",
96
+ "Accept": "application/json",
97
+ }
98
+ if user_token:
99
+ headers["X-API-Key"] = user_token
100
+
101
+ timeout = httpx.Timeout(600.0)
102
+ async with httpx.AsyncClient(timeout=timeout) as client:
103
+ r = await client.request(method, url, headers=headers, json=json_body)
104
+
105
+ ct = r.headers.get("content-type", "")
106
+
107
+ if "application/json" in ct:
108
+ try:
109
+ return JSONResponse(status_code=r.status_code, content=r.json())
110
+ except Exception:
111
+ return JSONResponse(status_code=r.status_code, content={"error": r.text[:500]})
112
+ # Fallback: return short text envelope if non-JSON
113
+ return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]})
114
+
115
+ async def _forward_stream(path: str, files=None, data=None, user_token: str | None = None, method: str = "POST"):
116
+ url = f"{UPSTREAM_URL}{path}"
117
+ headers = {
118
+ "Authorization": f"Bearer {HF_TOKEN}",
119
+ "Accept": "*/*",
120
+ }
121
+ if user_token:
122
+ headers["X-API-Key"] = user_token
123
+
124
+ timeout = httpx.Timeout(600.0)
125
+
126
+ client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
127
+
128
+ # Don't open the context yet; the iterator must own the context lifetime.
129
+ stream_ctx = client.stream(method, url, headers=headers, files=files, data=data)
130
+
131
+ # Mutable holders we can fill once the stream opens
132
+ status_holder = {"code": 200}
133
+ media_type_holder = {"ct": "application/octet-stream"}
134
+ headers_holder = {}
135
+
136
+ async def body_iter():
137
+ try:
138
+ async with stream_ctx as resp:
139
+ status_holder["code"] = resp.status_code
140
+ media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream")
141
+ headers_holder.update(_filter_resp_headers(resp.headers))
142
+
143
+ # If upstream already returned an error, buffer it (short text/json)
144
+ if resp.status_code >= 400:
145
+ # Buffer entire payload and yield once
146
+ chunk = await resp.aread()
147
+ yield chunk
148
+ return
149
+
150
+ async for chunk in resp.aiter_raw():
151
+ yield chunk
152
+ finally:
153
+ await client.aclose()
154
+
155
+ response = StreamingResponse(
156
+ body_iter(),
157
+ status_code=status_holder["code"],
158
+ media_type=media_type_holder["ct"],
159
+ headers=headers_holder,
160
+ )
161
+ return response
162
+
163
+
164
+ # for demo
165
+ async def _forward_demo_stream(path: str, *, files: dict | None, data: dict | None, method: str = "POST"):
166
+ if not DEMO_FORWARD_URL or not HF_TOKEN or not DATFID_DEMO_TOKEN:
167
+ raise HTTPException(status_code=500, detail="Demo not configured.")
168
+
169
+ url = DEMO_FORWARD_URL + path
170
+ headers = {
171
+ # HF private-space gate:
172
+ "Authorization": f"Bearer {HF_TOKEN}",
173
+ # App-level demo token (checked by the private API):
174
+ "X-DATFID-Token": DATFID_DEMO_TOKEN,
175
+ "Accept": "*/*",
176
+ }
177
+
178
+ timeout = httpx.Timeout(120.0)
179
+ client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
180
+ stream_ctx = client.stream(method, url, headers=headers, files=files, data=data)
181
+
182
+ status_holder = {"code": 200}
183
+ media_type_holder = {"ct": "application/octet-stream"}
184
+ headers_holder = {}
185
+
186
+ async def body_iter():
187
+ try:
188
+ async with stream_ctx as resp:
189
+ status_holder["code"] = resp.status_code
190
+ media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream")
191
+ headers_holder.update(_filter_resp_headers(resp.headers))
192
+
193
+ if resp.status_code >= 400:
194
+ chunk = await resp.aread()
195
+ yield chunk
196
+ return
197
+
198
+ async for chunk in resp.aiter_raw():
199
+ yield chunk
200
+ finally:
201
+ await client.aclose()
202
+
203
+ response = StreamingResponse(
204
+ body_iter(),
205
+ status_code=status_holder["code"],
206
+ media_type=media_type_holder["ct"],
207
+ headers=headers_holder,
208
+ )
209
+ return response
210
+
211
+ @app.get("/")
212
+ async def root(req: Request):
213
+ # Forward to private root (private gate still needs HF token)
214
+ user_token = _extract_user_token(req) # optional here
215
+ return await _forward("/", "GET", user_token=user_token)
216
+
217
+ @app.get("/secure-ping/")
218
+ async def secure_ping(req: Request):
219
+ # Require user's DATFID token in Authorization: Bearer <dt+...>
220
+ user_token = _extract_user_token(req)
221
+ if not user_token:
222
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
223
+ return await _forward("/secure-ping/", "GET", user_token=user_token)
224
+
225
+ @app.post("/modelfit/")
226
+ async def modelfit(req: Request):
227
+ user_token = _extract_user_token(req)
228
+ if not user_token:
229
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
230
+ body = await req.json()
231
+ return await _forward("/modelfit/", "POST", json_body=body, user_token=user_token)
232
+
233
+ @app.post("/modelforecast/")
234
+ async def modelforecast(req: Request):
235
+ user_token = _extract_user_token(req)
236
+ if not user_token:
237
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
238
+ body = await req.json()
239
+ return await _forward("/modelforecast/", "POST", json_body=body, user_token=user_token)
240
+
241
+ @app.post("/modelfit-file/")
242
+ async def modelfit_file(
243
+ req: Request,
244
+ file: UploadFile = File(...),
245
+ id_col: str = Form(...),
246
+ time_col: str = Form(...),
247
+ y: str = Form(...),
248
+ # optional knobs
249
+ lag_y: str = Form(""),
250
+ lagged_features: str = Form(""), # JSON string or empty
251
+ current_features: str = Form(""), # "all" | JSON string | ""
252
+ filter_by_significance: str = Form("false"),
253
+ meanvar_test: str = Form("false"),
254
+ ):
255
+ user_token = _extract_user_token(req)
256
+ if not user_token:
257
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
258
+
259
+ raw = await file.read()
260
+ if len(raw) > SDK_MAX_BODY_BYTES_extended:
261
+ raise HTTPException(status_code=413, detail="Payload too large.")
262
+
263
+ files = {
264
+ "file": (file.filename, raw, file.content_type or "application/octet-stream"),
265
+ }
266
+ data = {
267
+ "id_col": id_col,
268
+ "time_col": time_col,
269
+ "y": y,
270
+ "lag_y": lag_y,
271
+ "lagged_features": lagged_features,
272
+ "current_features": current_features,
273
+ "filter_by_significance": filter_by_significance,
274
+ "meanvar_test": meanvar_test,
275
+ }
276
+ return await _forward_stream("/modelfit-file/", files=files, data=data, user_token=user_token, method="POST")
277
+
278
+ @app.post("/modelforecast-file/")
279
+ async def modelforecast_file(
280
+ req: Request,
281
+ df_forecast: UploadFile = File(...),
282
+ ):
283
+ user_token = _extract_user_token(req)
284
+ if not user_token:
285
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
286
+
287
+ raw = await df_forecast.read()
288
+ if len(raw) > SDK_MAX_BODY_BYTES_extended:
289
+ raise HTTPException(status_code=413, detail="Payload too large.")
290
+
291
+ files = {
292
+ "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
293
+ }
294
+ return await _forward_stream("/modelforecast-file/", files=files, data=None, user_token=user_token, method="POST")
295
+
296
+ @app.post("/modelfit-file-demo/")
297
+ @GLOBAL_LIMIT
298
+ @limiter.limit("10/10minute") # 10 calls per 10 minutes per IP
299
+ async def modelfit_file_demo(
300
+ request: Request,
301
+ file: UploadFile = File(...),
302
+ id_col: str = Form(...),
303
+ time_col: str = Form(...),
304
+ y: str = Form(...),
305
+ # optional knobs
306
+ lag_y: str = Form(""),
307
+ lagged_features: str = Form(""),
308
+ current_features: str = Form(""),
309
+ filter_by_significance: str = Form("false"),
310
+ meanvar_test: str = Form("false"),
311
+ ):
312
+
313
+ # read once, size-guard it, then forward
314
+ raw = await file.read()
315
+ if len(raw) > SDK_MAX_BODY_BYTES:
316
+ raise HTTPException(status_code=413, detail="Payload too large.")
317
+
318
+ files = {
319
+ "file": (file.filename, raw, file.content_type or "application/octet-stream"),
320
+ }
321
+ data = {
322
+ "id_col": id_col,
323
+ "time_col": time_col,
324
+ "y": y,
325
+ "lag_y": lag_y,
326
+ "lagged_features": lagged_features,
327
+ "current_features": current_features,
328
+ "filter_by_significance": filter_by_significance,
329
+ "meanvar_test": meanvar_test,
330
+ }
331
+ # the path goes to the private demo route
332
+ return await _forward_demo_stream("/modelfit-file-demo/", files=files, data=data, method="POST")
333
+
334
+ @app.post("/modelforecast-file-demo/")
335
+ @GLOBAL_LIMIT
336
+ @limiter.limit("10/10minute")
337
+ async def modelforecast_file_demo(
338
+ request: Request,
339
+ df_forecast: UploadFile = File(...),
340
+ ):
341
+
342
+ raw = await df_forecast.read()
343
+ if len(raw) > SDK_MAX_BODY_BYTES:
344
+ raise HTTPException(status_code=413, detail="Payload too large.")
345
+
346
+ files = {
347
+ "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
348
+ }
349
+ return await _forward_demo_stream("/modelforecast-file-demo/", files=files, data=None, method="POST")
350
+
351
+ @app.get("/health-demo-proxy")
352
+ async def health_demo_proxy():
353
+ # convenience endpoint to test private-space + demo token hop
354
+ try:
355
+ return await _forward_demo_stream("/health-demo", files=None, data=None, method="GET")
356
+ except HTTPException as e:
357
+ # bubble up errors so you can diagnose missing tokens, wrong URL, etc.
358
+ raise e
359
+
360
+ @app.post("/modelfit_ind/")
361
+ async def modelfit_ind(req: Request):
362
+ user_token = _extract_user_token(req)
363
+ if not user_token:
364
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
365
+ body = await req.json()
366
+ return await _forward("/modelfit_ind/", "POST", json_body=body, user_token=user_token)
367
+
368
+ @app.post("/modelforecast_ind/")
369
+ async def modelforecast_ind(req: Request):
370
+ user_token = _extract_user_token(req)
371
+ if not user_token:
372
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
373
+ body = await req.json()
374
+ return await _forward("/modelforecast_ind/", "POST", json_body=body, user_token=user_token)