From 672ab223b22ee4ef7a3cbb332cff848b64d09285 Mon Sep 17 00:00:00 2001 From: melvinchia3636 Date: Tue, 2 Jun 2026 17:12:48 +0800 Subject: [PATCH] feat(server): refactor AI provider into pluggable architecture with deepseek, groq, ollama, openai support --- server/src/core/functions/external/ai.ts | 121 ------------------ .../src/core/functions/external/ai/index.ts | 83 ++++++++++++ .../ai/providers/deepseek.provider.ts | 94 ++++++++++++++ .../external/ai/providers/groq.provider.ts | 55 ++++++++ .../functions/external/ai/providers/index.ts | 23 ++++ .../external/ai/providers/ollama.provider.ts | 74 +++++++++++ .../external/ai/providers/openai.provider.ts | 75 +++++++++++ 7 files changed, 404 insertions(+), 121 deletions(-) delete mode 100644 server/src/core/functions/external/ai.ts create mode 100644 server/src/core/functions/external/ai/index.ts create mode 100644 server/src/core/functions/external/ai/providers/deepseek.provider.ts create mode 100644 server/src/core/functions/external/ai/providers/groq.provider.ts create mode 100644 server/src/core/functions/external/ai/providers/index.ts create mode 100644 server/src/core/functions/external/ai/providers/ollama.provider.ts create mode 100644 server/src/core/functions/external/ai/providers/openai.provider.ts diff --git a/server/src/core/functions/external/ai.ts b/server/src/core/functions/external/ai.ts deleted file mode 100644 index b6838b6fb..000000000 --- a/server/src/core/functions/external/ai.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { getAPIKey } from '@functions/database' -import { validateCallerAccess } from '@functions/database/getAPIKey' -import { createServiceLogger } from '@functions/logging' -import { zodTextFormat } from '@functions/utils/zodResponseFormat' -import chalk from 'chalk' -import Groq from 'groq-sdk' -import OpenAI from 'openai' - -import { - ClientError, - FetchAIFunc, - getCallerModuleId -} from '@lifeforge/server-utils' - -const logger = createServiceLogger('AI') - -const fetchAI: FetchAIFunc = async ({ - pb, - provider, - model, - messages, - structure -}) => { - if (structure && provider !== 'openai') { - throw new Error('Structure is only supported for OpenAI provider') - } - - const callerModule = getCallerModuleId() - - if (!callerModule) { - throw new Error('Unable to determine caller module for API key validation.') - } - - await validateCallerAccess(callerModule, provider) - - const apiKey = await getAPIKey(pb)(provider) - - if (!apiKey) { - throw new ClientError(`API key for ${provider} not found.`) - } - - logger.debug( - `${chalk.blue(callerModule)} is sending ${chalk.blue(messages.length)} message(s) to ${chalk.green( - model - )} on provider ${chalk.green(provider)} using model: ${chalk.green(model)}.` - ) - - if (provider === 'groq') { - const client = new Groq({ - apiKey - }) - - const response = await client.chat.completions.create({ - messages: messages as never, - model - }) - - const res = response.choices[0]?.message?.content - - if (!res) { - logger.error('No response received from Groq model.') - - return null - } - - logger.debug( - `Received response (${chalk.blue(res.length)} characters) from Groq model: ${chalk.green(model)}` - ) - - return res as any - } - - const client = new OpenAI({ - apiKey - }) - - if (structure) { - const completion = await client.responses.parse({ - model, - input: messages as never, - text: { - format: zodTextFormat(structure, 'response') - } - }) - - const parsedResponse = completion.output_parsed - - if (!parsedResponse) { - logger.error('No structured response received from OpenAI model.') - - return null - } - - logger.debug( - `Received structured response (${chalk.blue(Object.keys(parsedResponse).length)} fields) from OpenAI model: ${chalk.green(model)}` - ) - - return parsedResponse - } - - const response = await client.responses.create({ - input: messages as never, - model - }) - - const res = response.output_text - - if (!res) { - logger.error('No text response received from OpenAI model.') - - return null - } - - logger.debug( - `Received text response (${chalk.blue(res.length)} characters) from OpenAI model: ${chalk.green(model)}` - ) - - return res as any -} - -export default fetchAI diff --git a/server/src/core/functions/external/ai/index.ts b/server/src/core/functions/external/ai/index.ts new file mode 100644 index 000000000..920a0b81b --- /dev/null +++ b/server/src/core/functions/external/ai/index.ts @@ -0,0 +1,83 @@ +import { getAPIKey } from '@functions/database' +import { validateCallerAccess } from '@functions/database/getAPIKey' +import { createServiceLogger } from '@functions/logging' +import chalk from 'chalk' +import OpenAI from 'openai' +import z from 'zod' + +import { + CleanedSchemas, + ClientError, + FetchAIFunc, + IPBService, + getCallerModuleId +} from '@lifeforge/server-utils' + +import { getProvider, registerProvider } from './providers' +import { deepseekProvider } from './providers/deepseek.provider' +import { groqProvider } from './providers/groq.provider' +import { ollamaProvider } from './providers/ollama.provider' +import { openaiProvider } from './providers/openai.provider' + +const logger = createServiceLogger('AI') + +registerProvider('openai', openaiProvider) +registerProvider('groq', groqProvider) +registerProvider('ollama', ollamaProvider) +registerProvider('deepseek', deepseekProvider) + +async function fetchAI({ + pb, + provider, + model, + messages, + structure +}: { + pb: IPBService + provider: 'groq' | 'openai' | 'ollama' | 'deepseek' + model: string + messages: OpenAI.ChatCompletionMessageParam[] + structure?: T +}): Promise<(T extends z.ZodTypeAny ? z.infer : string) | null> { + const callerModule = getCallerModuleId() + + if (!callerModule) { + throw new Error('Unable to determine caller module for API key validation.') + } + + const aiProvider = getProvider(provider) + + if (!aiProvider) { + throw new ClientError(`AI provider ${provider} is not registered.`) + } + + let apiKey: string | undefined + + if (aiProvider.requireAPIKey) { + await validateCallerAccess(callerModule, provider) + + const fetchedKey = await getAPIKey(pb, callerModule)(provider) + + if (!fetchedKey) { + throw new ClientError(`API key for ${provider} not found.`) + } + apiKey = fetchedKey + } + + logger.debug( + `${chalk.blue(callerModule)} is sending ${chalk.blue(messages.length)} message(s) to ${chalk.green( + model + )} on provider ${chalk.green(provider)} using model: ${chalk.green(model)}.` + ) + + return aiProvider.fetch({ + model, + messages, + apiKey, + structure + }) +} + +export { getProvider, registerProvider } from './providers' + +export default fetchAI as FetchAIFunc diff --git a/server/src/core/functions/external/ai/providers/deepseek.provider.ts b/server/src/core/functions/external/ai/providers/deepseek.provider.ts new file mode 100644 index 000000000..b15aec600 --- /dev/null +++ b/server/src/core/functions/external/ai/providers/deepseek.provider.ts @@ -0,0 +1,94 @@ +import { createServiceLogger } from '@functions/logging' +import chalk from 'chalk' +import OpenAI from 'openai' +import z from 'zod' + +import { AIProvider } from './index' + +const logger = createServiceLogger('AI') + +async function fetchDeepSeek< + T extends z.ZodTypeAny | undefined = undefined +>(params: { + model: string + messages: OpenAI.ChatCompletionMessageParam[] + apiKey?: string + structure?: T +}): Promise<(T extends z.ZodTypeAny ? z.infer : string) | null> { + const { model, messages, apiKey, structure } = params + + if (!apiKey) { + throw new Error('API key is required for DeepSeek provider') + } + + const client = new OpenAI({ + baseURL: 'https://api.deepseek.com', + apiKey + }) + + if (structure) { + const systemPrompt = `You must return your response as a JSON object strictly matching this schema. Do not output any markdown formatting, wrappers, or text outside the JSON object. + +JSON Schema: +${JSON.stringify(structure)}` + + const response = await client.chat.completions.create({ + model, + messages: [ + { + role: 'system', + content: systemPrompt + }, + ...messages + ] as any, + response_format: { type: 'json_object' } + }) + + const res = response.choices[0]?.message?.content + + if (!res) { + logger.error('No structured response received from DeepSeek model.') + + return null + } + + try { + const parsedResponse = JSON.parse(res) + + logger.debug( + `Received structured response from DeepSeek model: ${chalk.green(model)}` + ) + + return parsedResponse as T extends z.ZodTypeAny ? z.infer : string + } catch (e) { + logger.error('Failed to parse JSON response from DeepSeek model: ' + res) + + return null + } + } + + const response = await client.chat.completions.create({ + model, + messages: messages as any + }) + + const res = response.choices[0]?.message?.content + + if (!res) { + logger.error('No text response received from DeepSeek model.') + + return null + } + + logger.debug( + `Received text response (${chalk.blue(res.length)} characters) from DeepSeek model: ${chalk.green(model)}` + ) + + return res as T extends z.ZodTypeAny ? z.infer : string +} + +export const deepseekProvider: AIProvider = { + name: 'deepseek', + requireAPIKey: true, + fetch: fetchDeepSeek +} diff --git a/server/src/core/functions/external/ai/providers/groq.provider.ts b/server/src/core/functions/external/ai/providers/groq.provider.ts new file mode 100644 index 000000000..e4b6780f8 --- /dev/null +++ b/server/src/core/functions/external/ai/providers/groq.provider.ts @@ -0,0 +1,55 @@ +import { createServiceLogger } from '@functions/logging' +import chalk from 'chalk' +import Groq from 'groq-sdk' +import OpenAI from 'openai' +import z from 'zod' + +import { AIProvider } from './index' + +const logger = createServiceLogger('AI') + +async function fetchGroq< + T extends z.ZodTypeAny | undefined = undefined +>(params: { + model: string + messages: OpenAI.ChatCompletionMessageParam[] + apiKey?: string + structure?: T +}): Promise<(T extends z.ZodTypeAny ? z.infer : string) | null> { + const { model, messages, apiKey, structure } = params + + if (!apiKey) { + throw new Error('API key is required for Groq provider') + } + + if (structure) { + throw new Error('Structure is only supported for OpenAI provider') + } + + const client = new Groq({ apiKey }) + + const response = await client.chat.completions.create({ + messages: messages as never, + model + }) + + const res = response.choices[0]?.message?.content + + if (!res) { + logger.error('No response received from Groq model.') + + return null + } + + logger.debug( + `Received response (${chalk.blue(res.length)} characters) from Groq model: ${chalk.green(model)}` + ) + + return res as T extends z.ZodTypeAny ? z.infer : string +} + +export const groqProvider: AIProvider = { + name: 'groq', + requireAPIKey: true, + fetch: fetchGroq +} diff --git a/server/src/core/functions/external/ai/providers/index.ts b/server/src/core/functions/external/ai/providers/index.ts new file mode 100644 index 000000000..de8bb5340 --- /dev/null +++ b/server/src/core/functions/external/ai/providers/index.ts @@ -0,0 +1,23 @@ +import OpenAI from 'openai' +import z from 'zod' + +export interface AIProvider { + name: string + requireAPIKey: boolean + fetch(params: { + model: string + messages: OpenAI.ChatCompletionMessageParam[] + apiKey?: string + structure?: T + }): Promise<(T extends z.ZodTypeAny ? z.infer : string) | null> +} + +const registry = new Map() + +export function registerProvider(name: string, provider: AIProvider): void { + registry.set(name, provider) +} + +export function getProvider(name: string): AIProvider | undefined { + return registry.get(name) +} diff --git a/server/src/core/functions/external/ai/providers/ollama.provider.ts b/server/src/core/functions/external/ai/providers/ollama.provider.ts new file mode 100644 index 000000000..d9143b98b --- /dev/null +++ b/server/src/core/functions/external/ai/providers/ollama.provider.ts @@ -0,0 +1,74 @@ +import { createServiceLogger } from '@functions/logging' +import { zodTextFormat } from '@functions/utils/zodResponseFormat' +import chalk from 'chalk' +import OpenAI from 'openai' +import z from 'zod' + +import { AIProvider } from './index' + +const logger = createServiceLogger('AI') + +async function fetchOllama< + T extends z.ZodTypeAny | undefined = undefined +>(params: { + model: string + messages: OpenAI.ChatCompletionMessageParam[] + apiKey?: string + structure?: T +}): Promise<(T extends z.ZodTypeAny ? z.infer : string) | null> { + const { model, messages, apiKey, structure } = params + + const client = new OpenAI({ + baseURL: 'http://localhost:11434/v1', + apiKey: apiKey || 'ollama' + }) + + if (structure) { + const completion = await client.responses.parse({ + model, + input: messages as never, + text: { + format: zodTextFormat(structure, 'response') + } + }) + + const parsedResponse = completion.output_parsed + + if (!parsedResponse) { + logger.error('No structured response received from Ollama model.') + + return null + } + + logger.debug( + `Received structured response (${chalk.blue(Object.keys(parsedResponse).length)} fields) from Ollama model: ${chalk.green(model)}` + ) + + return parsedResponse as T extends z.ZodTypeAny ? z.infer : string + } + + const response = await client.responses.create({ + input: messages as never, + model + }) + + const res = response.output_text + + if (!res) { + logger.error('No text response received from Ollama model.') + + return null + } + + logger.debug( + `Received text response (${chalk.blue(res.length)} characters) from Ollama model: ${chalk.green(model)}` + ) + + return res as T extends z.ZodTypeAny ? z.infer : string +} + +export const ollamaProvider: AIProvider = { + name: 'ollama', + requireAPIKey: false, + fetch: fetchOllama +} diff --git a/server/src/core/functions/external/ai/providers/openai.provider.ts b/server/src/core/functions/external/ai/providers/openai.provider.ts new file mode 100644 index 000000000..b9daa3efb --- /dev/null +++ b/server/src/core/functions/external/ai/providers/openai.provider.ts @@ -0,0 +1,75 @@ +import { createServiceLogger } from '@functions/logging' +import { zodTextFormat } from '@functions/utils/zodResponseFormat' +import chalk from 'chalk' +import OpenAI from 'openai' +import z from 'zod' + +import { AIProvider } from './index' + +const logger = createServiceLogger('AI') + +async function fetchOpenAI< + T extends z.ZodTypeAny | undefined = undefined +>(params: { + model: string + messages: OpenAI.ChatCompletionMessageParam[] + apiKey?: string + structure?: T +}): Promise<(T extends z.ZodTypeAny ? z.infer : string) | null> { + const { model, messages, apiKey, structure } = params + + if (!apiKey) { + throw new Error('API key is required for OpenAI provider') + } + + const client = new OpenAI({ apiKey }) + + if (structure) { + const completion = await client.responses.parse({ + model, + input: messages as never, + text: { + format: zodTextFormat(structure, 'response') + } + }) + + const parsedResponse = completion.output_parsed + + if (!parsedResponse) { + logger.error('No structured response received from OpenAI model.') + + return null + } + + logger.debug( + `Received structured response (${chalk.blue(Object.keys(parsedResponse).length)} fields) from OpenAI model: ${chalk.green(model)}` + ) + + return parsedResponse as T extends z.ZodTypeAny ? z.infer : string + } + + const response = await client.responses.create({ + input: messages as never, + model + }) + + const res = response.output_text + + if (!res) { + logger.error('No text response received from OpenAI model.') + + return null + } + + logger.debug( + `Received text response (${chalk.blue(res.length)} characters) from OpenAI model: ${chalk.green(model)}` + ) + + return res as T extends z.ZodTypeAny ? z.infer : string +} + +export const openaiProvider: AIProvider = { + name: 'openai', + requireAPIKey: true, + fetch: fetchOpenAI +}