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
47 changes: 43 additions & 4 deletions src/main/agent/pi-model-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ const REASONING_MODEL_PATTERN =
const DEEPSEEK_V4_MODEL_PATTERN = /(?:^|[/_-])deepseek[-_.]?v4(?:$|[-:_.])/i;
type PiRegistryProvider = Parameters<typeof getModel>[0];

interface KnownSyntheticModelMetadata {
reasoning: boolean;
input: Model<Api>['input'];
cost: Model<Api>['cost'];
contextWindow: number;
}

export interface PiModelStringInput {
provider?: string;
customProtocol?: string;
Expand Down Expand Up @@ -120,6 +127,31 @@ const KNOWN_MODEL_SPECS: Record<string, { contextWindow: number; maxTokens: numb
'command-r': { contextWindow: 131072, maxTokens: 4096 },
};

const KNOWN_SYNTHETIC_MODEL_METADATA: Record<string, KnownSyntheticModelMetadata> = {
'minimax-m3': {
reasoning: true,
input: ['text', 'image'],
cost: {
input: 0.6,
output: 2.4,
cacheRead: 0.12,
cacheWrite: 0,
},
contextWindow: 1000000,
},
'minimax-m2.7': {
reasoning: true,
input: ['text'],
cost: {
input: 0.3,
output: 1.2,
cacheRead: 0.06,
cacheWrite: 0.375,
},
contextWindow: 204800,
},
};

function lookupModelSpecs(
modelId: string
): { contextWindow: number; maxTokens: number } | undefined {
Expand All @@ -133,6 +165,10 @@ function lookupModelSpecs(
return undefined;
}

function lookupSyntheticModelMetadata(modelId: string): KnownSyntheticModelMetadata | undefined {
return KNOWN_SYNTHETIC_MODEL_METADATA[modelId.toLowerCase()];
}

export function buildSyntheticPiModel(
modelId: string,
provider: string,
Expand All @@ -144,7 +180,9 @@ export function buildSyntheticPiModel(
maxTokens?: number
): Model<Api> {
const api = apiOverride || inferPiApi(protocol);
const autoReasoning = reasoning ?? REASONING_MODEL_PATTERN.test(modelId);
const knownMetadata = lookupSyntheticModelMetadata(modelId);
const autoReasoning =
reasoning ?? knownMetadata?.reasoning ?? REASONING_MODEL_PATTERN.test(modelId);
const knownSpecs = lookupModelSpecs(modelId);
return {
id: modelId,
Expand All @@ -153,9 +191,10 @@ export function buildSyntheticPiModel(
provider,
baseUrl: baseUrl || '',
reasoning: autoReasoning,
input: ['text', 'image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: contextWindow ?? knownSpecs?.contextWindow ?? 128000,
input: knownMetadata?.input ?? ['text', 'image'],
cost: knownMetadata?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
contextWindow ?? knownMetadata?.contextWindow ?? knownSpecs?.contextWindow ?? 128000,
maxTokens: maxTokens ?? knownSpecs?.maxTokens ?? 16384,
} as Model<Api>;
}
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,9 @@
"note": "Use Gemini-compatible mode and the exact model ID exposed by the endpoint."
},
"minimax": {
"name": "MiniMax",
"note": "Use OpenAI-compatible mode unless your MiniMax gateway documents another protocol."
"globalName": "MiniMax (Global)",
"chinaName": "MiniMax (China)",
"note": "Choose the endpoint that matches your account region. Both compatible protocols are supported."
},
"genericOpenAI": {
"name": "Generic OpenAI-compatible",
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,9 @@
"note": "推荐使用 Gemini 兼容协议,并填写该 endpoint 暴露的精确模型 ID。"
},
"minimax": {
"name": "MiniMax",
"note": "除非网关文档明确说明其他协议,否则优先按 OpenAI 兼容协议配置。"
"globalName": "MiniMax (Global)",
"chinaName": "MiniMax (China)",
"note": "Choose the endpoint that matches your account region. Both compatible protocols are supported."
},
"genericOpenAI": {
"name": "通用 OpenAI 兼容服务",
Expand Down
2 changes: 2 additions & 0 deletions src/shared/api-model-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export const API_PROVIDER_PRESETS: SharedProviderPresets = {
{ id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
{ id: 'kimi-k2-thinking', name: 'kimi-k2-thinking' },
{ id: 'glm-5', name: 'glm-5' },
{ id: 'MiniMax-M3', name: 'MiniMax-M3' },
{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7' },
{ id: 'MiniMax-M2.5', name: 'MiniMax-M2.5' },
{ id: 'qwen-max', name: 'qwen-max' },
{ id: 'grok-code-fast-1', name: 'grok-code-fast-1' },
Expand Down
72 changes: 59 additions & 13 deletions src/shared/api-provider-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ export type CommonProviderSetupId =
| 'glm-anthropic'
| 'ollama'
| 'gemini-custom'
| 'minimax'
| 'minimax-global-openai'
| 'minimax-global-anthropic'
| 'minimax-cn-openai'
| 'minimax-cn-anthropic'
| 'generic-openai';

export interface CommonProviderSetup {
Expand Down Expand Up @@ -126,19 +129,57 @@ export const COMMON_PROVIDER_SETUPS: CommonProviderSetup[] = [
},
},
{
id: 'minimax',
nameKey: 'api.guidance.setups.minimax.name',
id: 'minimax-global-openai',
nameKey: 'api.guidance.setups.minimax.globalName',
noteKey: 'api.guidance.setups.minimax.note',
applyProvider: 'custom',
recommendedProtocol: 'openai',
recommendedBaseUrl: 'https://api.minimax.chat/v1',
exampleModel: 'MiniMax-M2.5',
recommendedBaseUrl: 'https://api.minimax.io/v1',
exampleModel: 'MiniMax-M3',
matcher: {
hosts: ['api.minimax.chat'],
hostContains: ['minimax'],
hosts: ['api.minimax.io'],
pathPrefixes: ['/v1'],
},
},
{
id: 'minimax-global-anthropic',
nameKey: 'api.guidance.setups.minimax.globalName',
noteKey: 'api.guidance.setups.minimax.note',
applyProvider: 'custom',
recommendedProtocol: 'anthropic',
recommendedBaseUrl: 'https://api.minimax.io/anthropic',
exampleModel: 'MiniMax-M3',
matcher: {
hosts: ['api.minimax.io'],
pathPrefixes: ['/anthropic'],
},
},
{
id: 'minimax-cn-openai',
nameKey: 'api.guidance.setups.minimax.chinaName',
noteKey: 'api.guidance.setups.minimax.note',
applyProvider: 'custom',
recommendedProtocol: 'openai',
recommendedBaseUrl: 'https://api.minimaxi.com/v1',
exampleModel: 'MiniMax-M3',
matcher: {
hosts: ['api.minimaxi.com'],
pathPrefixes: ['/v1'],
},
},
{
id: 'minimax-cn-anthropic',
nameKey: 'api.guidance.setups.minimax.chinaName',
noteKey: 'api.guidance.setups.minimax.note',
applyProvider: 'custom',
recommendedProtocol: 'anthropic',
recommendedBaseUrl: 'https://api.minimaxi.com/anthropic',
exampleModel: 'MiniMax-M3',
matcher: {
hosts: ['api.minimaxi.com'],
pathPrefixes: ['/anthropic'],
},
},
{
id: 'generic-openai',
nameKey: 'api.guidance.setups.genericOpenAI.name',
Expand Down Expand Up @@ -186,17 +227,18 @@ function matchesPath(pathname: string, setup: CommonProviderSetup): boolean {
const normalizedPath = pathname.replace(/\/+$/, '') || '/';
const { pathPrefixes, pathIncludes } = setup.matcher || {};

const prefixOk = !pathPrefixes?.length
|| pathPrefixes.some((prefix) => {
const prefixOk =
!pathPrefixes?.length ||
pathPrefixes.some((prefix) => {
const value = prefix || '/';
return normalizedPath === value || normalizedPath.startsWith(`${value}/`);
});
if (!prefixOk) {
return false;
}

const includesOk = !pathIncludes?.length
|| pathIncludes.some((fragment) => normalizedPath.includes(fragment));
const includesOk =
!pathIncludes?.length || pathIncludes.some((fragment) => normalizedPath.includes(fragment));
return includesOk;
}

Expand Down Expand Up @@ -239,7 +281,9 @@ export function detectCommonProviderSetup(baseUrl: string | undefined): CommonPr
return null;
}

export function orderCommonProviderSetups(activeId?: CommonProviderSetupId | null): CommonProviderSetup[] {
export function orderCommonProviderSetups(
activeId?: CommonProviderSetupId | null
): CommonProviderSetup[] {
if (!activeId) {
return COMMON_PROVIDER_SETUPS;
}
Expand All @@ -255,7 +299,9 @@ export function getFallbackOpenAISetup(): CommonProviderSetup {
return COMMON_PROVIDER_SETUPS.find((setup) => setup.id === 'generic-openai')!;
}

function detectProviderGuidanceHintCode(details: string | undefined): ProviderGuidanceHintCode | null {
function detectProviderGuidanceHintCode(
details: string | undefined
): ProviderGuidanceHintCode | null {
const value = details?.trim().toLowerCase();
if (!value) {
return null;
Expand Down
55 changes: 55 additions & 0 deletions tests/anthropic-base-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Anthropic from '@anthropic-ai/sdk';
import { createServer } from 'node:http';
import { describe, expect, it } from 'vitest';

describe('Anthropic-compatible base URLs', () => {
it('appends the messages path after the configured base URL', async () => {
let requestPath = '';
const server = createServer((request, response) => {
requestPath = request.url || '';
request.resume();
response.writeHead(200, { 'content-type': 'application/json' });
response.end(
JSON.stringify({
id: 'msg_test',
type: 'message',
role: 'assistant',
model: 'MiniMax-M3',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
})
);
});

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});

const address = server.address();
if (!address || typeof address === 'string') {
server.close();
throw new Error('Expected a local TCP server address');
}

try {
const client = new Anthropic({
apiKey: 'placeholder',
baseURL: `http://127.0.0.1:${address.port}/anthropic`,
});
await client.messages.create({
model: 'MiniMax-M3',
max_tokens: 1,
messages: [{ role: 'user', content: 'ping' }],
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}

expect(requestPath).toBe('/anthropic/v1/messages');
});
});
4 changes: 4 additions & 0 deletions tests/api-config-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ describe('api config state helpers', () => {
'kimi-k2-thinking'
);
expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain('glm-5');
expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain('MiniMax-M3');
expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain(
'MiniMax-M2.7'
);
expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain(
'MiniMax-M2.5'
);
Expand Down
28 changes: 28 additions & 0 deletions tests/pi-model-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ describe('pi model resolution helpers', () => {
expect(model.baseUrl).toBe('https://api.x.ai/v1');
});

it('uses current MiniMax metadata for synthetic fallback models', () => {
const m3 = buildSyntheticPiModel(
'MiniMax-M3',
'anthropic',
'anthropic',
'https://api.minimax.io/anthropic'
);
expect(m3).toMatchObject({
reasoning: true,
input: ['text', 'image'],
cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 },
contextWindow: 1000000,
});

const m27 = buildSyntheticPiModel(
'MiniMax-M2.7',
'openai',
'openai',
'https://api.minimaxi.com/v1'
);
expect(m27).toMatchObject({
reasoning: true,
input: ['text'],
cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
contextWindow: 204800,
});
});

it('preserves explicit provider-prefixed ids for openrouter synthetic fallbacks', () => {
const fallback = resolveSyntheticPiModelFallback({
rawModel: 'z-ai/glm-5-turbo',
Expand Down
49 changes: 49 additions & 0 deletions tests/provider-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,55 @@ describe('provider guidance helpers', () => {
expect(setup?.recommendedProtocol).toBe('openai');
});

it('exposes both MiniMax regions through both compatible protocols', () => {
const setups = COMMON_PROVIDER_SETUPS.filter((setup) => setup.id.startsWith('minimax-'));

expect(
setups.map((setup) => ({
id: setup.id,
protocol: setup.recommendedProtocol,
baseUrl: setup.recommendedBaseUrl,
model: setup.exampleModel,
}))
).toEqual([
{
id: 'minimax-global-openai',
protocol: 'openai',
baseUrl: 'https://api.minimax.io/v1',
model: 'MiniMax-M3',
},
{
id: 'minimax-global-anthropic',
protocol: 'anthropic',
baseUrl: 'https://api.minimax.io/anthropic',
model: 'MiniMax-M3',
},
{
id: 'minimax-cn-openai',
protocol: 'openai',
baseUrl: 'https://api.minimaxi.com/v1',
model: 'MiniMax-M3',
},
{
id: 'minimax-cn-anthropic',
protocol: 'anthropic',
baseUrl: 'https://api.minimaxi.com/anthropic',
model: 'MiniMax-M3',
},
]);

expect(detectCommonProviderSetup('https://api.minimax.io/v1')?.id).toBe(
'minimax-global-openai'
);
expect(detectCommonProviderSetup('https://api.minimax.io/anthropic')?.id).toBe(
'minimax-global-anthropic'
);
expect(detectCommonProviderSetup('https://api.minimaxi.com/v1')?.id).toBe('minimax-cn-openai');
expect(detectCommonProviderSetup('https://api.minimaxi.com/anthropic')?.id).toBe(
'minimax-cn-anthropic'
);
});

it('detects OpenRouter and prefers the dedicated provider tab', () => {
const setup = detectCommonProviderSetup('https://openrouter.ai/api/v1');
expect(setup?.id).toBe('openrouter');
Expand Down
Loading