diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5221bc88..483ba7f0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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)) - 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)) diff --git a/packages/ai/README.md b/packages/ai/README.md index 964ea3ae..90a71116 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -56,6 +56,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **Cerebras** - **xAI** - **OpenRouter** +- **MiniMax** - **GitHub Copilot** (requires OAuth, see below) - **Google Gemini CLI** (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` | | OpenRouter | `OPENROUTER_API_KEY` | | zAI | `ZAI_API_KEY` | +| MiniMax | `MINIMAX_API_KEY` | | GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` | When set, the library automatically uses these keys: diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 952946a6..fc2d0564 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -478,6 +478,33 @@ async function loadModelsDevData(): Promise[]> { } } + // 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`); return models; } catch (error) { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index d597a450..bf3472a5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2686,6 +2686,42 @@ export const MODELS = { maxTokens: 16384, } 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": { "codestral-latest": { id: "codestral-latest", @@ -4529,7 +4565,7 @@ export const MODELS = { cacheWrite: 18.75, }, contextWindow: 200000, - maxTokens: 4096, + maxTokens: 32000, } satisfies Model<"openai-completions">, "anthropic/claude-opus-4.5": { id: "anthropic/claude-opus-4.5", diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 72bb8203..bfc71b6b 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -378,38 +378,34 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream // Bedrock requires all tool results to be in one message const toolResults: ContentBlock.ToolResultMember[] = []; - // Add current tool result - for (const c of m.content) { - toolResults.push({ - toolResult: { - toolUseId: m.toolCallId, - content: [ - c.type === "image" - ? { image: createImageBlock(c.mimeType, c.data) } - : { text: sanitizeSurrogates(c.text) }, - ], - status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, - }, - }); - } + // Add current tool result with all content blocks combined + toolResults.push({ + toolResult: { + toolUseId: m.toolCallId, + content: m.content.map((c) => + c.type === "image" + ? { image: createImageBlock(c.mimeType, c.data) } + : { text: sanitizeSurrogates(c.text) }, + ), + status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, + }, + }); // Look ahead for consecutive toolResult messages let j = i + 1; while (j < messages.length && messages[j].role === "toolResult") { const nextMsg = messages[j] as ToolResultMessage; - for (const c of nextMsg.content) { - toolResults.push({ - toolResult: { - toolUseId: nextMsg.toolCallId, - content: [ - c.type === "image" - ? { image: createImageBlock(c.mimeType, c.data) } - : { text: sanitizeSurrogates(c.text) }, - ], - status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, - }, - }); - } + toolResults.push({ + toolResult: { + toolUseId: nextMsg.toolCallId, + content: nextMsg.content.map((c) => + c.type === "image" + ? { image: createImageBlock(c.mimeType, c.data) } + : { text: sanitizeSurrogates(c.text) }, + ), + status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, + }, + }); j++; } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 498d26a0..f7cd936a 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -98,6 +98,7 @@ export function getEnvApiKey(provider: any): string | undefined { openrouter: "OPENROUTER_API_KEY", zai: "ZAI_API_KEY", mistral: "MISTRAL_API_KEY", + minimax: "MINIMAX_API_KEY", opencode: "OPENCODE_API_KEY", }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index fd83cf9e..ef245f23 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -58,6 +58,7 @@ export type KnownProvider = | "openrouter" | "zai" | "mistral" + | "minimax" | "opencode"; export type Provider = KnownProvider | string; diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index 8cb79264..07c7400b 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -17,6 +17,7 @@ import type { AssistantMessage } from "../types.js"; * - 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" * - 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 * - Mistral: Returns "400 status code (no body)" - handled separately below * - 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 available context size/i, // llama.cpp server /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax /context[_ ]length[_ ]exceeded/i, // Generic fallback /too many tokens/i, // Generic fallback /token limit exceeded/i, // Generic fallback diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 40951119..a0594edd 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -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 describe("Google Gemini CLI Provider Abort", () => { it.skipIf(!geminiCliToken)("should abort mid-stream", { retry: 3 }, async () => { diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 9017e5f2..eb40b893 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -396,6 +396,22 @@ describe("Context overflow error handling", () => { }, 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 // Expected pattern: "maximum context length is X tokens" diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 7f1c1cc7..b19aa8a1 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -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", () => { const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index b133b4b8..deca07eb 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -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) // Tokens are resolved at module level (see oauthTokens above) diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 892e198d..0297a8fe 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -46,7 +46,8 @@ async function testTokensOnAbort(llm: Model, options: Op 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, - // 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 ( llm.api === "openai-completions" || llm.api === "openai-responses" || @@ -58,6 +59,10 @@ async function testTokensOnAbort(llm: Model, options: Op ) { expect(msg.usage.input).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 { expect(msg.usage.input).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) // ========================================================================= diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 413fe3f0..c8e33a53 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -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", () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 1271dad5..ee89af11 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -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 // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 80a471e2..b28c41d3 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -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", () => { const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index f1bf1389..7ca93488 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -167,6 +167,7 @@ Add API keys to `~/.pi/agent/auth.json`: | xAI | `xai` | `XAI_API_KEY` | | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | | ZAI | `zai` | `ZAI_API_KEY` | +| MiniMax | `minimax` | `MINIMAX_API_KEY` | Auth file keys take priority over environment variables. @@ -1142,7 +1143,7 @@ pi [options] [@files...] [messages...] | Option | Description | |--------|-------------| -| `--provider ` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom | +| `--provider ` | 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 ` | Model ID | | `--api-key ` | API key (overrides environment) | | `--system-prompt ` | Custom system prompt (text or file path) | diff --git a/packages/coding-agent/examples/extensions/sandbox/index.ts b/packages/coding-agent/examples/extensions/sandbox/index.ts index ba55eaf8..e9d07c77 100644 --- a/packages/coding-agent/examples/extensions/sandbox/index.ts +++ b/packages/coding-agent/examples/extensions/sandbox/index.ts @@ -211,7 +211,7 @@ export default function (pi: ExtensionAPI) { pi.registerTool({ ...localBash, label: "bash (sandboxed)", - async execute(id, params, onUpdate, ctx, signal) { + async execute(id, params, onUpdate, _ctx, signal) { if (!sandboxEnabled || !sandboxInitialized) { return localBash.execute(id, params, signal, onUpdate); } diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 1b1a10a2..e4442d5e 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -243,6 +243,8 @@ ${chalk.bold("Environment Variables:")} XAI_API_KEY - xAI Grok API key OPENROUTER_API_KEY - OpenRouter 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_ACCESS_KEY_ID - AWS access key for Amazon Bedrock AWS_SECRET_ACCESS_KEY - AWS secret key for Amazon Bedrock diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 5ed4df2e..017ea576 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -26,6 +26,7 @@ export const defaultModelPerProvider: Record = { cerebras: "zai-glm-4.6", zai: "glm-4.6", mistral: "devstral-medium-latest", + minimax: "MiniMax-M2.1", opencode: "claude-opus-4-5", };