diff --git a/README.md b/README.md index be457922..fc427b74 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ - **See every session in one place** — switch context without losing momentum. - **Control everything keyboard-first** — every action has a shortcut, mouse optional. - **Monitor progress from your phone** — scan a QR code, watch agents work over Wi-Fi or Tailscale. -- **Ask about code with any LLM** — the inline code Q&A feature supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (default) or [MiniMax](https://www.minimax.io/) M2.7 (204K context) — configurable in Settings. +- **Ask about code with any LLM** — the inline code Q&A feature supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (default) or [MiniMax](https://www.minimax.io/) M3 and M2.7 for text-only code questions — configurable in Settings.
How does it compare? diff --git a/electron/ipc/ask-code-minimax.test.ts b/electron/ipc/ask-code-minimax.test.ts index 8a73d51a..d4b28811 100644 --- a/electron/ipc/ask-code-minimax.test.ts +++ b/electron/ipc/ask-code-minimax.test.ts @@ -7,7 +7,6 @@ vi.stubGlobal('fetch', mockFetch); import { askAboutCodeMinimax, cancelAskAboutCodeMinimax, - MINIMAX_MODEL, setMinimaxApiKey, } from './ask-code-minimax.js'; @@ -164,7 +163,7 @@ describe('askAboutCodeMinimax', () => { ); }); - it('uses MiniMax-M2.7 model', async () => { + it('uses MiniMax-M3 by default', async () => { const { win, messages } = makeMockWin(); mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); @@ -180,7 +179,27 @@ describe('askAboutCodeMinimax', () => { const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { model: string; }; - expect(body.model).toBe(MINIMAX_MODEL); + expect(body.model).toBe('MiniMax-M3'); + }); + + it('uses the selected MiniMax model', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r6-model', + channelId: 'ch6-model', + prompt: 'Test', + modelId: 'MiniMax-M2.7', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + model: string; + }; + expect(body.model).toBe('MiniMax-M2.7'); }); it('uses temperature in MiniMax allowed range (0, 1]', async () => { diff --git a/electron/ipc/ask-code-minimax.ts b/electron/ipc/ask-code-minimax.ts index 0f262296..8347319a 100644 --- a/electron/ipc/ask-code-minimax.ts +++ b/electron/ipc/ask-code-minimax.ts @@ -8,15 +8,22 @@ import { assertCanStart, assertPromptWithinLimit, } from './request-registry.js'; +import { + DEFAULT_MINIMAX_MODEL_ID, + DEFAULT_MINIMAX_REGION, + getMinimaxModel, + minimaxChatCompletionsUrl, +} from './minimax-catalog.js'; interface MinimaxAskCodeRequest { requestId: string; channelId: string; prompt: string; + modelId?: string; } -const MINIMAX_API_URL = 'https://api.minimax.io/v1/chat/completions'; -export const MINIMAX_MODEL = 'MiniMax-M2.7'; +const MINIMAX_API_URL = minimaxChatCompletionsUrl(DEFAULT_MINIMAX_REGION); +export const MINIMAX_MODEL = DEFAULT_MINIMAX_MODEL_ID; const activeRequests = new RequestRegistry({ maxConcurrent: ASK_CODE_MAX_CONCURRENT, @@ -31,8 +38,10 @@ export function setMinimaxApiKey(key: string): void { } export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequest): void { - const { requestId, channelId, prompt } = args; + const { requestId, channelId, prompt, modelId } = args; const apiKey = storedApiKey; + const model = + getMinimaxModel(modelId ?? DEFAULT_MINIMAX_MODEL_ID)?.id ?? DEFAULT_MINIMAX_MODEL_ID; if (!apiKey) { throw new Error('MiniMax API key is not set. Please configure it in Settings.'); @@ -62,7 +71,7 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model: MINIMAX_MODEL, + model, messages: [ { role: 'system', diff --git a/electron/ipc/ask-code.ts b/electron/ipc/ask-code.ts index c6b1e867..2cb85a45 100644 --- a/electron/ipc/ask-code.ts +++ b/electron/ipc/ask-code.ts @@ -23,6 +23,7 @@ interface AskCodeRequest { prompt: string; cwd: string; provider?: AskCodeProvider; + model?: string; } const activeRequests = new RequestRegistry({ @@ -31,12 +32,12 @@ const activeRequests = new RequestRegistry({ }); export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { - const { requestId, channelId, prompt, cwd, provider } = args; + const { requestId, channelId, prompt, cwd, provider, model } = args; // Route to MiniMax backend when configured if (provider === 'minimax') { activeRequests.cancel(requestId); - askAboutCodeMinimax(win, { requestId, channelId, prompt }); + askAboutCodeMinimax(win, { requestId, channelId, prompt, modelId: model }); return; } diff --git a/electron/ipc/minimax-catalog.test.ts b/electron/ipc/minimax-catalog.test.ts new file mode 100644 index 00000000..57eaa032 --- /dev/null +++ b/electron/ipc/minimax-catalog.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_MINIMAX_MODEL_ID, + DEFAULT_MINIMAX_REGION, + MINIMAX_ENDPOINTS, + getMinimaxEndpoint, + getMinimaxModel, + minimaxBaseUrl, + minimaxChatCompletionsUrl, + minimaxModelIds, +} from './minimax-catalog.js'; + +describe('minimax catalog models', () => { + it('retains M2.7 alongside M3', () => { + expect(minimaxModelIds()).toEqual(['MiniMax-M3', 'MiniMax-M2.7']); + }); + + it('defaults to M3', () => { + expect(DEFAULT_MINIMAX_MODEL_ID).toBe('MiniMax-M3'); + expect(getMinimaxModel(DEFAULT_MINIMAX_MODEL_ID)).toBeDefined(); + }); + + it('represents M3 with a 1M context window, image/video input and adaptive/disabled thinking', () => { + const m3 = getMinimaxModel('MiniMax-M3'); + expect(m3).toBeDefined(); + expect(m3?.contextWindow).toBe(1_000_000); + expect(m3?.pricingUsdPerMillionTokens).toEqual({ + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: null, + }); + expect(m3?.inputModalities).toEqual(['text', 'image', 'video']); + expect(m3?.thinking).toEqual(['adaptive', 'disabled']); + }); + + it('represents M2.7 as a 204K text-only always-on model', () => { + const m27 = getMinimaxModel('MiniMax-M2.7'); + expect(m27).toBeDefined(); + expect(m27?.contextWindow).toBe(204_800); + expect(m27?.pricingUsdPerMillionTokens).toEqual({ + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }); + expect(m27?.inputModalities).toEqual(['text']); + expect(m27?.thinking).toEqual(['always_on']); + }); + + it('returns undefined for unknown models', () => { + expect(getMinimaxModel('nope')).toBeUndefined(); + }); +}); + +describe('minimax catalog endpoints', () => { + it('exposes the global and China regions', () => { + expect(MINIMAX_ENDPOINTS.map((e) => e.region)).toEqual(['global_en', 'cn_zh']); + }); + + it('exposes both protocol base URLs for the global region', () => { + expect(minimaxBaseUrl('global_en', 'openai')).toBe('https://api.minimax.io/v1'); + expect(minimaxBaseUrl('global_en', 'anthropic')).toBe('https://api.minimax.io/anthropic'); + }); + + it('exposes both protocol base URLs for the China region', () => { + expect(minimaxBaseUrl('cn_zh', 'openai')).toBe('https://api.minimaxi.com/v1'); + expect(minimaxBaseUrl('cn_zh', 'anthropic')).toBe('https://api.minimaxi.com/anthropic'); + }); + + it('defaults to the global region', () => { + expect(DEFAULT_MINIMAX_REGION).toBe('global_en'); + }); + + it('builds the chat completions URL from the region base URL', () => { + expect(minimaxChatCompletionsUrl('global_en')).toBe( + 'https://api.minimax.io/v1/chat/completions', + ); + expect(minimaxChatCompletionsUrl('cn_zh')).toBe('https://api.minimaxi.com/v1/chat/completions'); + }); + + it('resolves the default global endpoint', () => { + expect(getMinimaxEndpoint(DEFAULT_MINIMAX_REGION).region).toBe('global_en'); + }); +}); diff --git a/electron/ipc/minimax-catalog.ts b/electron/ipc/minimax-catalog.ts new file mode 100644 index 00000000..b0610969 --- /dev/null +++ b/electron/ipc/minimax-catalog.ts @@ -0,0 +1,135 @@ +/** + * Catalog of the MiniMax models and regional API endpoints used by the inline + * "Ask about Code" provider. + * + * Each model carries its context window, pricing, input modalities and + * thinking modes, and each region exposes both supported protocol base URLs. + */ + +/** Regions where the MiniMax platform is reachable. */ +export type MinimaxRegion = 'global_en' | 'cn_zh'; + +/** Wire protocols each MiniMax endpoint speaks. */ +export type MinimaxProtocol = 'openai' | 'anthropic'; + +/** Input modalities a MiniMax model can accept. */ +export type MinimaxInputModality = 'text' | 'image' | 'video'; + +/** Thinking modes a MiniMax model supports. */ +export type MinimaxThinkingMode = 'adaptive' | 'disabled' | 'always_on'; + +export type MinimaxModelId = 'MiniMax-M3' | 'MiniMax-M2.7'; + +export interface MinimaxPricing { + input: number; + output: number; + cacheRead: number; + cacheWrite: number | null; +} + +export interface MinimaxModel { + /** Model identifier sent as the `model` field on the wire. */ + id: MinimaxModelId; + /** Maximum number of tokens the model accepts in a single request. */ + contextWindow: number; + /** USD per million tokens. */ + pricingUsdPerMillionTokens: MinimaxPricing; + /** Input modalities the model understands. */ + inputModalities: MinimaxInputModality[]; + /** Thinking modes the model exposes. */ + thinking: MinimaxThinkingMode[]; +} + +export interface MinimaxEndpoint { + region: MinimaxRegion; + /** Base URL for the `openai` protocol; chat completions live under `${openaiBaseUrl}/chat/completions`. */ + openaiBaseUrl: string; + /** Base URL for the `anthropic` protocol. */ + anthropicBaseUrl: string; + /** Root of the platform documentation for this region. */ + docsRoot: string; +} + +/** + * Supported models, newest first. M3 carries a 1M-token context window and + * accepts image and video input with adaptive or disabled thinking; M2.7 is + * retained as a 204K-token text-only model with always-on thinking. + */ +export const MINIMAX_MODELS: MinimaxModel[] = [ + { + id: 'MiniMax-M3', + contextWindow: 1_000_000, + pricingUsdPerMillionTokens: { + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: null, + }, + inputModalities: ['text', 'image', 'video'], + thinking: ['adaptive', 'disabled'], + }, + { + id: 'MiniMax-M2.7', + contextWindow: 204_800, + pricingUsdPerMillionTokens: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + inputModalities: ['text'], + thinking: ['always_on'], + }, +]; + +/** Regional endpoints and the protocol base URLs each one exposes. */ +export const MINIMAX_ENDPOINTS: MinimaxEndpoint[] = [ + { + region: 'global_en', + openaiBaseUrl: 'https://api.minimax.io/v1', + anthropicBaseUrl: 'https://api.minimax.io/anthropic', + docsRoot: 'https://platform.minimax.io/docs', + }, + { + region: 'cn_zh', + openaiBaseUrl: 'https://api.minimaxi.com/v1', + anthropicBaseUrl: 'https://api.minimaxi.com/anthropic', + docsRoot: 'https://platform.minimaxi.com/docs', + }, +]; + +const MINIMAX_ENDPOINT_BY_REGION: Record = Object.fromEntries( + MINIMAX_ENDPOINTS.map((endpoint) => [endpoint.region, endpoint]), +) as Record; + +/** Default model used by the inline code Q&A provider. */ +export const DEFAULT_MINIMAX_MODEL_ID = 'MiniMax-M3'; + +/** Default region used by the inline code Q&A provider. */ +export const DEFAULT_MINIMAX_REGION: MinimaxRegion = 'global_en'; + +/** Model ids in the catalog, newest first. */ +export function minimaxModelIds(): string[] { + return MINIMAX_MODELS.map((model) => model.id); +} + +/** Looks up a model by id, or `undefined` when it is not in the catalog. */ +export function getMinimaxModel(id: string): MinimaxModel | undefined { + return MINIMAX_MODELS.find((model) => model.id === id); +} + +/** Returns the endpoint for a supported region. */ +export function getMinimaxEndpoint(region: MinimaxRegion): MinimaxEndpoint { + return MINIMAX_ENDPOINT_BY_REGION[region]; +} + +/** Returns the base URL a region exposes for the given protocol. */ +export function minimaxBaseUrl(region: MinimaxRegion, protocol: MinimaxProtocol): string { + const endpoint = getMinimaxEndpoint(region); + return protocol === 'anthropic' ? endpoint.anthropicBaseUrl : endpoint.openaiBaseUrl; +} + +/** Builds the chat completions URL for a region. */ +export function minimaxChatCompletionsUrl(region: MinimaxRegion): string { + return `${minimaxBaseUrl(region, 'openai')}/chat/completions`; +} diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index aae2d88a..16ee631e 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -893,12 +893,14 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.cwd, 'cwd'); const provider: string | undefined = typeof args.provider === 'string' ? args.provider : undefined; + const model: string | undefined = typeof args.model === 'string' ? args.model : undefined; askAboutCode(win, { requestId: args.requestId, channelId: args.onOutput.__CHANNEL_ID__, prompt: args.prompt, cwd: args.cwd, provider: provider === 'minimax' ? 'minimax' : 'claude', + model, }); }); diff --git a/src/components/AskCodeCard.tsx b/src/components/AskCodeCard.tsx index 4a952e1b..af69b089 100644 --- a/src/components/AskCodeCard.tsx +++ b/src/components/AskCodeCard.tsx @@ -64,6 +64,7 @@ export function AskCodeCard(props: AskCodeCardProps) { cwd: props.worktreePath, onOutput: channel, provider: store.askCodeProvider, + model: store.minimaxModel, }).catch((err: unknown) => { setError(errMessage(err)); setLoading(false); diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 7b2f246f..1ae6cf65 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -28,6 +28,7 @@ import { setDockerImage, setShareDockerAgentAuth, setAskCodeProvider, + setMinimaxModel, setMinimaxApiKey, setAppearanceMode, setLightTheme, @@ -590,10 +591,42 @@ export function SettingsDialog(props: SettingsDialogProps) { }} > - + +