diff --git a/README.md b/README.md index 23a7ba2..2e3f55f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,13 @@ Then put the following: openai_key = "" ``` +To use additional LLM providers, add their API keys: +``` +anthropic_key = "" +replicate_key = "" +minimax_key = "" +``` + Then run the app from the "home page" file. @@ -99,6 +106,7 @@ You can set the configuration either through natural language or manually for bo - OpenAI: ID is "openai:" e.g. "openai:gpt-4-1106-preview" - Anthropic: ID is "anthropic:" e.g. "anthropic:claude-2" - Replicate: ID is "replicate:" + - MiniMax: ID is "minimax:" e.g. "minimax:MiniMax-M2.7" or "minimax:MiniMax-M2.7-highspeed" - HuggingFace: ID is "local:" e.g. "local:BAAI/bge-small-en" - **Embeddings**: Supports text-embedding-ada-002 by default, but also supports Hugging Face models. To use a hugging face model simply prepend with local, e.g. local:BAAI/bge-small-en. diff --git a/core/utils.py b/core/utils.py index c39a02c..f46bdfc 100644 --- a/core/utils.py +++ b/core/utils.py @@ -93,6 +93,13 @@ def _resolve_llm(llm_str: str) -> LLM: elif tokens[0] == "replicate": os.environ["REPLICATE_API_KEY"] = st.secrets.replicate_key llm = Replicate(model=tokens[1]) + elif tokens[0] == "minimax": + llm = OpenAI( + model=tokens[1], + api_key=st.secrets.minimax_key, + api_base="https://api.minimax.io/v1", + temperature=1.0, + ) else: raise ValueError(f"LLM {llm_str} not recognized.") return llm diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py new file mode 100644 index 0000000..514172f --- /dev/null +++ b/tests/test_minimax_integration.py @@ -0,0 +1,77 @@ +"""Integration tests for MiniMax LLM provider via OpenAI-compatible API. + +These tests make real API calls to MiniMax and require MINIMAX_API_KEY. +Run with: MINIMAX_API_KEY= pytest tests/test_minimax_integration.py -v +""" + +import os + +import pytest + +# Skip all tests if MINIMAX_API_KEY is not set +pytestmark = pytest.mark.skipif( + not os.environ.get("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set", +) + + +class TestMiniMaxAPIIntegration: + """Integration tests that call the real MiniMax API.""" + + def test_minimax_chat_completion(self): + """Test basic chat completion via MiniMax OpenAI-compatible API.""" + from openai import OpenAI + + client = OpenAI( + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", + ) + + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "Say hello in one word."}], + temperature=1.0, + max_tokens=10, + ) + + assert response.choices + assert len(response.choices[0].message.content.strip()) > 0 + + def test_minimax_highspeed_model(self): + """Test MiniMax-M2.7-highspeed model works.""" + from openai import OpenAI + + client = OpenAI( + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", + ) + + response = client.chat.completions.create( + model="MiniMax-M2.7-highspeed", + messages=[{"role": "user", "content": "Say hello in one word."}], + temperature=1.0, + max_tokens=100, + ) + + assert response.choices + assert len(response.choices[0].message.content.strip()) > 0 + + def test_minimax_streaming(self): + """Test streaming works with MiniMax API.""" + from openai import OpenAI + + client = OpenAI( + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", + ) + + stream = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "Say hi."}], + temperature=1.0, + max_tokens=10, + stream=True, + ) + + chunks = list(stream) + assert len(chunks) > 0 diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 0000000..79e70ec --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,221 @@ +"""Tests for MiniMax LLM provider integration.""" + +import os +import sys +from unittest.mock import MagicMock, patch, call + +import pytest + + +# Mock all heavy dependencies before importing core.utils +_mock_modules = {} +for mod_name in [ + "streamlit", "streamlit_pills", + "llama_index", "llama_index.llms", "llama_index.llms.base", + "llama_index.llms.utils", "llama_index.llms.openai_utils", + "llama_index.agent", "llama_index.agent.types", + "llama_index.agent.react", "llama_index.agent.react.prompts", + "llama_index.agent.react.formatter", + "llama_index.embeddings", "llama_index.embeddings.utils", + "llama_index.tools", "llama_index.schema", + "llama_index.callbacks", + "llama_index.indices", "llama_index.indices.multi_modal", + "llama_index.indices.multi_modal.base", + "llama_index.indices.multi_modal.retriever", + "llama_index.multi_modal_llms", "llama_index.multi_modal_llms.openai", + "llama_index.query_engine", "llama_index.query_engine.multi_modal", + "llama_index.chat_engine", "llama_index.chat_engine.types", + "llama_index.llms.openai", + "llama_hub", "llama_hub.web", "llama_hub.web.simple_web", + "llama_hub.web.simple_web.base", + "llama_hub.tools", "llama_hub.tools.metaphor", + "llama_hub.tools.metaphor.base", + "core.callback_manager", +]: + if mod_name not in sys.modules: + _mock_modules[mod_name] = MagicMock() + sys.modules[mod_name] = _mock_modules[mod_name] + +# Set up streamlit mock with secrets +st_mock = sys.modules["streamlit"] +st_mock.secrets = MagicMock() +st_mock.secrets.openai_key = "fake-openai-key" +st_mock.secrets.anthropic_key = "fake-anthropic-key" +st_mock.secrets.replicate_key = "fake-replicate-key" +st_mock.secrets.minimax_key = "fake-minimax-key" + +# Set up llama_index mock classes +MockOpenAI = MagicMock() +MockAnthropic = MagicMock() +MockReplicate = MagicMock() +MockLLMBase = MagicMock() +MockBaseModel = MagicMock() + +sys.modules["llama_index.llms"].OpenAI = MockOpenAI +sys.modules["llama_index.llms"].Anthropic = MockAnthropic +sys.modules["llama_index.llms"].Replicate = MockReplicate +sys.modules["llama_index.llms.base"].LLM = MockLLMBase + +# Mock pydantic classes that core.utils uses +from pydantic import BaseModel, Field + +# Now import core.utils - need to force re-import +if "core.utils" in sys.modules: + del sys.modules["core.utils"] +if "core" in sys.modules: + del sys.modules["core"] +if "core.builder_config" in sys.modules: + del sys.modules["core.builder_config"] + +# Mock builder_config +builder_config_mock = MagicMock() +builder_config_mock.BUILDER_LLM = MagicMock() +sys.modules["core.builder_config"] = builder_config_mock + +import core.utils +from core.utils import _resolve_llm, RAGParams + + +class TestResolveLLMMiniMax: + """Unit tests for _resolve_llm with MiniMax provider.""" + + def setup_method(self): + """Reset mocks before each test.""" + MockOpenAI.reset_mock() + MockAnthropic.reset_mock() + MockReplicate.reset_mock() + st_mock.secrets.minimax_key = "fake-minimax-key" + + def test_minimax_prefix_creates_openai_with_correct_params(self): + """Test that 'minimax:MiniMax-M2.7' creates OpenAI with MiniMax API base.""" + MockOpenAI.return_value = MagicMock() + + result = _resolve_llm("minimax:MiniMax-M2.7") + + MockOpenAI.assert_called_once_with( + model="MiniMax-M2.7", + api_key="fake-minimax-key", + api_base="https://api.minimax.io/v1", + temperature=1.0, + ) + assert result == MockOpenAI.return_value + + def test_minimax_highspeed_model(self): + """Test that 'minimax:MiniMax-M2.7-highspeed' works correctly.""" + MockOpenAI.return_value = MagicMock() + + result = _resolve_llm("minimax:MiniMax-M2.7-highspeed") + + MockOpenAI.assert_called_once_with( + model="MiniMax-M2.7-highspeed", + api_key="fake-minimax-key", + api_base="https://api.minimax.io/v1", + temperature=1.0, + ) + assert result == MockOpenAI.return_value + + def test_minimax_uses_minimax_key_from_secrets(self): + """Test that MiniMax provider reads API key from st.secrets.minimax_key.""" + st_mock.secrets.minimax_key = "test-api-key-12345" + MockOpenAI.return_value = MagicMock() + + _resolve_llm("minimax:MiniMax-M2.7") + + call_kwargs = MockOpenAI.call_args[1] + assert call_kwargs["api_key"] == "test-api-key-12345" + + def test_minimax_temperature_default_is_1(self): + """Test that MiniMax provider sets temperature to 1.0 by default.""" + MockOpenAI.return_value = MagicMock() + + _resolve_llm("minimax:MiniMax-M2.7") + + call_kwargs = MockOpenAI.call_args[1] + assert call_kwargs["temperature"] == 1.0 + + def test_minimax_api_base_is_overseas(self): + """Test that MiniMax uses overseas API base URL.""" + MockOpenAI.return_value = MagicMock() + + _resolve_llm("minimax:MiniMax-M2.7") + + call_kwargs = MockOpenAI.call_args[1] + assert call_kwargs["api_base"] == "https://api.minimax.io/v1" + + def test_minimax_does_not_set_openai_env_var(self): + """Test that MiniMax provider doesn't pollute OPENAI_API_KEY env var.""" + # Clear any existing key + prev = os.environ.pop("OPENAI_API_KEY", None) + MockOpenAI.return_value = MagicMock() + + _resolve_llm("minimax:MiniMax-M2.7") + + assert os.environ.get("OPENAI_API_KEY") is None or os.environ.get("OPENAI_API_KEY") == prev + if prev: + os.environ["OPENAI_API_KEY"] = prev + + def test_openai_provider_still_works(self): + """Regression test: OpenAI provider is unaffected by MiniMax addition.""" + MockOpenAI.return_value = MagicMock() + + _resolve_llm("openai:gpt-4") + + MockOpenAI.assert_called_once_with(model="gpt-4") + assert os.environ.get("OPENAI_API_KEY") == "fake-openai-key" + + def test_unknown_provider_raises_error(self): + """Test that unknown provider prefix raises ValueError.""" + with pytest.raises(ValueError, match="not recognized"): + _resolve_llm("unknown:some-model") + + +class TestResolveLLMExistingProviders: + """Regression tests for existing providers.""" + + def setup_method(self): + MockOpenAI.reset_mock() + MockAnthropic.reset_mock() + MockReplicate.reset_mock() + + def test_anthropic_provider(self): + """Regression test: Anthropic provider still works.""" + MockAnthropic.return_value = MagicMock() + + _resolve_llm("anthropic:claude-2") + + MockAnthropic.assert_called_once_with(model="claude-2") + + def test_replicate_provider(self): + """Regression test: Replicate provider still works.""" + MockReplicate.return_value = MagicMock() + + _resolve_llm("replicate:some-model") + + MockReplicate.assert_called_once_with(model="some-model") + + def test_default_openai_no_prefix(self): + """Test that no prefix defaults to OpenAI.""" + MockOpenAI.return_value = MagicMock() + + _resolve_llm("gpt-4") + + MockOpenAI.assert_called_once_with(model="gpt-4") + + +class TestRAGParamsWithMiniMax: + """Test RAGParams with MiniMax model strings.""" + + def test_rag_params_accepts_minimax_llm_string(self): + """Test that RAGParams accepts a minimax: prefixed LLM string.""" + params = RAGParams(llm="minimax:MiniMax-M2.7") + assert params.llm == "minimax:MiniMax-M2.7" + + def test_rag_params_accepts_minimax_highspeed(self): + """Test that RAGParams accepts MiniMax-M2.7-highspeed.""" + params = RAGParams(llm="minimax:MiniMax-M2.7-highspeed") + assert params.llm == "minimax:MiniMax-M2.7-highspeed" + + def test_rag_params_default_llm_unchanged(self): + """Test that default LLM is still gpt-4-1106-preview.""" + params = RAGParams() + assert params.llm == "gpt-4-1106-preview"