eval-hub-sdk 0.1.0a0__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.
@@ -0,0 +1,342 @@
1
+ """Standard API endpoints for framework adapters."""
2
+
3
+ import logging
4
+ from collections.abc import AsyncGenerator
5
+
6
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
7
+ from fastapi.responses import StreamingResponse
8
+
9
+ from ...models.api import (
10
+ BenchmarkInfo,
11
+ EvaluationJob,
12
+ EvaluationRequest,
13
+ EvaluationResponse,
14
+ FrameworkInfo,
15
+ HealthResponse,
16
+ JobStatus,
17
+ OCICoordinate,
18
+ PersistResponse,
19
+ )
20
+ from ..models.framework import FrameworkAdapter
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def create_adapter_api(adapter: FrameworkAdapter) -> APIRouter:
26
+ """Create FastAPI router with standard endpoints for a framework adapter.
27
+
28
+ Args:
29
+ adapter: The framework adapter instance
30
+
31
+ Returns:
32
+ APIRouter: Router with standard endpoints
33
+ """
34
+ router = APIRouter()
35
+
36
+ @router.get("/health", response_model=HealthResponse, tags=["Health"])
37
+ @router.options("/health", tags=["Health"])
38
+ async def health_check() -> HealthResponse:
39
+ """Check the health of the framework adapter."""
40
+ try:
41
+ return await adapter.health_check()
42
+ except Exception as e:
43
+ logger.exception("Health check failed")
44
+ raise HTTPException(
45
+ status_code=503, detail=f"Health check failed: {str(e)}"
46
+ )
47
+
48
+ @router.get("/info", response_model=FrameworkInfo, tags=["Info"])
49
+ async def get_framework_info() -> FrameworkInfo:
50
+ """Get information about the framework adapter."""
51
+ try:
52
+ return await adapter.get_framework_info()
53
+ except Exception as e:
54
+ logger.exception("Failed to get framework info")
55
+ raise HTTPException(
56
+ status_code=500, detail=f"Failed to get framework info: {str(e)}"
57
+ )
58
+
59
+ @router.get("/benchmarks", response_model=list[BenchmarkInfo], tags=["Benchmarks"])
60
+ async def list_benchmarks() -> list[BenchmarkInfo]:
61
+ """List all available benchmarks."""
62
+ try:
63
+ return await adapter.list_benchmarks()
64
+ except Exception as e:
65
+ logger.exception("Failed to list benchmarks")
66
+ raise HTTPException(
67
+ status_code=500, detail=f"Failed to list benchmarks: {str(e)}"
68
+ )
69
+
70
+ @router.get(
71
+ "/benchmarks/{benchmark_id}", response_model=BenchmarkInfo, tags=["Benchmarks"]
72
+ )
73
+ async def get_benchmark_info(benchmark_id: str) -> BenchmarkInfo:
74
+ """Get detailed information about a specific benchmark."""
75
+ try:
76
+ benchmark_info = await adapter.get_benchmark_info(benchmark_id)
77
+ if not benchmark_info:
78
+ raise HTTPException(
79
+ status_code=404, detail=f"Benchmark '{benchmark_id}' not found"
80
+ )
81
+ return benchmark_info
82
+ except HTTPException:
83
+ raise
84
+ except Exception as e:
85
+ logger.exception(f"Failed to get benchmark info for {benchmark_id}")
86
+ raise HTTPException(
87
+ status_code=500, detail=f"Failed to get benchmark info: {str(e)}"
88
+ )
89
+
90
+ @router.post(
91
+ "/evaluations",
92
+ response_model=EvaluationJob,
93
+ status_code=201,
94
+ tags=["Evaluations"],
95
+ )
96
+ async def submit_evaluation(
97
+ request: EvaluationRequest, background_tasks: BackgroundTasks
98
+ ) -> EvaluationJob:
99
+ """Submit an evaluation job."""
100
+ try:
101
+ # Validate the request
102
+ benchmark_info = await adapter.get_benchmark_info(request.benchmark_id)
103
+ if not benchmark_info:
104
+ raise HTTPException(
105
+ status_code=404,
106
+ detail=f"Benchmark '{request.benchmark_id}' not found",
107
+ )
108
+
109
+ # Submit the evaluation
110
+ job = await adapter.submit_evaluation(request)
111
+
112
+ logger.info(
113
+ f"Submitted evaluation job {job.job_id} for benchmark {request.benchmark_id}"
114
+ )
115
+ return job
116
+
117
+ except HTTPException:
118
+ raise
119
+ except ValueError as e:
120
+ raise HTTPException(
121
+ status_code=400, detail=f"Invalid evaluation request: {str(e)}"
122
+ )
123
+ except Exception as e:
124
+ logger.exception("Failed to submit evaluation")
125
+ raise HTTPException(
126
+ status_code=500, detail=f"Failed to submit evaluation: {str(e)}"
127
+ )
128
+
129
+ @router.get(
130
+ "/evaluations/{job_id}", response_model=EvaluationJob, tags=["Evaluations"]
131
+ )
132
+ async def get_job_status(job_id: str) -> EvaluationJob:
133
+ """Get the status of an evaluation job."""
134
+ try:
135
+ job = await adapter.get_job_status(job_id)
136
+ if not job:
137
+ raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
138
+ return job
139
+ except HTTPException:
140
+ raise
141
+ except Exception as e:
142
+ logger.exception(f"Failed to get job status for {job_id}")
143
+ raise HTTPException(
144
+ status_code=500, detail=f"Failed to get job status: {str(e)}"
145
+ )
146
+
147
+ @router.get(
148
+ "/evaluations/{job_id}/results",
149
+ response_model=EvaluationResponse,
150
+ tags=["Evaluations"],
151
+ )
152
+ async def get_evaluation_results(job_id: str) -> EvaluationResponse:
153
+ """Get the results of a completed evaluation."""
154
+ try:
155
+ # First check if job exists
156
+ job = await adapter.get_job_status(job_id)
157
+ if not job:
158
+ raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
159
+
160
+ # Check if results are available
161
+ if job.status == JobStatus.PENDING:
162
+ raise HTTPException(
163
+ status_code=202, detail=f"Job '{job_id}' is still pending"
164
+ )
165
+ elif job.status == JobStatus.RUNNING:
166
+ raise HTTPException(
167
+ status_code=202, detail=f"Job '{job_id}' is still running"
168
+ )
169
+ elif job.status == JobStatus.FAILED:
170
+ raise HTTPException(
171
+ status_code=422,
172
+ detail=f"Job '{job_id}' failed: {job.error_message}",
173
+ )
174
+ elif job.status == JobStatus.CANCELLED:
175
+ raise HTTPException(
176
+ status_code=410, detail=f"Job '{job_id}' was cancelled"
177
+ )
178
+
179
+ # Get results
180
+ results = await adapter.get_evaluation_results(job_id)
181
+ if not results:
182
+ raise HTTPException(
183
+ status_code=404, detail=f"Results for job '{job_id}' not found"
184
+ )
185
+
186
+ return results
187
+
188
+ except HTTPException:
189
+ raise
190
+ except Exception as e:
191
+ logger.exception(f"Failed to get evaluation results for {job_id}")
192
+ raise HTTPException(
193
+ status_code=500, detail=f"Failed to get evaluation results: {str(e)}"
194
+ )
195
+
196
+ @router.delete("/evaluations/{job_id}", tags=["Evaluations"])
197
+ async def cancel_job(job_id: str) -> dict[str, bool | str]:
198
+ """Cancel an evaluation job."""
199
+ try:
200
+ success = await adapter.cancel_job(job_id)
201
+ if not success:
202
+ # Check if job exists
203
+ job = await adapter.get_job_status(job_id)
204
+ if not job:
205
+ raise HTTPException(
206
+ status_code=404, detail=f"Job '{job_id}' not found"
207
+ )
208
+ else:
209
+ raise HTTPException(
210
+ status_code=409,
211
+ detail=f"Job '{job_id}' cannot be cancelled (status: {job.status})",
212
+ )
213
+
214
+ return {
215
+ "success": True,
216
+ "message": f"Job '{job_id}' cancelled successfully",
217
+ }
218
+
219
+ except HTTPException:
220
+ raise
221
+ except Exception as e:
222
+ logger.exception(f"Failed to cancel job {job_id}")
223
+ raise HTTPException(
224
+ status_code=500, detail=f"Failed to cancel job: {str(e)}"
225
+ )
226
+
227
+ @router.get(
228
+ "/evaluations", response_model=list[EvaluationJob], tags=["Evaluations"]
229
+ )
230
+ async def list_jobs(
231
+ status: JobStatus | None = None, limit: int | None = None
232
+ ) -> list[EvaluationJob]:
233
+ """List evaluation jobs, optionally filtered by status."""
234
+ try:
235
+ jobs = await adapter.list_active_jobs()
236
+
237
+ # Filter by status if specified
238
+ if status:
239
+ jobs = [job for job in jobs if job.status == status]
240
+
241
+ # Apply limit if specified
242
+ if limit and limit > 0:
243
+ jobs = jobs[:limit]
244
+
245
+ return jobs
246
+
247
+ except Exception as e:
248
+ logger.exception("Failed to list jobs")
249
+ raise HTTPException(
250
+ status_code=500, detail=f"Failed to list jobs: {str(e)}"
251
+ )
252
+
253
+ @router.get(
254
+ "/evaluations/{job_id}/stream",
255
+ response_class=StreamingResponse,
256
+ tags=["Evaluations"],
257
+ )
258
+ async def stream_job_updates(job_id: str) -> StreamingResponse:
259
+ """Stream real-time updates for an evaluation job."""
260
+ try:
261
+ # Check if job exists
262
+ job = await adapter.get_job_status(job_id)
263
+ if not job:
264
+ raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
265
+
266
+ async def event_stream() -> AsyncGenerator[str, None]:
267
+ """Generate Server-Sent Events for job updates."""
268
+ async for updated_job in adapter.stream_job_updates(job_id):
269
+ # Format as Server-Sent Event
270
+ yield f"data: {updated_job.model_dump_json()}\n\n"
271
+
272
+ # Stop streaming when job is complete
273
+ if updated_job.status in [
274
+ JobStatus.COMPLETED,
275
+ JobStatus.FAILED,
276
+ JobStatus.CANCELLED,
277
+ ]:
278
+ break
279
+
280
+ return StreamingResponse(
281
+ event_stream(),
282
+ media_type="text/event-stream",
283
+ headers={
284
+ "Cache-Control": "no-cache",
285
+ "Connection": "keep-alive",
286
+ },
287
+ )
288
+
289
+ except HTTPException:
290
+ raise
291
+ except Exception as e:
292
+ logger.exception(f"Failed to stream job updates for {job_id}")
293
+ raise HTTPException(
294
+ status_code=500, detail=f"Failed to stream job updates: {str(e)}"
295
+ )
296
+
297
+ @router.post(
298
+ "/evaluations/{job_id}/persist",
299
+ response_model=PersistResponse,
300
+ tags=["Evaluations"],
301
+ )
302
+ async def persist_job_files(
303
+ job_id: str, coordinate: OCICoordinate
304
+ ) -> PersistResponse:
305
+ """Persist job files as OCI artifact.
306
+
307
+ Manually trigger OCI artifact persistence for completed job files.
308
+
309
+ Args:
310
+ job_id: The job identifier
311
+ coordinate: OCI coordinates (reference and optional subject)
312
+
313
+ Returns:
314
+ PersistResponse: Persistence status and artifact information
315
+
316
+ Raises:
317
+ HTTPException: If job not found, not completed, or has no files to persist
318
+ """
319
+ try:
320
+ result = await adapter.persist_job_files_oci(job_id, coordinate)
321
+ if result is None:
322
+ raise HTTPException(
323
+ status_code=404,
324
+ detail=f"Job {job_id} has no files to persist",
325
+ )
326
+ return result
327
+ except ValueError as e:
328
+ error_msg = str(e)
329
+ # Job not found -> 404, job not completed -> 409
330
+ if "not found" in error_msg:
331
+ raise HTTPException(status_code=404, detail=error_msg)
332
+ else:
333
+ raise HTTPException(status_code=409, detail=error_msg)
334
+ except HTTPException:
335
+ raise
336
+ except Exception as e:
337
+ logger.exception(f"Failed to persist files for job {job_id}")
338
+ raise HTTPException(
339
+ status_code=500, detail=f"Failed to persist files: {str(e)}"
340
+ )
341
+
342
+ return router
@@ -0,0 +1,135 @@
1
+ """API router utilities for framework adapters."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from ..models.framework import FrameworkAdapter
11
+ from .endpoints import create_adapter_api
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class AdapterAPIRouter:
17
+ """Router for creating standardized API servers for framework adapters."""
18
+
19
+ def __init__(self, adapter: FrameworkAdapter):
20
+ """Initialize the router with a framework adapter.
21
+
22
+ Args:
23
+ adapter: The framework adapter to expose via API
24
+ """
25
+ self.adapter = adapter
26
+ self.app = FastAPI(
27
+ title="EvalHub Framework Adapter",
28
+ description=f"{adapter.config.adapter_name} - API for {adapter.config.framework_id} framework adapter",
29
+ version=adapter.config.version,
30
+ docs_url="/docs",
31
+ redoc_url="/redoc",
32
+ )
33
+ self._setup_middleware()
34
+ self._setup_routes()
35
+ self._setup_exception_handlers()
36
+ self._setup_events()
37
+
38
+ def _setup_middleware(self) -> None:
39
+ """Set up middleware for the FastAPI app."""
40
+ # CORS middleware
41
+ self.app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=["*"], # Configure appropriately for production
44
+ allow_credentials=True,
45
+ allow_methods=["*"],
46
+ allow_headers=["*"],
47
+ )
48
+
49
+ # Note: Request logging removed to avoid middleware compatibility issues
50
+
51
+ def _setup_routes(self) -> None:
52
+ """Set up API routes."""
53
+ # Include the standard adapter endpoints
54
+ api_router = create_adapter_api(self.adapter)
55
+ self.app.include_router(
56
+ api_router, prefix="/api/v1", tags=["Framework Adapter API"]
57
+ )
58
+
59
+ # Root endpoint
60
+ @self.app.get("/", tags=["Root"])
61
+ async def root() -> dict[str, str]:
62
+ """Root endpoint with basic information."""
63
+ framework_info = await self.adapter.get_framework_info()
64
+ return {
65
+ "message": f"Welcome to {self.adapter.config.adapter_name} API",
66
+ "framework_id": framework_info.framework_id,
67
+ "version": framework_info.version,
68
+ "api_docs": "/docs",
69
+ "health_check": "/api/v1/health",
70
+ }
71
+
72
+ def _setup_exception_handlers(self) -> None:
73
+ """Set up global exception handlers."""
74
+
75
+ @self.app.exception_handler(404)
76
+ async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
77
+ return JSONResponse(
78
+ status_code=404,
79
+ content={
80
+ "error_type": "NotFound",
81
+ "error_message": "Resource not found",
82
+ "path": str(request.url.path),
83
+ },
84
+ )
85
+
86
+ @self.app.exception_handler(500)
87
+ async def internal_error_handler(request: Request, exc: Any) -> JSONResponse:
88
+ logger.exception("Internal server error")
89
+ return JSONResponse(
90
+ status_code=500,
91
+ content={
92
+ "error_type": "InternalError",
93
+ "error_message": "An internal error occurred",
94
+ },
95
+ )
96
+
97
+ def _setup_events(self) -> None:
98
+ """Set up startup and shutdown event handlers."""
99
+
100
+ @self.app.on_event("startup")
101
+ async def startup_event() -> None:
102
+ await self.startup()
103
+
104
+ @self.app.on_event("shutdown")
105
+ async def shutdown_event() -> None:
106
+ await self.shutdown()
107
+
108
+ async def startup(self) -> None:
109
+ """Startup handler for the API server."""
110
+ try:
111
+ await self.adapter.initialize()
112
+ logger.info(
113
+ f"Framework adapter {self.adapter.config.framework_id} initialized"
114
+ )
115
+ except Exception:
116
+ logger.exception("Failed to initialize framework adapter")
117
+ raise
118
+
119
+ async def shutdown(self) -> None:
120
+ """Shutdown handler for the API server."""
121
+ try:
122
+ await self.adapter.shutdown()
123
+ logger.info(
124
+ f"Framework adapter {self.adapter.config.framework_id} shut down"
125
+ )
126
+ except Exception:
127
+ logger.exception("Error during adapter shutdown")
128
+
129
+ def get_app(self) -> FastAPI:
130
+ """Get the FastAPI application.
131
+
132
+ Returns:
133
+ FastAPI: The configured FastAPI application
134
+ """
135
+ return self.app