CoolFace
Apppublic

DGXAI/driftcall

sourceHugging Faceapache-2.0updated 5mo agoView on Hugging Face
0likes
app.py787 linesDownload Raw Back to root
1"""DriftCall env Space — FastAPI + OpenEnv-compliant REST surface.2 3Implements ``docs/modules/deploy_env_space.md`` and DESIGN.md §3.3 / §11.1.4 5Endpoints:6    GET  /healthz   → 200 text/plain "ok" (unauthenticated)7    POST /reset     → 200 application/json (create / recycle session)8    POST /step      → 200 application/json (advance one turn)9    GET  /state     → 200 application/json (read DriftCallState)10    POST /close     → 200 application/json (evict session)11 12Headers (mutating endpoints): ``Authorization: Bearer <DRIFTCALL_ENV_TOKEN>``13and ``X-Session-Id: <[A-Za-z0-9_-]{1,64}>``.14 15Error modes (deploy_env_space.md §5):16    M1 401 unauthorized          M7  400 bad_json17    M2 400 missing_session_id    M8  400 invalid_action18    M3 404 session_not_found     M9  500 internal_error19    M4 404 session_expired       M10 500 io_error20    M5 429 max_sessions          M11 413 payload_too_large21    M6 503 model_not_ready       M12 409 reset_in_progress22 23All error bodies: ``{"error": {"code": <slug>, "message": <str>,24"request_id": <asgi-id>}}``; ``Cache-Control: no-store``; only M5 carries25``Retry-After: 30``. No stack traces ever leak across the wire.26"""27 28from __future__ import annotations29 30import asyncio31import contextlib32import dataclasses33import json34import logging35import os36import re37import time38from contextlib import asynccontextmanager39from dataclasses import dataclass, replace40from typing import TYPE_CHECKING, Any41 42from fastapi import FastAPI, Request, Response43from fastapi.responses import JSONResponse, PlainTextResponse44from starlette.middleware.base import BaseHTTPMiddleware45 46from cells.step_04_models import ActionType, DriftCallAction47from cells.step_10_env import (48    DriftCallEnv,49    EnvClosedError,50    EnvNotReadyError,51    EpisodeAlreadyTerminalError,52    InvalidActionError,53    InvalidConfigError,54    UnknownDomainError,55    UnknownToolError,56)57 58if TYPE_CHECKING:59    from collections.abc import AsyncIterator, Awaitable, Callable60 61    from starlette.types import ASGIApp62 63logger = logging.getLogger(__name__)64 65# ---------------------------------------------------------------------------66# Constants67# ---------------------------------------------------------------------------68 69_MAX_SESSIONS: int = 1070_TTL_S: float = 3600.071_SWEEP_INTERVAL_S: float = 60.072_MAX_SESSION_ID_LEN: int = 6473_SESSION_ID_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_-]{1,64}$")74_MAX_BODY_BYTES: int = 1 * 1024 * 1024  # 1 MiB75_RETRY_AFTER_S: str = "30"76_TOKEN_ENV_VAR: str = "DRIFTCALL_ENV_TOKEN"77 78 79# ---------------------------------------------------------------------------80# Time source (test-overridable)81# ---------------------------------------------------------------------------82 83 84def _monotonic() -> float:85    """Indirection for tests to monkeypatch."""86 87    return time.monotonic()88 89 90# ---------------------------------------------------------------------------91# Errors / envelope92# ---------------------------------------------------------------------------93 94 95@dataclass(frozen=True)96class _ApiError(Exception):97    """Internal exception → uniform error envelope (deploy_env_space.md §5)."""98 99    code: str100    message: str101    http_status: int102    retry_after: bool = False103 104 105_NO_STORE: dict[str, str] = {"Cache-Control": "no-store"}106 107 108def _error_response(err: _ApiError, request_id: str) -> JSONResponse:109    body = {110        "error": {111            "code": err.code,112            "message": err.message,113            "request_id": request_id,114        }115    }116    headers = dict(_NO_STORE)117    if err.retry_after:118        headers["Retry-After"] = _RETRY_AFTER_S119    return JSONResponse(status_code=err.http_status, content=body, headers=headers)120 121 122# ---------------------------------------------------------------------------123# Session cache124# ---------------------------------------------------------------------------125 126 127@dataclass(frozen=True)128class SessionEntry:129    """Frozen per project rule — every touch produces a new entry."""130 131    env: DriftCallEnv132    created_at: float133    last_touched: float134    reset_count: int135    lock: asyncio.Lock136 137 138class SessionCache:139    """In-memory session registry with LRU + TTL eviction."""140 141    def __init__(self, *, max_sessions: int = _MAX_SESSIONS, ttl_s: float = _TTL_S) -> None:142        self._max = max_sessions143        self._ttl = ttl_s144        self._store: dict[str, SessionEntry] = {}145        self._guard = asyncio.Lock()146 147    @property148    def size(self) -> int:149        return len(self._store)150 151    def get(self, sid: str) -> SessionEntry | None:152        return self._store.get(sid)153 154    async def acquire_lock(self, sid: str) -> asyncio.Lock:155        """Return (or lazily create) the per-session lock."""156        async with self._guard:157            entry = self._store.get(sid)158            if entry is not None:159                return entry.lock160            return asyncio.Lock()161 162    async def insert_or_replace(self, sid: str, env_factory: Callable[[], DriftCallEnv]) -> SessionEntry:163        """Insert a new env or replace an existing one (in-place reset)."""164        async with self._guard:165            now = _monotonic()166            existing = self._store.get(sid)167            if existing is not None:168                # In-place reset (§7.1 case after winner completed).169                try:170                    existing.env.close()171                except Exception:172                    logger.exception("env.close() raised on in-place reset for sid=%s", sid)173                env = env_factory()174                entry = SessionEntry(175                    env=env,176                    created_at=now,177                    last_touched=now,178                    reset_count=existing.reset_count + 1,179                    lock=existing.lock,180                )181                self._store[sid] = entry182                return entry183            # New session — enforce cap.184            if len(self._store) >= self._max:185                # Try LRU evict only if any entry is older than the others by TTL/2.186                victim_sid = min(self._store, key=lambda k: self._store[k].last_touched)187                victim = self._store[victim_sid]188                age = now - victim.last_touched189                if age <= 0.0:190                    raise _ApiError(191                        code="max_sessions",192                        message=f"max concurrent sessions reached ({self._max})",193                        http_status=429,194                        retry_after=True,195                    )196                try:197                    victim.env.close()198                except Exception:199                    logger.exception("env.close() raised on LRU eviction for sid=%s", victim_sid)200                self._store.pop(victim_sid, None)201            env = env_factory()202            entry = SessionEntry(203                env=env,204                created_at=now,205                last_touched=now,206                reset_count=0,207                lock=asyncio.Lock(),208            )209            self._store[sid] = entry210            return entry211 212    def touch(self, sid: str) -> tuple[SessionEntry | None, bool]:213        """Update last_touched. Returns ``(entry, was_expired)``.214 215        - ``(entry, False)`` on hit216        - ``(None, True)`` if the entry was present but evicted by this call217          due to TTL expiry218        - ``(None, False)`` if there was never an entry under this sid219        """220        entry = self._store.get(sid)221        if entry is None:222            return None, False223        now = _monotonic()224        if now - entry.last_touched > self._ttl:225            try:226                entry.env.close()227            except Exception:228                logger.exception("env.close() raised on expired touch for sid=%s", sid)229            self._store.pop(sid, None)230            return None, True231        new = replace(entry, last_touched=now)232        self._store[sid] = new233        return new, False234 235    def evict(self, sid: str) -> SessionEntry | None:236        """Pop a session out of the cache. Returns the removed entry or None."""237        return self._store.pop(sid, None)238 239    def sweep(self) -> int:240        """Synchronous TTL sweep — evict every entry past TTL."""241        now = _monotonic()242        expired = [sid for sid, e in self._store.items() if now - e.last_touched > self._ttl]243        for sid in expired:244            entry = self._store.pop(sid)245            try:246                entry.env.close()247            except Exception:248                logger.exception("env.close() raised on sweep for sid=%s", sid)249        if expired:250            logger.info(251                json.dumps(252                    {253                        "event": "session_sweep",254                        "expired_count": len(expired),255                        "cache_size": len(self._store),256                    }257                )258            )259        return len(expired)260 261 262# ---------------------------------------------------------------------------263# App state container264# ---------------------------------------------------------------------------265 266 267@dataclass268class _AppState:269    """Mutable (intentional) — owned by lifespan; readers go through getters."""270 271    cache: SessionCache272    models_ready: bool = False273    sweep_task: asyncio.Task[None] | None = None274    bearer_token: str = ""275 276 277def _get_state(app: FastAPI) -> _AppState:278    state: _AppState = app.state.driftcall279    return state280 281 282# ---------------------------------------------------------------------------283# Lifespan — eager-load Kokoro + Whisper before serving (M6 guard)284# ---------------------------------------------------------------------------285 286 287def _eager_load_models() -> None:288    """Force-load TTS + ASR singletons. Test patches this to avoid network."""289    from cells.step_09_audio import get_asr_engine, get_tts_engine290 291    get_tts_engine()292    get_asr_engine()293 294 295@asynccontextmanager296async def lifespan(app: FastAPI) -> AsyncIterator[None]:297    cache = SessionCache()298    token = os.environ.get(_TOKEN_ENV_VAR, "")299    if not token:300        # Fail-fast per deploy_env_space.md §3.5.301        raise RuntimeError(302            f"{_TOKEN_ENV_VAR} environment variable not set; refusing to start"303        )304    state = _AppState(cache=cache, bearer_token=token)305    app.state.driftcall = state306 307    # Eager model load (M6 guard — must complete before serving).308    try:309        await asyncio.to_thread(_eager_load_models)310    except Exception:311        logger.exception("eager model load failed")312        raise313    state.models_ready = True314 315    # Background TTL sweep.316    async def _sweep_loop() -> None:317        try:318            while True:319                await asyncio.sleep(_SWEEP_INTERVAL_S)320                cache.sweep()321        except asyncio.CancelledError:322            raise323 324    state.sweep_task = asyncio.create_task(_sweep_loop())325    try:326        yield327    finally:328        if state.sweep_task is not None:329            state.sweep_task.cancel()330            with contextlib.suppress(asyncio.CancelledError, Exception):331                await state.sweep_task332 333 334# ---------------------------------------------------------------------------335# Body-size middleware (M11)336# ---------------------------------------------------------------------------337 338 339class _BodySizeMiddleware(BaseHTTPMiddleware):340    def __init__(self, app: ASGIApp, *, max_bytes: int = _MAX_BODY_BYTES) -> None:341        super().__init__(app)342        self._max_bytes = max_bytes343 344    async def dispatch(345        self, request: Request, call_next: Callable[[Request], Awaitable[Response]]346    ) -> Response:347        cl = request.headers.get("content-length")348        if cl is not None:349            try:350                cl_int = int(cl)351            except ValueError:352                cl_int = -1353            if cl_int > self._max_bytes:354                err = _ApiError(355                    code="payload_too_large",356                    message="request body exceeds 1 MiB",357                    http_status=413,358                )359                return _error_response(err, _request_id(request))360        return await call_next(request)361 362 363# ---------------------------------------------------------------------------364# Helpers — auth, headers, body parsing365# ---------------------------------------------------------------------------366 367 368def _request_id(request: Request) -> str:369    return str(id(request))370 371 372def _check_bearer(request: Request, state: _AppState) -> None:373    auth = request.headers.get("authorization", "")374    if not auth.startswith("Bearer "):375        raise _ApiError(376            code="unauthorized",377            message="missing or non-Bearer Authorization header",378            http_status=401,379        )380    token = auth[len("Bearer ") :].strip()381    if token != state.bearer_token or not token:382        raise _ApiError(383            code="unauthorized",384            message="invalid bearer token",385            http_status=401,386        )387 388 389def _check_session_header(request: Request) -> str:390    sid = request.headers.get("x-session-id", "")391    if not sid or not _SESSION_ID_RE.match(sid):392        raise _ApiError(393            code="missing_session_id",394            message="X-Session-Id header missing or malformed",395            http_status=400,396        )397    return sid398 399 400def _check_models_ready(state: _AppState) -> None:401    if not state.models_ready:402        raise _ApiError(403            code="model_not_ready",404            message="audio models still loading; retry shortly",405            http_status=503,406        )407 408 409async def _parse_json_body(request: Request) -> dict[str, Any]:410    raw = await request.body()411    if len(raw) > _MAX_BODY_BYTES:412        raise _ApiError(413            code="payload_too_large",414            message="request body exceeds 1 MiB",415            http_status=413,416        )417    if not raw:418        return {}419    try:420        parsed = json.loads(raw)421    except (json.JSONDecodeError, UnicodeDecodeError) as exc:422        raise _ApiError(423            code="bad_json",424            message=f"malformed JSON: {exc.__class__.__name__}",425            http_status=400,426        ) from exc427    if not isinstance(parsed, dict):428        raise _ApiError(429            code="bad_json",430            message="request body must be a JSON object",431            http_status=400,432        )433    return parsed434 435 436# ---------------------------------------------------------------------------437# Action / config validation (envelope-level — env owns deep validation)438# ---------------------------------------------------------------------------439 440 441def _build_action(raw: Any) -> DriftCallAction:442    if not isinstance(raw, dict):443        raise _ApiError(444            code="invalid_action",445            message="action must be a JSON object",446            http_status=400,447        )448    atype_raw = raw.get("action_type")449    if not isinstance(atype_raw, str):450        raise _ApiError(451            code="invalid_action",452            message="action.action_type must be a string",453            http_status=400,454        )455    try:456        atype = ActionType(atype_raw)457    except ValueError as exc:458        raise _ApiError(459            code="invalid_action",460            message=f"unknown action_type {atype_raw!r}",461            http_status=400,462        ) from exc463 464    tool_name = raw.get("tool_name")465    tool_args = raw.get("tool_args")466    message = raw.get("message")467    confidence = raw.get("confidence")468    rationale = raw.get("rationale")469 470    # Action-type contract checks (deep checks happen inside env._validate_action).471    if atype == ActionType.TOOL_CALL and (472        tool_name is None or not isinstance(tool_name, str) or tool_args is None473    ):474        raise _ApiError(475            code="invalid_action",476            message="TOOL_CALL requires tool_name (str) and tool_args (object)",477            http_status=400,478        )479    return DriftCallAction(480        action_type=atype,481        tool_name=tool_name if isinstance(tool_name, str) else None,482        tool_args=tool_args if isinstance(tool_args, dict) else None,483        message=message if isinstance(message, str) else None,484        confidence=float(confidence) if isinstance(confidence, (int, float)) and not isinstance(confidence, bool) else None,485        rationale=rationale if isinstance(rationale, str) else None,486    )487 488 489def _build_env_config(reset_body: dict[str, Any]) -> dict[str, Any]:490    raw_cfg = reset_body.get("config")491    if raw_cfg is None:492        raw_cfg = {}493    if not isinstance(raw_cfg, dict):494        raise _ApiError(495            code="invalid_action",496            message="config must be a JSON object",497            http_status=400,498        )499    return raw_cfg500 501 502# ---------------------------------------------------------------------------503# Serialization helpers504# ---------------------------------------------------------------------------505 506 507def _to_jsonable(obj: Any) -> Any:508    """Recursively convert frozen dataclasses / tuples / enums to JSON-safe form."""509    if dataclasses.is_dataclass(obj) and not isinstance(obj, type):510        return {k: _to_jsonable(v) for k, v in dataclasses.asdict(obj).items()}511    if isinstance(obj, ActionType):512        return obj.value513    if isinstance(obj, dict):514        return {k: _to_jsonable(v) for k, v in obj.items()}515    if isinstance(obj, (list, tuple)):516        return [_to_jsonable(v) for v in obj]517    return obj518 519 520# ---------------------------------------------------------------------------521# Endpoint handlers (one function per route)522# ---------------------------------------------------------------------------523 524 525async def _handle_reset(request: Request, state: _AppState) -> Response:526    _check_bearer(request, state)527    _check_models_ready(state)528    sid = _check_session_header(request)529    body = await _parse_json_body(request)530    cfg = _build_env_config(body)531    seed_raw = body.get("seed")532    if seed_raw is not None and (not isinstance(seed_raw, int) or isinstance(seed_raw, bool)):533        raise _ApiError(534            code="invalid_action",535            message="seed must be an int or null",536            http_status=400,537        )538    seed: int | None = seed_raw if isinstance(seed_raw, int) and not isinstance(seed_raw, bool) else None539 540    cache = state.cache541    # Per-session reset lock (§7.1).542    existing = cache.get(sid)543    if existing is not None and existing.lock.locked():544        raise _ApiError(545            code="reset_in_progress",546            message="concurrent /reset on same session id",547            http_status=409,548        )549 550    # Acquire lock (creates one if not present).551    lock = await cache.acquire_lock(sid)552    if lock.locked():553        raise _ApiError(554            code="reset_in_progress",555            message="concurrent /reset on same session id",556            http_status=409,557        )558 559    async with lock:560        def _factory() -> DriftCallEnv:561            try:562                return DriftCallEnv(cfg)563            except InvalidConfigError as exc:564                raise _ApiError(565                    code="invalid_action",566                    message=f"invalid config: {exc}",567                    http_status=400,568                ) from exc569 570        try:571            entry = await cache.insert_or_replace(sid, _factory)572        except _ApiError:573            raise574        except Exception as exc:575            logger.exception("env construction failed for sid=%s", sid)576            raise _ApiError(577                code="internal_error",578                message="env construction failed",579                http_status=500,580            ) from exc581 582        try:583            obs = await asyncio.to_thread(entry.env.reset, seed)584        except InvalidConfigError as exc:585            cache.evict(sid)586            raise _ApiError(587                code="invalid_action",588                message=f"invalid config at reset: {exc}",589                http_status=400,590            ) from exc591        except OSError as exc:592            cache.evict(sid)593            raise _ApiError(594                code="io_error",595                message=f"I/O error during reset: {exc.__class__.__name__}",596                http_status=500,597            ) from exc598        except Exception as exc:599            cache.evict(sid)600            logger.exception("env.reset raised for sid=%s", sid)601            raise _ApiError(602                code="internal_error",603                message="env.reset raised",604                http_status=500,605            ) from exc606 607    body_out = {608        "observation": _to_jsonable(obs),609        "episode_id": entry.env.state().episode_id,610        "max_turns": entry.env.state().max_turns,611    }612    return JSONResponse(status_code=200, content=body_out)613 614 615async def _handle_step(request: Request, state: _AppState) -> Response:616    _check_bearer(request, state)617    _check_models_ready(state)618    sid = _check_session_header(request)619    body = await _parse_json_body(request)620    raw_action = body.get("action")621    action = _build_action(raw_action)622 623    entry, was_expired = state.cache.touch(sid)624    if entry is None:625        if was_expired:626            raise _ApiError(627                code="session_expired",628                message="session TTL expired; call /reset",629                http_status=404,630            )631        raise _ApiError(632            code="session_not_found",633            message="X-Session-Id has no live session; call /reset",634            http_status=404,635        )636 637    try:638        obs = await asyncio.to_thread(entry.env.step, action)639    except (InvalidActionError, UnknownToolError, UnknownDomainError) as exc:640        raise _ApiError(641            code="invalid_action",642            message=str(exc),643            http_status=400,644        ) from exc645    except (EnvNotReadyError, EnvClosedError, EpisodeAlreadyTerminalError) as exc:646        raise _ApiError(647            code="invalid_action",648            message=str(exc),649            http_status=400,650        ) from exc651    except OSError as exc:652        raise _ApiError(653            code="io_error",654            message=f"I/O error during step: {exc.__class__.__name__}",655            http_status=500,656        ) from exc657    except Exception as exc:658        logger.exception("env.step raised for sid=%s", sid)659        raise _ApiError(660            code="internal_error",661            message="env.step raised",662            http_status=500,663        ) from exc664 665    reward: float | None = None666    info: dict[str, Any] = {}667    if entry.env.done():668        try:669            rewards = entry.env.rewards()670            reward = float(getattr(rewards, "reward", 0.0))671            info["terminated_by"] = entry.env.episode().terminated_by672        except Exception:673            reward = None674 675    body_out = {676        "observation": _to_jsonable(obs),677        "reward": reward,678        "done": bool(entry.env.done()),679        "info": info,680    }681    return JSONResponse(status_code=200, content=body_out)682 683 684async def _handle_state(request: Request, state: _AppState) -> Response:685    _check_bearer(request, state)686    _check_models_ready(state)687    sid = _check_session_header(request)688    entry, was_expired = state.cache.touch(sid)689    if entry is None:690        if was_expired:691            raise _ApiError(692                code="session_expired",693                message="session TTL expired; call /reset",694                http_status=404,695            )696        raise _ApiError(697            code="session_not_found",698            message="X-Session-Id has no live session; call /reset",699            http_status=404,700        )701    try:702        st = entry.env.state()703    except EnvNotReadyError as exc:704        raise _ApiError(705            code="invalid_action",706            message=str(exc),707            http_status=400,708        ) from exc709    body_out = {"state": _to_jsonable(st), "turn": st.turn}710    return JSONResponse(status_code=200, content=body_out)711 712 713async def _handle_close(request: Request, state: _AppState) -> Response:714    _check_bearer(request, state)715    _check_models_ready(state)716    sid = _check_session_header(request)717    entry = state.cache.evict(sid)718    if entry is None:719        return JSONResponse(status_code=200, content={"closed": True, "final_state": None})720    final_state: Any = None721    try:722        final_state = _to_jsonable(entry.env.state())723    except EnvNotReadyError:724        final_state = None725    try:726        entry.env.close()727    except Exception:728        logger.exception("env.close raised on /close for sid=%s", sid)729    return JSONResponse(status_code=200, content={"closed": True, "final_state": final_state})730 731 732# ---------------------------------------------------------------------------733# App factory + route wiring734# ---------------------------------------------------------------------------735 736 737def create_app() -> FastAPI:738    """Construct a fresh FastAPI app. Used by tests to get an isolated instance."""739    app = FastAPI(lifespan=lifespan, title="DriftCall Env", version="0.1.0")740    app.add_middleware(_BodySizeMiddleware, max_bytes=_MAX_BODY_BYTES)741 742    @app.get("/healthz", response_class=PlainTextResponse)743    async def healthz() -> PlainTextResponse:744        return PlainTextResponse(content="ok", status_code=200)745 746    @app.post("/reset")747    async def reset_route(request: Request) -> Response:748        try:749            return await _handle_reset(request, _get_state(app))750        except _ApiError as err:751            return _error_response(err, _request_id(request))752 753    @app.post("/step")754    async def step_route(request: Request) -> Response:755        try:756            return await _handle_step(request, _get_state(app))757        except _ApiError as err:758            return _error_response(err, _request_id(request))759 760    @app.get("/state")761    async def state_route(request: Request) -> Response:762        try:763            return await _handle_state(request, _get_state(app))764        except _ApiError as err:765            return _error_response(err, _request_id(request))766 767    @app.post("/close")768    async def close_route(request: Request) -> Response:769        try:770            return await _handle_close(request, _get_state(app))771        except _ApiError as err:772            return _error_response(err, _request_id(request))773 774    return app775 776 777app = create_app()778 779 780__all__ = [781    "SessionCache",782    "SessionEntry",783    "app",784    "create_app",785    "lifespan",786]787