Add MiniMax provider support (#656 by @dannote)

- Add minimax to KnownProvider and Api types
- Add MINIMAX_API_KEY to getEnvApiKey()
- Generate MiniMax-M2 and MiniMax-M2.1 models
- Add context overflow detection pattern
- Add tests to all required test files
- Update README and CHANGELOG with attribution

Also fixes:
- Bedrock duplicate toolResult ID when content has multiple blocks
- Sandbox extension unused parameter lint warning
This commit is contained in:
Mario Zechner 2026-01-13 02:27:09 +01:00
parent edc576024f
commit 8af8d0d672
20 changed files with 233 additions and 31 deletions

View file

@ -4,6 +4,7 @@
### Added ### Added
- MiniMax provider support with M2 and M2.1 models via Anthropic-compatible API ([#656](https://github.com/badlogic/pi-mono/pull/656) by [@dannote](https://github.com/dannote))
- Add Amazon Bedrock provider with prompt caching for Claude models (experimental, tested with Anthropic Claude models only) ([#494](https://github.com/badlogic/pi-mono/pull/494) by [@unexge](https://github.com/unexge)) - Add Amazon Bedrock provider with prompt caching for Claude models (experimental, tested with Anthropic Claude models only) ([#494](https://github.com/badlogic/pi-mono/pull/494) by [@unexge](https://github.com/unexge))
- Added `serviceTier` option for OpenAI Responses requests ([#672](https://github.com/badlogic/pi-mono/pull/672) by [@markusylisiurunen](https://github.com/markusylisiurunen)) - Added `serviceTier` option for OpenAI Responses requests ([#672](https://github.com/badlogic/pi-mono/pull/672) by [@markusylisiurunen](https://github.com/markusylisiurunen))
- **Anthropic caching on OpenRouter**: Interactions with Anthropic models via OpenRouter now set a 5-minute cache point using Anthropic-style `cache_control` breakpoints on the last assistant or user message. ([#584](https://github.com/badlogic/pi-mono/pull/584) by [@nathyong](https://github.com/nathyong)) - **Anthropic caching on OpenRouter**: Interactions with Anthropic models via OpenRouter now set a 5-minute cache point using Anthropic-style `cache_control` breakpoints on the last assistant or user message. ([#584](https://github.com/badlogic/pi-mono/pull/584) by [@nathyong](https://github.com/nathyong))

View file

@ -56,6 +56,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- **Cerebras** - **Cerebras**
- **xAI** - **xAI**
- **OpenRouter** - **OpenRouter**
- **MiniMax**
- **GitHub Copilot** (requires OAuth, see below) - **GitHub Copilot** (requires OAuth, see below)
- **Google Gemini CLI** (requires OAuth, see below) - **Google Gemini CLI** (requires OAuth, see below)
- **Antigravity** (requires OAuth, see below) - **Antigravity** (requires OAuth, see below)
@ -862,6 +863,7 @@ In Node.js environments, you can set environment variables to avoid passing API
| xAI | `XAI_API_KEY` | | xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` | | OpenRouter | `OPENROUTER_API_KEY` |
| zAI | `ZAI_API_KEY` | | zAI | `ZAI_API_KEY` |
| MiniMax | `MINIMAX_API_KEY` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` | | GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
When set, the library automatically uses these keys: When set, the library automatically uses these keys:

View file

@ -478,6 +478,33 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
} }
} }
// Process MiniMax models
if (data.minimax?.models) {
for (const [modelId, model] of Object.entries(data.minimax.models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
models.push({
id: modelId,
name: m.name || modelId,
api: "anthropic-messages",
provider: "minimax",
// MiniMax's Anthropic-compatible API - SDK appends /v1/messages
baseUrl: "https://api.minimax.io/anthropic",
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: m.cost?.cache_write || 0,
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
}
}
console.log(`Loaded ${models.length} tool-capable models from models.dev`); console.log(`Loaded ${models.length} tool-capable models from models.dev`);
return models; return models;
} catch (error) { } catch (error) {

View file

@ -2686,6 +2686,42 @@ export const MODELS = {
maxTokens: 16384, maxTokens: 16384,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
}, },
"minimax": {
"MiniMax-M2": {
id: "MiniMax-M2",
name: "MiniMax-M2",
api: "anthropic-messages",
provider: "minimax",
baseUrl: "https://api.minimax.io/anthropic",
reasoning: true,
input: ["text"],
cost: {
input: 0.3,
output: 1.2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 196608,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"MiniMax-M2.1": {
id: "MiniMax-M2.1",
name: "MiniMax-M2.1",
api: "anthropic-messages",
provider: "minimax",
baseUrl: "https://api.minimax.io/anthropic",
reasoning: true,
input: ["text"],
cost: {
input: 0.3,
output: 1.2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
},
"mistral": { "mistral": {
"codestral-latest": { "codestral-latest": {
id: "codestral-latest", id: "codestral-latest",
@ -4529,7 +4565,7 @@ export const MODELS = {
cacheWrite: 18.75, cacheWrite: 18.75,
}, },
contextWindow: 200000, contextWindow: 200000,
maxTokens: 4096, maxTokens: 32000,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"anthropic/claude-opus-4.5": { "anthropic/claude-opus-4.5": {
id: "anthropic/claude-opus-4.5", id: "anthropic/claude-opus-4.5",

View file

@ -378,38 +378,34 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream
// Bedrock requires all tool results to be in one message // Bedrock requires all tool results to be in one message
const toolResults: ContentBlock.ToolResultMember[] = []; const toolResults: ContentBlock.ToolResultMember[] = [];
// Add current tool result // Add current tool result with all content blocks combined
for (const c of m.content) { toolResults.push({
toolResults.push({ toolResult: {
toolResult: { toolUseId: m.toolCallId,
toolUseId: m.toolCallId, content: m.content.map((c) =>
content: [ c.type === "image"
c.type === "image" ? { image: createImageBlock(c.mimeType, c.data) }
? { image: createImageBlock(c.mimeType, c.data) } : { text: sanitizeSurrogates(c.text) },
: { text: sanitizeSurrogates(c.text) }, ),
], status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, },
}, });
});
}
// Look ahead for consecutive toolResult messages // Look ahead for consecutive toolResult messages
let j = i + 1; let j = i + 1;
while (j < messages.length && messages[j].role === "toolResult") { while (j < messages.length && messages[j].role === "toolResult") {
const nextMsg = messages[j] as ToolResultMessage; const nextMsg = messages[j] as ToolResultMessage;
for (const c of nextMsg.content) { toolResults.push({
toolResults.push({ toolResult: {
toolResult: { toolUseId: nextMsg.toolCallId,
toolUseId: nextMsg.toolCallId, content: nextMsg.content.map((c) =>
content: [ c.type === "image"
c.type === "image" ? { image: createImageBlock(c.mimeType, c.data) }
? { image: createImageBlock(c.mimeType, c.data) } : { text: sanitizeSurrogates(c.text) },
: { text: sanitizeSurrogates(c.text) }, ),
], status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, },
}, });
});
}
j++; j++;
} }

View file

@ -98,6 +98,7 @@ export function getEnvApiKey(provider: any): string | undefined {
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
zai: "ZAI_API_KEY", zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY", mistral: "MISTRAL_API_KEY",
minimax: "MINIMAX_API_KEY",
opencode: "OPENCODE_API_KEY", opencode: "OPENCODE_API_KEY",
}; };

View file

@ -58,6 +58,7 @@ export type KnownProvider =
| "openrouter" | "openrouter"
| "zai" | "zai"
| "mistral" | "mistral"
| "minimax"
| "opencode"; | "opencode";
export type Provider = KnownProvider | string; export type Provider = KnownProvider | string;

View file

@ -17,6 +17,7 @@ import type { AssistantMessage } from "../types.js";
* - llama.cpp: "the request exceeds the available context size, try increasing it" * - llama.cpp: "the request exceeds the available context size, try increasing it"
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length" * - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y" * - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
* - MiniMax: "invalid params, context window exceeds limit"
* - Cerebras: Returns "400 status code (no body)" - handled separately below * - Cerebras: Returns "400 status code (no body)" - handled separately below
* - Mistral: Returns "400 status code (no body)" - handled separately below * - Mistral: Returns "400 status code (no body)" - handled separately below
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
@ -33,6 +34,7 @@ const OVERFLOW_PATTERNS = [
/exceeds the limit of \d+/i, // GitHub Copilot /exceeds the limit of \d+/i, // GitHub Copilot
/exceeds the available context size/i, // llama.cpp server /exceeds the available context size/i, // llama.cpp server
/greater than the context length/i, // LM Studio /greater than the context length/i, // LM Studio
/context window exceeds limit/i, // MiniMax
/context[_ ]length[_ ]exceeded/i, // Generic fallback /context[_ ]length[_ ]exceeded/i, // Generic fallback
/too many tokens/i, // Generic fallback /too many tokens/i, // Generic fallback
/token limit exceeded/i, // Generic fallback /token limit exceeded/i, // Generic fallback

View file

@ -160,6 +160,18 @@ describe("AI Providers Abort Tests", () => {
}); });
}); });
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Abort", () => {
const llm = getModel("minimax", "MiniMax-M2.1");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
// Google Gemini CLI / Antigravity share the same provider, so one test covers both // Google Gemini CLI / Antigravity share the same provider, so one test covers both
describe("Google Gemini CLI Provider Abort", () => { describe("Google Gemini CLI Provider Abort", () => {
it.skipIf(!geminiCliToken)("should abort mid-stream", { retry: 3 }, async () => { it.skipIf(!geminiCliToken)("should abort mid-stream", { retry: 3 }, async () => {

View file

@ -396,6 +396,22 @@ describe("Context overflow error handling", () => {
}, 120000); }, 120000);
}); });
// =============================================================================
// MiniMax
// Expected pattern: TBD - need to test actual error message
// =============================================================================
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax", () => {
it("MiniMax-M2.1 - should detect overflow via isContextOverflow", async () => {
const model = getModel("minimax", "MiniMax-M2.1");
const result = await testContextOverflow(model, process.env.MINIMAX_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// ============================================================================= // =============================================================================
// OpenRouter - Multiple backend providers // OpenRouter - Multiple backend providers
// Expected pattern: "maximum context length is X tokens" // Expected pattern: "maximum context length is X tokens"

View file

@ -322,6 +322,26 @@ describe("AI Providers Empty Message Tests", () => {
}); });
}); });
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Empty Messages", () => {
const llm = getModel("minimax", "MiniMax-M2.1");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Empty Messages", () => { describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Empty Messages", () => {
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");

View file

@ -699,6 +699,30 @@ describe("Generate E2E Tests", () => {
}); });
}); });
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider (MiniMax-M2.1 via Anthropic Messages)", () => {
const llm = getModel("minimax", "MiniMax-M2.1");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
it("should handle thinking mode", { retry: 3 }, async () => {
await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
});
});
// ========================================================================= // =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json) // OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// Tokens are resolved at module level (see oauthTokens above) // Tokens are resolved at module level (see oauthTokens above)

View file

@ -46,7 +46,8 @@ async function testTokensOnAbort<TApi extends Api>(llm: Model<TApi>, options: Op
expect(msg.stopReason).toBe("aborted"); expect(msg.stopReason).toBe("aborted");
// OpenAI providers, OpenAI Codex, Gemini CLI, zai, Amazon Bedrock, and the GPT-OSS model on Antigravity only send usage in the final chunk, // OpenAI providers, OpenAI Codex, Gemini CLI, zai, Amazon Bedrock, and the GPT-OSS model on Antigravity only send usage in the final chunk,
// so when aborted they have no token stats Anthropic and Google send usage information early in the stream // so when aborted they have no token stats. Anthropic and Google send usage information early in the stream.
// MiniMax reports input tokens but not output tokens when aborted.
if ( if (
llm.api === "openai-completions" || llm.api === "openai-completions" ||
llm.api === "openai-responses" || llm.api === "openai-responses" ||
@ -58,6 +59,10 @@ async function testTokensOnAbort<TApi extends Api>(llm: Model<TApi>, options: Op
) { ) {
expect(msg.usage.input).toBe(0); expect(msg.usage.input).toBe(0);
expect(msg.usage.output).toBe(0); expect(msg.usage.output).toBe(0);
} else if (llm.provider === "minimax") {
// MiniMax reports input tokens early but output tokens only in final chunk
expect(msg.usage.input).toBeGreaterThan(0);
expect(msg.usage.output).toBe(0);
} else { } else {
expect(msg.usage.input).toBeGreaterThan(0); expect(msg.usage.input).toBeGreaterThan(0);
expect(msg.usage.output).toBeGreaterThan(0); expect(msg.usage.output).toBeGreaterThan(0);
@ -146,6 +151,14 @@ describe("Token Statistics on Abort", () => {
}); });
}); });
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider", () => {
const llm = getModel("minimax", "MiniMax-M2.1");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
});
});
// ========================================================================= // =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json) // OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// ========================================================================= // =========================================================================

View file

@ -171,6 +171,14 @@ describe("Tool Call Without Result Tests", () => {
}); });
}); });
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider", () => {
const model = getModel("minimax", "MiniMax-M2.1");
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
await testToolCallWithoutResult(model);
});
});
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider", () => { describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider", () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");

View file

@ -325,6 +325,29 @@ describe("totalTokens field", () => {
); );
}); });
// =========================================================================
// MiniMax
// =========================================================================
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax", () => {
it(
"MiniMax-M2.1 - should return totalTokens equal to sum of components",
{ retry: 3, timeout: 60000 },
async () => {
const llm = getModel("minimax", "MiniMax-M2.1");
console.log(`\nMiniMax / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.MINIMAX_API_KEY });
logUsage("First request", first);
logUsage("Second request", second);
assertTotalTokensEqualsComponents(first);
assertTotalTokensEqualsComponents(second);
},
);
});
// ========================================================================= // =========================================================================
// OpenRouter - Multiple backend providers // OpenRouter - Multiple backend providers
// ========================================================================= // =========================================================================

View file

@ -618,6 +618,22 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
}); });
}); });
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Unicode Handling", () => {
const llm = getModel("minimax", "MiniMax-M2.1");
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
await testEmojiInToolResults(llm);
});
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
await testRealWorldLinkedInData(llm);
});
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
await testUnpairedHighSurrogate(llm);
});
});
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Unicode Handling", () => { describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Unicode Handling", () => {
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");

View file

@ -167,6 +167,7 @@ Add API keys to `~/.pi/agent/auth.json`:
| xAI | `xai` | `XAI_API_KEY` | | xAI | `xai` | `XAI_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| ZAI | `zai` | `ZAI_API_KEY` | | ZAI | `zai` | `ZAI_API_KEY` |
| MiniMax | `minimax` | `MINIMAX_API_KEY` |
Auth file keys take priority over environment variables. Auth file keys take priority over environment variables.
@ -1142,7 +1143,7 @@ pi [options] [@files...] [messages...]
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom | | `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`, `amazon-bedrock`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, `minimax`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
| `--model <id>` | Model ID | | `--model <id>` | Model ID |
| `--api-key <key>` | API key (overrides environment) | | `--api-key <key>` | API key (overrides environment) |
| `--system-prompt <text\|file>` | Custom system prompt (text or file path) | | `--system-prompt <text\|file>` | Custom system prompt (text or file path) |

View file

@ -211,7 +211,7 @@ export default function (pi: ExtensionAPI) {
pi.registerTool({ pi.registerTool({
...localBash, ...localBash,
label: "bash (sandboxed)", label: "bash (sandboxed)",
async execute(id, params, onUpdate, ctx, signal) { async execute(id, params, onUpdate, _ctx, signal) {
if (!sandboxEnabled || !sandboxInitialized) { if (!sandboxEnabled || !sandboxInitialized) {
return localBash.execute(id, params, signal, onUpdate); return localBash.execute(id, params, signal, onUpdate);
} }

View file

@ -243,6 +243,8 @@ ${chalk.bold("Environment Variables:")}
XAI_API_KEY - xAI Grok API key XAI_API_KEY - xAI Grok API key
OPENROUTER_API_KEY - OpenRouter API key OPENROUTER_API_KEY - OpenRouter API key
ZAI_API_KEY - ZAI API key ZAI_API_KEY - ZAI API key
MISTRAL_API_KEY - Mistral API key
MINIMAX_API_KEY - MiniMax API key
AWS_PROFILE - AWS profile for Amazon Bedrock AWS_PROFILE - AWS profile for Amazon Bedrock
AWS_ACCESS_KEY_ID - AWS access key for Amazon Bedrock AWS_ACCESS_KEY_ID - AWS access key for Amazon Bedrock
AWS_SECRET_ACCESS_KEY - AWS secret key for Amazon Bedrock AWS_SECRET_ACCESS_KEY - AWS secret key for Amazon Bedrock

View file

@ -26,6 +26,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
cerebras: "zai-glm-4.6", cerebras: "zai-glm-4.6",
zai: "glm-4.6", zai: "glm-4.6",
mistral: "devstral-medium-latest", mistral: "devstral-medium-latest",
minimax: "MiniMax-M2.1",
opencode: "claude-opus-4-5", opencode: "claude-opus-4-5",
}; };