Users entering the full endpoint URL (e.g. https://openrouter.ai/api/v1/chat/completions) caused a 404 because the code appended /chat/completions again. Now strips any trailing /chat/completions before re-appending, and adds a hint in the UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
107 lines
2.9 KiB
TypeScript
107 lines
2.9 KiB
TypeScript
/**
|
|
* 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<AICallerResult> {
|
|
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(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
|
|
|
|
const usage = data.usage || undefined;
|
|
|
|
return {
|
|
content: cleaned,
|
|
usage,
|
|
responseTimeMs,
|
|
rawResponse: content,
|
|
};
|
|
}
|