diff --git a/src/services/llm/llm.service.ts b/src/services/llm/llm.service.ts new file mode 100644 index 0000000..54772ad --- /dev/null +++ b/src/services/llm/llm.service.ts @@ -0,0 +1,87 @@ +import { env } from '../../config/env.js'; + +export interface LlmConfig { + baseUrl: string; + model: string; + apiKey?: string; + timeoutMs: number; + temperature: number; + maxTokens: number; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface ChatCompletionResponse { + choices: Array<{ + message?: { content: string }; + text?: string; + }>; +} + +export class LlmService { + private readonly config: LlmConfig; + + constructor(config?: Partial) { + this.config = { + baseUrl: config?.baseUrl ?? env.LLM_BASE_URL, + model: config?.model ?? env.LLM_MODEL, + apiKey: config?.apiKey ?? env.LLM_API_KEY, + timeoutMs: config?.timeoutMs ?? env.LLM_TIMEOUT_MS, + temperature: config?.temperature ?? env.LLM_TEMPERATURE, + maxTokens: config?.maxTokens ?? env.LLM_MAX_TOKENS, + }; + } + + async chat(messages: ChatMessage[]): Promise { + const url = `${this.config.baseUrl.replace(/\/$/, '')}/chat/completions`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.config.apiKey) { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } + + const body = { + model: this.config.model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + temperature: this.config.temperature, + max_tokens: this.config.maxTokens, + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs); + + try { + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`LLM request failed: ${res.status} ${res.statusText} - ${text}`); + } + + const data = (await res.json()) as ChatCompletionResponse; + + const choice = data.choices?.[0]; + const content = choice?.message?.content ?? choice?.text ?? ''; + + return content.trim(); + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error) { + throw err; + } + throw new Error('LLM request failed'); + } + } +}