275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
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<T> extends GenerateInput {
|
|
schema: z.ZodType<T>;
|
|
}
|
|
|
|
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<ChatCompletionResult>)
|
|
| null = null;
|
|
|
|
export function setChatCompletionForTesting(
|
|
handler: ((request: ChatCompletionRequest) => Promise<ChatCompletionResult>) | 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(".") : "<root>";
|
|
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<T>(input: GenerateInput): Promise<T> {
|
|
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: <T>(input: GenerateInput) => Promise<T> =
|
|
generateJson;
|
|
|
|
export function setGenerateJsonForValidation(
|
|
generator: typeof generateJsonForValidation,
|
|
) {
|
|
generateJsonForValidation = generator;
|
|
}
|
|
|
|
export async function generateValidatedJson<T>({
|
|
schema,
|
|
...input
|
|
}: GenerateValidatedJsonInput<T>): Promise<T> {
|
|
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<unknown>(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}`);
|
|
}
|