// 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"; import { corsHeaders } from "../_shared/cors.ts"; 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; let items = Array.isArray(input) ? [...input] : [{ role: "user", content: [ input ?? "", ], }]; 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 })); 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 { 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), }], }]; } } /** ----------------- LOGGING HELPERS (diagnóstico) ----------------- */ function nowIso() { return new Date().toISOString(); } function redactAuthHeader(v: string | null) { if (!v) return null; // "Bearer xxx" => "Bearer [REDACTED]" const parts = v.split(" "); if (parts.length >= 2) return `${parts[0]} [REDACTED]`; return "[REDACTED]"; } function safePreview(val: unknown, maxLen = 1200) { try { const jsonStr = JSON.stringify(val, (k, v) => { if (typeof v === "string") { if (v.length > 500) return v.slice(0, 500) + "…[TRUNCATED]"; // evita imprimir base64/data URLs if (v.startsWith("data:") && v.includes(";base64,")) { return "[DATA_URL_REDACTED]"; } } return v; }); if (!jsonStr) return String(val); return jsonStr.length > maxLen ? jsonStr.slice(0, maxLen) + "…[TRUNCATED]" : jsonStr; } catch { return String(val); } } function headersSnapshot(req: Request) { const h: Record = {}; for (const [k, v] of req.headers.entries()) { if (k.toLowerCase() === "authorization") h[k] = redactAuthHeader(v) ?? ""; else h[k] = v; } return h; } function summarizeInputShape(input: any) { const out: any = { type: typeof input }; if (Array.isArray(input)) { out.kind = "array"; out.length = input.length; out.lastRole = input?.[input.length - 1]?.role ?? null; out.roles = input.map((m: any) => m?.role).filter(Boolean); // intenta contar parts (sin imprimir textos) try { let parts = 0; for (const m of input) { if (Array.isArray(m?.content)) parts += m.content.length; } out.totalParts = parts; } catch { // ignore } } return out; } function summarizeFormat(format: any) { if (!format) return null; const t = format.type ?? null; const name = format.name ?? null; const strict = format.strict ?? null; const schemaKeys = format.schema ? Object.keys(format.schema) : []; return { type: t, name, strict, hasSchema: Boolean(format.schema), schemaTopKeys: schemaKeys.slice(0, 20), }; } function toolsSummary(tools: any[]) { const arr = Array.isArray(tools) ? tools : []; return arr.map((t) => { if (!t) return t; const base: any = { type: t.type }; if (t.type === "file_search") { base.vector_store_ids_count = (t.vector_store_ids ?? []).length; } if (t.type === "mcp") { base.server_label = t.server_label ?? null; base.server_url = t.server_url ? "[SET]" : null; base.require_approval = t.require_approval ?? null; base.authorization = t.authorization ? "[REDACTED]" : null; base.allowed_tools_count = Array.isArray(t.allowed_tools) ? t.allowed_tools.length : null; } return base; }); } /** ----------------------------------------------------------------- */ Deno.serve(async (req) => { const cid = crypto.randomUUID(); const t0 = performance.now(); const log = (msg: string, extra?: unknown) => { if (extra !== undefined) { console.log( `[${nowIso()}][ai-structured][${cid}] ${msg}`, safePreview(extra), ); } else { console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`); } }; const warn = (msg: string, extra?: unknown) => { if (extra !== undefined) { console.warn( `[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`, safePreview(extra), ); } else { console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`); } }; const errlog = (msg: string, extra?: unknown) => { if (extra !== undefined) { console.error( `[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`, safePreview(extra), ); } else { console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`); } }; if (req.method === "OPTIONS") { log("OPTIONS preflight ✅", { method: req.method, url: req.url, headers: headersSnapshot(req), }); return new Response(null, { status: 204, headers: corsHeaders }); } log("START 🚀", { method: req.method, url: req.url }); log("Headers snapshot 🧾", headersSnapshot(req)); try { // 1) Auth requerido ✅ log("Step 1: Auth check 🔐"); const authHeaderRaw = req.headers.get("Authorization") ?? req.headers.get("authorization"); log("Authorization header present?", { present: Boolean(authHeaderRaw), value: redactAuthHeader(authHeaderRaw), }); if (!authHeaderRaw) { warn("Missing Authorization header"); return json( { ok: false, error: "Missing Authorization header", cid }, 401, ); } const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); log("Env presence (Supabase) 🌍", { hasSUPABASE_URL: Boolean(SUPABASE_URL), hasSUPABASE_ANON_KEY: Boolean(SUPABASE_ANON_KEY), }); if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { errlog("Missing SUPABASE_URL / SUPABASE_ANON_KEY"); return json({ ok: false, error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY", cid, }, 500); } const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { global: { headers: { Authorization: authHeaderRaw } }, }); const tAuth = performance.now(); const { data: userData, error: userErr } = await supabaseUser.auth .getUser(); log("Supabase auth.getUser() done ⏱️", { ms: Math.round(performance.now() - tAuth), }); if (userErr || !userData?.user) { warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null }); return json({ ok: false, error: "Invalid token", cid }, 401); } const user = userData.user; log("Authenticated user ✅", { userId: user.id, email: user.email ?? null, }); const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); log("Env presence (service role) 🗝️", { hasSERVICE_ROLE: Boolean(SERVICE_ROLE), }); const supabaseAdmin = SERVICE_ROLE ? createClient(SUPABASE_URL, SERVICE_ROLE) : supabaseUser; log("Supabase client selected 🧩", { adminMode: Boolean(SERVICE_ROLE) }); // 2) Parse body (JSON o multipart) 📦 log("Step 2: Parse body 📦"); const contentType = req.headers.get("content-type") ?? ""; log("Content-Type", { contentType }); let payload: any = {}; let tempFiles: File[] = []; const tParse = performance.now(); if (contentType.includes("multipart/form-data")) { log("Parsing multipart/form-data 🧷"); let form; try { form = await req.formData(); } catch (error) { errlog("Invalid multipart/form-data body", { message: (error as Error)?.message ?? String(error), stack: (error as Error)?.stack ?? null, }); return json({ ok: false, error: "Invalid multipart/form-data body", details: (error as Error)?.message ?? String(error), cid, }, 400); } // keys overview const keys = Array.from(form.keys()); log("Multipart keys", { keys, keysCount: keys.length }); const raw = form.get("payload") ?? "{}"; log("Multipart payload field type", { type: typeof raw }); try { payload = JSON.parse(String(raw)); } catch (e) { errlog("Multipart `payload` is not valid JSON", { rawPreview: String(raw).slice(0, 400), error: (e as Error)?.message ?? String(e), }); return json({ ok: false, error: "Invalid JSON in multipart `payload` field", cid, }, 400); } for ( const key of ["files", "files[]", "archivos", "archivosTemporales"] ) { const vals = form.getAll(key); if (vals?.length) { log(`Found multipart field "${key}"`, { count: vals.length }); } for (const v of vals) { if (v instanceof File) tempFiles.push(v); } } if (tempFiles.length === 0) { for (const [k, v] of form.entries()) { if (v instanceof File) tempFiles.push(v); // opcional: log campos no-file si necesitas // else log("Form entry", { key: k, type: typeof v }); } } log("Multipart files detected 📎", { count: tempFiles.length, files: tempFiles.map((f) => ({ name: f.name, type: f.type, size: f.size, })), }); } else { log("Parsing JSON body 🧾"); try { payload = await req.json(); } catch (e) { errlog("Invalid JSON body", { error: (e as Error)?.message ?? String(e), }); return json({ ok: false, error: "Invalid JSON body", cid }, 400); } } log("Body parsed ⏱️", { ms: Math.round(performance.now() - tParse) }); log("Payload top-level keys", { keys: Object.keys(payload ?? {}), hasOpenAI: Boolean(payload?.openai), hasResponse: Boolean(payload?.response), }); // 3) Construye config final para OpenAI (passthrough + overrides) 🧠 log("Step 3: Build responseCfg 🧠"); const responseCfg: any = { ...(payload.openai ?? {}), ...(payload.response ?? {}), }; if (payload.input !== undefined && responseCfg.input === undefined) { responseCfg.input = payload.input; log("responseCfg.input set from payload.input"); } if (responseCfg.input !== undefined) { responseCfg.input = normalizeInputUser(responseCfg.input); log( "responseCfg.input normalized ✅", summarizeInputShape(responseCfg.input), ); } else { warn("responseCfg.input is undefined (is that expected?)"); } 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; } responseCfg.model = responseCfg.model ?? "gpt-5"; log("OpenAI config summary 🤖", { model: responseCfg.model, hasConversation: Boolean(responseCfg.conversation), hasPreviousResponseId: Boolean(responseCfg.previous_response_id), hasReasoning: Boolean(responseCfg.reasoning), include: Array.isArray(responseCfg.include) ? responseCfg.include : null, }); if (responseCfg.conversation && responseCfg.previous_response_id) { warn("Invalid: conversation + previous_response_id together"); return json( { ok: false, error: "Do not send both `conversation` and `previous_response_id`.", cid, }, 400, ); } // 4) Structured Outputs (JSON Schema) ✅ log("Step 4: Structured output setup ✅"); let structured = payload.structured ?? payload.outputSchema; log("Structured presence", { hasStructured: Boolean(structured), structuredType: typeof structured, }); if (!structured) { warn("Missing structured schema"); return json({ ok: false, error: "Missing `structured` (JSON schema format).", cid, }, 400); } if (typeof structured === "string") { log("Parsing structured from string 🧩", { preview: structured.slice(0, 200), }); try { structured = JSON.parse(structured); } catch (e) { errlog("`structured` string is not valid JSON", { error: (e as Error)?.message ?? String(e), }); return json({ ok: false, error: "`structured` is a string but not valid JSON.", cid, }, 400); } } 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) { warn("structured.schema missing"); return json( { ok: false, error: "structured.schema is required.", cid }, 400, ); } } responseCfg.text = { ...(responseCfg.text ?? {}), format }; log("Structured format summary 🧾", summarizeFormat(format)); // 5) Referencias (3 tipos) 📎 log("Step 5: References & tools 📎"); const references = payload.references ?? {}; const openaiFileIds: string[] = references.openaiFileIds ?? payload.openaiFileIds ?? []; const vectorStoreIds: string[] = references.vectorStoreIds ?? payload.vectorStoreIds ?? []; log("References summary", { openaiFileIdsCount: openaiFileIds.length, vectorStoreIdsCount: vectorStoreIds.length, usarMCP: Boolean( payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled, ), }); // 5a) Vector stores => file_search tool const tools: any[] = Array.isArray(responseCfg.tools) ? [...responseCfg.tools] : []; log("Initial tools (from client)", toolsSummary(tools)); if (vectorStoreIds.length) { tools.push({ type: "file_search", vector_store_ids: vectorStoreIds }); const include = Array.isArray(responseCfg.include) ? responseCfg.include : []; if (!include.includes("file_search_call.results")) { include.push("file_search_call.results"); } responseCfg.include = include; log("Added file_search tool 🗂️", { vectorStoreIdsCount: vectorStoreIds.length, include: responseCfg.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"); log("MCP config presence", { hasServerUrl: Boolean(server_url), hasAuthorization: Boolean( mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION"), ), server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ?? "supabase", }); if (!server_url) { warn("usarMCP=true but missing server_url"); return json( { ok: false, error: "usarMCP=true pero falta MCP_SERVER_URL o mcp.server_url", cid, }, 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, }); log("Added MCP tool 🔌", toolsSummary([tools[tools.length - 1]])); } responseCfg.tools = tools; log("Final tools summary 🧰", toolsSummary(responseCfg.tools)); // 5c) OpenAI file IDs => se insertan al input como input_file if (openaiFileIds.length) { log("Injecting OpenAI file_ids into input 🧷", { count: openaiFileIds.length, }); responseCfg.input = normalizeInputWithOpenAIFileIds( responseCfg.input, openaiFileIds, ); log( "Input after OpenAI file_ids injection", summarizeInputShape(responseCfg.input), ); } // 6) Archivos temporales => subir a Supabase Storage (NO a OpenAI) ☁️ log("Step 6: Temp files -> Storage ☁️"); 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"; log("Storage target", { bucket, prefix, filesCount: tempFiles.length }); for (const f of tempFiles) { const safeName = (f.name ?? "file").replaceAll(/[^a-zA-Z0-9._-]/g, "_"); const path = `${prefix}/${user.id}/${crypto.randomUUID()}-${safeName}`; log("Uploading file to storage ⬆️", { name: f.name, type: f.type, size: f.size, path, }); const tUp = performance.now(); const bytes = new Uint8Array(await f.arrayBuffer()); // warning útil: btoa + spread puede tronarse con archivos grandes if (bytes.length > 2 * MB) { warn( "Large file detected: base64 building might be heavy (btoa + spread) 🧨", { name: f.name, bytes: bytes.length, approxBase64Chars: Math.ceil(bytes.length * 4 / 3), }, ); } const { data, error } = await supabaseAdmin.storage .from(bucket) .upload(path, bytes, { contentType: f.type, upsert: false }); log("Storage upload done ⏱️", { ms: Math.round(performance.now() - tUp), }); if (error) { errlog("Storage upload failed", { message: error.message, file: f.name, bucket, path, }); return json({ ok: false, error: `Storage upload failed: ${error.message}`, file: f.name, cid, }, 500); } // Add as an input_file base64 reference to the last user message content const tB64 = performance.now(); const fileBase64 = btoa(String.fromCharCode(...bytes)); // ⚠️ potencialmente pesado log("Base64 built ⏱️", { ms: Math.round(performance.now() - tB64), base64Chars: fileBase64.length, }); 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}`, }); log("Appended input_file to last user message 🧷", { lastRole: lastMsg.role, partsCount: lastMsg.content.length, filename: f.name, // no imprimimos file_data }); } else { warn( "Could not append input_file: last message is not user/content array", { lastRole: lastMsg?.role ?? null, lastContentType: typeof lastMsg?.content, }, ); } } else { warn( "responseCfg.input not array or empty; cannot append file_data", summarizeInputShape(responseCfg.input), ); } uploadedToStorage.push({ bucket, path: data.path, name: f.name, type: f.type, size: f.size, }); } } else { log("No temp files to upload 🧼"); } // 7) Llamada a OpenAI Responses API 🚀 log("Step 7: OpenAI call 🚀"); const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); log("Env presence (OpenAI) 🔑", { hasOPENAI_API_KEY: Boolean(OPENAI_API_KEY), }); if (!OPENAI_API_KEY) { errlog("Missing OPENAI_API_KEY"); return json({ ok: false, error: "Missing OPENAI_API_KEY", cid }, 500); } // snapshot del request hacia OpenAI (sin secretos y sin megadata) log("OpenAI request snapshot (sanitized) 🧪", { model: responseCfg.model, input: summarizeInputShape(responseCfg.input), tools: toolsSummary(responseCfg.tools), include: Array.isArray(responseCfg.include) ? responseCfg.include : null, hasTextFormat: Boolean(responseCfg.text?.format), textFormat: summarizeFormat(responseCfg.text?.format), hasConversation: Boolean(responseCfg.conversation), hasPreviousResponseId: Boolean(responseCfg.previous_response_id), }); const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); let resp: any = null; const tOpenAI = performance.now(); try { resp = await openai.responses.create(responseCfg); } catch (error) { errlog("OpenAI API error", { message: (error as Error)?.message ?? String(error), stack: (error as Error)?.stack ?? null, }); return json({ ok: false, error: "OpenAI API error", details: (error as Error)?.message ?? String(error), cid, }, 500); } log("OpenAI call done ⏱️", { ms: Math.round(performance.now() - tOpenAI) }); const outputText = extractOutputText(resp); log("OpenAI response basics ✅", { responseId: resp?.id ?? null, model: resp?.model ?? responseCfg.model, hasConversationObj: Boolean(resp?.conversation), usage: resp?.usage ?? null, outputTextChars: outputText?.length ?? 0, }); let output: unknown = null; let outputParseError: string | null = null; const tParseOut = performance.now(); try { output = JSON.parse(outputText); log("Output JSON parsed ✅", { ms: Math.round(performance.now() - tParseOut), }); } catch (e) { outputParseError = (e as Error)?.message ?? String(e); warn("Output JSON parse FAILED 🧯", { error: outputParseError, outputTextPreview: (outputText ?? "").slice(0, 600), }); } const conv = resp?.conversation?.id ?? (typeof resp?.conversation === "string" ? resp.conversation : responseCfg.conversation ?? null); log("RETURN ✅", { ok: !outputParseError, conversationId: conv, responseId: resp?.id ?? null, totalMs: Math.round(performance.now() - t0), }); return json({ ok: outputParseError ? false : true, cid, // 👈 útil para correlacionar logs responseId: resp.id, conversationId: conv, model: resp.model ?? responseCfg.model, usage: resp.usage ?? null, outputText, output, outputParseError, references: { uploadedToStorage, openaiFileIds, vectorStoreIds, archivosReferenciaIds: references.archivosReferenciaIds ?? payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [], }, tools: sanitizeTools(responseCfg.tools), }); } catch (e) { errlog("Unhandled error 💥", { message: (e as Error)?.message ?? String(e), stack: (e as Error)?.stack ?? null, totalMs: Math.round(performance.now() - t0), }); return json( { ok: false, error: (e as Error)?.message ?? String(e), cid }, 500, ); } });