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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ OmAgent is python library for building multimodal language agents with ease. We
- Native multimodal interaction support include VLM models, real-time API, computer vision models, mobile connection and etc.
- A suite of state-of-the-art unimodal and multimodal agent algorithms that goes beyond simple LLM reasoning, e.g. ReAct, CoT, SC-Cot etc.
- Supports local deployment of models. You can deploy your own models locally by using Ollama[Ollama](./docs/concepts/models/Ollama.md) or [LocalAI](./examples/video_understanding/docs/local-ai.md).
- Supports multiple cloud LLM providers including OpenAI, Azure OpenAI, and [MiniMax](./docs/concepts/models/MiniMax.md) (M2.7/M2.5 models with up to 1M context).
- Fully distributed architecture, supports custom scaling. Also supports Lite mode, eliminating the need for middleware deployment.


Expand Down Expand Up @@ -64,6 +65,12 @@ The container.yaml file is a configuration file that manages dependencies and se
```
You can use a locally deployed Ollama to call your own language model. The tutorial is [here](docs/concepts/models/Ollama.md).

You can also use [MiniMax](docs/concepts/models/MiniMax.md) as your LLM provider:
```bash
export MINIMAX_API_KEY="your_minimax_api_key"
```
Then use `configs/llms/minimax.yml` instead of `gpt.yml`.

### Run the demo

1. Run the simple VQA demo with webpage GUI:
Expand Down
90 changes: 90 additions & 0 deletions docs/concepts/models/MiniMax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# MiniMax

[MiniMax](https://www.minimax.io/) provides large language models accessible via an OpenAI-compatible API. OmAgent supports MiniMax as a first-class LLM provider through the `MiniMaxLLM` class.

## Supported Models

| Model | Context Window | Description |
|-------|---------------|-------------|
| MiniMax-M2.7 | 1M tokens | Latest flagship model |
| MiniMax-M2.7-highspeed | 1M tokens | High-speed variant of M2.7 |
| MiniMax-M2.5 | 204K tokens | Previous generation model |
| MiniMax-M2.5-highspeed | 204K tokens | High-speed variant of M2.5 |

## Setup

1. Get your API key from [MiniMax Platform](https://platform.minimax.chat/).
2. Set the environment variable:
```bash
export MINIMAX_API_KEY="your_minimax_api_key"
```

## Configuration

### YAML Configuration

Create a YAML config file (e.g., `configs/llms/minimax.yml`):

```yaml
name: MiniMaxLLM
model_id: MiniMax-M2.7
api_key: ${env| MINIMAX_API_KEY}
endpoint: https://api.minimax.io/v1
temperature: 0
```

### Python Configuration

```python
from omagent_core.models.llms.minimax_llm import MiniMaxLLM

llm = MiniMaxLLM(
model_id="MiniMax-M2.7",
api_key="your_api_key",
temperature=0,
)
```

### Using with BaseLLMBackend

```python
from typing import List
from pydantic import Field
from omagent_core.models.llms.base import BaseLLMBackend
from omagent_core.models.llms.minimax_llm import MiniMaxLLM
from omagent_core.models.llms.prompt.prompt import PromptTemplate
from omagent_core.models.llms.prompt.parser import StrParser

class MyAgent(BaseLLMBackend):
prompts: List[PromptTemplate] = Field(
default=[
PromptTemplate.from_template("You are a helpful assistant.", role="system"),
PromptTemplate.from_template("{{instruction}}", role="user"),
]
)
llm: MiniMaxLLM = {
"name": "MiniMaxLLM",
"model_id": "MiniMax-M2.7",
"api_key": "your_api_key",
}
output_parser: StrParser = StrParser()
```

## Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `model_id` | str | `MiniMax-M2.7` | The model to use |
| `api_key` | str | `$MINIMAX_API_KEY` | Your MiniMax API key |
| `endpoint` | str | `https://api.minimax.io/v1` | API endpoint URL |
| `temperature` | float | `0.7` | Sampling temperature (0-1.0) |
| `top_p` | float | `1.0` | Top-p sampling parameter |
| `max_tokens` | int | `2048` | Maximum tokens to generate |
| `stream` | bool | `false` | Enable streaming responses |
| `response_format` | str | `text` | Response format (`text` or `json_object`) |

## Notes

- Temperature is automatically clamped to the [0, 1.0] range.
- The MiniMax API is OpenAI-compatible, so it works seamlessly with the OpenAI SDK.
- Think tags (`<think>...</think>`) in model responses are automatically stripped.
5 changes: 5 additions & 0 deletions examples/step1_simpleVQA/configs/llms/minimax.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: MiniMaxLLM
model_id: MiniMax-M2.7
api_key: ${env| MINIMAX_API_KEY}
endpoint: https://api.minimax.io/v1
temperature: 0
251 changes: 251 additions & 0 deletions omagent-core/src/omagent_core/models/llms/minimax_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import os
import re
import sysconfig
from datetime import datetime
from typing import Any, Dict, List, Optional, Union

import geocoder
from openai import AsyncOpenAI, OpenAI
from pydantic import Field

from omagent_core.models.llms.base import BaseLLM
from omagent_core.models.llms.schemas import Content, Message
from omagent_core.utils.registry import registry

BASIC_SYS_PROMPT = """You are an intelligent agent that can help in many regions.
Flowing are some basic information about your working environment, please try your best to answer the questions based on them if needed.
Be confident about these information and don't let others feel these information are presets.
Be concise.
---BASIC INFORMATION---
Current Datetime: {}
Region: {}
Operating System: {}"""

# MiniMax supported models and their context window sizes
MINIMAX_MODELS = {
"MiniMax-M2.7": 1048576, # 1M context
"MiniMax-M2.7-highspeed": 1048576, # 1M context
"MiniMax-M2.5": 204800, # 204K context
"MiniMax-M2.5-highspeed": 204800, # 204K context
}

MINIMAX_API_BASE = "https://api.minimax.io/v1"


@registry.register_llm()
class MiniMaxLLM(BaseLLM):
"""MiniMax LLM provider using OpenAI-compatible API.

MiniMax provides large language models accessible via an OpenAI-compatible
API endpoint. Supported models include MiniMax-M2.7, MiniMax-M2.7-highspeed,
MiniMax-M2.5, and MiniMax-M2.5-highspeed.

Configuration example (YAML):
name: MiniMaxLLM
model_id: MiniMax-M2.7
api_key: ${env| MINIMAX_API_KEY}
temperature: 0
"""

model_id: str = Field(
default=os.getenv("MINIMAX_MODEL_ID", "MiniMax-M2.7"),
description="The model id of MiniMax LLM",
)
api_key: str = Field(
default=os.getenv("MINIMAX_API_KEY"),
description="The api key of MiniMax",
)
endpoint: str = Field(
default=os.getenv("MINIMAX_ENDPOINT", MINIMAX_API_BASE),
description="The endpoint of MiniMax LLM service",
)
temperature: float = Field(
default=0.7,
description="The temperature of LLM, must be in [0, 1.0]",
)
top_p: float = Field(
default=1.0,
description="The top p of LLM",
)
stream: bool = Field(default=False, description="Whether to stream the response")
max_tokens: int = Field(default=2048, description="The max tokens of LLM")
use_default_sys_prompt: bool = Field(
default=True, description="Whether to use the default system prompt"
)
response_format: Optional[Union[dict, str]] = Field(
default="text", description="The response format"
)
n: int = Field(default=1, description="The number of responses to generate")
stop: Union[str, List[str], None] = Field(
default=None, description="Specifies stop sequences"
)
stream_options: Optional[dict] = Field(
default=None, description="Configuration options for streaming responses"
)
tools: Optional[List[dict]] = Field(
default=None, description="A list of function tools the model can call"
)
tool_choice: Optional[str] = Field(
default="none",
description="Controls which tool is called by the model",
)

class Config:
"""Configuration for this pydantic object."""

protected_namespaces = ()
extra = "allow"

def _clamp_temperature(self, temperature: float) -> float:
"""Clamp temperature to MiniMax's accepted range [0, 1.0]."""
return max(0.0, min(1.0, temperature))

def check_response_format(self) -> None:
if isinstance(self.response_format, str):
if self.response_format == "text":
self.response_format = {"type": "text"}
elif self.response_format == "json_object":
self.response_format = {"type": "json_object"}
elif isinstance(self.response_format, dict):
for key, value in self.response_format.items():
if key not in ["type"]:
raise ValueError(f"Invalid response format key: {key}")
if key == "type":
if value not in ["text", "json_object"]:
raise ValueError(f"Invalid response format value: {value}")
else:
raise ValueError(f"Invalid response format: {self.response_format}")
Comment on lines +103 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

response_format dict validation allows invalid structures.

The validation logic at lines 110-115 iterates over dict keys but only checks if keys are not "type". This means:

  1. An empty dict {} passes validation (no "type" key required)
  2. A dict with only invalid keys like {"foo": "bar"} raises on foo but not on missing type

Consider validating that "type" key exists and has a valid value:

🛠️ Proposed fix
     def check_response_format(self) -> None:
         if isinstance(self.response_format, str):
             if self.response_format == "text":
                 self.response_format = {"type": "text"}
             elif self.response_format == "json_object":
                 self.response_format = {"type": "json_object"}
+            else:
+                raise ValueError(f"Invalid response format: {self.response_format}")
         elif isinstance(self.response_format, dict):
-            for key, value in self.response_format.items():
-                if key not in ["type"]:
-                    raise ValueError(f"Invalid response format key: {key}")
-                if key == "type":
-                    if value not in ["text", "json_object"]:
-                        raise ValueError(f"Invalid response format value: {value}")
+            if "type" not in self.response_format:
+                raise ValueError("response_format dict must contain 'type' key")
+            if self.response_format["type"] not in ["text", "json_object"]:
+                raise ValueError(f"Invalid response format type: {self.response_format['type']}")
+            if len(self.response_format) > 1:
+                extra_keys = set(self.response_format.keys()) - {"type"}
+                raise ValueError(f"Invalid response format keys: {extra_keys}")
         else:
             raise ValueError(f"Invalid response format: {self.response_format}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@omagent-core/src/omagent_core/models/llms/minimax_llm.py` around lines 103 -
117, The dict validation in check_response_format currently allows empty dicts
and doesn't require the "type" key; update check_response_format so that when
self.response_format is a dict you first assert that "type" is present (raise
ValueError if missing or not a str), then validate that
self.response_format["type"] is one of the allowed values ("text",
"json_object"); keep rejecting any other unexpected keys if desired, but the
critical fix is to require and validate the "type" key before accepting the
dict.


def model_post_init(self, __context: Any) -> None:
self.temperature = self._clamp_temperature(self.temperature)
self.check_response_format()
self.client = OpenAI(api_key=self.api_key, base_url=self.endpoint)
self.aclient = AsyncOpenAI(api_key=self.api_key, base_url=self.endpoint)

def _strip_think_tags(self, content: str) -> str:
"""Strip <think>...</think> tags from model response.

If stripping would result in empty content, returns the original
content with only the tags removed (preserving inner text).
"""
if content is None:
return content
stripped = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
if stripped:
return stripped
# If all content was inside think tags, remove tags but keep inner text
fallback = re.sub(r"</?think>", "", content).strip()
return fallback if fallback else content

def _call(self, records: List[Message], **kwargs) -> Dict:
if self.api_key is None or self.api_key == "":
raise ValueError("api_key is required")

messages = self._msg2req(records)
temperature = self._clamp_temperature(
kwargs.get("temperature", self.temperature)
)

res = self.client.chat.completions.create(
model=self.model_id,
messages=messages,
temperature=temperature,
max_tokens=kwargs.get("max_tokens", self.max_tokens),
response_format=kwargs.get("response_format", self.response_format),
tools=kwargs.get("tools", None),
tool_choice=kwargs.get("tool_choice", None),
stream=kwargs.get("stream", self.stream),
n=kwargs.get("n", self.n),
top_p=kwargs.get("top_p", self.top_p),
stop=kwargs.get("stop", self.stop),
stream_options=kwargs.get("stream_options", self.stream_options),
)
Comment on lines +149 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

tools and tool_choice instance defaults not passed through.

Lines 155-156 use kwargs.get("tools", None) and kwargs.get("tool_choice", None) instead of falling back to self.tools and self.tool_choice. This means the instance-level defaults (lines 85-91) are never used.

🐛 Proposed fix
-            tools=kwargs.get("tools", None),
-            tool_choice=kwargs.get("tool_choice", None),
+            tools=kwargs.get("tools", self.tools),
+            tool_choice=kwargs.get("tool_choice", self.tool_choice),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
res = self.client.chat.completions.create(
model=self.model_id,
messages=messages,
temperature=temperature,
max_tokens=kwargs.get("max_tokens", self.max_tokens),
response_format=kwargs.get("response_format", self.response_format),
tools=kwargs.get("tools", None),
tool_choice=kwargs.get("tool_choice", None),
stream=kwargs.get("stream", self.stream),
n=kwargs.get("n", self.n),
top_p=kwargs.get("top_p", self.top_p),
stop=kwargs.get("stop", self.stop),
stream_options=kwargs.get("stream_options", self.stream_options),
)
res = self.client.chat.completions.create(
model=self.model_id,
messages=messages,
temperature=temperature,
max_tokens=kwargs.get("max_tokens", self.max_tokens),
response_format=kwargs.get("response_format", self.response_format),
tools=kwargs.get("tools", self.tools),
tool_choice=kwargs.get("tool_choice", self.tool_choice),
stream=kwargs.get("stream", self.stream),
n=kwargs.get("n", self.n),
top_p=kwargs.get("top_p", self.top_p),
stop=kwargs.get("stop", self.stop),
stream_options=kwargs.get("stream_options", self.stream_options),
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@omagent-core/src/omagent_core/models/llms/minimax_llm.py` around lines 149 -
162, The instance defaults for tools and tool_choice are not used because the
call in the method uses kwargs.get("tools", None) and kwargs.get("tool_choice",
None); update the call to fall back to the instance attributes (self.tools and
self.tool_choice) so the instance-level defaults declared on the class are
respected (i.e., replace the kwargs.get default None for "tools" and
"tool_choice" with self.tools and self.tool_choice when building the arguments
passed to self.client.chat.completions.create).


if kwargs.get("stream", self.stream):
return res
else:
result = res.model_dump()
# Strip think tags from response content
for choice in result.get("choices", []):
msg = choice.get("message", {})
if msg.get("content"):
msg["content"] = self._strip_think_tags(msg["content"])
return result
Comment on lines +140 to +173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check return types of _call in other LLM implementations
rg -n "def _call" --type=py -A 10 omagent-core/src/omagent_core/models/llms/ | head -100

Repository: om-ai-lab/OmAgent

Length of output: 6553


🏁 Script executed:

# Check the actual generate() implementation in base.py
sed -n '44,65p' omagent-core/src/omagent_core/models/llms/base.py

Repository: om-ai-lab/OmAgent

Length of output: 943


🏁 Script executed:

# Also check the full _call signature and any return value transformations
sed -n '40,80p' omagent-core/src/omagent_core/models/llms/base.py

Repository: om-ai-lab/OmAgent

Length of output: 1601


🏁 Script executed:

# Search for calls to generate() to see how the return value is used
rg "\.generate\(" --type=py -B 2 -A 2 omagent-core/src/ | head -60

Repository: om-ai-lab/OmAgent

Length of output: 1871


🏁 Script executed:

# Check if there are tests for minimax that show expected behavior
fd "test.*minimax" --type=py omagent-core/ 2>/dev/null || echo "No test files found with minimax in name"

Repository: om-ai-lab/OmAgent

Length of output: 101


🏁 Script executed:

# Check test files for LLMs to understand expected return type
fd "test.*llm" --type=py omagent-core/ 2>/dev/null | head -5

Repository: om-ai-lab/OmAgent

Length of output: 43


🏁 Script executed:

# Check tool_system manager.py to see how fix_res is used
sed -n '1,400p' omagent-core/src/omagent_core/tool_system/manager.py | grep -A 10 "fix_res = self.llm.generate"

Repository: om-ai-lab/OmAgent

Length of output: 433


🏁 Script executed:

# Verify minimax implementation matches pattern of other providers
# Check what minimax _call actually returns (full return statement)
sed -n '140,173p' omagent-core/src/omagent_core/models/llms/minimax_llm.py

Repository: om-ai-lab/OmAgent

Length of output: 1485


Type annotation contract violation: _call declares return type str but actually returns Dict.

The base class BaseLLM._call() declares return type str, and BaseLLM.generate() directly returns this result. However, this implementation (and all other LLM providers) actually return Dict. Real callers like tool_system/manager.py and the base class usage in base.py treat the result as a dictionary (accessing nested keys like ["choices"][0]), confirming the actual contract is Dict, not str.

This type annotation mismatch affects all LLM implementations systematically. Update the base class signatures in base.py (lines 44 and 47) to declare -> Dict instead of -> str.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@omagent-core/src/omagent_core/models/llms/minimax_llm.py` around lines 140 -
173, The BaseLLM type annotations are wrong: update the return types of
BaseLLM._call and BaseLLM.generate from -> str to -> Dict to match actual
implementations (e.g., minimax_llm.MinimaxLLM._call) and callers that expect
dictionaries; also add/ensure from typing import Dict is present and adjust any
docstrings or type comments referencing str; run static checks to confirm all
LLM subclasses conform to the new Dict return type.


async def _acall(self, records: List[Message], **kwargs) -> Dict:
if self.api_key is None or self.api_key == "":
raise ValueError("api_key is required")

messages = self._msg2req(records)
temperature = self._clamp_temperature(
kwargs.get("temperature", self.temperature)
)

res = await self.aclient.chat.completions.create(
model=self.model_id,
messages=messages,
temperature=temperature,
max_tokens=kwargs.get("max_tokens", self.max_tokens),
response_format=kwargs.get("response_format", self.response_format),
tools=kwargs.get("tools", None),
n=kwargs.get("n", self.n),
top_p=kwargs.get("top_p", self.top_p),
stop=kwargs.get("stop", self.stop),
stream_options=kwargs.get("stream_options", self.stream_options),
)

result = res.model_dump()
# Strip think tags from response content
for choice in result.get("choices", []):
msg = choice.get("message", {})
if msg.get("content"):
msg["content"] = self._strip_think_tags(msg["content"])
return result
Comment on lines +175 to +203

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

_acall missing streaming support unlike _call.

_call (lines 164-165) checks kwargs.get("stream", self.stream) and returns the raw stream object when streaming is enabled. However, _acall doesn't have this check and always calls res.model_dump(), which will fail or behave unexpectedly when streaming is requested asynchronously.

🐛 Proposed fix to add streaming support to _acall
     async def _acall(self, records: List[Message], **kwargs) -> Dict:
         if self.api_key is None or self.api_key == "":
             raise ValueError("api_key is required")
 
         messages = self._msg2req(records)
         temperature = self._clamp_temperature(
             kwargs.get("temperature", self.temperature)
         )
 
         res = await self.aclient.chat.completions.create(
             model=self.model_id,
             messages=messages,
             temperature=temperature,
             max_tokens=kwargs.get("max_tokens", self.max_tokens),
             response_format=kwargs.get("response_format", self.response_format),
             tools=kwargs.get("tools", None),
             n=kwargs.get("n", self.n),
             top_p=kwargs.get("top_p", self.top_p),
             stop=kwargs.get("stop", self.stop),
             stream_options=kwargs.get("stream_options", self.stream_options),
+            stream=kwargs.get("stream", self.stream),
         )
 
+        if kwargs.get("stream", self.stream):
+            return res
+
         result = res.model_dump()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@omagent-core/src/omagent_core/models/llms/minimax_llm.py` around lines 175 -
203, The async method _acall currently always calls res.model_dump() and strips
tags, which breaks when streaming is requested; modify _acall to respect
kwargs.get("stream", self.stream) (same check as _call) and if streaming is
enabled return the raw streaming response from
self.aclient.chat.completions.create (do not call res.model_dump() or
post-process), otherwise proceed with model_dump(), strip think tags via
_strip_think_tags, and return the parsed result; locate references to _acall,
_call, self.aclient.chat.completions.create, and res.model_dump to implement
this conditional flow.


def _msg2req(self, records: List[Message]) -> list:
def get_content(msg: List[Content] | Content) -> List[dict] | str:
if isinstance(msg, list):
return [c.model_dump(exclude_none=True) for c in msg]
elif isinstance(msg, Content) and msg.type == "text":
return msg.text
elif isinstance(msg, Content) and msg.type == "image_url":
return [msg.model_dump(exclude_none=True)]
else:
raise ValueError("Invalid message type")

messages = [
{"role": message.role, "content": get_content(message.content)}
for message in records
]
if self.use_default_sys_prompt:
messages = [self._generate_default_sys_prompt()] + messages
return messages

def _generate_default_sys_prompt(self) -> Dict:
loc = self._get_location()
os_info = self._get_linux_distribution()
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
prompt_str = BASIC_SYS_PROMPT.format(current_time, loc, os_info)
return {"role": "system", "content": prompt_str}

def _get_linux_distribution(self) -> str:
platform = sysconfig.get_platform()
if "linux" in platform:
if os.path.exists("/etc/lsb-release"):
with open("/etc/lsb-release", "r") as f:
for line in f:
if line.startswith("DISTRIB_DESCRIPTION="):
return line.split("=")[1].strip()
elif os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
for line in f:
if line.startswith("PRETTY_NAME="):
return line.split("=")[1].strip()
return platform

def _get_location(self) -> str:
g = geocoder.ip("me")
if g.ok:
return g.city + "," + g.country
else:
return "unknown"
Loading