import OpenAI from "openai"; export interface LLMClientConfig { baseURL?: string; apiKey?: string; model: string; } export interface LLMResult { content: string; // Provider finish reason: "stop" (complete), "length" (truncated by // max_tokens — yields incomplete JSON), "content_filter", etc. The parse // stage uses this to retry truncated github-trending output. finishReason: string | null; } export class LLMClient { private client: OpenAI; private model: string; constructor(config: LLMClientConfig) { this.client = new OpenAI({ baseURL: config.baseURL || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1", apiKey: config.apiKey || process.env.OPENAI_API_KEY, }); this.model = config.model; } async chat(systemPrompt: string, userMessage: string): Promise { const response = await this.client.chat.completions.create({ model: this.model, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userMessage }, ], temperature: 0.3, response_format: { type: "json_object" }, // github-trending with a large trending list (many repos × verbose // intro/narration) can exceed 8k output tokens and get truncated mid-JSON, // breaking parsing. 16384 gives headroom. (The gateway clamps rather than // errors on an oversized value, and the parse stage additionally retries // once with a conciseness nudge if finish_reason="length".) max_tokens: 16384, }); const choice = response.choices[0]; const content = choice?.message?.content; if (!content) throw new Error("LLM returned empty response"); return { content, finishReason: choice?.finish_reason ?? null }; } }