"""FastAPI transport for the unified Arena service.""" from __future__ import annotations import json import os from typing import AsyncIterator from fastapi import FastAPI, HTTPException, Query, Request, Response from pydantic import BaseModel, Field from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse, StreamingResponse from .model_catalog import ModelProviderConfig, ProviderModel from .run_models import RunRequest, RunView from .run_store import InvalidRunToken, RunNotFound from .validation_models import ValidationReport from .service import ( ArenaService, FileAccessFailed, ) from .skill_catalog import SkillInfo from .thread_inspector import ( AgentRunFailed, FileEntry, MessageResponse, ThreadInspector, ThreadNotFound, ThreadResponse, ) app = FastAPI(title="Backyard Demo Builder", version="0.1.0") service = ArenaService() thread_inspector = ThreadInspector() sessions = thread_inspector.sessions API_KEY = os.getenv("ARENA_API_KEY", "") class AuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): if request.url.path in ("/health",) or not API_KEY: return await call_next(request) auth = request.headers.get("Authorization", "") expected = f"Bearer {API_KEY}" if auth != expected: return JSONResponse( status_code=401, content={"detail": "Unauthorized"}, ) return await call_next(request) if API_KEY: app.add_middleware(AuthMiddleware) class MessageRequest(BaseModel): content: str = Field(min_length=1) class RejoinRequest(BaseModel): token: str = Field(min_length=1) class RunMessageRequest(BaseModel): content: str = Field(min_length=1) @app.get("/health") async def health() -> dict[str, str]: return service.health() @app.get("/skills") async def list_skills() -> list[SkillInfo]: return service.list_skills() @app.post("/models/catalog") async def list_provider_models(req: ModelProviderConfig) -> list[ProviderModel]: try: return service.list_provider_models(req) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=502, detail="Could not fetch provider models") from exc @app.post("/models/catalog/refresh") async def refresh_provider_models(req: ModelProviderConfig) -> list[ProviderModel]: try: return service.refresh_provider_models(req) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=502, detail="Could not refresh provider models") from exc @app.post("/runs") async def create_run(req: RunRequest) -> RunView: return service.create_run(req).run @app.post("/runs/start") async def start_run(req: RunRequest) -> RunView: return service.start_run(req).run @app.get("/runs/{run_id}") async def get_run(run_id: str) -> RunView: try: return service.get_run(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc @app.get("/runs/{run_id}/events") async def stream_run_events(run_id: str) -> StreamingResponse: try: service.get_run(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc async def event_generator() -> AsyncIterator[str]: import asyncio q = service.subscribe_events(run_id) loop = asyncio.get_event_loop() while True: event = await loop.run_in_executor(None, q.get) if event is None: break yield f"data: {json.dumps(event, default=str)}\n\n" return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @app.get("/runs/{run_id}/codebase.zip") async def get_run_codebase_zip(run_id: str) -> Response: try: payload = service.get_run_codebase_zip(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc except FileAccessFailed as exc: raise HTTPException(status_code=409, detail="Run has no codebase archive") from exc return Response( content=payload.data, media_type=payload.content_type, headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'}, ) @app.get("/runs/{run_id}/field_notes.md") async def get_run_field_notes(run_id: str) -> Response: try: payload = service.get_run_field_notes(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc return Response( content=payload.data, media_type=payload.content_type, headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'}, ) @app.get("/runs/{run_id}/trace.json") async def get_run_trace_json(run_id: str) -> Response: try: payload = service.get_run_trace_json(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc return Response( content=payload.data, media_type=payload.content_type, headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'}, ) @app.get("/runs/{run_id}/submission.md") async def get_run_submission_markdown(run_id: str) -> Response: try: payload = service.get_run_submission_markdown(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc return Response( content=payload.data, media_type=payload.content_type, headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'}, ) @app.get("/runs/{run_id}/space_README.md") async def get_run_space_readme(run_id: str) -> Response: try: payload = service.get_run_space_readme(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc return Response( content=payload.data, media_type=payload.content_type, headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'}, ) @app.post("/runs/rejoin") async def rejoin_run(req: RejoinRequest) -> RunView: try: return service.rejoin_run(req.token).run except InvalidRunToken as exc: raise HTTPException(status_code=404, detail="Token not found") from exc @app.post("/runs/{run_id}/cancel") async def cancel_run(run_id: str) -> RunView: try: return service.cancel_run(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc @app.post("/runs/{run_id}/messages") async def add_run_message(run_id: str, req: RunMessageRequest) -> RunView: try: return service.add_run_message(run_id, req.content) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc @app.post("/runs/{run_id}/validate") async def validate_run(run_id: str) -> ValidationReport: try: return service.validate_run(run_id) except RunNotFound as exc: raise HTTPException(status_code=404, detail="Run not found") from exc except FileAccessFailed as exc: raise HTTPException(status_code=409, detail="Run has no codebase archive") from exc @app.post("/threads") async def create_thread() -> ThreadResponse: try: return thread_inspector.create_thread() except Exception as exc: raise HTTPException(status_code=500, detail="Could not create agent session") from exc @app.post("/threads/{thread_id}/messages") async def send_message(thread_id: str, req: MessageRequest) -> MessageResponse: try: return thread_inspector.send_message(thread_id, req.content) except ThreadNotFound as exc: raise HTTPException(status_code=404, detail="Thread not found") from exc except AgentRunFailed as exc: raise HTTPException(status_code=502, detail="Agent run failed") from exc @app.get("/threads/{thread_id}/files") async def list_files(thread_id: str, path: str = Query("/")) -> list[FileEntry]: try: return thread_inspector.list_files(thread_id, path) except ThreadNotFound as exc: raise HTTPException(status_code=404, detail="Thread not found") from exc except FileAccessFailed as exc: raise HTTPException(status_code=400, detail="Could not list files") from exc @app.get("/threads/{thread_id}/files/content") async def read_file(thread_id: str, path: str) -> dict[str, str]: try: return thread_inspector.read_file(thread_id, path) except ThreadNotFound as exc: raise HTTPException(status_code=404, detail="Thread not found") from exc except FileAccessFailed as exc: raise HTTPException(status_code=400, detail="Could not read file") from exc @app.delete("/threads/{thread_id}") async def delete_thread(thread_id: str) -> dict[str, bool]: try: return thread_inspector.delete_thread(thread_id) except ThreadNotFound as exc: raise HTTPException(status_code=404, detail="Thread not found") from exc