diff --git a/.env.example b/.env.example index 9f6d59f..6e544bd 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,19 @@ OPENAI_API_KEY= +# LLM provider — auto-detected from API keys if unset +# Options: openai, minimax +# LLM_PROVIDER=openai + # LLM model — strong models are required for reliable UI generation -# Recommended: gpt-5.4, gpt-5.4-pro, claude-opus-4-6, gemini-3.1-pro +# Recommended: gpt-5.4, gpt-5.4-pro, claude-opus-4-6, gemini-3.1-pro, MiniMax-M2.7 LLM_MODEL=gpt-5.4-2026-03-05 +# Custom base URL for OpenAI-compatible providers (overrides provider preset) +# LLM_BASE_URL= + +# MiniMax (https://www.minimaxi.com) — set key to auto-select MiniMax provider +# MINIMAX_API_KEY= + # Rate limiting (per IP) — disabled by default RATE_LIMIT_ENABLED=false RATE_LIMIT_WINDOW_MS=60000 diff --git a/README.md b/README.md index 2b5eff4..4d3aa27 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,11 @@ make dev # Start all services > | `gpt-5.4` / `gpt-5.4-pro` | OpenAI | > | `claude-opus-4-6` | Anthropic | > | `gemini-3.1-pro` | Google | +> | `MiniMax-M2.7` / `MiniMax-M2.7-highspeed` | [MiniMax](https://www.minimaxi.com) | > > Smaller or weaker models will produce broken layouts, missing interactivity, or incomplete visualizations. +> +> **Using MiniMax:** Set `MINIMAX_API_KEY` in your `.env` — the provider is auto-detected. Defaults to `MiniMax-M2.7` (1M context window). See [MiniMax docs](https://www.minimaxi.com/document/introduction) for API keys. - **App**: http://localhost:3000 - **Agent**: http://localhost:8123 diff --git a/apps/agent/main.py b/apps/agent/main.py index 9d558e6..ef18aca 100644 --- a/apps/agent/main.py +++ b/apps/agent/main.py @@ -3,12 +3,10 @@ It defines the workflow graph, state, tools, nodes and edges. """ -import os - from copilotkit import CopilotKitMiddleware from langchain.agents import create_agent -from langchain_openai import ChatOpenAI +from src.llm_provider import create_llm from src.query import query_data from src.todos import AgentState, todo_tools from src.form import generate_form @@ -19,7 +17,7 @@ _skills_text = load_all_skills() agent = create_agent( - model=ChatOpenAI(model=os.environ.get("LLM_MODEL", "gpt-5.4-2026-03-05")), + model=create_llm(), tools=[query_data, *todo_tools, generate_form, *template_tools], middleware=[CopilotKitMiddleware()], state_schema=AgentState, diff --git a/apps/agent/src/llm_provider.py b/apps/agent/src/llm_provider.py new file mode 100644 index 0000000..a58ea68 --- /dev/null +++ b/apps/agent/src/llm_provider.py @@ -0,0 +1,89 @@ +""" +LLM provider factory for multi-provider support. + +Supports OpenAI (default), MiniMax, and any OpenAI-compatible provider +via the LLM_BASE_URL environment variable. + +Provider auto-detection priority: + 1. Explicit LLM_PROVIDER env var + 2. MINIMAX_API_KEY present → minimax + 3. OPENAI_API_KEY present → openai (default) + +MiniMax models use the OpenAI-compatible API at https://api.minimax.io/v1 +with temperature clamped to (0.0, 1.0]. +""" + +import os +from langchain_openai import ChatOpenAI + + +# Provider presets: base_url and api_key env var name +PROVIDER_PRESETS = { + "openai": { + "base_url": None, # uses default + "api_key_env": "OPENAI_API_KEY", + }, + "minimax": { + "base_url": "https://api.minimax.io/v1", + "api_key_env": "MINIMAX_API_KEY", + "default_model": "MiniMax-M2.7", + }, +} + +# Models that require temperature clamping to (0.0, 1.0] +_CLAMP_TEMPERATURE_PROVIDERS = {"minimax"} + + +def _detect_provider() -> str: + """Auto-detect provider from environment variables.""" + explicit = os.environ.get("LLM_PROVIDER", "").strip().lower() + if explicit: + return explicit + + if os.environ.get("MINIMAX_API_KEY"): + return "minimax" + + return "openai" + + +def create_llm() -> ChatOpenAI: + """ + Create a ChatOpenAI-compatible LLM instance based on environment config. + + Environment variables: + LLM_PROVIDER – Provider name: "openai" | "minimax" (auto-detected if unset) + LLM_MODEL – Model name (provider-specific default if unset) + LLM_BASE_URL – Custom base URL (overrides provider preset) + LLM_TEMPERATURE – Temperature value (default: 0.7) + OPENAI_API_KEY – OpenAI API key + MINIMAX_API_KEY – MiniMax API key + """ + provider = _detect_provider() + preset = PROVIDER_PRESETS.get(provider, {}) + + model = os.environ.get("LLM_MODEL") or preset.get("default_model") or "gpt-5.4-2026-03-05" + base_url = os.environ.get("LLM_BASE_URL") or preset.get("base_url") + + # Resolve API key + api_key_env = preset.get("api_key_env", "OPENAI_API_KEY") + api_key = os.environ.get(api_key_env) or os.environ.get("OPENAI_API_KEY", "") + + # Parse temperature + temperature = float(os.environ.get("LLM_TEMPERATURE", "0.7")) + + # Clamp temperature for providers that require it (MiniMax: (0.0, 1.0]) + if provider in _CLAMP_TEMPERATURE_PROVIDERS: + temperature = max(0.01, min(temperature, 1.0)) + + kwargs = { + "model": model, + "temperature": temperature, + } + + if base_url: + kwargs["base_url"] = base_url + + if api_key: + kwargs["api_key"] = api_key + + return ChatOpenAI(**kwargs) diff --git a/apps/agent/tests/__init__.py b/apps/agent/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/agent/tests/conftest.py b/apps/agent/tests/conftest.py new file mode 100644 index 0000000..7ba6da2 --- /dev/null +++ b/apps/agent/tests/conftest.py @@ -0,0 +1,26 @@ +"""Configure imports so tests can import from the agent src directory.""" + +import importlib +import sys +import types +from pathlib import Path + +# The system has a `src` package installed globally that shadows our local +# `src/` directory. Remove it so that the agent's own `src` package is found. +agent_root = Path(__file__).resolve().parents[1] +src_dir = agent_root / "src" + +# Remove any pre-existing `src` module from the cache +for key in list(sys.modules): + if key == "src" or key.startswith("src."): + del sys.modules[key] + +# Ensure agent root is first on the path +if str(agent_root) not in sys.path: + sys.path.insert(0, str(agent_root)) + +# Register our local src as a namespace package so submodule imports work +src_mod = types.ModuleType("src") +src_mod.__path__ = [str(src_dir)] +src_mod.__package__ = "src" +sys.modules["src"] = src_mod diff --git a/apps/agent/tests/test_llm_provider.py b/apps/agent/tests/test_llm_provider.py new file mode 100644 index 0000000..15ddfd6 --- /dev/null +++ b/apps/agent/tests/test_llm_provider.py @@ -0,0 +1,267 @@ +"""Unit tests for the LLM provider factory.""" + +import os +from unittest.mock import patch, MagicMock + +import pytest + +from src.llm_provider import create_llm, _detect_provider, PROVIDER_PRESETS + + +# --------------------------------------------------------------------------- +# _detect_provider tests +# --------------------------------------------------------------------------- + + +class TestDetectProvider: + """Tests for provider auto-detection logic.""" + + @patch.dict(os.environ, {}, clear=True) + def test_defaults_to_openai(self): + assert _detect_provider() == "openai" + + @patch.dict(os.environ, {"LLM_PROVIDER": "minimax"}, clear=True) + def test_explicit_provider_minimax(self): + assert _detect_provider() == "minimax" + + @patch.dict(os.environ, {"LLM_PROVIDER": "openai"}, clear=True) + def test_explicit_provider_openai(self): + assert _detect_provider() == "openai" + + @patch.dict(os.environ, {"LLM_PROVIDER": " MiniMax "}, clear=True) + def test_explicit_provider_strips_and_lowercases(self): + assert _detect_provider() == "minimax" + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "mm-test-key"}, clear=True) + def test_auto_detect_minimax_from_api_key(self): + assert _detect_provider() == "minimax" + + @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}, clear=True) + def test_auto_detect_openai_from_api_key(self): + assert _detect_provider() == "openai" + + @patch.dict( + os.environ, + {"LLM_PROVIDER": "openai", "MINIMAX_API_KEY": "mm-test-key"}, + clear=True, + ) + def test_explicit_provider_takes_priority_over_auto_detect(self): + assert _detect_provider() == "openai" + + @patch.dict( + os.environ, + {"MINIMAX_API_KEY": "mm-key", "OPENAI_API_KEY": "sk-key"}, + clear=True, + ) + def test_minimax_key_takes_priority_over_openai_key(self): + assert _detect_provider() == "minimax" + + +# --------------------------------------------------------------------------- +# PROVIDER_PRESETS tests +# --------------------------------------------------------------------------- + + +class TestProviderPresets: + """Tests for provider preset configuration.""" + + def test_openai_preset_exists(self): + assert "openai" in PROVIDER_PRESETS + + def test_minimax_preset_exists(self): + assert "minimax" in PROVIDER_PRESETS + + def test_minimax_base_url(self): + assert PROVIDER_PRESETS["minimax"]["base_url"] == "https://api.minimax.io/v1" + + def test_minimax_default_model(self): + assert PROVIDER_PRESETS["minimax"]["default_model"] == "MiniMax-M2.7" + + def test_minimax_api_key_env(self): + assert PROVIDER_PRESETS["minimax"]["api_key_env"] == "MINIMAX_API_KEY" + + def test_openai_no_base_url(self): + assert PROVIDER_PRESETS["openai"]["base_url"] is None + + +# --------------------------------------------------------------------------- +# create_llm tests +# --------------------------------------------------------------------------- + + +class TestCreateLlm: + """Tests for the LLM factory function.""" + + @patch.dict( + os.environ, + {"OPENAI_API_KEY": "sk-test-key"}, + clear=True, + ) + def test_creates_openai_llm_by_default(self): + llm = create_llm() + assert llm.model_name == "gpt-5.4-2026-03-05" + + @patch.dict( + os.environ, + {"MINIMAX_API_KEY": "mm-test-key"}, + clear=True, + ) + def test_creates_minimax_llm_from_api_key(self): + llm = create_llm() + assert llm.model_name == "MiniMax-M2.7" + assert "minimax.io" in str(llm.openai_api_base) + + @patch.dict( + os.environ, + {"LLM_PROVIDER": "minimax", "MINIMAX_API_KEY": "mm-key"}, + clear=True, + ) + def test_minimax_uses_correct_base_url(self): + llm = create_llm() + assert str(llm.openai_api_base) == "https://api.minimax.io/v1" + + @patch.dict( + os.environ, + { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "mm-key", + "LLM_MODEL": "MiniMax-M2.7-highspeed", + }, + clear=True, + ) + def test_minimax_custom_model(self): + llm = create_llm() + assert llm.model_name == "MiniMax-M2.7-highspeed" + + @patch.dict( + os.environ, + { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "mm-key", + "LLM_TEMPERATURE": "0.0", + }, + clear=True, + ) + def test_minimax_temperature_clamped_above_zero(self): + llm = create_llm() + assert llm.temperature >= 0.01 + + @patch.dict( + os.environ, + { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "mm-key", + "LLM_TEMPERATURE": "1.5", + }, + clear=True, + ) + def test_minimax_temperature_clamped_at_one(self): + llm = create_llm() + assert llm.temperature <= 1.0 + + @patch.dict( + os.environ, + { + "OPENAI_API_KEY": "sk-key", + "LLM_TEMPERATURE": "0.0", + }, + clear=True, + ) + def test_openai_temperature_not_clamped(self): + llm = create_llm() + assert llm.temperature == 0.0 + + @patch.dict( + os.environ, + { + "OPENAI_API_KEY": "sk-key", + "LLM_TEMPERATURE": "0.5", + }, + clear=True, + ) + def test_custom_temperature(self): + llm = create_llm() + assert llm.temperature == 0.5 + + @patch.dict( + os.environ, + { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "mm-key", + "LLM_BASE_URL": "https://custom.proxy.example.com/v1", + }, + clear=True, + ) + def test_custom_base_url_overrides_preset(self): + llm = create_llm() + assert "custom.proxy.example.com" in str(llm.openai_api_base) + + @patch.dict( + os.environ, + { + "OPENAI_API_KEY": "sk-key", + "LLM_MODEL": "gpt-5.4-pro", + }, + clear=True, + ) + def test_openai_custom_model(self): + llm = create_llm() + assert llm.model_name == "gpt-5.4-pro" + + @patch.dict( + os.environ, + { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "mm-key", + "LLM_TEMPERATURE": "0.7", + }, + clear=True, + ) + def test_minimax_normal_temperature_passes_through(self): + llm = create_llm() + assert llm.temperature == 0.7 + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + """Edge case tests for provider factory.""" + + @patch.dict( + os.environ, + {"LLM_PROVIDER": "unknown_provider", "OPENAI_API_KEY": "sk-key"}, + clear=True, + ) + def test_unknown_provider_falls_back_to_defaults(self): + llm = create_llm() + # Should still create an LLM with default model + assert llm.model_name == "gpt-5.4-2026-03-05" + + @patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "mm-key", + "OPENAI_API_KEY": "sk-key", + "LLM_PROVIDER": "", + }, + clear=True, + ) + def test_empty_provider_triggers_auto_detect(self): + llm = create_llm() + assert llm.model_name == "MiniMax-M2.7" + + @patch.dict( + os.environ, + { + "LLM_PROVIDER": "minimax", + "OPENAI_API_KEY": "sk-fallback", + }, + clear=True, + ) + def test_minimax_provider_falls_back_to_openai_key(self): + """When MINIMAX_API_KEY is unset but LLM_PROVIDER=minimax, use OPENAI_API_KEY as fallback.""" + llm = create_llm() + assert llm.model_name == "MiniMax-M2.7" diff --git a/apps/agent/tests/test_llm_provider_integration.py b/apps/agent/tests/test_llm_provider_integration.py new file mode 100644 index 0000000..e685b1e --- /dev/null +++ b/apps/agent/tests/test_llm_provider_integration.py @@ -0,0 +1,56 @@ +""" +Integration tests for the MiniMax LLM provider. + +These tests call the real MiniMax API and are skipped when MINIMAX_API_KEY +is not set. Run with: + + MINIMAX_API_KEY= pytest tests/test_llm_provider_integration.py -v +""" + +import os + +import pytest +from langchain_core.messages import HumanMessage + +from src.llm_provider import create_llm + +pytestmark = pytest.mark.skipif( + not os.environ.get("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set — skipping MiniMax integration tests", +) + + +@pytest.fixture +def minimax_llm(): + """Create a MiniMax LLM instance for testing.""" + os.environ.setdefault("LLM_PROVIDER", "minimax") + return create_llm() + + +class TestMiniMaxIntegration: + """Integration tests against the real MiniMax API.""" + + def test_basic_chat_completion(self, minimax_llm): + """Verify a simple chat completion returns a non-empty response.""" + result = minimax_llm.invoke([HumanMessage(content="Say 'hello' and nothing else.")]) + assert result.content + assert "hello" in result.content.lower() + + def test_streaming_response(self, minimax_llm): + """Verify streaming produces at least one chunk.""" + chunks = list(minimax_llm.stream([HumanMessage(content="Count from 1 to 3.")])) + assert len(chunks) > 0 + full_text = "".join(c.content for c in chunks) + assert "1" in full_text + + def test_multi_turn_conversation(self, minimax_llm): + """Verify the model handles multi-turn conversations.""" + from langchain_core.messages import AIMessage + + messages = [ + HumanMessage(content="My name is Alice."), + AIMessage(content="Nice to meet you, Alice!"), + HumanMessage(content="What is my name?"), + ] + result = minimax_llm.invoke(messages) + assert "alice" in result.content.lower()