Vadashuk commited on
Commit
76eb57f
·
verified ·
1 Parent(s): f3523ce

Upload 4 files

Browse files
Files changed (1) hide show
  1. main.py +3 -259
main.py CHANGED
@@ -1,25 +1,16 @@
1
  # main.py
2
- import os, json, base64, requests, traceback
3
  import pandas as pd
4
  import numpy as np
5
  import io, tempfile, textwrap
6
  import statsmodels.api as sm
7
 
8
- from fastapi import FastAPI, HTTPException, Body, Header, Depends, UploadFile, File, Form, Response, status, Request
9
  from fastapi.responses import FileResponse, PlainTextResponse, ORJSONResponse
10
  from fastapi.concurrency import run_in_threadpool
11
  from datetime import datetime
12
  from typing import List, Dict, Any, Optional
13
  from dataclasses import dataclass
14
- from starlette.middleware.base import BaseHTTPMiddleware
15
- import time
16
-
17
- # ---------- Server-side config ----------
18
- GITHUB_OWNER = "datfid-valeriidashuk"
19
- GITHUB_REPO = "datfid-hf"
20
- USAGE_PATH = "hf_usage.json" # <— add this
21
- BRANCH = "main"
22
- GITHUB_PAT = os.environ.get("Github_key") # must have Contents: Read & Write
23
 
24
  # Predefined globals
25
  stored_model = None
@@ -244,164 +235,6 @@ class DATFIDModel:
244
  out["forecast"] = out[f"{self.y}_forecast"]
245
  return out
246
 
247
- def _gh_get(path: str):
248
- url = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/contents/{path}?ref={BRANCH}"
249
- headers = {
250
- "Authorization": f"Bearer {GITHUB_PAT}",
251
- "Accept": "application/vnd.github+json",
252
- "X-GitHub-Api-Version": "2022-11-28",
253
- }
254
- r = requests.get(url, headers=headers, timeout=15)
255
- if r.status_code == 404:
256
- return None, None # file not found
257
- if r.status_code != 200:
258
- raise HTTPException(status_code=502, detail=f"GitHub GET {path} failed")
259
- data = r.json()
260
- content = base64.b64decode(data["content"]).decode("utf-8")
261
- sha = data["sha"]
262
- return content, sha
263
-
264
- def _gh_put(path: str, text: str, *, sha: Optional[str], message: str):
265
- url = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/contents/{path}"
266
- headers = {
267
- "Authorization": f"Bearer {GITHUB_PAT}",
268
- "Accept": "application/vnd.github+json",
269
- "X-GitHub-Api-Version": "2022-11-28",
270
- }
271
- payload = {
272
- "message": message,
273
- "content": base64.b64encode(text.encode("utf-8")).decode("utf-8"),
274
- "branch": BRANCH,
275
- }
276
- if sha:
277
- payload["sha"] = sha
278
- r = requests.put(url, headers=headers, json=payload, timeout=20)
279
- if r.status_code in (200, 201):
280
- return r.json()["content"]["sha"]
281
- # surface conflict so we can retry
282
- if r.status_code in (409, 422):
283
- raise RuntimeError("GITHUB_CONFLICT")
284
- raise HTTPException(status_code=502, detail=f"GitHub PUT {path} failed")
285
-
286
- def _today() -> str:
287
- """Return current date as YYYY-MM-DD."""
288
- return datetime.utcnow().strftime("%Y-%m-%d")
289
-
290
- def _bucket(nbytes: Optional[int]) -> str:
291
- """Categorize byte size into buckets."""
292
- if not isinstance(nbytes, int):
293
- return "<1MB"
294
- if nbytes < 1_000_000:
295
- return "<1MB"
296
- if nbytes < 10_000_000:
297
- return "1-10MB"
298
- return ">10MB"
299
-
300
- def _new_day_bucket():
301
- """Create a new day bucket with all metrics initialized."""
302
- return {
303
- "calls": 0,
304
- "ok_2xx": 0,
305
- "client_4xx": 0,
306
- "server_5xx": 0,
307
- "total_duration_ms": 0,
308
- "req_size_bucket": {"<1MB": 0, "1-10MB": 0, ">10MB": 0},
309
- "resp_size_bucket": {"<1MB": 0, "1-10MB": 0, ">10MB": 0},
310
- }
311
-
312
- def bump_usage(user_id: str, endpoint: str, max_retries: int = 5,
313
- status_code: Optional[int] = None, duration_ms: Optional[int] = None,
314
- req_bytes: Optional[int] = None, resp_bytes: Optional[int] = None):
315
- """
316
- Increment usage counters in hf_usage.json with optimistic concurrency.
317
- Now supports extended metrics: status codes, duration, request/response sizes.
318
- """
319
- yyyymm = datetime.utcnow().strftime("%Y-%m")
320
- yyyymmdd = _today()
321
-
322
- attempt = 0
323
- while True:
324
- attempt += 1
325
- # read current usage (or create)
326
- txt, sha = _gh_get(USAGE_PATH)
327
- if txt is None:
328
- usage = {}
329
- sha = None
330
- else:
331
- try:
332
- usage = json.loads(txt) if txt.strip() else {}
333
- except Exception:
334
- # corrupt file -> start fresh but do not lose the old SHA reference
335
- usage = {}
336
-
337
- # Get or create user record
338
- rec = usage.get(user_id) or {
339
- "total": 0,
340
- "by_month": {},
341
- "by_endpoint": {},
342
- "by_endpoint_month": {},
343
- "updated_at": None,
344
- }
345
-
346
- # Initialize endpoints structure if not present
347
- if "endpoints" not in rec:
348
- rec["endpoints"] = {}
349
-
350
- # Get or create endpoint record
351
- ep_rec = rec["endpoints"].get(endpoint) or {
352
- "total": 0,
353
- }
354
-
355
- # Get or create day bucket for this endpoint
356
- if yyyymmdd not in ep_rec:
357
- ep_rec[yyyymmdd] = _new_day_bucket()
358
-
359
- day_bucket = ep_rec[yyyymmdd]
360
-
361
- # Increment basic counters
362
- rec["total"] = int(rec.get("total", 0)) + 1
363
- rec["by_month"][yyyymm] = int(rec["by_month"].get(yyyymm, 0)) + 1
364
- rec["by_endpoint"][endpoint] = int(rec["by_endpoint"].get(endpoint, 0)) + 1
365
- key_ep_month = f"{yyyymm}:{endpoint}"
366
- rec["by_endpoint_month"][key_ep_month] = int(rec["by_endpoint_month"].get(key_ep_month, 0)) + 1
367
- rec["updated_at"] = datetime.utcnow().isoformat() + "Z"
368
-
369
- # Increment endpoint-level counters
370
- ep_rec["total"] = int(ep_rec.get("total", 0)) + 1
371
-
372
- # Increment extended metrics if provided
373
- day_bucket["calls"] += 1
374
- if status_code is not None:
375
- if 200 <= status_code < 300:
376
- day_bucket["ok_2xx"] += 1
377
- elif 400 <= status_code < 500:
378
- day_bucket["client_4xx"] += 1
379
- elif 500 <= status_code < 600:
380
- day_bucket["server_5xx"] += 1
381
- if duration_ms is not None:
382
- day_bucket["total_duration_ms"] += duration_ms
383
- if req_bytes is not None:
384
- day_bucket["req_size_bucket"][_bucket(req_bytes)] += 1
385
- if resp_bytes is not None:
386
- day_bucket["resp_size_bucket"][_bucket(resp_bytes)] += 1
387
-
388
- # Update nested structures
389
- ep_rec[yyyymmdd] = day_bucket
390
- rec["endpoints"][endpoint] = ep_rec
391
- usage[user_id] = rec
392
-
393
- new_txt = json.dumps(usage, ensure_ascii = False, indent = 2, sort_keys = True) + "\n"
394
- try:
395
- _gh_put(USAGE_PATH, new_txt, sha=sha, message=f"usage: +1 {user_id} {endpoint} {yyyymmdd}")
396
- return
397
- except RuntimeError as e:
398
- # conflict -> backoff & retry
399
- if str(e) == "GITHUB_CONFLICT" and attempt < max_retries:
400
- import time as _t
401
- _t.sleep(0.25 * attempt)
402
- continue
403
- raise
404
-
405
  def _maybe_json_list(s: Optional[str]):
406
  if s is None or s == "":
407
  return []
@@ -572,79 +405,12 @@ def _result_to_text(result_obj: Any) -> str:
572
  # ---------- FastAPI app ----------
573
  app = FastAPI(
574
  title="DATFID API",
575
- description="Public demo API (no token auth)",
576
  docs_url="/docs",
577
  redoc_url=None,
578
  default_response_class=ORJSONResponse,
579
  )
580
 
581
- # ---------- Metrics Middleware ----------
582
- class UsageMetricsMiddleware(BaseHTTPMiddleware):
583
- """Middleware to capture extended usage metrics and call bump_usage with full context."""
584
-
585
- async def dispatch(self, request: Request, call_next):
586
- # Capture start time
587
- t0 = time.time()
588
-
589
- # Get request size
590
- req_bytes = None
591
- try:
592
- cl = request.headers.get("content-length")
593
- if cl:
594
- req_bytes = int(cl)
595
- except Exception:
596
- pass
597
-
598
- # Process request (this will execute dependencies and handlers)
599
- response = await call_next(request)
600
-
601
- # After handler execution, check if this request was metered
602
- # (dependencies set request.state during handler execution)
603
- has_metered = hasattr(request.state, "metered_user_id") and hasattr(request.state, "metered_endpoint")
604
-
605
- if not has_metered:
606
- # Not a metered endpoint, just return response
607
- return response
608
-
609
- # Calculate duration
610
- duration_ms = int((time.time() - t0) * 1000)
611
-
612
- # Get response size
613
- resp_bytes = None
614
- try:
615
- rcl = response.headers.get("content-length")
616
- if rcl:
617
- resp_bytes = int(rcl)
618
- except Exception:
619
- pass
620
-
621
- # Get status code
622
- status_code = getattr(response, "status_code", None)
623
-
624
- # Call bump_usage with extended metrics (non-blocking)
625
- # In fully public mode without Github_key, skip persistence silently.
626
- if not GITHUB_PAT:
627
- return response
628
- try:
629
- user_id = request.state.metered_user_id
630
- endpoint = request.state.metered_endpoint
631
- bump_usage(
632
- user_id=user_id,
633
- endpoint=endpoint,
634
- status_code=status_code,
635
- duration_ms=duration_ms,
636
- req_bytes=req_bytes,
637
- resp_bytes=resp_bytes,
638
- )
639
- except Exception:
640
- # Never break the request due to metrics failure
641
- traceback.print_exc()
642
-
643
- return response
644
-
645
- # Register middleware
646
- app.add_middleware(UsageMetricsMiddleware)
647
-
648
  # In-memory model state
649
  @dataclass
650
  class ModelSlot:
@@ -665,24 +431,9 @@ def root():
665
  def secure_ping():
666
  return {"ok": True}
667
 
668
- # Reusable dependency for protected routes
669
- def require_valid_token(x_api_key: Optional[str] = Header(None)) -> str:
670
- return x_api_key or ""
671
-
672
- def metered(endpoint_name: str):
673
- """Public dependency: no auth, keeps endpoint metrics labels."""
674
- async def _dep(request: Request, x_api_key: Optional[str] = Header(None)) -> str:
675
- user_id = "public"
676
- # Store in request state for middleware to use
677
- request.state.metered_user_id = user_id
678
- request.state.metered_endpoint = endpoint_name
679
- return x_api_key or ""
680
- return _dep
681
-
682
  # ---------- Model endpoints (guarded) ----------
683
  @app.post("/modelfit/")
684
  async def modelfit(
685
- _: str = Depends(metered("modelfit")),
686
  df: List[Dict] = Body(...),
687
  id_col: str = Body(...),
688
  time_col: str = Body(...),
@@ -738,7 +489,6 @@ async def modelfit(
738
 
739
  @app.post("/modelforecast/")
740
  async def modelforecast(
741
- _: str = Depends(metered("modelforecast")),
742
  df_forecast: List[Dict] = Body(...),
743
  ):
744
  global stored_model, stored_result_join
@@ -754,7 +504,6 @@ async def modelforecast(
754
 
755
  @app.post("/modelfit-file/")
756
  async def modelfit_file(
757
- _: str = Depends(metered("modelfit-file")),
758
  file: UploadFile = File(...),
759
  id_col: str = Form(...),
760
  time_col: str = Form(...),
@@ -823,7 +572,6 @@ async def modelfit_file(
823
 
824
  @app.post("/modelforecast-file/")
825
  async def modelforecast_file(
826
- _: str = Depends(metered("modelforecast-file")),
827
  df_forecast: UploadFile = File(...),
828
  ):
829
  global stored_model, stored_result_join
@@ -870,7 +618,6 @@ async def modelforecast_file(
870
 
871
  @app.post("/modelfit_ind/")
872
  async def modelfit_ind(
873
- _: str = Depends(metered("modelfit_ind")),
874
  df: List[Dict] = Body(...),
875
  id_col: str = Body(...),
876
  time_col: str = Body(...),
@@ -936,7 +683,6 @@ async def modelfit_ind(
936
 
937
  @app.post("/modelforecast_ind/")
938
  async def modelforecast_ind(
939
- _: str = Depends(metered("modelforecast_ind")),
940
  df_forecast: List[Dict] = Body(...),
941
  ):
942
  # Use per-ID models created by /modelfit_ind/
@@ -986,7 +732,6 @@ async def modelforecast_ind(
986
 
987
  @app.post("/modelfit-file_ind/")
988
  async def modelfit_file_ind(
989
- _: str = Depends(metered("modelfit-file_ind")),
990
  file: UploadFile = File(...),
991
  id_col: str = Form(...),
992
  time_col: str = Form(...),
@@ -1066,7 +811,6 @@ async def modelfit_file_ind(
1066
 
1067
  @app.post("/modelforecast-file_ind/")
1068
  async def modelforecast_file_ind(
1069
- _: str = Depends(metered("modelforecast-file_ind")),
1070
  df_forecast: UploadFile = File(...),
1071
  ):
1072
  global model_store
 
1
  # main.py
2
+ import json, traceback
3
  import pandas as pd
4
  import numpy as np
5
  import io, tempfile, textwrap
6
  import statsmodels.api as sm
7
 
8
+ from fastapi import FastAPI, HTTPException, Body, UploadFile, File, Form, Response, status, Request
9
  from fastapi.responses import FileResponse, PlainTextResponse, ORJSONResponse
10
  from fastapi.concurrency import run_in_threadpool
11
  from datetime import datetime
12
  from typing import List, Dict, Any, Optional
13
  from dataclasses import dataclass
 
 
 
 
 
 
 
 
 
14
 
15
  # Predefined globals
16
  stored_model = None
 
235
  out["forecast"] = out[f"{self.y}_forecast"]
236
  return out
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  def _maybe_json_list(s: Optional[str]):
239
  if s is None or s == "":
240
  return []
 
405
  # ---------- FastAPI app ----------
406
  app = FastAPI(
407
  title="DATFID API",
408
+ description="Public demo API",
409
  docs_url="/docs",
410
  redoc_url=None,
411
  default_response_class=ORJSONResponse,
412
  )
413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  # In-memory model state
415
  @dataclass
416
  class ModelSlot:
 
431
  def secure_ping():
432
  return {"ok": True}
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  # ---------- Model endpoints (guarded) ----------
435
  @app.post("/modelfit/")
436
  async def modelfit(
 
437
  df: List[Dict] = Body(...),
438
  id_col: str = Body(...),
439
  time_col: str = Body(...),
 
489
 
490
  @app.post("/modelforecast/")
491
  async def modelforecast(
 
492
  df_forecast: List[Dict] = Body(...),
493
  ):
494
  global stored_model, stored_result_join
 
504
 
505
  @app.post("/modelfit-file/")
506
  async def modelfit_file(
 
507
  file: UploadFile = File(...),
508
  id_col: str = Form(...),
509
  time_col: str = Form(...),
 
572
 
573
  @app.post("/modelforecast-file/")
574
  async def modelforecast_file(
 
575
  df_forecast: UploadFile = File(...),
576
  ):
577
  global stored_model, stored_result_join
 
618
 
619
  @app.post("/modelfit_ind/")
620
  async def modelfit_ind(
 
621
  df: List[Dict] = Body(...),
622
  id_col: str = Body(...),
623
  time_col: str = Body(...),
 
683
 
684
  @app.post("/modelforecast_ind/")
685
  async def modelforecast_ind(
 
686
  df_forecast: List[Dict] = Body(...),
687
  ):
688
  # Use per-ID models created by /modelfit_ind/
 
732
 
733
  @app.post("/modelfit-file_ind/")
734
  async def modelfit_file_ind(
 
735
  file: UploadFile = File(...),
736
  id_col: str = Form(...),
737
  time_col: str = Form(...),
 
811
 
812
  @app.post("/modelforecast-file_ind/")
813
  async def modelforecast_file_ind(
 
814
  df_forecast: UploadFile = File(...),
815
  ):
816
  global model_store