Tokiva-backend-Node / src /utils /ai-client.ts
anoderb
debug: add chatbot raw completion logging
ee10e38
Raw
History Blame Contribute Delete
6.06 kB
/**
* ai-client.ts
* Client untuk berkomunikasi dengan AI melalui OpenRouter API.
* Menggunakan SDK OpenAI karena OpenRouter API-nya OpenAI-compatible.
*/
import OpenAI from 'openai';
import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions';
// Inisialisasi client OpenAI dengan baseURL OpenRouter
// Lazy initialization client OpenAI menggunakan konfigurasi dari .env
let openai: OpenAI | null = null;
function getOpenAI(): OpenAI {
if (openai) return openai;
const aiBaseUrl = process.env.AI_BASE_URL;
const aiApiKey = process.env.AI_API_KEY;
const aiModel = process.env.AI_MODEL;
if (!aiApiKey || !aiBaseUrl || !aiModel) {
throw new Error('Konfigurasi AI tidak lengkap di file .env (Pastikan AI_BASE_URL, AI_API_KEY, dan AI_MODEL terisi).');
}
openai = new OpenAI({
baseURL: aiBaseUrl,
apiKey: aiApiKey,
});
return openai;
}
export interface PesanChat {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tool_call_id?: string;
tool_calls?: any[];
}
export interface HasilAI {
balasan: string | null;
tool_calls: ToolCall[] | null;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface ToolCall {
id: string;
nama: string;
argumen: Record<string, any>;
}
/**
* Kirim pesan ke AI dan dapatkan response.
* Support function calling / tool use.
*/
export async function chatCompletion(
messages: PesanChat[],
tools?: ChatCompletionTool[]
): Promise<HasilAI> {
const model = process.env.AI_MODEL!;
const maxTokens = Number(process.env.AI_MAX_TOKENS) || 2000;
const requestMessages: ChatCompletionMessageParam[] = messages.map((m) => {
if (m.role === 'tool') {
return {
role: 'tool' as const,
content: m.content,
tool_call_id: m.tool_call_id || '',
};
}
if (m.role === 'assistant' && m.tool_calls) {
return {
role: 'assistant' as const,
content: m.content || null,
tool_calls: m.tool_calls,
};
}
return {
role: m.role as 'system' | 'user' | 'assistant',
content: m.content,
};
});
const params: any = {
model,
messages: requestMessages,
max_tokens: maxTokens,
temperature: 0.7,
};
// Hanya tambahkan tools jika ada
if (tools && tools.length > 0) {
params.tools = tools;
params.tool_choice = 'auto';
}
console.log('[AI Client] Params:', JSON.stringify(params, null, 2));
const response = await getOpenAI().chat.completions.create(params);
console.log('[AI Client] Raw Response Choices:', JSON.stringify(response.choices, null, 2));
const choice = response.choices[0];
// Parse tool calls jika ada secara native
let toolCalls: ToolCall[] | null = null;
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
toolCalls = choice.message.tool_calls.map((tc) => ({
id: tc.id,
nama: (tc as any).function.name,
argumen: JSON.parse((tc as any).function.arguments || '{}'),
}));
}
let balasan = choice.message.content || null;
// Fallback parser jika AI menulis tag DSML secara literal di teks (karena limitasi API/model gratis)
if (!toolCalls && balasan) {
const dsmlCalls = parseDsmlToolCalls(balasan);
if (dsmlCalls) {
toolCalls = dsmlCalls;
// Bersihkan teks tag XML/DSML dari balasan agar bubble chat rapi
balasan = balasan
.replace(/<\s*\|?\s*\|?\s*DSML[\s\S]*?<\s*\/\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*tool_calls\s*>/g, '')
// Hapus juga tag invoke/tool_calls sisa jika terpisah
.replace(/<\s*\|?\s*\|?\s*DSML[\s\S]*?>/g, '')
.replace(/<\s*\/\s*\|?\s*\|?\s*DSML[\s\S]*?>/g, '')
.trim();
}
}
return {
balasan,
tool_calls: toolCalls,
usage: response.usage
? {
prompt_tokens: response.usage.prompt_tokens,
completion_tokens: response.usage.completion_tokens,
total_tokens: response.usage.total_tokens,
}
: undefined,
};
}
/**
* Parser penanganan tag DSML/XML tool calls yang dikirim literal oleh model AI
*/
function parseDsmlToolCalls(text: string): ToolCall[] | null {
if (!text.includes('tool_calls') && !text.includes('DSML')) return null;
try {
const toolCalls: ToolCall[] = [];
// Regex fleksibel mencocokkan invoke block: < | | DSML | | invoke name="NAMA"> ... </ | | DSML | | invoke>
const invokeRegex = /<\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\s*\/\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*invoke\s*>/g;
let match;
let idx = 0;
while ((match = invokeRegex.exec(text)) !== null) {
const toolName = match[1];
const innerContent = match[2];
const args: Record<string, any> = {};
// Regex mencocokkan argument: <arg name="NAMA">NILAI</arg>
const argRegex = /<\s*(?:arg|parameter)\s+name="([^"]+)"\s*>([\s\S]*?)<\s*\/\s*(?:arg|parameter)\s*>/g;
let argMatch;
while ((argMatch = argRegex.exec(innerContent)) !== null) {
const argName = argMatch[1];
const argValue = argMatch[2].trim();
if (/^-?\d+$/.test(argValue)) {
args[argName] = parseInt(argValue, 10);
} else if (/^-?\d+\.\d+$/.test(argValue)) {
args[argName] = parseFloat(argValue);
} else {
args[argName] = argValue;
}
}
toolCalls.push({
id: `dsml_${Date.now()}_${idx++}`,
nama: toolName,
argumen: args,
});
}
return toolCalls.length > 0 ? toolCalls : null;
} catch (err) {
console.error('Gagal parsing DSML tool calls:', err);
return null;
}
}
/**
* Kirim pesan sederhana tanpa tools (untuk format ulang jawaban dll).
*/
export async function chatSederhana(systemPrompt: string, userMessage: string): Promise<string> {
const hasil = await chatCompletion([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
]);
return hasil.balasan || '';
}