import { ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; export type AiProviderConfig = { apiKey: string; baseUrl: string; model: string; }; export function resolveAiProvider(config: ConfigService): AiProviderConfig { const configured = config.get('AI_PROVIDER')?.trim().toLowerCase(); const groqKey = config.get('GROQ_API_KEY')?.trim(); const openAiKey = config.get('OPENAI_API_KEY')?.trim(); const useGroq = configured === 'groq' || (!configured && !!groqKey) || (!openAiKey && !!groqKey); if (useGroq) { if (!groqKey) { throw new ServiceUnavailableException( 'AI is not configured. Set GROQ_API_KEY on the server.', ); } return { apiKey: groqKey, baseUrl: 'https://api.groq.com/openai/v1', model: config.get('GROQ_MODEL')?.trim() || 'llama-3.3-70b-versatile', }; } if (!openAiKey) { throw new ServiceUnavailableException( 'AI is not configured. Set GROQ_API_KEY or OPENAI_API_KEY on the server.', ); } return { apiKey: openAiKey, baseUrl: 'https://api.openai.com/v1', model: config.get('OPENAI_MODEL')?.trim() || 'gpt-4.1-nano', }; } function supportsCustomTemperature(model: string): boolean { // gpt-5 / o-series chat models only accept the API default temperature. const id = model.trim().toLowerCase(); return !( id.startsWith('gpt-5') || id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4') ); } export async function requestAiJsonCompletion( provider: AiProviderConfig, systemPrompt: string, userPrompt: string, temperature = 0.4, ): Promise { const body: Record = { model: provider.model, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], }; if (supportsCustomTemperature(provider.model)) { body.temperature = temperature; } const response = await fetch(`${provider.baseUrl}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!response.ok) { const detail = await response.text(); throw new Error(`AI provider error (${response.status}): ${detail.slice(0, 240)}`); } const payload = (await response.json()) as { choices?: { message?: { content?: string } }[]; }; const content = payload.choices?.[0]?.message?.content; if (!content) { throw new Error('AI returned an empty response'); } return content; }