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)
This commit is contained in:
octo-patch 2026-04-08 13:56:20 +08:00
parent 6aacccf24a
commit 5f52ce2ee9
5 changed files with 143 additions and 13 deletions

View file

@ -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([]);
});

View file

@ -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): `);

View file

@ -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<LLMConfig>): 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<LLMConfig>): 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;

View file

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

View file

@ -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<string, string> },
];
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<string, string> },
];
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<string, string> },
];
const body = JSON.parse(init.body as string);
expect(body.temperature).toBe(0.7);
expect(body.model).toBe('MiniMax-M2.7-highspeed');
});
});