Files
GEOAgentArticleOptimizer/src/lib/llm/client.ts
T

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}`);
}