From edcd0f096328f96aa20dd010607e31d30c0811a4 Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Fri, 17 Oct 2025 16:29:22 +0300 Subject: [PATCH] add llm service --- .env.example | 19 +++ server/src/config/env.ts | 8 ++ server/src/services/llm-service.ts | 186 +++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 .env.example create mode 100644 server/src/services/llm-service.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b95e168 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Application +APP_NAME= + +# Server +SERVER_PORT= +API_BASE_PATH= +DATABASE_URL= +URL_READER_API_KEY= +# URL_READER_BASE_URL=https://r.jina.ai + +LLM_API_URL= +LLM_API_KEY= +LLM_MODEL= + +# Client +CLIENT_PORT= +API_PROXY_TARGET= +VITE_APP_NAME= +VITE_API_BASE_URL= diff --git a/server/src/config/env.ts b/server/src/config/env.ts index 69d8d01..02934f4 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -14,6 +14,9 @@ const urlReaderBaseUrl = (process.env.URL_READER_BASE_URL ?? 'https://r.jina.ai' '' ); const urlReaderApiKey = process.env.URL_READER_API_KEY ?? ''; +const llmApiUrl = process.env.LLM_API_URL ?? ''; +const llmApiKey = process.env.LLM_API_KEY ?? ''; +const llmModel = process.env.LLM_MODEL ?? 'gpt-4o-mini'; export const env = { port, @@ -23,5 +26,10 @@ export const env = { urlReader: { baseUrl: urlReaderBaseUrl, apiKey: urlReaderApiKey + }, + llm: { + apiUrl: llmApiUrl, + apiKey: llmApiKey, + model: llmModel } }; diff --git a/server/src/services/llm-service.ts b/server/src/services/llm-service.ts new file mode 100644 index 0000000..7cee4f9 --- /dev/null +++ b/server/src/services/llm-service.ts @@ -0,0 +1,186 @@ +import { env } from '../config/env.js'; +import { HttpError } from '../errors/http-error.js'; + +export interface LlmResponseSchema { + name: string; + schema: Record; +} + +export interface LlmStructuredRequest { + systemPrompt: string; + prompt: string; + responseSchema: LlmResponseSchema; +} + +const extractStructuredContent = (payload: unknown): T => { + if (!payload || typeof payload !== 'object') { + throw new HttpError(502, 'LLM provider returned an unexpected payload.'); + } + + const maybeChoices = (payload as { choices?: unknown[] }).choices; + if (!Array.isArray(maybeChoices) || maybeChoices.length === 0) { + throw new HttpError(502, 'LLM provider did not return any choices.'); + } + + const firstChoice = maybeChoices[0]; + if (!firstChoice || typeof firstChoice !== 'object') { + throw new HttpError(502, 'LLM provider returned a malformed choice.'); + } + + const message = (firstChoice as { message?: unknown }).message; + if (!message || typeof message !== 'object') { + throw new HttpError(502, 'LLM provider did not return a message.'); + } + + const parsed = (message as { parsed?: unknown }).parsed; + if (parsed !== undefined) { + return parsed as T; + } + + const content = (message as { content?: unknown }).content; + if (typeof content === 'string') { + try { + return JSON.parse(content) as T; + } catch { + throw new HttpError( + 502, + 'LLM provider returned content but it was not valid JSON as required by the schema.' + ); + } + } + + if (Array.isArray(content)) { + const combined = content + .map((part) => { + if (!part || typeof part !== 'object') { + return ''; + } + + const text = (part as { text?: unknown }).text; + return typeof text === 'string' ? text : ''; + }) + .join('') + .trim(); + + if (!combined) { + throw new HttpError(502, 'LLM provider returned empty content.'); + } + + try { + return JSON.parse(combined) as T; + } catch { + throw new HttpError( + 502, + 'LLM provider returned content parts but they were not valid JSON as required by the schema.' + ); + } + } + + throw new HttpError(502, 'LLM provider response did not contain structured content.'); +}; + +export const requestStructuredLlmResponse = async ({ + systemPrompt, + prompt, + responseSchema +}: LlmStructuredRequest): Promise => { + if (typeof fetch !== 'function') { + throw new HttpError(500, 'LLM service requires fetch support but it is unavailable.'); + } + + if (!env.llm.apiUrl || !env.llm.apiKey) { + throw new HttpError(500, 'LLM service is not configured.'); + } + + if (!responseSchema?.name?.trim()) { + throw new HttpError(500, 'Response schema must define a non-empty name.'); + } + + if ( + !responseSchema.schema || + typeof responseSchema.schema !== 'object' || + Array.isArray(responseSchema.schema) + ) { + throw new HttpError(500, 'Response schema must be a JSON schema object.'); + } + + const requestBody = { + model: env.llm.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ], + response_format: { + type: 'json_schema', + json_schema: { + name: responseSchema.name, + schema: responseSchema.schema + } + } + }; + + let response: Awaited>; + try { + response = await fetch(env.llm.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.llm.apiKey}` + }, + body: JSON.stringify(requestBody) + }); + } catch (error) { + throw new HttpError( + 502, + 'We could not reach the LLM service. Please try again later.', + error instanceof Error ? { message: error.message } : undefined + ); + } + + if (!response.ok) { + let errorDetails: unknown; + try { + errorDetails = await response.json(); + } catch { + errorDetails = await response.text(); + } + + throw new HttpError( + 502, + 'The LLM service returned an unexpected response. Please try again later.', + errorDetails + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new HttpError(502, 'LLM provider returned a non-JSON response.'); + } + + return extractStructuredContent(payload); +}; + +/* +example: + +const response = await requestStructuredLlmResponse({ + systemPrompt: 'You are a helpful assistant.', + prompt: 'What is the capital of France?', + responseSchema: { + name: 'capital', + schema: { + type: 'object', + properties: { + capital: { + type: 'string' + } + }, + required: ['capital'] + } + } +}); + +console.log(response); // { capital: 'Paris' } + */