From 5f52ce2ee9cc96c9d90d4f84357a44cb478cb2a4 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Wed, 8 Apr 2026 13:56:20 +0800 Subject: [PATCH] feat: add native MiniMax provider to CLI wiki command - Add 'minimax' as a first-class LLMProvider in the gitnexus CLI - Add MINIMAX_API_KEY env var support in resolveLLMConfig - Set default base URL to https://api.minimax.io/v1 for minimax provider - Set default model to MiniMax-M2.7 for minimax provider - Clamp temperature=0 to 1.0 (MiniMax requires temp > 0) - Add 'MiniMax (api.minimax.io)' choice in interactive wiki setup - Update CLIConfig type to include 'minimax' provider - Add unit tests for MiniMax CLI provider (base URL, temperature clamping) - Add unit tests for MiniMax in settings-service (display name, models, config) --- .../test/unit/settings-service.test.ts | 28 ++++++ gitnexus/src/cli/wiki.ts | 23 +++-- gitnexus/src/core/wiki/llm-client.ts | 16 ++-- gitnexus/src/storage/repo-manager.ts | 2 +- gitnexus/test/unit/wiki-llm-client.test.ts | 87 +++++++++++++++++++ 5 files changed, 143 insertions(+), 13 deletions(-) diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts index 17514725c..b688b6db9 100644 --- a/gitnexus-web/test/unit/settings-service.test.ts +++ b/gitnexus-web/test/unit/settings-service.test.ts @@ -104,6 +104,26 @@ describe('getActiveProviderConfig', () => { expect(config!.provider).toBe('openai'); }); + it('returns config for minimax when API key is set', () => { + const settings = loadSettings(); + settings.activeProvider = 'minimax'; + settings.minimax = { ...settings.minimax, apiKey: 'minimax-key-123', model: 'MiniMax-M2.7' }; + saveSettings(settings); + + const config = getActiveProviderConfig(); + expect(config).not.toBeNull(); + expect(config!.provider).toBe('minimax'); + }); + + it('returns null for minimax with no API key', () => { + const settings = loadSettings(); + settings.activeProvider = 'minimax'; + settings.minimax = { ...settings.minimax, apiKey: '' }; + saveSettings(settings); + + expect(getActiveProviderConfig()).toBeNull(); + }); + it('returns null for openrouter with empty API key', () => { const settings = loadSettings(); settings.activeProvider = 'openrouter'; @@ -139,6 +159,7 @@ describe('getProviderDisplayName', () => { expect(getProviderDisplayName('anthropic')).toBe('Anthropic'); expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)'); expect(getProviderDisplayName('openrouter')).toBe('OpenRouter'); + expect(getProviderDisplayName('minimax')).toBe('MiniMax'); }); }); @@ -149,6 +170,13 @@ describe('getAvailableModels', () => { expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514'); }); + it('returns MiniMax models', () => { + const models = getAvailableModels('minimax'); + expect(models).toContain('MiniMax-M2.7'); + expect(models).toContain('MiniMax-M2.7-highspeed'); + expect(models.length).toBe(2); + }); + it('returns empty array for unknown provider', () => { expect(getAvailableModels('unknown' as any)).toEqual([]); }); diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index c3d0c5237..678b9d793 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -192,7 +192,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } else { console.log(" No LLM configured. Let's set it up.\n"); console.log( - ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, or Cursor CLI.\n', + ' Supports OpenAI, OpenRouter, Azure, MiniMax, any OpenAI-compatible API, or Cursor CLI.\n', ); // Check if Cursor CLI is available @@ -203,12 +203,13 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio console.log(' [2] OpenRouter (openrouter.ai)'); console.log(' [3] Azure OpenAI'); console.log(' [4] Custom endpoint'); + console.log(' [5] MiniMax (api.minimax.io)'); if (hasCursor) { - console.log(' [5] Cursor CLI (local, uses your Cursor subscription)'); + console.log(' [6] Cursor CLI (local, uses your Cursor subscription)'); } console.log(''); - const maxChoice = hasCursor ? '5' : '4'; + const maxChoice = hasCursor ? '6' : '5'; const choice = await prompt(` Select provider (1/${maxChoice}): `); let baseUrl: string; @@ -216,7 +217,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio let provider: LLMProvider = 'openai'; let key = ''; - if (choice === '5' && hasCursor) { + if (choice === '6' && hasCursor) { // Cursor CLI selected - model defaults to 'auto' (Cursor's default) provider = 'cursor'; baseUrl = ''; @@ -293,7 +294,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio provider: 'azure', }; } else { - // OpenAI-compatible provider (OpenAI, OpenRouter, Custom) + // OpenAI-compatible provider (OpenAI, OpenRouter, MiniMax, Custom) if (choice === '2') { baseUrl = 'https://openrouter.ai/api/v1'; defaultModel = 'minimax/minimax-m2.7'; @@ -307,6 +308,10 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } defaultModel = 'gpt-4o-mini'; provider = 'custom'; + } else if (choice === '5') { + baseUrl = 'https://api.minimax.io/v1'; + defaultModel = 'MiniMax-M2.7'; + provider = 'minimax'; } else { baseUrl = 'https://api.openai.com/v1'; defaultModel = 'gpt-4o-mini'; @@ -317,8 +322,12 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio const modelInput = await prompt(` Model (default: ${defaultModel}): `); const model = modelInput || defaultModel; - // API key — pre-fill hint if env var exists - const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || ''; + // API key — pre-fill hint if env var exists (check MINIMAX_API_KEY for minimax provider) + const envKey = + (provider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || + process.env.GITNEXUS_API_KEY || + process.env.OPENAI_API_KEY || + ''; if (envKey) { const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4); const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 24b35c842..6ca3e7e52 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -7,7 +7,7 @@ * Config priority: CLI flags > env vars > defaults */ -export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; +export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor' | 'minimax'; export interface LLMConfig { apiKey: string; @@ -16,7 +16,7 @@ export interface LLMConfig { maxTokens: number; temperature: number; /** Provider type — controls auth header behaviour */ - provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; + provider?: LLMProvider; /** Azure api-version query param (e.g. '2024-10-21'). Appended to URL when set. */ apiVersion?: string; /** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */ @@ -39,8 +39,10 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< const { loadCLIConfig } = await import('../../storage/repo-manager.js'); const savedConfig = await loadCLIConfig(); + const effectiveProvider = overrides?.provider || savedConfig.provider; const apiKey = overrides?.apiKey || + (effectiveProvider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || savedConfig.apiKey || @@ -52,13 +54,13 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< overrides?.baseUrl || process.env.GITNEXUS_LLM_BASE_URL || savedConfig.baseUrl || - 'https://openrouter.ai/api/v1', + (effectiveProvider === 'minimax' ? 'https://api.minimax.io/v1' : 'https://openrouter.ai/api/v1'), model: overrides?.model || process.env.GITNEXUS_MODEL || (savedConfig.provider === 'cursor' ? savedConfig.cursorModel : undefined) || savedConfig.model || - 'minimax/minimax-m2.7', + (effectiveProvider === 'minimax' ? 'MiniMax-M2.7' : 'minimax/minimax-m2.7'), maxTokens: overrides?.maxTokens ?? 16_384, temperature: overrides?.temperature ?? 0, provider: overrides?.provider ?? savedConfig.provider ?? 'openai', @@ -133,6 +135,9 @@ export async function callLLM( // Detect Azure endpoint (by provider field or URL pattern) const azure = config.provider === 'azure' || isAzureProvider(config.baseUrl); + // Detect MiniMax provider — temperature must be in (0.0, 1.0], clamp 0 to 1.0 + const minimax = config.provider === 'minimax'; + // Warn when using Azure legacy deployment URL without api-version if (azure && !config.apiVersion && config.baseUrl.includes('/deployments/')) { console.warn( @@ -156,8 +161,9 @@ export async function callLLM( body.max_completion_tokens = config.maxTokens; // Only send temperature for non-Azure providers — some Azure models reject non-default values + // MiniMax requires temperature in (0.0, 1.0] — clamp 0 to 1.0 to avoid API errors if (!reasoning && !azure && config.temperature !== undefined) { - body.temperature = config.temperature; + body.temperature = minimax && config.temperature === 0 ? 1.0 : config.temperature; } if (useStream) body.stream = true; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index cc447aa4c..20584a767 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -324,7 +324,7 @@ export interface CLIConfig { apiKey?: string; model?: string; baseUrl?: string; - provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; + provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor' | 'minimax'; cursorModel?: string; /** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */ apiVersion?: string; diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 8236557b0..4ec1d6e1c 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -330,3 +330,90 @@ describe('readSSEStream — content_filter handling', () => { ).rejects.toThrow('content filter'); }); }); + +describe('callLLM — MiniMax provider', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('sends request to api.minimax.io with Bearer auth', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'hello' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'minimax-test-key', + baseUrl: 'https://api.minimax.io/v1', + model: 'MiniMax-M2.7', + maxTokens: 100, + temperature: 1.0, + provider: 'minimax', + }); + + const [url, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(url).toContain('api.minimax.io/v1/chat/completions'); + expect(init.headers['Authorization']).toBe('Bearer minimax-test-key'); + expect((init.headers as any)['api-key']).toBeUndefined(); + }); + + it('clamps temperature 0 to 1.0 for MiniMax to avoid API errors', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'hello' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'minimax-test-key', + baseUrl: 'https://api.minimax.io/v1', + model: 'MiniMax-M2.7', + maxTokens: 100, + temperature: 0, + provider: 'minimax', + }); + + const [, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + const body = JSON.parse(init.body as string); + expect(body.temperature).toBe(1.0); + }); + + it('passes temperature unchanged when already > 0 for MiniMax', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'hello' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'minimax-test-key', + baseUrl: 'https://api.minimax.io/v1', + model: 'MiniMax-M2.7-highspeed', + maxTokens: 100, + temperature: 0.7, + provider: 'minimax', + }); + + const [, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + const body = JSON.parse(init.body as string); + expect(body.temperature).toBe(0.7); + expect(body.model).toBe('MiniMax-M2.7-highspeed'); + }); +});