pipelab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. pipelab/__init__.py +5 -0
  2. pipelab/api/__init__.py +3 -0
  3. pipelab/api/app.py +174 -0
  4. pipelab/api/routers/__init__.py +3 -0
  5. pipelab/api/routers/datasets.py +79 -0
  6. pipelab/api/routers/deployments.py +175 -0
  7. pipelab/api/routers/experiments.py +162 -0
  8. pipelab/api/routers/models.py +51 -0
  9. pipelab/api/routers/monitoring.py +43 -0
  10. pipelab/api/routers/pipelines.py +379 -0
  11. pipelab/api/routers/projects.py +103 -0
  12. pipelab/api/routers/settings.py +24 -0
  13. pipelab/api/schemas.py +215 -0
  14. pipelab/cli.py +116 -0
  15. pipelab/discovery.py +229 -0
  16. pipelab/entities/__init__.py +50 -0
  17. pipelab/entities/alert.py +16 -0
  18. pipelab/entities/data_context.py +33 -0
  19. pipelab/entities/data_split.py +16 -0
  20. pipelab/entities/dataset.py +17 -0
  21. pipelab/entities/dataset_version.py +20 -0
  22. pipelab/entities/deployment.py +20 -0
  23. pipelab/entities/discovered_service.py +16 -0
  24. pipelab/entities/experiment.py +16 -0
  25. pipelab/entities/metric.py +21 -0
  26. pipelab/entities/model.py +17 -0
  27. pipelab/entities/model_registry.py +20 -0
  28. pipelab/entities/monitoring.py +19 -0
  29. pipelab/entities/parameter.py +13 -0
  30. pipelab/entities/pipeline.py +31 -0
  31. pipelab/entities/pipeline_context.py +35 -0
  32. pipelab/entities/project.py +14 -0
  33. pipelab/entities/project_config.py +22 -0
  34. pipelab/entities/run.py +25 -0
  35. pipelab/entities/services.py +102 -0
  36. pipelab/infrastructure/__init__.py +3 -0
  37. pipelab/infrastructure/mlflow_client.py +33 -0
  38. pipelab/pipeline.py +175 -0
  39. pipelab/pipelines/__init__.py +7 -0
  40. pipelab/pipelines/engine.py +154 -0
  41. pipelab/plugins/__init__.py +7 -0
  42. pipelab/plugins/plugin.py +49 -0
  43. pipelab/services/__init__.py +3 -0
  44. pipelab/services/interfaces.py +214 -0
  45. pipelab/services/mlflow_dataset_service.py +143 -0
  46. pipelab/services/mlflow_deployment_service.py +87 -0
  47. pipelab/services/mlflow_experiment_service.py +119 -0
  48. pipelab/services/mlflow_model_registry_service.py +103 -0
  49. pipelab/services/mlflow_monitoring_service.py +83 -0
  50. pipelab/services/mlflow_project_service.py +63 -0
  51. pipelab/static/assets/index-D6fc7pTq.css +1 -0
  52. pipelab/static/assets/index-DTeahxud.js +134 -0
  53. pipelab/static/favicon.svg +23 -0
  54. pipelab/static/icons.svg +27 -0
  55. pipelab/static/index.html +18 -0
  56. pipelab-0.1.0.dist-info/METADATA +376 -0
  57. pipelab-0.1.0.dist-info/RECORD +61 -0
  58. pipelab-0.1.0.dist-info/WHEEL +5 -0
  59. pipelab-0.1.0.dist-info/entry_points.txt +2 -0
  60. pipelab-0.1.0.dist-info/licenses/LICENSE +202 -0
  61. pipelab-0.1.0.dist-info/top_level.txt +1 -0
pipelab/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
4
+ """pipelab — ML Pipeline Management Platform."""
5
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
pipelab/api/app.py ADDED
@@ -0,0 +1,174 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
4
+ """FastAPI application factory and service registry."""
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi import FastAPI
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.staticfiles import StaticFiles
15
+ from starlette.exceptions import HTTPException
16
+
17
+
18
+ class SpaStaticFiles(StaticFiles):
19
+ """StaticFiles que faz fallback para index.html em rotas do SPA.
20
+
21
+ Paths sem extensão (ex.: /projects, /dashboard) devolvem index.html para
22
+ que o React Router cuide do roteamento no cliente. Assets com extensão
23
+ ausentes continuam retornando 404 apropriado.
24
+ """
25
+
26
+ async def get_response(self, path: str, scope):
27
+ try:
28
+ return await super().get_response(path, scope)
29
+ except HTTPException as exc:
30
+ if exc.status_code != 404:
31
+ raise
32
+ last_segment = path.rsplit("/", 1)[-1]
33
+ if "." in last_segment:
34
+ raise
35
+ return await super().get_response("index.html", scope)
36
+
37
+ from pipelab.infrastructure.mlflow_client import configure as configure_mlflow
38
+ from pipelab.plugins.plugin import PipelabPlugin
39
+ from pipelab.discovery import discover_projects, discover_services, ProjectConfig, DiscoveredService
40
+
41
+ # Service singletons
42
+ from pipelab.services.mlflow_project_service import MlflowProjectService
43
+ from pipelab.services.mlflow_dataset_service import MlflowDatasetService
44
+ from pipelab.services.mlflow_experiment_service import MlflowExperimentService
45
+ from pipelab.services.mlflow_model_registry_service import MlflowModelRegistryService
46
+ from pipelab.services.mlflow_deployment_service import MlflowDeploymentService
47
+ from pipelab.services.mlflow_monitoring_service import MlflowMonitoringService
48
+
49
+ _services: dict[str, Any] = {}
50
+ _plugins: list[PipelabPlugin] = []
51
+
52
+ # Discovered projects and their services
53
+ _projects: list[ProjectConfig] = []
54
+ _discovered_services: dict[str, list[DiscoveredService]] = {} # project_name -> services
55
+
56
+
57
+ def get_service(name: str) -> Any:
58
+ return _services[name]
59
+
60
+
61
+ def get_projects() -> list[ProjectConfig]:
62
+ return _projects
63
+
64
+
65
+ def get_discovered_services(project_name: str) -> list[DiscoveredService]:
66
+ return _discovered_services.get(project_name, [])
67
+
68
+
69
+ def get_project(project_name: str) -> ProjectConfig | None:
70
+ for p in _projects:
71
+ if p.name == project_name:
72
+ return p
73
+ return None
74
+
75
+
76
+ def register_plugin(plugin: PipelabPlugin) -> None:
77
+ _plugins.append(plugin)
78
+
79
+
80
+ def get_workdir() -> str:
81
+ """Return the active workdir path."""
82
+ return os.environ.get("PIPELAB_WORKDIR", os.getcwd())
83
+
84
+
85
+ def run_discovery() -> None:
86
+ """Re-scan the workdir for projects and services."""
87
+ global _projects, _discovered_services
88
+ workdir = get_workdir()
89
+ _projects = discover_projects(workdir)
90
+ _discovered_services = {}
91
+ for project in _projects:
92
+ _discovered_services[project.name] = discover_services(project)
93
+
94
+
95
+ def refresh_discovery() -> None:
96
+ """Alias for run_discovery (backward compat)."""
97
+ run_discovery()
98
+
99
+
100
+ @asynccontextmanager
101
+ async def lifespan(app: FastAPI):
102
+ # Initialize MLflow — resolve relative tracking URI against workdir
103
+ tracking_uri = os.environ.get("MLFLOW_TRACKING_URI", "mlruns")
104
+ workdir = get_workdir()
105
+ if not tracking_uri.startswith(("/", "http", "sqlite", "file:")):
106
+ # Relative path — resolve against workdir
107
+ tracking_uri = str(Path(workdir) / tracking_uri)
108
+ configure_mlflow(tracking_uri)
109
+
110
+ # Register internal services
111
+ _services["projects"] = MlflowProjectService()
112
+ _services["datasets"] = MlflowDatasetService()
113
+ _services["experiments"] = MlflowExperimentService()
114
+ _services["models"] = MlflowModelRegistryService()
115
+ _services["deployments"] = MlflowDeploymentService()
116
+ _services["monitoring"] = MlflowMonitoringService()
117
+
118
+ # Discover projects and user pipeline services
119
+ refresh_discovery()
120
+
121
+ # Register plugins
122
+ for plugin in _plugins:
123
+ plugin.on_register()
124
+ overrides = plugin.get_services()
125
+ if overrides:
126
+ _services.update(overrides)
127
+
128
+ yield
129
+
130
+
131
+ def create_app() -> FastAPI:
132
+ app = FastAPI(
133
+ title="PipeLab",
134
+ description="ML Pipeline Management Platform",
135
+ version="0.1.0",
136
+ lifespan=lifespan,
137
+ )
138
+
139
+ # CORS
140
+ app.add_middleware(
141
+ CORSMiddleware,
142
+ allow_origins=["*"],
143
+ allow_credentials=True,
144
+ allow_methods=["*"],
145
+ allow_headers=["*"],
146
+ )
147
+
148
+ # API routers
149
+ from pipelab.api.routers import (
150
+ projects, datasets, experiments, models,
151
+ pipelines, deployments, monitoring, settings,
152
+ )
153
+ app.include_router(projects.router, prefix="/api/v1/projects", tags=["Projects"])
154
+ app.include_router(datasets.router, prefix="/api/v1/datasets", tags=["Datasets"])
155
+ app.include_router(experiments.router, prefix="/api/v1/experiments", tags=["Experiments"])
156
+ app.include_router(models.router, prefix="/api/v1/models", tags=["Models"])
157
+ app.include_router(pipelines.router, prefix="/api/v1/pipelines", tags=["Pipelines"])
158
+ app.include_router(deployments.router, prefix="/api/v1/deployments", tags=["Deployments"])
159
+ app.include_router(monitoring.router, prefix="/api/v1/monitoring", tags=["Monitoring"])
160
+ app.include_router(settings.router, prefix="/api/v1/settings", tags=["Settings"])
161
+
162
+ # Plugin routes
163
+ for plugin in _plugins:
164
+ routes = plugin.get_routes()
165
+ if routes:
166
+ for r in routes:
167
+ app.include_router(r, prefix=f"/api/v1/plugins/{plugin.name}")
168
+
169
+ # Serve React static files
170
+ static_dir = Path(__file__).resolve().parent.parent / "static"
171
+ if static_dir.is_dir():
172
+ app.mount("/", SpaStaticFiles(directory=str(static_dir), html=True), name="static")
173
+
174
+ return app
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
@@ -0,0 +1,79 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
4
+ """Datasets API router — CRUD, versioning, splits."""
5
+ from __future__ import annotations
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+
9
+ from pipelab.api.app import get_service
10
+ from pipelab.api.schemas import (
11
+ DatasetCreate, DatasetOut,
12
+ DatasetVersionCreate, DatasetVersionOut,
13
+ DataSplitCreate, DataSplitOut,
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get("/", response_model=list[DatasetOut])
20
+ async def list_datasets(project_id: str | None = None):
21
+ svc = get_service("datasets")
22
+ return [DatasetOut(**{k: getattr(d, k) for k in DatasetOut.model_fields})
23
+ for d in svc.list_datasets(project_id)]
24
+
25
+
26
+ @router.get("/{dataset_id}", response_model=DatasetOut)
27
+ async def get_dataset(dataset_id: str):
28
+ svc = get_service("datasets")
29
+ d = svc.get_dataset(dataset_id)
30
+ if d is None:
31
+ raise HTTPException(404, "Dataset not found")
32
+ return DatasetOut(**{k: getattr(d, k) for k in DatasetOut.model_fields})
33
+
34
+
35
+ @router.post("/", response_model=DatasetOut, status_code=201)
36
+ async def create_dataset(body: DatasetCreate):
37
+ svc = get_service("datasets")
38
+ d = svc.create_dataset(body.label, body.description, body.project_id)
39
+ return DatasetOut(**{k: getattr(d, k) for k in DatasetOut.model_fields})
40
+
41
+
42
+ @router.delete("/{dataset_id}")
43
+ async def delete_dataset(dataset_id: str):
44
+ svc = get_service("datasets")
45
+ if not svc.delete_dataset(dataset_id):
46
+ raise HTTPException(404, "Dataset not found")
47
+ return {"ok": True}
48
+
49
+
50
+ # --- Versions -----------------------------------------------------------------
51
+
52
+ @router.get("/{dataset_id}/versions", response_model=list[DatasetVersionOut])
53
+ async def list_versions(dataset_id: str):
54
+ svc = get_service("datasets")
55
+ return [DatasetVersionOut(**{k: getattr(v, k) for k in DatasetVersionOut.model_fields})
56
+ for v in svc.list_versions(dataset_id)]
57
+
58
+
59
+ @router.post("/{dataset_id}/versions", response_model=DatasetVersionOut, status_code=201)
60
+ async def create_version(dataset_id: str, body: DatasetVersionCreate):
61
+ svc = get_service("datasets")
62
+ v = svc.create_version(dataset_id, body.version, body.uri, body.metadata)
63
+ return DatasetVersionOut(**{k: getattr(v, k) for k in DatasetVersionOut.model_fields})
64
+
65
+
66
+ # --- Splits -------------------------------------------------------------------
67
+
68
+ @router.get("/{dataset_id}/splits", response_model=list[DataSplitOut])
69
+ async def list_splits(dataset_id: str):
70
+ svc = get_service("datasets")
71
+ return [DataSplitOut(**{k: getattr(s, k) for k in DataSplitOut.model_fields})
72
+ for s in svc.list_splits(dataset_id)]
73
+
74
+
75
+ @router.post("/{dataset_id}/splits", response_model=DataSplitOut, status_code=201)
76
+ async def create_split(dataset_id: str, body: DataSplitCreate):
77
+ svc = get_service("datasets")
78
+ s = svc.create_split(dataset_id, body.name, body.method, body.params)
79
+ return DataSplitOut(**{k: getattr(s, k) for k in DataSplitOut.model_fields})
@@ -0,0 +1,175 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
4
+ """Deployments API router — deploy/undeploy models via discovered DeployService providers."""
5
+ from __future__ import annotations
6
+
7
+ import time
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from pipelab.api.app import get_project, get_discovered_services, refresh_discovery
14
+ from pipelab.api.schemas import DeployRequest, DeploymentOut
15
+ from pipelab.discovery import instantiate_service
16
+ from pipelab.pipeline import PipelineContext
17
+ from pipelab.entities.services import DeployService
18
+
19
+ router = APIRouter()
20
+
21
+ # In-memory deployment registry (production system would use persistent storage)
22
+ _active_deployments: dict[str, dict] = {}
23
+
24
+
25
+ @router.get("/", response_model=list[DeploymentOut])
26
+ async def list_deployments():
27
+ """List all active deployments."""
28
+ return [DeploymentOut(**d) for d in _active_deployments.values()]
29
+
30
+
31
+ @router.post("/", response_model=DeploymentOut, status_code=201)
32
+ async def deploy_model(body: DeployRequest):
33
+ """Deploy a model using a discovered DeployService provider."""
34
+ proj = get_project(body.project_name)
35
+ if proj is None:
36
+ raise HTTPException(404, f"Project '{body.project_name}' not found")
37
+
38
+ # Instantiate the selected deploy service
39
+ try:
40
+ svc = instantiate_service(proj, body.deploy_service)
41
+ except ValueError as exc:
42
+ raise HTTPException(404, str(exc))
43
+
44
+ if not isinstance(svc, DeployService):
45
+ raise HTTPException(400, f"'{body.deploy_service}' is not a DeployService")
46
+
47
+ # Build context for the deploy service
48
+ ctx = PipelineContext(
49
+ project_name=proj.name,
50
+ project_path=proj.path,
51
+ config={
52
+ "model_name": body.model_name,
53
+ "model_version": body.version,
54
+ "alias": body.alias,
55
+ "port": body.port,
56
+ },
57
+ )
58
+
59
+ try:
60
+ result = svc.execute(ctx)
61
+ except Exception as exc:
62
+ raise HTTPException(500, f"Deploy failed: {exc}")
63
+
64
+ key = f"{body.model_name}-{body.version}"
65
+ dep = {
66
+ "model_name": body.model_name,
67
+ "version": body.version,
68
+ "alias": body.alias,
69
+ "deploy_service": body.deploy_service,
70
+ "endpoint_uri": result.get("endpoint_uri") if isinstance(result, dict) else None,
71
+ "status": "active",
72
+ "created_at": time.time(),
73
+ "port": body.port,
74
+ "pid": result.get("pid") if isinstance(result, dict) else None,
75
+ }
76
+ _active_deployments[key] = dep
77
+
78
+ # Record deployment in project YAML
79
+ from pipelab.discovery import load_project_config, save_project_config
80
+ from pathlib import Path
81
+ yaml_path = Path(proj.path) / "pipelab.yaml"
82
+ fresh_proj = load_project_config(yaml_path)
83
+ fresh_proj.deployments.append({
84
+ "model_name": body.model_name,
85
+ "version": body.version,
86
+ "alias": body.alias,
87
+ "deploy_service": body.deploy_service,
88
+ "port": body.port,
89
+ })
90
+ save_project_config(fresh_proj)
91
+ refresh_discovery()
92
+
93
+ return DeploymentOut(**dep)
94
+
95
+
96
+ @router.delete("/{model_name}/{version}")
97
+ async def undeploy_model(model_name: str, version: str, project: str | None = None):
98
+ """Undeploy a model — stops the serving process."""
99
+ key = f"{model_name}-{version}"
100
+ dep = _active_deployments.get(key)
101
+ if dep is None:
102
+ raise HTTPException(404, "Deployment not found")
103
+
104
+ deploy_service_name = dep.get("deploy_service", "MLflowServeProvider")
105
+
106
+ # Try to call undeploy on the original provider
107
+ if project:
108
+ proj = get_project(project)
109
+ if proj:
110
+ try:
111
+ svc = instantiate_service(proj, deploy_service_name)
112
+ if isinstance(svc, DeployService):
113
+ ctx = PipelineContext(
114
+ project_name=proj.name,
115
+ project_path=proj.path,
116
+ config={
117
+ "model_name": model_name,
118
+ "model_version": version,
119
+ "pid": dep.get("pid"),
120
+ },
121
+ )
122
+ svc.undeploy(ctx)
123
+ except Exception:
124
+ pass # best-effort cleanup
125
+ else:
126
+ # Fallback: kill process directly if we have a pid
127
+ pid = dep.get("pid")
128
+ if pid:
129
+ import os, signal
130
+ try:
131
+ os.kill(pid, signal.SIGTERM)
132
+ except ProcessLookupError:
133
+ pass
134
+
135
+ dep["status"] = "inactive"
136
+ _active_deployments[key] = dep
137
+
138
+ return {"ok": True, "model_name": model_name, "version": version, "status": "inactive"}
139
+
140
+
141
+ class TestEndpointRequest(BaseModel):
142
+ endpoint_uri: str
143
+ payload: dict | list | str
144
+ content_type: str = "application/json"
145
+
146
+
147
+ @router.post("/test-endpoint")
148
+ async def test_endpoint(body: TestEndpointRequest):
149
+ """Proxy a test payload to a deployed model endpoint and return the response."""
150
+ import httpx
151
+
152
+ headers = {"Content-Type": body.content_type}
153
+ payload = body.payload if isinstance(body.payload, str) else body.payload
154
+
155
+ try:
156
+ async with httpx.AsyncClient(timeout=30.0) as client:
157
+ import json
158
+ data = json.dumps(payload) if not isinstance(payload, str) else payload
159
+ resp = await client.post(body.endpoint_uri, content=data, headers=headers)
160
+ try:
161
+ resp_body = resp.json()
162
+ except Exception:
163
+ resp_body = resp.text
164
+ return {
165
+ "status_code": resp.status_code,
166
+ "headers": dict(resp.headers),
167
+ "body": resp_body,
168
+ "elapsed_ms": resp.elapsed.total_seconds() * 1000,
169
+ }
170
+ except httpx.ConnectError:
171
+ raise HTTPException(502, f"Cannot connect to {body.endpoint_uri} — is the model server running?")
172
+ except httpx.TimeoutException:
173
+ raise HTTPException(504, f"Request to {body.endpoint_uri} timed out (30s)")
174
+ except Exception as exc:
175
+ raise HTTPException(500, f"Test request failed: {exc}")
@@ -0,0 +1,162 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
4
+ """Experiments API router — experiments, runs, compare."""
5
+ from __future__ import annotations
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel
9
+ from typing import Any
10
+
11
+ from pipelab.api.app import get_service
12
+ from pipelab.api.schemas import (
13
+ ExperimentCreate, ExperimentOut,
14
+ RunOut, CompareRequest,
15
+ )
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ @router.get("/", response_model=list[ExperimentOut])
21
+ async def list_experiments(project_id: str | None = None):
22
+ svc = get_service("experiments")
23
+ return [ExperimentOut(**{k: getattr(e, k) for k in ExperimentOut.model_fields})
24
+ for e in svc.list_experiments(project_id)]
25
+
26
+
27
+ @router.get("/{name}", response_model=ExperimentOut)
28
+ async def get_experiment(name: str):
29
+ svc = get_service("experiments")
30
+ e = svc.get_experiment(name)
31
+ if e is None:
32
+ raise HTTPException(404, "Experiment not found")
33
+ return ExperimentOut(**{k: getattr(e, k) for k in ExperimentOut.model_fields})
34
+
35
+
36
+ @router.post("/", response_model=ExperimentOut, status_code=201)
37
+ async def create_experiment(body: ExperimentCreate):
38
+ svc = get_service("experiments")
39
+ e = svc.create_experiment(body.name, body.project_id, body.description)
40
+ return ExperimentOut(**{k: getattr(e, k) for k in ExperimentOut.model_fields})
41
+
42
+
43
+ @router.delete("/{name}")
44
+ async def delete_experiment(name: str):
45
+ svc = get_service("experiments")
46
+ if not svc.delete_experiment(name):
47
+ raise HTTPException(404, "Experiment not found")
48
+ return {"ok": True}
49
+
50
+
51
+ # --- Runs ---------------------------------------------------------------------
52
+
53
+ @router.get("/{name}/runs", response_model=list[RunOut])
54
+ async def list_runs(name: str):
55
+ svc = get_service("experiments")
56
+ return [RunOut(**{k: getattr(r, k) for k in RunOut.model_fields})
57
+ for r in svc.list_runs(name)]
58
+
59
+
60
+ @router.get("/runs/{run_id}", response_model=RunOut)
61
+ async def get_run(run_id: str):
62
+ svc = get_service("experiments")
63
+ r = svc.get_run(run_id)
64
+ if r is None:
65
+ raise HTTPException(404, "Run not found")
66
+ return RunOut(**{k: getattr(r, k) for k in RunOut.model_fields})
67
+
68
+
69
+ @router.get("/runs/{run_id}/artifacts")
70
+ async def get_run_artifacts(run_id: str) -> list[dict[str, Any]]:
71
+ svc = get_service("experiments")
72
+ return svc.get_run_artifacts(run_id)
73
+
74
+
75
+ @router.post("/runs/compare", response_model=list[RunOut])
76
+ async def compare_runs(body: CompareRequest):
77
+ svc = get_service("experiments")
78
+ return [RunOut(**{k: getattr(r, k) for k in RunOut.model_fields})
79
+ for r in svc.compare_runs(body.run_ids)]
80
+
81
+
82
+ # --- Benchmark ----------------------------------------------------------------
83
+
84
+ class BenchmarkRequest(BaseModel):
85
+ experiment_names: list[str]
86
+
87
+
88
+ @router.post("/benchmark")
89
+ async def benchmark_models(body: BenchmarkRequest) -> list[dict[str, Any]]:
90
+ """
91
+ Aggregate runs from the given experiments, group by model name
92
+ (derived from mlflow.runName tag or run name), and compute
93
+ mean / std of every metric across the runs of each model group.
94
+ Returns a list of model summaries for comparison.
95
+ """
96
+ from statistics import mean, stdev
97
+
98
+ svc = get_service("experiments")
99
+ # Collect all runs across selected experiments
100
+ all_runs = []
101
+ for exp_name in body.experiment_names:
102
+ runs = svc.list_runs(exp_name)
103
+ all_runs.extend(runs)
104
+
105
+ # Group runs by model name (strip k-fold suffixes like _fold_0, _k0, etc.)
106
+ import re
107
+ def _model_group_name(run_name: str) -> str:
108
+ """Strip trailing fold/k-fold index patterns to group CV runs."""
109
+ # Patterns: _fold_0, _fold0, _k0, _k_0, _cv0, _cv_0, _split_0
110
+ cleaned = re.sub(r'[_\-](fold|k|cv|split)[_\-]?\d+$', '', run_name, flags=re.IGNORECASE)
111
+ return cleaned if cleaned else run_name
112
+
113
+ model_groups: dict[str, list] = {}
114
+ for r in all_runs:
115
+ raw_name = r.name or r.tags.get("mlflow.runName") or r.run_id[:8]
116
+ model_name = _model_group_name(raw_name)
117
+ model_groups.setdefault(model_name, []).append(r)
118
+
119
+ results = []
120
+ for model_name, runs in model_groups.items():
121
+ # Collect all metric keys
122
+ all_metric_keys = set()
123
+ for r in runs:
124
+ all_metric_keys.update(r.metrics.keys())
125
+
126
+ # Compute mean/std for each metric
127
+ metric_summary = {}
128
+ for key in sorted(all_metric_keys):
129
+ values = [r.metrics[key] for r in runs if key in r.metrics]
130
+ if values:
131
+ m = mean(values)
132
+ s = stdev(values) if len(values) > 1 else 0.0
133
+ metric_summary[key] = {
134
+ "mean": round(m, 6),
135
+ "std": round(s, 6),
136
+ "values": [round(v, 6) for v in values],
137
+ "count": len(values),
138
+ }
139
+
140
+ # Parameters (take from first run as representative)
141
+ first_run = runs[0]
142
+ params = dict(first_run.parameters)
143
+
144
+ # Collect run IDs
145
+ run_ids = [r.run_id for r in runs]
146
+
147
+ # Get experiment name from first run
148
+ experiment_name = first_run.experiment_name
149
+
150
+ results.append({
151
+ "model_name": model_name,
152
+ "experiment_name": experiment_name,
153
+ "run_count": len(runs),
154
+ "run_ids": run_ids,
155
+ "metrics": metric_summary,
156
+ "parameters": params,
157
+ "tags": dict(first_run.tags),
158
+ "model_uri": first_run.model_uri,
159
+ "status": first_run.status,
160
+ })
161
+
162
+ return results
@@ -0,0 +1,51 @@
1
+ # Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
2
+ # Licensed under the Apache License, Version 2.0.
3
+
4
+ """Models API router — registry, versions, stage transitions."""
5
+ from __future__ import annotations
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from typing import Any
9
+
10
+ from pipelab.api.app import get_service
11
+ from pipelab.api.schemas import (
12
+ ModelRegisterRequest, ModelTransitionRequest,
13
+ ModelRegistryOut,
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get("/", response_model=list[dict[str, Any]])
20
+ async def list_models():
21
+ svc = get_service("models")
22
+ return svc.list_registered_models()
23
+
24
+
25
+ @router.get("/{model_name}/versions", response_model=list[ModelRegistryOut])
26
+ async def get_model_versions(model_name: str):
27
+ svc = get_service("models")
28
+ return [ModelRegistryOut(**{k: getattr(v, k) for k in ModelRegistryOut.model_fields})
29
+ for v in svc.get_model_versions(model_name)]
30
+
31
+
32
+ @router.post("/register", response_model=ModelRegistryOut, status_code=201)
33
+ async def register_model(body: ModelRegisterRequest):
34
+ svc = get_service("models")
35
+ v = svc.register_model(body.run_id, body.model_name, body.artifact_path)
36
+ return ModelRegistryOut(**{k: getattr(v, k) for k in ModelRegistryOut.model_fields})
37
+
38
+
39
+ @router.put("/{model_name}/versions/{version}/transition", response_model=ModelRegistryOut)
40
+ async def transition_stage(model_name: str, version: str, body: ModelTransitionRequest):
41
+ svc = get_service("models")
42
+ v = svc.transition_model_stage(model_name, version, body.stage)
43
+ return ModelRegistryOut(**{k: getattr(v, k) for k in ModelRegistryOut.model_fields})
44
+
45
+
46
+ @router.delete("/{model_name}")
47
+ async def delete_model(model_name: str):
48
+ svc = get_service("models")
49
+ if not svc.delete_model(model_name):
50
+ raise HTTPException(404, "Model not found")
51
+ return {"ok": True}