From 9f65fc55aa63d70e054a37a7d15519e25a3a7edf Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:04:20 +0800 Subject: [PATCH 1/2] Add MiniMax chat provider --- .../processors/providers/minimax/__init__.py | 19 ++ .../providers/minimax/chat_completions.py | 176 ++++++++++++++++++ llmstack/server/settings.py | 6 + 3 files changed, 201 insertions(+) create mode 100644 llmstack/processors/providers/minimax/__init__.py create mode 100644 llmstack/processors/providers/minimax/chat_completions.py diff --git a/llmstack/processors/providers/minimax/__init__.py b/llmstack/processors/providers/minimax/__init__.py new file mode 100644 index 00000000000..fb93021802a --- /dev/null +++ b/llmstack/processors/providers/minimax/__init__.py @@ -0,0 +1,19 @@ +from pydantic import Field + +from llmstack.processors.providers.config import ProviderConfig + + +class MiniMaxProviderConfig(ProviderConfig): + provider_slug: str = "minimax" + api_key: str = Field( + title="API Key", + description="Your MiniMax API Key", + default="", + json_schema_extra={"widget": "password", "advanced_parameter": False}, + ) + base_url: str = Field( + title="Base URL", + description=("Base URL for the MiniMax chat API. " "Use https://api.minimaxi.com/v1 for the China endpoint."), + default="https://api.minimax.io/v1", + min_length=1, + ) diff --git a/llmstack/processors/providers/minimax/chat_completions.py b/llmstack/processors/providers/minimax/chat_completions.py new file mode 100644 index 00000000000..d7c173973b4 --- /dev/null +++ b/llmstack/processors/providers/minimax/chat_completions.py @@ -0,0 +1,176 @@ +import logging +from typing import List, Optional + +from asgiref.sync import async_to_sync +from pydantic import Field, model_validator + +from llmstack.apps.schemas import OutputTemplate +from llmstack.common.blocks.base.schema import StrEnum +from llmstack.processors.providers.api_processor_interface import ( + ApiProcessorInterface, + ApiProcessorSchema, +) + +logger = logging.getLogger(__name__) + + +class MessagesModel(StrEnum): + MINIMAX_M3 = "MiniMax-M3" + MINIMAX_M2_7 = "MiniMax-M2.7" + + def model_name(self): + return self.value + + +class ThinkingType(StrEnum): + ADAPTIVE = "adaptive" + DISABLED = "disabled" + + +class Role(StrEnum): + USER = "user" + SYSTEM = "system" + ASSISTANT = "assistant" + + +class ChatMessage(ApiProcessorSchema): + role: Role = Field( + default=Role.USER, + description="The role of the message. Can be 'system', 'user', or 'assistant'.", + ) + message: str = Field( + default="", + description="The message text.", + json_schema_extra={"widget": "textarea"}, + ) + + +class MessagesInput(ApiProcessorSchema): + messages: List[ChatMessage] = Field( + default=[ + ChatMessage(), + ], + description="A list of messages, each with a role and message text.", + ) + + +class MessagesOutput(ApiProcessorSchema): + result: str = Field(description="The response message.") + + +class MessagesConfiguration(ApiProcessorSchema): + system_prompt: str = Field( + default="", + description="A system prompt is a way of providing context and instructions to the model.", + json_schema_extra={"widget": "textarea", "advanced_parameter": False}, + ) + model: MessagesModel = Field( + default=MessagesModel.MINIMAX_M3, + description="The MiniMax model that will generate the responses.", + json_schema_extra={"advanced_parameter": False}, + ) + max_tokens: int = Field( + ge=1, + default=256, + description="The maximum number of tokens to generate before stopping.", + json_schema_extra={"advanced_parameter": False}, + ) + temperature: float = Field( + default=1.0, + description="Amount of randomness injected into the response.", + ge=0.0, + le=2.0, + json_schema_extra={"advanced_parameter": False}, + ) + thinking: ThinkingType = Field( + default=ThinkingType.ADAPTIVE, + description=("Thinking mode for MiniMax-M3. MiniMax-M2.7 always keeps thinking enabled."), + ) + retain_history: Optional[bool] = Field( + default=False, + description="Retain and use the chat history. (Only works in apps)", + ) + + @model_validator(mode="after") + def validate_thinking(self): + if self.model == MessagesModel.MINIMAX_M2_7 and self.thinking == ThinkingType.DISABLED: + raise ValueError("MiniMax-M2.7 does not support disabling thinking") + return self + + +class MessagesProcessor(ApiProcessorInterface[MessagesInput, MessagesOutput, MessagesConfiguration]): + @staticmethod + def name() -> str: + return "Chat Completions" + + @staticmethod + def slug() -> str: + return "chat_completions" + + @staticmethod + def description() -> str: + return "MiniMax Chat Completions" + + @staticmethod + def provider_slug() -> str: + return "minimax" + + @classmethod + def get_output_template(cls) -> OutputTemplate: + return OutputTemplate( + markdown="""{{ result }}""", + jsonpath="$.result", + ) + + def process_session_data(self, session_data): + self._chat_history = session_data["chat_history"] if "chat_history" in session_data else [] + + def session_data_to_persist(self) -> dict: + if self._config.retain_history: + return {"chat_history": self._chat_history} + return {} + + def process(self) -> MessagesOutput: + from llmstack.common.utils.sslr import LLM + + minimax_provider_config = self.get_provider_config(model_slug=self._config.model.model_name()) + client = LLM( + provider="openai", + openai_api_key=minimax_provider_config.api_key, + openai_base_url=minimax_provider_config.base_url, + ) + messages = [] + + if self._config.system_prompt: + messages.append({"role": "system", "content": self._config.system_prompt}) + + if self._chat_history: + for message in self._chat_history: + messages.append({"role": message["role"], "content": message["message"]}) + + for message in self._input.messages: + messages.append({"role": str(message.role), "content": str(message.message)}) + + response = client.chat.completions.create( + messages=messages, + model=self._config.model.model_name(), + max_tokens=self._config.max_tokens, + stream=True, + temperature=self._config.temperature, + extra_body={"thinking": {"type": self._config.thinking.value}}, + ) + + for result in response: + choice = result.choices[0] + if choice.delta.content: + async_to_sync(self._output_stream.write)(MessagesOutput(result=choice.delta.content)) + + output = self._output_stream.finalize() + + if self._config.retain_history: + for message in self._input.messages: + self._chat_history.append({"role": str(message.role), "message": str(message.message)}) + + self._chat_history.append({"role": "assistant", "message": output.result}) + + return output diff --git a/llmstack/server/settings.py b/llmstack/server/settings.py index b720d01ecf4..ef8db3c500d 100644 --- a/llmstack/server/settings.py +++ b/llmstack/server/settings.py @@ -574,6 +574,12 @@ "slug": "mistral", "config_schema": "llmstack.processors.providers.mistral.MistralProviderConfig", }, + { + "name": "MiniMax", + "processor_packages": ["llmstack.processors.providers.minimax"], + "slug": "minimax", + "config_schema": "llmstack.processors.providers.minimax.MiniMaxProviderConfig", + }, { "name": "Meta", "processor_packages": ["llmstack.processors.providers.meta"], From 4d806a48394c90af93a2c326bff09f05d5bd53dc Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:43:43 +0800 Subject: [PATCH 2/2] Complete MiniMax protocol support --- .../utils/sslr/resources/chat/completions.py | 35 ++++- .../processors/providers/minimax/__init__.py | 7 +- .../providers/minimax/chat_completions.py | 106 +++++++++++-- .../processors/providers/minimax/client.py | 23 +++ llmstack/processors/providers/test_minimax.py | 147 ++++++++++++++++++ 5 files changed, 297 insertions(+), 21 deletions(-) create mode 100644 llmstack/processors/providers/minimax/client.py create mode 100644 llmstack/processors/providers/test_minimax.py diff --git a/llmstack/common/utils/sslr/resources/chat/completions.py b/llmstack/common/utils/sslr/resources/chat/completions.py index 9a848fc3f77..c45f0371178 100644 --- a/llmstack/common/utils/sslr/resources/chat/completions.py +++ b/llmstack/common/utils/sslr/resources/chat/completions.py @@ -47,6 +47,29 @@ __all__ = ["Completions", "AsyncCompletions"] +def _convert_media_url_to_anthropic(part, media_type): + media = part[f"{media_type}_url"] + if isinstance(media, str): + media = {"url": media} + + url = media["url"] + if url.startswith("data:") and ";base64," in url: + metadata, data = url.split(",", 1) + source = { + "type": "base64", + "media_type": metadata.removeprefix("data:").removesuffix(";base64"), + "data": data, + } + else: + source = {"type": "url", "url": url} + + for option in ("detail", "fps", "max_long_side_pixel"): + if media.get(option) is not None: + source[option] = media[option] + + return {"type": media_type, "source": source} + + def _convert_to_anthropic_format(messages): anthropic_messages = [] last_role = None @@ -66,6 +89,10 @@ def _convert_to_anthropic_format(messages): anthropic_messages[-1]["content"].append( {"type": "text", "text": part.get("data") or part.get("text")} ) + elif part["type"] == "image_url": + anthropic_messages[-1]["content"].append(_convert_media_url_to_anthropic(part, "image")) + elif part["type"] == "video_url": + anthropic_messages[-1]["content"].append(_convert_media_url_to_anthropic(part, "video")) elif part["type"] == "file": if part["mime_type"].startswith("image"): anthropic_messages[-1]["content"].append( @@ -363,8 +390,8 @@ def _transform_grpc_response(model_response): ) elif part.function_call: call_id = generate_uuid( - f"""{part.function_call.name}_{ - json.dumps(convert_google_function_call_args_map_to_dict(part.function_call.args))}""" + f"{part.function_call.name}_" + f"{json.dumps(convert_google_function_call_args_map_to_dict(part.function_call.args))}" ) parts.append( { @@ -438,8 +465,8 @@ def _transform_streaming_grpc_response(chunk, usage_data=None): ) elif part.function_call: call_id = generate_uuid( - f"""{part.function_call.name}_{ - json.dumps(convert_google_function_call_args_map_to_dict(part.function_call.args))}""" + f"{part.function_call.name}_" + f"{json.dumps(convert_google_function_call_args_map_to_dict(part.function_call.args))}" ) parts.append( { diff --git a/llmstack/processors/providers/minimax/__init__.py b/llmstack/processors/providers/minimax/__init__.py index fb93021802a..2399c574b4a 100644 --- a/llmstack/processors/providers/minimax/__init__.py +++ b/llmstack/processors/providers/minimax/__init__.py @@ -13,7 +13,12 @@ class MiniMaxProviderConfig(ProviderConfig): ) base_url: str = Field( title="Base URL", - description=("Base URL for the MiniMax chat API. " "Use https://api.minimaxi.com/v1 for the China endpoint."), + description=( + "Base URL for the MiniMax chat API. Use https://api.minimax.io/v1 or " + "https://api.minimaxi.com/v1 for the OpenAI-compatible API. Use " + "https://api.minimax.io/anthropic or https://api.minimaxi.com/anthropic " + "for the Anthropic-compatible API." + ), default="https://api.minimax.io/v1", min_length=1, ) diff --git a/llmstack/processors/providers/minimax/chat_completions.py b/llmstack/processors/providers/minimax/chat_completions.py index d7c173973b4..88c2004480f 100644 --- a/llmstack/processors/providers/minimax/chat_completions.py +++ b/llmstack/processors/providers/minimax/chat_completions.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Annotated, List, Literal, Optional, Union from asgiref.sync import async_to_sync from pydantic import Field, model_validator @@ -10,6 +10,7 @@ ApiProcessorInterface, ApiProcessorSchema, ) +from llmstack.processors.providers.minimax.client import create_minimax_client logger = logging.getLogger(__name__) @@ -33,14 +34,58 @@ class Role(StrEnum): ASSISTANT = "assistant" +class TextContent(ApiProcessorSchema): + type: Literal["text"] + text: str = Field(description="The message text.") + + +class MediaUrl(ApiProcessorSchema): + url: str = Field(description="A public URL or data URL.") + detail: Optional[Literal["low", "default", "high"]] = Field( + default=None, + description="The media understanding detail level.", + ) + max_long_side_pixel: Optional[int] = Field( + default=None, + ge=1, + description="The longest-side pixel limit.", + ) + + +class VideoUrl(MediaUrl): + url: str = Field(description="A public URL, data URL, or uploaded file reference.") + fps: Optional[float] = Field( + default=None, + ge=0.2, + le=5, + description="The video frame sampling rate.", + ) + + +class ImageContent(ApiProcessorSchema): + type: Literal["image_url"] + image_url: MediaUrl + + +class VideoContent(ApiProcessorSchema): + type: Literal["video_url"] + video_url: VideoUrl + + +MessageContent = Annotated[ + Union[TextContent, ImageContent, VideoContent], + Field(discriminator="type"), +] + + class ChatMessage(ApiProcessorSchema): role: Role = Field( default=Role.USER, description="The role of the message. Can be 'system', 'user', or 'assistant'.", ) - message: str = Field( + message: Union[str, List[MessageContent]] = Field( default="", - description="The message text.", + description="Text or multimodal message content. Image and video content require MiniMax-M3.", json_schema_extra={"widget": "textarea"}, ) @@ -66,7 +111,10 @@ class MessagesConfiguration(ApiProcessorSchema): ) model: MessagesModel = Field( default=MessagesModel.MINIMAX_M3, - description="The MiniMax model that will generate the responses.", + description=( + "The MiniMax model that will generate the responses. MiniMax-M3 has a " + "1,000,000-token context window; MiniMax-M2.7 has a 204,800-token context window." + ), json_schema_extra={"advanced_parameter": False}, ) max_tokens: int = Field( @@ -92,9 +140,15 @@ class MessagesConfiguration(ApiProcessorSchema): ) @model_validator(mode="after") - def validate_thinking(self): + def validate_model_options(self): if self.model == MessagesModel.MINIMAX_M2_7 and self.thinking == ThinkingType.DISABLED: raise ValueError("MiniMax-M2.7 does not support disabling thinking") + max_output_tokens = { + MessagesModel.MINIMAX_M3: 524288, + MessagesModel.MINIMAX_M2_7: 204800, + }[self.model] + if self.max_tokens > max_output_tokens: + raise ValueError(f"{self.model} supports at most {max_output_tokens} output tokens") return self @@ -131,14 +185,6 @@ def session_data_to_persist(self) -> dict: return {} def process(self) -> MessagesOutput: - from llmstack.common.utils.sslr import LLM - - minimax_provider_config = self.get_provider_config(model_slug=self._config.model.model_name()) - client = LLM( - provider="openai", - openai_api_key=minimax_provider_config.api_key, - openai_base_url=minimax_provider_config.base_url, - ) messages = [] if self._config.system_prompt: @@ -149,7 +195,24 @@ def process(self) -> MessagesOutput: messages.append({"role": message["role"], "content": message["message"]}) for message in self._input.messages: - messages.append({"role": str(message.role), "content": str(message.message)}) + if isinstance(message.message, list): + content = [part.model_dump(exclude_none=True) for part in message.message] + else: + content = message.message + messages.append({"role": str(message.role), "content": content}) + + has_media = any( + isinstance(message["content"], list) + and any( + isinstance(part, dict) and part.get("type") in ("image_url", "video_url") for part in message["content"] + ) + for message in messages + ) + if self._config.model == MessagesModel.MINIMAX_M2_7 and has_media: + raise ValueError("MiniMax-M2.7 only supports text input") + + minimax_provider_config = self.get_provider_config(model_slug=self._config.model.model_name()) + client = create_minimax_client(minimax_provider_config) response = client.chat.completions.create( messages=messages, @@ -163,13 +226,24 @@ def process(self) -> MessagesOutput: for result in response: choice = result.choices[0] if choice.delta.content: - async_to_sync(self._output_stream.write)(MessagesOutput(result=choice.delta.content)) + if isinstance(choice.delta.content, list): + text_content = "".join( + entry.get("data", "") for entry in choice.delta.content if entry.get("type") == "text" + ) + else: + text_content = choice.delta.content + if text_content: + async_to_sync(self._output_stream.write)(MessagesOutput(result=text_content)) output = self._output_stream.finalize() if self._config.retain_history: for message in self._input.messages: - self._chat_history.append({"role": str(message.role), "message": str(message.message)}) + if isinstance(message.message, list): + persisted_message = [part.model_dump(exclude_none=True) for part in message.message] + else: + persisted_message = message.message + self._chat_history.append({"role": str(message.role), "message": persisted_message}) self._chat_history.append({"role": "assistant", "message": output.result}) diff --git a/llmstack/processors/providers/minimax/client.py b/llmstack/processors/providers/minimax/client.py new file mode 100644 index 00000000000..e020ffc6018 --- /dev/null +++ b/llmstack/processors/providers/minimax/client.py @@ -0,0 +1,23 @@ +from llmstack.processors.providers.minimax import MiniMaxProviderConfig + + +def create_minimax_client(provider_config: MiniMaxProviderConfig, http_client=None): + from llmstack.common.utils.sslr import LLM + + base_url = provider_config.base_url.rstrip("/") + client_options = {"http_client": http_client} if http_client is not None else {} + + if base_url.endswith("/anthropic"): + return LLM( + provider="anthropic", + anthropic_api_key=provider_config.api_key, + base_url=f"{base_url}/v1", + **client_options, + ) + + return LLM( + provider="openai", + openai_api_key=provider_config.api_key, + openai_base_url=base_url, + **client_options, + ) diff --git a/llmstack/processors/providers/test_minimax.py b/llmstack/processors/providers/test_minimax.py new file mode 100644 index 00000000000..448216ffd50 --- /dev/null +++ b/llmstack/processors/providers/test_minimax.py @@ -0,0 +1,147 @@ +import json +import unittest +from datetime import timedelta + +import httpx + +from llmstack.common.utils.sslr.resources.chat.completions import ( + _convert_to_anthropic_format, +) +from llmstack.processors.providers.minimax import MiniMaxProviderConfig +from llmstack.processors.providers.minimax.client import create_minimax_client + + +class MiniMaxProviderTest(unittest.TestCase): + def test_provider_schema_lists_all_supported_endpoints(self): + description = MiniMaxProviderConfig.model_fields["base_url"].description + + self.assertIn("https://api.minimax.io/v1", description) + self.assertIn("https://api.minimaxi.com/v1", description) + self.assertIn("https://api.minimax.io/anthropic", description) + self.assertIn("https://api.minimaxi.com/anthropic", description) + + def test_client_routes_all_supported_endpoints(self): + cases = ( + ("https://api.minimax.io/v1", "https://api.minimax.io/v1/chat/completions", "openai"), + ("https://api.minimaxi.com/v1", "https://api.minimaxi.com/v1/chat/completions", "openai"), + ("https://api.minimax.io/anthropic", "https://api.minimax.io/anthropic/v1/messages", "anthropic"), + ( + "https://api.minimaxi.com/anthropic", + "https://api.minimaxi.com/anthropic/v1/messages", + "anthropic", + ), + ) + + for base_url, expected_url, protocol in cases: + with self.subTest(base_url=base_url): + captured_request = None + + def handler(request): + nonlocal captured_request + captured_request = request + if protocol == "anthropic": + response = httpx.Response( + 200, + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "MiniMax-M3", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + response.elapsed = timedelta() + return response + response = httpx.Response( + 200, + json={ + "id": "chatcmpl_test", + "object": "chat.completion", + "created": 0, + "model": "MiniMax-M3", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + response.elapsed = timedelta() + return response + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + client = create_minimax_client( + MiniMaxProviderConfig(api_key="test-key", base_url=base_url), + http_client=http_client, + ) + try: + client.chat.completions.create( + messages=[{"role": "user", "content": "Hello"}], + model="MiniMax-M3", + max_tokens=8, + stream=False, + extra_body={"thinking": {"type": "adaptive"}}, + ) + finally: + client.close() + + self.assertIsNotNone(captured_request) + self.assertEqual(str(captured_request.url), expected_url) + self.assertEqual(json.loads(captured_request.content)["thinking"], {"type": "adaptive"}) + + def test_multimodal_content_converts_to_anthropic_blocks(self): + messages = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,aW1hZ2U=", + "detail": "high", + }, + }, + { + "type": "video_url", + "video_url": { + "url": "https://cdn.example.com/video.mp4", + "fps": 2, + }, + }, + ], + } + ] + + converted = _convert_to_anthropic_format(messages) + + self.assertEqual( + converted[0]["content"], + [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "aW1hZ2U=", + "detail": "high", + }, + }, + { + "type": "video", + "source": { + "type": "url", + "url": "https://cdn.example.com/video.mp4", + "fps": 2, + }, + }, + ], + ) + + +if __name__ == "__main__": + unittest.main()