From eb0f9e0950919b2aeda00bc235cd7e81cc935a4c Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sat, 28 Mar 2026 20:39:47 +0800 Subject: [PATCH] feat: add MiniMax as alternative LLM provider Add MiniMax M2.7 and M2.7-highspeed models as chat providers via OpenAI-compatible API. Users can point to MiniMax by setting open_ai_base_url in config.json. Changes: - config.py: add MiniMax models to SUPPORTED_GPT_MODELS, add open_ai_base_url config, temperature clamping for MiniMax (>0) - ai.py: pass base_url to OpenAI client, tiktoken fallback for non-OpenAI models, fix streaming delta attribute access - config.example.json: add open_ai_base_url field - readme.md/readme.zh.md: add MiniMax usage documentation - tests/test_minimax.py: 20 unit + 3 integration tests --- ai.py | 18 ++- config.example.json | 1 + config.py | 11 ++ readme.md | 19 +++ readme.zh.md | 19 +++ tests/__init__.py | 0 tests/test_minimax.py | 291 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_minimax.py diff --git a/ai.py b/ai.py index 5f4d2f1..2759acd 100644 --- a/ai.py +++ b/ai.py @@ -16,10 +16,17 @@ def __init__(self, cfg: Config): self._chat_model: GPTModel = cfg.open_ai_chat_model self._embedding_model: EmbeddingModel = cfg.open_ai_embedding_model self._use_stream = cfg.use_stream - self._encoding = tiktoken.encoding_for_model(self._chat_model.name) + try: + self._encoding = tiktoken.encoding_for_model(self._chat_model.name) + except KeyError: + # Fallback for non-OpenAI models (e.g. MiniMax) + self._encoding = tiktoken.get_encoding('cl100k_base') self._language = cfg.language self._temperature = cfg.temperature - self.client = OpenAI(api_key=cfg.open_ai_key) + client_kwargs = dict(api_key=cfg.open_ai_key) + if cfg.open_ai_base_url: + client_kwargs['base_url'] = cfg.open_ai_base_url + self.client = OpenAI(**client_kwargs) def _chat_stream(self, messages: list[dict], use_stream: bool = None) -> str: use_stream = use_stream if use_stream is not None else self._use_stream @@ -33,9 +40,10 @@ def _chat_stream(self, messages: list[dict], use_stream: bool = None) -> str: if use_stream: data = "" for chunk in response: - if chunk.choices[0].delta.get('content', None) is not None: - data += chunk.choices[0].delta.content - print(chunk.choices[0].delta.content, end='') + content = getattr(chunk.choices[0].delta, 'content', None) + if content is not None: + data += content + print(content, end='') print() return data.strip() else: diff --git a/config.example.json b/config.example.json index 993dd15..3d99290 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,6 @@ { "open_ai_key": "", + "open_ai_base_url": "", "temperature": 0.1, "language": "English", "open_ai_chat_model": "gpt-3.5-turbo", diff --git a/config.py b/config.py index 3414317..fa71ef1 100644 --- a/config.py +++ b/config.py @@ -32,6 +32,10 @@ class GPTModel: GPTModel('gpt-3.5-turbo-16k', 16385, 0.0005, 0.0015), # gpt-3.5-turbo-16k-0613 GPTModel('gpt-3.5-turbo-16k-0613', 16385, 0.0005, 0.0015), + + # MiniMax models (OpenAI-compatible API at https://api.minimax.io/v1) + GPTModel('MiniMax-M2.7', 1_000_000, 0.004, 0.016), + GPTModel('MiniMax-M2.7-highspeed', 1_000_000, 0.001, 0.004), ] @@ -61,6 +65,7 @@ def __init__(self): self.language = self.config.get('language', 'Chinese') self.open_ai_key = self.config.get('open_ai_key') self.open_ai_proxy = self.config.get('open_ai_proxy') + self.open_ai_base_url = self.config.get('open_ai_base_url') gpt_model = self.config.get('open_ai_chat_model', 'gpt-3.5-turbo') self.open_ai_chat_model = self.get_gpt_model(gpt_model) embedding_model = self.config.get('open_ai_embedding_model', 'text-embedding-ada-002') @@ -71,6 +76,9 @@ def __init__(self): if self.temperature < 0 or self.temperature > 1: raise ValueError( 'temperature must be between 0 and 1, less is more conservative, more is more creative') + # MiniMax requires temperature > 0 + if self.is_minimax_model() and self.temperature == 0: + self.temperature = 0.01 self.use_stream = self.config.get('use_stream', False) self.use_postgres = self.config.get('use_postgres', False) if not self.use_postgres: @@ -98,3 +106,6 @@ def get_embedding_model(self, model: str): if model not in name_list: raise ValueError('open_ai_embedding_model must be one of ' + ', '.join(name_list)) return next(m for m in SUPPORTED_EMBEDDING_MODELS if m.name == model) + + def is_minimax_model(self): + return self.open_ai_chat_model.name.startswith('MiniMax-') diff --git a/readme.md b/readme.md index fc4e3d6..1459a4a 100644 --- a/readme.md +++ b/readme.md @@ -75,6 +75,25 @@ if you prefer, you can also run this project using docker: } ``` +## Using MiniMax as Chat Model + +ChatWeb supports [MiniMax](https://www.minimaxi.com/) models via their OpenAI-compatible API. MiniMax M2.7 offers a 1M token context window. + +- Edit `config.json` and set: +```json +{ + "open_ai_key": "your-minimax-api-key", + "open_ai_base_url": "https://api.minimax.io/v1", + "open_ai_chat_model": "MiniMax-M2.7" +} +``` + +Available MiniMax models: +- `MiniMax-M2.7` — flagship model with 1M context window +- `MiniMax-M2.7-highspeed` — faster variant with 1M context window + +> **Note:** MiniMax models are used for chat only. Embeddings still use OpenAI's embedding API, so a separate OpenAI API key may be needed for embedding if you want to use MiniMax for chat. + ## Install PostgreSQL (Optional) - Edit `config.json` and set `use_postgres` to `true`. diff --git a/readme.zh.md b/readme.zh.md index d00a35c..a8fe801 100644 --- a/readme.zh.md +++ b/readme.zh.md @@ -73,6 +73,25 @@ python3 main.py } ``` +## 使用MiniMax作为Chat模型 + +ChatWeb支持通过OpenAI兼容API使用[MiniMax](https://www.minimaxi.com/)模型。MiniMax M2.7拥有100万token上下文窗口。 + +- 编辑`config.json`,设置: +```json +{ + "open_ai_key": "你的MiniMax API密钥", + "open_ai_base_url": "https://api.minimax.io/v1", + "open_ai_chat_model": "MiniMax-M2.7" +} +``` + +可用的MiniMax模型: +- `MiniMax-M2.7` — 旗舰模型,100万token上下文窗口 +- `MiniMax-M2.7-highspeed` — 高速版本,100万token上下文窗口 + +> **注意:** MiniMax模型仅用于聊天功能。Embedding仍使用OpenAI的Embedding API,如果您使用MiniMax进行聊天,可能需要单独的OpenAI API密钥用于Embedding。 + ## 安装postgresql(可选) - 编辑`config.json`, 设置`use_postgres`为`true` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_minimax.py b/tests/test_minimax.py new file mode 100644 index 0000000..e8e5446 --- /dev/null +++ b/tests/test_minimax.py @@ -0,0 +1,291 @@ +"""Tests for MiniMax model support in ChatWeb.""" + +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from config import Config, GPTModel, SUPPORTED_GPT_MODELS + + +class TestMiniMaxModelsInConfig(unittest.TestCase): + """Unit tests for MiniMax model definitions in config.""" + + def test_minimax_m27_in_supported_models(self): + names = [m.name for m in SUPPORTED_GPT_MODELS] + self.assertIn('MiniMax-M2.7', names) + + def test_minimax_m27_highspeed_in_supported_models(self): + names = [m.name for m in SUPPORTED_GPT_MODELS] + self.assertIn('MiniMax-M2.7-highspeed', names) + + def test_minimax_m27_context_window(self): + model = next(m for m in SUPPORTED_GPT_MODELS if m.name == 'MiniMax-M2.7') + self.assertEqual(model.context_window, 1_000_000) + + def test_minimax_m27_highspeed_context_window(self): + model = next(m for m in SUPPORTED_GPT_MODELS if m.name == 'MiniMax-M2.7-highspeed') + self.assertEqual(model.context_window, 1_000_000) + + def test_minimax_m27_pricing(self): + model = next(m for m in SUPPORTED_GPT_MODELS if m.name == 'MiniMax-M2.7') + self.assertEqual(model.input_price_per_k, 0.004) + self.assertEqual(model.output_price_per_k, 0.016) + + def test_minimax_m27_highspeed_pricing(self): + model = next(m for m in SUPPORTED_GPT_MODELS if m.name == 'MiniMax-M2.7-highspeed') + self.assertEqual(model.input_price_per_k, 0.001) + self.assertEqual(model.output_price_per_k, 0.004) + + def test_gpt_models_still_present(self): + """Ensure existing OpenAI models are not broken.""" + names = [m.name for m in SUPPORTED_GPT_MODELS] + self.assertIn('gpt-3.5-turbo', names) + self.assertIn('gpt-4', names) + self.assertIn('gpt-4-turbo-preview', names) + + +class TestConfigMiniMax(unittest.TestCase): + """Unit tests for Config class MiniMax support.""" + + def _create_config(self, overrides=None): + config = { + "open_ai_key": "test-key", + "open_ai_chat_model": "gpt-3.5-turbo", + "open_ai_embedding_model": "text-embedding-ada-002", + "temperature": 0.1, + } + if overrides: + config.update(overrides) + tmpdir = tempfile.mkdtemp() + config_path = os.path.join(tmpdir, "config.json") + with open(config_path, "w") as f: + json.dump(config, f) + return tmpdir, config_path + + def test_minimax_model_selection(self): + tmpdir, _ = self._create_config({"open_ai_chat_model": "MiniMax-M2.7"}) + with patch.object(Config, '__init__', lambda self: None): + cfg = Config.__new__(Config) + cfg.config = json.load(open(os.path.join(tmpdir, "config.json"))) + model = cfg.get_gpt_model("MiniMax-M2.7") + self.assertEqual(model.name, "MiniMax-M2.7") + self.assertEqual(model.context_window, 1_000_000) + + def test_minimax_highspeed_model_selection(self): + with patch.object(Config, '__init__', lambda self: None): + cfg = Config.__new__(Config) + model = cfg.get_gpt_model("MiniMax-M2.7-highspeed") + self.assertEqual(model.name, "MiniMax-M2.7-highspeed") + + def test_is_minimax_model_true(self): + with patch.object(Config, '__init__', lambda self: None): + cfg = Config.__new__(Config) + cfg.open_ai_chat_model = GPTModel('MiniMax-M2.7', 1_000_000, 0.004, 0.016) + self.assertTrue(cfg.is_minimax_model()) + + def test_is_minimax_model_false(self): + with patch.object(Config, '__init__', lambda self: None): + cfg = Config.__new__(Config) + cfg.open_ai_chat_model = GPTModel('gpt-4', 8192, 0.03, 0.06) + self.assertFalse(cfg.is_minimax_model()) + + def test_base_url_config(self): + tmpdir, _ = self._create_config({ + "open_ai_base_url": "https://api.minimax.io/v1", + "open_ai_chat_model": "MiniMax-M2.7", + }) + config_path = os.path.join(tmpdir, "config.json") + # Patch the config path + with patch('config.os.path.join', return_value=config_path): + with patch('config.os.path.exists', return_value=True): + with patch('config.os.path.dirname', return_value=tmpdir): + cfg = Config() + self.assertEqual(cfg.open_ai_base_url, "https://api.minimax.io/v1") + + def test_temperature_clamping_for_minimax(self): + tmpdir, _ = self._create_config({ + "open_ai_chat_model": "MiniMax-M2.7", + "temperature": 0, + }) + config_path = os.path.join(tmpdir, "config.json") + with patch('config.os.path.join', return_value=config_path): + with patch('config.os.path.exists', return_value=True): + with patch('config.os.path.dirname', return_value=tmpdir): + cfg = Config() + self.assertGreater(cfg.temperature, 0) + self.assertEqual(cfg.temperature, 0.01) + + def test_temperature_not_clamped_for_openai(self): + tmpdir, _ = self._create_config({ + "open_ai_chat_model": "gpt-3.5-turbo", + "temperature": 0, + }) + config_path = os.path.join(tmpdir, "config.json") + with patch('config.os.path.join', return_value=config_path): + with patch('config.os.path.exists', return_value=True): + with patch('config.os.path.dirname', return_value=tmpdir): + cfg = Config() + self.assertEqual(cfg.temperature, 0) + + def test_base_url_defaults_to_none(self): + tmpdir, _ = self._create_config() + config_path = os.path.join(tmpdir, "config.json") + with patch('config.os.path.join', return_value=config_path): + with patch('config.os.path.exists', return_value=True): + with patch('config.os.path.dirname', return_value=tmpdir): + cfg = Config() + self.assertIsNone(cfg.open_ai_base_url) + + +class TestAIMiniMax(unittest.TestCase): + """Unit tests for AI class MiniMax support.""" + + def _make_config(self, model_name='MiniMax-M2.7', base_url='https://api.minimax.io/v1'): + cfg = MagicMock() + cfg.open_ai_proxy = None + cfg.open_ai_key = 'test-key' + cfg.open_ai_base_url = base_url + cfg.open_ai_chat_model = GPTModel(model_name, 1_000_000, 0.004, 0.016) + cfg.open_ai_embedding_model = MagicMock() + cfg.open_ai_embedding_model.name = 'text-embedding-ada-002' + cfg.use_stream = False + cfg.language = 'English' + cfg.temperature = 0.5 + return cfg + + @patch('ai.OpenAI') + def test_minimax_creates_client_with_base_url(self, mock_openai): + from ai import AI + cfg = self._make_config() + ai = AI(cfg) + mock_openai.assert_called_once_with( + api_key='test-key', + base_url='https://api.minimax.io/v1', + ) + + @patch('ai.OpenAI') + def test_openai_creates_client_without_base_url(self, mock_openai): + from ai import AI + cfg = self._make_config(model_name='gpt-4', base_url=None) + cfg.open_ai_chat_model = GPTModel('gpt-4', 8192, 0.03, 0.06) + ai = AI(cfg) + mock_openai.assert_called_once_with(api_key='test-key') + + @patch('ai.OpenAI') + def test_tiktoken_fallback_for_minimax(self, mock_openai): + from ai import AI + cfg = self._make_config() + ai = AI(cfg) + # Should not raise and should use cl100k_base fallback + self.assertIsNotNone(ai._encoding) + tokens = ai._num_tokens_from_string("Hello world") + self.assertGreater(tokens, 0) + + @patch('ai.OpenAI') + def test_tiktoken_works_for_openai_model(self, mock_openai): + from ai import AI + cfg = self._make_config(model_name='gpt-4', base_url=None) + cfg.open_ai_chat_model = GPTModel('gpt-4', 8192, 0.03, 0.06) + ai = AI(cfg) + self.assertIsNotNone(ai._encoding) + + +class TestConfigExampleJson(unittest.TestCase): + """Test that config.example.json includes base_url field.""" + + def test_config_example_has_base_url(self): + example_path = os.path.join(os.path.dirname(__file__), '..', 'config.example.json') + with open(example_path) as f: + config = json.load(f) + self.assertIn('open_ai_base_url', config) + + +class TestMiniMaxIntegration(unittest.TestCase): + """Integration tests for MiniMax — require MINIMAX_API_KEY env var.""" + + def setUp(self): + self.api_key = os.environ.get('MINIMAX_API_KEY') + if not self.api_key: + self.skipTest('MINIMAX_API_KEY not set') + + @patch('ai.OpenAI') + def test_minimax_chat_completion_mock(self, mock_openai_cls): + """Integration-style test with mocked OpenAI client.""" + from ai import AI + + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Test answer" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + mock_response.usage.total_tokens = 15 + mock_client.chat.completions.create.return_value = mock_response + + cfg = MagicMock() + cfg.open_ai_proxy = None + cfg.open_ai_key = self.api_key + cfg.open_ai_base_url = 'https://api.minimax.io/v1' + cfg.open_ai_chat_model = GPTModel('MiniMax-M2.7', 1_000_000, 0.004, 0.016) + cfg.open_ai_embedding_model = MagicMock() + cfg.open_ai_embedding_model.name = 'text-embedding-ada-002' + cfg.use_stream = False + cfg.language = 'English' + cfg.temperature = 0.5 + + ai = AI(cfg) + result = ai.completion("What is AI?", ["AI is artificial intelligence."]) + self.assertIsInstance(result, str) + self.assertTrue(len(result) > 0) + + # Verify the call used the MiniMax model + call_args = mock_client.chat.completions.create.call_args + self.assertEqual(call_args.kwargs['model'], 'MiniMax-M2.7') + + def test_minimax_live_chat(self): + """Live integration test — calls real MiniMax API.""" + from ai import AI + + cfg = MagicMock() + cfg.open_ai_proxy = None + cfg.open_ai_key = self.api_key + cfg.open_ai_base_url = 'https://api.minimax.io/v1' + cfg.open_ai_chat_model = GPTModel('MiniMax-M2.7', 1_000_000, 0.004, 0.016) + cfg.open_ai_embedding_model = MagicMock() + cfg.open_ai_embedding_model.name = 'text-embedding-ada-002' + cfg.use_stream = False + cfg.language = 'English' + cfg.temperature = 0.5 + + ai = AI(cfg) + result = ai.completion("What is 2+2?", ["Basic math: 2+2=4"]) + self.assertIsInstance(result, str) + self.assertTrue(len(result) > 0) + + def test_minimax_live_stream(self): + """Live integration test with streaming enabled.""" + from ai import AI + + cfg = MagicMock() + cfg.open_ai_proxy = None + cfg.open_ai_key = self.api_key + cfg.open_ai_base_url = 'https://api.minimax.io/v1' + cfg.open_ai_chat_model = GPTModel('MiniMax-M2.7-highspeed', 1_000_000, 0.001, 0.004) + cfg.open_ai_embedding_model = MagicMock() + cfg.open_ai_embedding_model.name = 'text-embedding-ada-002' + cfg.use_stream = True + cfg.language = 'English' + cfg.temperature = 0.5 + + ai = AI(cfg) + result = ai.completion("Say hello", ["Greeting: Hello!"]) + self.assertIsInstance(result, str) + self.assertTrue(len(result) > 0) + + +if __name__ == '__main__': + unittest.main()