"""FastAPI server exposing the CustomerSupportEnv over HTTP. Endpoints: GET / — browser debug UI (HTML) GET /docs — interactive OpenAPI (Swagger UI, default theme) POST /reset — start a new episode POST /step — apply an action GET /state — read current state without advancing POST /close — release episode resources POST /inference — run the full LLM inference loop GET /health — liveness probe (JSON) """ from __future__ import annotations import io import json import os import time from contextlib import redirect_stdout from collections import defaultdict, deque from pathlib import Path from typing import Any, Literal from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, ConfigDict, Field from env.environment import CustomerSupportEnv from models.action import Action from sandbox.env_adapter import SandboxEnv _OPENAPI_TAGS = [ { "name": "Environment", "description": ( "Episode lifecycle for the customer-support RL environment: " "call **reset** before **step**; use **state** to inspect without advancing." ), }, { "name": "Interface", "description": "Browser UI for manual debugging (HTML, not JSON).", }, { "name": "System", "description": "Health checks and optional batch inference (requires server env configuration).", }, ] app = FastAPI( title="OpenEnv Enterprise Incident Command Center", version="2.0.0", openapi_tags=_OPENAPI_TAGS, description=( "Deterministic enterprise incident simulation for [OpenEnv](https://github.com/open-env). " "Supports backward-compatible ticket mode and a full incident command lifecycle " "with enterprise tools, policy drift, and multi-step remediation." ), ) _DEFAULT_SESSION_ID = "default" _API_KEY = os.environ.get("OPENENV_API_KEY", "").strip() _RATE_LIMIT_PER_MIN = int(os.environ.get("OPENENV_RATE_LIMIT_PER_MIN", "120")) _AUDIT_LOG_PATH = os.environ.get("OPENENV_AUDIT_LOG_PATH", "").strip() _AUTH_EXEMPT_PATHS = frozenset(["/", "/docs", "/openapi.json", "/redoc", "/health"]) EnvLike = CustomerSupportEnv | SandboxEnv _USE_SANDBOX = os.environ.get("OPENENV_SANDBOX", "false").strip().lower() in {"1", "true", "yes", "on"} _SANDBOX_CLUSTER_URL = os.environ.get("OPENENV_SANDBOX_CLUSTER_URL", "http://localhost").strip() _SANDBOX_CHAOS_URL = os.environ.get("OPENENV_SANDBOX_CHAOS_URL", "http://localhost:6660").strip() def _create_env() -> EnvLike: if _USE_SANDBOX: return SandboxEnv(cluster_base_url=_SANDBOX_CLUSTER_URL, chaos_url=_SANDBOX_CHAOS_URL) return CustomerSupportEnv() _SESSION_ENVS: dict[str, EnvLike] = {_DEFAULT_SESSION_ID: _create_env()} _RATE_LIMIT_WINDOWS: dict[str, deque[float]] = defaultdict(deque) def _resolve_session_id( request_value: str | None, header_value: str | None, ) -> str: session_id = (request_value or header_value or _DEFAULT_SESSION_ID).strip() return session_id or _DEFAULT_SESSION_ID def _audit_event(payload: dict[str, Any]) -> None: line = json.dumps(payload, separators=(",", ":"), ensure_ascii=True) if _AUDIT_LOG_PATH: path = Path(_AUDIT_LOG_PATH) path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8") as handle: handle.write(line + "\n") async def _get_env(session_id: str, *, create_if_missing: bool = True) -> EnvLike: env = _SESSION_ENVS.get(session_id) if env is None and create_if_missing: env = _create_env() _SESSION_ENVS[session_id] = env if env is None: raise HTTPException(status_code=404, detail=f"Unknown session_id '{session_id}'") return env async def _close_session(session_id: str) -> None: env = _SESSION_ENVS.get(session_id) if env is None: return await env.close() if session_id != _DEFAULT_SESSION_ID: _SESSION_ENVS.pop(session_id, None) @app.middleware("http") async def security_and_audit_middleware(request: Request, call_next): # type: ignore[no-untyped-def] started = time.perf_counter() client_host = request.client.host if request.client else "unknown" req_api_key = request.headers.get("X-API-Key", "") req_session_id = request.headers.get("X-Session-ID", _DEFAULT_SESSION_ID) status_code = 500 try: if _API_KEY and request.url.path not in _AUTH_EXEMPT_PATHS: if req_api_key != _API_KEY: status_code = 401 return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) if _RATE_LIMIT_PER_MIN > 0 and request.url.path not in _AUTH_EXEMPT_PATHS: now = time.monotonic() rate_key = req_api_key or client_host window = _RATE_LIMIT_WINDOWS[rate_key] while window and now - window[0] > 60.0: window.popleft() if len(window) >= _RATE_LIMIT_PER_MIN: status_code = 429 return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"}) window.append(now) response = await call_next(request) status_code = response.status_code return response finally: elapsed_ms = round((time.perf_counter() - started) * 1000, 2) _audit_event( { "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "method": request.method, "path": request.url.path, "status": status_code, "elapsed_ms": elapsed_ms, "client": client_host, "session_id": req_session_id, } ) # ── request / response schemas ─────────────────────────────────────────────── class ResetRequest(BaseModel): """Start a new episode; same inputs yield the same deterministic scenario.""" model_config = ConfigDict( json_schema_extra={ "examples": [ {"seed": 0, "difficulty": "easy", "mode": "ticket"}, {"seed": 0, "difficulty": "easy", "mode": "incident"}, {"seed": 0, "difficulty": "nightmare", "mode": "incident"}, { "seed": 0, "difficulty": "hard", "mode": "incident", "drill_mode": True, "drill_seed": 7, }, {"seed": 0, "difficulty": None, "mode": "ticket"}, ] } ) seed: int = Field( default=0, description="Index into the ticket pool (modulo pool size). With `difficulty` omitted, indexes the combined bank.", ) mode: Literal["ticket", "incident"] = Field( default="ticket", description="Episode mode. `ticket` preserves legacy triage behavior; `incident` enables incident lifecycle simulation.", ) difficulty: Literal["easy", "medium", "hard", "nightmare"] | None = Field( default=None, description=( "Difficulty filter. Ticket mode supports easy/medium/hard; " "incident mode supports easy/medium/hard/nightmare." ), ) session_id: str | None = Field( default=None, description="Optional episode session key. If omitted, uses the default shared session.", ) drill_mode: bool = Field( default=False, description=( "Sandbox-only: enable deterministic failure curriculum drill events " "during incident episodes." ), ) drill_seed: int | None = Field( default=None, description="Sandbox-only deterministic seed override for drill schedule.", ) class StepRequest(BaseModel): """Apply one agent action. Must match the current phase (see `observation.phase` and `available_actions`).""" model_config = ConfigDict( json_schema_extra={ "example": { "action": { "action_type": "classify", "category": "general_inquiry", "priority": "medium", } } } ) action: Action = Field( description=( "Discriminated union on `action_type`: classify, route, respond, escalate, request_info, " "resolve, check_monitoring, probe_service, fetch_logs, fetch_user_data, check_billing, " "query_kb, check_policy, query_incident_history, follow_runbook_step, apply_fix, verify_fix, " "rollback_fix, notify_stakeholders, write_postmortem, update_kb. " "Wrong shape -> **422**; wrong phase -> **200** with a penalty in `reward`." ), ) session_id: str | None = Field( default=None, description="Optional episode session key. Must match a prior reset session to avoid cross-episode overlap.", ) class EnvResponse(BaseModel): """Observation and reward after reset, step, or state.""" observation: dict[str, Any] | None = Field( description="Ticket + phase + history. `null` only from GET /state when reset has not been called yet.", ) reward: float = Field(description="Reward for the last transition; 0 on reset and on GET /state.") done: bool = Field(description="True after a terminal resolve (episode finished).") info: dict[str, Any] = Field( default_factory=dict, description="Diagnostics (e.g. difficulty, last feedback, reward breakdown).", ) class InferenceResponse(BaseModel): """Output from running the bundled LLM inference script once.""" stdout: str = Field(description="Captured stdout from `inference.run()` (validator-style log lines).") score: float = Field(description="Parsed from the final `[END]` line when present; else 0.0.") success: bool = Field(description="Parsed from the final `[END]` line when present; else false.") # ── helpers ────────────────────────────────────────────────────────────────── def _result_to_dict(result: Any, *, session_id: str | None = None) -> dict[str, Any]: info = dict(result.info) if session_id is not None: info["session_id"] = session_id return { "observation": result.observation.model_dump(), "reward": result.reward, "done": result.done, "info": info, } _DEBUG_UI_HTML = """
Ticket triage + incident operations in one console · /health
—
—
—
—