Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary><strong>How does it compare?</strong></summary>
Expand Down
25 changes: 22 additions & 3 deletions electron/ipc/ask-code-minimax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ vi.stubGlobal('fetch', mockFetch);
import {
askAboutCodeMinimax,
cancelAskAboutCodeMinimax,
MINIMAX_MODEL,
setMinimaxApiKey,
} from './ask-code-minimax.js';

Expand Down Expand Up @@ -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'));
Expand All @@ -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 () => {
Expand Down
17 changes: 13 additions & 4 deletions electron/ipc/ask-code-minimax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbortController>({
maxConcurrent: ASK_CODE_MAX_CONCURRENT,
Expand All @@ -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.');
Expand Down Expand Up @@ -62,7 +71,7 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MINIMAX_MODEL,
model,
messages: [
{
role: 'system',
Expand Down
5 changes: 3 additions & 2 deletions electron/ipc/ask-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface AskCodeRequest {
prompt: string;
cwd: string;
provider?: AskCodeProvider;
model?: string;
}

const activeRequests = new RequestRegistry<ChildProcess>({
Expand All @@ -31,12 +32,12 @@ const activeRequests = new RequestRegistry<ChildProcess>({
});

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

Expand Down
85 changes: 85 additions & 0 deletions electron/ipc/minimax-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
135 changes: 135 additions & 0 deletions electron/ipc/minimax-catalog.ts
Original file line number Diff line number Diff line change
@@ -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<MinimaxRegion, MinimaxEndpoint> = Object.fromEntries(
MINIMAX_ENDPOINTS.map((endpoint) => [endpoint.region, endpoint]),
) as Record<MinimaxRegion, MinimaxEndpoint>;

/** 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`;
}
2 changes: 2 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});

Expand Down
1 change: 1 addition & 0 deletions src/components/AskCodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading