ModelForge CI
deploy: 2026-06-19 19:24 UTC
6761f70
Raw
History Blame Contribute Delete
51.4 kB
"""ModelForge API — FastAPI entrypoint."""
import json
import logging
import os
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
import pandas as pd
from fastapi import FastAPI, HTTPException, UploadFile, File, WebSocket, WebSocketDisconnect, Depends
from starlette.background import BackgroundTask
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, field_validator
from typing import Literal
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="ModelForge API", version="0.3.0")
_CORS_ORIGINS = [
o.strip()
for o in os.getenv("CORS_ORIGIN", "http://localhost:3000,http://localhost:3456").split(",")
if o.strip()
]
# Project-scoped regex: production + this project's Vercel PREVIEW deployments
# only (e.g. no-code-model-trainer-git-feature.vercel.app) — NOT every
# *.vercel.app / *.hf.space, which previously let any site on those hosts make
# credentialed requests. Override via CORS_ORIGIN_REGEX, or add exact origins
# through CORS_ORIGIN.
_CORS_ORIGIN_REGEX = os.getenv(
"CORS_ORIGIN_REGEX",
r"https://no-code-model-trainer[a-z0-9-]*\.vercel\.app",
)
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_origin_regex=_CORS_ORIGIN_REGEX,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
UPLOADS_DIR = Path(os.getenv("UPLOAD_DIR", str(Path(__file__).parent / "uploads")))
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# Known runs directory — artifact_path must stay within this tree
RUNS_DIR = Path(os.getenv("RUNS_DIR", str(Path(__file__).parent.parent / "agents" / "runs")))
# In-memory file registry: file_id → absolute Path.
# Rebuilt from .meta.json sidecars on startup so it survives server restarts.
_FILE_REGISTRY: dict[str, Path] = {}
# Pending clarifications: run_id → original ChatRequest params (+ "_expires_at").
# Populated when IntentAgent returns confidence < 0.7 and asks a clarifying question.
# Consumed (and removed) by POST /clarify/{run_id}.
# TTL-bounded so abandoned clarifications don't accumulate forever (issue #23).
_pending_clarifications: dict[str, dict] = {}
_CLARIFICATION_TTL_SECONDS = int(os.getenv("CLARIFICATION_TTL_SECONDS", "3600"))
_MAX_PENDING_CLARIFICATIONS = 500
def _purge_expired_clarifications() -> None:
now = time.monotonic()
for k in [k for k, v in _pending_clarifications.items() if v.get("_expires_at", 0) < now]:
_pending_clarifications.pop(k, None)
def _rebuild_registry_from_disk() -> None:
"""Scan UPLOADS_DIR for .meta.json sidecars and restore the in-memory registry."""
for meta_file in UPLOADS_DIR.glob("*.meta.json"):
try:
import json as _json
meta = _json.loads(meta_file.read_text())
file_id = meta.get("file_id")
data_path = Path(meta.get("data_path", ""))
if file_id and data_path.exists():
_FILE_REGISTRY[file_id] = data_path
except Exception as exc:
logger.warning("Could not restore registry entry from %s: %s", meta_file, exc)
if _FILE_REGISTRY:
logger.info("Restored %d file(s) to registry from disk", len(_FILE_REGISTRY))
_rebuild_registry_from_disk()
from auth import get_current_user
def _service_client():
"""Return a service-role Supabase client, or None if not configured."""
url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL")
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
if not url or not key:
return None
try:
from supabase import create_client
return create_client(url, key)
except Exception as exc:
logger.warning("Could not init Supabase service client: %s", exc)
return None
def _assert_run_owner(run_id: str, user: dict[str, Any] | None) -> None:
"""
Reject the request if `user` is not the owner of `run_id`.
Best-effort: when no user is attached (permissive rollout phase) or Supabase
is not configured, the check is skipped. When a verified user IS present and
the run is owned by someone else, raise 403.
"""
if not user:
return
sb = _service_client()
if sb is None:
return
try:
res = sb.table("runs").select("user_id").eq("id", run_id).limit(1).execute()
except Exception as exc:
logger.warning("Ownership lookup failed for run %s: %s", run_id, exc)
return
rows = res.data or []
if rows and rows[0].get("user_id") and rows[0]["user_id"] != user.get("id"):
raise HTTPException(403, "You do not have access to this run.")
# ── Per-user quotas (issue #29) ──────────────────────────────────────────────
_MAX_CONCURRENT_RUNS = int(os.getenv("MAX_CONCURRENT_RUNS", "2"))
_MAX_DAILY_RUNS = int(os.getenv("MAX_DAILY_RUNS", "25"))
# Only recent runs count toward the concurrency cap, so an orphaned 'running'
# row (until the #22 reconciliation lands) can't lock a user out permanently.
_ACTIVE_RUN_WINDOW_HOURS = 6
def _enforce_quota(
user: dict[str, Any] | None,
new_runs: int = 1,
exclude_run_id: str | None = None,
) -> None:
"""
Reject the request (429) if starting `new_runs` would exceed the user's
concurrency or daily run quota. Best-effort: skipped when unauthenticated
or Supabase is unavailable, so it never blocks the permissive rollout phase.
"""
if not user:
return
uid = user.get("id")
if not uid:
return
sb = _service_client()
if sb is None:
return
from datetime import timedelta
now = datetime.now(timezone.utc)
try:
active_cut = (now - timedelta(hours=_ACTIVE_RUN_WINDOW_HOURS)).isoformat()
q = (
sb.table("runs").select("id", count="exact")
.eq("user_id", uid)
.in_("status", ["pending", "running"])
.gte("created_at", active_cut)
)
if exclude_run_id:
q = q.neq("id", exclude_run_id)
active = q.execute().count or 0
except Exception as exc:
logger.warning("[quota] concurrency check failed for %s: %s", uid, exc)
return
if active + new_runs > _MAX_CONCURRENT_RUNS:
raise HTTPException(
429,
f"You already have {active} run(s) in progress (limit "
f"{_MAX_CONCURRENT_RUNS}). Wait for one to finish before starting more.",
)
try:
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
q = (
sb.table("runs").select("id", count="exact")
.eq("user_id", uid)
.gte("created_at", day_start)
)
if exclude_run_id:
q = q.neq("id", exclude_run_id)
today = q.execute().count or 0
except Exception as exc:
logger.warning("[quota] daily check failed for %s: %s", uid, exc)
return
if today + new_runs > _MAX_DAILY_RUNS:
raise HTTPException(
429,
f"Daily run limit reached ({_MAX_DAILY_RUNS} runs/day). "
"This resets at 00:00 UTC.",
)
# ── Orphaned-run reconciliation (issue #22) ──────────────────────────────────
# Training runs in-process, so a restart/redeploy kills the in-memory task but
# leaves the Supabase row stuck at 'running'/'pending' forever. On startup, fail
# any such row older than the threshold so the UI stops showing a phantom run.
_ORPHAN_RUN_MINUTES = int(os.getenv("ORPHAN_RUN_MINUTES", "30"))
def _reconcile_orphaned_runs() -> None:
sb = _service_client()
if sb is None:
return
from datetime import timedelta
now = datetime.now(timezone.utc)
cutoff = (now - timedelta(minutes=_ORPHAN_RUN_MINUTES)).isoformat()
try:
res = (
sb.table("runs")
.update({
"status": "failed",
"error_message": "Run was interrupted by a backend restart and could not be resumed.",
"completed_at": now.isoformat(),
})
.in_("status", ["running", "pending"])
.lt("created_at", cutoff)
.execute()
)
n = len(res.data or [])
if n:
logger.info("[startup] Reconciled %d orphaned run(s) → failed", n)
except Exception as exc:
logger.warning("[startup] orphan reconciliation failed: %s", exc)
_reconcile_orphaned_runs()
from services.socket_manager import manager as socket_manager
@app.websocket("/ws/{job_id}")
async def websocket_endpoint(websocket: WebSocket, job_id: str):
await socket_manager.connect(job_id, websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
socket_manager.disconnect(job_id, websocket)
except Exception as exc:
logger.warning("WebSocket error for job %s: %s", job_id, exc)
socket_manager.disconnect(job_id, websocket)
@app.post("/upload")
async def upload_dataset(
file: UploadFile = File(...),
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, Any]:
"""
Upload a dataset file. Returns metadata including sample rows, class
distribution, and data quality warnings — all without hitting the GPU.
"""
if not file.filename:
raise HTTPException(400, "No filename provided")
safe_name = Path(file.filename).name
if not safe_name:
raise HTTPException(400, "Invalid filename")
allowed = {".csv", ".json", ".jsonl", ".txt"}
suffix = Path(safe_name).suffix.lower()
if suffix not in allowed:
raise HTTPException(400, f"Unsupported file type {suffix}. Allowed: {', '.join(allowed)}")
file_id = str(uuid.uuid4())
dest = UPLOADS_DIR / f"{file_id}{suffix}"
content = await file.read()
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
if len(content) > MAX_UPLOAD_BYTES:
raise HTTPException(
413,
f"File too large ({len(content) // (1024 * 1024)} MB). Maximum allowed size is 50 MB.",
)
dest.write_bytes(content)
_FILE_REGISTRY[file_id] = dest
# Write sidecar so the registry survives server restarts
meta_file = UPLOADS_DIR / f"{file_id}.meta.json"
meta_file.write_text(json.dumps({
"file_id": file_id,
"original_name": safe_name,
"data_path": str(dest),
}))
try:
if suffix == ".csv":
df = pd.read_csv(dest)
elif suffix == ".jsonl":
df = pd.read_json(dest, lines=True)
elif suffix == ".json":
df = pd.read_json(dest)
else: # .txt — one text record per non-empty line
lines = [ln for ln in dest.read_text(encoding="utf-8").splitlines() if ln.strip()]
df = pd.DataFrame({"text": lines})
except Exception as exc:
dest.unlink(missing_ok=True)
meta_file.unlink(missing_ok=True)
_FILE_REGISTRY.pop(file_id, None)
raise HTTPException(422, f"Could not parse file: {exc}") from exc
text_cols = [
c for c in df.columns
if df[c].dtype == "object" and df[c].dropna().str.len().mean() > 10
]
label_cols = [
c for c in df.columns
if c.lower() in {"label", "target", "class", "sentiment", "category"}
]
# Class distribution for the first detected label column
class_distribution: dict[str, int] = {}
if label_cols:
class_distribution = df[label_cols[0]].astype(str).value_counts().to_dict()
# Text length stats for the first detected text column
text_length_stats: dict[str, float] = {}
if text_cols:
lengths = df[text_cols[0]].dropna().astype(str).str.len()
text_length_stats = {
"min": int(lengths.min()),
"max": int(lengths.max()),
"mean": round(float(lengths.mean()), 1),
"p50": round(float(lengths.quantile(0.50)), 1),
"p90": round(float(lengths.quantile(0.90)), 1),
"p99": round(float(lengths.quantile(0.99)), 1),
}
# Quick data quality warnings (pure Python, no GPU)
data_warnings: list[str] = []
total = len(df)
if total < 50:
data_warnings.append(f"Very small dataset ({total} rows) — results may be unreliable.")
elif total < 200:
data_warnings.append(f"Small dataset ({total} rows) — consider data augmentation.")
if label_cols and class_distribution:
counts = list(class_distribution.values())
if len(counts) > 1:
imbalance = max(counts) / max(min(counts), 1)
if imbalance > 10:
data_warnings.append(f"Severe class imbalance ({imbalance:.0f}:1 ratio) — weighted loss will be applied automatically.")
elif imbalance > 3:
data_warnings.append(f"Moderate class imbalance ({imbalance:.1f}:1 ratio) — consider oversampling.")
if text_cols and text_length_stats:
if text_length_stats.get("p90", 0) > 400:
data_warnings.append("Many texts exceed 400 chars — they will be truncated to model max_length.")
if text_length_stats.get("mean", 100) < 10:
data_warnings.append("Average text is very short — there may be insufficient signal for training.")
dup_count = int(df.duplicated().sum())
if dup_count > 0:
data_warnings.append(f"{dup_count} duplicate row(s) detected — CleanAgent will remove them.")
null_count = int(df[text_cols[0]].isna().sum()) if text_cols else 0
if null_count > 0:
data_warnings.append(f"{null_count} row(s) with empty text detected — will be removed before training.")
# Build histogram of text lengths (10 bins)
text_length_histogram: list[dict] = []
if text_cols and len(df) > 0:
lengths_series = df[text_cols[0]].dropna().astype(str).str.len()
try:
hist, bin_edges = _compute_histogram(lengths_series.tolist(), bins=10)
text_length_histogram = [
{"bin_start": int(bin_edges[i]), "bin_end": int(bin_edges[i + 1]), "count": int(hist[i])}
for i in range(len(hist))
]
except Exception:
pass
return {
"file_id": file_id,
"filename": safe_name,
"rows": len(df),
"columns": list(df.columns),
"text_columns": text_cols,
"label_columns": label_cols,
"sample_rows": df.sample(min(5, len(df)), random_state=42).to_dict("records"),
"unique_labels": (
df[label_cols[0]].dropna().unique().tolist() if label_cols else []
),
"class_distribution": class_distribution,
"text_length_stats": text_length_stats,
"text_length_histogram": text_length_histogram,
"data_warnings": data_warnings,
"duplicate_count": dup_count,
"null_count": null_count,
"file_size_bytes": len(content),
}
def _validate_file_id(file_id: str) -> str:
"""Security: ensure file_id is a valid UUID before touching the filesystem."""
try:
uuid.UUID(file_id)
except ValueError:
raise HTTPException(400, "Invalid file_id format.")
return file_id
class RenameDatasetRequest(BaseModel):
filename: str
@field_validator("filename")
@classmethod
def validate_filename(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("filename cannot be empty")
if "/" in v or "\\" in v or ".." in v:
raise ValueError("filename contains invalid characters")
return v
@app.delete("/datasets/{file_id}")
async def delete_dataset(
file_id: str,
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, str]:
"""Remove an uploaded dataset and its registry entry."""
_validate_file_id(file_id)
if file_id not in _FILE_REGISTRY:
raise HTTPException(404, f"Dataset {file_id} not found.")
data_path = _FILE_REGISTRY.pop(file_id)
data_path.unlink(missing_ok=True)
meta_file = UPLOADS_DIR / f"{file_id}.meta.json"
meta_file.unlink(missing_ok=True)
return {"status": "deleted", "file_id": file_id}
@app.patch("/datasets/{file_id}")
async def rename_dataset(
file_id: str,
req: RenameDatasetRequest,
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, str]:
"""Rename a dataset (updates the stored display name, not the physical file)."""
_validate_file_id(file_id)
if file_id not in _FILE_REGISTRY:
raise HTTPException(404, f"Dataset {file_id} not found.")
meta_file = UPLOADS_DIR / f"{file_id}.meta.json"
if meta_file.exists():
try:
meta = json.loads(meta_file.read_text())
meta["original_name"] = req.filename
meta_file.write_text(json.dumps(meta))
except Exception as exc:
logger.warning("Could not update meta for %s: %s", file_id, exc)
return {"status": "renamed", "file_id": file_id, "filename": req.filename}
def _compute_histogram(values: list, bins: int = 10) -> tuple[list[int], list[float]]:
"""Pure-Python histogram computation (avoids numpy import at module level)."""
if not values:
return [], []
mn, mx = min(values), max(values)
if mn == mx:
return [len(values)], [float(mn), float(mx) + 1]
step = (mx - mn) / bins
edges = [mn + i * step for i in range(bins + 1)]
counts = [0] * bins
for v in values:
idx = min(int((v - mn) / step), bins - 1)
counts[idx] += 1
return counts, edges
class ChatRequest(BaseModel):
message: str
file_id: str | None = None
run_id: str | None = None
hyperparameter_overrides: dict[str, Any] = {}
hf_token: str | None = None
# A3: set to a previous run_id to resume from its last checkpoint
resume_from_run_id: str | None = None
@field_validator("message")
@classmethod
def message_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("message cannot be empty")
return v.strip()
def _resolve_file_id(file_id: str | None) -> str | None:
if not file_id:
return None
path = _FILE_REGISTRY.get(file_id)
if path is None:
raise HTTPException(404, "Dataset not found. Please re-upload your file.")
return str(path)
@app.post("/chat")
async def chat(
req: ChatRequest,
user: dict[str, Any] | None = Depends(get_current_user),
) -> StreamingResponse:
"""
Stream agent responses as Server-Sent Events.
Pass resume_from_run_id to continue a pipeline that failed mid-way —
completed stages (Intent, Data, Clean, Model) are restored from the
checkpoint and skipped, so only the remaining stages execute.
"""
if req.run_id:
_assert_run_owner(req.run_id, user)
# Don't count a resume of an already-running run against the quota.
if not req.resume_from_run_id:
_enforce_quota(user, new_runs=1, exclude_run_id=req.run_id)
dataset_path = _resolve_file_id(req.file_id)
_agents_import()
async def event_stream():
try:
from agents.pipeline import TrainingPipeline
from services.run_event_writer import (
write_agent_event,
write_pipeline_checkpoint,
load_pipeline_checkpoint,
)
# A3: load checkpoint when resuming
initial_context: dict | None = None
if req.resume_from_run_id:
initial_context = await load_pipeline_checkpoint(req.resume_from_run_id)
if initial_context:
logger.info(
"Resuming run %s from checkpoint (completed: %s)",
req.resume_from_run_id,
initial_context.get("completed_stages", []),
)
pipeline = TrainingPipeline()
async for result, context in pipeline.run_streaming(
user_intent=req.message,
dataset_path=dataset_path,
hyperparameter_overrides=req.hyperparameter_overrides,
hf_token=req.hf_token,
run_id=req.run_id,
initial_context=initial_context,
):
data = json.dumps({
"agent": result.agent_name,
"success": result.success,
"message": result.message,
"output": result.output,
"metadata": result.metadata,
})
yield f"data: {data}\n\n"
# ── HITL: pause pipeline when IntentAgent needs clarification ──
# IntentAgent returns next_agent=None with clarification_needed set
# when confidence < 0.7. Store params so /clarify/{run_id} can resume.
if (
result.agent_name == "Intent"
and result.success
and result.output.get("clarification_needed")
and req.run_id
):
_purge_expired_clarifications()
if len(_pending_clarifications) >= _MAX_PENDING_CLARIFICATIONS:
oldest = min(
_pending_clarifications,
key=lambda k: _pending_clarifications[k].get("_expires_at", 0),
)
_pending_clarifications.pop(oldest, None)
_pending_clarifications[req.run_id] = {
"message": req.message,
"file_id": req.file_id,
"hyperparameter_overrides": req.hyperparameter_overrides,
"hf_token": req.hf_token,
"clarification_question": result.output["clarification_needed"],
"_expires_at": time.monotonic() + _CLARIFICATION_TTL_SECONDS,
}
logger.info(
"[%s] Clarification needed — run paused awaiting user response",
req.run_id,
)
if req.run_id:
# Always persist the agent event
await write_agent_event(
run_id=req.run_id,
agent_name=result.agent_name,
success=result.success,
message=result.message,
output=result.output,
)
# A3: after each successful stage, checkpoint the full context
# so a resume can skip already-completed work
if result.success:
await write_pipeline_checkpoint(
run_id=req.run_id,
completed_stages=list(context.completed_stages),
checkpoint_data=pipeline.context_snapshot(context),
)
if not result.success:
break
except Exception as exc:
logger.error("Agent pipeline error: %s", exc, exc_info=True)
err_payload = json.dumps({
"agent": "System", "success": False,
"message": "An internal error occurred. Please try again.", "output": {},
})
yield f"data: {err_payload}\n\n"
finally:
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
class ClarifyRequest(BaseModel):
user_response: str
@field_validator("user_response")
@classmethod
def response_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("user_response cannot be empty")
return v.strip()
@app.post("/clarify/{run_id}")
async def clarify_intent(
run_id: str,
req: ClarifyRequest,
user: dict[str, Any] | None = Depends(get_current_user),
) -> StreamingResponse:
"""
Resume a pipeline that paused waiting for user clarification.
The original intent is amended with the user's response and the pipeline
restarts from IntentAgent with the enriched intent. Returns SSE.
"""
params = _pending_clarifications.pop(run_id, None)
if params is not None and params.get("_expires_at", 0) < time.monotonic():
params = None # expired — treat as not found
if params is None:
raise HTTPException(
404,
f"No pending clarification found for run_id={run_id}. "
"The session may have timed out or already been resumed."
)
original_intent = params["message"]
amended_intent = (
f"{original_intent}\n\n"
f"User clarification: {req.user_response}"
)
file_id = params.get("file_id")
overrides = params.get("hyperparameter_overrides", {})
hf_token = params.get("hf_token")
dataset_path = _resolve_file_id(file_id)
_agents_import()
async def clarify_stream():
try:
from agents.pipeline import TrainingPipeline
from services.run_event_writer import write_agent_event, write_pipeline_checkpoint
pipeline = TrainingPipeline()
async for result, context in pipeline.run_streaming(
user_intent=amended_intent,
dataset_path=dataset_path,
hyperparameter_overrides=overrides,
hf_token=hf_token,
run_id=run_id,
):
data = json.dumps({
"agent": result.agent_name,
"success": result.success,
"message": result.message,
"output": result.output,
"metadata": result.metadata,
})
yield f"data: {data}\n\n"
await write_agent_event(
run_id=run_id,
agent_name=result.agent_name,
success=result.success,
message=result.message,
output=result.output,
)
if result.success:
await write_pipeline_checkpoint(
run_id=run_id,
completed_stages=list(context.completed_stages),
checkpoint_data=pipeline.context_snapshot(context),
)
if not result.success:
break
except Exception as exc:
logger.error("Clarify pipeline error: %s", exc, exc_info=True)
yield f"data: {json.dumps({'agent': 'System', 'success': False, 'message': str(exc), 'output': {}})}\n\n"
finally:
yield "data: [DONE]\n\n"
return StreamingResponse(clarify_stream(), media_type="text/event-stream")
@app.post("/train")
async def start_training_job_deprecated() -> None:
"""
Deprecated legacy training endpoint.
Use POST /chat with the agent pipeline instead.
"""
raise HTTPException(
status_code=410,
detail="This endpoint is deprecated. Use POST /chat with the agent pipeline for training.",
)
def _agents_import():
agents_path = Path(__file__).parent.parent / "agents"
if str(agents_path) not in sys.path:
sys.path.insert(0, str(agents_path))
@app.post("/train/{run_id}/cancel")
async def cancel_training(
run_id: str,
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, str]:
"""Signal an active training run to stop at the next step boundary."""
_assert_run_owner(run_id, user)
_agents_import()
from agents.train_agent import cancel_run
found = cancel_run(run_id)
if not found:
raise HTTPException(404, f"No active training run found for run_id={run_id}")
return {"status": "cancelling", "run_id": run_id}
@app.post("/train/{run_id}/pause")
async def pause_training(
run_id: str,
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, str]:
"""Block training at the next step boundary."""
_assert_run_owner(run_id, user)
_agents_import()
from agents.train_agent import pause_run, is_paused
if is_paused(run_id):
raise HTTPException(409, "Training is already paused.")
found = pause_run(run_id)
if not found:
raise HTTPException(404, f"No active training run found for run_id={run_id}")
return {"status": "paused", "run_id": run_id}
@app.post("/train/{run_id}/resume")
async def resume_training(
run_id: str,
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, str]:
"""Unblock a paused training run."""
_assert_run_owner(run_id, user)
_agents_import()
from agents.train_agent import resume_run, is_paused
if not is_paused(run_id):
raise HTTPException(409, "Training is not paused.")
found = resume_run(run_id)
if not found:
raise HTTPException(404, f"No active training run found for run_id={run_id}")
return {"status": "resumed", "run_id": run_id}
# ─────────────────────────────────────────────────────────────────────────────
# Hyperparameter Sweep
# ─────────────────────────────────────────────────────────────────────────────
_SWEEP_MAX_RUNS = 12 # hard cap to prevent runaway sweeps
class SweepConfig(BaseModel):
lr_values: list[float] = []
batch_values: list[int] = []
epoch_values: list[int] = []
lora_r_values: list[int] = []
class SweepRequest(BaseModel):
message: str
file_id: str | None = None
hf_token: str | None = None
parent_run_id: str | None = None
hyperparameter_overrides: dict[str, Any] = {}
sweep_config: SweepConfig
@field_validator("message")
@classmethod
def message_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("message cannot be empty")
return v.strip()
def _build_sweep_combos(cfg: SweepConfig, base: dict[str, Any]) -> list[dict[str, Any]]:
"""Cartesian product of all non-empty sweep_config lists merged into base overrides."""
import itertools
axes: list[tuple[str, list[Any]]] = []
if cfg.lr_values:
axes.append(("learning_rate", cfg.lr_values))
if cfg.batch_values:
axes.append(("batch_size", cfg.batch_values))
if cfg.epoch_values:
axes.append(("num_epochs", cfg.epoch_values))
if cfg.lora_r_values:
axes.append(("lora_r", cfg.lora_r_values))
if not axes:
return [dict(base)]
keys = [k for k, _ in axes]
values = [v for _, v in axes]
return [
{**base, **dict(zip(keys, combo))}
for combo in itertools.product(*values)
]
async def _run_sweep_child(
run_id: str,
message: str,
dataset_path: str | None,
overrides: dict[str, Any],
hf_token: str | None,
sweep_id: str,
sweep_config_combo: dict[str, Any],
) -> None:
"""Train one child run of a sweep. Updates Supabase run record on completion."""
_agents_import()
from agents.pipeline import TrainingPipeline
from services.run_event_writer import write_agent_event, write_pipeline_checkpoint
supabase_url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
sb = None
if supabase_url and supabase_key:
try:
from supabase import create_client
sb = create_client(supabase_url, supabase_key)
except Exception:
pass
if sb:
sb.table("runs").update({"status": "running"}).eq("id", run_id).execute()
all_results: list[Any] = []
pipeline_success = True
try:
pipeline = TrainingPipeline()
async for result, context in pipeline.run_streaming(
user_intent=message,
dataset_path=dataset_path,
hyperparameter_overrides=overrides,
hf_token=hf_token,
run_id=run_id,
):
all_results.append(result)
await write_agent_event(
run_id=run_id,
agent_name=result.agent_name,
success=result.success,
message=result.message,
output=result.output,
)
if result.success:
await write_pipeline_checkpoint(
run_id=run_id,
completed_stages=list(context.completed_stages),
checkpoint_data=pipeline.context_snapshot(context),
)
if not result.success:
pipeline_success = False
break
except Exception as exc:
logger.error("[sweep][%s] child run error: %s", run_id, exc, exc_info=True)
pipeline_success = False
if sb:
intent_out = next((r.output for r in all_results if r.agent_name == "Intent"), {})
model_out = next((r.output for r in all_results if r.agent_name == "Model"), {})
train_out = next((r.output for r in all_results if r.agent_name == "Train" and r.output.get("final") is not False), {})
eval_out = next((r.output for r in all_results if r.agent_name == "Eval"), {})
m_src = eval_out if eval_out else train_out
try:
sb.table("runs").update({
"status": "completed" if pipeline_success else "failed",
"task_type": intent_out.get("task_type"),
"model_id": model_out.get("base_model") or intent_out.get("base_model_hint"),
"intent_spec": intent_out,
"model_recipe": model_out,
"metrics": {
"accuracy": m_src.get("accuracy"),
"f1": m_src.get("f1"),
"precision": m_src.get("precision"),
"recall": m_src.get("recall"),
"evaluation_grade": eval_out.get("evaluation_grade"),
"difficulty_tier": eval_out.get("difficulty_tier"),
"grade_rationale": eval_out.get("grade_rationale"),
"summary": eval_out.get("summary"),
"strengths": eval_out.get("strengths"),
"concerns": eval_out.get("concerns"),
"next_steps": eval_out.get("next_steps"),
},
"artifact_path": train_out.get("artifact_path"),
"sweep_config": sweep_config_combo,
"completed_at": datetime.now(timezone.utc).isoformat(),
}).eq("id", run_id).execute()
except Exception as exc:
logger.warning("[sweep][%s] failed to update run record: %s", run_id, exc)
@app.post("/sweep")
async def launch_sweep(
req: SweepRequest,
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, Any]:
"""
Launch N parallel training runs with different hyperparameter combos.
Returns immediately with sweep_id and run_ids.
Each child run updates its Supabase row when it completes.
"""
combos = _build_sweep_combos(req.sweep_config, req.hyperparameter_overrides)
if not combos:
raise HTTPException(400, "sweep_config must specify at least one parameter list (lr_values, batch_values, epoch_values, or lora_r_values)")
if len(combos) > _SWEEP_MAX_RUNS:
raise HTTPException(
400,
f"Sweep would produce {len(combos)} runs (max {_SWEEP_MAX_RUNS}). "
"Reduce the number of values per parameter."
)
_enforce_quota(user, new_runs=len(combos))
sweep_id = str(uuid.uuid4())
dataset_path = _resolve_file_id(req.file_id)
# Create one Supabase run row per combo
supabase_url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
sb = None
if supabase_url and supabase_key:
try:
from supabase import create_client
sb = create_client(supabase_url, supabase_key)
except Exception:
pass
# runs.user_id is a NOT NULL uuid FK to auth.users — only the backend can
# create rows when it knows the authenticated owner. Without a verified user
# (permissive rollout phase) the frontend is responsible for creating the
# rows, and the child runs below will UPDATE them by id.
owner_id = user.get("id") if user else None
run_ids: list[str] = []
for combo in combos:
run_id = str(uuid.uuid4())
if sb and owner_id:
try:
result = sb.table("runs").insert({
"id": run_id,
"user_id": owner_id,
"status": "pending",
"sweep_id": sweep_id,
"parent_run_id": req.parent_run_id,
"sweep_config": combo,
}).execute()
# Use the DB-assigned id if available
if result.data:
run_id = result.data[0].get("id", run_id)
except Exception as exc:
logger.warning("[sweep] failed to create run row: %s", exc)
run_ids.append(run_id)
# Fire all child runs concurrently (fire-and-forget)
import asyncio as _asyncio
for run_id, combo in zip(run_ids, combos):
_asyncio.ensure_future(
_run_sweep_child(
run_id=run_id,
message=req.message,
dataset_path=dataset_path,
overrides=combo,
hf_token=req.hf_token,
sweep_id=sweep_id,
sweep_config_combo=combo,
)
)
logger.info("[sweep:%s] launched %d child runs", sweep_id, len(run_ids))
return {"sweep_id": sweep_id, "run_ids": run_ids, "total": len(run_ids)}
@app.get("/status/{job_id}")
async def get_status(job_id: str) -> dict[str, Any]:
"""Deprecated — training progress is streamed via SSE on POST /chat."""
raise HTTPException(
410,
"This endpoint is deprecated. Training progress is streamed in real-time "
"via Server-Sent Events on POST /chat — connect to that stream instead.",
)
class InferRequest(BaseModel):
run_id: str
text: str
artifact_path: str
label_names: list[str] = []
@field_validator("text")
@classmethod
def text_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("text cannot be empty")
return v.strip()
@field_validator("run_id")
@classmethod
def run_id_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("run_id cannot be empty")
return v.strip()
@field_validator("artifact_path")
@classmethod
def artifact_path_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("artifact_path cannot be empty")
return v.strip()
def _validate_artifact_path(artifact_path: str, run_id: str) -> Path:
"""
Security check: artifact_path must resolve to a path within RUNS_DIR.
Prevents path traversal attacks where a user supplies an arbitrary filesystem path.
"""
resolved = Path(artifact_path).resolve()
runs_resolved = RUNS_DIR.resolve()
# Also allow paths relative to the agents directory (agent pipeline output)
agents_runs = (Path(__file__).parent.parent / "agents" / "runs").resolve()
def _within(child: Path, parent: Path) -> bool:
# True if child is the dir itself or genuinely nested under it.
# Path containment (not string prefix) so a sibling like `runs_evil`
# cannot satisfy the check by sharing a name prefix.
return child == parent or parent in child.parents
if not (_within(resolved, runs_resolved) or _within(resolved, agents_runs)):
logger.warning(
"Rejected artifact_path outside RUNS_DIR: run_id=%s path=%s",
run_id, artifact_path,
)
raise HTTPException(422, "Invalid model artifact path.")
return resolved
@app.post("/infer")
async def run_inference(
req: InferRequest,
user: dict[str, Any] | None = Depends(get_current_user),
) -> dict[str, Any]:
"""Run classification inference on a trained model."""
import asyncio
from services.inference_cache import cache
_assert_run_owner(req.run_id, user)
if len(req.text) > 2000:
raise HTTPException(
400,
f"Text is too long ({len(req.text)} chars). Maximum 2000 characters.",
)
# Security: validate artifact_path is within the known runs directory
_validate_artifact_path(req.artifact_path, req.run_id)
try:
result = await cache.predict(
run_id=req.run_id,
text=req.text,
artifact_path=req.artifact_path,
label_names=req.label_names,
)
return result
except FileNotFoundError as exc:
raise HTTPException(422, str(exc)) from exc
except asyncio.TimeoutError as exc:
raise HTTPException(504, str(exc)) from exc
except RuntimeError as exc:
logger.error("Inference runtime error for run %s: %s", req.run_id, exc, exc_info=True)
raise HTTPException(500, "Model inference failed. Please try again.") from exc
except Exception as exc:
logger.error("Inference error for run %s: %s", req.run_id, exc, exc_info=True)
raise HTTPException(500, "An unexpected error occurred during inference.") from exc
class ExportRequest(BaseModel):
run_id: str
artifact_path: str
format: Literal["onnx", "torchscript"] = "onnx"
opset_version: int = 14
optimize: bool = True
@field_validator("run_id", "artifact_path")
@classmethod
def not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("field cannot be empty")
return v.strip()
@app.post("/export")
async def export_model_endpoint(
req: ExportRequest,
user: dict[str, Any] | None = Depends(get_current_user),
) -> FileResponse:
"""Convert a trained model to ONNX or TorchScript and return as a download."""
import asyncio
import shutil
_assert_run_owner(req.run_id, user)
validated_path = _validate_artifact_path(req.artifact_path, req.run_id)
from services.model_exporter import export_model
try:
out_file: Path = await asyncio.to_thread(
export_model,
artifact_path=str(validated_path),
export_format=req.format,
opset_version=req.opset_version,
optimize=req.optimize,
)
except FileNotFoundError as exc:
raise HTTPException(422, str(exc)) from exc
except RuntimeError as exc:
logger.error("Export failed for run %s: %s", req.run_id, exc, exc_info=True)
raise HTTPException(500, f"Export failed: {exc}") from exc
except Exception as exc:
logger.error("Unexpected export error for run %s: %s", req.run_id, exc, exc_info=True)
raise HTTPException(500, "An unexpected error occurred during export.") from exc
suffix = ".onnx" if req.format == "onnx" else ".pt"
filename = f"model_{req.run_id[:8]}{suffix}"
tmp_parent = out_file.parent
def cleanup() -> None:
shutil.rmtree(tmp_parent, ignore_errors=True)
return FileResponse(
path=str(out_file),
media_type="application/octet-stream",
filename=filename,
background=BackgroundTask(cleanup),
)
@app.get("/models")
async def get_models(
task_type: str | None = None,
category: str | None = None,
max_params_m: int | None = None,
provider: str | None = None,
lora_only: bool = False,
q: str | None = None,
) -> list[dict[str, Any]]:
"""Return filtered model catalog."""
_agents_import()
from agents.model_catalog import filter_catalog
results = filter_catalog(
task_type=task_type,
category=category,
max_params_m=max_params_m,
provider=provider,
lora_only=lora_only,
)
if q:
ql = q.lower()
results = [
m for m in results
if ql in m["display_name"].lower()
or ql in m["description"].lower()
or ql in m.get("best_for", "").lower()
or ql in m["model_id"].lower()
or any(ql in tag for tag in m.get("tags", []))
]
return results
@app.get("/leaderboard")
async def get_leaderboard(limit: int = 50) -> list[dict[str, Any]]:
"""
Community leaderboard: completed runs aggregated by model_id, ranked by best F1.
Uses the service-role key so it reads across all users (RLS bypassed intentionally —
only anonymised aggregate stats are returned, no user_id or run_id exposed).
Returns [] gracefully when Supabase is not configured.
"""
from collections import defaultdict
supabase_url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
if not supabase_url or not supabase_key:
return []
try:
from supabase import create_client
sb = create_client(supabase_url, supabase_key)
except Exception as exc:
logger.warning("[leaderboard] supabase init failed: %s", exc)
return []
try:
result = (
sb.table("runs")
.select("model_id, task_type, metrics, completed_at")
.eq("status", "completed")
.not_.is_("model_id", "null")
.order("completed_at", desc=True)
.limit(2000)
.execute()
)
except Exception as exc:
logger.warning("[leaderboard] query failed: %s", exc)
return []
runs = result.data or []
# Aggregate per model_id
agg: dict[str, dict[str, Any]] = defaultdict(lambda: {
"f1_scores": [],
"accuracy_scores": [],
"task_types": set(),
"run_count": 0,
"last_run_at": None,
})
for run in runs:
mid = run.get("model_id")
if not mid:
continue
metrics = run.get("metrics") or {}
f1 = metrics.get("f1")
acc = metrics.get("accuracy")
task = run.get("task_type")
finished = run.get("completed_at")
bucket = agg[mid]
bucket["run_count"] += 1
if isinstance(f1, (int, float)):
bucket["f1_scores"].append(float(f1))
if isinstance(acc, (int, float)):
bucket["accuracy_scores"].append(float(acc))
if task:
bucket["task_types"].add(task)
if finished and (bucket["last_run_at"] is None or finished > bucket["last_run_at"]):
bucket["last_run_at"] = finished
# Merge with catalog metadata
_agents_import()
from agents.model_catalog import get_model
entries: list[dict[str, Any]] = []
for model_id, stats in agg.items():
cat = get_model(model_id) or {}
f1s = stats["f1_scores"]
accs = stats["accuracy_scores"]
entries.append({
"model_id": model_id,
"display_name": cat.get("display_name", model_id),
"category": cat.get("category", "unknown"),
"provider": cat.get("provider", "unknown"),
"param_count": cat.get("param_count", ""),
"quality_tier": cat.get("quality_tier", ""),
"lora_compatible": cat.get("lora_compatible", False),
"run_count": stats["run_count"],
"best_f1": round(max(f1s), 4) if f1s else None,
"avg_f1": round(sum(f1s) / len(f1s), 4) if f1s else None,
"avg_accuracy": round(sum(accs) / len(accs), 4) if accs else None,
"task_types": sorted(stats["task_types"]),
"last_run_at": stats["last_run_at"],
})
# Sort: best_f1 desc (None last), then run_count desc as tiebreaker
entries.sort(key=lambda e: (e["best_f1"] is None, -(e["best_f1"] or 0), -e["run_count"]))
for i, entry in enumerate(entries[:limit], 1):
entry["rank"] = i
return entries[:limit]
@app.get("/runs/{run_id}/script")
async def download_training_script(
run_id: str,
user: dict[str, Any] | None = Depends(get_current_user),
) -> StreamingResponse:
"""
Generate and download a standalone Python training script for a completed run.
Loads the pipeline checkpoint from Supabase (task_spec + model_recipe +
training_result), generates a copy-paste-runnable script via code_generator,
and returns it as a .py file download.
"""
from services.run_event_writer import load_pipeline_checkpoint
_assert_run_owner(run_id, user)
checkpoint = await load_pipeline_checkpoint(run_id)
if not checkpoint:
raise HTTPException(
404,
f"No pipeline checkpoint found for run_id={run_id}. "
"The run may not have completed or Supabase is not configured."
)
task_spec = checkpoint.get("task_spec") or {}
data_profile = checkpoint.get("data_profile") or {}
model_recipe = checkpoint.get("model_recipe") or {}
training_result = checkpoint.get("training_result") or {}
if not model_recipe or not training_result:
raise HTTPException(
422,
"Run did not complete training — no model recipe or training result available."
)
_agents_import()
from agents.services.code_generator import generate_training_script, CodeGenerationError
try:
script = generate_training_script(
task_spec=task_spec,
data_profile=data_profile,
model_recipe=model_recipe,
training_result=training_result,
)
except CodeGenerationError as exc:
logger.error("Script generation failed for run %s: %s", run_id, exc)
raise HTTPException(500, f"Could not generate training script: {exc}") from exc
filename = f"train_{run_id[:8]}.py"
return StreamingResponse(
iter([script]),
media_type="text/x-python",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "version": app.version}