DrValera commited on
Commit
b9b23d9
·
verified ·
1 Parent(s): 73641fe

Added significance level as input

Browse files
Files changed (2) hide show
  1. Dockerfile +19 -24
  2. main.py +375 -373
Dockerfile CHANGED
@@ -1,25 +1,20 @@
1
- # Dockerfile (public proxy)
2
- FROM python:3.11-slim
3
-
4
-
5
-
6
-
7
-
8
-
9
- WORKDIR /app
10
- ENV PYTHONDONTWRITEBYTECODE=1
11
- ENV PYTHONUNBUFFERED=1
12
-
13
- # Install Python deps
14
- COPY requirements.txt .
15
- RUN pip install --no-cache-dir -r requirements.txt
16
-
17
- # Copy code
18
- COPY main.py .
19
-
20
- # Hugging Face expects the app to listen on port 7860
21
- ENV PORT=7860
22
- EXPOSE 7860
23
-
24
- # Launch FastAPI
25
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # Dockerfile (public proxy)
2
+ FROM python:3.11-slim
3
+
4
+ WORKDIR /app
5
+ ENV PYTHONDONTWRITEBYTECODE=1
6
+ ENV PYTHONUNBUFFERED=1
7
+
8
+ # Install Python deps
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ # Copy code
13
+ COPY main.py .
14
+
15
+ # Hugging Face expects the app to listen on port 7860
16
+ ENV PORT=7860
17
+ EXPOSE 7860
18
+
19
+ # Launch FastAPI
 
 
 
 
 
20
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py CHANGED
@@ -1,374 +1,376 @@
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)
 
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
+ signif: str = Form("0.05"),
255
+ ):
256
+ user_token = _extract_user_token(req)
257
+ if not user_token:
258
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
259
+
260
+ raw = await file.read()
261
+ if len(raw) > SDK_MAX_BODY_BYTES_extended:
262
+ raise HTTPException(status_code=413, detail="Payload too large.")
263
+
264
+ files = {
265
+ "file": (file.filename, raw, file.content_type or "application/octet-stream"),
266
+ }
267
+ data = {
268
+ "id_col": id_col,
269
+ "time_col": time_col,
270
+ "y": y,
271
+ "lag_y": lag_y,
272
+ "lagged_features": lagged_features,
273
+ "current_features": current_features,
274
+ "filter_by_significance": filter_by_significance,
275
+ "meanvar_test": meanvar_test,
276
+ "signif": signif,
277
+ }
278
+ return await _forward_stream("/modelfit-file/", files=files, data=data, user_token=user_token, method="POST")
279
+
280
+ @app.post("/modelforecast-file/")
281
+ async def modelforecast_file(
282
+ req: Request,
283
+ df_forecast: UploadFile = File(...),
284
+ ):
285
+ user_token = _extract_user_token(req)
286
+ if not user_token:
287
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
288
+
289
+ raw = await df_forecast.read()
290
+ if len(raw) > SDK_MAX_BODY_BYTES_extended:
291
+ raise HTTPException(status_code=413, detail="Payload too large.")
292
+
293
+ files = {
294
+ "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
295
+ }
296
+ return await _forward_stream("/modelforecast-file/", files=files, data=None, user_token=user_token, method="POST")
297
+
298
+ @app.post("/modelfit-file-demo/")
299
+ @GLOBAL_LIMIT
300
+ @limiter.limit("10/10minute") # 10 calls per 10 minutes per IP
301
+ async def modelfit_file_demo(
302
+ request: Request,
303
+ file: UploadFile = File(...),
304
+ id_col: str = Form(...),
305
+ time_col: str = Form(...),
306
+ y: str = Form(...),
307
+ # optional knobs
308
+ lag_y: str = Form(""),
309
+ lagged_features: str = Form(""),
310
+ current_features: str = Form(""),
311
+ filter_by_significance: str = Form("false"),
312
+ meanvar_test: str = Form("false"),
313
+ ):
314
+
315
+ # read once, size-guard it, then forward
316
+ raw = await file.read()
317
+ if len(raw) > SDK_MAX_BODY_BYTES:
318
+ raise HTTPException(status_code=413, detail="Payload too large.")
319
+
320
+ files = {
321
+ "file": (file.filename, raw, file.content_type or "application/octet-stream"),
322
+ }
323
+ data = {
324
+ "id_col": id_col,
325
+ "time_col": time_col,
326
+ "y": y,
327
+ "lag_y": lag_y,
328
+ "lagged_features": lagged_features,
329
+ "current_features": current_features,
330
+ "filter_by_significance": filter_by_significance,
331
+ "meanvar_test": meanvar_test,
332
+ }
333
+ # the path goes to the private demo route
334
+ return await _forward_demo_stream("/modelfit-file-demo/", files=files, data=data, method="POST")
335
+
336
+ @app.post("/modelforecast-file-demo/")
337
+ @GLOBAL_LIMIT
338
+ @limiter.limit("10/10minute")
339
+ async def modelforecast_file_demo(
340
+ request: Request,
341
+ df_forecast: UploadFile = File(...),
342
+ ):
343
+
344
+ raw = await df_forecast.read()
345
+ if len(raw) > SDK_MAX_BODY_BYTES:
346
+ raise HTTPException(status_code=413, detail="Payload too large.")
347
+
348
+ files = {
349
+ "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
350
+ }
351
+ return await _forward_demo_stream("/modelforecast-file-demo/", files=files, data=None, method="POST")
352
+
353
+ @app.get("/health-demo-proxy")
354
+ async def health_demo_proxy():
355
+ # convenience endpoint to test private-space + demo token hop
356
+ try:
357
+ return await _forward_demo_stream("/health-demo", files=None, data=None, method="GET")
358
+ except HTTPException as e:
359
+ # bubble up errors so you can diagnose missing tokens, wrong URL, etc.
360
+ raise e
361
+
362
+ @app.post("/modelfit_ind/")
363
+ async def modelfit_ind(req: Request):
364
+ user_token = _extract_user_token(req)
365
+ if not user_token:
366
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
367
+ body = await req.json()
368
+ return await _forward("/modelfit_ind/", "POST", json_body=body, user_token=user_token)
369
+
370
+ @app.post("/modelforecast_ind/")
371
+ async def modelforecast_ind(req: Request):
372
+ user_token = _extract_user_token(req)
373
+ if not user_token:
374
+ raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
375
+ body = await req.json()
376
  return await _forward("/modelforecast_ind/", "POST", json_body=body, user_token=user_token)