feat(server): refactor AI provider into pluggable architecture with deepseek, groq, ollama, openai support

This commit is contained in:
melvinchia3636
2026-06-02 17:12:48 +08:00
parent a09c1ca272
commit 672ab223b2
7 changed files with 404 additions and 121 deletions

View File

@@ -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

View 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

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}