import type { Entity, ExtractResponse, Relation, TierInfo, TierName, } from "../types"; const REQUEST_TIMEOUT_MS = 20_000; const TIER_RETRY_DELAYS_MS = [300, 900] as const; interface ApiExtractResponse { tokens: string[]; entities: Entity[]; relations: Relation[]; } interface ApiTierResponse { tiers: TierInfo[]; } interface ApiErrorResponse { error?: { message?: string }; } interface RuntimeConfiguration { apiBaseUrl?: string; } class DeploymentConfigurationError extends Error {} function getApiBaseUrl(): string { const runtimeConfiguration = ( globalThis as typeof globalThis & { __TEXTMOSAIC_CONFIG__?: RuntimeConfiguration; } ).__TEXTMOSAIC_CONFIG__; const configuredUrl = runtimeConfiguration?.apiBaseUrl ?? import.meta.env.VITE_API_BASE_URL; if (configuredUrl) { return configuredUrl.replace(/\/$/, ""); } const hostname = globalThis.location?.hostname; if ( hostname === undefined || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ) { return "http://127.0.0.1:7860"; } throw new DeploymentConfigurationError( "This deployment is missing VITE_API_BASE_URL. Set the frontend container environment variable to the public API URL.", ); } function toClientResponse(response: ApiExtractResponse): ExtractResponse { // This is the deliberate API boundary. All current fields are single words, // so the required snake_case-to-camelCase conversion is a no-op today. return { tokens: response.tokens, entities: response.entities.map((entity) => ({ ...entity })), relations: response.relations.map((relation) => ({ ...relation })), }; } async function request(path: string, init?: RequestInit): Promise { const apiBaseUrl = getApiBaseUrl(); const controller = new AbortController(); const timeout = globalThis.setTimeout( () => controller.abort(), REQUEST_TIMEOUT_MS, ); let response: Response; try { response = await fetch(`${apiBaseUrl}${path}`, { headers: { "Content-Type": "application/json", ...init?.headers }, signal: controller.signal, ...init, }); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { throw new Error("The extraction service did not respond in time."); } throw new Error("Unable to reach the extraction service."); } finally { globalThis.clearTimeout(timeout); } if (!response.ok) { const body = (await response.json().catch(() => ({}))) as ApiErrorResponse; throw new Error( body.error?.message ?? `Request failed with status ${response.status}.`, ); } return (await response.json()) as T; } export async function getTiers(): Promise { let lastError: Error | undefined; for (const delay of [...TIER_RETRY_DELAYS_MS, 0]) { if (delay > 0) { await new Promise((resolve) => globalThis.setTimeout(resolve, delay)); } try { return (await request("/tiers")).tiers; } catch (error) { if (error instanceof DeploymentConfigurationError) { throw error; } lastError = error instanceof Error ? error : new Error("Unable to load model tiers."); } } throw lastError ?? new Error("Unable to load model tiers."); } export async function extractText( text: string, tier: TierName, ): Promise { const response = await request("/extract", { method: "POST", body: JSON.stringify({ text, tier }), }); return toClientResponse(response); } export { toClientResponse };