Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions llmstack/common/utils/sslr/resources/chat/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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(
{
Expand Down
24 changes: 24 additions & 0 deletions llmstack/processors/providers/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
)
250 changes: 250 additions & 0 deletions llmstack/processors/providers/minimax/chat_completions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
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 = "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 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. Image and video content require MiniMax-M3.",
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. 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(
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_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


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 == 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,
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:
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
23 changes: 23 additions & 0 deletions llmstack/processors/providers/minimax/client.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading