-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: add MiniMax as a native LLM provider (M2.7 + M2.5) #1827
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,315 @@ | ||||||
| import type { ClientOptions } from "openai"; | ||||||
| import OpenAI from "openai"; | ||||||
| import { LogLine } from "../types/public/logs.js"; | ||||||
| import { AvailableModel } from "../types/public/model.js"; | ||||||
| import { | ||||||
| ChatMessage, | ||||||
| CreateChatCompletionOptions, | ||||||
| LLMClient, | ||||||
| LLMResponse, | ||||||
| } from "./LLMClient.js"; | ||||||
| import { CreateChatCompletionResponseError } from "../types/public/sdkErrors.js"; | ||||||
| import { toJsonSchema } from "../zodCompat.js"; | ||||||
|
|
||||||
| export class MiniMaxClient extends LLMClient { | ||||||
| public type = "minimax" as const; | ||||||
| private client: OpenAI; | ||||||
| declare public clientOptions: ClientOptions; | ||||||
| public hasVision = false; | ||||||
|
|
||||||
| constructor({ | ||||||
| modelName, | ||||||
| clientOptions, | ||||||
| userProvidedInstructions, | ||||||
| }: { | ||||||
| logger: (message: LogLine) => void; | ||||||
| modelName: AvailableModel; | ||||||
| clientOptions?: ClientOptions; | ||||||
| userProvidedInstructions?: string; | ||||||
| }) { | ||||||
| super(modelName, userProvidedInstructions); | ||||||
|
|
||||||
| // Create OpenAI client with the base URL set to MiniMax API | ||||||
| this.client = new OpenAI({ | ||||||
| baseURL: "https://api.minimax.io/v1", | ||||||
| apiKey: clientOptions?.apiKey || process.env.MINIMAX_API_KEY, | ||||||
| ...clientOptions, | ||||||
| }); | ||||||
|
|
||||||
| this.modelName = modelName; | ||||||
| this.clientOptions = clientOptions; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Strip thinking tags from M2.7 model responses. | ||||||
| * M2.7 models include `<think>...</think>` blocks in their output. | ||||||
| */ | ||||||
| private stripThinking(content: string | null): string | null { | ||||||
| if (!content) return content; | ||||||
| return content.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim() || null; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Extract the actual model name to send to the MiniMax API. | ||||||
| * Handles both modern format (minimax/MiniMax-M2.7) and | ||||||
| * deprecated format (minimax-MiniMax-M2.7). | ||||||
| */ | ||||||
| private getApiModelName(): string { | ||||||
| if (this.modelName.includes("/")) { | ||||||
| return this.modelName.substring(this.modelName.indexOf("/") + 1); | ||||||
| } | ||||||
| if (this.modelName.startsWith("minimax-")) { | ||||||
| return this.modelName.substring("minimax-".length); | ||||||
| } | ||||||
| return this.modelName; | ||||||
| } | ||||||
|
|
||||||
| async createChatCompletion<T = LLMResponse>({ | ||||||
| options, | ||||||
| retries, | ||||||
| logger, | ||||||
| }: CreateChatCompletionOptions): Promise<T> { | ||||||
| const optionsWithoutImage = { ...options }; | ||||||
| delete optionsWithoutImage.image; | ||||||
|
|
||||||
| logger({ | ||||||
| category: "minimax", | ||||||
| message: "creating chat completion", | ||||||
| level: 2, | ||||||
| auxiliary: { | ||||||
| options: { | ||||||
| value: JSON.stringify(optionsWithoutImage), | ||||||
| type: "object", | ||||||
| }, | ||||||
| }, | ||||||
| }); | ||||||
|
|
||||||
| // Format messages for MiniMax API (using OpenAI format) | ||||||
| const formattedMessages = options.messages.map((msg: ChatMessage) => { | ||||||
| const baseMessage = { | ||||||
| content: | ||||||
| typeof msg.content === "string" | ||||||
| ? msg.content | ||||||
| : Array.isArray(msg.content) && | ||||||
| msg.content.length > 0 && | ||||||
| "text" in msg.content[0] | ||||||
| ? msg.content[0].text | ||||||
| : "", | ||||||
| }; | ||||||
|
|
||||||
| if (msg.role === "system") { | ||||||
| return { ...baseMessage, role: "system" as const }; | ||||||
| } else if (msg.role === "assistant") { | ||||||
| return { ...baseMessage, role: "assistant" as const }; | ||||||
| } else { | ||||||
| return { ...baseMessage, role: "user" as const }; | ||||||
| } | ||||||
| }); | ||||||
|
|
||||||
| // Format tools if provided | ||||||
| let tools = options.tools?.map((tool) => ({ | ||||||
| type: "function" as const, | ||||||
| function: { | ||||||
| name: tool.name, | ||||||
| description: tool.description, | ||||||
| parameters: { | ||||||
| type: "object", | ||||||
| properties: tool.parameters.properties, | ||||||
| required: tool.parameters.required, | ||||||
| }, | ||||||
| }, | ||||||
| })); | ||||||
|
|
||||||
| // Add response model as a tool if provided | ||||||
| if (options.response_model) { | ||||||
| const jsonSchema = toJsonSchema(options.response_model.schema) as { | ||||||
| properties?: Record<string, unknown>; | ||||||
| required?: string[]; | ||||||
| }; | ||||||
| const schemaProperties = jsonSchema.properties || {}; | ||||||
| const schemaRequired = jsonSchema.required || []; | ||||||
|
|
||||||
| const responseTool = { | ||||||
| type: "function" as const, | ||||||
| function: { | ||||||
| name: "print_extracted_data", | ||||||
| description: | ||||||
| "Prints the extracted data based on the provided schema.", | ||||||
| parameters: { | ||||||
| type: "object", | ||||||
| properties: schemaProperties, | ||||||
| required: schemaRequired, | ||||||
| }, | ||||||
| }, | ||||||
| }; | ||||||
|
|
||||||
| tools = tools ? [...tools, responseTool] : [responseTool]; | ||||||
| } | ||||||
|
|
||||||
| try { | ||||||
| // MiniMax requires temperature in (0.0, 1.0] - zero is not allowed | ||||||
| let temperature = options.temperature || 0.7; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Temperature clamping is bypassed for zero The Use nullish coalescing (
Suggested change
|
||||||
| if (temperature <= 0) { | ||||||
| temperature = 0.01; | ||||||
| } else if (temperature > 1) { | ||||||
| temperature = 1.0; | ||||||
| } | ||||||
|
|
||||||
| const apiModelName = this.getApiModelName(); | ||||||
|
|
||||||
| // Use OpenAI client with MiniMax API | ||||||
| const apiResponse = await this.client.chat.completions.create({ | ||||||
| model: apiModelName, | ||||||
| messages: [ | ||||||
| ...formattedMessages, | ||||||
| // Add explicit instruction to return JSON if we have a response model | ||||||
| ...(options.response_model | ||||||
| ? [ | ||||||
| { | ||||||
| role: "system" as const, | ||||||
| content: `IMPORTANT: Your response must be valid JSON that matches this schema: ${JSON.stringify( | ||||||
| options.response_model.schema, | ||||||
| )}`, | ||||||
| }, | ||||||
| ] | ||||||
| : []), | ||||||
| ], | ||||||
| temperature, | ||||||
| max_tokens: options.maxOutputTokens, | ||||||
| tools: tools, | ||||||
| tool_choice: options.tool_choice || "auto", | ||||||
| }); | ||||||
|
|
||||||
| // Strip thinking tags from M2.7 model responses | ||||||
| const rawContent = apiResponse.choices[0]?.message?.content || null; | ||||||
| const cleanedContent = this.stripThinking(rawContent); | ||||||
|
|
||||||
| // Format the response to match the expected LLMResponse format | ||||||
| const response: LLMResponse = { | ||||||
| id: apiResponse.id, | ||||||
| object: "chat.completion", | ||||||
| created: Date.now(), | ||||||
| model: apiModelName, | ||||||
| choices: [ | ||||||
| { | ||||||
| index: 0, | ||||||
| message: { | ||||||
| role: "assistant", | ||||||
| content: cleanedContent, | ||||||
| tool_calls: apiResponse.choices[0]?.message?.tool_calls || [], | ||||||
| }, | ||||||
| finish_reason: apiResponse.choices[0]?.finish_reason || "stop", | ||||||
| }, | ||||||
| ], | ||||||
| usage: { | ||||||
| prompt_tokens: apiResponse.usage?.prompt_tokens || 0, | ||||||
| completion_tokens: apiResponse.usage?.completion_tokens || 0, | ||||||
| total_tokens: apiResponse.usage?.total_tokens || 0, | ||||||
| }, | ||||||
| }; | ||||||
|
|
||||||
| logger({ | ||||||
| category: "minimax", | ||||||
| message: "response", | ||||||
| level: 2, | ||||||
| auxiliary: { | ||||||
| response: { | ||||||
| value: JSON.stringify(response), | ||||||
| type: "object", | ||||||
| }, | ||||||
| requestId: { | ||||||
| value: options.requestId, | ||||||
| type: "string", | ||||||
| }, | ||||||
| }, | ||||||
| }); | ||||||
|
|
||||||
| // If there's no response model, return the entire response object | ||||||
| if (!options.response_model) { | ||||||
| return response as T; | ||||||
| } | ||||||
|
|
||||||
| // Otherwise, try parsing the JSON from the tool call or content | ||||||
| const toolCall = response.choices[0]?.message?.tool_calls?.[0]; | ||||||
| if (toolCall?.function?.arguments) { | ||||||
| try { | ||||||
| const result = JSON.parse(toolCall.function.arguments); | ||||||
| const finalResponse = { | ||||||
| data: result, | ||||||
| usage: response.usage, | ||||||
| }; | ||||||
| return finalResponse as T; | ||||||
| } catch (e) { | ||||||
| logger({ | ||||||
| category: "minimax", | ||||||
| message: "failed to parse tool call arguments as JSON, retrying", | ||||||
| level: 0, | ||||||
| auxiliary: { | ||||||
| error: { | ||||||
| value: e.message, | ||||||
| type: "string", | ||||||
| }, | ||||||
| }, | ||||||
| }); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // If we have content but no tool calls, try to parse the content as JSON | ||||||
| const content = response.choices[0]?.message?.content; | ||||||
| if (content) { | ||||||
| try { | ||||||
| // Try to extract JSON from the content | ||||||
| const jsonMatch = content.match(/\{[\s\S]*\}/); | ||||||
| if (jsonMatch) { | ||||||
| const result = JSON.parse(jsonMatch[0]); | ||||||
| const finalResponse = { | ||||||
| data: result, | ||||||
| usage: response.usage, | ||||||
| }; | ||||||
| return finalResponse as T; | ||||||
| } | ||||||
| } catch (e) { | ||||||
| logger({ | ||||||
| category: "minimax", | ||||||
| message: "failed to parse content as JSON", | ||||||
| level: 0, | ||||||
| auxiliary: { | ||||||
| error: { | ||||||
| value: e.message, | ||||||
| type: "string", | ||||||
| }, | ||||||
| }, | ||||||
| }); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // If we still haven't found valid JSON and have retries left, try again | ||||||
| if (!retries || retries < 5) { | ||||||
| return this.createChatCompletion({ | ||||||
| options, | ||||||
| logger, | ||||||
| retries: (retries ?? 0) + 1, | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| throw new CreateChatCompletionResponseError("Invalid response schema"); | ||||||
| } catch (error) { | ||||||
| logger({ | ||||||
| category: "minimax", | ||||||
| message: "error creating chat completion", | ||||||
| level: 0, | ||||||
| auxiliary: { | ||||||
| error: { | ||||||
| value: error.message, | ||||||
| type: "string", | ||||||
| }, | ||||||
| requestId: { | ||||||
| value: options.requestId, | ||||||
| type: "string", | ||||||
| }, | ||||||
| }, | ||||||
| }); | ||||||
| throw error; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Custom agent: Ensure we never check against hardcoded lists of allowed LLM model names
These two new hardcoded model name entries violate the rule against adding hardcoded model name allowlists. The modern
minimax/slash-based routing already works for any model name without hardcoding (via theif (subProvider === "minimax")block). Adding legacy-format entries here means each new MiniMax model requires a code change to this map.Consider not adding legacy format support for a brand-new provider — users should use the modern
minimax/MiniMax-M2.5format from the start, avoiding the need for hardcoded entries entirely.Prompt for AI agents