Función de ai-structured simplificada + tipado de la entrada
This commit is contained in:
@@ -9,3 +9,8 @@ entrypoint = "./functions/ai-generate-plan/index.ts"
|
|||||||
# Specifies static files to be bundled with the function. Supports glob patterns.
|
# Specifies static files to be bundled with the function. Supports glob patterns.
|
||||||
# For example, if you want to serve static HTML pages in your function:
|
# For example, if you want to serve static HTML pages in your function:
|
||||||
# static_files = [ "./functions/ai-generate-plan/*.html" ]
|
# static_files = [ "./functions/ai-generate-plan/*.html" ]
|
||||||
|
|
||||||
|
[functions.ai-structured]
|
||||||
|
enabled = true
|
||||||
|
verify_jwt = true
|
||||||
|
import_map = "./functions/ai-structured/deno.json"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createClient } from "npm:@supabase/supabase-js@2";
|
|||||||
import type { AIGeneratePlanInput } from "./types.ts";
|
import type { AIGeneratePlanInput } from "./types.ts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { systemPrompt } from "./prompts.ts";
|
import { systemPrompt } from "./prompts.ts";
|
||||||
|
import { strict } from "node:assert";
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
@@ -136,24 +137,25 @@ Deno.serve(async (req) => {
|
|||||||
- Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque}
|
- Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque}
|
||||||
- Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`;
|
- Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`;
|
||||||
const aiStructuredPayload = {
|
const aiStructuredPayload = {
|
||||||
response: {
|
model: "gpt-5-nano",
|
||||||
input: [
|
input: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
],
|
],
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
name: `${estructuraPlan?.nombre}`,
|
||||||
|
schema: estructuraPlan?.definicion,
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
structured: estructuraPlan.definicion,
|
|
||||||
references: {
|
|
||||||
openaiFileIds: payload.iaConfig.archivosReferencia,
|
|
||||||
vectorStoreIds: payload.iaConfig.repositoriosIds,
|
|
||||||
},
|
|
||||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const aiStructuredFormData = new FormData();
|
const aiStructuredFormData = new FormData();
|
||||||
aiStructuredFormData.append("payload", JSON.stringify(aiStructuredPayload));
|
aiStructuredFormData.append("options", JSON.stringify(aiStructuredPayload));
|
||||||
for (const file of payload.archivosAdjuntos ?? []) {
|
for (const file of payload.archivosAdjuntos ?? []) {
|
||||||
aiStructuredFormData.append("archivos", file);
|
aiStructuredFormData.append("files", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: aiJson, error } = await supabaseService.functions.invoke(
|
const { data: aiJson, error } = await supabaseService.functions.invoke(
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"openai": "npm:openai"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,852 +1,161 @@
|
|||||||
// supabase/functions/ai-structured/index.ts
|
// supabase/functions/ai-structured/index.ts
|
||||||
/// <reference lib="deno.window" />
|
/// <reference lib="deno.window" />
|
||||||
import OpenAI from "npm:openai";
|
import OpenAI from "openai";
|
||||||
import type * as OpenAITypes from "npm:openai/types/types.js";
|
import type * as OpenAITypes from "openai";
|
||||||
|
|
||||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
function json(body: unknown, status = 200) {
|
|
||||||
return new Response(JSON.stringify(body, null, 2), {
|
|
||||||
status,
|
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<string, string> = {};
|
|
||||||
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) => {
|
Deno.serve(async (req) => {
|
||||||
const cid = crypto.randomUUID();
|
if (req.method === "OPTIONS") return json({ ok: true });
|
||||||
const t0 = performance.now();
|
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||||
|
|
||||||
const log = (msg: string, extra?: unknown) => {
|
console.log("Received request");
|
||||||
if (extra !== undefined) {
|
|
||||||
console.log(
|
|
||||||
`[${nowIso()}][ai-structured][${cid}] ${msg}`,
|
|
||||||
safePreview(extra),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const warn = (msg: string, extra?: unknown) => {
|
|
||||||
if (extra !== undefined) {
|
|
||||||
console.warn(
|
|
||||||
`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`,
|
|
||||||
safePreview(extra),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const errlog = (msg: string, extra?: unknown) => {
|
|
||||||
if (extra !== undefined) {
|
|
||||||
console.error(
|
|
||||||
`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`,
|
|
||||||
safePreview(extra),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
log("OPTIONS preflight ✅", {
|
|
||||||
method: req.method,
|
|
||||||
url: req.url,
|
|
||||||
headers: headersSnapshot(req),
|
|
||||||
});
|
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
log("START 🚀", { method: req.method, url: req.url });
|
|
||||||
log("Headers snapshot 🧾", headersSnapshot(req));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) Auth requerido ✅
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
||||||
log("Step 1: Auth check 🔐");
|
const SUPABASE_SERVICE_ROLE_KEY =
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||||
const authHeaderRaw = req.headers.get("Authorization") ??
|
const SUPABASE_BUCKET = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||||
req.headers.get("authorization");
|
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||||
log("Authorization header present?", {
|
|
||||||
present: Boolean(authHeaderRaw),
|
|
||||||
value: redactAuthHeader(authHeaderRaw),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!authHeaderRaw) {
|
|
||||||
warn("Missing Authorization header");
|
|
||||||
return json(
|
|
||||||
{ ok: false, error: "Missing Authorization header", cid },
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
|
||||||
log("Env presence (Supabase) 🌍", {
|
|
||||||
hasSUPABASE_URL: Boolean(SUPABASE_URL),
|
|
||||||
hasSUPABASE_ANON_KEY: Boolean(SUPABASE_ANON_KEY),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !OPENAI_API_KEY) {
|
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !OPENAI_API_KEY) {
|
||||||
return json({ error: "Missing env vars" }, 500);
|
return json({ error: "Missing env vars" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
console.log("Env vars loaded");
|
||||||
global: { headers: { Authorization: authHeaderRaw } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const tAuth = performance.now();
|
type InputForm = {
|
||||||
const { data: userData, error: userErr } = await supabaseUser.auth
|
options: OpenAITypes.OpenAI.Responses.ResponseCreateParams;
|
||||||
.getUser();
|
files: File[];
|
||||||
log("Supabase auth.getUser() done ⏱️", {
|
};
|
||||||
ms: Math.round(performance.now() - tAuth),
|
const fd = await req.formData();
|
||||||
});
|
|
||||||
|
|
||||||
if (userErr || !userData?.user) {
|
// Asume siempre vienen: options, files[]
|
||||||
warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null });
|
const optionsRaw = fd.get("options");
|
||||||
return json({ ok: false, error: "Invalid token", cid }, 401);
|
|
||||||
|
const inputForm: InputForm = {
|
||||||
|
options: typeof optionsRaw === "string"
|
||||||
|
? JSON.parse(optionsRaw)
|
||||||
|
: optionsRaw,
|
||||||
|
|
||||||
|
files: fd.getAll("files").filter((x): x is File => x instanceof File),
|
||||||
|
};
|
||||||
|
console.log(`Parsed form data: ${inputForm.files.length} files`);
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||||
|
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
||||||
|
|
||||||
|
console.log("Parsed options");
|
||||||
|
|
||||||
|
// 1) Upload a Supabase Storage
|
||||||
|
const supabase_paths: string[] = [];
|
||||||
|
for (const file of inputForm.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 */
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw new Error(`Supabase upload failed: ${error.message}`);
|
||||||
|
supabase_paths.push(data.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userData.user;
|
console.log("Uploaded files to Supabase Storage");
|
||||||
log("Authenticated user ✅", {
|
|
||||||
userId: user.id,
|
|
||||||
email: user.email ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
// 2) Upload a OpenAI Files
|
||||||
log("Env presence (service role) 🗝️", {
|
const openai_file_ids: string[] = [];
|
||||||
hasSERVICE_ROLE: Boolean(SERVICE_ROLE),
|
for (const file of inputForm.files ?? []) {
|
||||||
});
|
const created = await openai.files.create({
|
||||||
|
file,
|
||||||
const supabaseAdmin = SERVICE_ROLE
|
purpose: "user_data",
|
||||||
? createClient(SUPABASE_URL, SERVICE_ROLE)
|
|
||||||
: supabaseUser;
|
|
||||||
|
|
||||||
log("Supabase client selected 🧩", { adminMode: Boolean(SERVICE_ROLE) });
|
|
||||||
|
|
||||||
// 2) Parse body (JSON o multipart) 📦
|
|
||||||
log("Step 2: Parse body 📦");
|
|
||||||
const contentType = req.headers.get("content-type") ?? "";
|
|
||||||
log("Content-Type", { contentType });
|
|
||||||
|
|
||||||
let payload: any = {};
|
|
||||||
let tempFiles: File[] = [];
|
|
||||||
|
|
||||||
const tParse = performance.now();
|
|
||||||
if (contentType.includes("multipart/form-data")) {
|
|
||||||
log("Parsing multipart/form-data 🧷");
|
|
||||||
let form;
|
|
||||||
try {
|
|
||||||
form = await req.formData();
|
|
||||||
} catch (error) {
|
|
||||||
errlog("Invalid multipart/form-data body", {
|
|
||||||
message: (error as Error)?.message ?? String(error),
|
|
||||||
stack: (error as Error)?.stack ?? null,
|
|
||||||
});
|
|
||||||
return json({
|
|
||||||
ok: false,
|
|
||||||
error: "Invalid multipart/form-data body",
|
|
||||||
details: (error as Error)?.message ?? String(error),
|
|
||||||
cid,
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// keys overview
|
|
||||||
const keys = Array.from(form.keys());
|
|
||||||
log("Multipart keys", { keys, keysCount: keys.length });
|
|
||||||
|
|
||||||
const raw = form.get("payload") ?? "{}";
|
|
||||||
log("Multipart payload field type", { type: typeof raw });
|
|
||||||
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(String(raw));
|
|
||||||
} catch (e) {
|
|
||||||
errlog("Multipart `payload` is not valid JSON", {
|
|
||||||
rawPreview: String(raw).slice(0, 400),
|
|
||||||
error: (e as Error)?.message ?? String(e),
|
|
||||||
});
|
|
||||||
return json({
|
|
||||||
ok: false,
|
|
||||||
error: "Invalid JSON in multipart `payload` field",
|
|
||||||
cid,
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (
|
|
||||||
const key of ["files", "files[]", "archivos", "archivosTemporales"]
|
|
||||||
) {
|
|
||||||
const vals = form.getAll(key);
|
|
||||||
if (vals?.length) {
|
|
||||||
log(`Found multipart field "${key}"`, { count: vals.length });
|
|
||||||
}
|
|
||||||
for (const v of vals) {
|
|
||||||
if (v instanceof File) tempFiles.push(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempFiles.length === 0) {
|
|
||||||
for (const [k, v] of form.entries()) {
|
|
||||||
if (v instanceof File) tempFiles.push(v);
|
|
||||||
// opcional: log campos no-file si necesitas
|
|
||||||
// else log("Form entry", { key: k, type: typeof v });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log("Multipart files detected 📎", {
|
|
||||||
count: tempFiles.length,
|
|
||||||
files: tempFiles.map((f) => ({
|
|
||||||
name: f.name,
|
|
||||||
type: f.type,
|
|
||||||
size: f.size,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
} else {
|
openai_file_ids.push(created.id);
|
||||||
log("Parsing JSON body 🧾");
|
|
||||||
try {
|
|
||||||
payload = await req.json();
|
|
||||||
} catch (e) {
|
|
||||||
errlog("Invalid JSON body", {
|
|
||||||
error: (e as Error)?.message ?? String(e),
|
|
||||||
});
|
|
||||||
return json({ ok: false, error: "Invalid JSON body", cid }, 400);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
log("Body parsed ⏱️", { ms: Math.round(performance.now() - tParse) });
|
|
||||||
log("Payload top-level keys", {
|
console.log("Uploaded files to OpenAI Files");
|
||||||
keys: Object.keys(payload ?? {}),
|
|
||||||
hasOpenAI: Boolean(payload?.openai),
|
// 3) Inject file_ids into options.input by appending a new user message
|
||||||
hasResponse: Boolean(payload?.response),
|
const fileParts: OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
|
||||||
|
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(inputForm.options?.input)
|
||||||
|
? inputForm.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) 🧠
|
inputForm.options.input = inputArr;
|
||||||
log("Step 3: Build responseCfg 🧠");
|
|
||||||
const responseCfg: any = {
|
console.log("Prepared OpenAI options with file references");
|
||||||
...(payload.openai ?? {}),
|
|
||||||
...(payload.response ?? {}),
|
// 4) Call Responses API
|
||||||
|
const openai_raw = await openai.responses.create(inputForm.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) {
|
const response: SuccessResponse = {
|
||||||
responseCfg.input = payload.input;
|
ok: true,
|
||||||
log("responseCfg.input set from payload.input");
|
openai_raw, // cruda
|
||||||
}
|
supabase_paths, // rutas en el bucket
|
||||||
|
openai_file_ids, // IDs en OpenAI
|
||||||
|
options: inputForm.options, // opciones usadas
|
||||||
|
};
|
||||||
|
|
||||||
if (responseCfg.input !== undefined) {
|
return json(response);
|
||||||
responseCfg.input = normalizeInputUser(responseCfg.input);
|
} catch (e) {
|
||||||
log(
|
if (e instanceof Error) {
|
||||||
"responseCfg.input normalized ✅",
|
console.error("Error occurred:", e.message);
|
||||||
summarizeInputShape(responseCfg.input),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
warn("responseCfg.input is undefined (is that expected?)");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.model && !responseCfg.model) responseCfg.model = payload.model;
|
|
||||||
if (payload.conversationId && responseCfg.conversation === undefined) {
|
|
||||||
responseCfg.conversation = payload.conversationId;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
payload.previousResponseId &&
|
|
||||||
responseCfg.previous_response_id === undefined
|
|
||||||
) responseCfg.previous_response_id = payload.previousResponseId;
|
|
||||||
if (payload.reasoning && responseCfg.reasoning === undefined) {
|
|
||||||
responseCfg.reasoning = payload.reasoning;
|
|
||||||
}
|
|
||||||
|
|
||||||
responseCfg.model = responseCfg.model ?? "gpt-5";
|
|
||||||
log("OpenAI config summary 🤖", {
|
|
||||||
model: responseCfg.model,
|
|
||||||
hasConversation: Boolean(responseCfg.conversation),
|
|
||||||
hasPreviousResponseId: Boolean(responseCfg.previous_response_id),
|
|
||||||
hasReasoning: Boolean(responseCfg.reasoning),
|
|
||||||
include: Array.isArray(responseCfg.include) ? responseCfg.include : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (responseCfg.conversation && responseCfg.previous_response_id) {
|
|
||||||
warn("Invalid: conversation + previous_response_id together");
|
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "Do not send both `conversation` and `previous_response_id`.",
|
|
||||||
cid,
|
error: e?.message ?? String(e),
|
||||||
|
// si fue Zod, normalmente trae detalles en `issues`
|
||||||
|
issues: (e as any)?.issues,
|
||||||
},
|
},
|
||||||
400,
|
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 {
|
} else {
|
||||||
log("No temp files to upload 🧼");
|
console.error("Unknown error occurred:", e);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "An unknown error occurred",
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) Llamada a OpenAI Responses API 🚀
|
|
||||||
log("Step 7: OpenAI call 🚀");
|
|
||||||
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
|
|
||||||
log("Env presence (OpenAI) 🔑", {
|
|
||||||
hasOPENAI_API_KEY: Boolean(OPENAI_API_KEY),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!OPENAI_API_KEY) {
|
|
||||||
errlog("Missing OPENAI_API_KEY");
|
|
||||||
return json({ ok: false, error: "Missing OPENAI_API_KEY", cid }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshot del request hacia OpenAI (sin secretos y sin megadata)
|
|
||||||
log("OpenAI request snapshot (sanitized) 🧪", {
|
|
||||||
model: responseCfg.model,
|
|
||||||
input: summarizeInputShape(responseCfg.input),
|
|
||||||
tools: toolsSummary(responseCfg.tools),
|
|
||||||
include: Array.isArray(responseCfg.include) ? responseCfg.include : null,
|
|
||||||
hasTextFormat: Boolean(responseCfg.text?.format),
|
|
||||||
textFormat: summarizeFormat(responseCfg.text?.format),
|
|
||||||
hasConversation: Boolean(responseCfg.conversation),
|
|
||||||
hasPreviousResponseId: Boolean(responseCfg.previous_response_id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
|
||||||
let resp: any = null;
|
|
||||||
|
|
||||||
const tOpenAI = performance.now();
|
|
||||||
try {
|
|
||||||
resp = await openai.responses.create(responseCfg);
|
|
||||||
} catch (error) {
|
|
||||||
errlog("OpenAI API error", {
|
|
||||||
message: (error as Error)?.message ?? String(error),
|
|
||||||
stack: (error as Error)?.stack ?? null,
|
|
||||||
});
|
|
||||||
return json({
|
|
||||||
ok: false,
|
|
||||||
error: "OpenAI API error",
|
|
||||||
details: (error as Error)?.message ?? String(error),
|
|
||||||
cid,
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
log("OpenAI call done ⏱️", { ms: Math.round(performance.now() - tOpenAI) });
|
|
||||||
|
|
||||||
const outputText = extractOutputText(resp);
|
|
||||||
log("OpenAI response basics ✅", {
|
|
||||||
responseId: resp?.id ?? null,
|
|
||||||
model: resp?.model ?? responseCfg.model,
|
|
||||||
hasConversationObj: Boolean(resp?.conversation),
|
|
||||||
usage: resp?.usage ?? null,
|
|
||||||
outputTextChars: outputText?.length ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
let output: unknown = null;
|
|
||||||
let outputParseError: string | null = null;
|
|
||||||
const tParseOut = performance.now();
|
|
||||||
try {
|
|
||||||
output = JSON.parse(outputText);
|
|
||||||
log("Output JSON parsed ✅", {
|
|
||||||
ms: Math.round(performance.now() - tParseOut),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
outputParseError = (e as Error)?.message ?? String(e);
|
|
||||||
warn("Output JSON parse FAILED 🧯", {
|
|
||||||
error: outputParseError,
|
|
||||||
outputTextPreview: (outputText ?? "").slice(0, 600),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const conv = resp?.conversation?.id ??
|
|
||||||
(typeof resp?.conversation === "string"
|
|
||||||
? resp.conversation
|
|
||||||
: responseCfg.conversation ?? null);
|
|
||||||
|
|
||||||
log("RETURN ✅", {
|
|
||||||
ok: !outputParseError,
|
|
||||||
conversationId: conv,
|
|
||||||
responseId: resp?.id ?? null,
|
|
||||||
totalMs: Math.round(performance.now() - t0),
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({
|
|
||||||
ok: outputParseError ? false : true,
|
|
||||||
cid, // 👈 útil para correlacionar logs
|
|
||||||
responseId: resp.id,
|
|
||||||
conversationId: conv,
|
|
||||||
model: resp.model ?? responseCfg.model,
|
|
||||||
usage: resp.usage ?? null,
|
|
||||||
|
|
||||||
outputText,
|
|
||||||
output,
|
|
||||||
outputParseError,
|
|
||||||
|
|
||||||
references: {
|
|
||||||
uploadedToStorage,
|
|
||||||
openaiFileIds,
|
|
||||||
vectorStoreIds,
|
|
||||||
archivosReferenciaIds: references.archivosReferenciaIds ??
|
|
||||||
payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [],
|
|
||||||
},
|
|
||||||
|
|
||||||
tools: sanitizeTools(responseCfg.tools),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
errlog("Unhandled error 💥", {
|
|
||||||
message: (e as Error)?.message ?? String(e),
|
|
||||||
stack: (e as Error)?.stack ?? null,
|
|
||||||
totalMs: Math.round(performance.now() - t0),
|
|
||||||
});
|
|
||||||
return json(
|
|
||||||
{ ok: false, error: (e as Error)?.message ?? String(e), cid },
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user