mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
add llm service
This commit is contained in:
@@ -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=
|
||||||
@@ -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 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 = {
|
export const env = {
|
||||||
port,
|
port,
|
||||||
@@ -23,5 +26,10 @@ export const env = {
|
|||||||
urlReader: {
|
urlReader: {
|
||||||
baseUrl: urlReaderBaseUrl,
|
baseUrl: urlReaderBaseUrl,
|
||||||
apiKey: urlReaderApiKey
|
apiKey: urlReaderApiKey
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
apiUrl: llmApiUrl,
|
||||||
|
apiKey: llmApiKey,
|
||||||
|
model: llmModel
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
|
||||||
|
export interface LlmResponseSchema {
|
||||||
|
name: string;
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmStructuredRequest {
|
||||||
|
systemPrompt: string;
|
||||||
|
prompt: string;
|
||||||
|
responseSchema: LlmResponseSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractStructuredContent = <T>(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 <T>({
|
||||||
|
systemPrompt,
|
||||||
|
prompt,
|
||||||
|
responseSchema
|
||||||
|
}: LlmStructuredRequest): Promise<T> => {
|
||||||
|
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<ReturnType<typeof fetch>>;
|
||||||
|
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<T>(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' }
|
||||||
|
*/
|
||||||
Reference in New Issue
Block a user