diff --git a/apps/server/.env.example b/apps/server/.env.example index 9bea3b69a..dad73bd06 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -35,6 +35,10 @@ OPENROUTER_API_KEY=your-openrouter-api-key OPENROUTER_REFERER=http://localhost:3000 OPENROUTER_TITLE=AI Service +# MiniMax Configuration (OpenAI-compatible) +MINIMAX_API_KEY=your-minimax-api-key +MINIMAX_API_BASE=https://api.minimax.io/v1 + # JWT Configuration (ES256 - ECDSA P-256) # Generate keys with: openssl ecparam -name prime256v1 -genkey -noout -out private.pem # openssl ec -in private.pem -pubout -out public.pem diff --git a/apps/server/libs/ai-client/src/ai-client.module.ts b/apps/server/libs/ai-client/src/ai-client.module.ts index a05b37995..90ceff182 100644 --- a/apps/server/libs/ai-client/src/ai-client.module.ts +++ b/apps/server/libs/ai-client/src/ai-client.module.ts @@ -5,6 +5,7 @@ import { OpenAIProvider } from './providers/openai.provider.js'; import { ClaudeProvider } from './providers/claude.provider.js'; import { GeminiProvider } from './providers/gemini.provider.js'; import { OpenRouterProvider } from './providers/openrouter.provider.js'; +import { MinimaxProvider } from './providers/minimax.provider.js'; @Global() @Module({ @@ -14,6 +15,7 @@ import { OpenRouterProvider } from './providers/openrouter.provider.js'; ClaudeProvider, GeminiProvider, OpenRouterProvider, + MinimaxProvider, AiClientService, ], exports: [AiClientService], diff --git a/apps/server/libs/ai-client/src/ai-client.service.ts b/apps/server/libs/ai-client/src/ai-client.service.ts index 05435b79e..0384ec4c6 100644 --- a/apps/server/libs/ai-client/src/ai-client.service.ts +++ b/apps/server/libs/ai-client/src/ai-client.service.ts @@ -9,6 +9,7 @@ import { OpenAIProvider } from './providers/openai.provider.js'; import { ClaudeProvider } from './providers/claude.provider.js'; import { GeminiProvider } from './providers/gemini.provider.js'; import { OpenRouterProvider } from './providers/openrouter.provider.js'; +import { MinimaxProvider } from './providers/minimax.provider.js'; @Injectable() export class AiClientService { @@ -20,12 +21,14 @@ export class AiClientService { private readonly claudeProvider: ClaudeProvider, private readonly geminiProvider: GeminiProvider, private readonly openRouterProvider: OpenRouterProvider, + private readonly minimaxProvider: MinimaxProvider, ) { this.providers = new Map([ [AIProvider.OPENAI, this.openAIProvider], [AIProvider.CLAUDE, this.claudeProvider], [AIProvider.GEMINI, this.geminiProvider], [AIProvider.OPENROUTER, this.openRouterProvider], + [AIProvider.MINIMAX, this.minimaxProvider], ]); } diff --git a/apps/server/libs/ai-client/src/interfaces/ai-provider.interface.ts b/apps/server/libs/ai-client/src/interfaces/ai-provider.interface.ts index 3af21ec96..8d6a1b503 100644 --- a/apps/server/libs/ai-client/src/interfaces/ai-provider.interface.ts +++ b/apps/server/libs/ai-client/src/interfaces/ai-provider.interface.ts @@ -3,6 +3,7 @@ export enum AIProvider { CLAUDE = 'claude', GEMINI = 'gemini', OPENROUTER = 'openrouter', + MINIMAX = 'minimax', } // Claude (Anthropic) Models @@ -62,16 +63,26 @@ export type OpenRouterModel = | (typeof OPENROUTER_MODELS)[number] | (string & {}); +// MiniMax Models +export const MINIMAX_MODELS = ['MiniMax-M3', 'MiniMax-M2.7'] as const; +export type MinimaxModel = (typeof MINIMAX_MODELS)[number] | (string & {}); + // Model arrays by provider for runtime validation export const MODELS_BY_PROVIDER: Record = { [AIProvider.CLAUDE]: CLAUDE_MODELS, [AIProvider.OPENAI]: OPENAI_MODELS, [AIProvider.GEMINI]: GEMINI_MODELS, [AIProvider.OPENROUTER]: OPENROUTER_MODELS, + [AIProvider.MINIMAX]: MINIMAX_MODELS, }; // Union of all AI models -export type AIModel = ClaudeModel | OpenAIModel | GeminiModel | OpenRouterModel; +export type AIModel = + | ClaudeModel + | OpenAIModel + | GeminiModel + | OpenRouterModel + | MinimaxModel; // Type mapping: Provider -> Model type export type ModelForProvider = T extends AIProvider.CLAUDE @@ -82,7 +93,9 @@ export type ModelForProvider = T extends AIProvider.CLAUDE ? GeminiModel : T extends AIProvider.OPENROUTER ? OpenRouterModel - : string; + : T extends AIProvider.MINIMAX + ? MinimaxModel + : string; export interface AIMessage { role: 'system' | 'user' | 'assistant'; diff --git a/apps/server/libs/ai-client/src/providers/minimax.provider.ts b/apps/server/libs/ai-client/src/providers/minimax.provider.ts new file mode 100644 index 000000000..bf8909040 --- /dev/null +++ b/apps/server/libs/ai-client/src/providers/minimax.provider.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import OpenAI from 'openai'; +import { + IAIProviderAdapter, + AICompletionRequest, + AICompletionResponse, + AIProvider, + AIChatResponse, +} from '../interfaces/ai-provider.interface.js'; +import { ConfigService as DbConfigService } from '@team9/database'; + +@Injectable() +export class MinimaxProvider implements IAIProviderAdapter { + private readonly logger = new Logger(MinimaxProvider.name); + private client: OpenAI | null = null; + + constructor(private readonly configService: DbConfigService) {} + + private getClient(): OpenAI { + if (!this.client) { + const config = this.configService.getAIProviderConfig('minimax'); + const apiKey = config.apiKey; + + if (!apiKey) { + this.logger.warn( + 'MINIMAX_API_KEY is not configured in database or environment', + ); + } + + // MiniMax exposes an OpenAI-compatible chat completions API. + this.client = new OpenAI({ + apiKey: apiKey || 'dummy-key', + baseURL: config.baseURL || 'https://api.minimax.io/v1', + }); + } + return this.client; + } + + chat( + request: AICompletionRequest & { stream?: T }, + ): AIChatResponse { + if (request.stream) { + return this.streamChat(request) as AIChatResponse; + } + return this.blockingChat(request) as AIChatResponse; + } + + private async blockingChat( + request: AICompletionRequest, + ): Promise { + try { + const response = await this.getClient().chat.completions.create({ + model: request.model, + messages: request.messages, + temperature: request.temperature ?? 0.7, + max_tokens: request.maxTokens, + }); + + const choice = response.choices[0]; + return { + provider: AIProvider.MINIMAX, + model: request.model, + content: choice.message.content || '', + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + completionTokens: response.usage?.completion_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, + finishReason: choice.finish_reason || undefined, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error( + `MiniMax completion error: ${errorMessage}`, + errorStack, + ); + throw error; + } + } + + private async *streamChat( + request: AICompletionRequest, + ): AsyncGenerator { + try { + const stream = await this.getClient().chat.completions.create({ + model: request.model, + messages: request.messages, + temperature: request.temperature ?? 0.7, + max_tokens: request.maxTokens, + stream: true, + }); + + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content; + if (content) { + yield content; + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error(`MiniMax stream error: ${errorMessage}`, errorStack); + throw error; + } + } +} diff --git a/apps/server/libs/database/src/config-keys.ts b/apps/server/libs/database/src/config-keys.ts index 6ecaaf7be..f16882533 100644 --- a/apps/server/libs/database/src/config-keys.ts +++ b/apps/server/libs/database/src/config-keys.ts @@ -17,6 +17,10 @@ export enum ConfigKey { OPENROUTER_API_KEY = 'OPENROUTER_API_KEY', OPENROUTER_REFERER = 'OPENROUTER_REFERER', OPENROUTER_TITLE = 'OPENROUTER_TITLE', + + // MiniMax Configuration + MINIMAX_API_KEY = 'MINIMAX_API_KEY', + MINIMAX_API_BASE = 'MINIMAX_API_BASE', } /** @@ -58,4 +62,13 @@ export const ConfigMetadata: Record< description: 'OpenRouter X-Title header', isSecret: false, }, + [ConfigKey.MINIMAX_API_KEY]: { + description: 'MiniMax API key', + isSecret: true, + }, + [ConfigKey.MINIMAX_API_BASE]: { + description: 'MiniMax OpenAI-compatible API base URL', + isSecret: false, + defaultValue: 'https://api.minimax.io/v1', + }, }; diff --git a/apps/server/libs/database/src/config.service.spec.ts b/apps/server/libs/database/src/config.service.spec.ts new file mode 100644 index 000000000..a71fb4d09 --- /dev/null +++ b/apps/server/libs/database/src/config.service.spec.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from '@jest/globals'; +import { ConfigService } from './config.service.js'; + +describe('ConfigService MiniMax provider', () => { + afterEach(() => { + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_API_BASE; + }); + + it('reads the API key and defaults to the global API base URL', () => { + process.env.MINIMAX_API_KEY = 'test-minimax-key'; + const service = new ConfigService({} as never); + + expect(service.getAIProviderConfig('minimax')).toEqual({ + apiKey: 'test-minimax-key', + baseURL: 'https://api.minimax.io/v1', + }); + expect(service.isAIProviderConfigured('minimax')).toBe(true); + }); + + it('honors a custom API base URL from the environment', () => { + process.env.MINIMAX_API_KEY = 'test-minimax-key'; + process.env.MINIMAX_API_BASE = 'https://api.minimaxi.com/v1'; + const service = new ConfigService({} as never); + + expect(service.getAIProviderConfig('minimax')).toEqual({ + apiKey: 'test-minimax-key', + baseURL: 'https://api.minimaxi.com/v1', + }); + }); +}); diff --git a/apps/server/libs/database/src/config.service.ts b/apps/server/libs/database/src/config.service.ts index 8bac98c8c..edcb6c1fe 100644 --- a/apps/server/libs/database/src/config.service.ts +++ b/apps/server/libs/database/src/config.service.ts @@ -121,7 +121,9 @@ export class ConfigService { /** * Get AI provider configuration */ - getAIProviderConfig(provider: 'openai' | 'claude' | 'gemini' | 'openrouter') { + getAIProviderConfig( + provider: 'openai' | 'claude' | 'gemini' | 'openrouter' | 'minimax', + ) { switch (provider) { case 'openai': return { @@ -160,6 +162,17 @@ export class ConfigService { process.env.OPENROUTER_TITLE, ), }; + case 'minimax': + return { + apiKey: this.get( + ConfigKey.MINIMAX_API_KEY, + process.env.MINIMAX_API_KEY, + ), + baseURL: this.get( + ConfigKey.MINIMAX_API_BASE, + process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1', + ), + }; } } @@ -167,7 +180,7 @@ export class ConfigService { * Check if AI provider is configured */ isAIProviderConfigured( - provider: 'openai' | 'claude' | 'gemini' | 'openrouter', + provider: 'openai' | 'claude' | 'gemini' | 'openrouter' | 'minimax', ): boolean { const config = this.getAIProviderConfig(provider); return !!config.apiKey; diff --git a/apps/server/libs/shared/src/interfaces/ai.interface.ts b/apps/server/libs/shared/src/interfaces/ai.interface.ts index 919564860..d5988a4f4 100644 --- a/apps/server/libs/shared/src/interfaces/ai.interface.ts +++ b/apps/server/libs/shared/src/interfaces/ai.interface.ts @@ -10,7 +10,7 @@ export interface AIMessage { * AI Completion Request interface */ export interface AICompletionRequest { - provider: 'openai' | 'claude' | 'gemini' | 'openrouter'; + provider: 'openai' | 'claude' | 'gemini' | 'openrouter' | 'minimax'; model: string; messages: AIMessage[]; temperature?: number; @@ -41,4 +41,5 @@ export enum AIProvider { CLAUDE = 'claude', GEMINI = 'gemini', OPENROUTER = 'openrouter', + MINIMAX = 'minimax', }