/** * Shared utility for calling OpenAI-compatible chat completion APIs. * Used by both production AI features and shadow AI benchmarking. */ export interface AICallerParams { apiUrl: string; apiKey: string; model: string; messages: Array<{ role: string; content: string }>; temperature: number; maxTokens: number; timeoutMs?: number; } export interface AICallerResult { content: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; responseTimeMs: number; rawResponse: string; } export async function callOpenAICompatible(params: AICallerParams): Promise { const { apiUrl, apiKey, model, messages, temperature, maxTokens, timeoutMs = 600000 } = params; const requestBody = { model, messages, temperature, max_tokens: maxTokens, }; const bodyString = JSON.stringify(requestBody); const startTime = Date.now(); const { URL } = await import('url'); const https = await import('https'); const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => { // Normalize: strip trailing slash and /chat/completions if user included it let baseUrl = apiUrl.replace(/\/+$/, ''); if (baseUrl.endsWith('/chat/completions')) { baseUrl = baseUrl.slice(0, -'/chat/completions'.length); } const url = new URL(`${baseUrl}/chat/completions`); const options = { hostname: url.hostname, port: url.port || 443, path: url.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), }, timeout: timeoutMs, }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { resolve({ status: res.statusCode!, body: data }); }); }); req.on('error', (err) => reject(err)); req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out after ${timeoutMs / 1000}s`)); }); req.write(bodyString); req.end(); }); const responseTimeMs = Date.now() - startTime; if (aiResult.status >= 400) { throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`); } const data = JSON.parse(aiResult.body); const content = data.choices?.[0]?.message?.content || null; if (!content) { throw new Error('AI model returned empty content'); } // Clean response: strip markdown fences and thinking blocks let cleaned = content.trim(); if (cleaned.startsWith('```')) { cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); } cleaned = cleaned.replace(/[\s\S]*?<\/think>\s*/g, '').trim(); const usage = data.usage || undefined; return { content: cleaned, usage, responseTimeMs, rawResponse: content, }; }