279 lines
7.0 KiB
TypeScript
279 lines
7.0 KiB
TypeScript
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<string, string>;
|
|
};
|
|
|
|
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<SessionStatus>("/v1/auth/session");
|
|
}
|
|
|
|
async listModels() {
|
|
return this.request<ModelCatalogResponse>("/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<T>(path: string, options?: RequestOptions): Promise<T> {
|
|
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<string, ((payload: any) => 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;
|
|
}
|
|
}
|
|
}
|