import { BaseHandler } from './BaseHandler'; import { IPromptOptions, IPromptGenericConfig, IConvMessage, ModuleOptions } from '../../types'; import { libx } from 'libx.js/build/bundles/essentials.js'; import { Streams } from 'libx.js/build/modules/Streams'; export class OpenAiHandler extends BaseHandler { private baseUrl = 'https://api.openai.com/v1'; constructor(options: ModuleOptions) { super(options); } async execute(isStream: boolean, options: IPromptOptions): Promise { const isPlain = options.config.plain ?? false; try { const config = this.getConfig(options.config); const apiKey = this.getApiKey('openai'); if (!apiKey) { throw new Error('OpenAI API key not configured'); } // Prepare messages const messages = this.prepareMessages(options); // Build payload with correct token parameter (filter out undefined values) const payload: any = { model: config.model, messages, stream: isStream, }; // Add optional parameters only if defined if (config.temperature !== undefined) payload.temperature = config.temperature; if (config.top_p !== undefined) payload.top_p = config.top_p; if (config.frequency_penalty !== undefined) payload.frequency_penalty = config.frequency_penalty; if (config.presence_penalty !== undefined) payload.presence_penalty = config.presence_penalty; if (config.stop !== undefined) payload.stop = config.stop; if (config.user !== undefined) payload.user = config.user; // Use max_completion_tokens for newer models, max_tokens for older models if (config.usesCompletionTokens) { payload.max_completion_tokens = config.max_tokens; } else { payload.max_tokens = config.max_tokens; } if (isStream) { return await this.executeStream(payload, apiKey, isPlain); } else { return await this.executeSync(payload, apiKey, isPlain); } } catch (error) { return this.handleError(error, isPlain); } } getConfig(generalConfig: IPromptGenericConfig): any { let model = generalConfig.model || 'gpt-4'; // Strip provider prefix if present model = this.normalizeModelName(model, 'openai'); // Normalize common aliases and future names to stable OpenAI API models const normalized = this.normalizeModelAlias(model); model = normalized; // Check for o1/o3 models which don't support system messages const isReasoningModel = model.startsWith('o1') || model.startsWith('o3'); // Check if model uses max_completion_tokens instead of max_tokens // This includes: o1, o3, gpt-4o, gpt-5, and future models const usesCompletionTokens = model.startsWith('o1') || model.startsWith('o3') || model.startsWith('gpt-4o') || model.startsWith('gpt-5'); // Limit max_tokens based on model let maxTokens = generalConfig.maxTokens || 2000; if (model.includes('gpt-3.5-turbo')) { maxTokens = Math.min(maxTokens, 4096); } else if (model.includes('gpt-4') && !model.includes('gpt-4o')) { maxTokens = Math.min(maxTokens, 8192); } return { model, temperature: isReasoningModel ? 1 : (generalConfig.temperature ?? 0.7), max_tokens: maxTokens, top_p: isReasoningModel ? 1 : generalConfig.topP, frequency_penalty: generalConfig.frequencyPenalty || 0, presence_penalty: generalConfig.presencePenalty || 0, stop: generalConfig.stopSequences, user: generalConfig.user, noSystem: isReasoningModel, usesCompletionTokens, }; } // Map loose or preview model names to API-supported identifiers private normalizeModelAlias(model: string): string { const m = model.trim(); // Handle GPT-5 placeholders to closest compatible family (use latest chat model) if (m.startsWith('gpt-5')) return 'chatgpt-4o-latest'; // Map 4.5 preview to 4o if (m.includes('4.5')) return 'gpt-4o'; if (m === 'gpt-4.5-preview') return 'gpt-4o'; // Normalize common nicknames if (m === 'gpt-4o-mini') return 'gpt-4o-mini'; if (m === 'gpt-4o') return 'gpt-4o'; if (m === 'chatgpt-4o-latest') return 'chatgpt-4o-latest'; // Leave o1/o3 and standard names as-is return m; } private prepareMessages(options: IPromptOptions): any[] { const messages: any[] = []; const config = this.getConfig(options.config); // Add system prompt if provided and model supports it if (options.systemPrompt && !config.noSystem) { messages.push({ role: 'system', content: options.systemPrompt }); } // Add conversation messages if (options.messages) { for (const msg of options.messages) { if (msg.role === 'system' && config.noSystem) { // Convert system messages to user messages for o1/o3 models messages.push({ role: 'user', content: msg.content }); } else { const message: any = { role: msg.role, content: msg.content }; // Handle multi-modal (image input) if (msg.files && msg.files.length > 0) { message.content = [ { type: 'text', text: msg.content } ]; for (const file of msg.files) { if (file.type?.startsWith('image/')) { message.content.push({ type: 'image_url', image_url: { url: file.url } }); } } } if (msg.name) message.name = msg.name; messages.push(message); } } } return messages; } private async executeStream(payload: any, apiKey: string, isPlain: boolean): Promise { const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const url = `${this.baseUrl}/chat/completions`; libx.log.v('OpenAiHandler: streaming request to', url); let faultyChunk = ''; const promise = Streams.getStream( url, async (eventChunk: string) => { try { // Split by newlines and filter empty lines const rows = eventChunk.split('\n').filter((x) => x.trim() != ''); for (let row of rows) { // Handle chunks that don't start with "data:" if (!row.startsWith('data:')) { row = faultyChunk + row; faultyChunk = ''; } const dataStr = row.substring(6); if (dataStr === '[DONE]') continue; try { const data = JSON.parse(dataStr); const delta = data.choices?.[0]?.delta?.content; if (delta) { await this.writeStreamChunk(writer, encoder, delta, isPlain); } } catch (e) { // If JSON parse fails, this might be a partial chunk faultyChunk = row; } } } catch (err) { libx.log.w('OpenAiHandler: chunk processing error', err); } }, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: payload, encoding: 'utf-8', useEventBuffering: false, } ); promise.then(() => { libx.log.v('OpenAiHandler: stream completed'); if (!isPlain) { writer.write(encoder.encode('data: [DONE]\n\n')); } writer.close(); }).catch((error) => { libx.log.e('OpenAiHandler: stream error', error); if (isPlain) { writer.write(encoder.encode(`\nError: ${error.message}`)); } else { writer.write(encoder.encode(`data: ${JSON.stringify({ error: error.message })}\n\n`)); } writer.close(); }); return readable; } private async executeSync(payload: any, apiKey: string, isPlain: boolean): Promise { const url = `${this.baseUrl}/chat/completions`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify(payload) }); if (!response.ok) { const error = await response.text(); throw new Error(`OpenAI API error: ${error}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content || ''; const metadata = { model: data.model, usage: data.usage, finish_reason: data.choices?.[0]?.finish_reason }; return this.writeSyncResponse(content, metadata, isPlain); } }