mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
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:
parent
6aacccf24a
commit
5f52ce2ee9
5 changed files with 143 additions and 13 deletions
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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): `);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue