diff --git a/deno.json b/deno.json index 01e30c1..aedfed2 100644 --- a/deno.json +++ b/deno.json @@ -1,3 +1,7 @@ { - "allowScripts": ["npm:deno@2.6.4", "npm:supabase-js@1.0.4", "npm:supabase@2.72.6"] + "allowScripts": ["npm:deno@2.6.4", "npm:supabase-js@1.0.4", "npm:supabase@2.72.6"], + "imports": { + "@openai/openai": "jsr:@openai/openai@^6.16.0", + "@zod/zod": "jsr:@zod/zod@^4.3.5" + } } diff --git a/deno.lock b/deno.lock index 7570fe8..8af7c40 100644 --- a/deno.lock +++ b/deno.lock @@ -2,21 +2,30 @@ "version": "5", "specifiers": { "jsr:@openai/openai@*": "6.9.0", + "jsr:@openai/openai@^6.16.0": "6.16.0", "jsr:@std/assert@1": "1.0.16", "jsr:@std/dotenv@*": "0.225.6", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@supabase/functions-js@*": "2.90.1", + "jsr:@zod/zod@^4.3.5": "4.3.5", "npm:@supabase/supabase-js@^2.90.1": "2.90.1", "npm:@types/bun@^1.3.5": "1.3.5", "npm:deno@^2.6.4": "2.6.4", "npm:openai@^6.16.0": "6.16.0", "npm:supabase-js@^1.0.4": "1.0.4", - "npm:supabase@^2.72.6": "2.72.6" + "npm:supabase@^2.72.6": "2.72.6", + "npm:zod@3": "3.25.76" }, "jsr": { "@openai/openai@6.9.0": { "integrity": "d1828101f5a782a75c25805c4e06c1316d9c19f422d84755fb908046184c3ef6" }, + "@openai/openai@6.16.0": { + "integrity": "ccee548f61c382d715091fff0c2c3390a4487823644430b8235df543d6d6a78b", + "dependencies": [ + "npm:zod" + ] + }, "@std/assert@1.0.16": { "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", "dependencies": [ @@ -31,6 +40,9 @@ }, "@supabase/functions-js@2.90.1": { "integrity": "9a077ecf42aa84594ef3aac621d643e15ffaa9506a4c051e35c93f127ffdb035" + }, + "@zod/zod@4.3.5": { + "integrity": "3d14553f025d6e3d1a836b9c56e366bbce27d942301e8c7c1ac4c365df5f8085" } }, "npm": { @@ -294,9 +306,16 @@ }, "yallist@5.0.0": { "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" + }, + "zod@3.25.76": { + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } }, "workspace": { + "dependencies": [ + "jsr:@openai/openai@^6.16.0", + "jsr:@zod/zod@^4.3.5" + ], "packageJson": { "dependencies": [ "npm:@supabase/supabase-js@^2.90.1", diff --git a/files/sample.pdf b/files/sample.pdf new file mode 100644 index 0000000..8efd05c Binary files /dev/null and b/files/sample.pdf differ diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts index 15c449f..19cfafc 100644 --- a/supabase/functions/ai-structured/index.ts +++ b/supabase/functions/ai-structured/index.ts @@ -1,872 +1,151 @@ // supabase/functions/ai-structured/index.ts /// import OpenAI from "npm:openai"; +import type * as OpenAITypes from "npm:openai/types/types.js"; + 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), { +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + }, }); -} - -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(); + if (req.method === "OPTIONS") return json({ ok: true }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - 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)); + console.log("Received request"); try { - // 1) Auth requerido ✅ - log("Step 1: Auth check 🔐"); + const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; + const SUPABASE_SERVICE_ROLE_KEY = + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + const SUPABASE_BUCKET = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage"; + const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? ""; - 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, - ); + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !OPENAI_API_KEY) { + return json({ error: "Missing env vars" }, 500); } - 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), - }); + console.log("Env vars loaded"); - 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); - } + type InputForm = { + options: Record & { + model: string; + input: Array<{ role: string; content: unknown }>; + }; + files: File[]; + }; + const fd = await req.formData(); - const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - global: { headers: { Authorization: authHeaderRaw } }, - }); + // Asume siempre vienen: options, files[] + const optionsRaw = fd.get("options"); - const tAuth = performance.now(); - const { data: userData, error: userErr } = await supabaseUser.auth - .getUser(); - log("Supabase auth.getUser() done ⏱️", { - ms: Math.round(performance.now() - tAuth), - }); + const input: InputForm = { + options: typeof optionsRaw === "string" + ? JSON.parse(optionsRaw) + : optionsRaw, - if (userErr || !userData?.user) { - warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null }); - return json({ ok: false, error: "Invalid token", cid }, 401); - } + files: fd.getAll("files[]").filter((x): x is File => x instanceof File), + }; + console.log(`Parsed form data: ${input.files.length} files`); - const user = userData.user; - log("Authenticated user ✅", { - userId: user.id, - email: user.email ?? null, - }); + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); - const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); - log("Env presence (service role) 🗝️", { - hasSERVICE_ROLE: Boolean(SERVICE_ROLE), - }); + console.log("Parsed options"); - 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, + // 1) Upload a Supabase Storage + const supabase_paths: string[] = []; + for (const file of input.files ?? []) { + const path = `${crypto.randomUUID()}-${file.name}`; + const { data, error } = await supabase.storage + .from(SUPABASE_BUCKET) + .upload(path, file, { + contentType: file.type || "application/octet-stream", + upsert: false, + /* metadata */ }); - 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 }); + if (error) throw new Error(`Supabase upload failed: ${error.message}`); + supabase_paths.push(data.path); + } - const raw = form.get("payload") ?? "{}"; - log("Multipart payload field type", { type: typeof raw }); + console.log("Uploaded files to Supabase Storage"); - 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, - })), + // 2) Upload a OpenAI Files + const openai_file_ids: string[] = []; + for (const file of input.files ?? []) { + const created = await openai.files.create({ + file, + purpose: "user_data", }); - } 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); - } + openai_file_ids.push(created.id); } - 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), + + console.log("Uploaded files to OpenAI Files"); + + // 3) Inject file_ids into options.input by appending a new user message + const fileParts = openai_file_ids.map((id) => ({ + type: "input_file", + file_id: id, + })); + + // Asume que SIEMPRE hay input; de todas formas, cae bien si no. + const inputArr = Array.isArray(input.options?.input as Array) + ? input.options.input + : []; + + inputArr.push({ + role: "user", + content: [ + ...fileParts, + { type: "input_text", text: "usa estos archivos como referencia" }, + ], }); - // 3) Construye config final para OpenAI (passthrough + overrides) 🧠 - log("Step 3: Build responseCfg 🧠"); - const responseCfg: any = { - ...(payload.openai ?? {}), - ...(payload.response ?? {}), + input.options.input = inputArr; + + console.log("Prepared OpenAI options with file references"); + + // 4) Call Responses API + const openai_raw = await openai.responses.create(input.options); + + console.log("Received response from OpenAI Responses API"); + + type SuccessResponse = { + ok: true; + openai_raw: unknown; + supabase_paths: string[]; + openai_file_ids: string[]; + options: unknown; }; - if (payload.input !== undefined && responseCfg.input === undefined) { - responseCfg.input = payload.input; - log("responseCfg.input set from payload.input"); - } + const response: SuccessResponse = { + ok: true, + openai_raw, // cruda + supabase_paths, // rutas en el bucket + openai_file_ids, // IDs en OpenAI + options: input.options, // opciones usadas + }; - 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-nano"; - 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), - }); - - const response = 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), - }); - - // Log final response - log("Response payload preview:", { - references: { - uploadedToStorage, - openaiFileIds, - vectorStoreIds, - archivosReferenciaIds: references.archivosReferenciaIds ?? - payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [], - }, - }); - - return response; + return json(response); } 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, + { + ok: false, + + error: e?.message ?? String(e), + // si fue Zod, normalmente trae detalles en `issues` + issues: (e as any)?.issues, + }, + 400, ); } }); diff --git a/supabase/functions/tests/ai-structured-test.ts b/supabase/functions/tests/ai-structured-test.ts index 98da2d5..423e9bf 100644 --- a/supabase/functions/tests/ai-structured-test.ts +++ b/supabase/functions/tests/ai-structured-test.ts @@ -25,7 +25,9 @@ function mustEnv() { if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required"); } -async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken: string }> { +async function getAuthedClient(): Promise< + { client: SupabaseClient; accessToken: string } +> { mustEnv(); const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, options); @@ -36,7 +38,9 @@ async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken: if (error) throw new Error("Sign-in failed: " + error.message); const accessToken = data.session?.access_token; - if (!accessToken) throw new Error("No access_token returned from signInWithPassword"); + if (!accessToken) { + throw new Error("No access_token returned from signInWithPassword"); + } return { client, accessToken }; } @@ -44,58 +48,45 @@ async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken: Deno.test("ai-structured (JSON body)", async () => { const { client, accessToken } = await getAuthedClient(); + const form = new FormData(); + form.append( + "options", + JSON.stringify({ + input: "Genera 3 ideas de negocio innovadoras en formato JSON.", + model: "gpt-5-nano", + text: { + format: { + "type": "json_schema", + "name": "business_ideas", + "schema": { + "type": "array", + "properties": { + "idea": { "type": "string", "description": "The business idea" }, + }, + "required": ["idea"], + "additionalProperties": false, + }, + }, + }, + }), + ); + const { data, error } = await client.functions.invoke("ai-structured", { headers: { Authorization: `Bearer ${accessToken}`, // 👈 clave para que tu función pase el authHeader check }, - body: { - response: { - model: "gpt-5", - input: [ - { role: "system", content: "Responde SIEMPRE en JSON válido." }, - { role: "user", content: "Dame 3 ideas de proyecto de IA para educación." }, - ], - }, - structured: { - type: "json_schema", - name: "ideas", - strict: true, - schema: { - type: "object", - properties: { - ideas: { - type: "array", - items: { - type: "object", - properties: { - titulo: { type: "string" }, - descripcion: { type: "string" }, - }, - required: ["titulo", "descripcion"], - additionalProperties: false, - }, - }, - }, - required: ["ideas"], - additionalProperties: false, - }, - }, - references: {}, - usarMCP: false, - }, + body: form, }); if (error) throw new Error("Invoke failed: " + error.message); assert(data, "Expected data from function"); - - // tu función responde { ok, output, outputText, ... } assertEquals(data.ok, true); - assert(Array.isArray(data.output?.ideas), "output.ideas should be an array"); - assertEquals(data.output.ideas.length, 3); - for (const it of data.output.ideas) { - assert(typeof it.titulo === "string"); - assert(typeof it.descripcion === "string"); - } + assert( + Array.isArray(data.openai_raw.output), + "Expected output to be an array", + ); + assert(data.openai_raw.output.length === 3, "Expected 3 business ideas"); + console.log(data); }); Deno.test("ai-structured (multipart + file)", async () => { @@ -106,7 +97,10 @@ Deno.test("ai-structured (multipart + file)", async () => { const file = new File([bytes], "carta.pdf", { type: "application/pdf" }); const payload = { - response: { input: "Resume estos documentos en JSON.", model: "gpt-5" }, + response: { + input: "Resume estos documentos en JSON.", + model: "gpt-5-nano", + }, structured: { type: "json_schema", name: "resumen",