Prefer the cheapest ChatGPT model that supports our JSON completions, and skip custom temperature for gpt-5/o-series models. Co-authored-by: Cursor <cursoragent@cursor.com>
100 lines
2.6 KiB
TypeScript
100 lines
2.6 KiB
TypeScript
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<string>('AI_PROVIDER')?.trim().toLowerCase();
|
|
const groqKey = config.get<string>('GROQ_API_KEY')?.trim();
|
|
const openAiKey = config.get<string>('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<string>('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<string>('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<string> {
|
|
const body: Record<string, unknown> = {
|
|
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;
|
|
}
|