diff --git a/apps/website/app/api/embeddings/openai/small/route.ts b/apps/website/app/api/embeddings/openai/small/route.ts deleted file mode 100644 index 11da80051..000000000 --- a/apps/website/app/api/embeddings/openai/small/route.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import OpenAI from "openai"; -import cors from "~/utils/llm/cors"; - -const apiKey = process.env.OPENAI_API_KEY; - -if (!apiKey) { - console.error( - "Missing OPENAI_API_KEY environment variable. The embeddings API will not function.", - ); -} - -const openai = apiKey ? new OpenAI({ apiKey }) : null; - -type RequestBody = { - input: string | string[]; - model?: string; - dimensions?: number; - encoding_format?: "float" | "base64"; -}; - -const OPENAI_REQUEST_TIMEOUT_MS = 30000; - -export const POST = async (req: NextRequest): Promise => { - let response: NextResponse; - - if (!apiKey) { - response = NextResponse.json( - { - error: "Server configuration error.", - details: "Embeddings service is not configured.", - }, - { status: 500 }, - ); - return cors(req, response) as NextResponse; - } - - try { - const body: RequestBody = await req.json(); - const { - input, - model = "text-embedding-3-small", - dimensions, - encoding_format = "float", - } = body; - - if (!input || (Array.isArray(input) && input.length === 0)) { - response = NextResponse.json( - { error: "Input text cannot be empty." }, - { status: 400 }, - ); - return cors(req, response) as NextResponse; - } - - const options: OpenAI.EmbeddingCreateParams = { - model, - input, - dimensions, - encoding_format, - }; - - const embeddingsPromise = openai!.embeddings.create(options); - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error("OpenAI API request timeout")), - OPENAI_REQUEST_TIMEOUT_MS, - ), - ); - - const openAIResponse = (await Promise.race([ - embeddingsPromise, - timeoutPromise, - ])) as OpenAI.CreateEmbeddingResponse; - - response = NextResponse.json(openAIResponse, { status: 200 }); - } catch (error: unknown) { - console.error("Error calling OpenAI Embeddings API:", error); - const errorMessage = - process.env.NODE_ENV === "development" - ? error instanceof Error - ? error.message - : "Unknown error" - : "Internal server error"; - response = NextResponse.json( - { - error: "Failed to generate embeddings.", - details: errorMessage, - }, - { status: 500 }, - ); - } - - return cors(req, response) as NextResponse; -}; - -export const OPTIONS = async (req: NextRequest): Promise => { - return cors(req, new NextResponse(null, { status: 204 })) as NextResponse; -}; diff --git a/apps/website/app/api/embeddings/route.ts b/apps/website/app/api/embeddings/route.ts new file mode 100644 index 000000000..6579dbd4b --- /dev/null +++ b/apps/website/app/api/embeddings/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import cors from "~/utils/llm/cors"; +import { genericEmbedding } from "~/utils/llm/embeddings"; +import { EmbeddingSettings, Provider } from "~/types/llm"; + +type RequestBody = { + input: string | string[]; + settings: EmbeddingSettings; + provider?: Provider; +}; + +export const POST = async (req: NextRequest): Promise => { + let response: NextResponse; + + try { + const body: RequestBody = await req.json(); + const { input, settings, provider = "openai" } = body; + + if (!input || (Array.isArray(input) && input.length === 0)) { + response = NextResponse.json( + { error: "Input text cannot be empty." }, + { status: 400 }, + ); + return cors(req, response) as NextResponse; + } + + const embeddings = await genericEmbedding(input, settings, provider); + if (embeddings === undefined) + response = NextResponse.json( + { + error: "Failed to generate embeddings.", + }, + { status: 500 }, + ); + else response = NextResponse.json(embeddings, { status: 200 }); + } catch (error: unknown) { + console.error("Error calling OpenAI Embeddings API:", error); + const errorMessage = + process.env.NODE_ENV === "development" + ? error instanceof Error + ? error.message + : "Unknown error" + : "Internal server error"; + response = NextResponse.json( + { + error: "Failed to generate embeddings.", + details: errorMessage, + }, + { status: 500 }, + ); + } + + return cors(req, response) as NextResponse; +}; + +export const OPTIONS = async (req: NextRequest): Promise => { + return cors(req, new NextResponse(null, { status: 204 })) as NextResponse; +}; diff --git a/apps/website/app/api/supabase/rpc/search-content/route.ts b/apps/website/app/api/supabase/rpc/search-content/route.ts new file mode 100644 index 000000000..56feda0c3 --- /dev/null +++ b/apps/website/app/api/supabase/rpc/search-content/route.ts @@ -0,0 +1,135 @@ +import { createClient } from "~/utils/supabase/server"; +import { NextResponse, NextRequest } from "next/server"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import cors from "~/utils/llm/cors"; +import type { Database } from "@repo/database/types.gen.ts"; +import { get_known_embedding } from "~/utils/supabase/dbUtils"; +import { genericEmbedding } from "~/utils/llm/embeddings"; +import type { Provider, EmbeddingSettings } from "~/types/llm"; + +type RequestBody = { + queryText: string; // The text that the content embeddings will be compared to. + subsetPlatformIds: string[]; // Restrict results to these contents. Uses Platform (eg Roam) identifiers. +}; + +type RpcResponseItem = + Database["public"]["Functions"]["match_embeddings_for_subset_nodes"]["Returns"]; + +async function callMatchEmbeddingsRpc( + supabase: SupabaseClient, + query: RequestBody, +): Promise<{ data?: RpcResponseItem; error?: string }> { + const { queryText, subsetPlatformIds } = query; + const provider: Provider = "openai"; + const settings: EmbeddingSettings = { model: "text-embedding-3-small" }; + + const table_data = get_known_embedding( + settings.model, + settings.dimensions, + provider, + ); + if (table_data === undefined) { + return { + error: "Invalid model information", + }; + } + + let newEmbedding; + try { + newEmbedding = await genericEmbedding(queryText, settings, provider); + } catch (error) { + if (error instanceof Error) + return { + error: error.message, + }; + return { + error: `Unknown error generating embeddings: ${error}`, + }; + } + if (!Array.isArray(subsetPlatformIds)) { + console.log( + "[API Route] callMatchEmbeddingsRpc: Invalid subsetPlatformIds.", + ); + return { error: "Invalid subsetPlatformIds" }; + } + + // If subsetPlatformIds is empty, the RPC might not find anything or error, + // depending on its implementation. It might be more efficient to return early. + if (subsetPlatformIds.length === 0) { + console.log( + "[API Route] callMatchEmbeddingsRpc: subsetPlatformIds is empty, returning empty array without calling RPC.", + ); + return { data: [] }; // Return empty array, no need to call RPC + } + + const response = await supabase.rpc("match_embeddings_for_subset_nodes", { + p_query_embedding: JSON.stringify(newEmbedding), + p_subset_roam_uids: subsetPlatformIds, + }); + return { data: response.data || undefined, error: response.error?.message }; +} + +export async function POST(request: NextRequest) { + console.log("[API Route] POST /api/supabase/rpc/search: Request received"); + const supabase = await createClient(); + let response: NextResponse; + + try { + const body: RequestBody = await request.json(); + console.log("[API Route] POST: Parsed request body:", body); + + console.log("[API Route] POST: Calling callMatchEmbeddingsRpc."); + const { data, error } = await callMatchEmbeddingsRpc(supabase, body); + console.log("[API Route] POST: Received from callMatchEmbeddingsRpc:", { + dataLength: data?.length, + error, + }); + + if (error) { + console.error( + "[API Route] POST: Error after callMatchEmbeddingsRpc:", + error, + ); + const statusCode = error?.includes("Invalid") ? 400 : 500; + response = NextResponse.json( + { + error: error || "Failed to match embeddings via RPC.", + }, + { status: statusCode }, + ); + } else { + console.log( + "[API Route] POST: Successfully processed request. Sending data back. Data length:", + data?.length, + ); + response = NextResponse.json(data, { status: 200 }); + } + } catch (e: any) { + console.error( + "[API Route] POST: Exception in POST handler:", + e.message, + e.stack, + ); + if (e instanceof SyntaxError && e.message.toLowerCase().includes("json")) { + response = NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); + } else { + response = NextResponse.json( + { error: "An unexpected error occurred processing your request." }, + { status: 500 }, + ); + } + } + console.log( + "[API Route] POST: Sending final response with status:", + response.status, + ); + return cors(request, response) as NextResponse; +} + +export async function OPTIONS(request: NextRequest) { + const response = new NextResponse(null, { status: 204 }); + return cors(request, response) as NextResponse; +} diff --git a/apps/website/app/types/llm.ts b/apps/website/app/types/llm.ts index b461fee47..6e0712ed8 100644 --- a/apps/website/app/types/llm.ts +++ b/apps/website/app/types/llm.ts @@ -1,3 +1,5 @@ +export type Provider = "openai" | "anthropic" | "gemini"; + export type Message = { role: string; content: string; @@ -19,6 +21,11 @@ export type RequestBody = { settings: Settings; }; +export type EmbeddingSettings = { + model: string; + dimensions?: number; +}; + export const CONTENT_TYPE_JSON = "application/json"; export const CONTENT_TYPE_TEXT = "text/plain"; diff --git a/apps/website/app/utils/llm/embeddings.ts b/apps/website/app/utils/llm/embeddings.ts new file mode 100644 index 000000000..577a0e676 --- /dev/null +++ b/apps/website/app/utils/llm/embeddings.ts @@ -0,0 +1,59 @@ +import OpenAI from "openai"; +import { EmbeddingSettings, Provider } from "~/types/llm"; +import { openaiConfig } from "./providers"; + +const OPENAI_REQUEST_TIMEOUT_MS = 30000; + +const openaiEmbedding = async ( + input: string | string[], + settings: EmbeddingSettings, +): Promise => { + const config = openaiConfig; + const apiKey = process.env[config.apiKeyEnvVar]; + if (!apiKey) + throw new Error( + `API key not configured. Please set the ${config.apiKeyEnvVar} environment variable in your Vercel project settings.`, + ); + const openai = new OpenAI({ apiKey: apiKey }); + + let options: OpenAI.EmbeddingCreateParams = { + model: settings.model, + input, + }; + if (settings.dimensions) { + options = { ...options, ...{ dimensions: settings.dimensions } }; + } + + const embeddingsPromise = openai!.embeddings.create(options); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("OpenAI API request timeout")), + OPENAI_REQUEST_TIMEOUT_MS, + ), + ); + + const response = await Promise.race([embeddingsPromise, timeoutPromise]); + const embeddings = response.data.map((d) => d.embedding); + if (Array.isArray(input)) return embeddings; + else return embeddings[0]; +}; + +export const genericEmbedding = async ( + input: string | string[], + settings: EmbeddingSettings, + provider: Provider = "openai", +): Promise => { + if (provider == "openai") { + return await openaiEmbedding(input, settings); + } else { + // Note: There are two paths here. + // Earlier code choose to add openai to dependencies and use the library. It's what I had built on. + // We could follow that pattern, add anthropic/gemini, and use those in the handlers as well. + // The new code pattern uses direct api calls and structures. + // It should not be too considerable an effort to extend the LLMProviderConfig for embeddings. + // Either way is minimal work, but I think neither should be pursued without discussing + // the implicit tradeoff: More dependencies vs more resilience to API changes. + // right now I choose to minimize changes to my work to reduce scope. + throw Error("Not implemented"); + } +}; diff --git a/apps/website/app/utils/llm/providers.ts b/apps/website/app/utils/llm/providers.ts index 8a94fec14..2a02e07a6 100644 --- a/apps/website/app/utils/llm/providers.ts +++ b/apps/website/app/utils/llm/providers.ts @@ -1,4 +1,4 @@ -import { LLMProviderConfig, Message, Settings } from "~/types/llm"; +import { LLMProviderConfig, Message, Settings, Provider } from "~/types/llm"; export const openaiConfig: LLMProviderConfig = { apiKeyEnvVar: "OPENAI_API_KEY", @@ -58,3 +58,9 @@ export const anthropicConfig: LLMProviderConfig = { extractResponseText: (responseData: any) => responseData.content?.[0]?.text, errorMessagePath: "error?.message", }; + +export const CONFIG_FOR_PROVIDER: { [key in Provider]: LLMProviderConfig } = { + openai: openaiConfig, + anthropic: anthropicConfig, + gemini: geminiConfig, +}; diff --git a/apps/website/app/utils/supabase/dbUtils.ts b/apps/website/app/utils/supabase/dbUtils.ts index 6841b797e..04da4e37a 100644 --- a/apps/website/app/utils/supabase/dbUtils.ts +++ b/apps/website/app/utils/supabase/dbUtils.ts @@ -5,11 +5,13 @@ import type { } from "@supabase/supabase-js"; import { Database, Tables, TablesInsert } from "@repo/database/types.gen.ts"; +export type EmbeddingTableData = { + tableName: keyof Database["public"]["Tables"]; + tableSize: number; +}; + export const KNOWN_EMBEDDING_TABLES: { - [key: string]: { - tableName: keyof Database["public"]["Tables"]; - tableSize: number; - }; + [key: string]: EmbeddingTableData; } = { openai_text_embedding_3_small_1536: { tableName: "ContentEmbedding_openai_text_embedding_3_small_1536", @@ -17,6 +19,37 @@ export const KNOWN_EMBEDDING_TABLES: { }, }; +const KNOWN_EMBEDDINGS: { [key: string]: string } = { + "openai-text-embedding-3-small-1536": "openai_text_embedding_3_small_1536", +}; + +const DEFAULT_DIMENSIONS: { [key: string]: number } = { + "text-embedding-3-small": 1536, +}; + +export function get_known_embedding( + model: string, + dimensions: number | undefined, + provider: string, +): EmbeddingTableData | undefined { + if (!provider) { + console.warn("No provider specified, defaulting to 'openai'"); + } + if (!dimensions) { + console.error(`No default dimensions found for model: ${model}`); + return undefined; + } + const embeddingName = + KNOWN_EMBEDDINGS[`${provider || "openai"}-${model}-${dimensions}`]; + if (!embeddingName) { + console.error( + `No embedding configuration found for: ${provider || "openai"}-${model}-${dimensions}`, + ); + return undefined; + } + return KNOWN_EMBEDDING_TABLES[embeddingName || ""]; +} + const UNIQUE_KEY_RE = /^Key \(([^)]+)\)=\(([\^)]+)\) already exists\.$/; const UNIQUE_INDEX_RE = /duplicate key value violates unique constraint "(\w+)"/;