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