From 0fa547ccdcc24dac18085fa9328094a57731cfff Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 12 Aug 2026 02:11:47 +0800 Subject: [PATCH] feat: refresh MiniMax model and endpoint configuration (#2780) --- .claude/skills/gitnexus-cli/SKILL.md | 2 +- .../skills/gitnexus-cli/SKILL.md | 2 +- gitnexus-web/src/components/SettingsPanel.tsx | 93 +++++++- gitnexus-web/src/core/llm/agent.ts | 36 +++- gitnexus-web/src/core/llm/settings-service.ts | 27 ++- gitnexus-web/src/core/llm/types.ts | 73 ++++++- gitnexus-web/src/locales/en/settings.json | 16 +- gitnexus-web/src/locales/zh-CN/settings.json | 16 +- gitnexus-web/test/unit/agent-abort.test.ts | 31 +++ gitnexus-web/test/unit/agent-history.test.ts | 61 ++++++ .../test/unit/settings-service.test.ts | 69 ++++++ gitnexus/skills/gitnexus-cli.md | 2 +- gitnexus/src/cli/i18n/en.ts | 9 +- gitnexus/src/cli/i18n/zh-CN.ts | 9 +- gitnexus/src/cli/index.ts | 11 +- gitnexus/src/cli/wiki.ts | 67 +++++- gitnexus/src/core/wiki/llm-client.ts | 104 +++++++-- gitnexus/src/storage/repo-manager.ts | 3 +- gitnexus/test/unit/wiki-flags.test.ts | 200 +++++++++++++++++- gitnexus/test/unit/wiki-llm-client.test.ts | 145 ++++++++++++- 20 files changed, 902 insertions(+), 74 deletions(-) diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md index 342e8b08f..853d44860 100644 --- a/.claude/skills/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus-cli/SKILL.md @@ -60,7 +60,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | Flag | Effect | | ------------------- | ----------------------------------------- | | `--force` | Force full regeneration | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--model ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 91c1ae992..9c7a1b599 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -60,7 +60,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | Flag | Effect | |------|--------| | `--force` | Force full regeneration, also required to re-gerenate an existing wiki in a different language | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--model ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 0c3a22aec..9b32ffd92 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -21,7 +21,13 @@ import { fetchOpenRouterModels, } from '../core/llm/settings-service'; import { getAuthToken, setAuthToken } from '../services/backend-client'; -import type { LLMSettings, LLMProvider } from '../core/llm/types'; +import type { LLMSettings, LLMProvider, MiniMaxThinkingMode } from '../core/llm/types'; +import { + getMiniMaxModelCapabilities, + MINIMAX_ANTHROPIC_BASE_URLS, + MINIMAX_DOCS_ROOTS, + MINIMAX_MODEL_IDS, +} from '../core/llm/types'; import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants'; import { ProviderConfigCard } from './settings/ProviderConfigCard'; import { SecretInput } from './settings/SecretInput'; @@ -341,6 +347,20 @@ export const SettingsPanel = ({ if (!isOpen) return null; + const miniMaxModel = settings.minimax?.model ?? MINIMAX_MODEL_IDS[0]; + const miniMaxCapabilities = getMiniMaxModelCapabilities(miniMaxModel); + const configuredMiniMaxThinkingMode = settings.minimax?.thinkingMode; + const miniMaxThinkingMode = + configuredMiniMaxThinkingMode && + miniMaxCapabilities?.thinkingModes.includes(configuredMiniMaxThinkingMode) + ? configuredMiniMaxThinkingMode + : (miniMaxCapabilities?.thinkingModes[0] ?? configuredMiniMaxThinkingMode ?? 'adaptive'); + const miniMaxBaseUrl = settings.minimax?.baseUrl ?? MINIMAX_ANTHROPIC_BASE_URLS.global_en; + const miniMaxDocsRoot = + miniMaxBaseUrl === MINIMAX_ANTHROPIC_BASE_URLS.cn_zh + ? MINIMAX_DOCS_ROOTS.cn_zh + : MINIMAX_DOCS_ROOTS.global_en; + const providers: LLMProvider[] = [ 'openai', 'gemini', @@ -864,7 +884,7 @@ export const SettingsPanel = ({ value: settings.minimax?.apiKey ?? '', placeholder: t('settings:providers.minimax.apiKeyPlaceholder'), helperText: t('settings:providers.minimax.helperText'), - helperLink: 'https://platform.minimax.io', + helperLink: miniMaxDocsRoot, helperLinkLabel: t('settings:providers.minimax.helperLinkLabel'), isVisible: !!showApiKey['minimax'], onChange: (value) => @@ -875,16 +895,79 @@ export const SettingsPanel = ({ onToggleVisibility: () => toggleApiKeyVisibility('minimax'), }} model={{ - value: settings.minimax?.model ?? 'MiniMax-M2.5', + value: miniMaxModel, placeholder: t('settings:providers.minimax.modelPlaceholder'), onChange: (value) => setSettings((prev) => ({ ...prev, - minimax: { ...prev.minimax!, model: value }, + minimax: { + ...prev.minimax!, + model: value, + thinkingMode: + getMiniMaxModelCapabilities(value)?.thinkingModes[0] ?? + prev.minimax?.thinkingMode, + }, })), helperText: t('settings:providers.minimax.helperModel'), }} - /> + > +
+ + +
+ +
+ + + {miniMaxCapabilities && ( +

+ {t('settings:providers.minimax.capabilities', { + contextWindow: miniMaxCapabilities.contextWindow.toLocaleString(), + modalities: miniMaxCapabilities.inputModalities.join(', '), + })} +

+ )} +
+ )} {/* DeepSeek Settings */} diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index c10748fd0..555cf0d10 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -20,6 +20,7 @@ import { ChatOllama } from '@langchain/ollama'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { createGraphRAGTools, type GraphRAGBackend } from './tools'; import type { + AgentUserContent, ProviderConfig, OpenAIConfig, AzureOpenAIConfig, @@ -32,7 +33,9 @@ import type { DeepSeekConfig, AgentStreamChunk, AgentHistoryMessage, + MiniMaxThinkingMode, } from './types'; +import { getMiniMaxModelCapabilities, MINIMAX_ANTHROPIC_BASE_URLS } from './types'; import { type CodebaseContext, buildDynamicSystemPrompt, @@ -275,14 +278,28 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { throw new Error('MiniMax API key is required but was not provided'); } + const capabilities = getMiniMaxModelCapabilities(minimaxConfig.model); + const requestedThinkingMode = minimaxConfig.thinkingMode; + const thinkingMode: MiniMaxThinkingMode | undefined = + requestedThinkingMode && capabilities?.thinkingModes.includes(requestedThinkingMode) + ? requestedThinkingMode + : (capabilities?.thinkingModes[0] ?? requestedThinkingMode); + const thinking = + thinkingMode && thinkingMode !== 'always_on' ? { type: thinkingMode } : undefined; + const temperature = + thinkingMode === 'adaptive' || thinkingMode === 'always_on' + ? undefined + : (minimaxConfig.temperature ?? 0.1); + return new ChatAnthropic({ anthropicApiKey: minimaxConfig.apiKey, model: minimaxConfig.model, - temperature: minimaxConfig.temperature ?? 0.1, + ...(temperature !== undefined ? { temperature } : {}), maxTokens: minimaxConfig.maxTokens ?? 8192, streaming: true, + ...(thinking ? { thinking } : {}), clientOptions: { - baseURL: 'https://api.minimax.io/anthropic', + baseURL: minimaxConfig.baseUrl ?? MINIMAX_ANTHROPIC_BASE_URLS.global_en, }, }); } @@ -393,7 +410,7 @@ export const createGraphRAGAgent = ( /** * Message type for agent conversation */ -export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessage; +export type AgentMessage = { role: 'user'; content: AgentUserContent } | AgentHistoryMessage; export interface AgentRuntimeOptions { /** Capture assistant/tool messages for providers that require exact transcript replay. */ @@ -412,7 +429,9 @@ const isAbortError = (error: unknown, signal?: AbortSignal): boolean => { export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] => messages.map((message) => { if (message.role === 'user') { - return new HumanMessage(message.content); + return typeof message.content === 'string' + ? new HumanMessage(message.content) + : new HumanMessage({ content: message.content as any }); } if (message.role === 'tool') { return new ToolMessage({ @@ -542,6 +561,7 @@ export async function* streamAgentResponse( // Handle content that can be string or array of content blocks let content: string = ''; + let thinkingContent: string = ''; if (typeof rawContent === 'string') { content = rawContent; } else if (Array.isArray(rawContent)) { @@ -550,6 +570,14 @@ export async function* streamAgentResponse( .filter((block: any) => block.type === 'text' || typeof block === 'string') .map((block: any) => (typeof block === 'string' ? block : block.text || '')) .join(''); + thinkingContent = rawContent + .filter((block: any) => block?.type === 'thinking') + .map((block: any) => block.thinking || '') + .join(''); + } + + if (thinkingContent) { + yield { type: 'reasoning', reasoning: thinkingContent }; } // If chunk has content, stream it diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 79a7a4309..fb2591172 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -19,12 +19,32 @@ import { GLMConfig, DeepSeekConfig, ProviderConfig, + MINIMAX_MODEL_IDS, } from './types'; import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants'; import { resilientFetch } from 'gitnexus-shared'; const STORAGE_KEY = 'gitnexus-llm-settings'; +const mergeMiniMaxSettings = ( + stored?: LLMSettings['minimax'], +): NonNullable => { + const merged = { + ...DEFAULT_LLM_SETTINGS.minimax, + ...stored, + }; + + if (!(MINIMAX_MODEL_IDS as readonly string[]).includes(merged.model ?? '')) { + return { + ...merged, + model: DEFAULT_LLM_SETTINGS.minimax?.model, + thinkingMode: DEFAULT_LLM_SETTINGS.minimax?.thinkingMode, + }; + } + + return merged; +}; + const mergeWithDefaults = (parsed?: Partial | null): LLMSettings => ({ ...DEFAULT_LLM_SETTINGS, ...parsed, @@ -52,10 +72,7 @@ const mergeWithDefaults = (parsed?: Partial | null): LLMSettings => ...DEFAULT_LLM_SETTINGS.openrouter, ...parsed?.openrouter, }, - minimax: { - ...DEFAULT_LLM_SETTINGS.minimax, - ...parsed?.minimax, - }, + minimax: mergeMiniMaxSettings(parsed?.minimax), glm: { ...DEFAULT_LLM_SETTINGS.glm, ...parsed?.glm, @@ -437,7 +454,7 @@ export const getAvailableModels = (provider: LLMProvider): string[] => { case 'ollama': return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder']; case 'minimax': - return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed']; + return [...MINIMAX_MODEL_IDS]; case 'glm': return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5']; case 'deepseek': diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index b7727da10..c5198bd16 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -20,6 +20,71 @@ export type LLMProvider = | 'glm' | 'deepseek'; +export const MINIMAX_ANTHROPIC_BASE_URLS = { + global_en: 'https://api.minimax.io/anthropic', + cn_zh: 'https://api.minimaxi.com/anthropic', +} as const; + +export const MINIMAX_DOCS_ROOTS = { + global_en: 'https://platform.minimax.io/docs', + cn_zh: 'https://platform.minimaxi.com/docs', +} as const; + +export const MINIMAX_MODEL_IDS = ['MiniMax-M3', 'MiniMax-M2.7'] as const; + +export type MiniMaxModelId = (typeof MINIMAX_MODEL_IDS)[number]; +export type MiniMaxThinkingMode = 'adaptive' | 'disabled' | 'always_on'; +export type MiniMaxInputModality = 'text' | 'image' | 'video'; + +export interface MiniMaxModelCapabilities { + contextWindow: number; + inputModalities: readonly MiniMaxInputModality[]; + thinkingModes: readonly MiniMaxThinkingMode[]; +} + +export const MINIMAX_MODEL_CAPABILITIES: Record = { + 'MiniMax-M3': { + contextWindow: 1_000_000, + inputModalities: ['text', 'image', 'video'], + thinkingModes: ['adaptive', 'disabled'], + }, + 'MiniMax-M2.7': { + contextWindow: 204_800, + inputModalities: ['text'], + thinkingModes: ['always_on'], + }, +}; + +export const getMiniMaxModelCapabilities = (model: string): MiniMaxModelCapabilities | undefined => + MINIMAX_MODEL_CAPABILITIES[model as MiniMaxModelId]; + +export type MiniMaxMediaDetail = 'low' | 'default' | 'high'; + +export type MiniMaxMediaSource = + | { + type: 'url'; + url: string; + detail?: MiniMaxMediaDetail; + fps?: number; + max_long_side_pixel?: number; + } + | { + type: 'base64'; + media_type: string; + data: string; + detail?: MiniMaxMediaDetail; + fps?: number; + max_long_side_pixel?: number; + }; + +export type AgentUserContent = + | string + | Array< + | { type: 'text'; text: string } + | { type: 'image'; source: MiniMaxMediaSource } + | { type: 'video'; source: MiniMaxMediaSource } + >; + /** * Base configuration shared by all providers */ @@ -94,7 +159,9 @@ export interface OpenRouterConfig extends BaseProviderConfig { export interface MiniMaxConfig extends BaseProviderConfig { provider: 'minimax'; apiKey: string; - model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed' + model: string; + baseUrl?: string; + thinkingMode?: MiniMaxThinkingMode; } /** @@ -200,7 +267,9 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = { }, minimax: { apiKey: '', - model: 'MiniMax-M2.5', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.global_en, + thinkingMode: 'adaptive', temperature: 0.1, }, glm: { diff --git a/gitnexus-web/src/locales/en/settings.json b/gitnexus-web/src/locales/en/settings.json index cf9746c72..91c6b2a68 100644 --- a/gitnexus-web/src/locales/en/settings.json +++ b/gitnexus-web/src/locales/en/settings.json @@ -76,8 +76,20 @@ "apiKeyPlaceholder": "Enter your MiniMax API key", "helperText": "Get your API key from", "helperLinkLabel": "MiniMax Platform", - "modelPlaceholder": "e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed", - "helperModel": "Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)" + "modelPlaceholder": "e.g., MiniMax-M3 or MiniMax-M2.7", + "helperModel": "Available: MiniMax-M3 (default) and MiniMax-M2.7", + "endpoint": "Regional endpoint", + "endpoints": { + "global": "Global (api.minimax.io)", + "china": "China (api.minimaxi.com)" + }, + "thinking": "Thinking mode", + "thinkingModes": { + "adaptive": "Adaptive", + "disabled": "Disabled", + "always_on": "Always on" + }, + "capabilities": "{{contextWindow}} token context | Inputs: {{modalities}}" }, "glm": { "apiKeyPlaceholder": "Enter your Z.AI API key" diff --git a/gitnexus-web/src/locales/zh-CN/settings.json b/gitnexus-web/src/locales/zh-CN/settings.json index 0efe220d4..4bc4fb05b 100644 --- a/gitnexus-web/src/locales/zh-CN/settings.json +++ b/gitnexus-web/src/locales/zh-CN/settings.json @@ -76,8 +76,20 @@ "apiKeyPlaceholder": "输入 MiniMax API Key", "helperText": "从这里获取 API Key:", "helperLinkLabel": "MiniMax Platform", - "modelPlaceholder": "例如:MiniMax-M2.5、MiniMax-M2.5-highspeed", - "helperModel": "可用:MiniMax-M2.5(默认)、MiniMax-M2.5-highspeed(更快)" + "modelPlaceholder": "例如:MiniMax-M3 或 MiniMax-M2.7", + "helperModel": "可用:MiniMax-M3(默认)和 MiniMax-M2.7", + "endpoint": "区域端点", + "endpoints": { + "global": "全球(api.minimax.io)", + "china": "中国(api.minimaxi.com)" + }, + "thinking": "思考模式", + "thinkingModes": { + "adaptive": "自适应", + "disabled": "关闭", + "always_on": "始终开启" + }, + "capabilities": "{{contextWindow}} token 上下文 | 输入:{{modalities}}" }, "glm": { "apiKeyPlaceholder": "输入 Z.AI API Key" diff --git a/gitnexus-web/test/unit/agent-abort.test.ts b/gitnexus-web/test/unit/agent-abort.test.ts index 2a8475e34..92af12a0e 100644 --- a/gitnexus-web/test/unit/agent-abort.test.ts +++ b/gitnexus-web/test/unit/agent-abort.test.ts @@ -95,3 +95,34 @@ describe('streamAgentResponse abort', () => { expect(chunks).toEqual([{ type: 'error', error: 'Cannot abort the current transaction' }]); }); }); + +describe('streamAgentResponse content blocks', () => { + const userMessage: AgentMessage[] = [{ role: 'user', content: 'hello' }]; + + it('emits thinking blocks as reasoning', async () => { + const agent = { + stream: async function* () { + yield [ + 'messages', + [ + { + _getType: () => 'ai', + content: [{ type: 'thinking', thinking: 'Reviewing the repository context.' }], + tool_calls: [], + }, + ], + ]; + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage)) { + chunks.push(chunk); + } + + expect(chunks).toEqual([ + { type: 'reasoning', reasoning: 'Reviewing the repository context.' }, + { type: 'done', historyMessages: undefined }, + ]); + }); +}); diff --git a/gitnexus-web/test/unit/agent-history.test.ts b/gitnexus-web/test/unit/agent-history.test.ts index 756534b25..f672e8a9d 100644 --- a/gitnexus-web/test/unit/agent-history.test.ts +++ b/gitnexus-web/test/unit/agent-history.test.ts @@ -10,6 +10,7 @@ import { DeepSeekChatOpenAI, DeepSeekChatOpenAICompletions, } from '../../src/core/llm/deepseek-chat-model'; +import { MINIMAX_ANTHROPIC_BASE_URLS, MINIMAX_MODEL_IDS } from '../../src/core/llm/types'; describe('buildLangChainMessages', () => { it('reconstructs assistant tool-call turns for replay', () => { @@ -50,6 +51,24 @@ describe('buildLangChainMessages', () => { ]); expect((langChainMessages[2] as any).tool_call_id).toBe('call_weather'); }); + + it('preserves MiniMax image and video content blocks', () => { + const content = [ + { type: 'text' as const, text: 'Compare these inputs.' }, + { + type: 'image' as const, + source: { type: 'url' as const, url: 'https://example.com/image.png' }, + }, + { + type: 'video' as const, + source: { type: 'url' as const, url: 'https://example.com/video.mp4', fps: 1 }, + }, + ]; + + const [message] = buildLangChainMessages([{ role: 'user', content }]); + + expect((message as any).content).toEqual(content); + }); }); describe('serializeAgentHistoryMessages', () => { @@ -206,6 +225,48 @@ it('drops reasoningContent from serialized assistant messages without tool calls }); describe('createChatModel', () => { + it('configures MiniMax-M3 adaptive thinking on the China endpoint', () => { + const model = createChatModel({ + provider: 'minimax', + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, + thinkingMode: 'adaptive', + temperature: 0.1, + } as any) as any; + + expect(model.model).toBe(MINIMAX_MODEL_IDS[0]); + expect(model.clientOptions.baseURL).toBe(MINIMAX_ANTHROPIC_BASE_URLS.cn_zh); + expect(model.thinking).toEqual({ type: 'adaptive' }); + expect(model.temperature).toBeUndefined(); + }); + + it('supports disabled thinking for MiniMax-M3', () => { + const model = createChatModel({ + provider: 'minimax', + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[0], + thinkingMode: 'disabled', + temperature: 0.1, + } as any) as any; + + expect(model.thinking).toEqual({ type: 'disabled' }); + expect(model.temperature).toBe(0.1); + }); + + it('keeps MiniMax-M2.7 thinking always on', () => { + const model = createChatModel({ + provider: 'minimax', + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[1], + thinkingMode: 'disabled', + temperature: 0.1, + } as any) as any; + + expect(model.invocationParams({}).thinking).toBeUndefined(); + expect(model.temperature).toBeUndefined(); + }); + it('keeps DeepSeek model subclasses on withConfig clones used for tool binding', () => { const model = createChatModel({ provider: 'deepseek', diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts index a9ded356f..b0762604f 100644 --- a/gitnexus-web/test/unit/settings-service.test.ts +++ b/gitnexus-web/test/unit/settings-service.test.ts @@ -10,6 +10,12 @@ import { getAvailableModels, getProviderCapabilities, } from '../../src/core/llm/settings-service'; +import { + getMiniMaxModelCapabilities, + MINIMAX_ANTHROPIC_BASE_URLS, + MINIMAX_MODEL_IDS, +} from '../../src/core/llm/types'; +import { createChatModel } from '../../src/core/llm/agent'; describe('loadSettings', () => { it('returns defaults when nothing is stored', () => { @@ -17,6 +23,11 @@ describe('loadSettings', () => { expect(settings.activeProvider).toBeDefined(); expect(settings.openai).toBeDefined(); expect(settings.ollama).toBeDefined(); + expect(settings.minimax).toMatchObject({ + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.global_en, + thinkingMode: 'adaptive', + }); }); it('merges stored values with defaults', () => { @@ -35,6 +46,30 @@ describe('loadSettings', () => { expect(settings.openai).toBeDefined(); }); + it('migrates unsupported legacy MiniMax models to the current default', () => { + sessionStorage.setItem( + 'gitnexus-llm-settings', + JSON.stringify({ + activeProvider: 'minimax', + minimax: { + apiKey: 'minimax-test-key', + model: 'MiniMax-M2.5', + temperature: 0.1, + }, + }), + ); + + const settings = loadSettings(); + expect(settings.minimax).toMatchObject({ + model: MINIMAX_MODEL_IDS[0], + thinkingMode: 'adaptive', + }); + + const model = createChatModel(getActiveProviderConfig()!) as any; + expect(model.model).toBe(MINIMAX_MODEL_IDS[0]); + expect(model.thinking).toEqual({ type: 'adaptive' }); + }); + it('returns defaults on corrupted JSON', () => { sessionStorage.setItem('gitnexus-llm-settings', 'not-json{{{'); const settings = loadSettings(); @@ -116,6 +151,26 @@ describe('getActiveProviderConfig', () => { expect(config!.provider).toBe('deepseek'); }); + it('returns the regional endpoint and thinking mode for MiniMax', () => { + const settings = loadSettings(); + settings.activeProvider = 'minimax'; + settings.minimax = { + ...settings.minimax, + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, + thinkingMode: 'disabled', + }; + saveSettings(settings); + + expect(getActiveProviderConfig()).toMatchObject({ + provider: 'minimax', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, + thinkingMode: 'disabled', + }); + }); + it('returns null for openrouter with empty API key', () => { const settings = loadSettings(); settings.activeProvider = 'openrouter'; @@ -161,6 +216,20 @@ describe('getAvailableModels', () => { expect(getAvailableModels('ollama').length).toBeGreaterThan(0); expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514'); expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash'); + expect(getAvailableModels('minimax')).toEqual([...MINIMAX_MODEL_IDS]); + }); + + it('describes MiniMax model input and thinking capabilities', () => { + expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[0])).toEqual({ + contextWindow: 1_000_000, + inputModalities: ['text', 'image', 'video'], + thinkingModes: ['adaptive', 'disabled'], + }); + expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[1])).toEqual({ + contextWindow: 204_800, + inputModalities: ['text'], + thinkingModes: ['always_on'], + }); }); it('returns empty array for unknown provider', () => { diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index 342e8b08f..853d44860 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -60,7 +60,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | Flag | Effect | | ------------------- | ----------------------------------------- | | `--force` | Force full regeneration | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--model ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 566dc6d87..c12d18b85 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -226,16 +226,15 @@ export const en = { 'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)', 'help.option.wiki.force': 'Force full regeneration even if up to date', 'help.option.wiki.provider': - 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', - 'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)', + 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: minimax)', + 'help.option.wiki.model': 'LLM model or deployment name (default: MiniMax-M3)', 'help.option.wiki.baseUrl': 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', 'help.option.wiki.apiKey': 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)', 'help.option.wiki.apiVersion': 'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)', - 'help.option.wiki.reasoningModel': - 'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens', - 'help.option.wiki.noReasoningModel': 'Disable reasoning model mode (overrides saved config)', + 'help.option.wiki.reasoningModel': 'Enable reasoning mode; MiniMax-M3 uses adaptive thinking', + 'help.option.wiki.noReasoningModel': 'Disable reasoning mode; MiniMax-M3 disables thinking', 'help.option.wiki.concurrency': 'Parallel LLM calls (default: 3)', 'help.option.wiki.timeout': 'LLM request timeout in seconds (default: disabled)', 'help.option.wiki.retries': 'Max LLM retry attempts per request (default: 3)', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 0c99d37d9..827587dd9 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -214,15 +214,14 @@ export const zhCN = { '清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', 'help.option.wiki.provider': - 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)', - 'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)', + 'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:minimax)', + 'help.option.wiki.model': 'LLM 模型或 deployment 名称(默认:MiniMax-M3)', 'help.option.wiki.baseUrl': 'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1', 'help.option.wiki.apiKey': 'LLM API key 或 Azure api-key(保存到 ~/.gitnexus/config.json)', 'help.option.wiki.apiVersion': 'Azure api-version 查询参数,例如 2024-10-21(仅旧版 Azure API)', - 'help.option.wiki.reasoningModel': - '标记 deployment 为 reasoning model(o1/o3/o4-mini)— 去除 temperature,使用 max_completion_tokens', - 'help.option.wiki.noReasoningModel': '禁用 reasoning model 模式(覆盖已保存配置)', + 'help.option.wiki.reasoningModel': '启用 reasoning 模式;MiniMax-M3 使用自适应 thinking', + 'help.option.wiki.noReasoningModel': '禁用 reasoning 模式;MiniMax-M3 关闭 thinking', 'help.option.wiki.concurrency': '并行 LLM 调用数(默认:3)', 'help.option.wiki.timeout': 'LLM 请求超时时间(秒,默认:禁用)', 'help.option.wiki.retries': '每个请求的最大 LLM 重试次数(默认:3)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 41aad4d6a..1ccf75c2f 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -303,9 +303,9 @@ program .option('-f, --force', 'Force full regeneration even if up to date') .option( '--provider ', - 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', + 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: minimax)', ) - .option('--model ', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)') + .option('--model ', 'LLM model or deployment name (default: MiniMax-M3)') .option( '--base-url ', 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', @@ -315,11 +315,8 @@ program '--api-version ', 'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)', ) - .option( - '--reasoning-model', - 'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens', - ) - .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') + .option('--reasoning-model', 'Enable reasoning mode; MiniMax-M3 uses adaptive thinking') + .option('--no-reasoning-model', 'Disable reasoning mode; MiniMax-M3 disables thinking') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') .option('--timeout ', 'LLM request timeout in seconds (default: disabled)') .option('--retries ', 'Max LLM retry attempts per request (default: 3)') diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index ef6776fbd..d65d130a7 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -18,6 +18,8 @@ import { } from '../storage/repo-manager.js'; import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js'; import { + MINIMAX_MODEL_IDS, + MINIMAX_OPENAI_BASE_URLS, parseLLMAllowedInsecureHttpHosts, resolveLLMConfig, type LLMProvider, @@ -216,11 +218,30 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) ) { const existing = await loadCLIConfig(); const updates: Partial = {}; + const providerChanged = !!options.provider && options.provider !== existing.provider; + if (providerChanged) { + updates.apiKey = undefined; + updates.baseUrl = undefined; + updates.model = undefined; + updates.apiVersion = undefined; + updates.isReasoningModel = undefined; + } if (options.apiKey) updates.apiKey = options.apiKey; if (options.baseUrl) updates.baseUrl = options.baseUrl; if (options.provider) updates.provider = options.provider; if (options.apiVersion) updates.apiVersion = options.apiVersion; if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel; + if (options.provider === 'minimax') { + if (providerChanged && options.reasoningModel === undefined) { + updates.isReasoningModel = undefined; + } + if (!options.baseUrl && (providerChanged || !existing.baseUrl)) { + updates.baseUrl = MINIMAX_OPENAI_BASE_URLS.global_en; + } + if (!options.model && (providerChanged || !existing.model)) { + updates.model = MINIMAX_MODEL_IDS[0]; + } + } // Save model to appropriate field based on provider. if (options.model) { const targetProvider = options.provider ?? existing.provider; @@ -237,7 +258,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) const savedConfig = await loadCLIConfig(); const hasSavedConfig = !!( isLocalProvider(savedConfig.provider) || - (savedConfig.apiKey && savedConfig.baseUrl) + (savedConfig.apiKey && (savedConfig.baseUrl || savedConfig.provider === 'minimax')) ); const hasCLIOverrides = !!( options?.apiKey || @@ -265,7 +286,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) // Non-interactive mode — need either API key or Cursor CLI if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) { console.log(' Error: No LLM API key found.'); - console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,'); + console.log(' Set MINIMAX_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,'); console.log(' or pass --api-key , or use --provider cursor|claude|codex|opencode.\n'); process.exitCode = 1; return; @@ -273,9 +294,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) // Non-interactive with env var or cursor — just use it } else { console.log(" No LLM configured. Let's set it up.\n"); - console.log( - ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n', - ); + console.log(' Supports MiniMax, OpenAI-compatible APIs, and local agent CLIs.\n'); // Check if local agent CLIs are available. const hasCursor = detectCursorCLI(); @@ -292,7 +311,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) console.log(' [2] OpenRouter (openrouter.ai)'); console.log(' [3] Azure OpenAI'); console.log(' [4] Custom endpoint'); - let nextChoice = 5; + console.log(' [5] MiniMax Global (api.minimax.io)'); + console.log(' [6] MiniMax China (api.minimaxi.com)'); + let nextChoice = 7; if (hasCursor) { const choice = String(nextChoice++); localChoices.push({ @@ -413,10 +434,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) provider: 'azure', }; } else { - // OpenAI-compatible provider (OpenAI, OpenRouter, Custom) + // OpenAI-compatible provider setup if (choice === '2') { baseUrl = 'https://openrouter.ai/api/v1'; - defaultModel = 'minimax/minimax-m2.5'; + defaultModel = ''; provider = 'openrouter'; } else if (choice === '4') { baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): '); @@ -427,6 +448,11 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } defaultModel = 'gpt-4o-mini'; provider = 'custom'; + } else if (choice === '5' || choice === '6') { + baseUrl = + choice === '6' ? MINIMAX_OPENAI_BASE_URLS.cn_zh : MINIMAX_OPENAI_BASE_URLS.global_en; + defaultModel = MINIMAX_MODEL_IDS[0]; + provider = 'minimax'; } else { baseUrl = 'https://api.openai.com/v1'; defaultModel = 'gpt-4o-mini'; @@ -434,11 +460,22 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } // Model - const modelInput = await prompt(` Model (default: ${defaultModel}): `); + const modelInput = await prompt( + defaultModel ? ` Model (default: ${defaultModel}): ` : ' Model: ', + ); const model = modelInput || defaultModel; + if (!model) { + console.log('\n No model provided. Aborting.\n'); + process.exitCode = 1; + return; + } // API key — pre-fill hint if env var exists - const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || ''; + 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): `); @@ -458,7 +495,15 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } // Save - await saveCLIConfig({ apiKey: key, baseUrl, model, provider }); + await saveCLIConfig({ + ...savedConfig, + apiKey: key, + baseUrl, + model, + provider, + apiVersion: undefined, + isReasoningModel: undefined, + }); console.log(' Config saved to ~/.gitnexus/config.json\n'); llmConfig = { ...llmConfig, apiKey: key, baseUrl, model, provider }; diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 2fe42cdf0..9e5988550 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -4,7 +4,7 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from ' * LLM Client for Wiki Generation * * OpenAI-compatible API client using native fetch. - * Supports OpenAI, Azure, LiteLLM, Ollama, and any OpenAI-compatible endpoint. + * Supports MiniMax and other OpenAI-compatible endpoints. * * Config priority: CLI flags > env vars > defaults */ @@ -17,7 +17,40 @@ export type LLMProvider = | 'cursor' | 'claude' | 'codex' - | 'opencode'; + | 'opencode' + | 'minimax'; + +export const MINIMAX_OPENAI_BASE_URLS = { + global_en: 'https://api.minimax.io/v1', + cn_zh: 'https://api.minimaxi.com/v1', +} as const; + +export const MINIMAX_MODEL_IDS = ['MiniMax-M3', 'MiniMax-M2.7'] as const; + +export type MiniMaxThinkingMode = 'adaptive' | 'disabled' | 'always_on'; + +export type LLMUserContent = + | string + | Array< + | { type: 'text'; text: string } + | { + type: 'image_url'; + image_url: { + url: string; + detail?: 'low' | 'default' | 'high'; + max_long_side_pixel?: number; + }; + } + | { + type: 'video_url'; + video_url: { + url: string; + detail?: 'low' | 'default' | 'high'; + fps?: number; + max_long_side_pixel?: number; + }; + } + >; export interface LLMConfig { apiKey: string; @@ -45,6 +78,17 @@ export interface LLMResponse { completionTokens?: number; } +export function resolveMiniMaxThinkingMode( + model: string, + reasoningOverride?: boolean, +): MiniMaxThinkingMode | undefined { + if (model === MINIMAX_MODEL_IDS[1]) return 'always_on'; + if (model === MINIMAX_MODEL_IDS[0]) { + return reasoningOverride === false ? 'disabled' : 'adaptive'; + } + return undefined; +} + /** * Resolve LLM configuration from env vars, saved config, and optional overrides. * Priority: overrides (CLI flags) > env vars > ~/.gitnexus/config.json > error @@ -54,7 +98,11 @@ export interface LLMResponse { export async function resolveLLMConfig(overrides?: Partial): Promise { const { loadCLIConfig } = await import('../../storage/repo-manager.js'); const savedConfig = await loadCLIConfig(); - const savedProvider = overrides?.provider ?? savedConfig.provider; + const hasLegacyHttpConfig = !savedConfig.provider && !!(savedConfig.model || savedConfig.baseUrl); + const savedProvider = + overrides?.provider ?? savedConfig.provider ?? (hasLegacyHttpConfig ? 'openai' : 'minimax'); + const reuseSavedHttpConfig = + savedConfig.provider === savedProvider || (hasLegacyHttpConfig && savedProvider === 'openai'); const savedLocalModel = savedProvider === 'cursor' ? savedConfig.cursorModel @@ -73,9 +121,10 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< const apiKey = overrides?.apiKey || - process.env.GITNEXUS_API_KEY || - process.env.OPENAI_API_KEY || - savedConfig.apiKey || + (savedProvider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || + (savedProvider !== 'minimax' ? process.env.GITNEXUS_API_KEY : undefined) || + (savedProvider !== 'minimax' ? process.env.OPENAI_API_KEY : undefined) || + (reuseSavedHttpConfig ? savedConfig.apiKey : undefined) || ''; return { @@ -83,19 +132,28 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< baseUrl: overrides?.baseUrl || process.env.GITNEXUS_LLM_BASE_URL || - savedConfig.baseUrl || - 'https://openrouter.ai/api/v1', + (reuseSavedHttpConfig ? savedConfig.baseUrl : undefined) || + (savedProvider === 'minimax' + ? MINIMAX_OPENAI_BASE_URLS.global_en + : 'https://openrouter.ai/api/v1'), model: overrides?.model || (localProvider ? undefined : process.env.GITNEXUS_MODEL) || savedLocalModel || - (localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'), + (localProvider + ? '' + : (reuseSavedHttpConfig ? savedConfig.model : undefined) || + (savedProvider === 'minimax' ? MINIMAX_MODEL_IDS[0] : '')), maxTokens: overrides?.maxTokens ?? 16_384, temperature: overrides?.temperature ?? 0, - provider: savedProvider ?? 'openai', + provider: savedProvider, apiVersion: - overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion, - isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel, + overrides?.apiVersion || + (savedProvider === 'azure' ? process.env.GITNEXUS_AZURE_API_VERSION : undefined) || + (reuseSavedHttpConfig ? savedConfig.apiVersion : undefined), + isReasoningModel: + overrides?.isReasoningModel ?? + (reuseSavedHttpConfig ? savedConfig.isReasoningModel : undefined), allowedInsecureHttpHosts: overrides?.allowedInsecureHttpHosts ?? parseLLMAllowedInsecureHttpHosts(process.env[LLM_ALLOW_INSECURE_CONNECTION_ENV]), @@ -252,7 +310,7 @@ export interface CallLLMOptions { * Retries up to 3 times on transient failures (429, 5xx, network errors). */ export async function callLLM( - prompt: string, + prompt: LLMUserContent, config: LLMConfig, systemPrompt?: string, options?: CallLLMOptions, @@ -260,7 +318,7 @@ export async function callLLM( // Validate base URL before any fetch (CodeQL js/http-to-file-access) validateLLMBaseUrl(config.baseUrl, config.allowedInsecureHttpHosts); - const messages: Array<{ role: string; content: string }> = []; + const messages: Array<{ role: string; content: LLMUserContent }> = []; if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } @@ -276,8 +334,15 @@ export async function callLLM( ); } - // Detect reasoning model (o1, o3, o4-mini etc.) or explicit override - const reasoning = isReasoningModel(config.model, config.isReasoningModel); + const miniMaxThinkingMode = + config.provider === 'minimax' + ? resolveMiniMaxThinkingMode(config.model, config.isReasoningModel) + : undefined; + + // Detect reasoning models or explicit provider-specific thinking configuration. + const reasoning = miniMaxThinkingMode + ? miniMaxThinkingMode !== 'disabled' + : isReasoningModel(config.model, config.isReasoningModel); const url = buildRequestUrl(config.baseUrl, azure ? config.apiVersion : undefined); const useStream = !!options?.onChunk; @@ -288,6 +353,13 @@ export async function callLLM( messages, }; + if (miniMaxThinkingMode === 'adaptive' || miniMaxThinkingMode === 'disabled') { + body.thinking = { type: miniMaxThinkingMode }; + } + if (config.provider === 'minimax') { + body.reasoning_split = true; + } + // max_tokens is deprecated; use max_completion_tokens for all models body.max_completion_tokens = config.maxTokens; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index cbfdf441f..da089a666 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -1982,7 +1982,8 @@ export interface CLIConfig { | 'cursor' | 'claude' | 'codex' - | 'opencode'; + | 'opencode' + | 'minimax'; cursorModel?: string; claudeModel?: string; codexModel?: string; diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 43d2a070a..69ae6762a 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -143,6 +143,7 @@ describe('resolveLLMConfig', () => { afterEach(async () => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -211,7 +212,7 @@ describe('resolveLLMConfig', () => { vi.doMock('../../src/storage/repo-manager.js', () => ({ loadCLIConfig: vi.fn().mockResolvedValue({ provider: 'openai', - model: 'minimax/minimax-m2.5', + model: 'legacy-http-model', }), })); @@ -226,7 +227,7 @@ describe('resolveLLMConfig', () => { vi.doMock('../../src/storage/repo-manager.js', () => ({ loadCLIConfig: vi.fn().mockResolvedValue({ provider: 'openai', - model: 'minimax/minimax-m2.5', + model: 'legacy-http-model', }), })); @@ -237,7 +238,22 @@ describe('resolveLLMConfig', () => { expect(config.model).toBe(''); }); - it('uses default OpenRouter model for openai provider', async () => { + it('uses MiniMax global defaults when no provider is configured', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({}), + })); + + const { MINIMAX_MODEL_IDS, MINIMAX_OPENAI_BASE_URLS, resolveLLMConfig } = + await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig(); + + expect(config.provider).toBe('minimax'); + expect(config.model).toBe(MINIMAX_MODEL_IDS[0]); + expect(config.baseUrl).toBe(MINIMAX_OPENAI_BASE_URLS.global_en); + }); + + it('uses the MiniMax-specific API key environment variable', async () => { + vi.stubEnv('MINIMAX_API_KEY', 'minimax-env-key'); vi.doMock('../../src/storage/repo-manager.js', () => ({ loadCLIConfig: vi.fn().mockResolvedValue({}), })); @@ -245,9 +261,43 @@ describe('resolveLLMConfig', () => { const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); const config = await resolveLLMConfig(); + expect(config.apiKey).toBe('minimax-env-key'); + }); + + it('preserves the configured China endpoint', async () => { + const { MINIMAX_MODEL_IDS, MINIMAX_OPENAI_BASE_URLS } = + await import('../../src/core/wiki/llm-client.js'); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'minimax', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_OPENAI_BASE_URLS.cn_zh, + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig(); + + expect(config.provider).toBe('minimax'); + expect(config.model).toBe(MINIMAX_MODEL_IDS[0]); + expect(config.baseUrl).toBe(MINIMAX_OPENAI_BASE_URLS.cn_zh); + }); + + it('preserves providerless HTTP configs created by earlier versions', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'legacy-http-key', + model: 'legacy-http-model', + baseUrl: 'https://legacy.example/v1', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig(); + expect(config.provider).toBe('openai'); - expect(config.model).toBe('minimax/minimax-m2.5'); - expect(config.baseUrl).toBe('https://openrouter.ai/api/v1'); + expect(config.model).toBe('legacy-http-model'); + expect(config.baseUrl).toBe('https://legacy.example/v1'); }); it('CLI overrides take priority over saved config', async () => { @@ -270,6 +320,146 @@ describe('resolveLLMConfig', () => { }); }); +describe('wikiCommand provider switch persistence', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + async function saveProviderSwitch( + existing: Record, + options: Record, + ) { + const saveCLIConfig = vi.fn(); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue(existing), + saveCLIConfig, + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: options.apiKey ?? '', + baseUrl: options.baseUrl ?? '', + model: options.model ?? '', + maxTokens: 16_384, + temperature: 0, + provider: options.provider, + apiVersion: options.apiVersion, + isReasoningModel: options.reasoningModel, + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: vi.fn().mockImplementation(function () { + return { + run: vi.fn().mockResolvedValue({ mode: 'up-to-date', pagesGenerated: 0 }), + }; + }), + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + await wikiCommand('/tmp/repo', options as Parameters[1]); + + return saveCLIConfig; + } + + it('preserves explicit OpenAI settings when switching away from MiniMax', async () => { + const saveCLIConfig = await saveProviderSwitch( + { + provider: 'minimax', + apiKey: 'old-minimax-key', + baseUrl: 'https://api.minimax.io/v1', + model: 'MiniMax-M3', + apiVersion: 'old-version', + isReasoningModel: false, + }, + { + provider: 'openai', + apiKey: 'new-openai-key', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5', + apiVersion: 'v1', + reasoningModel: true, + }, + ); + + expect(saveCLIConfig).toHaveBeenCalledWith({ + provider: 'openai', + apiKey: 'new-openai-key', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5', + apiVersion: 'v1', + isReasoningModel: true, + }); + }); + + it('preserves explicit MiniMax settings when switching from OpenAI', async () => { + const saveCLIConfig = await saveProviderSwitch( + { + provider: 'openai', + apiKey: 'old-openai-key', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + apiVersion: 'old-version', + isReasoningModel: true, + }, + { + provider: 'minimax', + apiKey: 'new-minimax-key', + baseUrl: 'https://api.minimaxi.com/v1', + model: 'MiniMax-M2.7', + apiVersion: 'v2', + reasoningModel: false, + }, + ); + + expect(saveCLIConfig).toHaveBeenCalledWith({ + provider: 'minimax', + apiKey: 'new-minimax-key', + baseUrl: 'https://api.minimaxi.com/v1', + model: 'MiniMax-M2.7', + apiVersion: 'v2', + isReasoningModel: false, + }); + }); +}); + // ─── --verbose flag ────────────────────────────────────────────────── describe('--verbose flag', () => { diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 91f8660db..2a20c7591 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; // Import the function we'll add in the next step import { LLM_ALLOW_INSECURE_CONNECTION_ENV, + MINIMAX_MODEL_IDS, + MINIMAX_OPENAI_BASE_URLS, isAzureProvider, isReasoningModel, buildRequestUrl, @@ -57,7 +59,7 @@ describe('isReasoningModel', () => { }); it('returns false for minimax', () => { - expect(isReasoningModel('minimax/minimax-m2.5')).toBe(false); + expect(isReasoningModel(MINIMAX_MODEL_IDS[1])).toBe(false); }); it('respects explicit override', () => { @@ -94,6 +96,40 @@ describe('buildRequestUrl', () => { }); }); +describe('resolveLLMConfig provider isolation', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('does not use OpenAI environment credentials for the default MiniMax provider', async () => { + vi.stubEnv('OPENAI_API_KEY', 'openai-key'); + vi.stubEnv('GITNEXUS_API_KEY', 'gitnexus-key'); + vi.stubEnv('MINIMAX_API_KEY', ''); + + const config = await resolveLLMConfig(); + + expect(config.provider).toBe('minimax'); + expect(config.apiKey).toBe(''); + }); + + it('does not reuse saved credentials or API versions after switching providers', async () => { + vi.spyOn(await import('../../src/storage/repo-manager.js'), 'loadCLIConfig').mockResolvedValue({ + provider: 'minimax', + apiKey: 'minimax-key', + baseUrl: MINIMAX_OPENAI_BASE_URLS.global_en, + model: MINIMAX_MODEL_IDS[0], + apiVersion: 'minimax-version', + }); + + const config = await resolveLLMConfig({ provider: 'openai' }); + + expect(config.apiKey).toBe(''); + expect(config.apiVersion).toBeUndefined(); + expect(config.baseUrl).toBe('https://openrouter.ai/api/v1'); + }); +}); + describe('callLLM — auth header', () => { afterEach(() => vi.unstubAllGlobals()); @@ -240,6 +276,113 @@ describe('callLLM — reasoning model params', () => { }); }); +describe('callLLM — MiniMax request params', () => { + afterEach(() => vi.unstubAllGlobals()); + + const createFetchSpy = () => + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + it('uses the China endpoint with adaptive thinking by default', async () => { + const fetchSpy = createFetchSpy(); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'minimax-test-key', + baseUrl: MINIMAX_OPENAI_BASE_URLS.cn_zh, + model: MINIMAX_MODEL_IDS[0], + maxTokens: 500, + temperature: 0.5, + provider: 'minimax', + }); + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(url).toBe(`${MINIMAX_OPENAI_BASE_URLS.cn_zh}/chat/completions`); + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body.reasoning_split).toBe(true); + expect(body.temperature).toBeUndefined(); + }); + + it('supports disabled thinking', async () => { + const fetchSpy = createFetchSpy(); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'minimax-test-key', + baseUrl: MINIMAX_OPENAI_BASE_URLS.global_en, + model: MINIMAX_MODEL_IDS[0], + maxTokens: 500, + temperature: 0.5, + provider: 'minimax', + isReasoningModel: false, + }); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.thinking).toEqual({ type: 'disabled' }); + expect(body.temperature).toBe(0.5); + }); + + it('leaves always-on thinking implicit', async () => { + const fetchSpy = createFetchSpy(); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'minimax-test-key', + baseUrl: MINIMAX_OPENAI_BASE_URLS.global_en, + model: MINIMAX_MODEL_IDS[1], + maxTokens: 500, + temperature: 0.5, + provider: 'minimax', + isReasoningModel: false, + }); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_split).toBe(true); + expect(body.temperature).toBeUndefined(); + }); + + it('preserves image and video content parts', async () => { + const fetchSpy = createFetchSpy(); + vi.stubGlobal('fetch', fetchSpy); + const prompt = [ + { type: 'text' as const, text: 'Compare these inputs.' }, + { + type: 'image_url' as const, + image_url: { url: 'https://example.com/image.png', detail: 'high' as const }, + }, + { + type: 'video_url' as const, + video_url: { url: 'https://example.com/video.mp4', fps: 1 }, + }, + ]; + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM(prompt, { + apiKey: 'minimax-test-key', + baseUrl: MINIMAX_OPENAI_BASE_URLS.global_en, + model: MINIMAX_MODEL_IDS[0], + maxTokens: 500, + temperature: 0.5, + provider: 'minimax', + }); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.messages).toEqual([{ role: 'user', content: prompt }]); + }); +}); + describe('callLLM — timeout handling', () => { afterEach(() => { vi.restoreAllMocks();