revert 1af0358e65
Deploy Function / deploy (push) Failing after 43s

revert Merge pull request 'REFACTOR OpenAI service to manage env and convo creation' (#12) from issue/9-hacer-una-funcin-que-inserte-el-id-de-conversacin into main

Reviewed-on: AlexRG/genesis-2#12
This commit is contained in:
2026-02-05 20:41:28 +00:00
parent 132a51133b
commit b1a90858dc
2 changed files with 214 additions and 179 deletions
+190 -166
View File
@@ -6,197 +6,221 @@ import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2";
// Use non-streaming params to ensure `responses.create` returns a typed Response // Use non-streaming params to ensure `responses.create` returns a typed Response
export type StructuredResponseOptions = export type StructuredResponseOptions =
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming; OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
export type StructuredResponseSuccess<TOutput = unknown> = { export type StructuredResponseSuccess<TOutput = unknown> = {
ok: true; ok: true;
output?: TOutput; // parsed JSON when available output?: TOutput; // parsed JSON when available
outputText?: string; // raw text when parsing is not possible outputText?: string; // raw text when parsing is not possible
model: string; model: string;
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null; usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
responseId: string; responseId: string;
conversationId?: string | null; conversationId?: string | null;
references: { references: {
uploadedToStorage: string[]; // supabase storage paths uploadedToStorage: string[]; // supabase storage paths
openaiFileIds: string[]; // file ids in OpenAI openaiFileIds: string[]; // file ids in OpenAI
}; };
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
}; };
export type StructuredResponseFailure = { export type StructuredResponseFailure = {
ok: false; ok: false;
code: code:
| "MissingEnv" | "MissingEnv"
| "StorageUploadFailed" | "StorageUploadFailed"
| "OpenAIFileUploadFailed" | "OpenAIFileUploadFailed"
| "OpenAIRequestFailed"; | "OpenAIRequestFailed";
message: string; message: string;
cause?: unknown; cause?: unknown;
}; };
export type StructuredResponseResult<TOutput = unknown> = export type StructuredResponseResult<TOutput = unknown> =
| StructuredResponseSuccess<TOutput> | StructuredResponseSuccess<TOutput>
| StructuredResponseFailure; | StructuredResponseFailure;
export interface OpenAIServiceConfig { export interface OpenAIServiceConfig {
supabaseUrl: string; supabaseUrl: string;
serviceRoleKey: string; serviceRoleKey: string;
openAIApiKey: string; openAIApiKey: string;
bucket?: string; // default: ai-storage bucket?: string; // default: ai-storage
} }
export class OpenAIService { export class OpenAIService {
private readonly supabase: SupabaseClient; private readonly supabase: SupabaseClient;
private readonly openai: OpenAI; private readonly openai: OpenAI;
private readonly bucket: string; private readonly bucket: string;
private constructor(client: SupabaseClient, openai: OpenAI, bucket: string) { private constructor(
this.supabase = client; client: SupabaseClient,
this.openai = openai; openai: OpenAI,
this.bucket = bucket; bucket: string,
} ) {
this.supabase = client;
static fromEnv(): StructuredResponseFailure | OpenAIService { this.openai = openai;
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; this.bucket = bucket;
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
return {
ok: false,
code: "MissingEnv",
message:
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
};
} }
const client = createClient(supabaseUrl, serviceRoleKey); static fromEnv(): StructuredResponseFailure | OpenAIService {
const openai = new OpenAI({ apiKey: openAIApiKey }); const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
return new OpenAIService(client, openai, bucket); const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
} const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
async createConversation(metadata?: Record<string, string>) { if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
const conversation = await this.openai.conversations.create({ return {
metadata, ok: false,
}); code: "MissingEnv",
return conversation; message:
} "Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
};
}
async createStructuredResponse<TOutput = unknown>( const client = createClient(supabaseUrl, serviceRoleKey);
options: StructuredResponseOptions, const openai = new OpenAI({ apiKey: openAIApiKey });
files?: File[], return new OpenAIService(client, openai, bucket);
): Promise<StructuredResponseResult<TOutput>> { }
try {
const uploadedToStorage = await this.uploadFilesToStorage(files ?? []);
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
// Narrow to non-streaming response async createStructuredResponse<TOutput = unknown>(
const openaiRaw = (await this.openai.responses.create( options: StructuredResponseOptions,
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming, files?: File[],
)) as OpenAITypes.OpenAI.Responses.Response; ): Promise<StructuredResponseResult<TOutput>> {
const { model, id: responseId } = openaiRaw;
const usage = openaiRaw?.usage ?? null;
const conversationId =
(
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
conversation_id?: string | null;
}
).conversation_id ?? null;
// Try to read structured JSON output
let output: TOutput | undefined = undefined;
let outputText: string | undefined = undefined;
// Prefer `output_text` if present (SDK convenience)
const maybeOutputText = openaiRaw.output_text;
if (typeof maybeOutputText === "string" && maybeOutputText.length > 0) {
outputText = maybeOutputText;
try { try {
output = JSON.parse(maybeOutputText) as TOutput; const uploadedToStorage = await this.uploadFilesToStorage(
} catch { files ?? [],
/* non-JSON text, keep as text only */ );
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
const newOptions = { ...options };
// Attach file references to the request as an extra user message
if (openaiFileIds.length > 0) {
const fileParts:
OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
openaiFileIds.map((id) => ({
type: "input_file",
file_id: id,
}));
const arr = Array.isArray(options.input) ? options.input : [];
arr.push({
role: "user",
content: [
...fileParts,
{
type: "input_text",
text: "Usa estos archivos como referencia",
},
],
});
newOptions.input = arr;
}
// Narrow to non-streaming response
const openaiRaw = (await this.openai.responses.create(
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
)) as OpenAITypes.OpenAI.Responses.Response;
const { model, id: responseId } = openaiRaw;
const usage = openaiRaw?.usage ?? null;
const conversationId = (
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
conversation_id?: string | null;
}
).conversation_id ?? null;
// Try to read structured JSON output
let output: TOutput | undefined = undefined;
let outputText: string | undefined = undefined;
// Prefer `output_text` if present (SDK convenience)
const maybeOutputText = openaiRaw.output_text;
if (
typeof maybeOutputText === "string" &&
maybeOutputText.length > 0
) {
outputText = maybeOutputText;
try {
output = JSON.parse(maybeOutputText) as TOutput;
} catch { /* non-JSON text, keep as text only */ }
} else {
// Fallback: attempt to serialize `openaiRaw.output` into text
const maybeOutput = openaiRaw.output as unknown;
if (typeof maybeOutput === "object" && maybeOutput != null) {
try {
outputText = JSON.stringify(maybeOutput);
output = maybeOutput as TOutput;
} catch { /* ignore */ }
}
}
return {
ok: true,
output,
outputText,
model: String(model),
usage,
responseId: String(responseId),
conversationId: conversationId ? String(conversationId) : null,
references: { uploadedToStorage, openaiFileIds },
openaiRaw,
};
} catch (err) {
const e = err as Error;
const message = e?.message ?? "Unknown error";
const code = message.includes("Supabase upload failed")
? "StorageUploadFailed"
: message.includes("OpenAI file upload failed")
? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed";
return { ok: false, code, message, cause: err };
} }
} else { }
// Fallback: attempt to serialize `openaiRaw.output` into text
const maybeOutput = openaiRaw.output as unknown; private async uploadFilesToStorage(files: File[]): Promise<string[]> {
if (typeof maybeOutput === "object" && maybeOutput != null) { const paths: string[] = [];
try { for (const file of files) {
outputText = JSON.stringify(maybeOutput); const safeName = this.sanitizeFilename(file.name);
output = maybeOutput as TOutput; const path = `${crypto.randomUUID()}-${safeName}`;
} catch { const { data, error } = await this.supabase.storage
/* ignore */ .from(this.bucket)
} .upload(path, file, {
contentType: file.type || "application/octet-stream",
upsert: false,
});
if (error) {
throw new Error(`Supabase upload failed: ${error.message}`);
}
paths.push(data.path);
} }
} return paths;
return {
ok: true,
output,
outputText,
model: String(model),
usage,
responseId: String(responseId),
conversationId: conversationId ? String(conversationId) : null,
references: { uploadedToStorage, openaiFileIds },
openaiRaw,
};
} catch (err) {
const e = err as Error;
const message = e?.message ?? "Unknown error";
const code = message.includes("Supabase upload failed")
? "StorageUploadFailed"
: message.includes("OpenAI file upload failed")
? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed";
return { ok: false, code, message, cause: err };
} }
}
private async uploadFilesToStorage(files: File[]): Promise<string[]> { private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
const paths: string[] = []; const ids: string[] = [];
for (const file of files) { for (const file of files) {
const safeName = this.sanitizeFilename(file.name); try {
const path = `${crypto.randomUUID()}-${safeName}`; const created = await this.openai.files.create({
const { data, error } = await this.supabase.storage file,
.from(this.bucket) purpose: "user_data",
.upload(path, file, { });
contentType: file.type || "application/octet-stream", ids.push(created.id);
upsert: false, } catch (e) {
}); throw new Error(
`OpenAI file upload failed: ${
if (error) { (e as Error)?.message ?? String(e)
throw new Error(`Supabase upload failed: ${error.message}`); }`,
} );
paths.push(data.path); }
}
return ids;
} }
return paths;
}
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> { private sanitizeFilename(name: string): string {
const ids: string[] = []; return name
for (const file of files) { .normalize("NFD")
try { .replace(/[\u0300-\u036f]/g, "")
const created = await this.openai.files.create({ .replace(/[^a-zA-Z0-9.-]/g, "_");
file,
purpose: "user_data",
});
ids.push(created.id);
} catch (e) {
throw new Error(
`OpenAI file upload failed: ${(e as Error)?.message ?? String(e)}`,
);
}
} }
return ids;
}
private sanitizeFilename(name: string): string {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9.-]/g, "_");
}
} }
@@ -1,7 +1,5 @@
// Setup type definitions for built-in Supabase Runtime APIs // Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts"; import "@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
import { OpenAIService } from "../_shared/openai-service.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
type WebhookPayload = { type WebhookPayload = {
@@ -15,6 +13,9 @@ type WebhookPayload = {
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SERVICE_ROLE_KEY = const SUPABASE_SERVICE_ROLE_KEY =
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? "";
const OPENAI_BASE_URL =
Deno.env.get("OPENAI_BASE_URL") ?? "https://api.openai.com/v1";
const ALLOWED_SCHEMA = "public"; const ALLOWED_SCHEMA = "public";
const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]); const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]);
@@ -26,16 +27,27 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
function jsonResponse(status: number, body: Record<string, unknown>) { function jsonResponse(status: number, body: Record<string, unknown>) {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status, status,
headers: { "Content-Type": "application/json", ...corsHeaders }, headers: { "Content-Type": "application/json" },
}); });
} }
async function createConversationId( async function createConversationId(metadata: Record<string, string>) {
openaiService: OpenAIService, const response = await fetch(`${OPENAI_BASE_URL}/conversations`, {
metadata: Record<string, string>, method: "POST",
) { headers: {
const conversation = await openaiService.createConversation(metadata); "Content-Type": "application/json",
const conversationId = conversation?.id as string | undefined; Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({ metadata }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenAI error: ${response.status} ${errorText}`);
}
const data = await response.json();
const conversationId = data?.id as string | undefined;
if (!conversationId) { if (!conversationId) {
throw new Error("OpenAI response missing conversation id"); throw new Error("OpenAI response missing conversation id");
@@ -53,9 +65,8 @@ Deno.serve(async (req) => {
return jsonResponse(500, { error: "Supabase env vars missing" }); return jsonResponse(500, { error: "Supabase env vars missing" });
} }
const openaiService = OpenAIService.fromEnv(); if (!OPENAI_API_KEY) {
if (!(openaiService instanceof OpenAIService)) { return jsonResponse(500, { error: "OPENAI_API_KEY missing" });
return jsonResponse(500, { error: openaiService.message });
} }
let payload: WebhookPayload; let payload: WebhookPayload;
@@ -97,7 +108,7 @@ Deno.serve(async (req) => {
let conversationId: string; let conversationId: string;
try { try {
conversationId = await createConversationId(openaiService, { conversationId = await createConversationId({
table, table,
record_id: String(recordId), record_id: String(recordId),
}); });