mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-28 06:46:24 +00:00
feat(server): refactor AI provider into pluggable architecture with deepseek, groq, ollama, openai support
This commit is contained in:
121
server/src/core/functions/external/ai.ts
vendored
121
server/src/core/functions/external/ai.ts
vendored
@@ -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
|
||||
83
server/src/core/functions/external/ai/index.ts
vendored
Normal file
83
server/src/core/functions/external/ai/index.ts
vendored
Normal file
@@ -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<T extends z.ZodTypeAny | undefined = undefined>({
|
||||
pb,
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
structure
|
||||
}: {
|
||||
pb: IPBService<CleanedSchemas>
|
||||
provider: 'groq' | 'openai' | 'ollama' | 'deepseek'
|
||||
model: string
|
||||
messages: OpenAI.ChatCompletionMessageParam[]
|
||||
structure?: T
|
||||
}): Promise<(T extends z.ZodTypeAny ? z.infer<T> : 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
|
||||
94
server/src/core/functions/external/ai/providers/deepseek.provider.ts
vendored
Normal file
94
server/src/core/functions/external/ai/providers/deepseek.provider.ts
vendored
Normal file
@@ -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<T> : 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<T> : 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<T> : string
|
||||
}
|
||||
|
||||
export const deepseekProvider: AIProvider = {
|
||||
name: 'deepseek',
|
||||
requireAPIKey: true,
|
||||
fetch: fetchDeepSeek
|
||||
}
|
||||
55
server/src/core/functions/external/ai/providers/groq.provider.ts
vendored
Normal file
55
server/src/core/functions/external/ai/providers/groq.provider.ts
vendored
Normal file
@@ -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<T> : 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<T> : string
|
||||
}
|
||||
|
||||
export const groqProvider: AIProvider = {
|
||||
name: 'groq',
|
||||
requireAPIKey: true,
|
||||
fetch: fetchGroq
|
||||
}
|
||||
23
server/src/core/functions/external/ai/providers/index.ts
vendored
Normal file
23
server/src/core/functions/external/ai/providers/index.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import OpenAI from 'openai'
|
||||
import z from 'zod'
|
||||
|
||||
export interface AIProvider {
|
||||
name: string
|
||||
requireAPIKey: boolean
|
||||
fetch<T extends z.ZodTypeAny | undefined = undefined>(params: {
|
||||
model: string
|
||||
messages: OpenAI.ChatCompletionMessageParam[]
|
||||
apiKey?: string
|
||||
structure?: T
|
||||
}): Promise<(T extends z.ZodTypeAny ? z.infer<T> : string) | null>
|
||||
}
|
||||
|
||||
const registry = new Map<string, AIProvider>()
|
||||
|
||||
export function registerProvider(name: string, provider: AIProvider): void {
|
||||
registry.set(name, provider)
|
||||
}
|
||||
|
||||
export function getProvider(name: string): AIProvider | undefined {
|
||||
return registry.get(name)
|
||||
}
|
||||
74
server/src/core/functions/external/ai/providers/ollama.provider.ts
vendored
Normal file
74
server/src/core/functions/external/ai/providers/ollama.provider.ts
vendored
Normal file
@@ -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<T> : 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<T> : 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<T> : string
|
||||
}
|
||||
|
||||
export const ollamaProvider: AIProvider = {
|
||||
name: 'ollama',
|
||||
requireAPIKey: false,
|
||||
fetch: fetchOllama
|
||||
}
|
||||
75
server/src/core/functions/external/ai/providers/openai.provider.ts
vendored
Normal file
75
server/src/core/functions/external/ai/providers/openai.provider.ts
vendored
Normal file
@@ -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<T> : 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<T> : 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<T> : string
|
||||
}
|
||||
|
||||
export const openaiProvider: AIProvider = {
|
||||
name: 'openai',
|
||||
requireAPIKey: true,
|
||||
fetch: fetchOpenAI
|
||||
}
|
||||
Reference in New Issue
Block a user