diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 23e0a548d11..80513d732c3 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -331,7 +331,7 @@ const moonshotSchema = apiModelIdProviderModelSchema.extend({ const minimaxSchema = apiModelIdProviderModelSchema.extend({ minimaxBaseUrl: z - .union([z.literal("https://api.minimax.io/v1"), z.literal("https://api.minimaxi.com/v1")]) + .union([z.literal("https://api.minimax.io/anthropic"), z.literal("https://api.minimaxi.com/anthropic")]) .optional(), minimaxApiKey: z.string().optional(), }) diff --git a/packages/types/src/providers/minimax.ts b/packages/types/src/providers/minimax.ts index 14c19616970..0c5ab7b55cf 100644 --- a/packages/types/src/providers/minimax.ts +++ b/packages/types/src/providers/minimax.ts @@ -17,7 +17,6 @@ export const minimaxModels = { 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.", }, @@ -30,10 +29,10 @@ export const minimaxModels = { 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 export const MINIMAX_DEFAULT_TEMPERATURE = 1.0 +export const MINIMAX_DEFAULT_MAX_TOKENS = 16384 diff --git a/src/api/index.ts b/src/api/index.ts index 351f4ef1bef..d85f490b128 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -40,7 +40,7 @@ import { FeatherlessHandler, VercelAiGatewayHandler, DeepInfraHandler, - MiniMaxHandler, + MiniMaxAnthropicHandler as MiniMaxHandler, } from "./providers" import { NativeOllamaHandler } from "./providers/native-ollama" diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index d01dfdab0ef..a05873313df 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -1,337 +1,297 @@ // npx vitest run src/api/providers/__tests__/minimax.spec.ts -vitest.mock("vscode", () => ({ - workspace: { - getConfiguration: vitest.fn().mockReturnValue({ - get: vitest.fn().mockReturnValue(600), // Default timeout in seconds - }), - }, -})) - -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" - -import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types" - -import { MiniMaxHandler } from "../minimax" +import { MiniMaxAnthropicHandler } from "../minimax" +import { ApiHandlerOptions } from "../../../shared/api" +import { + minimaxDefaultModelId, + minimaxModels, + MINIMAX_DEFAULT_TEMPERATURE, + MINIMAX_DEFAULT_MAX_TOKENS, +} from "@roo-code/types" + +const mockCreate = vitest.fn() + +vitest.mock("@anthropic-ai/sdk", () => { + const mockAnthropicConstructor = vitest.fn().mockImplementation(() => ({ + messages: { + create: mockCreate.mockImplementation(async (options) => { + if (!options.stream) { + return { + id: "test-completion", + content: [{ type: "text", text: "Test response from MiniMax" }], + role: "assistant", + model: options.model, + usage: { + input_tokens: 10, + output_tokens: 5, + }, + } + } + return { + async *[Symbol.asyncIterator]() { + yield { + type: "message_start", + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10, + }, + }, + } + yield { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "Hello", + }, + } + yield { + type: "content_block_delta", + delta: { + type: "text_delta", + text: " from MiniMax", + }, + } + }, + } + }), + }, + })) -vitest.mock("openai", () => { - const createMock = vitest.fn() return { - default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })), + Anthropic: mockAnthropicConstructor, } }) +// Import after mock +import { Anthropic } from "@anthropic-ai/sdk" + +const mockAnthropicConstructor = vitest.mocked(Anthropic) + describe("MiniMaxHandler", () => { - let handler: MiniMaxHandler - let mockCreate: any + let handler: MiniMaxAnthropicHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { vitest.clearAllMocks() - mockCreate = (OpenAI as unknown as any)().chat.completions.create + mockOptions = { + minimaxApiKey: "test-minimax-api-key", + apiModelId: minimaxDefaultModelId, + } + handler = new MiniMaxAnthropicHandler(mockOptions) }) - describe("International MiniMax (default)", () => { - beforeEach(() => { - handler = new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimax.io/v1", - }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(MiniMaxAnthropicHandler) + expect(handler.getModel().id).toBe(minimaxDefaultModelId) }) - it("should use the correct international MiniMax base URL by default", () => { - new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) - expect(OpenAI).toHaveBeenCalledWith( + it("should use default international base URL", () => { + new MiniMaxAnthropicHandler(mockOptions) + expect(mockAnthropicConstructor).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: "https://api.minimax.io/v1", + baseURL: "https://api.minimax.io/anthropic", + apiKey: "test-minimax-api-key", }), ) }) - it("should use the provided API key", () => { - const minimaxApiKey = "test-minimax-api-key" - new MiniMaxHandler({ minimaxApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey })) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(minimaxDefaultModelId) - expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) - }) - - it("should return specified model when valid model is provided", () => { - const testModelId: MinimaxModelId = "MiniMax-M2" - const handlerWithModel = new MiniMaxHandler({ - apiModelId: testModelId, - minimaxApiKey: "test-minimax-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(minimaxModels[testModelId]) - }) - - it("should return MiniMax-M2 model with correct configuration", () => { - const testModelId: MinimaxModelId = "MiniMax-M2" - const handlerWithModel = new MiniMaxHandler({ - apiModelId: testModelId, - minimaxApiKey: "test-minimax-api-key", + it("should use custom base URL if provided", () => { + const customBaseUrl = "https://api.minimaxi.com/anthropic" + new MiniMaxAnthropicHandler({ + ...mockOptions, + minimaxBaseUrl: customBaseUrl, }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(minimaxModels[testModelId]) - expect(model.info.contextWindow).toBe(192_000) - expect(model.info.maxTokens).toBe(16_384) - expect(model.info.supportsPromptCache).toBe(true) - expect(model.info.cacheWritesPrice).toBe(0.375) - expect(model.info.cacheReadsPrice).toBe(0.03) - }) - - it("should return MiniMax-M2-Stable model with correct configuration", () => { - const testModelId: MinimaxModelId = "MiniMax-M2-Stable" - const handlerWithModel = new MiniMaxHandler({ - apiModelId: testModelId, - minimaxApiKey: "test-minimax-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(minimaxModels[testModelId]) - expect(model.info.contextWindow).toBe(192_000) - expect(model.info.maxTokens).toBe(16_384) - expect(model.info.supportsPromptCache).toBe(true) - expect(model.info.cacheWritesPrice).toBe(0.375) - expect(model.info.cacheReadsPrice).toBe(0.03) + expect(mockAnthropicConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: customBaseUrl, + }), + ) }) - }) - describe("China MiniMax", () => { - beforeEach(() => { - handler = new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimaxi.com/v1", + it("should use China base URL when provided", () => { + const chinaBaseUrl = "https://api.minimaxi.com/anthropic" + new MiniMaxAnthropicHandler({ + ...mockOptions, + minimaxBaseUrl: chinaBaseUrl, }) + expect(mockAnthropicConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: chinaBaseUrl, + apiKey: "test-minimax-api-key", + }), + ) }) - it("should use the correct China MiniMax base URL", () => { - new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimaxi.com/v1", + it("should initialize without API key", () => { + const handlerWithoutKey = new MiniMaxAnthropicHandler({ + ...mockOptions, + minimaxApiKey: undefined, }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.minimaxi.com/v1" })) - }) - - 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 })) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(minimaxDefaultModelId) - expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) + expect(handlerWithoutKey).toBeInstanceOf(MiniMaxAnthropicHandler) }) }) - 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( + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + + it("should stream messages successfully", async () => { + const stream = handler.createMessage(systemPrompt, [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello MiniMax" }], + }, + ]) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify usage information + const usageChunk = chunks.find((chunk) => chunk.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk?.inputTokens).toBe(100) + expect(usageChunk?.outputTokens).toBe(50) + + // Verify text content + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Hello") + expect(textChunks[1].text).toBe(" from MiniMax") + + // Verify API call + expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: "https://api.minimax.io/v1", + model: minimaxDefaultModelId, + max_tokens: 16384, + temperature: MINIMAX_DEFAULT_TEMPERATURE, + system: [{ text: systemPrompt, type: "text" }], + stream: true, }), ) - - const model = handlerDefault.getModel() - expect(model.id).toBe(minimaxDefaultModelId) - expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) }) - it("should default to MiniMax-M2 model", () => { - const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) - const model = handlerDefault.getModel() - expect(model.id).toBe("MiniMax-M2") + it("should handle multiple messages", async () => { + const stream = handler.createMessage(systemPrompt, [ + { + role: "user", + content: [{ type: "text" as const, text: "First message" }], + }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Response" }], + }, + { + role: "user", + content: [{ type: "text" as const, text: "Second message" }], + }, + ]) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + expect(mockCreate).toHaveBeenCalled() }) }) - describe("API Methods", () => { - beforeEach(() => { - handler = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) + describe("completePrompt", () => { + it("should complete prompt successfully", async () => { + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("Test response from MiniMax") + expect(mockCreate).toHaveBeenCalledWith({ + model: minimaxDefaultModelId, + messages: [{ role: "user", content: "Test prompt" }], + max_tokens: MINIMAX_DEFAULT_MAX_TOKENS, + temperature: MINIMAX_DEFAULT_TEMPERATURE, + thinking: undefined, + stream: false, + }) }) - 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 } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) + it("should handle API errors", async () => { + mockCreate.mockRejectedValueOnce(new Error("MiniMax API Error")) + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("MiniMax API Error") }) - it("should handle errors in completePrompt", async () => { - const errorMessage = "MiniMax API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow() + it("should handle non-text content", async () => { + mockCreate.mockImplementationOnce(async () => ({ + content: [{ type: "image" }], + })) + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") }) - 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 }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + it("should handle empty response", async () => { + mockCreate.mockImplementationOnce(async () => ({ + content: [{ type: "text", text: "" }], + })) + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") }) + }) - 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 }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + describe("getModel", () => { + it("should return default model if no model ID is provided", () => { + const handlerWithoutModel = new MiniMaxAnthropicHandler({ + ...mockOptions, + apiModelId: undefined, }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(minimaxDefaultModelId) + expect(model.info).toBeDefined() }) - it("createMessage should pass correct parameters to MiniMax client", async () => { - const modelId: MinimaxModelId = "MiniMax-M2" - const modelInfo = minimaxModels[modelId] - const handlerWithModel = new MiniMaxHandler({ - apiModelId: modelId, - minimaxApiKey: "test-minimax-api-key", - }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - const systemPrompt = "Test system prompt for MiniMax" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for MiniMax" }] - - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)), - temperature: 1, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - undefined, - ) + it("should return MiniMax-M2 as default model", () => { + const model = handler.getModel() + expect(model.id).toBe("MiniMax-M2") + expect(model.info).toEqual(minimaxModels["MiniMax-M2"]) }) - it("should use temperature 1 by default", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - const messageGenerator = handler.createMessage("test", []) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 1, - }), - undefined, - ) + it("should return correct model configuration for MiniMax-M2", () => { + const model = handler.getModel() + expect(model.id).toBe("MiniMax-M2") + expect(model.info.maxTokens).toBe(16384) + expect(model.info.contextWindow).toBe(192_000) + expect(model.info.supportsImages).toBe(false) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.inputPrice).toBe(0.3) + expect(model.info.outputPrice).toBe(1.2) }) - it("should handle streaming chunks with null choices array", async () => { - const testContent = "Content after null choices" - - 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 }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + it("should use correct default max tokens", () => { + const model = handler.getModel() + expect(model.maxTokens).toBe(16384) }) }) describe("Model Configuration", () => { - it("should correctly configure MiniMax-M2 model properties", () => { - const model = minimaxModels["MiniMax-M2"] - expect(model.maxTokens).toBe(16_384) - expect(model.contextWindow).toBe(192_000) - expect(model.supportsImages).toBe(false) - expect(model.supportsPromptCache).toBe(true) - expect(model.inputPrice).toBe(0.3) - expect(model.outputPrice).toBe(1.2) - expect(model.cacheWritesPrice).toBe(0.375) - expect(model.cacheReadsPrice).toBe(0.03) + it("should have correct model configuration", () => { + expect(minimaxDefaultModelId).toBe("MiniMax-M2") + expect(minimaxModels["MiniMax-M2"]).toEqual({ + maxTokens: 16384, + contextWindow: 192_000, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 0.3, + outputPrice: 1.2, + cacheWritesPrice: 0.375, + cacheReadsPrice: 0.03, + description: + "MiniMax M2, a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.", + }) }) - it("should correctly configure MiniMax-M2-Stable model properties", () => { - const model = minimaxModels["MiniMax-M2-Stable"] - expect(model.maxTokens).toBe(16_384) - expect(model.contextWindow).toBe(192_000) - expect(model.supportsImages).toBe(false) - expect(model.supportsPromptCache).toBe(true) - expect(model.inputPrice).toBe(0.3) - expect(model.outputPrice).toBe(1.2) - expect(model.cacheWritesPrice).toBe(0.375) - expect(model.cacheReadsPrice).toBe(0.03) + it("should have correct default constants", () => { + expect(MINIMAX_DEFAULT_TEMPERATURE).toBe(1.0) + expect(MINIMAX_DEFAULT_MAX_TOKENS).toBe(16384) }) }) }) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 533023d0374..2a15194b308 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -34,4 +34,4 @@ export { RooHandler } from "./roo" export { FeatherlessHandler } from "./featherless" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { DeepInfraHandler } from "./deepinfra" -export { MiniMaxHandler } from "./minimax" +export { MiniMaxAnthropicHandler } from "./minimax" diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 8a8e8c14e5b..35b0b8d3170 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -1,19 +1,234 @@ -import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming" + +import { + type ModelInfo, + MINIMAX_DEFAULT_MAX_TOKENS, + MINIMAX_DEFAULT_TEMPERATURE, + 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" + +export class MiniMaxAnthropicHandler extends BaseProvider implements SingleCompletionHandler { + private options: ApiHandlerOptions + private client: Anthropic -export class MiniMaxHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "MiniMax", - baseURL: options.minimaxBaseUrl ?? "https://api.minimax.io/v1", - apiKey: options.minimaxApiKey, - defaultProviderModelId: minimaxDefaultModelId, - providerModels: minimaxModels, - defaultTemperature: 1.0, + super() + this.options = options + + this.client = new Anthropic({ + baseURL: this.options.minimaxBaseUrl || "https://api.minimax.io/anthropic", + apiKey: this.options.minimaxApiKey, + }) + } + + async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + let stream: AnthropicStream + let { id: modelId, maxTokens } = this.getModel() + + stream = await this.client.messages.create({ + model: modelId, + max_tokens: maxTokens ?? MINIMAX_DEFAULT_MAX_TOKENS, + temperature: MINIMAX_DEFAULT_TEMPERATURE, + system: [{ text: systemPrompt, type: "text" }], + messages, + stream: true, + }) + + let inputTokens = 0 + let outputTokens = 0 + let cacheWriteTokens = 0 + let cacheReadTokens = 0 + let thinkingDeltaAccumulator = "" + let thinkText = "" + let thinkSignature = "" + const lastStartedToolCall = { id: "", name: "", arguments: "" } + 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 + // along the way and at the end of the message. + 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": + // We may receive multiple text blocks, in which + // case just insert a line break between them. + if (chunk.index > 0) { + yield { type: "reasoning", text: "\n" } + } + + yield { type: "reasoning", text: chunk.content_block.thinking } + thinkText = chunk.content_block.thinking + thinkSignature = chunk.content_block.signature + if (thinkText && thinkSignature) { + yield { + type: "ant_thinking", + thinking: thinkText, + signature: thinkSignature, + } + } + break + case "redacted_thinking": + yield { + type: "reasoning", + text: "[Redacted thinking block]", + } + yield { + type: "ant_redacted_thinking", + data: chunk.content_block.data, + } + break + case "tool_use": + if (chunk.content_block.id && chunk.content_block.name) { + lastStartedToolCall.id = chunk.content_block.id + lastStartedToolCall.name = chunk.content_block.name + lastStartedToolCall.arguments = "" + } + break + case "text": + // We may receive multiple text blocks, in which + // case just insert a line break between them. + if (chunk.index > 0) { + yield { type: "text", text: "\n" } + } + + yield { type: "text", text: chunk.content_block.text } + break + } + break + case "content_block_delta": + switch (chunk.delta.type) { + case "thinking_delta": + yield { type: "reasoning", text: chunk.delta.thinking } + thinkingDeltaAccumulator += chunk.delta.thinking + break + case "signature_delta": + if (thinkingDeltaAccumulator && chunk.delta.signature) { + yield { + type: "ant_thinking", + thinking: thinkingDeltaAccumulator, + signature: chunk.delta.signature, + } + } + break + case "text_delta": + yield { type: "text", text: chunk.delta.text } + break + case "input_json_delta": + if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) { + //for native tool call logic + } + } + + break + case "content_block_stop": + break + } + } + + 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, + } + } + } + + getModel() { + const modelId = this.options.apiModelId + let id = modelId && modelId in minimaxModels ? (modelId as MinimaxModelId) : minimaxDefaultModelId + let info: ModelInfo = minimaxModels[id] + + const params = getModelParams({ + format: "anthropic", + modelId: id, + model: info, + settings: this.options, }) + + // The `:thinking` suffix indicates that the model is a "Hybrid" + // reasoning model and that reasoning is required to be enabled. + // The actual model ID honored by Anthropic's API does not have this + // suffix. + return { + id, + info, + ...params, + } + } + + async completePrompt(prompt: string) { + let { id: model } = this.getModel() + + const message = await this.client.messages.create({ + model, + max_tokens: MINIMAX_DEFAULT_MAX_TOKENS, + thinking: undefined, + temperature: MINIMAX_DEFAULT_TEMPERATURE, + messages: [{ role: "user", content: prompt }], + stream: false, + }) + + const content = message.content.find(({ type }) => type === "text") + return content?.type === "text" ? content.text : "" } } diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 8484e625958..51abf790bb7 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -6,6 +6,8 @@ export type ApiStreamChunk = | ApiStreamReasoningChunk | ApiStreamGroundingChunk | ApiStreamError + | ApiStreamAnthropicThinkingChunk + | ApiStreamAnthropicRedactedThinkingChunk export interface ApiStreamError { type: "error" @@ -43,3 +45,14 @@ export interface GroundingSource { url: string snippet?: string } + +export interface ApiStreamAnthropicThinkingChunk { + type: "ant_thinking" + thinking: string + signature: string +} + +export interface ApiStreamAnthropicRedactedThinkingChunk { + type: "ant_redacted_thinking" + data: string +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5d63189e3d8..9ae6c6029bd 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1985,7 +1985,9 @@ export class Task extends EventEmitter implements TaskLike { this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false this.assistantMessageParser.reset() - + const antThinkingContent = new Array< + Anthropic.Messages.RedactedThinkingBlock | Anthropic.Messages.ThinkingBlock + >() await this.diffViewProvider.reset() // Yields only if the first chunk is successful, otherwise will @@ -2040,6 +2042,19 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break + case "ant_thinking": + antThinkingContent.push({ + type: "thinking", + thinking: chunk.thinking, + signature: chunk.signature, + }) + break + case "ant_redacted_thinking": + antThinkingContent.push({ + type: "redacted_thinking", + data: chunk.data, + }) + break case "text": { assistantMessage += chunk.text @@ -2401,16 +2416,23 @@ export class Task extends EventEmitter implements TaskLike { }) } + const assistantMessageContent = new Array() + assistantMessageContent.push(...antThinkingContent) + // Check if we should preserve reasoning in the assistant message let finalAssistantMessage = assistantMessage + if (reasoningMessage && this.api.getModel().info.preserveReasoning) { // Prepend reasoning in XML tags to the assistant message so it's included in API history finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` } - + assistantMessageContent.push({ + type: "text", + text: finalAssistantMessage, + }) await this.addToApiConversationHistory({ role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], + content: assistantMessageContent, }) TelemetryService.instance.captureConversationMessage(this.taskId, "assistant") diff --git a/webview-ui/src/components/settings/providers/MiniMax.tsx b/webview-ui/src/components/settings/providers/MiniMax.tsx index 4055be7d179..6b7139fbed4 100644 --- a/webview-ui/src/components/settings/providers/MiniMax.tsx +++ b/webview-ui/src/components/settings/providers/MiniMax.tsx @@ -36,10 +36,10 @@ export const MiniMax = ({ apiConfiguration, setApiConfigurationField }: MiniMaxP value={apiConfiguration.minimaxBaseUrl} onChange={handleInputChange("minimaxBaseUrl")} className={cn("w-full")}> - + api.minimax.io - + api.minimaxi.com @@ -59,7 +59,7 @@ export const MiniMax = ({ apiConfiguration, setApiConfigurationField }: MiniMaxP {!apiConfiguration?.minimaxApiKey && (