Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
334 lines
9.0 KiB
TypeScript
334 lines
9.0 KiB
TypeScript
import { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js";
|
|
|
|
export type OpenBrainThoughtMetadata = Record<string, unknown> & {
|
|
sessionId?: string;
|
|
turn?: number;
|
|
role?: string;
|
|
type?: string;
|
|
};
|
|
|
|
export type OpenBrainThought = {
|
|
id: string;
|
|
content: string;
|
|
source: string;
|
|
metadata: OpenBrainThoughtMetadata | undefined;
|
|
createdAt: string | undefined;
|
|
updatedAt: string | undefined;
|
|
score: number | undefined;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type OpenBrainThoughtInput = {
|
|
content: string;
|
|
source: string;
|
|
metadata?: OpenBrainThoughtMetadata;
|
|
};
|
|
|
|
export type OpenBrainSearchInput = {
|
|
query: string;
|
|
limit: number;
|
|
source?: string;
|
|
};
|
|
|
|
export type OpenBrainClientOptions = {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
fetchImpl?: typeof fetch;
|
|
};
|
|
|
|
export interface OpenBrainClientLike {
|
|
createThought(input: OpenBrainThoughtInput): Promise<OpenBrainThought>;
|
|
search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]>;
|
|
listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]>;
|
|
updateThought(
|
|
id: string,
|
|
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
|
|
): Promise<OpenBrainThought>;
|
|
deleteThought(id: string): Promise<void>;
|
|
deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void>;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
|
const value = record[key];
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
const value = record[key];
|
|
return typeof value === "number" ? value : undefined;
|
|
}
|
|
|
|
function normalizeBaseUrl(baseUrl: string): string {
|
|
const normalized = baseUrl.trim().replace(/\/+$/, "");
|
|
if (normalized.length === 0) {
|
|
throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeApiKey(apiKey: string): string {
|
|
const normalized = apiKey.trim();
|
|
if (normalized.length === 0) {
|
|
throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeHeaders(headers: unknown): Record<string, string> {
|
|
if (headers === undefined) {
|
|
return {};
|
|
}
|
|
|
|
if (Array.isArray(headers)) {
|
|
const normalized: Record<string, string> = {};
|
|
for (const pair of headers) {
|
|
if (!Array.isArray(pair) || pair.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
const key = pair[0];
|
|
const value = pair[1];
|
|
if (typeof key !== "string" || typeof value !== "string") {
|
|
continue;
|
|
}
|
|
|
|
normalized[key] = value;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
if (headers instanceof Headers) {
|
|
const normalized: Record<string, string> = {};
|
|
for (const [key, value] of headers.entries()) {
|
|
normalized[key] = value;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
if (!isRecord(headers)) {
|
|
return {};
|
|
}
|
|
|
|
const normalized: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (typeof value === "string") {
|
|
normalized[key] = value;
|
|
continue;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
normalized[key] = value.join(", ");
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
async function readResponseBody(response: Response): Promise<string | undefined> {
|
|
try {
|
|
const body = await response.text();
|
|
return body.length > 0 ? body : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export class OpenBrainClient implements OpenBrainClientLike {
|
|
private readonly baseUrl: string;
|
|
private readonly apiKey: string;
|
|
private readonly fetchImpl: typeof fetch;
|
|
|
|
constructor(options: OpenBrainClientOptions) {
|
|
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
this.apiKey = normalizeApiKey(options.apiKey);
|
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
}
|
|
|
|
async createThought(input: OpenBrainThoughtInput): Promise<OpenBrainThought> {
|
|
const payload = await this.request<unknown>("/v1/thoughts", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
return this.extractThought(payload);
|
|
}
|
|
|
|
async search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]> {
|
|
const payload = await this.request<unknown>("/v1/search", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
return this.extractThoughtArray(payload);
|
|
}
|
|
|
|
async listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]> {
|
|
const params = new URLSearchParams({
|
|
limit: String(input.limit),
|
|
});
|
|
|
|
if (input.source !== undefined && input.source.length > 0) {
|
|
params.set("source", input.source);
|
|
}
|
|
|
|
const payload = await this.request<unknown>(`/v1/thoughts/recent?${params.toString()}`, {
|
|
method: "GET",
|
|
});
|
|
|
|
return this.extractThoughtArray(payload);
|
|
}
|
|
|
|
async updateThought(
|
|
id: string,
|
|
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
|
|
): Promise<OpenBrainThought> {
|
|
const responsePayload = await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
return this.extractThought(responsePayload);
|
|
}
|
|
|
|
async deleteThought(id: string): Promise<void> {
|
|
await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
async deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void> {
|
|
const query = new URLSearchParams();
|
|
if (params.source !== undefined && params.source.length > 0) {
|
|
query.set("source", params.source);
|
|
}
|
|
if (params.metadataId !== undefined && params.metadataId.length > 0) {
|
|
query.set("metadata_id", params.metadataId);
|
|
}
|
|
|
|
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
await this.request<unknown>(`/v1/thoughts${suffix}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
private async request<T>(endpoint: string, init: RequestInit): Promise<T> {
|
|
const headers = normalizeHeaders(init.headers);
|
|
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
|
|
if (init.body !== undefined && headers["Content-Type"] === undefined) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
|
|
const url = `${this.baseUrl}${endpoint}`;
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await this.fetchImpl(url, {
|
|
...init,
|
|
headers,
|
|
});
|
|
} catch (error) {
|
|
throw new OpenBrainRequestError({ endpoint, cause: error });
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new OpenBrainHttpError({
|
|
endpoint,
|
|
status: response.status,
|
|
responseBody: await readResponseBody(response),
|
|
});
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
if (!contentType.toLowerCase().includes("application/json")) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
private extractThoughtArray(payload: unknown): OpenBrainThought[] {
|
|
if (Array.isArray(payload)) {
|
|
return payload.map((item) => this.normalizeThought(item));
|
|
}
|
|
|
|
if (!isRecord(payload)) {
|
|
return [];
|
|
}
|
|
|
|
const candidates = [payload.thoughts, payload.data, payload.results, payload.items];
|
|
for (const candidate of candidates) {
|
|
if (Array.isArray(candidate)) {
|
|
return candidate.map((item) => this.normalizeThought(item));
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private extractThought(payload: unknown): OpenBrainThought {
|
|
if (isRecord(payload)) {
|
|
const nested = payload.thought;
|
|
if (nested !== undefined) {
|
|
return this.normalizeThought(nested);
|
|
}
|
|
|
|
const data = payload.data;
|
|
if (data !== undefined && !Array.isArray(data)) {
|
|
return this.normalizeThought(data);
|
|
}
|
|
}
|
|
|
|
return this.normalizeThought(payload);
|
|
}
|
|
|
|
private normalizeThought(value: unknown): OpenBrainThought {
|
|
if (!isRecord(value)) {
|
|
return {
|
|
id: "",
|
|
content: "",
|
|
source: "",
|
|
metadata: undefined,
|
|
createdAt: undefined,
|
|
updatedAt: undefined,
|
|
score: undefined,
|
|
};
|
|
}
|
|
|
|
const metadataValue = value.metadata;
|
|
const metadata = isRecord(metadataValue)
|
|
? ({ ...metadataValue } as OpenBrainThoughtMetadata)
|
|
: undefined;
|
|
|
|
const id = readString(value, "id") ?? readString(value, "thought_id") ?? "";
|
|
const content =
|
|
readString(value, "content") ??
|
|
readString(value, "text") ??
|
|
(value.content === undefined ? "" : String(value.content));
|
|
const source = readString(value, "source") ?? "";
|
|
|
|
const createdAt = readString(value, "createdAt") ?? readString(value, "created_at");
|
|
const updatedAt = readString(value, "updatedAt") ?? readString(value, "updated_at");
|
|
const score = readNumber(value, "score");
|
|
|
|
return {
|
|
...value,
|
|
id,
|
|
content,
|
|
source,
|
|
metadata,
|
|
createdAt,
|
|
updatedAt,
|
|
score,
|
|
};
|
|
}
|
|
}
|
|
|
|
export { normalizeApiKey, normalizeBaseUrl };
|