From 6ea0f6788f812ed505f7451eb2fee0cd9e7bd1f8 Mon Sep 17 00:00:00 2001 From: PR Bot Date: Fri, 20 Mar 2026 18:22:12 +0800 Subject: [PATCH] feat: add MiniMax as LLM provider Add MiniMax (M2.7, M2.5, M2.5-highspeed) as a first-class LLM provider using the OpenAI-compatible API at api.minimax.io/v1. - New MiniMaxEngine extending OpenAiEngine with think-tag stripping - Full setup wizard integration (provider selection, model list, API key URL) - Dynamic model fetching via /v1/models endpoint with 7-day cache - Error handling with billing URL and model suggestions - README updated with MiniMax provider config example - 32 unit tests + 3 integration tests --- README.md | 4 +- src/commands/config.ts | 16 +- src/commands/setup.ts | 6 +- src/engine/minimax.ts | 49 ++++++ src/utils/engine.ts | 4 + src/utils/errors.ts | 3 + src/utils/modelCache.ts | 32 ++++ test/unit/minimax-integration.test.ts | 112 +++++++++++++ test/unit/minimax.test.ts | 221 ++++++++++++++++++++++++++ 9 files changed, 440 insertions(+), 7 deletions(-) create mode 100644 src/engine/minimax.ts create mode 100644 test/unit/minimax-integration.test.ts create mode 100644 test/unit/minimax.test.ts diff --git a/README.md b/README.md index 7e506467..27b7b53a 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Create a `.env` file and add OpenCommit config variables there like this: ```env ... -OCO_AI_PROVIDER= +OCO_AI_PROVIDER= OCO_API_KEY= // or other LLM provider API token OCO_API_URL= OCO_API_CUSTOM_HEADERS= @@ -235,6 +235,8 @@ oco config set OCO_AI_PROVIDER=azure OCO_API_KEY= OCO_API_UR oco config set OCO_AI_PROVIDER=flowise OCO_API_KEY= OCO_API_URL= oco config set OCO_AI_PROVIDER=ollama OCO_API_KEY= OCO_API_URL= + +oco config set OCO_AI_PROVIDER=minimax OCO_API_KEY= ``` ### Locale configuration diff --git a/src/commands/config.ts b/src/commands/config.ts index ba4cb124..43ec3dcf 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -136,6 +136,8 @@ export const MODEL_LIST = { ], deepseek: ['deepseek-chat', 'deepseek-reasoner'], + minimax: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'], + // AI/ML API available chat-completion models // https://api.aimlapi.com/v1/models aimlapi: [ @@ -593,6 +595,8 @@ const getDefaultModel = (provider: string | undefined): string => { return MODEL_LIST.aimlapi[0]; case 'openrouter': return MODEL_LIST.openrouter[0]; + case 'minimax': + return MODEL_LIST.minimax[0]; default: return MODEL_LIST.openai[0]; } @@ -784,9 +788,10 @@ export const configValidators = { 'groq', 'deepseek', 'aimlapi', - 'openrouter' + 'openrouter', + 'minimax' ].includes(value) || value.startsWith('ollama'), - `${value} is not supported yet, use 'ollama', 'mlx', 'anthropic', 'azure', 'gemini', 'flowise', 'mistral', 'deepseek', 'aimlapi' or 'openai' (default)` + `${value} is not supported yet, use 'ollama', 'mlx', 'anthropic', 'azure', 'gemini', 'flowise', 'mistral', 'deepseek', 'aimlapi', 'minimax' or 'openai' (default)` ); return value; @@ -844,7 +849,8 @@ export enum OCO_AI_PROVIDER_ENUM { MLX = 'mlx', DEEPSEEK = 'deepseek', AIMLAPI = 'aimlapi', - OPENROUTER = 'openrouter' + OPENROUTER = 'openrouter', + MINIMAX = 'minimax' } export const PROVIDER_API_KEY_URLS: Record = { @@ -857,6 +863,7 @@ export const PROVIDER_API_KEY_URLS: Record = { [OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'https://openrouter.ai/keys', [OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'https://aimlapi.com/app/keys', [OCO_AI_PROVIDER_ENUM.AZURE]: 'https://portal.azure.com/', + [OCO_AI_PROVIDER_ENUM.MINIMAX]: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', [OCO_AI_PROVIDER_ENUM.OLLAMA]: null, [OCO_AI_PROVIDER_ENUM.MLX]: null, [OCO_AI_PROVIDER_ENUM.FLOWISE]: null, @@ -871,7 +878,8 @@ export const RECOMMENDED_MODELS: Record = { [OCO_AI_PROVIDER_ENUM.MISTRAL]: 'mistral-small-latest', [OCO_AI_PROVIDER_ENUM.DEEPSEEK]: 'deepseek-chat', [OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'openai/gpt-4o-mini', - [OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'gpt-4o-mini' + [OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'gpt-4o-mini', + [OCO_AI_PROVIDER_ENUM.MINIMAX]: 'MiniMax-M2.7' } export type ConfigType = { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 517750f9..e5f0850d 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -31,7 +31,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { [OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'OpenRouter (Multiple providers)', [OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'AI/ML API', [OCO_AI_PROVIDER_ENUM.AZURE]: 'Azure OpenAI', - [OCO_AI_PROVIDER_ENUM.MLX]: 'MLX (Apple Silicon, local)' + [OCO_AI_PROVIDER_ENUM.MLX]: 'MLX (Apple Silicon, local)', + [OCO_AI_PROVIDER_ENUM.MINIMAX]: 'MiniMax (M2.7, M2.5, fast inference)' }; const PRIMARY_PROVIDERS = [ @@ -48,7 +49,8 @@ const OTHER_PROVIDERS = [ OCO_AI_PROVIDER_ENUM.OPENROUTER, OCO_AI_PROVIDER_ENUM.AIMLAPI, OCO_AI_PROVIDER_ENUM.AZURE, - OCO_AI_PROVIDER_ENUM.MLX + OCO_AI_PROVIDER_ENUM.MLX, + OCO_AI_PROVIDER_ENUM.MINIMAX ]; const NO_API_KEY_PROVIDERS = [ diff --git a/src/engine/minimax.ts b/src/engine/minimax.ts new file mode 100644 index 00000000..1da52ccb --- /dev/null +++ b/src/engine/minimax.ts @@ -0,0 +1,49 @@ +import { OpenAI } from 'openai'; +import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff'; +import { normalizeEngineError } from '../utils/engineErrorHandler'; +import { removeContentTags } from '../utils/removeContentTags'; +import { tokenCount } from '../utils/tokenCount'; +import { OpenAiEngine, OpenAiConfig } from './openAi'; + +export interface MiniMaxConfig extends OpenAiConfig {} + +export class MiniMaxEngine extends OpenAiEngine { + constructor(config: MiniMaxConfig) { + super({ + baseURL: 'https://api.minimax.io/v1', + ...config + }); + } + + public generateCommitMessage = async ( + messages: Array + ): Promise => { + const params = { + model: this.config.model, + messages, + temperature: 0.01, + top_p: 0.1, + max_tokens: this.config.maxTokensOutput + }; + + try { + const REQUEST_TOKENS = messages + .map((msg) => tokenCount(msg.content as string) + 4) + .reduce((a, b) => a + b, 0); + + if ( + REQUEST_TOKENS > + this.config.maxTokensInput - this.config.maxTokensOutput + ) + throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens); + + const completion = await this.client.chat.completions.create(params); + + const message = completion.choices[0].message; + let content = message?.content; + return removeContentTags(content, 'think'); + } catch (error) { + throw normalizeEngineError(error, 'minimax', this.config.model); + } + }; +} diff --git a/src/utils/engine.ts b/src/utils/engine.ts index af96a8c1..0140ae31 100644 --- a/src/utils/engine.ts +++ b/src/utils/engine.ts @@ -13,6 +13,7 @@ import { MLXEngine } from '../engine/mlx'; import { DeepseekEngine } from '../engine/deepseek'; import { AimlApiEngine } from '../engine/aimlapi'; import { OpenRouterEngine } from '../engine/openrouter'; +import { MiniMaxEngine } from '../engine/minimax'; export function parseCustomHeaders(headers: any): Record { let parsedHeaders = {}; @@ -88,6 +89,9 @@ export function getEngine(): AiEngine { case OCO_AI_PROVIDER_ENUM.OPENROUTER: return new OpenRouterEngine(DEFAULT_CONFIG); + case OCO_AI_PROVIDER_ENUM.MINIMAX: + return new MiniMaxEngine(DEFAULT_CONFIG); + default: return new OpenAiEngine(DEFAULT_CONFIG); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 190a0165..8be6af8e 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -12,6 +12,7 @@ export const PROVIDER_BILLING_URLS: Record = { [OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'https://openrouter.ai/credits', [OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'https://aimlapi.com/app/billing', [OCO_AI_PROVIDER_ENUM.AZURE]: 'https://portal.azure.com/#view/Microsoft_Azure_CostManagement', + [OCO_AI_PROVIDER_ENUM.MINIMAX]: 'https://platform.minimaxi.com/user-center/basic-information', [OCO_AI_PROVIDER_ENUM.OLLAMA]: null, [OCO_AI_PROVIDER_ENUM.MLX]: null, [OCO_AI_PROVIDER_ENUM.FLOWISE]: null, @@ -202,6 +203,8 @@ export function getRecommendedModel(provider: string): string | null { return 'openai/gpt-4o-mini'; case OCO_AI_PROVIDER_ENUM.AIMLAPI: return 'gpt-4o-mini'; + case OCO_AI_PROVIDER_ENUM.MINIMAX: + return 'MiniMax-M2.7'; default: return null; } diff --git a/src/utils/modelCache.ts b/src/utils/modelCache.ts index f1bd8d55..834b7631 100644 --- a/src/utils/modelCache.ts +++ b/src/utils/modelCache.ts @@ -185,6 +185,30 @@ export async function fetchOpenRouterModels(apiKey: string): Promise { } } +export async function fetchMiniMaxModels(apiKey: string): Promise { + try { + const response = await fetch('https://api.minimax.io/v1/models', { + headers: { + Authorization: `Bearer ${apiKey}` + } + }); + + if (!response.ok) { + return MODEL_LIST.minimax; + } + + const data = await response.json(); + const models = data.data + ?.map((m: { id: string }) => m.id) + .filter((id: string) => id.startsWith('MiniMax-')) + .sort(); + + return models && models.length > 0 ? models : MODEL_LIST.minimax; + } catch { + return MODEL_LIST.minimax; + } +} + export async function fetchDeepSeekModels(apiKey: string): Promise { try { const response = await fetch('https://api.deepseek.com/v1/models', { @@ -273,6 +297,14 @@ export async function fetchModelsForProvider( } break; + case OCO_AI_PROVIDER_ENUM.MINIMAX: + if (apiKey) { + models = await fetchMiniMaxModels(apiKey); + } else { + models = MODEL_LIST.minimax; + } + break; + case OCO_AI_PROVIDER_ENUM.AIMLAPI: models = MODEL_LIST.aimlapi; break; diff --git a/test/unit/minimax-integration.test.ts b/test/unit/minimax-integration.test.ts new file mode 100644 index 00000000..5b338aae --- /dev/null +++ b/test/unit/minimax-integration.test.ts @@ -0,0 +1,112 @@ +import { OpenAI } from 'openai'; + +// Mock @clack/prompts to prevent process.exit calls +jest.mock('@clack/prompts', () => ({ + intro: jest.fn(), + outro: jest.fn() +})); + +/** + * Integration tests for MiniMax engine. + * These tests verify the MiniMax API works correctly via OpenAI-compatible SDK. + * This mirrors the exact behavior of MiniMaxEngine which extends OpenAiEngine. + * + * Run with: MINIMAX_API_KEY= npm run test -- test/unit/minimax-integration.test.ts + */ +const MINIMAX_API_KEY = process.env.MINIMAX_API_KEY; +const describeIntegration = MINIMAX_API_KEY ? describe : describe.skip; + +describeIntegration('MiniMax Integration (requires MINIMAX_API_KEY)', () => { + let client: OpenAI; + + beforeAll(() => { + client = new OpenAI({ + apiKey: MINIMAX_API_KEY!, + baseURL: 'https://api.minimax.io/v1' + }); + }); + + it('should generate a commit message with M2.7', async () => { + const completion = await client.chat.completions.create({ + model: 'MiniMax-M2.7', + messages: [ + { + role: 'system', + content: + 'You are an expert at writing concise, meaningful git commit messages. Generate a conventional commit message for the provided code diff. Output only the commit message, nothing else.' + }, + { + role: 'user', + content: `diff --git a/src/utils.ts b/src/utils.ts +--- a/src/utils.ts ++++ b/src/utils.ts +@@ -10,6 +10,10 @@ export function formatDate(date: Date): string { + return date.toISOString(); + } + ++export function formatCurrency(amount: number, currency: string = 'USD'): string { ++ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount); ++} ++ + export function capitalize(str: string): string {` + } + ], + temperature: 0.01, + top_p: 0.1, + max_tokens: 500 + }); + + const content = completion.choices[0].message?.content; + expect(content).toBeDefined(); + expect(typeof content).toBe('string'); + expect(content!.length).toBeGreaterThan(0); + }, 30000); + + it('should generate commit message with M2.5-highspeed', async () => { + const completion = await client.chat.completions.create({ + model: 'MiniMax-M2.5-highspeed', + messages: [ + { + role: 'system', + content: + 'You are an expert at writing concise git commit messages. Generate a conventional commit message. Output only the commit message.' + }, + { + role: 'user', + content: `diff --git a/README.md b/README.md +--- a/README.md ++++ b/README.md +@@ -1,3 +1,5 @@ + # My Project + + A simple project. ++ ++## Installation` + } + ], + temperature: 0.01, + top_p: 0.1, + max_tokens: 500 + }); + + const content = completion.choices[0].message?.content; + expect(content).toBeDefined(); + expect(typeof content).toBe('string'); + expect(content!.length).toBeGreaterThan(0); + }, 30000); + + it('should handle authentication error with invalid API key', async () => { + const badClient = new OpenAI({ + apiKey: 'invalid-api-key', + baseURL: 'https://api.minimax.io/v1' + }); + + await expect( + badClient.chat.completions.create({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'test' }], + max_tokens: 10 + }) + ).rejects.toThrow(); + }, 30000); +}); diff --git a/test/unit/minimax.test.ts b/test/unit/minimax.test.ts new file mode 100644 index 00000000..7dc16445 --- /dev/null +++ b/test/unit/minimax.test.ts @@ -0,0 +1,221 @@ +import { existsSync, readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { + MODEL_LIST, + OCO_AI_PROVIDER_ENUM, + PROVIDER_API_KEY_URLS, + RECOMMENDED_MODELS +} from '../../src/commands/config'; +import { + PROVIDER_BILLING_URLS, + getRecommendedModel, + getSuggestedModels +} from '../../src/utils/errors'; + +// Mock @clack/prompts to prevent process.exit calls +jest.mock('@clack/prompts', () => ({ + intro: jest.fn(), + outro: jest.fn() +})); + +const testDir = dirname(fileURLToPath(import.meta.url)); + +function readSource(relPath: string): string { + return readFileSync(join(testDir, relPath), 'utf8'); +} + +describe('MiniMax Provider', () => { + describe('Engine file structure', () => { + it('should have a minimax.ts engine file', () => { + const enginePath = join(testDir, '../../src/engine/minimax.ts'); + expect(existsSync(enginePath)).toBe(true); + }); + + it('should export MiniMaxEngine extending OpenAiEngine', () => { + const content = readSource('../../src/engine/minimax.ts'); + expect(content).toContain('export class MiniMaxEngine extends OpenAiEngine'); + expect(content).toContain('export interface MiniMaxConfig extends OpenAiConfig'); + }); + + it('should use MiniMax API base URL', () => { + const content = readSource('../../src/engine/minimax.ts'); + expect(content).toContain("baseURL: 'https://api.minimax.io/v1'"); + }); + + it('should use low temperature for deterministic output', () => { + const content = readSource('../../src/engine/minimax.ts'); + expect(content).toContain('temperature: 0.01'); + }); + + it('should strip think tags from response', () => { + const content = readSource('../../src/engine/minimax.ts'); + expect(content).toContain("removeContentTags(content, 'think')"); + }); + + it('should normalize errors with minimax provider name', () => { + const content = readSource('../../src/engine/minimax.ts'); + expect(content).toContain("normalizeEngineError(error, 'minimax'"); + }); + + it('should handle token count validation', () => { + const content = readSource('../../src/engine/minimax.ts'); + expect(content).toContain('GenerateCommitMessageErrorEnum.tooMuchTokens'); + expect(content).toContain('tokenCount'); + }); + + it('should allow user baseURL to override default via spread', () => { + const content = readSource('../../src/engine/minimax.ts'); + // The pattern: baseURL first, then ...config, so user config overrides + expect(content).toContain("baseURL: 'https://api.minimax.io/v1'"); + expect(content).toContain('...config'); + }); + }); + + describe('Config Integration', () => { + it('should have MINIMAX in OCO_AI_PROVIDER_ENUM', () => { + expect(OCO_AI_PROVIDER_ENUM.MINIMAX).toBe('minimax'); + }); + + it('should have MiniMax models in MODEL_LIST', () => { + expect(MODEL_LIST.minimax).toBeDefined(); + expect(MODEL_LIST.minimax).toContain('MiniMax-M2.7'); + expect(MODEL_LIST.minimax).toContain('MiniMax-M2.5'); + expect(MODEL_LIST.minimax).toContain('MiniMax-M2.5-highspeed'); + }); + + it('should have correct number of MiniMax models', () => { + expect(MODEL_LIST.minimax.length).toBe(3); + }); + + it('should have MiniMax in PROVIDER_API_KEY_URLS', () => { + expect(PROVIDER_API_KEY_URLS[OCO_AI_PROVIDER_ENUM.MINIMAX]).toBeDefined(); + expect(typeof PROVIDER_API_KEY_URLS[OCO_AI_PROVIDER_ENUM.MINIMAX]).toBe('string'); + }); + + it('should have MiniMax in RECOMMENDED_MODELS', () => { + expect(RECOMMENDED_MODELS[OCO_AI_PROVIDER_ENUM.MINIMAX]).toBe('MiniMax-M2.7'); + }); + + it('should have MiniMax recommended model in MODEL_LIST', () => { + const recommended = RECOMMENDED_MODELS[OCO_AI_PROVIDER_ENUM.MINIMAX]; + expect(MODEL_LIST.minimax).toContain(recommended); + }); + + it('should have M2.7 as first model (default)', () => { + expect(MODEL_LIST.minimax[0]).toBe('MiniMax-M2.7'); + }); + + it('should include minimax in provider validator', () => { + const content = readSource('../../src/commands/config.ts'); + // Check the validator includes minimax + const validatorSection = content.slice( + content.indexOf('[CONFIG_KEYS.OCO_AI_PROVIDER]'), + content.indexOf('[CONFIG_KEYS.OCO_AI_PROVIDER]') + 500 + ); + expect(validatorSection).toContain("'minimax'"); + }); + + it('should have minimax in getDefaultModel switch', () => { + const content = readSource('../../src/commands/config.ts'); + expect(content).toContain("case 'minimax':"); + expect(content).toContain('MODEL_LIST.minimax[0]'); + }); + }); + + describe('Engine Factory Integration', () => { + it('should import MiniMaxEngine', () => { + const content = readSource('../../src/utils/engine.ts'); + expect(content).toContain("import { MiniMaxEngine } from '../engine/minimax'"); + }); + + it('should have minimax case in engine switch', () => { + const content = readSource('../../src/utils/engine.ts'); + expect(content).toContain('case OCO_AI_PROVIDER_ENUM.MINIMAX:'); + expect(content).toContain('new MiniMaxEngine(DEFAULT_CONFIG)'); + }); + }); + + describe('Error Integration', () => { + it('should have MiniMax in PROVIDER_BILLING_URLS', () => { + expect(PROVIDER_BILLING_URLS[OCO_AI_PROVIDER_ENUM.MINIMAX]).toBeDefined(); + expect(typeof PROVIDER_BILLING_URLS[OCO_AI_PROVIDER_ENUM.MINIMAX]).toBe('string'); + }); + + it('should return MiniMax-M2.7 as recommended model', () => { + expect(getRecommendedModel(OCO_AI_PROVIDER_ENUM.MINIMAX)).toBe('MiniMax-M2.7'); + }); + + it('should return MiniMax model suggestions excluding failed model', () => { + const suggestions = getSuggestedModels(OCO_AI_PROVIDER_ENUM.MINIMAX, 'MiniMax-M2.7'); + expect(suggestions).toBeDefined(); + expect(Array.isArray(suggestions)).toBe(true); + expect(suggestions).not.toContain('MiniMax-M2.7'); + expect(suggestions.length).toBeGreaterThan(0); + }); + + it('should return all models when no model is excluded', () => { + const suggestions = getSuggestedModels(OCO_AI_PROVIDER_ENUM.MINIMAX, 'nonexistent-model'); + expect(suggestions.length).toBe(3); + }); + + it('should have minimax case in getRecommendedModel', () => { + const content = readSource('../../src/utils/errors.ts'); + expect(content).toContain('case OCO_AI_PROVIDER_ENUM.MINIMAX:'); + }); + }); + + describe('Setup Integration', () => { + it('should have MiniMax in provider display names', () => { + const content = readSource('../../src/commands/setup.ts'); + expect(content).toContain('[OCO_AI_PROVIDER_ENUM.MINIMAX]'); + expect(content).toContain('MiniMax'); + }); + + it('should be listed in OTHER_PROVIDERS', () => { + const content = readSource('../../src/commands/setup.ts'); + // Check OTHER_PROVIDERS array contains MINIMAX + const otherSection = content.slice( + content.indexOf('OTHER_PROVIDERS'), + content.indexOf('OTHER_PROVIDERS') + 500 + ); + expect(otherSection).toContain('OCO_AI_PROVIDER_ENUM.MINIMAX'); + }); + }); + + describe('Model Cache Integration', () => { + it('should have fetchMiniMaxModels function', () => { + const content = readSource('../../src/utils/modelCache.ts'); + expect(content).toContain('export async function fetchMiniMaxModels'); + expect(content).toContain('https://api.minimax.io/v1/models'); + }); + + it('should have MiniMax case in fetchModelsForProvider', () => { + const content = readSource('../../src/utils/modelCache.ts'); + expect(content).toContain('case OCO_AI_PROVIDER_ENUM.MINIMAX:'); + expect(content).toContain('fetchMiniMaxModels'); + }); + + it('should filter MiniMax models by prefix', () => { + const content = readSource('../../src/utils/modelCache.ts'); + expect(content).toContain("id.startsWith('MiniMax-')"); + }); + + it('should fall back to MODEL_LIST.minimax on error', () => { + const content = readSource('../../src/utils/modelCache.ts'); + expect(content).toContain('MODEL_LIST.minimax'); + }); + }); + + describe('README', () => { + it('should mention minimax as a provider option', () => { + const content = readSource('../../README.md'); + expect(content).toContain('minimax'); + }); + + it('should show minimax config example', () => { + const content = readSource('../../README.md'); + expect(content).toContain('OCO_AI_PROVIDER=minimax'); + }); + }); +});