import OpenAI from 'openai' import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions' import { prisma } from './prisma' // OpenAI client singleton with lazy initialization const globalForOpenAI = globalThis as unknown as { openai: OpenAI | undefined openaiInitialized: boolean } // ─── Model Type Detection ──────────────────────────────────────────────────── /** * Reasoning models that require different API parameters: * - Use max_completion_tokens instead of max_tokens * - Don't support response_format: json_object (must instruct JSON in prompt) * - Don't support temperature parameter * - Don't support system messages (use developer or user role instead) */ const REASONING_MODEL_PREFIXES = ['o1', 'o3', 'o4'] /** * Models that use max_completion_tokens instead of max_tokens. * This includes reasoning models AND newer GPT models (GPT-5+). */ const NEW_TOKEN_PARAM_PREFIXES = ['o1', 'o3', 'o4', 'gpt-5', 'gpt-6', 'gpt-7'] /** * Models that don't support custom temperature values. * These only accept the default temperature (1). */ const NO_TEMPERATURE_PREFIXES = ['o1', 'o3', 'o4', 'gpt-5', 'gpt-6', 'gpt-7'] /** * Check if a model is a reasoning model (o1, o3, o4 series) * These models have additional restrictions (no temperature, no json_object, etc.) */ export function isReasoningModel(model: string): boolean { const modelLower = model.toLowerCase() return REASONING_MODEL_PREFIXES.some(prefix => modelLower.startsWith(prefix) || modelLower.includes(`/${prefix}`) || modelLower.includes(`-${prefix}`) ) } /** * Check if a model requires max_completion_tokens instead of max_tokens. * This includes reasoning models AND newer GPT models (GPT-5+). */ export function usesNewTokenParam(model: string): boolean { const modelLower = model.toLowerCase() return NEW_TOKEN_PARAM_PREFIXES.some(prefix => modelLower.startsWith(prefix) || modelLower.includes(`/${prefix}`) || modelLower.includes(`-${prefix}`) ) } /** * Check if a model supports custom temperature values. * Newer models (o-series, GPT-5+) only accept default temperature (1). */ export function supportsTemperature(model: string): boolean { const modelLower = model.toLowerCase() return !NO_TEMPERATURE_PREFIXES.some(prefix => modelLower.startsWith(prefix) || modelLower.includes(`/${prefix}`) || modelLower.includes(`-${prefix}`) ) } // ─── Chat Completion Parameter Builder ─────────────────────────────────────── type MessageRole = 'system' | 'user' | 'assistant' | 'developer' export interface ChatCompletionOptions { messages: Array<{ role: MessageRole; content: string }> maxTokens?: number temperature?: number jsonMode?: boolean } /** * Build chat completion parameters with correct settings for the model type. * Handles differences between standard models and reasoning models. */ export function buildCompletionParams( model: string, options: ChatCompletionOptions ): ChatCompletionCreateParamsNonStreaming { const isReasoning = isReasoningModel(model) // Convert messages for reasoning models (system -> developer) const messages = options.messages.map(msg => { if (isReasoning && msg.role === 'system') { return { role: 'developer' as const, content: msg.content } } return msg as { role: 'system' | 'user' | 'assistant' | 'developer'; content: string } }) // For reasoning models requesting JSON, append JSON instruction to last user message if (isReasoning && options.jsonMode) { // Find last user message index (polyfill for findLastIndex) let lastUserIdx = -1 for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'user') { lastUserIdx = i break } } if (lastUserIdx !== -1) { messages[lastUserIdx] = { ...messages[lastUserIdx], content: messages[lastUserIdx].content + '\n\nIMPORTANT: Respond with valid JSON only, no other text.', } } } const params: ChatCompletionCreateParamsNonStreaming = { model, messages: messages as ChatCompletionCreateParamsNonStreaming['messages'], } // Token limit parameter differs between model types // Newer models (GPT-5+, o-series) use max_completion_tokens if (options.maxTokens) { if (usesNewTokenParam(model)) { params.max_completion_tokens = options.maxTokens } else { params.max_tokens = options.maxTokens } } // Newer models (o-series, GPT-5+) don't support custom temperature if (supportsTemperature(model) && options.temperature !== undefined) { params.temperature = options.temperature } // Reasoning models don't support response_format: json_object if (!isReasoning && options.jsonMode) { params.response_format = { type: 'json_object' } } return params } /** * Get OpenAI API key from SystemSettings */ async function getOpenAIApiKey(): Promise { try { const setting = await prisma.systemSettings.findUnique({ where: { key: 'openai_api_key' }, }) return setting?.value || process.env.OPENAI_API_KEY || null } catch { // Fall back to env var if database isn't available return process.env.OPENAI_API_KEY || null } } /** * Create OpenAI client instance */ async function createOpenAIClient(): Promise { const apiKey = await getOpenAIApiKey() if (!apiKey) { console.warn('OpenAI API key not configured') return null } return new OpenAI({ apiKey, }) } /** * Get the OpenAI client singleton * Returns null if API key is not configured */ export async function getOpenAI(): Promise { if (globalForOpenAI.openaiInitialized) { return globalForOpenAI.openai || null } const client = await createOpenAIClient() if (process.env.NODE_ENV !== 'production') { globalForOpenAI.openai = client || undefined globalForOpenAI.openaiInitialized = true } return client } /** * Check if OpenAI is configured and available */ export async function isOpenAIConfigured(): Promise { const apiKey = await getOpenAIApiKey() return !!apiKey } /** * List available models from OpenAI API */ export async function listAvailableModels(): Promise<{ success: boolean models?: string[] error?: string }> { try { const client = await getOpenAI() if (!client) { return { success: false, error: 'OpenAI API key not configured', } } const response = await client.models.list() const chatModels = response.data .filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4')) .map((m) => m.id) .sort() return { success: true, models: chatModels, } } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error', } } } /** * Validate that a specific model is available */ export async function validateModel(modelId: string): Promise<{ valid: boolean error?: string }> { try { const client = await getOpenAI() if (!client) { return { valid: false, error: 'OpenAI API key not configured', } } // Try a minimal completion with the model using correct parameters const params = buildCompletionParams(modelId, { messages: [{ role: 'user', content: 'test' }], maxTokens: 1, }) await client.chat.completions.create(params) return { valid: true } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' // Check for specific model errors if (message.includes('does not exist') || message.includes('model_not_found')) { return { valid: false, error: `Model "${modelId}" is not available with your API key`, } } return { valid: false, error: message, } } } /** * Test OpenAI connection with the configured model */ export async function testOpenAIConnection(): Promise<{ success: boolean error?: string model?: string modelTested?: string }> { try { const client = await getOpenAI() if (!client) { return { success: false, error: 'OpenAI API key not configured', } } // Get the configured model const configuredModel = await getConfiguredModel() // Test with the configured model using correct parameters const params = buildCompletionParams(configuredModel, { messages: [{ role: 'user', content: 'Hello' }], maxTokens: 5, }) const response = await client.chat.completions.create(params) return { success: true, model: response.model, modelTested: configuredModel, } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' const configuredModel = await getConfiguredModel() // Check for model-specific errors if (message.includes('does not exist') || message.includes('model_not_found')) { return { success: false, error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`, modelTested: configuredModel, } } return { success: false, error: message, modelTested: configuredModel, } } } // Default models for different use cases export const AI_MODELS = { ASSIGNMENT: 'gpt-4o', // Best for complex reasoning QUICK: 'gpt-4o-mini', // Faster, cheaper for simple tasks } as const /** * Get the admin-configured AI model from SystemSettings. * Falls back to the provided default if not configured. */ export async function getConfiguredModel(fallback: string = AI_MODELS.ASSIGNMENT): Promise { try { const setting = await prisma.systemSettings.findUnique({ where: { key: 'ai_model' }, }) return setting?.value || process.env.OPENAI_MODEL || fallback } catch { return process.env.OPENAI_MODEL || fallback } }