Files
MOPC-Portal/src/lib/openai.ts

386 lines
11 KiB
TypeScript
Raw Normal View History

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}`)
)
}
/**
* Check if a model requires higher token limits due to reasoning overhead.
* GPT-5 nano especially needs more tokens as reasoning consumes output budget.
*/
export function needsHigherTokenLimit(model: string): boolean {
const modelLower = model.toLowerCase()
return modelLower.includes('nano') || modelLower.includes('gpt-5')
}
/**
* Get minimum recommended max_tokens for a model.
* Reasoning models need higher limits because internal reasoning consumes tokens.
*/
export function getMinTokenLimit(model: string, requestedLimit?: number): number | undefined {
// For GPT-5 nano, reasoning uses significant token budget
// If user requests < 8000, bump it up or remove limit
if (needsHigherTokenLimit(model)) {
const minLimit = 16000 // Ensure enough headroom for reasoning
if (!requestedLimit) return undefined // No limit = model default
return Math.max(requestedLimit, minLimit)
}
return requestedLimit
}
// ─── 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
// Also ensure sufficient tokens for models with reasoning overhead (GPT-5 nano)
const effectiveMaxTokens = getMinTokenLimit(model, options.maxTokens)
if (effectiveMaxTokens) {
if (usesNewTokenParam(model)) {
params.max_completion_tokens = effectiveMaxTokens
} else {
params.max_tokens = effectiveMaxTokens
}
}
// 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<string | null> {
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<OpenAI | null> {
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<OpenAI | null> {
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<boolean> {
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<string> {
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
}
}