diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..919df1a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "configurations": [ + { + "name": "Attach V8 Inspector", + "type": "node", + "address": "127.0.0.1", + "request": "attach", + + // This is important: You must enter the path where your edge functions + // folders are located. + "localRoot": "${workspaceFolder}/supabase/functions/", + + // CLI mounts all functions under /home/deno/functions + "remoteRoot": "/home/deno/functions/", + + // As you can see from the two options above, VsCode tries to locate the script + // paths being used by the inspector session locally by matching local and remote + // paths. + // + // If a valid path cannot be found on either side, or the script contents of + // those paths do not match each other, the breakpoint setting may not proceed + // correctly. + // + // We set the two options below for this. + "sourceMaps": true, + "enableContentValidation": false, + + // Properties below are optional. + "restart": true, + "timeout": 1000000, + + // Inspector is accepting sessions on 8083 by default. + "port": 8083 + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/bun.lock b/bun.lock deleted file mode 100644 index d05e4ba..0000000 --- a/bun.lock +++ /dev/null @@ -1,103 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "dependencies": { - "@supabase/supabase-js": "^2.90.1", - "supabase": "^2.72.6", - "supabase-js": "^1.0.4", - }, - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", - }, - }, - }, - "packages": { - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - - "@supabase/auth-js": ["@supabase/auth-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng=="], - - "@supabase/functions-js": ["@supabase/functions-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw=="], - - "@supabase/postgrest-js": ["@supabase/postgrest-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ=="], - - "@supabase/realtime-js": ["@supabase/realtime-js@2.90.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w=="], - - "@supabase/storage-js": ["@supabase/storage-js@2.90.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg=="], - - "@supabase/supabase-js": ["@supabase/supabase-js@2.90.1", "", { "dependencies": { "@supabase/auth-js": "2.90.1", "@supabase/functions-js": "2.90.1", "@supabase/postgrest-js": "2.90.1", "@supabase/realtime-js": "2.90.1", "@supabase/storage-js": "2.90.1" } }, "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w=="], - - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], - - "@types/node": ["@types/node@25.0.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q=="], - - "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="], - - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "bin-links": ["bin-links@6.0.0", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w=="], - - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], - - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - - "cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="], - - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - - "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - - "npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="], - - "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], - - "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "supabase": ["supabase@2.72.6", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.2" }, "bin": { "supabase": "bin/supabase" } }, "sha512-axO1MkZgwMIxj78vKiGosnuXwzOaQq0Kz0I8jheNMwJH9dKGf3q/arivYT44z8u3t5FjGUxQWOuQoJetzcmm/A=="], - - "supabase-js": ["supabase-js@1.0.4", "", {}, "sha512-b9Pxxl3ACEUhY/qTyF7WG9yUQTQQ/5Qm/0KnqbBOQdMaKi13glxs0SzqB1LSJnk2mZvufm6FF8Pyv5ZMc5YVqw=="], - - "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - - "write-file-atomic": ["write-file-atomic@7.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg=="], - - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - } -} diff --git a/files/carta.pdf b/files/carta.pdf new file mode 100644 index 0000000..0f0af37 Binary files /dev/null and b/files/carta.pdf differ diff --git a/package.json b/package.json index 69f701b..ded9479 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@supabase/supabase-js": "^2.90.1", + "deno": "^2.6.4", "supabase": "^2.72.6", "supabase-js": "^1.0.4" }, @@ -9,7 +10,7 @@ "type": "module", "private": true, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "^1.3.5" }, "peerDependencies": { "typescript": "^5" diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts index bcdfe8d..3754010 100644 --- a/supabase/functions/ai-structured/index.ts +++ b/supabase/functions/ai-structured/index.ts @@ -1,23 +1,441 @@ -import OpenAI from 'jsr:@openai/openai'; +// 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) => { - const apiKey = Deno.env.get('OPENAI_API_KEY') - const openai = new OpenAI({ - apiKey: apiKey, - }) + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } - // Documentation here: https://github.com/openai/openai-node - const response = await openai.responses.create({ - model: 'gpt-5-nano', - instructions: 'You are a coding assistant that talks like a pirate', - input: 'Are semicolons optional in JavaScript?', - }); - const reply = response.output_text + 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); + } - return new Response(reply, { - headers: { 'Content-Type': 'text/plain' }, - }) -}) \ No newline at end of file + // 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); + } +}); diff --git a/supabase/functions/tests/ai-structured-test.ts b/supabase/functions/tests/ai-structured-test.ts new file mode 100644 index 0000000..98da2d5 --- /dev/null +++ b/supabase/functions/tests/ai-structured-test.ts @@ -0,0 +1,146 @@ +// supabase/functions/ai-structured/ai_structured.test.ts +/// +/// +/// + +import { assert, assertEquals } from "jsr:@std/assert@1"; +import "jsr:@std/dotenv/load"; +import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; // (o PUBLISHABLE_KEY si así lo nombraste) +const EMAIL = Deno.env.get("TEST_EMAIL") ?? "guillermo.arrieta@lasalle.mx"; +const PASSWORD = Deno.env.get("TEST_PASSWORD") ?? "admin"; + +const options = { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, +}; + +function mustEnv() { + if (!SUPABASE_URL) throw new Error("SUPABASE_URL is required"); + if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required"); +} + +async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken: string }> { + mustEnv(); + const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, options); + + const { data, error } = await client.auth.signInWithPassword({ + email: EMAIL, + password: PASSWORD, + }); + 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"); + + return { client, accessToken }; +} + +Deno.test("ai-structured (JSON body)", async () => { + const { client, accessToken } = await getAuthedClient(); + + 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, + }, + }); + + 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"); + } +}); + +Deno.test("ai-structured (multipart + file)", async () => { + const { client, accessToken } = await getAuthedClient(); + + // Lee un PDF local (ajusta path) + const bytes = await Deno.readFile("files/carta.pdf"); + const file = new File([bytes], "carta.pdf", { type: "application/pdf" }); + + const payload = { + response: { input: "Resume estos documentos en JSON.", model: "gpt-5" }, + structured: { + type: "json_schema", + name: "resumen", + strict: true, + schema: { + type: "object", + properties: { resumen: { type: "string" } }, + required: ["resumen"], + additionalProperties: false, + }, + }, + storage: { prefix: "tmp" }, + }; + + const fd = new FormData(); + fd.append("payload", JSON.stringify(payload)); + fd.append("files", file); + + const { data, error } = await client.functions.invoke("ai-structured", { + headers: { + Authorization: `Bearer ${accessToken}`, // 👈 necesario + // NO pongas Content-Type: fetch lo setea con boundary para FormData ✅ + }, + method: "POST", + body: fd, + }); + + if (error) throw new Error("Invoke failed: " + error.message); + assert(data, "Expected data from function"); + + assertEquals(data.ok, true); + assert(typeof data.output?.resumen === "string"); + assert(data.output.resumen.length > 0); + + // opcional: valida que registró upload(s) a storage + assert(Array.isArray(data.references?.uploadedToStorage)); +}); diff --git a/testers/ai-structured.ts b/testers/ai-structured.ts deleted file mode 100644 index 9f630c4..0000000 --- a/testers/ai-structured.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { supabase } from "./new-user"; - -const /* { data, error } */_ = await supabase.auth.signInWithPassword({ - email: "guillermo.arrieta@lasalle.mx", - password: "admin", -}); - -/* if (error) { - console.error("Error signing in:", error); - process.exit(1); -} else { - console.log("Successfully signed in:", data); -} */ - -try { - - const response = await supabase.functions.invoke("ai-structured", { - body: { - query: "Escribe un poema sobre la primavera en español", - }, - }); - console.log({ d: response.data, e: response.error }); -} catch (e: any) { - const res = e?.context ?? e?.response; - console.log("status", res?.status); - if (res?.text) console.log("text", await res.text()); - else console.log("raw error keys", Object.keys(e ?? {})); -} diff --git a/testers/new-user.ts b/testers/new-user.ts deleted file mode 100644 index 6909a24..0000000 --- a/testers/new-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createClient } from '@supabase/supabase-js' - -export const supabase = createClient(Bun.env.SUPABASE_URL!, Bun.env.SUPABASE_ANON_KEY!) -/* try { - const { data, error } = await supabase.auth.signUp({ - email: 'guillermo.arrieta@lasalle.mx', - password: 'sadfasdfsadfdsasadfsad', - }) - console.log({ data, error }) -} catch (error) { - console.log({ error }) -} */ \ No newline at end of file