import type { ChatDetail, ChatSummary, CompletionRequestMessage, CompletionStreamHandlers, ModelCatalogResponse, Provider, SearchDetail, SearchRunRequest, SearchStreamHandlers, SearchSummary, SessionStatus, WorkspaceItem, } from "./types.js"; type RequestOptions = { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: unknown; signal?: AbortSignal; headers?: Record; }; export class SybilApiClient { private readonly baseUrl: string; private readonly token: string | null; constructor(baseUrl: string, token: string | null) { this.baseUrl = baseUrl; this.token = token; } async verifySession() { return this.request("/v1/auth/session"); } async listModels() { return this.request("/v1/models"); } async listChats() { const data = await this.request<{ chats: ChatSummary[] }>("/v1/chats"); return data.chats; } async listWorkspaceItems() { const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items"); return data.items; } async createChat(title?: string) { const data = await this.request<{ chat: ChatSummary }>("/v1/chats", { method: "POST", body: { title }, }); return data.chat; } async getChat(chatId: string) { const data = await this.request<{ chat: ChatDetail }>(`/v1/chats/${chatId}`); return data.chat; } async suggestChatTitle(body: { chatId: string; content: string }) { const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", { method: "POST", body, }); return data.chat; } async deleteChat(chatId: string) { await this.request<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" }); } async listSearches() { const data = await this.request<{ searches: SearchSummary[] }>("/v1/searches"); return data.searches; } async createSearch(body?: { title?: string; query?: string }) { const data = await this.request<{ search: SearchSummary }>("/v1/searches", { method: "POST", body: body ?? {}, }); return data.search; } async getSearch(searchId: string) { const data = await this.request<{ search: SearchDetail }>(`/v1/searches/${searchId}`); return data.search; } async deleteSearch(searchId: string) { await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" }); } async runCompletionStream( body: { chatId: string; provider: Provider; model: string; messages: CompletionRequestMessage[]; }, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal } ) { await this.runSse( "/v1/chat-completions/stream", body, { meta: handlers.onMeta, tool_call: handlers.onToolCall, delta: handlers.onDelta, done: handlers.onDone, error: handlers.onError, }, options ); } async runSearchStream( searchId: string, body: SearchRunRequest, handlers: SearchStreamHandlers, options?: { signal?: AbortSignal } ) { await this.runSse( `/v1/searches/${searchId}/run/stream`, body, { search_results: handlers.onSearchResults, search_error: handlers.onSearchError, answer: handlers.onAnswer, answer_error: handlers.onAnswerError, done: handlers.onDone, error: handlers.onError, }, options ); } private async request(path: string, options?: RequestOptions): Promise { const headers = new Headers(options?.headers ?? {}); const hasBody = options?.body !== undefined; if (hasBody && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } if (this.token) { headers.set("Authorization", `Bearer ${this.token}`); } const init: RequestInit = { method: options?.method ?? "GET", headers, }; if (hasBody) { init.body = JSON.stringify(options?.body); } if (options?.signal) { init.signal = options.signal; } const response = await fetch(`${this.baseUrl}${path}`, init); if (!response.ok) { throw new Error(await this.readErrorMessage(response)); } return (await response.json()) as T; } private async runSse( path: string, body: unknown, handlers: Record void) | undefined>, options?: { signal?: AbortSignal } ) { const headers = new Headers({ Accept: "text/event-stream", "Content-Type": "application/json", }); if (this.token) { headers.set("Authorization", `Bearer ${this.token}`); } const init: RequestInit = { method: "POST", headers, body: JSON.stringify(body), }; if (options?.signal) { init.signal = options.signal; } const response = await fetch(`${this.baseUrl}${path}`, init); if (!response.ok) { throw new Error(await this.readErrorMessage(response)); } if (!response.body) { throw new Error("No response stream"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let eventName = "message"; let dataLines: string[] = []; const flushEvent = () => { if (!dataLines.length) { eventName = "message"; return; } const dataText = dataLines.join("\n"); let payload: any = null; try { payload = JSON.parse(dataText); } catch { payload = { message: dataText }; } handlers[eventName]?.(payload); dataLines = []; eventName = "message"; }; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newlineIndex = buffer.indexOf("\n"); while (newlineIndex >= 0) { const rawLine = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (!line) { flushEvent(); } else if (line.startsWith("event:")) { eventName = line.slice("event:".length).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } newlineIndex = buffer.indexOf("\n"); } } buffer += decoder.decode(); if (buffer.length) { const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; if (line.startsWith("event:")) { eventName = line.slice("event:".length).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } } flushEvent(); } private async readErrorMessage(response: Response) { const fallback = `${response.status} ${response.statusText}`; try { const body = (await response.json()) as { message?: string }; if (typeof body.message === "string" && body.message.trim()) { return body.message; } return fallback; } catch { return fallback; } } }