import OpenAI from "openai"; import type { z } from "zod"; export type LlmTaskName = | "unknown" | "fact_extractor" | "article_optimizer" | "quality_inspector" | "targeted_rewriter" | "renwei_copy_optimizer"; export interface GenerateInput { system?: string; prompt: string; model?: string; temperature?: number; task?: LlmTaskName; } export interface GenerateValidatedJsonInput extends GenerateInput { schema: z.ZodType; } export interface LlmProviderStatus { provider: "deepseek" | "openai"; configured: boolean; model: string; baseURL?: string; reason?: string; } export class LlmValidationError extends Error { constructor( message: string, public readonly task: LlmTaskName, ) { super(message); this.name = "LlmValidationError"; } } function getProvider() { return (process.env.LLM_PROVIDER || "deepseek").toLowerCase(); } export function getLlmProviderStatus(): LlmProviderStatus { const provider = getProvider(); if (provider === "openai") { return { provider: "openai", configured: Boolean(process.env.OPENAI_API_KEY), model: process.env.OPENAI_MODEL || "gpt-4.1-mini", reason: process.env.OPENAI_API_KEY ? undefined : "OPENAI_API_KEY is missing", }; } return { provider: "deepseek", configured: Boolean(process.env.DEEPSEEK_API_KEY), model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro", baseURL: process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com", reason: process.env.DEEPSEEK_API_KEY ? undefined : "DEEPSEEK_API_KEY is missing", }; } export function isLlmConfigured() { return getLlmProviderStatus().configured; } interface ChatCompletionResult { choices: Array<{ message: { content?: string | null } }>; } type ChatCompletionRequest = { model: string; temperature: number; response_format?: { type: "json_object" }; messages: Array<{ role: "system" | "user"; content: string }>; }; let chatCompletionForTesting: | ((request: ChatCompletionRequest) => Promise) | null = null; export function setChatCompletionForTesting( handler: ((request: ChatCompletionRequest) => Promise) | null, ) { chatCompletionForTesting = handler; } function getTask(input: GenerateInput): LlmTaskName { return input.task || "unknown"; } function getRawLogLimit() { const parsed = Number(process.env.LLM_LOG_RAW_LIMIT ?? "4000"); return Number.isFinite(parsed) && parsed >= 0 ? parsed : 4000; } function stringifyForLog(value: unknown) { return typeof value === "string" ? value : JSON.stringify(value); } function truncateRaw(value: string, maxLength = getRawLogLimit()) { return value.length > maxLength ? `${value.slice(0, maxLength)}...[truncated ${value.length - maxLength} chars]` : value; } function summarizeZodError(error: z.ZodError) { return error.issues .slice(0, 5) .map((issue) => { const path = issue.path.length > 0 ? issue.path.join(".") : ""; return `${path}: ${issue.message}`; }) .join("; "); } function quoteLogValue(value: string) { return JSON.stringify(value); } function createClient() { const status = getLlmProviderStatus(); if (!status.configured) { throw new Error(status.reason ?? "LLM provider is not configured"); } if (status.provider === "openai") { return { client: new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), model: status.model, }; } return { client: new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: status.baseURL, }), model: status.model, }; } export async function generateText(input: GenerateInput) { try { const { client, model } = createClient(); const response = await client.chat.completions.create({ model: input.model ?? model, temperature: input.temperature ?? 0.2, messages: [ ...(input.system ? [{ role: "system" as const, content: input.system }] : []), { role: "user" as const, content: input.prompt }, ], }); return response.choices[0]?.message.content ?? ""; } catch (error) { throw normalizeLlmError(error); } } export async function generateJson(input: GenerateInput): Promise { const task = getTask(input); const startedAt = Date.now(); try { const status = getLlmProviderStatus(); if (!status.configured) { throw new Error(status.reason ?? "LLM provider is not configured"); } const effectiveModel = input.model ?? status.model; console.info( `[llm:start] provider=${status.provider} model=${effectiveModel} task=${task}`, ); const request = { model: effectiveModel, temperature: input.temperature ?? 0.1, response_format: { type: "json_object" }, messages: [ ...(input.system ? [{ role: "system" as const, content: input.system }] : []), { role: "user" as const, content: input.prompt }, ], } satisfies ChatCompletionRequest; const response = chatCompletionForTesting ? await chatCompletionForTesting(request) : await createClient().client.chat.completions.create(request); const content = response.choices[0]?.message.content ?? "{}"; console.info( `[llm:response] task=${task} duration_ms=${Date.now() - startedAt} raw=${truncateRaw(content)}`, ); return JSON.parse(content) as T; } catch (error) { const normalized = normalizeLlmError(error); console.error( `[llm:error] task=${task} duration_ms=${Date.now() - startedAt} message=${quoteLogValue(normalized.message)}`, ); throw normalized; } } export let generateJsonForValidation: (input: GenerateInput) => Promise = generateJson; export function setGenerateJsonForValidation( generator: typeof generateJsonForValidation, ) { generateJsonForValidation = generator; } export async function generateValidatedJson({ schema, ...input }: GenerateValidatedJsonInput): Promise { const task = getTask(input); if (!isLlmConfigured()) { console.info(`[llm:validated] task=${task} ok=false reason=not_configured`); throw new LlmValidationError( getLlmProviderStatus().reason ?? "LLM provider is not configured", task, ); } const usesDefaultGenerator = generateJsonForValidation === generateJson; const status = getLlmProviderStatus(); const startedAt = Date.now(); if (!usesDefaultGenerator) { console.info( `[llm:start] provider=${status.provider} model=${input.model ?? status.model} task=${task}`, ); } try { const generated = await generateJsonForValidation(input); if (!usesDefaultGenerator) { console.info( `[llm:response] task=${task} duration_ms=${Date.now() - startedAt} raw=${truncateRaw(stringifyForLog(generated))}`, ); } const parsed = schema.safeParse(generated); if (parsed.success) { console.info(`[llm:validated] task=${task} ok=true`); return parsed.data; } console.warn( `[llm:validated] task=${task} ok=false zod_error=${quoteLogValue(summarizeZodError(parsed.error))}`, ); throw new LlmValidationError( `LLM response failed schema validation: ${summarizeZodError(parsed.error)}`, task, ); } catch (error) { if (error instanceof LlmValidationError) { throw error; } console.info(`[llm:validated] task=${task} ok=false reason=provider_error`); const message = error instanceof Error ? error.message : String(error); console.warn( usesDefaultGenerator ? `[llm:error] task=${task} message=${quoteLogValue(message)}` : `[llm:error] task=${task} duration_ms=${Date.now() - startedAt} message=${quoteLogValue(message)}`, ); throw error instanceof Error ? error : new Error(message); } } function normalizeLlmError(error: unknown) { const message = error instanceof Error ? error.message : "Unknown LLM error"; return new Error(`LLM provider error: ${message}`); }