312 lines · 9.1 KB · TypeScript
RawDownload
// CRITICALimport { getApiKey } from "../api-key";import { getStoredBackendUrl } from "../backend-url"; const DEFAULT_TIMEOUT_MS = 30_000;const DEFAULT_RETRIES = 3;const DEFAULT_RETRY_DELAY_MS = 1_000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const encodePathSegments = (path: string) =>  path    .split("/")    .filter((segment) => segment.length > 0)    .map((segment) => encodeURIComponent(segment))    .join("/"); function isRetryableError(error: unknown, status?: number): boolean {  if (status && status >= 500) return true;  if (status === 429) return true;  if (status === 408) return true;  if (error instanceof TypeError) return true;  if (error instanceof Error && error.name === "AbortError") return false;  return false;} export interface RequestOptions extends RequestInit {  timeout?: number;  retries?: number;  retryDelay?: number;} export class ApiError extends Error {  public readonly status: number;  public readonly body: unknown;   public constructor(message: string, status: number, body: unknown) {    super(message);    this.status = status;    this.body = body;  }} export interface ChatRunStreamEvent {  event: string;  data: Record<string, unknown>;} export type ApiCore = ReturnType<typeof createApiCore>; export function createApiCore(params: { baseUrl: string; useProxy: boolean }) {  const { baseUrl, useProxy } = params;   const buildUrl = (endpoint: string): string => {    const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;    return useProxy ? `${baseUrl}/${path}` : `${baseUrl}${endpoint}`;  };   const buildHeaders = (extraHeaders?: HeadersInit): Record<string, string> => {    const headers: Record<string, string> = { "Content-Type": "application/json" };     const storedBackendUrl = getStoredBackendUrl();    if (useProxy && storedBackendUrl) {      headers["X-Backend-Url"] = storedBackendUrl;    }     const storedKey = getApiKey();    if (storedKey) {      headers["Authorization"] = `Bearer ${storedKey}`;    }     if (extraHeaders) {      const merged = new Headers(extraHeaders);      merged.forEach((value, key) => {        headers[key] = value;      });    }     return headers;  };   const request = async <T>(endpoint: string, options: RequestOptions = {}): Promise<T> => {    const {      timeout = DEFAULT_TIMEOUT_MS,      retries = DEFAULT_RETRIES,      retryDelay = DEFAULT_RETRY_DELAY_MS,      ...fetchOptions    } = options;     const headers = buildHeaders(fetchOptions.headers);    const url = buildUrl(endpoint);     let lastError: Error | null = null;    let lastStatus: number | undefined;     for (let attempt = 0; attempt <= retries; attempt++) {      const controller = new AbortController();      const timeoutId = setTimeout(() => controller.abort(), timeout);       try {        const response = await fetch(url, {          ...fetchOptions,          headers,          credentials: "include",          signal: controller.signal,        });         clearTimeout(timeoutId);        lastStatus = response.status;         if (!response.ok) {          const errorBody = await response.json().catch(() => ({ detail: "Request failed" }));          const errorMessage =            errorBody.detail || errorBody.error?.message || `HTTP ${response.status}`;          lastError = new ApiError(errorMessage, response.status, errorBody);           if (isRetryableError(lastError, response.status) && attempt < retries) {            const delay = retryDelay * Math.pow(2, attempt);            console.warn(              `[API] Retry ${attempt + 1}/${retries} for ${endpoint} after ${delay}ms (status: ${response.status})`,            );            await sleep(delay);            continue;          }           throw lastError;        }         const text = await response.text();        return text ? (JSON.parse(text) as T) : (null as unknown as T);      } catch (error) {        clearTimeout(timeoutId);         if (error instanceof Error && error.name === "AbortError") {          lastError = new Error(`Request timeout after ${timeout}ms`);        } else if (error instanceof Error) {          lastError = error;        } else {          lastError = new Error(String(error));        }         if (isRetryableError(error, lastStatus) && attempt < retries) {          const delay = retryDelay * Math.pow(2, attempt);          console.warn(            `[API] Retry ${attempt + 1}/${retries} for ${endpoint} after ${delay}ms (${lastError.message})`,          );          await sleep(delay);          continue;        }         throw lastError;      }    }     throw lastError || new Error("Request failed after retries");  };   const parseSseStream = async function* (    reader: ReadableStreamDefaultReader<Uint8Array>,  ): AsyncGenerator<ChatRunStreamEvent> {    const decoder = new TextDecoder();    let buffer = "";    let eventType = "";    let dataLines: string[] = [];     const flushEvent = (): ChatRunStreamEvent | null => {      if (dataLines.length === 0) return null;      const dataString = dataLines.join("\n");      let data: Record<string, unknown>;      try {        data = JSON.parse(dataString) as Record<string, unknown>;      } catch {        data = { raw: dataString };      }      const payload = { event: eventType || "message", data };      eventType = "";      dataLines = [];      return payload;    };     while (true) {      const { done, value } = await reader.read();      if (done) break;       buffer += decoder.decode(value, { stream: true });      const lines = buffer.split(/\r?\n/);      buffer = lines.pop() || "";       for (const line of lines) {        if (!line) {          const payload = flushEvent();          if (payload) yield payload;          continue;        }         if (line.startsWith("event:")) {          eventType = line.slice(6).trim();          continue;        }         if (line.startsWith("data:")) {          dataLines.push(line.slice(5).trim());        }      }    }     const finalPayload = flushEvent();    if (finalPayload) yield finalPayload;  };   const parseOpenAIStream = async function* (    reader: ReadableStreamDefaultReader<Uint8Array>,  ): AsyncGenerator<Record<string, unknown>> {    const decoder = new TextDecoder();    let buffer = "";     while (true) {      const { done, value } = await reader.read();      if (done) break;       buffer += decoder.decode(value, { stream: true });      const lines = buffer.split(/\r?\n/);      buffer = lines.pop() || "";       for (const line of lines) {        const trimmed = line.trim();        if (!trimmed) continue;        if (!trimmed.startsWith("data:")) continue;        const raw = trimmed.slice(5).trim();        if (!raw) continue;        if (raw === "[DONE]") return;        try {          yield JSON.parse(raw) as Record<string, unknown>;        } catch {          yield { raw };        }      }    }  };   const postSseJson = async (    endpoint: string,    payload: unknown,    options: { signal?: AbortSignal } = {},  ): Promise<{ runId: string | null; stream: AsyncGenerator<ChatRunStreamEvent> }> => {    const url = buildUrl(endpoint);    const headers = buildHeaders({ Accept: "text/event-stream" });     const response = await fetch(url, {      method: "POST",      headers,      body: JSON.stringify(payload),      signal: options.signal,      credentials: "include",    });     if (!response.ok || !response.body) {      const errorBody = await response.json().catch(() => ({ detail: "Request failed" }));      const errorMessage =        errorBody.detail || errorBody.error?.message || `HTTP ${response.status}`;      throw new ApiError(errorMessage, response.status, errorBody);    }     const runId = response.headers.get("x-run-id");    const reader = response.body.getReader();    return { runId, stream: parseSseStream(reader) };  };   const postOpenAIStream = async (    endpoint: string,    payload: unknown,    options: { signal?: AbortSignal } = {},  ): Promise<{ stream: AsyncGenerator<Record<string, unknown>> }> => {    const url = buildUrl(endpoint);    const headers = buildHeaders({ Accept: "text/event-stream" });     const response = await fetch(url, {      method: "POST",      headers,      body: JSON.stringify(payload),      signal: options.signal,      credentials: "include",    });     if (!response.ok || !response.body) {      const text = await response.text().catch(() => "");      let errorBody: { detail?: string; error?: { message?: string } } = {};      if (text) {        try {          errorBody = JSON.parse(text) as { detail?: string; error?: { message?: string } };        } catch {          errorBody = {};        }      }      const errorMessage = errorBody.detail || errorBody.error?.message || text || `HTTP ${response.status}`;      throw new ApiError(errorMessage, response.status, errorBody || { raw: text });    }     const reader = response.body.getReader();    return { stream: parseOpenAIStream(reader) };  };   return {    baseUrl,    useProxy,    buildUrl,    buildHeaders,    request,    postSseJson,    postOpenAIStream,  };}