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 new file mode 100644 index 00000000000..2399c574b4a --- /dev/null +++ b/llmstack/processors/providers/minimax/__init__.py @@ -0,0 +1,24 @@ +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.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 new file mode 100644 index 00000000000..8d7dc2853a8 --- /dev/null +++ b/llmstack/processors/providers/minimax/chat_completions.py @@ -0,0 +1,266 @@ +import logging +from typing import Annotated, List, Literal, Optional, Union + +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, +) +from llmstack.processors.providers.minimax.client import create_minimax_client + +logger = logging.getLogger(__name__) + + +class MessagesModel(StrEnum): + MINIMAX_M3_1_FLASH = "MiniMax-M3.1-Flash" + MINIMAX_M3 = "MiniMax-M3" + + def model_name(self): + return self.value + + +class ThinkingType(StrEnum): + ADAPTIVE = "adaptive" + DISABLED = "disabled" + + +class Role(StrEnum): + USER = "user" + SYSTEM = "system" + 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: Union[str, List[MessageContent]] = Field( + default="", + description="Text or multimodal message content. MiniMax-M3.1-Flash and MiniMax-M3 support images and video.", + 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: str = Field( + default=MessagesModel.MINIMAX_M3_1_FLASH.value, + min_length=1, + description=( + "The MiniMax model that will generate the responses. MiniMax-M3.1-Flash and MiniMax-M3 have a " + "1,000,000-token context window. Saved legacy and custom model IDs remain supported." + ), + json_schema_extra={ + "advanced_parameter": False, + "widget": "customselect", + "options": [model.value for model in MessagesModel], + }, + ) + 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="MiniMax-M3 supports adaptive or disabled thinking. Flash and MiniMax-M2.7 require thinking.", + ) + reasoning_effort: Literal["low", "medium", "high", "xhigh", "max"] = Field( + default="max", + description="Thinking effort for MiniMax-M3.1-Flash. Not sent for other models.", + ) + retain_history: Optional[bool] = Field( + default=False, + description="Retain and use the chat history. (Only works in apps)", + ) + + @model_validator(mode="after") + def validate_model_options(self): + if self.model in (MessagesModel.MINIMAX_M3_1_FLASH, "MiniMax-M2.7") and self.thinking == ThinkingType.DISABLED: + raise ValueError(f"{self.model} does not support disabling thinking") + max_output_tokens = { + MessagesModel.MINIMAX_M3_1_FLASH: 524288, + MessagesModel.MINIMAX_M3: 524288, + "MiniMax-M2.7": 204800, + }.get(self.model) + if max_output_tokens is not None and self.max_tokens > max_output_tokens: + raise ValueError(f"{self.model} supports at most {max_output_tokens} output tokens") + 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: + 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: + 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 == "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) + client = create_minimax_client(minimax_provider_config) + extra_body = {"thinking": {"type": self._config.thinking.value}} + if self._config.model == MessagesModel.MINIMAX_M3_1_FLASH: + if minimax_provider_config.base_url.rstrip("/").endswith("/anthropic"): + extra_body["output_config"] = {"effort": self._config.reasoning_effort} + else: + extra_body["reasoning_effort"] = self._config.reasoning_effort + + response = client.chat.completions.create( + messages=messages, + model=self._config.model, + max_tokens=self._config.max_tokens, + stream=True, + temperature=self._config.temperature, + extra_body=extra_body, + ) + + for result in response: + choice = result.choices[0] + if 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: + 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}) + + return output 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..6c2cebd7352 --- /dev/null +++ b/llmstack/processors/providers/test_minimax.py @@ -0,0 +1,286 @@ +import json +import unittest +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import httpx +from pydantic import ValidationError + +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.chat_completions import ( + MessagesConfiguration, + MessagesInput, + MessagesOutput, + MessagesProcessor, +) +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, + }, + }, + ], + ) + + +class _OutputSink: + def __init__(self): + self.values = [] + + async def write(self, value): + self.values.append(value) + + def finalize(self): + return MessagesOutput(result="".join(value.result for value in self.values)) + + +class MiniMaxProcessorTest(unittest.TestCase): + def test_default_catalog_and_saved_models(self): + schema = MessagesConfiguration.model_json_schema()["properties"]["model"] + self.assertEqual(schema["default"], "MiniMax-M3.1-Flash") + self.assertEqual(schema["options"], ["MiniMax-M3.1-Flash", "MiniMax-M3"]) + self.assertEqual(schema["widget"], "customselect") + self.assertNotIn("enum", schema) + for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.1", "saved-custom-model"): + self.assertEqual(MessagesConfiguration(model=model).model, model) + + def test_model_limits_and_thinking(self): + for model in ("MiniMax-M3.1-Flash", "MiniMax-M3"): + self.assertEqual(MessagesConfiguration(model=model, max_tokens=524288).max_tokens, 524288) + with self.assertRaises(ValidationError): + MessagesConfiguration(model=model, max_tokens=524289) + for model in ("MiniMax-M3.1-Flash", "MiniMax-M2.7"): + with self.assertRaisesRegex(ValidationError, "does not support disabling thinking"): + MessagesConfiguration(model=model, thinking="disabled") + with self.assertRaises(ValidationError): + MessagesConfiguration(reasoning_effort="none") + self.assertEqual(MessagesConfiguration(model="MiniMax-M3", thinking="disabled").thinking, "disabled") + + def test_processor_serialized_requests(self): + media = [ + {"type": "text", "text": "Describe this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U=", "detail": "high"}}, + {"type": "video_url", "video_url": {"url": "https://example.com/video.mp4", "fps": 2}}, + ] + cases = [({}, "MiniMax-M3.1-Flash", "adaptive", "max")] + cases += [ + ({"reasoning_effort": effort}, "MiniMax-M3.1-Flash", "adaptive", effort) + for effort in ("low", "medium", "high", "xhigh", "max") + ] + cases += [ + ({"model": model, "thinking": thinking}, model, thinking, None) + for model, thinking in ( + ("MiniMax-M3", "adaptive"), + ("MiniMax-M3", "disabled"), + ("MiniMax-M2.7", "adaptive"), + ("MiniMax-M2.1", "adaptive"), + ("saved-custom-model", "disabled"), + ) + ] + for host in ("api.minimax.io", "api.minimaxi.com"): + for protocol in ("openai", "anthropic"): + for options, model, thinking, effort in cases: + with self.subTest(host=host, protocol=protocol, options=options): + requests = [] + + def handler(request): + requests.append(request) + response = httpx.Response(200, headers={"content-type": "text/event-stream"}, content=b"") + response.elapsed = timedelta() + return response + + base_url = f"https://{host}/" + ("anthropic/" if protocol == "anthropic" else "v1/") + provider = MiniMaxProviderConfig(api_key="test-key", base_url=base_url) + client = create_minimax_client( + provider, http_client=httpx.Client(transport=httpx.MockTransport(handler)) + ) + content = "Hello" if model in ("MiniMax-M2.7", "MiniMax-M2.1") else media + processor = SimpleNamespace( + _config=MessagesConfiguration(system_prompt="Be helpful", retain_history=True, **options), + _input=MessagesInput(messages=[{"role": "user", "message": content}]), + _chat_history=[ + {"role": "user", "message": "Earlier question"}, + {"role": "assistant", "message": "Earlier answer"}, + ], + get_provider_config=Mock(return_value=provider), + _output_stream=_OutputSink(), + ) + try: + with patch( + "llmstack.processors.providers.minimax.chat_completions.create_minimax_client", + return_value=client, + ): + MessagesProcessor.process(processor) + finally: + client.close() + self.assertEqual(len(requests), 1) + request = requests[0] + path = "/anthropic/v1/messages" if protocol == "anthropic" else "/v1/chat/completions" + self.assertEqual(str(request.url), f"https://{host}{path}") + payload = json.loads(request.content) + self.assertEqual(payload["model"], model) + self.assertEqual(payload["thinking"], {"type": thinking}) + self.assertTrue(payload["stream"]) + self.assertEqual(payload["max_tokens"], 256) + self.assertNotIn("service_tier", payload) + self.assertNotIn("reasoning_split", payload) + if effort: + if protocol == "anthropic": + self.assertEqual(payload["output_config"], {"effort": effort}) + self.assertNotIn("reasoning_effort", payload) + else: + self.assertEqual(payload["reasoning_effort"], effort) + self.assertNotIn("output_config", payload) + else: + self.assertNotIn("reasoning_effort", payload) + self.assertNotIn("output_config", payload) + if protocol == "anthropic": + self.assertEqual(payload["system"], "Be helpful") + expected = _convert_to_anthropic_format( + [ + {"role": "user", "content": "Earlier question"}, + {"role": "assistant", "content": "Earlier answer"}, + {"role": "user", "content": content}, + ] + ) + self.assertEqual(payload["messages"], expected) + else: + self.assertEqual(payload["messages"][-1]["content"], content) + self.assertEqual(payload["messages"][1]["content"], "Earlier question") + self.assertEqual(payload["messages"][2]["content"], "Earlier answer") + processor.get_provider_config.assert_called_once_with(model_slug=model) + self.assertEqual(processor._chat_history[0]["message"], "Earlier question") + self.assertEqual(processor._chat_history[-2]["message"], content) + + +if __name__ == "__main__": + unittest.main() 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"],