// supabase/functions/_shared/openai-service.ts /// import OpenAI from "npm:openai@6.16.0"; import type * as OpenAITypes from "npm:openai@6.16.0"; 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; 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 }; export type StructuredResponseFailure = { ok: false; code: | "MissingEnv" | "StorageUploadFailed" | "OpenAIFileUploadFailed" | "OpenAIRequestFailed"; message: string; cause?: unknown; }; export type StructuredResponseResult = | StructuredResponseSuccess | StructuredResponseFailure; export interface OpenAIServiceConfig { 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 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", }; } const client = createClient(supabaseUrl, serviceRoleKey); const openai = new OpenAI({ apiKey: openAIApiKey }); return new OpenAIService(client, openai, bucket); } async createConversation(metadata?: Record) { const conversation = await this.openai.conversations.create({ metadata, }); return conversation; } async createStructuredResponse( options: StructuredResponseOptions, files?: File[], ): Promise> { 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 }; } } 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, "_"); } }