From 8b5663a67e3c8b07645e2122c461757079664bce Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:35 +0800 Subject: [PATCH 1/2] feat(providers): add MiniMax model support --- config.toml | 15 ++ docs/setup/vendors.md | 47 ++++- src/rai_core/pyproject.toml | 3 +- .../initialization/config_initialization.py | 15 ++ .../initialization/model_initialization.py | 93 +++++++++ .../test_minimax_initialization.py | 183 ++++++++++++++++++ uv.lock | 65 ++++++- 7 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 tests/initialization/test_minimax_initialization.py diff --git a/config.toml b/config.toml index 89e94ed52..a407989fd 100644 --- a/config.toml +++ b/config.toml @@ -26,6 +26,21 @@ simple_model = "gemini-3-flash" complex_model = "gemini-3-pro" embeddings_model = "text-embedding-004" +[minimax] +simple_model = "MiniMax-M2.7" +complex_model = "MiniMax-M3" +embeddings_model = "" +protocol = "openai" +region = "global_en" + +[minimax.endpoints.global_en] +openai_base_url = "https://api.minimax.io/v1" +anthropic_base_url = "https://api.minimax.io/anthropic" + +[minimax.endpoints.cn_zh] +openai_base_url = "https://api.minimaxi.com/v1" +anthropic_base_url = "https://api.minimaxi.com/anthropic" + [tracing] project = "rai" diff --git a/docs/setup/vendors.md b/docs/setup/vendors.md index 855f0add7..e17167829 100644 --- a/docs/setup/vendors.md +++ b/docs/setup/vendors.md @@ -9,9 +9,9 @@ Alternatively vendors can be configured manually in `config.toml` file. The table summarizes vendor alternative for core AI service and optional RAI modules: -| Module | Open source | Alternative | Why to consider alternative? | More information | -| ----------------------------------------------- | ------------------ | ----------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [LLM service](#llm-model-configuration-in-rai) | Ollama | OpenAI, Bedrock | Overall performance of the LLM models, supported modalities and features | [LangChain models](https://docs.langchain4j.dev/integrations/language-models/) | +| Module | Open source | Alternative | Why to consider alternative? | More information | +| ----------------------------------------------- | ------------------ | -------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [LLM service](#llm-model-configuration-in-rai) | Ollama | OpenAI, Bedrock, MiniMax | Overall performance of the LLM models, supported modalities and features | [LangChain models](https://docs.langchain4j.dev/integrations/language-models/) | | **Optional:** [Tracing tool](./tracing.md) | Langfuse | LangSmith | Better integration with LangChain | [Comparison](https://langfuse.com/faq/all/langsmith-alternative) | | **Optional:** [Text to speech](#text-to-speech) | KokoroTTS, OpenTTS | ElevenLabs | Arguably, significantly better voice synthesis |
  • [KokoroTTS](https://huggingface.co/hexgrad/Kokoro-82M#usage)
  • [OpenTTS GitHub](https://github.com/synesthesiam/opentts)
  • [RAI voice interface][s2s]
  • | | **Optional:** [Speech to text](#speech-to-text) | Whisper | OpenAI Whisper (hosted) | When suitable local GPU is not an option |
  • [Whisper GitHub](https://github.com/openai/whisper)
  • [RAI voice interface][s2s]
  • | @@ -67,6 +67,47 @@ Ollama can be used to host models locally. 2. Use [RAI Configurator][configurator] -> `Model Selection` -> `bedrock` vendor +### MiniMax + +MiniMax can be configured through either its OpenAI-compatible or Anthropic-compatible +chat API. Set the API key before starting RAI: + +```bash +export MINIMAX_API_KEY="your-api-key" +``` + +The generated `config.toml` contains both supported regions and both protocols. Select the +active combination with `protocol` and `region`: + +```toml +[vendor] +simple_model = "minimax" +complex_model = "minimax" + +[minimax] +simple_model = "MiniMax-M2.7" +complex_model = "MiniMax-M3" +embeddings_model = "" +protocol = "openai" +region = "global_en" + +[minimax.endpoints.global_en] +openai_base_url = "https://api.minimax.io/v1" +anthropic_base_url = "https://api.minimax.io/anthropic" + +[minimax.endpoints.cn_zh] +openai_base_url = "https://api.minimaxi.com/v1" +anthropic_base_url = "https://api.minimaxi.com/anthropic" +``` + +The Anthropic-compatible base URL must end in `/anthropic`; the client appends the +`/v1/messages` request path. MiniMax does not provide an embeddings model in this +configuration, so keep `embeddings_model` assigned to a separate supported vendor. + +See the [global API documentation](https://platform.minimax.io/docs/api-reference/api-overview) +or the [China API documentation](https://platform.minimaxi.com/docs/api-reference/api-overview) +for service-specific details. + ## Complex LLM Model Configuration For custom setups please use LangChain API. diff --git a/src/rai_core/pyproject.toml b/src/rai_core/pyproject.toml index d7c3e5ae8..51c78bdbc 100644 --- a/src/rai_core/pyproject.toml +++ b/src/rai_core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rai_core" -version = "2.12.1" +version = "2.13.0" description = "Core functionality for RAI framework" readme = "README.md" requires-python = ">=3.10,<3.13" @@ -21,6 +21,7 @@ dependencies = [ "langchain>=1.0.0,<2.0.0", "langchain-aws", "langchain-openai", + "langchain-anthropic", "langchain-ollama", "langchain-google-genai", "langchain-community", diff --git a/src/rai_core/rai/initialization/config_initialization.py b/src/rai_core/rai/initialization/config_initialization.py index 94028b252..5b16ff690 100644 --- a/src/rai_core/rai/initialization/config_initialization.py +++ b/src/rai_core/rai/initialization/config_initialization.py @@ -47,6 +47,21 @@ complex_model = "gemini-3-pro" embeddings_model = "text-embedding-004" +[minimax] +simple_model = "MiniMax-M2.7" +complex_model = "MiniMax-M3" +embeddings_model = "" +protocol = "openai" +region = "global_en" + +[minimax.endpoints.global_en] +openai_base_url = "https://api.minimax.io/v1" +anthropic_base_url = "https://api.minimax.io/anthropic" + +[minimax.endpoints.cn_zh] +openai_base_url = "https://api.minimaxi.com/v1" +anthropic_base_url = "https://api.minimaxi.com/anthropic" + [tracing] project = "rai" diff --git a/src/rai_core/rai/initialization/model_initialization.py b/src/rai_core/rai/initialization/model_initialization.py index 6cd5d7e42..22fc8d6bb 100644 --- a/src/rai_core/rai/initialization/model_initialization.py +++ b/src/rai_core/rai/initialization/model_initialization.py @@ -67,6 +67,19 @@ class GoogleConfig(ModelConfig): pass +@dataclass +class MiniMaxEndpointConfig: + openai_base_url: str + anthropic_base_url: str + + +@dataclass +class MiniMaxConfig(ModelConfig): + protocol: Literal["openai", "anthropic"] + region: str + endpoints: Dict[str, MiniMaxEndpointConfig] + + @dataclass class LangfuseConfig: use_langfuse: bool @@ -93,6 +106,7 @@ class RAIConfig: openai: OpenAIConfig ollama: OllamaConfig google: GoogleConfig + minimax: MiniMaxConfig tracing: TracingConfig @@ -107,6 +121,14 @@ class RAIConfig: simple_model="", complex_model="", embeddings_model="", base_url="" ) _DEFAULT_GOOGLE = GoogleConfig(simple_model="", complex_model="", embeddings_model="") +_DEFAULT_MINIMAX = MiniMaxConfig( + simple_model="", + complex_model="", + embeddings_model="", + protocol="openai", + region="global_en", + endpoints={}, +) _DEFAULT_TRACING = TracingConfig( project="", langfuse=LangfuseConfig(use_langfuse=False, host=""), @@ -140,6 +162,21 @@ def load_config(config_path: Optional[str] = None) -> RAIConfig: if "google" in config_dict else _DEFAULT_GOOGLE ) + if "minimax" in config_dict: + minimax_dict = config_dict["minimax"] + minimax = MiniMaxConfig( + simple_model=minimax_dict["simple_model"], + complex_model=minimax_dict["complex_model"], + embeddings_model=minimax_dict.get("embeddings_model", ""), + protocol=minimax_dict.get("protocol", "openai"), + region=minimax_dict.get("region", "global_en"), + endpoints={ + region: MiniMaxEndpointConfig(**endpoint) + for region, endpoint in minimax_dict.get("endpoints", {}).items() + }, + ) + else: + minimax = _DEFAULT_MINIMAX if "tracing" in config_dict: tracing = TracingConfig( @@ -156,10 +193,48 @@ def load_config(config_path: Optional[str] = None) -> RAIConfig: openai=openai, ollama=ollama, google=google, + minimax=minimax, tracing=tracing, ) +def _create_minimax_chat_model( + model: str, + model_config: MiniMaxConfig, + kwargs: Dict[str, Any], +) -> Any: + try: + endpoint = model_config.endpoints[model_config.region] + except KeyError as exc: + raise ValueError( + f"MiniMax endpoint is not configured for region: {model_config.region}" + ) from exc + + model_kwargs = dict(kwargs) + if "api_key" not in model_kwargs: + api_key = os.getenv("MINIMAX_API_KEY") + if api_key: + model_kwargs["api_key"] = api_key + + if model_config.protocol == "openai": + from langchain_openai import ChatOpenAI + + return ChatOpenAI( + model=model, + base_url=endpoint.openai_base_url, + **model_kwargs, + ) + if model_config.protocol == "anthropic": + from langchain_anthropic import ChatAnthropic + + return ChatAnthropic( + model=model, + base_url=endpoint.anthropic_base_url, + **model_kwargs, + ) + raise ValueError(f"Unknown MiniMax protocol: {model_config.protocol}") + + def get_llm_model_config_and_vendor( model_type: Literal["simple_model", "complex_model"], vendor: Optional[str] = None, @@ -213,6 +288,12 @@ def get_llm_model( model_config = cast(GoogleConfig, model_config) return ChatGoogleGenerativeAI(model=model, **kwargs) + elif vendor == "minimax": + return _create_minimax_chat_model( + model, + cast(MiniMaxConfig, model_config), + kwargs, + ) else: raise ValueError(f"Unknown LLM vendor: {vendor}") @@ -255,6 +336,12 @@ def get_llm_model_direct( model_config = cast(GoogleConfig, model_config) return ChatGoogleGenerativeAI(model=model_name, **kwargs) + elif vendor == "minimax": + return _create_minimax_chat_model( + model_name, + cast(MiniMaxConfig, model_config), + kwargs, + ) else: raise ValueError(f"Unknown LLM vendor: {vendor}") @@ -268,6 +355,12 @@ def get_embeddings_model( model_config = getattr(config, vendor) + if vendor == "minimax": + raise ValueError( + "MiniMax does not provide an embeddings model. " + "Configure embeddings with a separate supported vendor." + ) + logger.info(f"Using embeddings model: {vendor}-{model_config.embeddings_model}") if vendor == "openai": from langchain_openai import OpenAIEmbeddings diff --git a/tests/initialization/test_minimax_initialization.py b/tests/initialization/test_minimax_initialization.py new file mode 100644 index 000000000..a6e779c20 --- /dev/null +++ b/tests/initialization/test_minimax_initialization.py @@ -0,0 +1,183 @@ +# Copyright (C) 2026 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest +from rai.initialization import model_initialization + +MINIMAX_CONFIG_TEMPLATE = """ +[vendor] +simple_model = "minimax" +complex_model = "minimax" +embeddings_model = "openai" + +[openai] +simple_model = "gpt-4o-mini" +complex_model = "gpt-4o" +embeddings_model = "text-embedding-3-small" +base_url = "https://api.openai.com/v1" + +[minimax] +simple_model = "MiniMax-M2.7" +complex_model = "MiniMax-M3" +embeddings_model = "" +protocol = "openai" +region = "global_en" + +[minimax.endpoints.global_en] +openai_base_url = "https://api.minimax.io/v1" +anthropic_base_url = "https://api.minimax.io/anthropic" + +[minimax.endpoints.cn_zh] +openai_base_url = "https://api.minimaxi.com/v1" +anthropic_base_url = "https://api.minimaxi.com/anthropic" +""" + + +class DummyModel: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + +def write_config(path: Path, config: str = MINIMAX_CONFIG_TEMPLATE) -> Path: + path.write_text(config, encoding="utf-8") + return path + + +def test_load_config_preserves_models_and_endpoints(tmp_path): + config = model_initialization.load_config( + str(write_config(tmp_path / "config.toml")) + ) + + assert config.minimax.simple_model == "MiniMax-M2.7" + assert config.minimax.complex_model == "MiniMax-M3" + assert config.minimax.endpoints["global_en"].openai_base_url.endswith("/v1") + assert config.minimax.endpoints["global_en"].anthropic_base_url.endswith( + "/anthropic" + ) + assert config.minimax.endpoints["cn_zh"].openai_base_url.endswith("/v1") + assert config.minimax.endpoints["cn_zh"].anthropic_base_url.endswith("/anthropic") + + +def test_get_llm_model_uses_openai_protocol_and_selected_region(monkeypatch, tmp_path): + config_path = write_config(tmp_path / "config.toml") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + monkeypatch.setattr("langchain_openai.ChatOpenAI", DummyModel) + + model = model_initialization.get_llm_model( + "complex_model", vendor="minimax", config_path=str(config_path) + ) + + assert isinstance(model, DummyModel) + assert model.kwargs["model"] == "MiniMax-M3" + assert model.kwargs["base_url"] == "https://api.minimax.io/v1" + assert model.kwargs["api_key"] == "test-key" + + +def test_get_llm_model_uses_anthropic_protocol(monkeypatch, tmp_path): + config = MINIMAX_CONFIG_TEMPLATE.replace( + 'protocol = "openai"', 'protocol = "anthropic"' + ) + config_path = write_config(tmp_path / "config.toml", config) + monkeypatch.setattr("langchain_anthropic.ChatAnthropic", DummyModel) + + model = model_initialization.get_llm_model( + "simple_model", vendor="minimax", config_path=str(config_path) + ) + + assert isinstance(model, DummyModel) + assert model.kwargs["model"] == "MiniMax-M2.7" + assert model.kwargs["base_url"] == "https://api.minimax.io/anthropic" + + +def test_get_llm_model_direct_preserves_explicit_api_key(monkeypatch, tmp_path): + config_path = write_config(tmp_path / "config.toml") + monkeypatch.setenv("MINIMAX_API_KEY", "environment-key") + monkeypatch.setattr("langchain_openai.ChatOpenAI", DummyModel) + + model = model_initialization.get_llm_model_direct( + "MiniMax-M3", + vendor="minimax", + config_path=str(config_path), + api_key="explicit-key", + ) + + assert model.kwargs["api_key"] == "explicit-key" + + +def test_get_embeddings_model_rejects_minimax(tmp_path): + config = MINIMAX_CONFIG_TEMPLATE.replace( + 'embeddings_model = "openai"', 'embeddings_model = "minimax"' + ) + config_path = write_config(tmp_path / "config.toml", config) + + with pytest.raises(ValueError, match="does not provide an embeddings model"): + model_initialization.get_embeddings_model(config_path=str(config_path)) + + +class CaptureHandler(BaseHTTPRequestHandler): + request_path = "" + + def do_POST(self): # noqa: N802 + self.__class__.request_path = self.path + content_length = int(self.headers.get("content-length", "0")) + self.rfile.read(content_length) + response = { + "id": "message-test", + "type": "message", + "role": "assistant", + "model": "MiniMax-M3", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + payload = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): # noqa: A002 + return + + +def test_anthropic_client_appends_messages_path(tmp_path, monkeypatch): + server = ThreadingHTTPServer(("127.0.0.1", 0), CaptureHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + base_url = f"http://127.0.0.1:{server.server_port}/anthropic" + config = MINIMAX_CONFIG_TEMPLATE.replace( + 'protocol = "openai"', 'protocol = "anthropic"' + ).replace("https://api.minimax.io/anthropic", base_url) + config_path = write_config(tmp_path / "config.toml", config) + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + + model = model_initialization.get_llm_model( + "complex_model", vendor="minimax", config_path=str(config_path) + ) + response = model.invoke("Say hello") + + assert response.content == "ok" + assert CaptureHandler.request_path == "/anthropic/v1/messages" + finally: + server.shutdown() + thread.join(timeout=5) diff --git a/uv.lock b/uv.lock index a430dfcd9..5218b1228 100644 --- a/uv.lock +++ b/uv.lock @@ -186,6 +186,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.116.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/dd/2a1e81cf1b163acc340afc4ec74ed1d86f5eed1a809fabdeed3e0997b346/anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256", size = 956896, upload-time = "2026-07-02T19:08:08.756Z" }, +] + [[package]] name = "antlr4-python3-runtime" version = "4.9.3" @@ -1088,6 +1107,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/90/022c79d6e5e6f843268c10b84d4a021ee3afba0621d3c176d3ff2024bfc8/dlinfo-2.0.0-py3-none-any.whl", hash = "sha256:b32cc18e3ea67c0ca9ca409e5b41eed863bd1363dbc9dd3de90fedf11b61e7bc", size = 3654, upload-time = "2025-01-16T15:43:09.474Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "donfig" version = "0.8.1.post1" @@ -2307,6 +2335,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/e8/a3b8cb0005553f6a876865073c81ef93bd7c5b18381bcb9ba4013af96ebc/langchain-1.2.15-py3-none-any.whl", hash = "sha256:e349db349cb3e9550c4044077cf90a1717691756cc236438404b23500e615874", size = 112714, upload-time = "2026-04-03T14:26:02.557Z" }, ] +[[package]] +name = "langchain-anthropic" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anthropic" }, + { name = "langchain-core" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/22/40ab129b08329ca295b391aa1d48267692b42594757084c6918e22b655ac/langchain_anthropic-1.4.8.tar.gz", hash = "sha256:c76891b2044d56105ff13c106ed12650637b53bd598a4bdf15b4796eefa2a4ec", size = 708524, upload-time = "2026-06-26T21:28:46.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/14/746235c4da89d9bc6a608c5f489f628e03feb8f697195c146e452c8f23c8/langchain_anthropic-1.4.8-py3-none-any.whl", hash = "sha256:778e9301b6fd517824f76ec1776975ce8add97a1f6a36c50ae3c2f4b03a66f7f", size = 52366, upload-time = "2026-06-26T21:28:45.535Z" }, +] + [[package]] name = "langchain-aws" version = "1.4.3" @@ -2347,10 +2389,11 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.28" +version = "1.4.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, + { name = "langchain-protocol" }, { name = "langsmith" }, { name = "packaging" }, { name = "pydantic" }, @@ -2359,9 +2402,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/b9/e937d0a90b26540bff07e7a7c64349f3b29c2dcc36257cd1cd3fdce17f2a/langchain_core-1.4.9.tar.gz", hash = "sha256:f8078901145bed0466755277500a5a22822a7b628808c4c0a28d4fc88895fcf2", size = 967294, upload-time = "2026-07-08T20:06:54.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/84/70/ade2fada52772798ef815b6352b59e71b116aa0c32c3aef5be3dc2cbed12/langchain_core-1.4.9-py3-none-any.whl", hash = "sha256:28e3909e2a10cc81504952d795ac0a9e014c0018121ef89d48dd396fa09ec624", size = 558293, upload-time = "2026-07-08T20:06:52.382Z" }, ] [[package]] @@ -2406,6 +2449,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/a6/68fb22e3604015e6f546fa1d3677d24378b482855ae74710cbf4aec44132/langchain_openai-1.1.12-py3-none-any.whl", hash = "sha256:da71ca3f2d18c16f7a2443cc306aa195ad2a07054335ac9b0626dcae02b6a0c5", size = 88487, upload-time = "2026-03-23T18:59:17.978Z" }, ] +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + [[package]] name = "langfuse" version = "3.14.6" @@ -4714,12 +4769,13 @@ requires-dist = [ [[package]] name = "rai-core" -version = "2.12.1" +version = "2.13.0" source = { editable = "src/rai_core" } dependencies = [ { name = "coloredlogs" }, { name = "deprecated" }, { name = "langchain" }, + { name = "langchain-anthropic" }, { name = "langchain-aws" }, { name = "langchain-community" }, { name = "langchain-core" }, @@ -4748,6 +4804,7 @@ requires-dist = [ { name = "coloredlogs", specifier = ">=15.0.1,<16.0.0" }, { name = "deprecated", specifier = ">=1.2.14,<2.0.0" }, { name = "langchain", specifier = ">=1.0.0,<2.0.0" }, + { name = "langchain-anthropic" }, { name = "langchain-aws" }, { name = "langchain-community" }, { name = "langchain-core", specifier = ">=1.2.10,<2.0.0" }, From 3b9ca228e5c9342e862d245ba682d85edc58d925 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:37:44 +0800 Subject: [PATCH 2/2] feat(minimax): default to Flash and verify serialized requests --- config.toml | 2 +- docs/setup/vendors.md | 9 +- .../initialization/config_initialization.py | 2 +- .../test_minimax_initialization.py | 254 ++++++++++++++---- 4 files changed, 211 insertions(+), 56 deletions(-) diff --git a/config.toml b/config.toml index a407989fd..d2a18e20d 100644 --- a/config.toml +++ b/config.toml @@ -27,7 +27,7 @@ complex_model = "gemini-3-pro" embeddings_model = "text-embedding-004" [minimax] -simple_model = "MiniMax-M2.7" +simple_model = "MiniMax-M3.1-Flash" complex_model = "MiniMax-M3" embeddings_model = "" protocol = "openai" diff --git a/docs/setup/vendors.md b/docs/setup/vendors.md index e17167829..aae460877 100644 --- a/docs/setup/vendors.md +++ b/docs/setup/vendors.md @@ -76,7 +76,12 @@ chat API. Set the API key before starting RAI: export MINIMAX_API_KEY="your-api-key" ``` -The generated `config.toml` contains both supported regions and both protocols. Select the +New configurations use `MiniMax-M3.1-Flash` for simple tasks and `MiniMax-M3` for +complex tasks. Existing saved model IDs and explicit `get_llm_model_direct` model +names are passed through unchanged. Configure MiniMax by editing `config.toml`; +the optional configurator does not currently list this vendor. + +The generated `config.toml` contains both regions and both protocols. Select the active combination with `protocol` and `region`: ```toml @@ -85,7 +90,7 @@ simple_model = "minimax" complex_model = "minimax" [minimax] -simple_model = "MiniMax-M2.7" +simple_model = "MiniMax-M3.1-Flash" complex_model = "MiniMax-M3" embeddings_model = "" protocol = "openai" diff --git a/src/rai_core/rai/initialization/config_initialization.py b/src/rai_core/rai/initialization/config_initialization.py index 5b16ff690..7946139e6 100644 --- a/src/rai_core/rai/initialization/config_initialization.py +++ b/src/rai_core/rai/initialization/config_initialization.py @@ -48,7 +48,7 @@ embeddings_model = "text-embedding-004" [minimax] -simple_model = "MiniMax-M2.7" +simple_model = "MiniMax-M3.1-Flash" complex_model = "MiniMax-M3" embeddings_model = "" protocol = "openai" diff --git a/tests/initialization/test_minimax_initialization.py b/tests/initialization/test_minimax_initialization.py index a6e779c20..fe7aa41a8 100644 --- a/tests/initialization/test_minimax_initialization.py +++ b/tests/initialization/test_minimax_initialization.py @@ -13,12 +13,14 @@ # limitations under the License. import json -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +import httpx import pytest -from rai.initialization import model_initialization +import tomli +import tomli_w +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from rai.initialization import config_initialization, model_initialization MINIMAX_CONFIG_TEMPLATE = """ [vendor] @@ -33,7 +35,7 @@ base_url = "https://api.openai.com/v1" [minimax] -simple_model = "MiniMax-M2.7" +simple_model = "MiniMax-M3.1-Flash" complex_model = "MiniMax-M3" embeddings_model = "" protocol = "openai" @@ -65,7 +67,7 @@ def test_load_config_preserves_models_and_endpoints(tmp_path): str(write_config(tmp_path / "config.toml")) ) - assert config.minimax.simple_model == "MiniMax-M2.7" + assert config.minimax.simple_model == "MiniMax-M3.1-Flash" assert config.minimax.complex_model == "MiniMax-M3" assert config.minimax.endpoints["global_en"].openai_base_url.endswith("/v1") assert config.minimax.endpoints["global_en"].anthropic_base_url.endswith( @@ -102,7 +104,7 @@ def test_get_llm_model_uses_anthropic_protocol(monkeypatch, tmp_path): ) assert isinstance(model, DummyModel) - assert model.kwargs["model"] == "MiniMax-M2.7" + assert model.kwargs["model"] == "MiniMax-M3.1-Flash" assert model.kwargs["base_url"] == "https://api.minimax.io/anthropic" @@ -131,53 +133,201 @@ def test_get_embeddings_model_rejects_minimax(tmp_path): model_initialization.get_embeddings_model(config_path=str(config_path)) -class CaptureHandler(BaseHTTPRequestHandler): - request_path = "" - - def do_POST(self): # noqa: N802 - self.__class__.request_path = self.path - content_length = int(self.headers.get("content-length", "0")) - self.rfile.read(content_length) - response = { - "id": "message-test", - "type": "message", - "role": "assistant", - "model": "MiniMax-M3", - "content": [{"type": "text", "text": "ok"}], - "stop_reason": "end_turn", - "stop_sequence": None, - "usage": {"input_tokens": 1, "output_tokens": 1}, - } - payload = json.dumps(response).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def log_message(self, format, *args): # noqa: A002 - return - - -def test_anthropic_client_appends_messages_path(tmp_path, monkeypatch): - server = ThreadingHTTPServer(("127.0.0.1", 0), CaptureHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - base_url = f"http://127.0.0.1:{server.server_port}/anthropic" - config = MINIMAX_CONFIG_TEMPLATE.replace( - 'protocol = "openai"', 'protocol = "anthropic"' - ).replace("https://api.minimax.io/anthropic", base_url) - config_path = write_config(tmp_path / "config.toml", config) - monkeypatch.setenv("MINIMAX_API_KEY", "test-key") +@pytest.fixture +def captured_requests(monkeypatch): + requests = [] + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + def send(client, request, **kwargs): + payload = json.loads(request.content) + requests.append((request, payload)) + if request.url.path.endswith("/messages"): + response = { + "id": "message-test", + "type": "message", + "role": "assistant", + "model": payload["model"], + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + else: + response = { + "id": "chat-test", + "object": "chat.completion", + "created": 0, + "model": payload["model"], + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + return httpx.Response(200, json=response, request=request) + + monkeypatch.setattr(httpx.Client, "send", send) + return requests + + +@pytest.mark.parametrize("protocol", ["openai", "anthropic"]) +@pytest.mark.parametrize( + "region,host", [("global_en", "api.minimax.io"), ("cn_zh", "api.minimaxi.com")] +) +@pytest.mark.parametrize("source", ["generated", "repository"]) +@pytest.mark.parametrize( + "model_type,expected", + [("simple_model", "MiniMax-M3.1-Flash"), ("complex_model", "MiniMax-M3")], +) +def test_default_request_payload( + tmp_path, + monkeypatch, + captured_requests, + protocol, + region, + host, + source, + model_type, + expected, +): + if source == "generated": + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.argv", ["rai-config-init"]) + config_initialization.main() + config_path = tmp_path / "config.toml" + else: + config_path = Path(__file__).resolve().parents[2] / "config.toml" + config = tomli.loads(config_path.read_text()) + config["vendor"][model_type] = "minimax" + config["minimax"].update(protocol=protocol, region=region) + config_path = write_config(tmp_path / "request.toml", tomli_w.dumps(config)) + + model = model_initialization.get_llm_model(model_type, config_path=str(config_path)) + assert model.invoke("Say hello").content == "ok" + + request, payload = captured_requests.pop() + assert request.url.host == host + assert request.url.path == ( + "/v1/chat/completions" if protocol == "openai" else "/anthropic/v1/messages" + ) + assert payload["model"] == expected + assert payload["messages"][0]["content"] == "Say hello" + assert "service_tier" not in payload + assert "reasoning_split" not in payload + assert payload.get("thinking", {}).get("type") != "disabled" + assert payload.get("reasoning_effort") != "none" + assert payload.get("output_config", {}).get("effort") != "none" + + +@pytest.mark.parametrize("protocol", ["openai", "anthropic"]) +@pytest.mark.parametrize("model_name", ["MiniMax-M2.7", "custom-deployment"]) +@pytest.mark.parametrize("direct", [False, True]) +def test_saved_and_explicit_models_survive_request( + tmp_path, monkeypatch, captured_requests, protocol, model_name, direct +): + config = tomli.loads(MINIMAX_CONFIG_TEMPLATE) + config["minimax"]["protocol"] = protocol + config["minimax"]["simple_model"] = model_name + if model_name == "custom-deployment": + config["minimax"]["endpoints"]["global_en"] = { + "openai_base_url": "https://custom.example/v1", + "anthropic_base_url": "https://custom.example/anthropic", + } + config_path = write_config(tmp_path / "config.toml", tomli_w.dumps(config)) + saved = config_path.read_bytes() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.argv", ["rai-config-init"]) + config_initialization.main() + assert config_path.read_bytes() == saved + if direct: + model = model_initialization.get_llm_model_direct( + model_name, "minimax", str(config_path) + ) + else: model = model_initialization.get_llm_model( - "complex_model", vendor="minimax", config_path=str(config_path) + "simple_model", config_path=str(config_path) ) - response = model.invoke("Say hello") + model.invoke("Say hello") + request, payload = captured_requests.pop() + assert payload["model"] == model_name + assert request.url.host == ( + "custom.example" if model_name == "custom-deployment" else "api.minimax.io" + ) - assert response.content == "ok" - assert CaptureHandler.request_path == "/anthropic/v1/messages" - finally: - server.shutdown() - thread.join(timeout=5) + +@pytest.mark.parametrize("protocol", ["openai", "anthropic"]) +@pytest.mark.parametrize("model_name", ["MiniMax-M3.1-Flash", "MiniMax-M3"]) +def test_request_preserves_options_tools_and_images( + tmp_path, captured_requests, protocol, model_name +): + config = tomli.loads(MINIMAX_CONFIG_TEMPLATE) + config["minimax"]["protocol"] = protocol + config_path = write_config(tmp_path / "config.toml", tomli_w.dumps(config)) + kwargs = ( + {"reasoning_effort": "high"} + if protocol == "openai" + else { + "thinking": {"type": "adaptive"}, + "model_kwargs": {"output_config": {"effort": "high"}}, + } + ) + model = model_initialization.get_llm_model_direct( + model_name, "minimax", str(config_path), api_key="explicit-key", **kwargs + ) + messages = [ + SystemMessage(content="Describe the scene."), + HumanMessage(content="Look up the cup."), + AIMessage( + content="", + tool_calls=[{"id": "call-1", "name": "lookup", "args": {"label": "cup"}}], + ), + ToolMessage(content="A blue cup", tool_call_id="call-1"), + HumanMessage( + content=[ + {"type": "text", "text": "Describe this image."}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,aW1hZ2U="}, + }, + ] + ), + ] + tool = { + "name": "lookup", + "description": "Look up a label", + "parameters": { + "type": "object", + "properties": {"label": {"type": "string"}}, + "required": ["label"], + }, + } + model.bind_tools([tool]).invoke(messages) + request, payload = captured_requests.pop() + assert payload["model"] == model_name + if protocol == "openai": + assert request.headers["authorization"] == "Bearer explicit-key" + assert payload["reasoning_effort"] == "high" + assert payload["messages"][0]["content"] == "Describe the scene." + assert payload["messages"][2]["tool_calls"][0]["id"] == "call-1" + assert payload["messages"][3]["tool_call_id"] == "call-1" + assert ( + payload["messages"][-1]["content"][-1]["image_url"]["url"] + == "data:image/png;base64,aW1hZ2U=" + ) + assert payload["tools"][0]["function"]["name"] == "lookup" + else: + assert request.headers["x-api-key"] == "explicit-key" + assert payload["thinking"] == {"type": "adaptive"} + assert payload["output_config"] == {"effort": "high"} + assert payload["system"] == "Describe the scene." + assert payload["messages"][1]["content"][0]["id"] == "call-1" + assert payload["messages"][-1]["content"][0]["tool_use_id"] == "call-1" + assert payload["messages"][-1]["content"][-1]["source"]["data"] == "aW1hZ2U=" + assert payload["tools"][0]["name"] == "lookup"