Files
genesis-2/supabase/functions/ai-structured/index.ts
T

442 lines
13 KiB
TypeScript

// supabase/functions/ai-structured/index.ts
import OpenAI from "npm:openai";
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
const MB = 1024 * 1024;
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
function extractOutputText(resp: any): string {
if (typeof resp?.output_text === "string") return resp.output_text;
const texts: string[] = [];
for (const item of resp?.output ?? []) {
if (item?.type === "message") {
for (const part of item?.content ?? []) {
if (part?.type === "output_text" && typeof part?.text === "string") {
texts.push(part.text);
}
}
}
}
return texts.join("\n");
}
function sanitizeTools(tools: any[]): any[] {
return (tools ?? []).map((t) => {
if (t?.type === "mcp" && t?.authorization) {
return { ...t, authorization: "[REDACTED]" };
}
return t;
});
}
function normalizeInputWithOpenAIFileIds(input: any, fileIds: string[]) {
if (!fileIds?.length) return input;
// Responses API acepta input como string o array.
let items = Array.isArray(input) ? [...input] : [{
role: "user",
content: [
input ?? "",
],
}];
// Busca último mensaje "user"
let idx = -1;
for (let i = items.length - 1; i >= 0; i--) {
if (items[i]?.role === "user") {
idx = i;
break;
}
}
if (idx === -1) {
items.push({ role: "user", content: [] });
idx = items.length - 1;
}
const msg = items[idx];
const fileParts = fileIds.map((id) => ({ type: "input_file", file_id: id }));
// Normalize content to array format
if (typeof msg.content === "string") {
msg.content = [{ type: "input_text", text: msg.content }, ...fileParts];
} else if (Array.isArray(msg.content)) {
msg.content = [...msg.content, ...fileParts];
} else {
// fallback raro
msg.content = [
{ type: "input_text", text: String(msg.content) },
...fileParts,
];
}
items[idx] = msg;
return items;
}
function normalizeInputUser(input: any) {
if (typeof input === "string") {
return [{
role: "user",
content: [{
type: "input_text",
text: input,
}],
}];
} else if (Array.isArray(input)) {
return input;
} else {
return [{
role: "user",
content: [{
type: "input_text",
text: String(input),
}],
}];
}
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
// 1) Auth requerido ✅
const authHeader = req.headers.get("Authorization") ??
req.headers.get("authorization");
if (!authHeader) {
return json({ ok: false, error: "Missing Authorization header" }, 401);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
return json({
ok: false,
error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY",
}, 500);
}
// Cliente con el JWT del usuario (para validar quién llama)
const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await supabaseUser.auth
.getUser();
if (userErr || !userData?.user) {
return json({ ok: false, error: "Invalid token" }, 401);
}
const user = userData.user;
// (Opcional) service role para uploads server-side (más confiable)
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
const supabaseAdmin = SERVICE_ROLE
? createClient(SUPABASE_URL, SERVICE_ROLE)
: supabaseUser;
// 2) Parse body (JSON o multipart) 📦
const contentType = req.headers.get("content-type") ?? "";
let payload: any = {};
let tempFiles: File[] = [];
if (contentType.includes("multipart/form-data")) {
let form;
try {
form = await req.formData();
} catch (error) {
return json({
ok: false,
error: "Invalid multipart/form-data body",
details: error,
}, 400);
}
const raw = form.get("payload") ?? "{}";
payload = JSON.parse(raw);
// Soporta nombres comunes
for (
const key of ["files", "files[]", "archivos", "archivosTemporales"]
) {
for (const v of form.getAll(key)) {
if (v instanceof File) tempFiles.push(v);
}
}
// Fallback: toma cualquier File en el form
if (tempFiles.length === 0) {
for (const [, v] of form.entries()) {
if (v instanceof File) tempFiles.push(v);
}
}
} else {
payload = await req.json();
}
// 3) Construye config final para OpenAI (passthrough + overrides) 🧠
// Puedes mandar "response" o "openai" (ambos se mezclan)
const responseCfg: any = {
...(payload.openai ?? {}),
...(payload.response ?? {}),
};
// Conveniencia top-level
if (payload.input !== undefined && responseCfg.input === undefined) {
responseCfg.input = payload.input;
}
// Normaliza input a array de mensajes
if (responseCfg.input !== undefined) {
responseCfg.input = normalizeInputUser(responseCfg.input);
}
if (payload.model && !responseCfg.model) responseCfg.model = payload.model;
if (payload.conversationId && responseCfg.conversation === undefined) {
responseCfg.conversation = payload.conversationId;
}
if (
payload.previousResponseId &&
responseCfg.previous_response_id === undefined
) responseCfg.previous_response_id = payload.previousResponseId;
if (payload.reasoning && responseCfg.reasoning === undefined) {
responseCfg.reasoning = payload.reasoning;
}
// Defaults
responseCfg.model = responseCfg.model ?? "gpt-5";
// Regla API: no mezclar conversation + previous_response_id
if (responseCfg.conversation && responseCfg.previous_response_id) {
return json(
{
ok: false,
error: "Do not send both `conversation` and `previous_response_id`.",
},
400,
);
}
// 4) Structured Outputs (JSON Schema) ✅
// Espera payload.structured (o payload.outputSchema) como objeto o string JSON
let structured = payload.structured ?? payload.outputSchema;
if (!structured) {
return json({
ok: false,
error: "Missing `structured` (JSON schema format).",
}, 400);
}
if (typeof structured === "string") {
try {
structured = JSON.parse(structured);
} catch {
return json({
ok: false,
error: "`structured` is a string but not valid JSON.",
}, 400);
}
}
// Permite que te manden { format: {...} }
const format = structured.format ? structured.format : structured;
if (!format.type) format.type = "json_schema";
if (format.type === "json_schema") {
format.name = format.name ?? "structured_output";
format.strict = format.strict ?? true;
if (!format.schema) {
return json(
{ ok: false, error: "structured.schema is required." },
400,
);
}
}
responseCfg.text = { ...(responseCfg.text ?? {}), format };
// 5) Referencias (3 tipos) 📎
const references = payload.references ?? {};
const openaiFileIds: string[] = references.openaiFileIds ??
payload.openaiFileIds ?? [];
const vectorStoreIds: string[] = references.vectorStoreIds ??
payload.vectorStoreIds ?? [];
// 5a) Vector stores => file_search tool
const tools: any[] = Array.isArray(responseCfg.tools)
? [...responseCfg.tools]
: [];
if (vectorStoreIds.length) {
tools.push({ type: "file_search", vector_store_ids: vectorStoreIds });
// Si quieres ver resultados de búsqueda en la respuesta
const include = Array.isArray(responseCfg.include)
? responseCfg.include
: [];
if (!include.includes("file_search_call.results")) {
include.push("file_search_call.results");
}
responseCfg.include = include;
}
// 5b) MCP opcional
const usarMCP: boolean = Boolean(
payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled,
);
if (usarMCP) {
const mcp = payload.mcp ?? {};
const server_url = mcp.server_url ?? Deno.env.get("MCP_SERVER_URL");
if (!server_url) {
return json(
{
ok: false,
error: "usarMCP=true pero falta MCP_SERVER_URL o mcp.server_url",
},
400,
);
}
tools.push({
type: "mcp",
server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ??
"supabase",
server_description: mcp.server_description ?? "Supabase MCP server",
server_url,
require_approval: mcp.require_approval ?? "never",
authorization: mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION"),
allowed_tools: mcp.allowed_tools, // opcional
});
}
responseCfg.tools = tools;
// 5c) OpenAI file IDs => se insertan al input como input_file
if (openaiFileIds.length) {
responseCfg.input = normalizeInputWithOpenAIFileIds(
responseCfg.input,
openaiFileIds,
);
}
// 6) Archivos temporales => subir a Supabase Storage (NO a OpenAI) ☁️
const uploadedToStorage: Array<{
bucket: string;
path: string;
name: string;
type: string;
size: number;
}> = [];
if (tempFiles.length) {
const bucket = payload.storage?.bucket ??
Deno.env.get("AI_FILES_BUCKET") ?? "ai-temp";
const prefix = payload.storage?.prefix ??
Deno.env.get("AI_FILES_PREFIX") ?? "tmp";
for (const f of tempFiles) {
const safeName = (f.name ?? "file").replaceAll(/[^a-zA-Z0-9._-]/g, "_");
const path = `${prefix}/${user.id}/${crypto.randomUUID()}-${safeName}`;
const bytes = new Uint8Array(await f.arrayBuffer());
const { data, error } = await supabaseAdmin.storage
.from(bucket)
.upload(path, bytes, { contentType: f.type, upsert: false });
if (error) {
return json({
ok: false,
error: `Storage upload failed: ${error.message}`,
file: f.name,
}, 500);
}
// Add as an input_file base64 reference to the last user message content
const fileBase64 = btoa(String.fromCharCode(...bytes));
if (Array.isArray(responseCfg.input) && responseCfg.input.length > 0) {
const lastMsg = responseCfg.input[responseCfg.input.length - 1];
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
lastMsg.content.push({
type: "input_file",
filename: f.name,
file_data: `data:${f.type};base64,${fileBase64}`,
});
}
}
uploadedToStorage.push({
bucket,
path: data.path,
name: f.name,
type: f.type,
size: f.size,
});
}
}
// 7) Llamada a OpenAI Responses API 🚀
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
if (!OPENAI_API_KEY) {
return json({ ok: false, error: "Missing OPENAI_API_KEY" }, 500);
}
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
let resp = null;
try {
resp = await openai.responses.create(responseCfg);
} catch (error) {
return json({
ok: false,
error: "OpenAI API error",
details: (error as Error)?.message ?? String(error),
}, 500);
}
const outputText = extractOutputText(resp);
let output: unknown = null;
let outputParseError: string | null = null;
try {
output = JSON.parse(outputText);
} catch (e) {
outputParseError = (e as Error)?.message ?? String(e);
}
const conv = resp?.conversation?.id ??
(typeof resp?.conversation === "string"
? resp.conversation
: responseCfg.conversation ?? null);
return json({
ok: outputParseError ? false : true,
responseId: resp.id,
conversationId: conv,
model: resp.model ?? responseCfg.model,
usage: resp.usage ?? null,
outputText,
output,
outputParseError,
references: {
uploadedToStorage,
openaiFileIds,
vectorStoreIds,
// Si quieres mandar tus UUID internos de archivos, aquí van (solo eco)
archivosReferenciaIds: references.archivosReferenciaIds ??
payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [],
},
tools: sanitizeTools(responseCfg.tools),
});
} catch (e) {
return json({ ok: false, error: (e as Error)?.message ?? String(e) }, 500);
}
});