Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d9fb9db
Squash of eng-294-create-supabase-insertupdate-route
maparent May 23, 2025
4c70142
Adopt most coderabbit suggestions.
maparent May 23, 2025
36cdade
'revert' most changes outside of apps/website/app/
maparent May 23, 2025
e5b14b7
minor eslint changes
maparent May 25, 2025
3424174
normalize imports
maparent May 25, 2025
48f7d4e
will never create an abstract agent, use concrete paths
maparent May 25, 2025
cf749b3
add NODE_ENV
maparent May 25, 2025
5a5cced
Remove most comments, arrow-ify a few functions, isolate more ItemVal…
maparent May 25, 2025
e717e2e
some more comment removal, some more typing exposed a bug
maparent May 25, 2025
a8f6345
generic arrow functions
maparent May 26, 2025
fd708d3
adjustments to model for uniqueness
maparent May 27, 2025
586d7db
Use upsert and specific uniqueOn in getOrCreateEntity. Use named para…
maparent May 27, 2025
438abf4
improvements to error handling
maparent May 27, 2025
11693ea
Use upsert in batches. Generalize error handling.
maparent May 27, 2025
32d3fe6
remove insert from route
maparent May 27, 2025
ac761cb
Getters and deleters
maparent May 27, 2025
916c358
repair vercel
maparent May 27, 2025
179c62f
comments and warnings
maparent May 27, 2025
4272cdf
coderabbit comments
maparent May 27, 2025
9460eca
replace GetOrCreateEntityResult, BatchProcessResult by PostgrestResponse
maparent May 28, 2025
8b75f15
Add table parameter to default handlers
maparent May 28, 2025
f18c87e
More renames, fix dangling agent
maparent May 28, 2025
6f2185e
more corrections
maparent May 28, 2025
86c973a
do not copy supabase file
maparent May 28, 2025
745648d
correction to path; adding ref
maparent May 28, 2025
d22e4a8
only run supabase locally
maparent May 29, 2025
351bed8
last review comments
maparent May 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/website/app/api/embeddings/openai/small/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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<NextResponse> => {
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<never>((_, 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<NextResponse> => {
return cors(req, new NextResponse(null, { status: 204 })) as NextResponse;
};
11 changes: 11 additions & 0 deletions apps/website/app/api/supabase/account/[id].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {
defaultOptionsHandler,
makeDefaultGetHandler,
makeDefaultDeleteHandler,
} from "~/utils/supabase/apiUtils";

export const GET = makeDefaultGetHandler("Account");

export const OPTIONS = defaultOptionsHandler;

export const DELETE = makeDefaultDeleteHandler("Account");
70 changes: 70 additions & 0 deletions apps/website/app/api/supabase/account/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { NextResponse, NextRequest } from "next/server";
import type { PostgrestSingleResponse } from "@supabase/supabase-js";

import { createClient } from "~/utils/supabase/server";
import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils";
import {
createApiResponse,
handleRouteError,
defaultOptionsHandler,
asPostgrestFailure,
} from "~/utils/supabase/apiUtils";
import { Tables, TablesInsert } from "@repo/database/types.gen.ts";

type AccountDataInput = TablesInsert<"Account">;
type AccountRecord = Tables<"Account">;

const validateAccount: ItemValidator<AccountDataInput> = (account) => {
if (!account || typeof account !== "object")
return "Invalid request body: expected a JSON object.";
if (!account.agent_id) return "Missing required agent_id";
if (!account.platform_id) return "Missing required platform_id";
return null;
};

const getOrCreateAccount = async (
supabasePromise: ReturnType<typeof createClient>,
accountData: AccountDataInput,
): Promise<PostgrestSingleResponse<AccountRecord>> => {
const {
agent_id,
platform_id,
active = true,
write_permission = true,
account_local_id,
} = accountData;

const error = validateAccount(accountData);
if (error !== null) return asPostgrestFailure(error, "invalid");

const supabase = await supabasePromise;

const result = await getOrCreateEntity<"Account">({
supabase,
tableName: "Account",
insertData: {
agent_id,
platform_id,
active,
write_permission,
account_local_id,
},
uniqueOn: ["agent_id", "platform_id"],
});
return result;
};

export const POST = async (request: NextRequest): Promise<NextResponse> => {
const supabasePromise = createClient();

try {
const body: AccountDataInput = await request.json();
const result = await getOrCreateAccount(supabasePromise, body);

return createApiResponse(request, result);
} catch (e: unknown) {
return handleRouteError(request, e, "/api/supabase/account");
}
};

export const OPTIONS = defaultOptionsHandler;
19 changes: 19 additions & 0 deletions apps/website/app/api/supabase/content-embedding/[id].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
defaultOptionsHandler,
makeDefaultGetHandler,
makeDefaultDeleteHandler,
} from "~/utils/supabase/apiUtils";

// TODO: Make model agnostic

export const GET = makeDefaultGetHandler(
"ContentEmbedding_openai_text_embedding_3_small_1536",
"targetId",
);

export const DELETE = makeDefaultDeleteHandler(
"ContentEmbedding_openai_text_embedding_3_small_1536",
"targetId",
);

export const OPTIONS = defaultOptionsHandler;
132 changes: 132 additions & 0 deletions apps/website/app/api/supabase/content-embedding/batch/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { NextResponse, NextRequest } from "next/server";
import type { PostgrestResponse } from "@supabase/supabase-js";

import { createClient } from "~/utils/supabase/server";
import {
createApiResponse,
handleRouteError,
defaultOptionsHandler,
asPostgrestFailure,
} from "~/utils/supabase/apiUtils";
import {
processAndInsertBatch,
KNOWN_EMBEDDING_TABLES,
} from "~/utils/supabase/dbUtils";
import {
ApiInputEmbeddingItem,
ApiOutputEmbeddingRecord,
embeddingInputProcessing,
embeddingOutputProcessing,
} from "~/utils/supabase/validators";

const DEFAULT_MODEL = "openai_text_embedding_3_small_1536";

const batchInsertEmbeddingsProcess = async (
supabase: Awaited<ReturnType<typeof createClient>>,
embeddingItems: ApiInputEmbeddingItem[],
): Promise<PostgrestResponse<ApiOutputEmbeddingRecord>> => {
// groupBy is node21 only, we are using 20. Group by model, by hand.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to handle this case? Stated differently, in what feature are we expecting to batch embeddings for different models, in the same call?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maparent I don't believe we discussed this. If we did, could you add the context in this comment.

// Note: This means that later index values may be totally wrong.
// Note2: The key is a ModelName, but I cannot use an enum as a key.
const byModel: { [key: string]: ApiInputEmbeddingItem[] } = {};
try {
embeddingItems.reduce((acc, item) => {
const model = item?.model || DEFAULT_MODEL;
if (acc[model] === undefined) {
acc[model] = [];
}
acc[model]!.push(item);
return acc;
}, byModel);
} catch (error) {
if (error instanceof Error) {
return asPostgrestFailure(error.message, "exception");
}
throw error;
}

const globalResults: ApiOutputEmbeddingRecord[] = [];
const partialErrors: string[] = [];
let created = false,
count = 0,
has_400 = false;
for (const modelName of Object.keys(byModel)) {
const embeddingItemsSet = byModel[modelName];
if (embeddingItemsSet === undefined) continue;
const tableData = KNOWN_EMBEDDING_TABLES[modelName];
if (tableData === undefined) continue;
const results = await processAndInsertBatch<
// any ContentEmbedding table for type checking purposes only
"ContentEmbedding_openai_text_embedding_3_small_1536",
ApiInputEmbeddingItem,
ApiOutputEmbeddingRecord
>({
supabase,
items: embeddingItemsSet,
tableName: tableData.tableName,
inputProcessor: embeddingInputProcessing,
outputProcessor: embeddingOutputProcessing,
});
if (results.data) {
count += results.data.length;
globalResults.push(...results.data);
created = created || results.status === 201;
} else {
partialErrors.push(results.error.message);
if (results.status === 400) has_400 = true;
}
}
if (count > 0) {
if (partialErrors.length > 0) {
return {
data: globalResults,
error: null,
status: has_400 ? 400 : 500,
count,
statusText: partialErrors.join("; "),
};
} else
return {
data: globalResults,
error: null,
status: created ? 201 : 200,
count,
statusText: created ? "created" : "success",
};
} else {
return asPostgrestFailure(
partialErrors.join("; "),
"multiple",
has_400 ? 400 : 500,
);
}
};

export const POST = async (request: NextRequest): Promise<NextResponse> => {
const supabase = await createClient();

try {
const body: ApiInputEmbeddingItem[] = await request.json();
if (!Array.isArray(body)) {
return createApiResponse(
request,
asPostgrestFailure(
"Request body must be an array of embedding items.",
"empty",
),
);
}

const result = await batchInsertEmbeddingsProcess(supabase, body);

return createApiResponse(request, result);
} catch (e: unknown) {
return handleRouteError(
request,
e,
`/api/supabase/content-embedding/batch`,
);
}
};

export const OPTIONS = defaultOptionsHandler;
Loading