feat: refresh MiniMax model and endpoint configuration (#2780)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run

This commit is contained in:
Octopus 2026-08-12 02:11:47 +08:00 committed by GitHub
parent 5f9648744c
commit 0fa547ccdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 902 additions and 74 deletions

View file

@ -60,7 +60,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--model <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |

View file

@ -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 <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--model <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |

View file

@ -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'),
}}
/>
>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">
{t('settings:providers.minimax.endpoint')}
</label>
<select
value={miniMaxBaseUrl}
onChange={(event) =>
setSettings((prev) => ({
...prev,
minimax: { ...prev.minimax!, baseUrl: event.target.value },
}))
}
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none focus:border-accent focus:ring-2 focus:ring-accent/20"
>
<option value={MINIMAX_ANTHROPIC_BASE_URLS.global_en}>
{t('settings:providers.minimax.endpoints.global')}
</option>
<option value={MINIMAX_ANTHROPIC_BASE_URLS.cn_zh}>
{t('settings:providers.minimax.endpoints.china')}
</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">
{t('settings:providers.minimax.thinking')}
</label>
<select
value={miniMaxThinkingMode}
disabled={miniMaxCapabilities?.thinkingModes.length === 1}
onChange={(event) =>
setSettings((prev) => ({
...prev,
minimax: {
...prev.minimax!,
thinkingMode: event.target.value as MiniMaxThinkingMode,
},
}))
}
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-sm text-text-primary transition-all outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{(miniMaxCapabilities?.thinkingModes ?? ['adaptive', 'disabled']).map((mode) => (
<option key={mode} value={mode}>
{t(`settings:providers.minimax.thinkingModes.${mode}`)}
</option>
))}
</select>
{miniMaxCapabilities && (
<p className="text-xs text-text-muted">
{t('settings:providers.minimax.capabilities', {
contextWindow: miniMaxCapabilities.contextWindow.toLocaleString(),
modalities: miniMaxCapabilities.inputModalities.join(', '),
})}
</p>
)}
</div>
</ProviderConfigCard>
)}
{/* DeepSeek Settings */}

View file

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

View file

@ -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<LLMSettings['minimax']> => {
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<LLMSettings> | null): LLMSettings => ({
...DEFAULT_LLM_SETTINGS,
...parsed,
@ -52,10 +72,7 @@ const mergeWithDefaults = (parsed?: Partial<LLMSettings> | 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':

View file

@ -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<MiniMaxModelId, MiniMaxModelCapabilities> = {
'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: {

View file

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

View file

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

View file

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

View file

@ -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',

View file

@ -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', () => {

View file

@ -60,7 +60,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--model <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |

View file

@ -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)',

View file

@ -214,15 +214,14 @@ export const zhCN = {
'清理已暂存的 LadybugDB 恢复 sidecarmissing-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 v1https://{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 modelo1/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',

View file

@ -303,9 +303,9 @@ program
.option('-f, --force', 'Force full regeneration even if up to date')
.option(
'--provider <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 <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
.option('--model <model>', 'LLM model or deployment name (default: MiniMax-M3)')
.option(
'--base-url <url>',
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
@ -315,11 +315,8 @@ program
'--api-version <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 <n>', 'Parallel LLM calls (default: 3)', '3')
.option('--timeout <seconds>', 'LLM request timeout in seconds (default: disabled)')
.option('--retries <n>', 'Max LLM retry attempts per request (default: 3)')

View file

@ -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<typeof existing> = {};
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 <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 };

View file

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

View file

@ -1982,7 +1982,8 @@ export interface CLIConfig {
| 'cursor'
| 'claude'
| 'codex'
| 'opencode';
| 'opencode'
| 'minimax';
cursorModel?: string;
claudeModel?: string;
codexModel?: string;

View file

@ -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<string, unknown>,
options: Record<string, unknown>,
) {
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<typeof import('../../src/core/wiki/llm-client.js')>();
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<typeof wikiCommand>[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', () => {

View file

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