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",