feat: implement Minimax as Anthropic-compatible provider (#9455)

This commit is contained in:
Daniel 2025-11-20 22:53:00 -05:00 committed by GitHub
parent 3da6f81fac
commit 905181810e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 491 additions and 90 deletions

View file

@ -601,7 +601,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
*/
// Providers that use Anthropic-style API protocol.
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock"]
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock", "minimax"]
export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => {
if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) {

View file

@ -13,11 +13,12 @@ export const minimaxModels = {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
supportsNativeTools: true,
preserveReasoning: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.03,
preserveReasoning: true,
description:
"MiniMax M2, a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.",
},
@ -26,14 +27,18 @@ export const minimaxModels = {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
supportsNativeTools: true,
preserveReasoning: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.03,
preserveReasoning: true,
description:
"MiniMax M2 Stable (High Concurrency, Commercial Use), a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.",
},
} as const satisfies Record<string, ModelInfo>
export const minimaxDefaultModelInfo: ModelInfo = minimaxModels[minimaxDefaultModelId]
export const MINIMAX_DEFAULT_MAX_TOKENS = 16_384
export const MINIMAX_DEFAULT_TEMPERATURE = 1.0

View file

@ -8,27 +8,35 @@ vitest.mock("vscode", () => ({
},
}))
import OpenAI from "openai"
import { Anthropic } from "@anthropic-ai/sdk"
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
import { MiniMaxHandler } from "../minimax"
vitest.mock("openai", () => {
const createMock = vitest.fn()
vitest.mock("@anthropic-ai/sdk", () => {
const mockCreate = vitest.fn()
const mockCountTokens = vitest.fn()
return {
default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })),
Anthropic: vitest.fn(() => ({
messages: {
create: mockCreate,
countTokens: mockCountTokens,
},
})),
}
})
describe("MiniMaxHandler", () => {
let handler: MiniMaxHandler
let mockCreate: any
let mockCountTokens: any
beforeEach(() => {
vitest.clearAllMocks()
mockCreate = (OpenAI as unknown as any)().chat.completions.create
const anthropicInstance = (Anthropic as unknown as any)()
mockCreate = anthropicInstance.messages.create
mockCountTokens = anthropicInstance.messages.countTokens
})
describe("International MiniMax (default)", () => {
@ -41,9 +49,21 @@ describe("MiniMaxHandler", () => {
it("should use the correct international MiniMax base URL by default", () => {
new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
expect(OpenAI).toHaveBeenCalledWith(
expect(Anthropic).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.minimax.io/v1",
baseURL: "https://api.minimax.io/anthropic",
}),
)
})
it("should convert /v1 endpoint to /anthropic endpoint", () => {
new MiniMaxHandler({
minimaxApiKey: "test-minimax-api-key",
minimaxBaseUrl: "https://api.minimax.io/v1",
})
expect(Anthropic).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.minimax.io/anthropic",
}),
)
})
@ -51,7 +71,7 @@ describe("MiniMaxHandler", () => {
it("should use the provided API key", () => {
const minimaxApiKey = "test-minimax-api-key"
new MiniMaxHandler({ minimaxApiKey })
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
expect(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
})
it("should return default model when no model is specified", () => {
@ -117,13 +137,25 @@ describe("MiniMaxHandler", () => {
minimaxApiKey: "test-minimax-api-key",
minimaxBaseUrl: "https://api.minimaxi.com/v1",
})
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.minimaxi.com/v1" }))
expect(Anthropic).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "https://api.minimaxi.com/anthropic" }),
)
})
it("should convert China /v1 endpoint to /anthropic endpoint", () => {
new MiniMaxHandler({
minimaxApiKey: "test-minimax-api-key",
minimaxBaseUrl: "https://api.minimaxi.com/v1",
})
expect(Anthropic).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "https://api.minimaxi.com/anthropic" }),
)
})
it("should use the provided API key for China", () => {
const minimaxApiKey = "test-minimax-api-key"
new MiniMaxHandler({ minimaxApiKey, minimaxBaseUrl: "https://api.minimaxi.com/v1" })
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
expect(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
})
it("should return default model when no model is specified", () => {
@ -136,9 +168,9 @@ describe("MiniMaxHandler", () => {
describe("Default behavior", () => {
it("should default to international base URL when none is specified", () => {
const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
expect(OpenAI).toHaveBeenCalledWith(
expect(Anthropic).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.minimax.io/v1",
baseURL: "https://api.minimax.io/anthropic",
}),
)
@ -161,7 +193,9 @@ describe("MiniMaxHandler", () => {
it("completePrompt method should return text from MiniMax API", async () => {
const expectedResponse = "This is a test response from MiniMax"
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
mockCreate.mockResolvedValueOnce({
content: [{ type: "text", text: expectedResponse }],
})
const result = await handler.completePrompt("test prompt")
expect(result).toBe(expectedResponse)
})
@ -175,18 +209,20 @@ describe("MiniMaxHandler", () => {
it("createMessage should yield text content from stream", async () => {
const testContent = "This is test content from MiniMax stream"
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vitest
.fn()
.mockResolvedValueOnce({
done: false,
value: { choices: [{ delta: { content: testContent } }] },
})
.mockResolvedValueOnce({ done: true }),
}),
}
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: () => ({
next: vitest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: "content_block_start",
index: 0,
content_block: { type: "text", text: testContent },
},
})
.mockResolvedValueOnce({ done: true }),
}),
})
const stream = handler.createMessage("system prompt", [])
@ -197,21 +233,24 @@ describe("MiniMaxHandler", () => {
})
it("createMessage should yield usage data from stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vitest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 20 },
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: () => ({
next: vitest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: "message_start",
message: {
usage: {
input_tokens: 10,
output_tokens: 20,
},
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
},
})
.mockResolvedValueOnce({ done: true }),
}),
})
const stream = handler.createMessage("system prompt", [])
@ -229,14 +268,12 @@ describe("MiniMaxHandler", () => {
minimaxApiKey: "test-minimax-api-key",
})
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
})
const systemPrompt = "Test system prompt for MiniMax"
@ -250,23 +287,20 @@ describe("MiniMaxHandler", () => {
model: modelId,
max_tokens: Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)),
temperature: 1,
messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]),
system: expect.any(Array),
messages: expect.any(Array),
stream: true,
stream_options: { include_usage: true },
}),
undefined,
)
})
it("should use temperature 1 by default", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
})
const messageGenerator = handler.createMessage("test", [])
@ -276,36 +310,74 @@ describe("MiniMaxHandler", () => {
expect.objectContaining({
temperature: 1,
}),
undefined,
)
})
it("should handle streaming chunks with null choices array", async () => {
const testContent = "Content after null choices"
it("should handle thinking blocks in stream", async () => {
const thinkingContent = "Let me think about this..."
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vitest
.fn()
.mockResolvedValueOnce({
done: false,
value: { choices: null },
})
.mockResolvedValueOnce({
done: false,
value: { choices: [{ delta: { content: testContent } }] },
})
.mockResolvedValueOnce({ done: true }),
}),
}
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: () => ({
next: vitest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: thinkingContent },
},
})
.mockResolvedValueOnce({ done: true }),
}),
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
expect(firstChunk.value).toEqual({ type: "reasoning", text: thinkingContent })
})
it("should handle tool calls in stream", async () => {
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: () => ({
next: vitest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tool-123",
name: "get_weather",
input: { city: "London" },
},
},
})
.mockResolvedValueOnce({
done: false,
value: {
type: "content_block_stop",
index: 0,
},
})
.mockResolvedValueOnce({ done: true }),
}),
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({
type: "tool_call",
id: "tool-123",
name: "get_weather",
arguments: JSON.stringify({ city: "London" }),
})
})
})

View file

@ -1,19 +1,343 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { CacheControlEphemeral } from "@anthropic-ai/sdk/resources"
import OpenAI from "openai"
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
import { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { calculateApiCostAnthropic } from "../../shared/cost"
import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters"
/**
* Converts OpenAI tool_choice to Anthropic ToolChoice format
*/
function convertOpenAIToolChoice(
toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"],
): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined {
if (!toolChoice) {
return undefined
}
if (typeof toolChoice === "string") {
switch (toolChoice) {
case "none":
return undefined // Anthropic doesn't have "none", just omit tools
case "auto":
return { type: "auto" }
case "required":
return { type: "any" }
default:
return { type: "auto" }
}
}
// Handle object form { type: "function", function: { name: string } }
if (typeof toolChoice === "object" && "function" in toolChoice) {
return {
type: "tool",
name: toolChoice.function.name,
}
}
return { type: "auto" }
}
export class MiniMaxHandler extends BaseProvider implements SingleCompletionHandler {
private options: ApiHandlerOptions
private client: Anthropic
export class MiniMaxHandler extends BaseOpenAiCompatibleProvider<MinimaxModelId> {
constructor(options: ApiHandlerOptions) {
super({
...options,
providerName: "MiniMax",
baseURL: options.minimaxBaseUrl ?? "https://api.minimax.io/v1",
super()
this.options = options
// Use Anthropic-compatible endpoint
// Default to international endpoint: https://api.minimax.io/anthropic
// China endpoint: https://api.minimaxi.com/anthropic
let baseURL = options.minimaxBaseUrl || "https://api.minimax.io/anthropic"
// If user provided a /v1 endpoint, convert to /anthropic
if (baseURL.endsWith("/v1")) {
baseURL = baseURL.replace(/\/v1$/, "/anthropic")
} else if (!baseURL.endsWith("/anthropic")) {
baseURL = `${baseURL.replace(/\/$/, "")}/anthropic`
}
this.client = new Anthropic({
baseURL,
apiKey: options.minimaxApiKey,
defaultProviderModelId: minimaxDefaultModelId,
providerModels: minimaxModels,
defaultTemperature: 1.0,
})
}
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
const { id: modelId, info, maxTokens, temperature } = this.getModel()
// MiniMax M2 models support prompt caching
const supportsPromptCache = info.supportsPromptCache ?? false
// Prepare request parameters
const requestParams: Anthropic.Messages.MessageCreateParams = {
model: modelId,
max_tokens: maxTokens ?? 16_384,
temperature: temperature ?? 1.0,
system: supportsPromptCache
? [{ text: systemPrompt, type: "text", cache_control: cacheControl }]
: [{ text: systemPrompt, type: "text" }],
messages: supportsPromptCache ? this.addCacheControl(messages, cacheControl) : messages,
stream: true,
}
// Add tool support if provided - convert OpenAI format to Anthropic format
// Only include native tools when toolProtocol is not 'xml'
if (metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml") {
requestParams.tools = convertOpenAIToolsToAnthropic(metadata.tools)
// Only add tool_choice if tools are present
if (metadata?.tool_choice) {
const convertedChoice = convertOpenAIToolChoice(metadata.tool_choice)
if (convertedChoice) {
requestParams.tool_choice = convertedChoice
}
}
}
stream = await this.client.messages.create(requestParams)
let inputTokens = 0
let outputTokens = 0
let cacheWriteTokens = 0
let cacheReadTokens = 0
// Track tool calls being accumulated via streaming
const toolCallAccumulator = new Map<number, { id: string; name: string; input: string }>()
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start": {
// Tells us cache reads/writes/input/output.
const {
input_tokens = 0,
output_tokens = 0,
cache_creation_input_tokens,
cache_read_input_tokens,
} = chunk.message.usage
yield {
type: "usage",
inputTokens: input_tokens,
outputTokens: output_tokens,
cacheWriteTokens: cache_creation_input_tokens || undefined,
cacheReadTokens: cache_read_input_tokens || undefined,
}
inputTokens += input_tokens
outputTokens += output_tokens
cacheWriteTokens += cache_creation_input_tokens || 0
cacheReadTokens += cache_read_input_tokens || 0
break
}
case "message_delta":
// Tells us stop_reason, stop_sequence, and output tokens
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
// No usage data, just an indicator that the message is done.
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
// Yield thinking/reasoning content
if (chunk.index > 0) {
yield { type: "reasoning", text: "\n" }
}
yield { type: "reasoning", text: chunk.content_block.thinking }
break
case "text":
// We may receive multiple text blocks
if (chunk.index > 0) {
yield { type: "text", text: "\n" }
}
yield { type: "text", text: chunk.content_block.text }
break
case "tool_use": {
// Tool use block started - store initial data
// If input is empty ({}), start with empty string as deltas will build it
// Otherwise, stringify the initial input as a base for potential deltas
const initialInput = chunk.content_block.input || {}
const hasInitialContent = Object.keys(initialInput).length > 0
toolCallAccumulator.set(chunk.index, {
id: chunk.content_block.id,
name: chunk.content_block.name,
input: hasInitialContent ? JSON.stringify(initialInput) : "",
})
break
}
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield { type: "reasoning", text: chunk.delta.thinking }
break
case "text_delta":
yield { type: "text", text: chunk.delta.text }
break
case "input_json_delta": {
// Accumulate tool input JSON as it streams
const existingToolCall = toolCallAccumulator.get(chunk.index)
if (existingToolCall) {
existingToolCall.input += chunk.delta.partial_json
}
break
}
}
break
case "content_block_stop": {
// Block is complete - yield tool call if this was a tool_use block
const completedToolCall = toolCallAccumulator.get(chunk.index)
if (completedToolCall) {
yield {
type: "tool_call",
id: completedToolCall.id,
name: completedToolCall.name,
arguments: completedToolCall.input,
}
// Remove from accumulator after yielding
toolCallAccumulator.delete(chunk.index)
}
break
}
}
}
// Calculate and yield final cost
if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) {
const { totalCost } = calculateApiCostAnthropic(
this.getModel().info,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
)
yield {
type: "usage",
inputTokens: 0,
outputTokens: 0,
totalCost,
}
}
}
/**
* Add cache control to the last two user messages for prompt caching
*/
private addCacheControl(
messages: Anthropic.Messages.MessageParam[],
cacheControl: CacheControlEphemeral,
): Anthropic.Messages.MessageParam[] {
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
return messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [{ type: "text", text: message.content, cache_control: cacheControl }]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? { ...content, cache_control: cacheControl }
: content,
),
}
}
return message
})
}
getModel() {
const modelId = this.options.apiModelId
const id = modelId && modelId in minimaxModels ? (modelId as MinimaxModelId) : minimaxDefaultModelId
const info = minimaxModels[id]
const params = getModelParams({
format: "anthropic",
modelId: id,
model: info,
settings: this.options,
defaultTemperature: 1.0,
})
return {
id,
info,
...params,
}
}
async completePrompt(prompt: string) {
const { id: model, temperature } = this.getModel()
const message = await this.client.messages.create({
model,
max_tokens: 16_384,
temperature: temperature ?? 1.0,
messages: [{ role: "user", content: prompt }],
stream: false,
})
const content = message.content.find(({ type }) => type === "text")
return content?.type === "text" ? content.text : ""
}
/**
* Counts tokens for the given content using Anthropic's token counting
* Falls back to base provider's tiktoken estimation if counting fails
*/
override async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
try {
const { id: model } = this.getModel()
const response = await this.client.messages.countTokens({
model,
messages: [{ role: "user", content: content }],
})
return response.input_tokens
} catch (error) {
// Log error but fallback to tiktoken estimation
console.warn("MiniMax token counting failed, using fallback", error)
// Use the base provider's implementation as fallback
return super.countTokens(content)
}
}
}