// 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); } });