mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: migrate MiniMax provider to AI SDK (#11357)
This commit is contained in:
parent
b7d6e4933d
commit
b6bf829ad9
2 changed files with 595 additions and 574 deletions
|
|
@ -1,407 +1,427 @@
|
|||
// npx vitest run src/api/providers/__tests__/minimax.spec.ts
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
|
||||
vitest.mock("vscode", () => ({
|
||||
workspace: {
|
||||
getConfiguration: vitest.fn().mockReturnValue({
|
||||
get: vitest.fn().mockReturnValue(600), // Default timeout in seconds
|
||||
}),
|
||||
},
|
||||
}))
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
|
||||
import { minimaxDefaultModelId } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
import type { ApiStream, ApiStreamChunk } from "../../transform/stream"
|
||||
import { MiniMaxHandler } from "../minimax"
|
||||
|
||||
vitest.mock("@anthropic-ai/sdk", () => {
|
||||
const mockCreate = vitest.fn()
|
||||
const {
|
||||
mockStreamText,
|
||||
mockGenerateText,
|
||||
mockCreateAnthropic,
|
||||
mockModel,
|
||||
mockMergeEnvironmentDetailsForMiniMax,
|
||||
mockHandleAiSdkError,
|
||||
} = vi.hoisted(() => {
|
||||
const mockModel = vi.fn().mockReturnValue("mock-model-instance")
|
||||
return {
|
||||
Anthropic: vitest.fn(() => ({
|
||||
messages: {
|
||||
create: mockCreate,
|
||||
},
|
||||
})),
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
mockCreateAnthropic: vi.fn().mockReturnValue(mockModel),
|
||||
mockModel,
|
||||
mockMergeEnvironmentDetailsForMiniMax: vi.fn((messages: Anthropic.Messages.MessageParam[]) => messages),
|
||||
mockHandleAiSdkError: vi.fn((error: unknown, providerName: string) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return new Error(`${providerName}: ${message}`)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("ai", () => ({
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}))
|
||||
|
||||
vi.mock("@ai-sdk/anthropic", () => ({
|
||||
createAnthropic: mockCreateAnthropic,
|
||||
}))
|
||||
|
||||
vi.mock("../../transform/minimax-format", () => ({
|
||||
mergeEnvironmentDetailsForMiniMax: mockMergeEnvironmentDetailsForMiniMax,
|
||||
}))
|
||||
|
||||
vi.mock("../../transform/ai-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../transform/ai-sdk")>()
|
||||
return {
|
||||
...actual,
|
||||
handleAiSdkError: mockHandleAiSdkError,
|
||||
}
|
||||
})
|
||||
|
||||
type HandlerOptions = Omit<Partial<ApiHandlerOptions>, "minimaxBaseUrl"> & {
|
||||
minimaxBaseUrl?: string
|
||||
}
|
||||
|
||||
function createHandler(options: HandlerOptions = {}) {
|
||||
return new MiniMaxHandler({
|
||||
minimaxApiKey: "test-api-key",
|
||||
...options,
|
||||
} as ApiHandlerOptions)
|
||||
}
|
||||
|
||||
function createMockStream(
|
||||
chunks: Array<Record<string, unknown>>,
|
||||
usage: { inputTokens?: number; outputTokens?: number } = { inputTokens: 10, outputTokens: 5 },
|
||||
providerMetadata: Record<string, Record<string, unknown>> = {
|
||||
anthropic: {
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
},
|
||||
) {
|
||||
const stream = (async function* () {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
fullStream: stream,
|
||||
usage: Promise.resolve(usage),
|
||||
providerMetadata: Promise.resolve(providerMetadata),
|
||||
response: Promise.resolve({ headers: new Headers() }),
|
||||
}
|
||||
}
|
||||
|
||||
async function collectChunks(stream: ApiStream): Promise<ApiStreamChunk[]> {
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe("MiniMaxHandler", () => {
|
||||
let handler: MiniMaxHandler
|
||||
let mockCreate: any
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
const anthropicInstance = (Anthropic as unknown as any)()
|
||||
mockCreate = anthropicInstance.messages.create
|
||||
vi.clearAllMocks()
|
||||
mockCreateAnthropic.mockReturnValue(mockModel)
|
||||
mockMergeEnvironmentDetailsForMiniMax.mockImplementation(
|
||||
(inputMessages: Anthropic.Messages.MessageParam[]) => inputMessages,
|
||||
)
|
||||
mockHandleAiSdkError.mockImplementation((error: unknown, providerName: string) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return new Error(`${providerName}: ${message}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("International MiniMax (default)", () => {
|
||||
beforeEach(() => {
|
||||
handler = new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
describe("constructor", () => {
|
||||
it("uses default base URL when no baseUrl is provided", () => {
|
||||
createHandler()
|
||||
expect(mockCreateAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/anthropic/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("converts /v1 base URL to /anthropic/v1", () => {
|
||||
createHandler({
|
||||
minimaxBaseUrl: "https://api.minimax.io/v1",
|
||||
})
|
||||
})
|
||||
|
||||
it("should use the correct international MiniMax base URL by default", () => {
|
||||
new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
expect(mockCreateAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/anthropic",
|
||||
baseURL: "https://api.minimax.io/anthropic/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert /v1 endpoint to /anthropic endpoint", () => {
|
||||
new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimax.io/v1",
|
||||
it("appends /v1 for base URL already ending with /anthropic", () => {
|
||||
createHandler({
|
||||
minimaxBaseUrl: "https://api.minimax.io/anthropic",
|
||||
})
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
|
||||
expect(mockCreateAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/anthropic",
|
||||
baseURL: "https://api.minimax.io/anthropic/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
const minimaxApiKey = "test-minimax-api-key"
|
||||
new MiniMaxHandler({ minimaxApiKey })
|
||||
expect(Anthropic).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",
|
||||
it("appends /anthropic/v1 when base URL has no suffix", () => {
|
||||
createHandler({
|
||||
minimaxBaseUrl: "https://api.minimax.io/custom",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(minimaxModels[testModelId])
|
||||
|
||||
expect(mockCreateAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/custom/anthropic/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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("supports the China endpoint", () => {
|
||||
createHandler({
|
||||
minimaxBaseUrl: "https://api.minimaxi.com/anthropic",
|
||||
})
|
||||
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(mockCreateAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimaxi.com/anthropic/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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",
|
||||
it("treats empty baseUrl as falsy and falls back to default", () => {
|
||||
createHandler({
|
||||
minimaxBaseUrl: "",
|
||||
})
|
||||
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(mockCreateAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/anthropic/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("passes API key through to createAnthropic", () => {
|
||||
createHandler({
|
||||
minimaxApiKey: "minimax-key-123",
|
||||
})
|
||||
|
||||
expect(mockCreateAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: "minimax-key-123",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("China MiniMax", () => {
|
||||
beforeEach(() => {
|
||||
handler = new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimaxi.com/v1",
|
||||
})
|
||||
})
|
||||
|
||||
it("should use the correct China MiniMax base URL", () => {
|
||||
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 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(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
describe("getModel", () => {
|
||||
it("returns default model when no model ID is specified", () => {
|
||||
const handler = createHandler()
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(minimaxDefaultModelId)
|
||||
expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Default behavior", () => {
|
||||
it("should default to international base URL when none is specified", () => {
|
||||
const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/anthropic",
|
||||
}),
|
||||
)
|
||||
|
||||
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")
|
||||
expect(model.temperature).toBe(1)
|
||||
})
|
||||
|
||||
it("returns specified model when valid model ID is provided", () => {
|
||||
const handler = createHandler({
|
||||
apiModelId: "MiniMax-M2-Stable",
|
||||
})
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("MiniMax-M2-Stable")
|
||||
})
|
||||
|
||||
it("falls back to default model when unknown model ID is provided", () => {
|
||||
const handler = createHandler({
|
||||
apiModelId: "unknown-model",
|
||||
})
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(minimaxDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("API Methods", () => {
|
||||
beforeEach(() => {
|
||||
handler = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
})
|
||||
describe("createMessage", () => {
|
||||
it("streams text chunks and calls streamText with expected params", async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
createMockStream([
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: " world" },
|
||||
]),
|
||||
)
|
||||
|
||||
it("completePrompt method should return text from MiniMax API", async () => {
|
||||
const expectedResponse = "This is a test response from MiniMax"
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [{ type: "text", text: expectedResponse }],
|
||||
})
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
const handler = createHandler()
|
||||
const chunks = await collectChunks(handler.createMessage(systemPrompt, messages))
|
||||
|
||||
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("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from MiniMax stream"
|
||||
|
||||
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", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
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 }),
|
||||
}),
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
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.mockResolvedValueOnce({
|
||||
[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(mockModel).toHaveBeenCalledWith("MiniMax-M2")
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: modelId,
|
||||
max_tokens: Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)),
|
||||
model: "mock-model-instance",
|
||||
system: systemPrompt,
|
||||
temperature: 1,
|
||||
system: expect.any(Array),
|
||||
messages: expect.any(Array),
|
||||
stream: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(2)
|
||||
expect(textChunks[0]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(textChunks[1]).toEqual({ type: "text", text: " world" })
|
||||
})
|
||||
|
||||
it("should use temperature 1 by default", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
it("streams reasoning chunks", async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
createMockStream([
|
||||
{ type: "reasoning", text: "thinking..." },
|
||||
{ type: "reasoning", text: " step 2" },
|
||||
]),
|
||||
)
|
||||
|
||||
const handler = createHandler()
|
||||
const chunks = await collectChunks(handler.createMessage(systemPrompt, messages))
|
||||
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks).toHaveLength(2)
|
||||
expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "thinking..." })
|
||||
expect(reasoningChunks[1]).toEqual({ type: "reasoning", text: " step 2" })
|
||||
})
|
||||
|
||||
it("streams tool call chunks", async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
createMockStream([
|
||||
{ type: "tool-input-start", id: "call_1", toolName: "read_file" },
|
||||
{ type: "tool-input-delta", id: "call_1", delta: '{"path":"a.ts"}' },
|
||||
{ type: "tool-input-end", id: "call_1" },
|
||||
]),
|
||||
)
|
||||
|
||||
const handler = createHandler()
|
||||
const chunks = await collectChunks(handler.createMessage(systemPrompt, messages))
|
||||
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_start",
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
})
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_delta",
|
||||
id: "call_1",
|
||||
delta: '{"path":"a.ts"}',
|
||||
})
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_end",
|
||||
id: "call_1",
|
||||
})
|
||||
})
|
||||
|
||||
it("yields usage chunk with token and cost information", async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
createMockStream(
|
||||
[{ type: "text-delta", text: "Done" }],
|
||||
{ inputTokens: 10, outputTokens: 5 },
|
||||
{
|
||||
anthropic: {
|
||||
cacheCreationInputTokens: 3,
|
||||
cacheReadInputTokens: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const handler = createHandler()
|
||||
const chunks = await collectChunks(handler.createMessage(systemPrompt, messages))
|
||||
const usageChunk = chunks.find((chunk) => chunk.type === "usage")
|
||||
|
||||
expect(usageChunk).toMatchObject({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheWriteTokens: 3,
|
||||
cacheReadTokens: 2,
|
||||
})
|
||||
expect(typeof usageChunk?.totalCost).toBe("number")
|
||||
})
|
||||
|
||||
const messageGenerator = handler.createMessage("test", [])
|
||||
await messageGenerator.next()
|
||||
it("calls mergeEnvironmentDetailsForMiniMax before conversion", async () => {
|
||||
const mergedMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Merged message" }],
|
||||
},
|
||||
]
|
||||
mockMergeEnvironmentDetailsForMiniMax.mockReturnValueOnce(mergedMessages)
|
||||
mockStreamText.mockReturnValue(createMockStream([{ type: "text-delta", text: "OK" }]))
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 1,
|
||||
}),
|
||||
const handler = createHandler()
|
||||
await collectChunks(handler.createMessage(systemPrompt, messages))
|
||||
|
||||
expect(mockMergeEnvironmentDetailsForMiniMax).toHaveBeenCalledWith(messages)
|
||||
const callArgs = mockStreamText.mock.calls[0]?.[0]
|
||||
expect(callArgs.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Merged message" }],
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle thinking blocks in stream", async () => {
|
||||
const thinkingContent = "Let me think about this..."
|
||||
|
||||
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 }),
|
||||
}),
|
||||
it("handles errors via handleAiSdkError", async () => {
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw new Error("API Error")
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
const handler = createHandler()
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
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)
|
||||
// Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "tool-123",
|
||||
name: "get_weather",
|
||||
arguments: undefined,
|
||||
})
|
||||
await expect(async () => {
|
||||
await collectChunks(stream)
|
||||
}).rejects.toThrow("MiniMax: API Error")
|
||||
expect(mockHandleAiSdkError).toHaveBeenCalledWith(expect.any(Error), "MiniMax")
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
describe("thinking signature", () => {
|
||||
it("returns undefined thought signature before any request", () => {
|
||||
const handler = createHandler()
|
||||
expect(handler.getThoughtSignature()).toBeUndefined()
|
||||
})
|
||||
|
||||
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("captures thought signature from stream providerMetadata", async () => {
|
||||
const signature = "test-thinking-signature"
|
||||
mockStreamText.mockReturnValue(
|
||||
createMockStream([
|
||||
{
|
||||
type: "reasoning-delta",
|
||||
text: "thinking...",
|
||||
providerMetadata: { anthropic: { signature } },
|
||||
},
|
||||
{ type: "text-delta", text: "Answer" },
|
||||
]),
|
||||
)
|
||||
|
||||
const handler = createHandler()
|
||||
await collectChunks(handler.createMessage(systemPrompt, messages))
|
||||
|
||||
expect(handler.getThoughtSignature()).toBe(signature)
|
||||
})
|
||||
|
||||
it("returns undefined redacted thinking blocks before any request", () => {
|
||||
const handler = createHandler()
|
||||
expect(handler.getRedactedThinkingBlocks()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("calls generateText with model and prompt and returns text", async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: "response" })
|
||||
|
||||
const handler = createHandler()
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
|
||||
expect(result).toBe("response")
|
||||
expect(mockModel).toHaveBeenCalledWith("MiniMax-M2")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "mock-model-instance",
|
||||
prompt: "test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAiSdkProvider", () => {
|
||||
it("returns true", () => {
|
||||
const handler = createHandler()
|
||||
expect(handler.isAiSdkProvider()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,277 +1,264 @@
|
|||
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 { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { streamText, generateText, ToolSet } from "ai"
|
||||
|
||||
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
|
||||
import { type ModelInfo, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format"
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
handleAiSdkError,
|
||||
} from "../transform/ai-sdk"
|
||||
import { calculateApiCostAnthropic } from "../../shared/cost"
|
||||
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
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 client: ReturnType<typeof createAnthropic>
|
||||
private options: ApiHandlerOptions
|
||||
private client: Anthropic
|
||||
private readonly providerName = "MiniMax"
|
||||
private lastThoughtSignature: string | undefined
|
||||
private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = []
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
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"
|
||||
const rawBaseUrl = this.options.minimaxBaseUrl
|
||||
let resolvedBaseUrl: string | undefined
|
||||
|
||||
// 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`
|
||||
if (rawBaseUrl) {
|
||||
if (rawBaseUrl.endsWith("/anthropic/v1")) {
|
||||
resolvedBaseUrl = rawBaseUrl
|
||||
} else if (rawBaseUrl.endsWith("/v1")) {
|
||||
resolvedBaseUrl = rawBaseUrl.slice(0, -3) + "/anthropic/v1"
|
||||
} else if (rawBaseUrl.endsWith("/anthropic")) {
|
||||
resolvedBaseUrl = rawBaseUrl + "/v1"
|
||||
} else {
|
||||
resolvedBaseUrl = rawBaseUrl + "/anthropic/v1"
|
||||
}
|
||||
} else {
|
||||
resolvedBaseUrl = "https://api.minimax.io/anthropic/v1"
|
||||
}
|
||||
|
||||
this.client = new Anthropic({
|
||||
baseURL,
|
||||
apiKey: options.minimaxApiKey,
|
||||
this.client = createAnthropic({
|
||||
baseURL: resolvedBaseUrl,
|
||||
apiKey: this.options.minimaxApiKey ?? "",
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
}
|
||||
|
||||
async *createMessage(
|
||||
override 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()
|
||||
const modelConfig = this.getModel()
|
||||
|
||||
// MiniMax M2 models support prompt caching
|
||||
const supportsPromptCache = info.supportsPromptCache ?? false
|
||||
// Reset thinking state for this request
|
||||
this.lastThoughtSignature = undefined
|
||||
this.lastRedactedThinkingBlocks = []
|
||||
|
||||
// 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)
|
||||
const modelParams = getModelParams({
|
||||
format: "anthropic",
|
||||
modelId: modelConfig.id,
|
||||
model: modelConfig.info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 1.0,
|
||||
})
|
||||
|
||||
// Build the system blocks array
|
||||
const systemBlocks: Anthropic.Messages.TextBlockParam[] = [
|
||||
supportsPromptCache
|
||||
? { text: systemPrompt, type: "text", cache_control: cacheControl }
|
||||
: { text: systemPrompt, type: "text" },
|
||||
]
|
||||
const mergedMessages = mergeEnvironmentDetailsForMiniMax(messages)
|
||||
const aiSdkMessages = convertToAiSdkMessages(mergedMessages)
|
||||
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
||||
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
||||
|
||||
// Prepare request parameters
|
||||
const requestParams: Anthropic.Messages.MessageCreateParams = {
|
||||
model: modelId,
|
||||
max_tokens: maxTokens ?? 16_384,
|
||||
temperature: temperature ?? 1.0,
|
||||
system: systemBlocks,
|
||||
messages: supportsPromptCache ? this.addCacheControl(processedMessages, cacheControl) : processedMessages,
|
||||
stream: true,
|
||||
tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []),
|
||||
tool_choice: convertOpenAIToolChoice(metadata?.tool_choice),
|
||||
}
|
||||
const anthropicProviderOptions: Record<string, unknown> = {}
|
||||
|
||||
stream = await this.client.messages.create(requestParams)
|
||||
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cacheWriteTokens = 0
|
||||
let cacheReadTokens = 0
|
||||
|
||||
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": {
|
||||
// Emit initial tool call partial with id and name
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: chunk.index,
|
||||
id: chunk.content_block.id,
|
||||
name: chunk.content_block.name,
|
||||
arguments: undefined,
|
||||
}
|
||||
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": {
|
||||
// Emit tool call partial chunks as arguments stream in
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: chunk.index,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
arguments: chunk.delta.partial_json,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
case "content_block_stop":
|
||||
// Block is complete - no action needed, NativeToolCallParser handles completion
|
||||
break
|
||||
if (modelParams.reasoning && modelParams.reasoningBudget) {
|
||||
anthropicProviderOptions.thinking = {
|
||||
type: "enabled",
|
||||
budgetTokens: modelParams.reasoningBudget,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
if (metadata?.parallelToolCalls === false) {
|
||||
anthropicProviderOptions.disableParallelToolUse = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } }
|
||||
const userMsgIndices = mergedMessages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
|
||||
const targetIndices = new Set<number>()
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
const secondLastUserMsgIndex = 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,
|
||||
),
|
||||
if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex)
|
||||
if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex)
|
||||
|
||||
if (targetIndices.size > 0) {
|
||||
this.applyCacheControlToAiSdkMessages(mergedMessages, aiSdkMessages, targetIndices, cacheProviderOption)
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
model: this.client(modelConfig.id),
|
||||
system: systemPrompt,
|
||||
...({
|
||||
systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
|
||||
} as Record<string, unknown>),
|
||||
messages: aiSdkMessages,
|
||||
temperature: modelParams.temperature,
|
||||
maxOutputTokens: modelParams.maxTokens ?? modelConfig.info.maxTokens,
|
||||
tools: aiSdkTools,
|
||||
toolChoice: mapToolChoice(metadata?.tool_choice),
|
||||
...(Object.keys(anthropicProviderOptions).length > 0 && {
|
||||
providerOptions: { anthropic: anthropicProviderOptions } as Record<string, Record<string, unknown>>,
|
||||
}),
|
||||
}
|
||||
|
||||
try {
|
||||
const result = streamText(requestOptions as Parameters<typeof streamText>[0])
|
||||
|
||||
for await (const part of result.fullStream) {
|
||||
const anthropicMetadata = (
|
||||
part as {
|
||||
providerMetadata?: {
|
||||
anthropic?: {
|
||||
signature?: string
|
||||
redactedData?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
).providerMetadata?.anthropic
|
||||
|
||||
if (anthropicMetadata?.signature) {
|
||||
this.lastThoughtSignature = anthropicMetadata.signature
|
||||
}
|
||||
|
||||
if (anthropicMetadata?.redactedData) {
|
||||
this.lastRedactedThinkingBlocks.push({
|
||||
type: "redacted_thinking",
|
||||
data: anthropicMetadata.redactedData,
|
||||
})
|
||||
}
|
||||
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
const usage = await result.usage
|
||||
const providerMetadata = await result.providerMetadata
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata)
|
||||
}
|
||||
} catch (error) {
|
||||
throw handleAiSdkError(error, this.providerName)
|
||||
}
|
||||
}
|
||||
|
||||
private processUsageMetrics(
|
||||
usage: { inputTokens?: number; outputTokens?: number },
|
||||
info: ModelInfo,
|
||||
providerMetadata?: Record<string, Record<string, unknown>>,
|
||||
): ApiStreamUsageChunk {
|
||||
const inputTokens = usage.inputTokens ?? 0
|
||||
const outputTokens = usage.outputTokens ?? 0
|
||||
|
||||
const anthropicMeta = providerMetadata?.anthropic as
|
||||
| { cacheCreationInputTokens?: number; cacheReadInputTokens?: number }
|
||||
| undefined
|
||||
const cacheWriteTokens = anthropicMeta?.cacheCreationInputTokens ?? 0
|
||||
const cacheReadTokens = anthropicMeta?.cacheReadInputTokens ?? 0
|
||||
|
||||
const { totalCost } = calculateApiCostAnthropic(
|
||||
info,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
)
|
||||
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
|
||||
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
|
||||
totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
private applyCacheControlToAiSdkMessages(
|
||||
originalMessages: Anthropic.Messages.MessageParam[],
|
||||
aiSdkMessages: { role: string; providerOptions?: Record<string, Record<string, unknown>> }[],
|
||||
targetOriginalIndices: Set<number>,
|
||||
cacheProviderOption: Record<string, Record<string, unknown>>,
|
||||
): void {
|
||||
let aiSdkIdx = 0
|
||||
for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) {
|
||||
const origMsg = originalMessages[origIdx]
|
||||
|
||||
if (typeof origMsg.content === "string") {
|
||||
if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) {
|
||||
aiSdkMessages[aiSdkIdx].providerOptions = {
|
||||
...aiSdkMessages[aiSdkIdx].providerOptions,
|
||||
...cacheProviderOption,
|
||||
}
|
||||
}
|
||||
aiSdkIdx++
|
||||
} else if (origMsg.role === "user") {
|
||||
const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result")
|
||||
const hasNonToolContent = origMsg.content.some(
|
||||
(part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image",
|
||||
)
|
||||
|
||||
if (hasToolResults && hasNonToolContent) {
|
||||
const userMsgIdx = aiSdkIdx + 1
|
||||
if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) {
|
||||
aiSdkMessages[userMsgIdx].providerOptions = {
|
||||
...aiSdkMessages[userMsgIdx].providerOptions,
|
||||
...cacheProviderOption,
|
||||
}
|
||||
}
|
||||
aiSdkIdx += 2
|
||||
} else if (hasToolResults) {
|
||||
if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) {
|
||||
aiSdkMessages[aiSdkIdx].providerOptions = {
|
||||
...aiSdkMessages[aiSdkIdx].providerOptions,
|
||||
...cacheProviderOption,
|
||||
}
|
||||
}
|
||||
aiSdkIdx++
|
||||
} else {
|
||||
if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) {
|
||||
aiSdkMessages[aiSdkIdx].providerOptions = {
|
||||
...aiSdkMessages[aiSdkIdx].providerOptions,
|
||||
...cacheProviderOption,
|
||||
}
|
||||
}
|
||||
aiSdkIdx++
|
||||
}
|
||||
} else {
|
||||
aiSdkIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel() {
|
||||
const modelId = this.options.apiModelId
|
||||
const id = modelId && modelId in minimaxModels ? (modelId as MinimaxModelId) : minimaxDefaultModelId
|
||||
|
||||
const id = modelId && modelId in minimaxModels ? (modelId as keyof typeof minimaxModels) : minimaxDefaultModelId
|
||||
const info = minimaxModels[id]
|
||||
|
||||
const params = getModelParams({
|
||||
|
|
@ -289,18 +276,32 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
|
|||
}
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string) {
|
||||
const { id: model, temperature } = this.getModel()
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id, maxTokens, 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,
|
||||
})
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.client(id),
|
||||
prompt,
|
||||
maxOutputTokens: maxTokens ?? minimaxModels[minimaxDefaultModelId].maxTokens,
|
||||
temperature,
|
||||
})
|
||||
|
||||
const content = message.content.find(({ type }) => type === "text")
|
||||
return content?.type === "text" ? content.text : ""
|
||||
return text
|
||||
} catch (error) {
|
||||
throw handleAiSdkError(error, this.providerName)
|
||||
}
|
||||
}
|
||||
|
||||
getThoughtSignature(): string | undefined {
|
||||
return this.lastThoughtSignature
|
||||
}
|
||||
|
||||
getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined {
|
||||
return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue