Skip to content

Commit 7c90190

Browse files
authored
Squash of eng-294-create-supabase-insertupdate-route (#176)
* Squash of eng-294-create-supabase-insertupdate-route Work by Sid: Hyde utility, embedding route, supabase insert routes Marc-Antoine: introduce generated types, further work on insert routes * Adopt most coderabbit suggestions. * 'revert' most changes outside of apps/website/app/ * minor eslint changes * normalize imports * will never create an abstract agent, use concrete paths * add NODE_ENV * Remove most comments, arrow-ify a few functions, isolate more ItemValidators * some more comment removal, some more typing exposed a bug * generic arrow functions * adjustments to model for uniqueness * Use upsert and specific uniqueOn in getOrCreateEntity. Use named parameters. * improvements to error handling * Use upsert in batches. Generalize error handling. * remove insert from route * Getters and deleters * repair vercel * comments and warnings * coderabbit comments * replace GetOrCreateEntityResult, BatchProcessResult by PostgrestResponse * Add table parameter to default handlers * More renames, fix dangling agent * more corrections * do not copy supabase file * correction to path; adding ref * only run supabase locally * last review comments
1 parent 8dfea18 commit 7c90190

26 files changed

Lines changed: 2138 additions & 100 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import OpenAI from "openai";
3+
import cors from "~/utils/llm/cors";
4+
5+
const apiKey = process.env.OPENAI_API_KEY;
6+
7+
if (!apiKey) {
8+
console.error(
9+
"Missing OPENAI_API_KEY environment variable. The embeddings API will not function.",
10+
);
11+
}
12+
13+
const openai = apiKey ? new OpenAI({ apiKey }) : null;
14+
15+
type RequestBody = {
16+
input: string | string[];
17+
model?: string;
18+
dimensions?: number;
19+
encoding_format?: "float" | "base64";
20+
};
21+
22+
const OPENAI_REQUEST_TIMEOUT_MS = 30000;
23+
24+
export const POST = async (req: NextRequest): Promise<NextResponse> => {
25+
let response: NextResponse;
26+
27+
if (!apiKey) {
28+
response = NextResponse.json(
29+
{
30+
error: "Server configuration error.",
31+
details: "Embeddings service is not configured.",
32+
},
33+
{ status: 500 },
34+
);
35+
return cors(req, response) as NextResponse;
36+
}
37+
38+
try {
39+
const body: RequestBody = await req.json();
40+
const {
41+
input,
42+
model = "text-embedding-3-small",
43+
dimensions,
44+
encoding_format = "float",
45+
} = body;
46+
47+
if (!input || (Array.isArray(input) && input.length === 0)) {
48+
response = NextResponse.json(
49+
{ error: "Input text cannot be empty." },
50+
{ status: 400 },
51+
);
52+
return cors(req, response) as NextResponse;
53+
}
54+
55+
const options: OpenAI.EmbeddingCreateParams = {
56+
model,
57+
input,
58+
dimensions,
59+
encoding_format,
60+
};
61+
62+
const embeddingsPromise = openai!.embeddings.create(options);
63+
const timeoutPromise = new Promise<never>((_, reject) =>
64+
setTimeout(
65+
() => reject(new Error("OpenAI API request timeout")),
66+
OPENAI_REQUEST_TIMEOUT_MS,
67+
),
68+
);
69+
70+
const openAIResponse = (await Promise.race([
71+
embeddingsPromise,
72+
timeoutPromise,
73+
])) as OpenAI.CreateEmbeddingResponse;
74+
75+
response = NextResponse.json(openAIResponse, { status: 200 });
76+
} catch (error: unknown) {
77+
console.error("Error calling OpenAI Embeddings API:", error);
78+
const errorMessage =
79+
process.env.NODE_ENV === "development"
80+
? error instanceof Error
81+
? error.message
82+
: "Unknown error"
83+
: "Internal server error";
84+
response = NextResponse.json(
85+
{
86+
error: "Failed to generate embeddings.",
87+
details: errorMessage,
88+
},
89+
{ status: 500 },
90+
);
91+
}
92+
93+
return cors(req, response) as NextResponse;
94+
};
95+
96+
export const OPTIONS = async (req: NextRequest): Promise<NextResponse> => {
97+
return cors(req, new NextResponse(null, { status: 204 })) as NextResponse;
98+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {
2+
defaultOptionsHandler,
3+
makeDefaultGetHandler,
4+
makeDefaultDeleteHandler,
5+
} from "~/utils/supabase/apiUtils";
6+
7+
export const GET = makeDefaultGetHandler("Account");
8+
9+
export const OPTIONS = defaultOptionsHandler;
10+
11+
export const DELETE = makeDefaultDeleteHandler("Account");
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { NextResponse, NextRequest } from "next/server";
2+
import type { PostgrestSingleResponse } from "@supabase/supabase-js";
3+
4+
import { createClient } from "~/utils/supabase/server";
5+
import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils";
6+
import {
7+
createApiResponse,
8+
handleRouteError,
9+
defaultOptionsHandler,
10+
asPostgrestFailure,
11+
} from "~/utils/supabase/apiUtils";
12+
import { Tables, TablesInsert } from "@repo/database/types.gen.ts";
13+
14+
type AccountDataInput = TablesInsert<"Account">;
15+
type AccountRecord = Tables<"Account">;
16+
17+
const validateAccount: ItemValidator<AccountDataInput> = (account) => {
18+
if (!account || typeof account !== "object")
19+
return "Invalid request body: expected a JSON object.";
20+
if (!account.agent_id) return "Missing required agent_id";
21+
if (!account.platform_id) return "Missing required platform_id";
22+
return null;
23+
};
24+
25+
const getOrCreateAccount = async (
26+
supabasePromise: ReturnType<typeof createClient>,
27+
accountData: AccountDataInput,
28+
): Promise<PostgrestSingleResponse<AccountRecord>> => {
29+
const {
30+
agent_id,
31+
platform_id,
32+
active = true,
33+
write_permission = true,
34+
account_local_id,
35+
} = accountData;
36+
37+
const error = validateAccount(accountData);
38+
if (error !== null) return asPostgrestFailure(error, "invalid");
39+
40+
const supabase = await supabasePromise;
41+
42+
const result = await getOrCreateEntity<"Account">({
43+
supabase,
44+
tableName: "Account",
45+
insertData: {
46+
agent_id,
47+
platform_id,
48+
active,
49+
write_permission,
50+
account_local_id,
51+
},
52+
uniqueOn: ["agent_id", "platform_id"],
53+
});
54+
return result;
55+
};
56+
57+
export const POST = async (request: NextRequest): Promise<NextResponse> => {
58+
const supabasePromise = createClient();
59+
60+
try {
61+
const body: AccountDataInput = await request.json();
62+
const result = await getOrCreateAccount(supabasePromise, body);
63+
64+
return createApiResponse(request, result);
65+
} catch (e: unknown) {
66+
return handleRouteError(request, e, "/api/supabase/account");
67+
}
68+
};
69+
70+
export const OPTIONS = defaultOptionsHandler;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {
2+
defaultOptionsHandler,
3+
makeDefaultGetHandler,
4+
makeDefaultDeleteHandler,
5+
} from "~/utils/supabase/apiUtils";
6+
7+
// TODO: Make model agnostic
8+
9+
export const GET = makeDefaultGetHandler(
10+
"ContentEmbedding_openai_text_embedding_3_small_1536",
11+
"targetId",
12+
);
13+
14+
export const DELETE = makeDefaultDeleteHandler(
15+
"ContentEmbedding_openai_text_embedding_3_small_1536",
16+
"targetId",
17+
);
18+
19+
export const OPTIONS = defaultOptionsHandler;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { NextResponse, NextRequest } from "next/server";
2+
import type { PostgrestResponse } from "@supabase/supabase-js";
3+
4+
import { createClient } from "~/utils/supabase/server";
5+
import {
6+
createApiResponse,
7+
handleRouteError,
8+
defaultOptionsHandler,
9+
asPostgrestFailure,
10+
} from "~/utils/supabase/apiUtils";
11+
import {
12+
processAndInsertBatch,
13+
KNOWN_EMBEDDING_TABLES,
14+
} from "~/utils/supabase/dbUtils";
15+
import {
16+
ApiInputEmbeddingItem,
17+
ApiOutputEmbeddingRecord,
18+
embeddingInputProcessing,
19+
embeddingOutputProcessing,
20+
} from "~/utils/supabase/validators";
21+
22+
const DEFAULT_MODEL = "openai_text_embedding_3_small_1536";
23+
24+
const batchInsertEmbeddingsProcess = async (
25+
supabase: Awaited<ReturnType<typeof createClient>>,
26+
embeddingItems: ApiInputEmbeddingItem[],
27+
): Promise<PostgrestResponse<ApiOutputEmbeddingRecord>> => {
28+
// groupBy is node21 only, we are using 20. Group by model, by hand.
29+
// Note: This means that later index values may be totally wrong.
30+
// Note2: The key is a ModelName, but I cannot use an enum as a key.
31+
const byModel: { [key: string]: ApiInputEmbeddingItem[] } = {};
32+
try {
33+
embeddingItems.reduce((acc, item) => {
34+
const model = item?.model || DEFAULT_MODEL;
35+
if (acc[model] === undefined) {
36+
acc[model] = [];
37+
}
38+
acc[model]!.push(item);
39+
return acc;
40+
}, byModel);
41+
} catch (error) {
42+
if (error instanceof Error) {
43+
return asPostgrestFailure(error.message, "exception");
44+
}
45+
throw error;
46+
}
47+
48+
const globalResults: ApiOutputEmbeddingRecord[] = [];
49+
const partialErrors: string[] = [];
50+
let created = false,
51+
count = 0,
52+
has_400 = false;
53+
for (const modelName of Object.keys(byModel)) {
54+
const embeddingItemsSet = byModel[modelName];
55+
if (embeddingItemsSet === undefined) continue;
56+
const tableData = KNOWN_EMBEDDING_TABLES[modelName];
57+
if (tableData === undefined) continue;
58+
const results = await processAndInsertBatch<
59+
// any ContentEmbedding table for type checking purposes only
60+
"ContentEmbedding_openai_text_embedding_3_small_1536",
61+
ApiInputEmbeddingItem,
62+
ApiOutputEmbeddingRecord
63+
>({
64+
supabase,
65+
items: embeddingItemsSet,
66+
tableName: tableData.tableName,
67+
inputProcessor: embeddingInputProcessing,
68+
outputProcessor: embeddingOutputProcessing,
69+
});
70+
if (results.data) {
71+
count += results.data.length;
72+
globalResults.push(...results.data);
73+
created = created || results.status === 201;
74+
} else {
75+
partialErrors.push(results.error.message);
76+
if (results.status === 400) has_400 = true;
77+
}
78+
}
79+
if (count > 0) {
80+
if (partialErrors.length > 0) {
81+
return {
82+
data: globalResults,
83+
error: null,
84+
status: has_400 ? 400 : 500,
85+
count,
86+
statusText: partialErrors.join("; "),
87+
};
88+
} else
89+
return {
90+
data: globalResults,
91+
error: null,
92+
status: created ? 201 : 200,
93+
count,
94+
statusText: created ? "created" : "success",
95+
};
96+
} else {
97+
return asPostgrestFailure(
98+
partialErrors.join("; "),
99+
"multiple",
100+
has_400 ? 400 : 500,
101+
);
102+
}
103+
};
104+
105+
export const POST = async (request: NextRequest): Promise<NextResponse> => {
106+
const supabase = await createClient();
107+
108+
try {
109+
const body: ApiInputEmbeddingItem[] = await request.json();
110+
if (!Array.isArray(body)) {
111+
return createApiResponse(
112+
request,
113+
asPostgrestFailure(
114+
"Request body must be an array of embedding items.",
115+
"empty",
116+
),
117+
);
118+
}
119+
120+
const result = await batchInsertEmbeddingsProcess(supabase, body);
121+
122+
return createApiResponse(request, result);
123+
} catch (e: unknown) {
124+
return handleRouteError(
125+
request,
126+
e,
127+
`/api/supabase/content-embedding/batch`,
128+
);
129+
}
130+
};
131+
132+
export const OPTIONS = defaultOptionsHandler;

0 commit comments

Comments
 (0)