From 0b7d2e83d7d86e20acb6dad6027b2ae50c13fec7 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Tue, 3 Feb 2026 09:38:56 -0600 Subject: [PATCH] Refactor OpenAI service to manage env and convo creation - Add fromEnv factory to OpenAIService to handle env vars - Move conversation creation into OpenAIService - Update create- chat-conversation function to use new OpenAIService methods - Add CORS headers to responses --- supabase/functions/_shared/openai-service.ts | 360 ++++++++---------- .../create-chat-conversation/index.ts | 37 +- 2 files changed, 181 insertions(+), 216 deletions(-) diff --git a/supabase/functions/_shared/openai-service.ts b/supabase/functions/_shared/openai-service.ts index afa0cd4..0f46ff6 100644 --- a/supabase/functions/_shared/openai-service.ts +++ b/supabase/functions/_shared/openai-service.ts @@ -6,221 +6,197 @@ import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2"; // Use non-streaming params to ensure `responses.create` returns a typed Response export type StructuredResponseOptions = - OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming; + OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming; export type StructuredResponseSuccess = { - ok: true; - output?: TOutput; // parsed JSON when available - outputText?: string; // raw text when parsing is not possible - model: string; - usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null; - responseId: string; - conversationId?: string | null; - references: { - uploadedToStorage: string[]; // supabase storage paths - openaiFileIds: string[]; // file ids in OpenAI - }; - openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers + ok: true; + output?: TOutput; // parsed JSON when available + outputText?: string; // raw text when parsing is not possible + model: string; + usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null; + responseId: string; + conversationId?: string | null; + references: { + uploadedToStorage: string[]; // supabase storage paths + openaiFileIds: string[]; // file ids in OpenAI + }; + openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers }; export type StructuredResponseFailure = { - ok: false; - code: - | "MissingEnv" - | "StorageUploadFailed" - | "OpenAIFileUploadFailed" - | "OpenAIRequestFailed"; - message: string; - cause?: unknown; + ok: false; + code: + | "MissingEnv" + | "StorageUploadFailed" + | "OpenAIFileUploadFailed" + | "OpenAIRequestFailed"; + message: string; + cause?: unknown; }; export type StructuredResponseResult = - | StructuredResponseSuccess - | StructuredResponseFailure; + | StructuredResponseSuccess + | StructuredResponseFailure; export interface OpenAIServiceConfig { - supabaseUrl: string; - serviceRoleKey: string; - openAIApiKey: string; - bucket?: string; // default: ai-storage + supabaseUrl: string; + serviceRoleKey: string; + openAIApiKey: string; + bucket?: string; // default: ai-storage } export class OpenAIService { - private readonly supabase: SupabaseClient; - private readonly openai: OpenAI; - private readonly bucket: string; + private readonly supabase: SupabaseClient; + private readonly openai: OpenAI; + private readonly bucket: string; - private constructor( - client: SupabaseClient, - openai: OpenAI, - bucket: string, - ) { - this.supabase = client; - this.openai = openai; - this.bucket = bucket; + private constructor(client: SupabaseClient, openai: OpenAI, bucket: string) { + this.supabase = client; + this.openai = openai; + this.bucket = bucket; + } + + static fromEnv(): StructuredResponseFailure | OpenAIService { + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + 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", + }; } - static fromEnv(): StructuredResponseFailure | OpenAIService { - const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; - 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"; + const client = createClient(supabaseUrl, serviceRoleKey); + const openai = new OpenAI({ apiKey: openAIApiKey }); + return new OpenAIService(client, openai, bucket); + } - if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) { - return { - ok: false, - code: "MissingEnv", - message: - "Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY", - }; - } + async createConversation(metadata?: Record) { + const conversation = await this.openai.conversations.create({ + metadata, + }); + return conversation; + } - const client = createClient(supabaseUrl, serviceRoleKey); - const openai = new OpenAI({ apiKey: openAIApiKey }); - return new OpenAIService(client, openai, bucket); - } + async createStructuredResponse( + options: StructuredResponseOptions, + files?: File[], + ): Promise> { + try { + const uploadedToStorage = await this.uploadFilesToStorage(files ?? []); + const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []); - async createStructuredResponse( - options: StructuredResponseOptions, - files?: File[], - ): Promise> { + // 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 { - const uploadedToStorage = await this.uploadFilesToStorage( - files ?? [], - ); - 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 }; + output = JSON.parse(maybeOutputText) as TOutput; + } catch { + /* non-JSON text, keep as text only */ } - } - - private async uploadFilesToStorage(files: File[]): Promise { - const paths: string[] = []; - for (const file of files) { - const safeName = this.sanitizeFilename(file.name); - const path = `${crypto.randomUUID()}-${safeName}`; - const { data, error } = await this.supabase.storage - .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); + } 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 paths; - } + } - private async uploadFilesToOpenAI(files: File[]): Promise { - const ids: string[] = []; - for (const file of files) { - try { - const created = await this.openai.files.create({ - 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; - } + 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"; - private sanitizeFilename(name: string): string { - return name - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-zA-Z0-9.-]/g, "_"); + return { ok: false, code, message, cause: err }; } + } + + private async uploadFilesToStorage(files: File[]): Promise { + const paths: string[] = []; + for (const file of files) { + const safeName = this.sanitizeFilename(file.name); + const path = `${crypto.randomUUID()}-${safeName}`; + const { data, error } = await this.supabase.storage + .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; + } + + private async uploadFilesToOpenAI(files: File[]): Promise { + const ids: string[] = []; + for (const file of files) { + try { + const created = await this.openai.files.create({ + 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, "_"); + } } diff --git a/supabase/functions/create-chat-conversation/index.ts b/supabase/functions/create-chat-conversation/index.ts index cadbed2..59d377e 100644 --- a/supabase/functions/create-chat-conversation/index.ts +++ b/supabase/functions/create-chat-conversation/index.ts @@ -1,5 +1,7 @@ // Setup type definitions for built-in Supabase Runtime APIs 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"; type WebhookPayload = { @@ -13,9 +15,6 @@ type WebhookPayload = { const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; const 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_TABLES = new Set(["planes_estudio", "asignaturas"]); @@ -27,27 +26,16 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { function jsonResponse(status: number, body: Record) { return new Response(JSON.stringify(body), { status, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...corsHeaders }, }); } -async function createConversationId(metadata: Record) { - const response = await fetch(`${OPENAI_BASE_URL}/conversations`, { - method: "POST", - headers: { - "Content-Type": "application/json", - 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; +async function createConversationId( + openaiService: OpenAIService, + metadata: Record, +) { + const conversation = await openaiService.createConversation(metadata); + const conversationId = conversation?.id as string | undefined; if (!conversationId) { throw new Error("OpenAI response missing conversation id"); @@ -65,8 +53,9 @@ Deno.serve(async (req) => { return jsonResponse(500, { error: "Supabase env vars missing" }); } - if (!OPENAI_API_KEY) { - return jsonResponse(500, { error: "OPENAI_API_KEY missing" }); + const openaiService = OpenAIService.fromEnv(); + if (!(openaiService instanceof OpenAIService)) { + return jsonResponse(500, { error: openaiService.message }); } let payload: WebhookPayload; @@ -108,7 +97,7 @@ Deno.serve(async (req) => { let conversationId: string; try { - conversationId = await createConversationId({ + conversationId = await createConversationId(openaiService, { table, record_id: String(recordId), });