2026-06-02 17:55:42 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Centralised LLM chat completion helper.
|
|
|
|
|
|
*
|
|
|
|
|
|
* All `openai.chat.completions.create` calls in the moderation subsystem
|
|
|
|
|
|
* go through this module so that model, concurrency, retry, and token
|
|
|
|
|
|
* defaults are maintained in one place.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import OpenAI from "openai";
|
2026-07-27 21:54:31 +07:00
|
|
|
|
import pLimit from "p-limit";
|
2026-07-31 23:09:00 +07:00
|
|
|
|
import { createChildLogger } from "@/shared/logger/index";
|
|
|
|
|
|
import { retryWithBackoff } from "@/shared/utils/index";
|
2026-06-02 17:55:42 +07:00
|
|
|
|
import { config } from "../../shared/config/config.js";
|
|
|
|
|
|
|
|
|
|
|
|
const log = createChildLogger("llm-client");
|
|
|
|
|
|
|
2026-07-27 21:54:31 +07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Concurrency limiter for LLM API calls (inlined from concurrencyLimiter.ts)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const llmSemaphore = pLimit(config.AI_LLM_MAX_CONCURRENT ?? 5);
|
|
|
|
|
|
|
|
|
|
|
|
let activeCount = 0;
|
|
|
|
|
|
let pendingCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
|
|
|
pendingCount++;
|
|
|
|
|
|
log.debug(
|
|
|
|
|
|
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
|
|
|
|
|
"Queuing LLM request",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return llmSemaphore(async () => {
|
|
|
|
|
|
pendingCount--;
|
|
|
|
|
|
activeCount++;
|
|
|
|
|
|
|
|
|
|
|
|
if (activeCount >= (config.AI_LLM_MAX_CONCURRENT ?? 5)) {
|
|
|
|
|
|
log.warn(
|
|
|
|
|
|
{ activeCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
|
|
|
|
|
"LLM concurrency limit reached",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await fn();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
activeCount--;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 13:07:01 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Covers all LLM response chunk shapes the streaming handler supports.
|
|
|
|
|
|
* Different providers (OpenAI, Anthropic-compatible, local LLMs) may return
|
|
|
|
|
|
* content in different fields — we try them all via optional chaining.
|
|
|
|
|
|
*/
|
|
|
|
|
|
type LLMResponseChunk = {
|
|
|
|
|
|
choices?: Array<{
|
|
|
|
|
|
delta?: { content?: string | null };
|
|
|
|
|
|
message?: { content?: string | null };
|
|
|
|
|
|
finish_reason?: string | null;
|
|
|
|
|
|
text?: string;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
message?: { content?: string | null };
|
|
|
|
|
|
content?: string;
|
|
|
|
|
|
response?: string;
|
|
|
|
|
|
finish_reason?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 17:55:42 +07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Lazy singleton — created on first use so that config is always resolved.
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
let openaiClient: OpenAI | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
function getClient(): OpenAI | null {
|
|
|
|
|
|
if (!config.AI_LLM_API_KEY) return null;
|
|
|
|
|
|
if (!openaiClient) {
|
|
|
|
|
|
openaiClient = new OpenAI({
|
|
|
|
|
|
apiKey: config.AI_LLM_API_KEY,
|
|
|
|
|
|
baseURL: config.AI_LLM_BASE_URL,
|
|
|
|
|
|
maxRetries: 0,
|
2026-06-05 19:08:57 +07:00
|
|
|
|
timeout: 60_000, // Diperbesar dari 15s ke 60s untuk mengakomodasi model delay tinggi
|
2026-06-02 17:55:42 +07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return openaiClient;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_RETRIES = 2;
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Public API
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export interface LlmCallOpts {
|
|
|
|
|
|
/** Conversation to send. Either a string (→ single user message) or an array of messages. */
|
|
|
|
|
|
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[];
|
|
|
|
|
|
/** Which model to use (defaults to config.AI_LLM_MODEL). */
|
|
|
|
|
|
model?: string;
|
|
|
|
|
|
/** Max output tokens (defaults to 8192). */
|
|
|
|
|
|
max_tokens?: number;
|
|
|
|
|
|
/** Temperature (defaults to 0.2). */
|
|
|
|
|
|
temperature?: number;
|
|
|
|
|
|
/** Top-p (defaults to 0.95). */
|
|
|
|
|
|
top_p?: number;
|
2026-06-03 00:40:42 +07:00
|
|
|
|
/** Force JSON output via response_format: { type: "json_object" }. */
|
|
|
|
|
|
jsonResponse?: { type: "json_object" };
|
2026-06-02 17:55:42 +07:00
|
|
|
|
/** Extra retries beyond DEFAULT_RETRIES (default 2). */
|
|
|
|
|
|
retries?: number;
|
2026-06-05 18:45:58 +07:00
|
|
|
|
/** Whether to use streaming (if true, will consume stream and return aggregated result) */
|
|
|
|
|
|
stream?: boolean;
|
2026-06-06 12:04:10 +07:00
|
|
|
|
/** Optional AbortSignal to cancel the API request */
|
|
|
|
|
|
signal?: AbortSignal;
|
2026-06-02 17:55:42 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Call the LLM with sensible defaults: concurrency cap, retry, model, tokens.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Returns the raw OpenAI ChatCompletion so callers can inspect
|
|
|
|
|
|
* `choices[0].message.content`, `finish_reason`, `usage`, etc.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function llmChat(
|
|
|
|
|
|
opts: LlmCallOpts,
|
|
|
|
|
|
): Promise<OpenAI.Chat.Completions.ChatCompletion | null> {
|
|
|
|
|
|
const client = getClient();
|
|
|
|
|
|
if (!client) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
messages,
|
|
|
|
|
|
model = config.AI_LLM_MODEL,
|
2026-06-05 18:45:58 +07:00
|
|
|
|
max_tokens,
|
|
|
|
|
|
temperature,
|
|
|
|
|
|
top_p,
|
2026-06-02 17:55:42 +07:00
|
|
|
|
jsonResponse,
|
|
|
|
|
|
retries = DEFAULT_RETRIES,
|
2026-06-05 19:44:06 +07:00
|
|
|
|
stream,
|
2026-06-06 12:04:10 +07:00
|
|
|
|
signal,
|
2026-06-02 17:55:42 +07:00
|
|
|
|
} = opts;
|
|
|
|
|
|
|
2026-06-09 10:16:04 +07:00
|
|
|
|
const params = {
|
2026-06-05 18:45:58 +07:00
|
|
|
|
model,
|
|
|
|
|
|
messages,
|
2026-06-09 10:16:04 +07:00
|
|
|
|
...(stream !== undefined ? { stream } : {}),
|
|
|
|
|
|
} as OpenAI.Chat.Completions.ChatCompletionCreateParams;
|
2026-06-05 18:45:58 +07:00
|
|
|
|
|
|
|
|
|
|
// Attach optional parameters only if explicitly provided to maintain
|
|
|
|
|
|
// maximum compatibility with various LLM providers and local APIs.
|
|
|
|
|
|
if (temperature !== undefined) params.temperature = temperature;
|
|
|
|
|
|
if (top_p !== undefined) params.top_p = top_p;
|
|
|
|
|
|
if (max_tokens !== undefined) params.max_tokens = max_tokens;
|
2026-06-02 17:55:42 +07:00
|
|
|
|
|
2026-06-03 00:40:42 +07:00
|
|
|
|
if (jsonResponse) {
|
2026-06-05 18:45:58 +07:00
|
|
|
|
params.response_format = jsonResponse;
|
2026-06-03 00:40:42 +07:00
|
|
|
|
}
|
2026-06-02 17:55:42 +07:00
|
|
|
|
|
|
|
|
|
|
return retryWithBackoff(
|
|
|
|
|
|
async () => {
|
2026-06-05 18:45:58 +07:00
|
|
|
|
return withLlmConcurrency(async () => {
|
2026-06-09 10:16:04 +07:00
|
|
|
|
const execute = async (
|
|
|
|
|
|
currentParams: OpenAI.Chat.Completions.ChatCompletionCreateParams,
|
|
|
|
|
|
) => {
|
2026-06-06 15:38:36 +07:00
|
|
|
|
const response = await client.chat.completions.create(currentParams, {
|
|
|
|
|
|
signal,
|
|
|
|
|
|
});
|
2026-06-05 19:44:06 +07:00
|
|
|
|
if (currentParams.stream) {
|
2026-06-05 18:47:30 +07:00
|
|
|
|
let content = "";
|
|
|
|
|
|
let finishReason = "stop";
|
2026-06-09 13:07:01 +07:00
|
|
|
|
for await (const chunk of response as unknown as AsyncIterable<LLMResponseChunk>) {
|
2026-06-05 18:47:30 +07:00
|
|
|
|
const choice = chunk?.choices?.[0];
|
2026-06-06 15:38:36 +07:00
|
|
|
|
const textChunk =
|
|
|
|
|
|
choice?.delta?.content ||
|
|
|
|
|
|
choice?.message?.content ||
|
|
|
|
|
|
choice?.text ||
|
|
|
|
|
|
chunk?.message?.content ||
|
|
|
|
|
|
chunk?.response ||
|
|
|
|
|
|
chunk?.content ||
|
2026-06-05 18:47:30 +07:00
|
|
|
|
"";
|
|
|
|
|
|
content += textChunk;
|
|
|
|
|
|
const fr = choice?.finish_reason || chunk?.finish_reason;
|
2026-06-05 19:44:06 +07:00
|
|
|
|
if (fr) finishReason = fr;
|
2026-06-05 18:45:58 +07:00
|
|
|
|
}
|
2026-06-05 18:47:30 +07:00
|
|
|
|
return {
|
2026-06-06 15:38:36 +07:00
|
|
|
|
id: "stream-aggregated",
|
2026-06-05 18:47:30 +07:00
|
|
|
|
choices: [
|
|
|
|
|
|
{
|
2026-06-06 15:38:36 +07:00
|
|
|
|
message: { role: "assistant", content, refusal: null },
|
2026-06-05 18:47:30 +07:00
|
|
|
|
finish_reason: finishReason,
|
|
|
|
|
|
index: 0,
|
|
|
|
|
|
logprobs: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
created: Math.floor(Date.now() / 1000),
|
2026-06-05 19:44:06 +07:00
|
|
|
|
model: currentParams.model,
|
2026-06-06 15:38:36 +07:00
|
|
|
|
object: "chat.completion",
|
2026-06-05 18:47:30 +07:00
|
|
|
|
} as OpenAI.Chat.Completions.ChatCompletion;
|
2026-06-05 18:45:58 +07:00
|
|
|
|
}
|
2026-06-05 18:47:30 +07:00
|
|
|
|
return response as OpenAI.Chat.Completions.ChatCompletion;
|
2026-06-05 19:44:06 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await execute(params);
|
2026-06-05 18:47:30 +07:00
|
|
|
|
} catch (error: any) {
|
2026-06-06 15:38:36 +07:00
|
|
|
|
const rawResponse =
|
|
|
|
|
|
error.error || error.body || error.response?.data || "N/A";
|
|
|
|
|
|
const errorStr = (
|
|
|
|
|
|
JSON.stringify(rawResponse) + String(error.message)
|
|
|
|
|
|
).toLowerCase();
|
2026-06-05 19:44:06 +07:00
|
|
|
|
|
|
|
|
|
|
// Auto-fallback: If provider strictly demands streaming (400 Bad Request on stream params)
|
2026-06-06 15:38:36 +07:00
|
|
|
|
if (
|
|
|
|
|
|
error.status === 400 &&
|
|
|
|
|
|
errorStr.includes("stream") &&
|
|
|
|
|
|
!params.stream
|
|
|
|
|
|
) {
|
|
|
|
|
|
log.warn(
|
|
|
|
|
|
{ model },
|
|
|
|
|
|
"Provider rejected non-streaming request. Fallback to stream: true initiated.",
|
|
|
|
|
|
);
|
2026-06-09 10:16:04 +07:00
|
|
|
|
(
|
|
|
|
|
|
params as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
|
|
|
|
|
|
).stream = true;
|
2026-06-05 19:44:06 +07:00
|
|
|
|
return await execute(params);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 18:47:30 +07:00
|
|
|
|
log.error(
|
|
|
|
|
|
{
|
|
|
|
|
|
error: error.message,
|
|
|
|
|
|
status: error.status,
|
2026-06-05 19:44:06 +07:00
|
|
|
|
rawResponse,
|
2026-06-06 15:38:36 +07:00
|
|
|
|
model,
|
2026-06-05 18:47:30 +07:00
|
|
|
|
},
|
2026-06-06 15:38:36 +07:00
|
|
|
|
"LLM API request failed",
|
2026-06-05 18:47:30 +07:00
|
|
|
|
);
|
|
|
|
|
|
throw error;
|
2026-06-05 18:45:58 +07:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-06-02 17:55:42 +07:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
retries,
|
2026-06-05 21:47:02 +07:00
|
|
|
|
minTimeout: 2_000,
|
|
|
|
|
|
maxTimeout: 30_000,
|
|
|
|
|
|
factor: 3,
|
2026-06-06 12:04:10 +07:00
|
|
|
|
signal,
|
2026-06-02 17:55:42 +07:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convenience for vision (image/sticker/emoji) analysis.
|
|
|
|
|
|
* Returns the raw completion content (trimmed) or null.
|
2026-07-31 23:09:00 +07:00
|
|
|
|
*
|
|
|
|
|
|
* NOTE: retries are disabled here on purpose — visionAnalyzer.ts already
|
|
|
|
|
|
* wraps this call in its own 3-attempt loop with exponential backoff.
|
|
|
|
|
|
* A second retry layer would multiply worst-case API calls (3×3=9/image).
|
2026-06-02 17:55:42 +07:00
|
|
|
|
*/
|
|
|
|
|
|
export async function llmVision(
|
|
|
|
|
|
promptText: string,
|
|
|
|
|
|
imageUrl: { url: string },
|
|
|
|
|
|
): Promise<string | null> {
|
|
|
|
|
|
const completion = await llmChat({
|
|
|
|
|
|
messages: [
|
|
|
|
|
|
{
|
|
|
|
|
|
role: "user",
|
|
|
|
|
|
content: [
|
|
|
|
|
|
{ type: "text" as const, text: promptText },
|
|
|
|
|
|
{ type: "image_url" as const, image_url: imageUrl },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL,
|
|
|
|
|
|
max_tokens: 500,
|
|
|
|
|
|
temperature: 0.1,
|
|
|
|
|
|
top_p: 0.9,
|
2026-07-31 23:09:00 +07:00
|
|
|
|
retries: 0,
|
2026-06-02 17:55:42 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!completion) return null;
|
|
|
|
|
|
return completion.choices[0]?.message?.content?.trim() ?? null;
|
|
|
|
|
|
}
|