/** Typed fetch wrapper for the FastAPI `/interactive/*` router. * * Base URL precedence: * 1. explicit `options.baseUrl` from the caller * 2. `VITE_PHYSIX_API_URL` baked in at build time. An EXPLICIT empty * string means "use same-origin / relative URLs" — that's how the * HF Space build is configured (the SPA and the FastAPI server * live behind the same host, so `/interactive/*` resolves to the * Space itself). * 3. fall back to `http://localhost:8000` for `pnpm dev` against a * separately-running uvicorn. */ import type { PhysiXAction, PhysiXObservation, TrajectorySample } from "@/types/physix"; const DEFAULT_BASE_URL = "http://localhost:8000"; function resolveBaseUrl(override?: string): string { if (override !== undefined) return override; const env = (import.meta as ImportMeta & { env?: Record }).env; // `??` (not `||`/truthy) so an explicit empty string survives — the // production build sets VITE_PHYSIX_API_URL="" to mean same-origin. // If the variable wasn't defined at all (dev), `env?.X` is undefined // and we fall through to the localhost default. const fromEnv = env?.VITE_PHYSIX_API_URL; return fromEnv ?? DEFAULT_BASE_URL; } export interface SystemDescriptor { system_id: string; state_variables: string[]; } export interface InteractiveStartResponse { session_id: string; observation: PhysiXObservation; system: SystemDescriptor; max_turns: number; } export interface LlmStepRequest { /** OpenAI-compatible /v1 base URL. Required by the server. */ base_url: string; /** Provider-native model id (HF repo id, Ollama tag, OpenAI name, …). */ model: string; /** Bearer token. Server falls back to env vars when omitted. */ api_key?: string | undefined; temperature?: number | undefined; max_tokens?: number | undefined; } export interface LlmStepResponse { observation: PhysiXObservation; predicted_trajectory: TrajectorySample[]; action: PhysiXAction; raw_completion: string; latency_s: number; model: string; } export interface SessionSummary { session_id: string; system_id: string; turn: number; max_turns: number; converged: boolean; done: boolean; } export interface LlmModelInfo { name: string; size_bytes: number | null; parameter_size: string | null; family: string | null; } export interface LlmModelsResponse { models: LlmModelInfo[]; error: string | null; } /** * OpenEnv stateless contract. The official OpenEnv server constructs a * fresh env per request, so bare `/reset` and `/step` are stateless — * they're useful for inspection and `metadata`/`schema` introspection, * but you cannot run a multi-turn episode through them. For stateful * step-by-step interaction we use the `/interactive/*` session router * instead (see `directStep`, `getSummary`, `endSession`). */ export interface OpenEnvResetRequest { seed?: number | null; episode_id?: string | null; /** PhysiX-specific extension forwarded via `additionalProperties: true`. */ system_id?: string | null; } export interface OpenEnvResetResponse { observation: PhysiXObservation; reward: number | null; done: boolean; } export interface OpenEnvMetadata { name: string; description: string; readme_content?: string | null; version?: string | null; author?: string | null; documentation_url?: string | null; } export interface OpenEnvSchemaResponse { action: Record; observation: Record; state: Record; } /** * Stateful per-session direct step. Same shape as `LlmStepResponse` * minus the `raw_completion` / `model` / `latency_s` fields, which * don't apply when the user supplies the action directly. */ export interface DirectStepResponse { observation: PhysiXObservation; predicted_trajectory: TrajectorySample[]; } export class InteractiveApiError extends Error { public readonly status: number; public readonly detail: string; public constructor(status: number, detail: string) { super(`[${status}] ${detail}`); this.name = "InteractiveApiError"; this.status = status; this.detail = detail; } } export interface InteractiveClientOptions { baseUrl?: string; fetchImpl?: typeof fetch; } export class InteractiveClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; public constructor(options: InteractiveClientOptions = {}) { this.baseUrl = resolveBaseUrl(options.baseUrl).replace(/\/+$/, ""); this.fetchImpl = options.fetchImpl ?? fetch.bind(globalThis); } public async listSystems(): Promise { return this.request("/interactive/systems", { method: "GET" }); } public async listModels(): Promise { return this.request("/interactive/models", { method: "GET" }); } public async startSession(payload: { system_id?: string | undefined; seed?: number | undefined; max_turns?: number | undefined; }): Promise { const compact = stripUndefined(payload); return this.request("/interactive/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(compact), }); } public async llmStep( sessionId: string, request: LlmStepRequest, ): Promise { const compact = stripUndefined(request as unknown as Record); return this.request( `/interactive/sessions/${encodeURIComponent(sessionId)}/llm-step`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(compact), }, ); } public async endSession(sessionId: string): Promise { const response = await this.fetchImpl( `${this.baseUrl}/interactive/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, ); if (!response.ok && response.status !== 404) { const detail = await safeReadDetail(response); throw new InteractiveApiError(response.status, detail); } } public async getSummary(sessionId: string): Promise { return this.request( `/interactive/sessions/${encodeURIComponent(sessionId)}`, { method: "GET" }, ); } // -------- OpenEnv read-only endpoints -------- public async openEnvMetadata(): Promise { return this.request("/metadata", { method: "GET" }); } public async openEnvSchema(): Promise { return this.request("/schema", { method: "GET" }); } /** * Stateless `POST /reset` — useful for inspecting what the env * returns at episode start, but the OpenEnv framework constructs a * new env per request so a follow-up `POST /step` cannot succeed. * For multi-step episodes use `startSession` + `directStep`. */ public async openEnvReset( payload: OpenEnvResetRequest, ): Promise { const compact = stripUndefined(payload as Record); return this.request("/reset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(compact), }); } // -------- Stateful per-session direct step -------- /** Apply a user-supplied action to a session created via `startSession`. */ public async directStep( sessionId: string, action: PhysiXAction, ): Promise { return this.request( `/interactive/sessions/${encodeURIComponent(sessionId)}/step`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }), }, ); } private async request(path: string, init: RequestInit): Promise { const response = await this.fetchImpl(`${this.baseUrl}${path}`, init); if (!response.ok) { const detail = await safeReadDetail(response); throw new InteractiveApiError(response.status, detail); } return (await response.json()) as T; } } function stripUndefined>(input: T): Partial { const out: Partial = {}; for (const [key, value] of Object.entries(input) as [keyof T, T[keyof T]][]) { if (value !== undefined) { out[key] = value; } } return out; } async function safeReadDetail(response: Response): Promise { try { const body = (await response.json()) as { detail?: unknown }; if (typeof body.detail === "string") return body.detail; return JSON.stringify(body); } catch { return response.statusText || "Request failed"; } }