Browse Source

feat(minimax): move environment_details to system message for thinking models (#10284)

Hannes Rudolph 6 days ago
parent
commit
e7c1851a8b

+ 21 - 0
packages/types/src/providers/minimax.ts

@@ -15,6 +15,8 @@ export const minimaxModels = {
 		supportsPromptCache: true,
 		supportsNativeTools: true,
 		defaultToolProtocol: "native",
+		includedTools: ["search_and_replace"],
+		excludedTools: ["apply_diff"],
 		preserveReasoning: true,
 		inputPrice: 0.3,
 		outputPrice: 1.2,
@@ -30,6 +32,8 @@ export const minimaxModels = {
 		supportsPromptCache: true,
 		supportsNativeTools: true,
 		defaultToolProtocol: "native",
+		includedTools: ["search_and_replace"],
+		excludedTools: ["apply_diff"],
 		preserveReasoning: true,
 		inputPrice: 0.3,
 		outputPrice: 1.2,
@@ -38,6 +42,23 @@ export const minimaxModels = {
 		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.",
 	},
+	"MiniMax-M2.1": {
+		maxTokens: 16_384,
+		contextWindow: 192_000,
+		supportsImages: false,
+		supportsPromptCache: true,
+		supportsNativeTools: true,
+		defaultToolProtocol: "native",
+		includedTools: ["search_and_replace"],
+		excludedTools: ["apply_diff"],
+		preserveReasoning: true,
+		inputPrice: 0.3,
+		outputPrice: 1.2,
+		cacheWritesPrice: 0.375,
+		cacheReadsPrice: 0.03,
+		description:
+			"MiniMax M2.1 builds on M2 with improved overall performance for agentic coding tasks and significantly faster response times.",
+	},
 } as const satisfies Record<string, ModelInfo>
 
 export const minimaxDefaultModelInfo: ModelInfo = minimaxModels[minimaxDefaultModelId]

+ 16 - 4
src/api/providers/minimax.ts

@@ -9,6 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api"
 
 import { ApiStream } from "../transform/stream"
 import { getModelParams } from "../transform/model-params"
+import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format"
 
 import { BaseProvider } from "./base-provider"
 import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
@@ -87,15 +88,26 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
 		// MiniMax M2 models support prompt caching
 		const supportsPromptCache = info.supportsPromptCache ?? false
 
+		// Merge environment_details from messages that follow tool_result blocks
+		// into the tool_result content. This preserves reasoning continuity for
+		// thinking models by preventing user messages from interrupting the
+		// reasoning context after tool use (similar to r1-format's mergeToolResultText).
+		const processedMessages = mergeEnvironmentDetailsForMiniMax(messages)
+
+		// Build the system blocks array
+		const systemBlocks: Anthropic.Messages.TextBlockParam[] = [
+			supportsPromptCache
+				? { text: systemPrompt, type: "text", cache_control: cacheControl }
+				: { text: systemPrompt, type: "text" },
+		]
+
 		// 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,
+			system: systemBlocks,
+			messages: supportsPromptCache ? this.addCacheControl(processedMessages, cacheControl) : processedMessages,
 			stream: true,
 		}
 

+ 336 - 0
src/api/transform/__tests__/minimax-format.spec.ts

@@ -0,0 +1,336 @@
+// npx vitest run api/transform/__tests__/minimax-format.spec.ts
+
+import { Anthropic } from "@anthropic-ai/sdk"
+
+import { mergeEnvironmentDetailsForMiniMax } from "../minimax-format"
+
+describe("mergeEnvironmentDetailsForMiniMax", () => {
+	it("should pass through simple text messages unchanged", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: "Hello",
+			},
+			{
+				role: "assistant",
+				content: "Hi there!",
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(2)
+		expect(result).toEqual(messages)
+	})
+
+	it("should pass through user messages with only tool_result blocks unchanged", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: "Tool result content",
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(1)
+		expect(result).toEqual(messages)
+	})
+
+	it("should pass through user messages with only text blocks unchanged", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text",
+						text: "Some user message",
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(1)
+		expect(result).toEqual(messages)
+	})
+
+	it("should merge text content into last tool_result when both tool_result AND text blocks exist", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: "Tool result content",
+					},
+					{
+						type: "text",
+						text: "<environment_details>\nCurrent Time: 2024-01-01\n</environment_details>",
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		// The message should have only tool_result with merged content
+		expect(result).toHaveLength(1)
+		expect(result[0].role).toBe("user")
+		const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[]
+		expect(content).toHaveLength(1)
+		expect(content[0].type).toBe("tool_result")
+		expect(content[0].tool_use_id).toBe("tool-123")
+		expect(content[0].content).toBe(
+			"Tool result content\n\n<environment_details>\nCurrent Time: 2024-01-01\n</environment_details>",
+		)
+	})
+
+	it("should merge multiple text blocks into last tool_result", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: "Tool result 1",
+					},
+					{
+						type: "text",
+						text: "First text block",
+					},
+					{
+						type: "tool_result",
+						tool_use_id: "tool-456",
+						content: "Tool result 2",
+					},
+					{
+						type: "text",
+						text: "Second text block",
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		// The message should have only tool_result blocks, with text merged into the last one
+		expect(result).toHaveLength(1)
+		const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[]
+		expect(content).toHaveLength(2)
+		expect(content[0].type).toBe("tool_result")
+		expect(content[0].content).toBe("Tool result 1") // First one unchanged
+		expect(content[1].type).toBe("tool_result")
+		expect(content[1].content).toBe("Tool result 2\n\nFirst text block\n\nSecond text block") // Second has merged text
+	})
+
+	it("should NOT merge text when images are present (cannot move images to tool_result)", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: "Tool result content",
+					},
+					{
+						type: "text",
+						text: "Some text",
+					},
+					{
+						type: "image",
+						source: {
+							type: "base64",
+							media_type: "image/png",
+							data: "base64data",
+						},
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		// Message should be unchanged since images are present
+		expect(result).toHaveLength(1)
+		expect(result).toEqual(messages)
+	})
+
+	it("should pass through assistant messages unchanged", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "text",
+						text: "I will help you with that.",
+					},
+					{
+						type: "tool_use",
+						id: "tool-123",
+						name: "read_file",
+						input: { path: "test.ts" },
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(1)
+		expect(result).toEqual(messages)
+	})
+
+	it("should handle mixed conversation with merging only for eligible messages", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: "Create a file",
+			},
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "text",
+						text: "I'll create the file.",
+					},
+					{
+						type: "tool_use",
+						id: "tool-123",
+						name: "write_file",
+						input: { path: "test.ts", content: "// test" },
+					},
+				],
+			},
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: "File created successfully",
+					},
+					{
+						type: "text",
+						text: "<environment_details>\nCurrent Time: 2024-01-01\n</environment_details>",
+					},
+				],
+			},
+			{
+				role: "assistant",
+				content: "The file has been created.",
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		// Should have all 4 messages
+		expect(result).toHaveLength(4)
+
+		// First user message unchanged (simple string)
+		expect(result[0]).toEqual(messages[0])
+
+		// Assistant message unchanged
+		expect(result[1]).toEqual(messages[1])
+
+		// Third message should have tool_result with merged environment_details
+		const thirdMessage = result[2].content as Anthropic.Messages.ToolResultBlockParam[]
+		expect(thirdMessage).toHaveLength(1)
+		expect(thirdMessage[0].type).toBe("tool_result")
+		expect(thirdMessage[0].content).toContain("File created successfully")
+		expect(thirdMessage[0].content).toContain("environment_details")
+
+		// Fourth message unchanged
+		expect(result[3]).toEqual(messages[3])
+	})
+
+	it("should handle string content in user messages", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: "Just a string message",
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(1)
+		expect(result).toEqual(messages)
+	})
+
+	it("should handle empty messages array", () => {
+		const messages: Anthropic.Messages.MessageParam[] = []
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(0)
+	})
+
+	it("should handle tool_result with array content", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: [
+							{ type: "text", text: "Part 1" },
+							{ type: "text", text: "Part 2" },
+						],
+					},
+					{
+						type: "text",
+						text: "<environment_details>Context</environment_details>",
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(1)
+		const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[]
+		expect(content).toHaveLength(1)
+		expect(content[0].type).toBe("tool_result")
+		// Array content should be concatenated and then merged with text
+		expect(content[0].content).toBe("Part 1\nPart 2\n\n<environment_details>Context</environment_details>")
+	})
+
+	it("should handle tool_result with empty content", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: "",
+					},
+					{
+						type: "text",
+						text: "<environment_details>Context</environment_details>",
+					},
+				],
+			},
+		]
+
+		const result = mergeEnvironmentDetailsForMiniMax(messages)
+
+		expect(result).toHaveLength(1)
+		const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[]
+		expect(content).toHaveLength(1)
+		expect(content[0].type).toBe("tool_result")
+		expect(content[0].content).toBe("<environment_details>Context</environment_details>")
+	})
+})

+ 118 - 0
src/api/transform/minimax-format.ts

@@ -0,0 +1,118 @@
+import { Anthropic } from "@anthropic-ai/sdk"
+
+type ContentBlock = Anthropic.Messages.ContentBlockParam
+
+/**
+ * Merges text content (like environment_details) that follows tool_result blocks
+ * into the last tool_result's content. This preserves reasoning continuity for
+ * thinking models by avoiding separate user messages after tool results.
+ *
+ * Key behavior:
+ * - User messages with ONLY tool_result blocks: keep as-is
+ * - User messages with ONLY text/image: keep as-is
+ * - User messages with tool_result blocks AND text blocks: merge the text blocks
+ *   into the last tool_result's content
+ *
+ * @param messages Array of Anthropic messages
+ * @returns Modified messages with text merged into tool_result content
+ */
+export function mergeEnvironmentDetailsForMiniMax(
+	messages: Anthropic.Messages.MessageParam[],
+): Anthropic.Messages.MessageParam[] {
+	const result: Anthropic.Messages.MessageParam[] = []
+
+	for (const message of messages) {
+		if (message.role === "user") {
+			if (typeof message.content === "string") {
+				// Simple string content - keep as-is
+				result.push(message)
+			} else if (Array.isArray(message.content)) {
+				// Check if this message has both tool_result blocks and text blocks
+				const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = []
+				const textBlocks: Anthropic.Messages.TextBlockParam[] = []
+				const imageBlocks: Anthropic.Messages.ImageBlockParam[] = []
+
+				for (const block of message.content) {
+					if (block.type === "tool_result") {
+						toolResultBlocks.push(block)
+					} else if (block.type === "text") {
+						textBlocks.push(block)
+					} else if (block.type === "image") {
+						imageBlocks.push(block)
+					}
+				}
+
+				// If we have tool_result blocks AND text blocks (like environment_details),
+				// merge the text into the last tool_result's content
+				const hasToolResults = toolResultBlocks.length > 0
+				const hasTextBlocks = textBlocks.length > 0
+				const hasImageBlocks = imageBlocks.length > 0
+
+				if (hasToolResults && hasTextBlocks && !hasImageBlocks) {
+					// Merge text content into the last tool_result
+					const textContent = textBlocks.map((b) => b.text).join("\n\n")
+					const modifiedToolResults = [...toolResultBlocks]
+					const lastToolResult = modifiedToolResults[modifiedToolResults.length - 1]
+
+					// Get existing content as string
+					let existingContent: string
+					if (typeof lastToolResult.content === "string") {
+						existingContent = lastToolResult.content
+					} else if (Array.isArray(lastToolResult.content)) {
+						existingContent =
+							lastToolResult.content
+								?.map((c) => {
+									if (c.type === "text") return c.text
+									if (c.type === "image") return "(image)"
+									return ""
+								})
+								.join("\n") ?? ""
+					} else {
+						existingContent = ""
+					}
+
+					// Merge text into the last tool_result
+					modifiedToolResults[modifiedToolResults.length - 1] = {
+						...lastToolResult,
+						content: existingContent ? `${existingContent}\n\n${textContent}` : textContent,
+					}
+
+					result.push({
+						...message,
+						content: modifiedToolResults as ContentBlock[],
+					})
+				} else {
+					// Keep the message as-is if:
+					// - Only tool_result blocks (no text to merge)
+					// - Only text/image blocks (no tool results)
+					// - Has images (can't merge into tool_result)
+					result.push(message)
+				}
+			} else {
+				// Unknown format - keep as-is
+				result.push(message)
+			}
+		} else {
+			// Assistant messages - keep as-is
+			result.push(message)
+		}
+	}
+
+	return result
+}
+
+/**
+ * @deprecated Use mergeEnvironmentDetailsForMiniMax instead. This function extracted
+ * environment_details to the system prompt, but the new approach merges them into
+ * tool_result content like r1-format does with mergeToolResultText.
+ */
+export function extractEnvironmentDetailsForMiniMax(messages: Anthropic.Messages.MessageParam[]): {
+	messages: Anthropic.Messages.MessageParam[]
+	extractedSystemContent: string[]
+} {
+	// For backwards compatibility, just return the merged messages with empty extracted content
+	return {
+		messages: mergeEnvironmentDetailsForMiniMax(messages),
+		extractedSystemContent: [],
+	}
+}