diff --git a/README.md b/README.md index 9c11a47d7..2a823f566 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Want to know more about its architecture and how it works? You can read it [here ## ✨ Features -🤖 **Support for all major AI providers** - Use local LLMs through Ollama or connect to OpenAI, Anthropic Claude, Google Gemini, Groq, and more. Mix and match models based on your needs. +🤖 **Support for all major AI providers** - Use local LLMs through Ollama or connect to OpenAI, Anthropic Claude, Google Gemini, Groq, MiniMax, and more. Mix and match models based on your needs. ⚡ **Smart search modes** - Choose Speed Mode when you need quick answers, Balanced Mode for everyday searches, or Quality Mode for deep research. diff --git a/src/lib/models/providers/index.ts b/src/lib/models/providers/index.ts index cabfaa96f..007eaa221 100644 --- a/src/lib/models/providers/index.ts +++ b/src/lib/models/providers/index.ts @@ -8,6 +8,7 @@ import GroqProvider from './groq'; import LemonadeProvider from './lemonade'; import AnthropicProvider from './anthropic'; import LMStudioProvider from './lmstudio'; +import MiniMaxProvider from './minimax'; export const providers: Record> = { openai: OpenAIProvider, @@ -18,6 +19,7 @@ export const providers: Record> = { lemonade: LemonadeProvider, anthropic: AnthropicProvider, lmstudio: LMStudioProvider, + minimax: MiniMaxProvider, }; export const getModelProvidersUIConfigSection = diff --git a/src/lib/models/providers/minimax/index.ts b/src/lib/models/providers/minimax/index.ts new file mode 100644 index 000000000..3e3be5ce2 --- /dev/null +++ b/src/lib/models/providers/minimax/index.ts @@ -0,0 +1,111 @@ +import { UIConfigField } from '@/lib/config/types'; +import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry'; +import { Model, ModelList, ProviderMetadata } from '../../types'; +import BaseEmbedding from '../../base/embedding'; +import BaseModelProvider from '../../base/provider'; +import BaseLLM from '../../base/llm'; +import MiniMaxLLM from './miniMaxLLM'; + +interface MiniMaxConfig { + apiKey: string; + baseURL?: string; +} + +const DEFAULT_CHAT_MODELS: Model[] = [ + { key: 'MiniMax-M2.7', name: 'MiniMax M2.7' }, + { key: 'MiniMax-M2.5', name: 'MiniMax M2.5' }, + { key: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 High Speed' }, +]; + +const providerConfigFields: UIConfigField[] = [ + { + type: 'password', + name: 'API Key', + key: 'apiKey', + description: 'Your MiniMax API key', + required: true, + placeholder: 'MiniMax API Key', + env: 'MINIMAX_API_KEY', + scope: 'server', + }, + { + type: 'string', + name: 'Base URL', + key: 'baseURL', + description: 'MiniMax API base URL (default: https://api.minimax.io/v1)', + required: false, + placeholder: 'https://api.minimax.io/v1', + env: 'MINIMAX_BASE_URL', + scope: 'server', + }, +]; + +class MiniMaxProvider extends BaseModelProvider { + constructor(id: string, name: string, config: MiniMaxConfig) { + super(id, name, config); + } + + async getDefaultModels(): Promise { + return { + embedding: [], + chat: DEFAULT_CHAT_MODELS, + }; + } + + async getModelList(): Promise { + const defaultModels = await this.getDefaultModels(); + const configProvider = getConfiguredModelProviderById(this.id)!; + + return { + embedding: [], + chat: [...defaultModels.chat, ...configProvider.chatModels], + }; + } + + async loadChatModel(key: string): Promise> { + const modelList = await this.getModelList(); + + const exists = modelList.chat.find((m) => m.key === key); + + if (!exists) { + throw new Error( + 'Error Loading MiniMax Chat Model. Invalid Model Selected', + ); + } + + return new MiniMaxLLM({ + apiKey: this.config.apiKey, + model: key, + baseURL: this.config.baseURL || 'https://api.minimax.io/v1', + }); + } + + async loadEmbeddingModel(key: string): Promise> { + throw new Error('MiniMax provider does not support embedding models.'); + } + + static parseAndValidate(raw: any): MiniMaxConfig { + if (!raw || typeof raw !== 'object') + throw new Error('Invalid config provided. Expected object'); + if (!raw.apiKey) + throw new Error('Invalid config provided. API key must be provided'); + + return { + apiKey: String(raw.apiKey), + ...(raw.baseURL && { baseURL: String(raw.baseURL) }), + }; + } + + static getProviderConfigFields(): UIConfigField[] { + return providerConfigFields; + } + + static getProviderMetadata(): ProviderMetadata { + return { + key: 'minimax', + name: 'MiniMax', + }; + } +} + +export default MiniMaxProvider; diff --git a/src/lib/models/providers/minimax/miniMaxLLM.ts b/src/lib/models/providers/minimax/miniMaxLLM.ts new file mode 100644 index 000000000..094cf280e --- /dev/null +++ b/src/lib/models/providers/minimax/miniMaxLLM.ts @@ -0,0 +1,131 @@ +import OpenAILLM from '../openai/openaiLLM'; +import { + GenerateObjectInput, + GenerateOptions, + GenerateTextInput, + GenerateTextOutput, + StreamTextOutput, +} from '../../types'; +import z from 'zod'; +import { parse } from 'partial-json'; +import { repairJson } from '@toolsycc/json-repair'; + +class MiniMaxLLM extends OpenAILLM { + async generateText(input: GenerateTextInput): Promise { + const clampedInput = { + ...input, + options: this.clampTemperature(input.options), + }; + return super.generateText(clampedInput); + } + + async *streamText( + input: GenerateTextInput, + ): AsyncGenerator { + const clampedInput = { + ...input, + options: this.clampTemperature(input.options), + }; + yield* super.streamText(clampedInput); + } + + async generateObject(input: GenerateObjectInput): Promise { + const jsonSchema = z.toJSONSchema(input.schema); + const jsonPrompt = `You must respond with valid JSON only, no other text. The JSON must conform to this schema:\n${JSON.stringify(jsonSchema, null, 2)}`; + + const systemMessage = { role: 'system' as const, content: jsonPrompt }; + const messages = [systemMessage, ...input.messages]; + + const response = await this.openAIClient.chat.completions.create({ + model: this.config.model, + messages: this.convertToOpenAIMessages(messages), + temperature: + this.clampTemperature(input.options)?.temperature ?? + this.clampTemperature(this.config.options)?.temperature ?? + 1.0, + top_p: input.options?.topP ?? this.config.options?.topP, + max_completion_tokens: + input.options?.maxTokens ?? this.config.options?.maxTokens, + stop: input.options?.stopSequences ?? this.config.options?.stopSequences, + frequency_penalty: + input.options?.frequencyPenalty ?? + this.config.options?.frequencyPenalty, + presence_penalty: + input.options?.presencePenalty ?? this.config.options?.presencePenalty, + }); + + if (response.choices && response.choices.length > 0) { + try { + return input.schema.parse( + JSON.parse( + repairJson(response.choices[0].message.content!, { + extractJson: true, + }) as string, + ), + ) as T; + } catch (err) { + throw new Error(`Error parsing response from MiniMax: ${err}`); + } + } + + throw new Error('No response from MiniMax'); + } + + async *streamObject(input: GenerateObjectInput): AsyncGenerator { + const jsonSchema = z.toJSONSchema(input.schema); + const jsonPrompt = `You must respond with valid JSON only, no other text. The JSON must conform to this schema:\n${JSON.stringify(jsonSchema, null, 2)}`; + + const systemMessage = { role: 'system' as const, content: jsonPrompt }; + const messages = [systemMessage, ...input.messages]; + + let receivedObj = ''; + + const stream = await this.openAIClient.chat.completions.create({ + model: this.config.model, + messages: this.convertToOpenAIMessages(messages), + temperature: + this.clampTemperature(input.options)?.temperature ?? + this.clampTemperature(this.config.options)?.temperature ?? + 1.0, + top_p: input.options?.topP ?? this.config.options?.topP, + max_completion_tokens: + input.options?.maxTokens ?? this.config.options?.maxTokens, + stop: input.options?.stopSequences ?? this.config.options?.stopSequences, + frequency_penalty: + input.options?.frequencyPenalty ?? + this.config.options?.frequencyPenalty, + presence_penalty: + input.options?.presencePenalty ?? this.config.options?.presencePenalty, + stream: true, + }); + + for await (const chunk of stream) { + if (chunk.choices && chunk.choices.length > 0) { + const content = chunk.choices[0].delta.content || ''; + receivedObj += content; + + try { + yield parse(receivedObj) as T; + } catch { + yield {} as T; + } + } + } + } + + private clampTemperature( + options?: GenerateOptions, + ): GenerateOptions | undefined { + if (!options) return options; + if ( + options.temperature !== undefined && + options.temperature !== null && + options.temperature <= 0 + ) { + return { ...options, temperature: 0.01 }; + } + return options; + } +} + +export default MiniMaxLLM;