diff --git a/.env.example b/.env.example index 3c0d4b35..016dfba9 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ OPENAI_API_KEY= # For OpenAI-compatible providers (OpenRouter, LiteLLM, vLLM, etc.): # OPENAI_BASE_URL=https://openrouter.ai/api/v1 # EMBEDDING_MODEL=openai/text-embedding-3-small + +# --- MiniMax LLM provider (alternative to OpenAI for classification/summarization) --- +# MINIMAX_API_KEY= # MiniMax API key (get one at https://platform.minimaxi.com) +# LLM_PROVIDER=auto # auto|openai|minimax — auto tries OPENAI_API_KEY, then MINIMAX_API_KEY +# CLASSIFICATION_MODEL=MiniMax-M2.7 # Default model when using MiniMax (204K context) AUTOMEM_API_TOKEN= ADMIN_API_TOKEN= ENABLE_GRAPH_VIEWER=true diff --git a/CLAUDE.md b/CLAUDE.md index b721778c..4ee9f851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,6 +238,10 @@ EMBEDDING_PROVIDER=auto # auto|voyage|openai|ollama|local|placeholder OPENAI_API_KEY= # For OpenAI or compatible provider (optional) OPENAI_BASE_URL= # Custom endpoint for OpenAI-compatible APIs used by embeddings and classification/enrichment (optional) +# LLM provider for classification/summarization (separate from embedding) +LLM_PROVIDER=auto # auto|openai|minimax — auto tries OPENAI_API_KEY, then MINIMAX_API_KEY +MINIMAX_API_KEY= # MiniMax API key for classification/summarization (optional) + # Consolidation intervals (seconds) CONSOLIDATION_DECAY_INTERVAL_SECONDS=86400 # 1 day (default) CONSOLIDATION_DECAY_IMPORTANCE_THRESHOLD=0.3 # Only skip truly low-importance items diff --git a/INSTALLATION.md b/INSTALLATION.md index dd1bd179..40c02a2b 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -395,6 +395,8 @@ Admin operations additionally require `X-Admin-Token: ` header. | `VOYAGE_MODEL` | Voyage model (Voyage provider) | `voyage-4` | | `OPENAI_API_KEY` | API key (OpenAI or compatible provider) | _unset_ | | `OPENAI_BASE_URL` | Custom endpoint for OpenAI-compatible providers | _unset_ | +| `LLM_PROVIDER` | LLM provider for classification/summarization (`auto`, `openai`, `minimax`) | `auto` | +| `MINIMAX_API_KEY` | MiniMax API key (alternative to OpenAI for classification/summarization) | _unset_ | 👉 **New to Qdrant?** See the [Qdrant Setup Guide](docs/QDRANT_SETUP.md) for setup options (self-hosted on Railway or Qdrant Cloud). diff --git a/README.md b/README.md index e2730e6c..0c1f2418 100644 --- a/README.md +++ b/README.md @@ -592,6 +592,8 @@ Run benchmarks: `make bench-eval BENCH=locomo-mini CONFIG=baseline` (quick) or ` - `VOYAGE_API_KEY` / `VOYAGE_MODEL` - Voyage embeddings (if using `voyage`) - `OPENAI_API_KEY` - OpenAI embeddings (if using `openai`) - `OPENAI_BASE_URL` - Custom endpoint for OpenAI-compatible providers (OpenRouter, LiteLLM, vLLM, etc.) +- `MINIMAX_API_KEY` - [MiniMax](https://platform.minimaxi.com) API key for classification/summarization LLM +- `LLM_PROVIDER` - Choose `auto`, `openai`, or `minimax` for classification/summarization LLM (`auto` tries OpenAI then MiniMax) - `OLLAMA_BASE_URL` / `OLLAMA_MODEL` - Ollama embeddings (if using `ollama`) - `ADMIN_API_TOKEN` - Required for `/admin/reembed` and enrichment controls - Consolidation tuning: `CONSOLIDATION_*_INTERVAL_SECONDS` diff --git a/automem/classification/memory_classifier.py b/automem/classification/memory_classifier.py index 3ae093a0..a6bbbd00 100644 --- a/automem/classification/memory_classifier.py +++ b/automem/classification/memory_classifier.py @@ -4,6 +4,12 @@ import re from typing import Any, Callable, Optional +# MiniMax model prefixes that require temperature clamping to (0, 1] +_MINIMAX_PREFIXES = ("MiniMax-", "minimax-", "abab") + +# Regex to strip blocks from model responses (used by MiniMax M2.5+) +_THINK_TAG_RE = re.compile(r".*?\s*", re.DOTALL) + class MemoryClassifier: """Classifies memories into specific types based on content patterns.""" @@ -139,11 +145,17 @@ def _classify_with_llm(self, content: str) -> Optional[tuple[str, float]]: uses_max_completion_tokens = self._classification_model.startswith( self.MAX_COMPLETION_PREFIXES ) + is_minimax = self._classification_model.startswith(_MINIMAX_PREFIXES) + if uses_max_completion_tokens: extra_params["max_completion_tokens"] = 50 else: extra_params["max_tokens"] = 50 - extra_params["temperature"] = 0.3 + temperature = 0.3 + # MiniMax requires temperature in (0, 1] + if is_minimax: + temperature = max(0.01, min(temperature, 1.0)) + extra_params["temperature"] = temperature response = client.chat.completions.create( model=self._classification_model, @@ -160,6 +172,13 @@ def _classify_with_llm(self, content: str) -> Optional[tuple[str, float]]: self._logger.warning("LLM returned empty classification response") return None + # Strip blocks (MiniMax M2.5+ reasoning traces) + if is_minimax: + raw_content = _THINK_TAG_RE.sub("", raw_content).strip() + if not raw_content: + self._logger.warning("LLM response was only thinking tags") + return None + try: result = json.loads(raw_content) except (json.JSONDecodeError, TypeError): diff --git a/automem/config.py b/automem/config.py index cbeb5089..2d12a409 100644 --- a/automem/config.py +++ b/automem/config.py @@ -112,6 +112,15 @@ EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") CLASSIFICATION_MODEL = os.getenv("CLASSIFICATION_MODEL", "gpt-4o-mini") +# LLM provider for classification/summarization +# "auto" (default): Use OPENAI_API_KEY if set, else MINIMAX_API_KEY +# "openai": Use OpenAI explicitly +# "minimax": Use MiniMax explicitly (OpenAI-compatible API at api.minimax.io) +LLM_PROVIDER = os.getenv("LLM_PROVIDER", "auto").strip().lower() +MINIMAX_API_KEY: str | None = os.getenv("MINIMAX_API_KEY") +MINIMAX_BASE_URL = "https://api.minimax.io/v1" +MINIMAX_DEFAULT_MODEL = "MiniMax-M2.7" + RECALL_RELATION_LIMIT = int(os.getenv("RECALL_RELATION_LIMIT", "5")) RECALL_EXPANSION_LIMIT = int(os.getenv("RECALL_EXPANSION_LIMIT", "25")) RECALL_MIN_SCORE = float(os.getenv("RECALL_MIN_SCORE", "0.0")) diff --git a/automem/service_runtime.py b/automem/service_runtime.py index 01621488..8d05c811 100644 --- a/automem/service_runtime.py +++ b/automem/service_runtime.py @@ -2,6 +2,8 @@ from typing import Any, Callable +from automem.config import MINIMAX_BASE_URL, MINIMAX_DEFAULT_MODEL + def init_openai( *, @@ -10,7 +12,13 @@ def init_openai( openai_cls: Any, get_env_fn: Callable[[str], str | None], ) -> None: - """Initialize OpenAI client for memory type classification (not embeddings).""" + """Initialize OpenAI-compatible client for memory type classification (not embeddings). + + Supports multiple LLM providers via the ``LLM_PROVIDER`` env var: + - ``auto`` (default): Use OPENAI_API_KEY if set, else MINIMAX_API_KEY + - ``openai``: Use OpenAI explicitly + - ``minimax``: Use MiniMax explicitly (OpenAI-compatible API at api.minimax.io) + """ if state.openai_client is not None: return @@ -18,21 +26,79 @@ def init_openai( logger.info("OpenAI package not installed (used for memory type classification)") return + llm_provider = (get_env_fn("LLM_PROVIDER") or "auto").strip().lower() + + if llm_provider == "minimax": + api_key = get_env_fn("MINIMAX_API_KEY") + if not api_key: + logger.info("LLM_PROVIDER=minimax but MINIMAX_API_KEY not set") + return + try: + state.openai_client = openai_cls(api_key=api_key, base_url=MINIMAX_BASE_URL) + state.llm_provider = "minimax" + logger.info( + "MiniMax client initialized for memory type classification " + "(model: %s)", + get_env_fn("CLASSIFICATION_MODEL") or MINIMAX_DEFAULT_MODEL, + ) + except Exception: + logger.exception("Failed to initialize MiniMax client") + state.openai_client = None + return + + if llm_provider == "openai": + api_key = get_env_fn("OPENAI_API_KEY") + if not api_key: + logger.info("LLM_PROVIDER=openai but OPENAI_API_KEY not set") + return + try: + openai_base_url = (get_env_fn("OPENAI_BASE_URL") or "").strip() or None + client_kwargs: dict[str, Any] = {"api_key": api_key} + if openai_base_url: + client_kwargs["base_url"] = openai_base_url + state.openai_client = openai_cls(**client_kwargs) + state.llm_provider = "openai" + logger.info("OpenAI client initialized for memory type classification") + except Exception: + logger.exception("Failed to initialize OpenAI client") + state.openai_client = None + return + + # Auto-detection: try OPENAI_API_KEY first, then MINIMAX_API_KEY api_key = get_env_fn("OPENAI_API_KEY") - if not api_key: - logger.info("OpenAI API key not provided (used for memory type classification)") + if api_key: + try: + openai_base_url = (get_env_fn("OPENAI_BASE_URL") or "").strip() or None + client_kwargs = {"api_key": api_key} + if openai_base_url: + client_kwargs["base_url"] = openai_base_url + state.openai_client = openai_cls(**client_kwargs) + state.llm_provider = "openai" + logger.info("OpenAI client initialized for memory type classification") + except Exception: + logger.exception("Failed to initialize OpenAI client") + state.openai_client = None + return + + minimax_key = get_env_fn("MINIMAX_API_KEY") + if minimax_key: + try: + state.openai_client = openai_cls(api_key=minimax_key, base_url=MINIMAX_BASE_URL) + state.llm_provider = "minimax" + logger.info( + "MiniMax client auto-detected for memory type classification " + "(model: %s)", + get_env_fn("CLASSIFICATION_MODEL") or MINIMAX_DEFAULT_MODEL, + ) + except Exception: + logger.exception("Failed to initialize MiniMax client") + state.openai_client = None return - try: - openai_base_url = (get_env_fn("OPENAI_BASE_URL") or "").strip() or None - client_kwargs = {"api_key": api_key} - if openai_base_url: - client_kwargs["base_url"] = openai_base_url - state.openai_client = openai_cls(**client_kwargs) - logger.info("OpenAI client initialized for memory type classification") - except Exception: - logger.exception("Failed to initialize OpenAI client") - state.openai_client = None + logger.info( + "No LLM API key provided for classification " + "(set OPENAI_API_KEY or MINIMAX_API_KEY)" + ) def get_memory_graph(*, state: Any, init_falkordb_fn: Callable[[], None]) -> Any: diff --git a/automem/utils/text.py b/automem/utils/text.py index 2c4a4c64..e6121e08 100644 --- a/automem/utils/text.py +++ b/automem/utils/text.py @@ -6,6 +6,12 @@ logger = logging.getLogger(__name__) +# MiniMax model prefixes that require temperature clamping to (0, 1] +_MINIMAX_PREFIXES = ("MiniMax-", "minimax-", "abab") + +# Regex to strip blocks from model responses (used by MiniMax M2.5+) +_THINK_TAG_RE = re.compile(r".*?\s*", re.DOTALL) + # Common stopwords to exclude from search tokens SEARCH_STOPWORDS = { "the", @@ -149,11 +155,16 @@ def summarize_content( # Build model-specific params (o-series and gpt-5 don't support temperature) extra_params: dict = {} + is_minimax = model.startswith(_MINIMAX_PREFIXES) if model.startswith(("o", "gpt-5")): extra_params["max_completion_tokens"] = token_limit else: extra_params["max_tokens"] = token_limit - extra_params["temperature"] = 0.3 + temperature = 0.3 + # MiniMax requires temperature in (0, 1] + if is_minimax: + temperature = max(0.01, min(temperature, 1.0)) + extra_params["temperature"] = temperature response = openai_client.chat.completions.create( model=model, @@ -166,6 +177,10 @@ def summarize_content( summary = response.choices[0].message.content.strip() + # Strip blocks (MiniMax M2.5+ reasoning traces) + if is_minimax and summary: + summary = _THINK_TAG_RE.sub("", summary).strip() + # Validate we actually got a shorter result if summary and len(summary) < len(content): logger.info( diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index d0ffffe3..eedfcc37 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -239,7 +239,9 @@ Controls embedding provider and classification model settings. | `EMBEDDING_MODEL` | OpenAI embedding model (used when provider is `openai`) | `text-embedding-3-small` | `text-embedding-3-small`, `text-embedding-3-large` | | `VECTOR_SIZE` | Embedding dimension | `1024` | Must match embedding provider (1024=voyage-4, 1536=text-embedding-3-small native; choose ≤1536 when truncating, 3072=text-embedding-3-large native) | | `VECTOR_SIZE_AUTODETECT` | Adopt existing collection dimension instead of failing on mismatch | `true` | `false` to enforce strict matching | -| `CLASSIFICATION_MODEL` | LLM for memory type classification | `gpt-4o-mini` | `gpt-4o-mini`, `gpt-4.1`, `gpt-5.1` | +| `CLASSIFICATION_MODEL` | LLM for memory type classification | `gpt-4o-mini` | `gpt-4o-mini`, `gpt-4.1`, `gpt-5.1`, `MiniMax-M2.7` | +| `LLM_PROVIDER` | LLM provider for classification/summarization | `auto` | `auto`, `openai`, `minimax` | +| `MINIMAX_API_KEY` | MiniMax API key (for MiniMax LLM provider) | _unset_ | Get one at [platform.minimaxi.com](https://platform.minimaxi.com) | **Embedding Provider Comparison:** @@ -272,6 +274,24 @@ VECTOR_SIZE=768 | `gpt-4o-mini` | $0.15/1M | $0.60/1M | **Default** - Good enough for classification | | `gpt-4.1` | ~$2/1M | ~$8/1M | Better reasoning | | `gpt-5.1` | $1.25/1M | $10/1M | Best reasoning, use for benchmarks | +| `MiniMax-M2.7` | ~$0.14/1M | ~$0.56/1M | MiniMax default, 204K context, OpenAI-compatible | +| `MiniMax-M2.7-highspeed` | ~$0.07/1M | ~$0.28/1M | Faster variant, 204K context | + +**LLM Provider for Classification/Summarization:** + +The `LLM_PROVIDER` env var controls which API backend is used for memory type +classification and auto-summarization (separate from the embedding provider): + +- `auto` (default): Use `OPENAI_API_KEY` if set, otherwise fall back to `MINIMAX_API_KEY` +- `openai`: Use OpenAI explicitly (requires `OPENAI_API_KEY`) +- `minimax`: Use [MiniMax](https://platform.minimaxi.com) explicitly (requires `MINIMAX_API_KEY`) + +MiniMax example: +```bash +LLM_PROVIDER=minimax +MINIMAX_API_KEY=eyJ... +CLASSIFICATION_MODEL=MiniMax-M2.7 +``` **⚠️ Changing embedding models requires re-embedding all memories.** See [Re-embedding Guide](#re-embedding-memories) below. diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py new file mode 100644 index 00000000..4999c548 --- /dev/null +++ b/tests/test_minimax_integration.py @@ -0,0 +1,128 @@ +"""Integration tests for MiniMax LLM provider. + +These tests require a valid MINIMAX_API_KEY environment variable. +They are skipped when the key is not available. + +Run with: + MINIMAX_API_KEY=your-key pytest tests/test_minimax_integration.py -v +""" + +import json +import os + +import pytest + +pytestmark = pytest.mark.integration + +MINIMAX_API_KEY = os.getenv("MINIMAX_API_KEY") +skip_no_key = pytest.mark.skipif( + not MINIMAX_API_KEY, + reason="MINIMAX_API_KEY not set — skipping MiniMax integration tests", +) + + +@skip_no_key +class TestMiniMaxLiveClassification: + """Integration tests for MiniMax-powered memory type classification.""" + + def _make_classifier(self): + """Create a MemoryClassifier wired to the real MiniMax API.""" + import openai + + from automem.classification.memory_classifier import MemoryClassifier + from automem.config import MINIMAX_BASE_URL, MINIMAX_DEFAULT_MODEL, normalize_memory_type + + client = openai.OpenAI( + api_key=MINIMAX_API_KEY, + base_url=MINIMAX_BASE_URL, + ) + + import logging + + logger = logging.getLogger("test_minimax_integration") + + classifier = MemoryClassifier( + normalize_memory_type=normalize_memory_type, + ensure_openai_client=lambda: None, + get_openai_client=lambda: client, + classification_model=MINIMAX_DEFAULT_MODEL, + logger=logger, + ) + return classifier + + def test_classify_decision(self): + """MiniMax should classify a decision-type memory.""" + classifier = self._make_classifier() + # Content that won't match regex patterns + content = ( + "After evaluating the options, the team committed to " + "adopting PostgreSQL as the primary database for the new service." + ) + memory_type, confidence = classifier.classify(content, use_llm=True) + assert memory_type in {"Decision", "Context", "Insight"}, ( + f"Expected Decision/Context/Insight, got {memory_type}" + ) + assert 0.0 < confidence <= 1.0 + + def test_classify_returns_valid_type(self): + """MiniMax should return a valid canonical memory type.""" + from automem.config import MEMORY_TYPES + + classifier = self._make_classifier() + content = "The CI pipeline keeps failing because of flaky network tests." + memory_type, confidence = classifier.classify(content, use_llm=True) + # Should be a canonical type or "Memory" (fallback) + assert memory_type in MEMORY_TYPES or memory_type == "Memory", ( + f"Got non-canonical type: {memory_type}" + ) + assert 0.0 <= confidence <= 1.0 + + def test_classify_returns_json_with_confidence(self): + """MiniMax should return a valid confidence score.""" + classifier = self._make_classifier() + content = "I notice I always check email first thing in the morning before coding." + memory_type, confidence = classifier.classify(content, use_llm=True) + assert isinstance(confidence, float) + assert 0.0 <= confidence <= 1.0 + + +@skip_no_key +class TestMiniMaxLiveSummarization: + """Integration tests for MiniMax-powered auto-summarization.""" + + def test_summarize_long_content(self): + """MiniMax should summarize long content to a shorter string.""" + import openai + + from automem.config import MINIMAX_BASE_URL, MINIMAX_DEFAULT_MODEL + from automem.utils.text import summarize_content + + client = openai.OpenAI( + api_key=MINIMAX_API_KEY, + base_url=MINIMAX_BASE_URL, + ) + + long_content = ( + "During the architecture review meeting on January 15th, the team discussed " + "the pros and cons of migrating from a monolithic architecture to microservices. " + "The main arguments in favor included independent deployment, technology diversity, " + "and team autonomy. The concerns raised were about increased operational complexity, " + "data consistency challenges, and the need for better monitoring infrastructure. " + "After a two-hour discussion, the team decided to start with a pilot project " + "by extracting the notification service as the first microservice, while keeping " + "the rest of the system as a monolith for now. The target date for the pilot " + "was set for March 1st, with a go/no-go decision after a month of production use." + ) + + result = summarize_content( + long_content, + client, + MINIMAX_DEFAULT_MODEL, + target_length=200, + ) + + assert result is not None, "Summarization returned None" + assert len(result) < len(long_content), ( + f"Summary ({len(result)} chars) not shorter than original ({len(long_content)} chars)" + ) + assert len(result) > 0, "Summary is empty" diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 00000000..a1be45b6 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,554 @@ +"""Tests for MiniMax LLM provider integration. + +Covers: +- init_openai() with MINIMAX_API_KEY auto-detection and explicit LLM_PROVIDER +- Temperature clamping for MiniMax models in classification and summarization +- tag stripping from MiniMax responses +- Default model selection when using MiniMax +""" + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from automem.classification.memory_classifier import ( + MemoryClassifier, + _MINIMAX_PREFIXES, + _THINK_TAG_RE, +) +from automem.config import MINIMAX_BASE_URL, MINIMAX_DEFAULT_MODEL +from automem.service_runtime import init_openai +from automem.utils.text import summarize_content + + +# --------------------------------------------------------------------------- +# init_openai() — MiniMax provider selection +# --------------------------------------------------------------------------- + + +class TestInitOpenAIMiniMax: + """Test MiniMax auto-detection and explicit provider selection in init_openai().""" + + def test_minimax_explicit_provider(self): + """LLM_PROVIDER=minimax should use MINIMAX_API_KEY and MiniMax base URL.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock() + openai_client = Mock() + openai_cls.return_value = openai_client + + env = { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "minimax-test-key", + } + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_called_once_with( + api_key="minimax-test-key", + base_url=MINIMAX_BASE_URL, + ) + assert state.openai_client is openai_client + assert state.llm_provider == "minimax" + + def test_minimax_explicit_provider_no_key(self): + """LLM_PROVIDER=minimax without MINIMAX_API_KEY should not initialize.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock() + + env = {"LLM_PROVIDER": "minimax"} + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_not_called() + assert state.openai_client is None + + def test_minimax_auto_detection(self): + """Auto mode with only MINIMAX_API_KEY should use MiniMax.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock() + openai_client = Mock() + openai_cls.return_value = openai_client + + env = { + "LLM_PROVIDER": "auto", + "MINIMAX_API_KEY": "minimax-test-key", + } + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_called_once_with( + api_key="minimax-test-key", + base_url=MINIMAX_BASE_URL, + ) + assert state.openai_client is openai_client + assert state.llm_provider == "minimax" + + def test_auto_prefers_openai_over_minimax(self): + """Auto mode with both keys should prefer OpenAI.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock() + openai_client = Mock() + openai_cls.return_value = openai_client + + env = { + "LLM_PROVIDER": "auto", + "OPENAI_API_KEY": "openai-test-key", + "MINIMAX_API_KEY": "minimax-test-key", + } + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_called_once_with(api_key="openai-test-key") + assert state.openai_client is openai_client + assert state.llm_provider == "openai" + + def test_openai_explicit_provider(self): + """LLM_PROVIDER=openai should use OPENAI_API_KEY.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock() + openai_client = Mock() + openai_cls.return_value = openai_client + + env = { + "LLM_PROVIDER": "openai", + "OPENAI_API_KEY": "openai-test-key", + "OPENAI_BASE_URL": "", + } + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_called_once_with(api_key="openai-test-key") + assert state.openai_client is openai_client + assert state.llm_provider == "openai" + + def test_openai_explicit_with_base_url(self): + """LLM_PROVIDER=openai with OPENAI_BASE_URL should pass base_url.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock() + openai_client = Mock() + openai_cls.return_value = openai_client + + env = { + "LLM_PROVIDER": "openai", + "OPENAI_API_KEY": "openai-test-key", + "OPENAI_BASE_URL": "https://custom.endpoint/v1", + } + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_called_once_with( + api_key="openai-test-key", + base_url="https://custom.endpoint/v1", + ) + assert state.openai_client is openai_client + + def test_no_keys_logs_message(self): + """Auto mode with no API keys should log a helpful message.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock() + + env = {"LLM_PROVIDER": "auto"} + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_not_called() + assert state.openai_client is None + # Should log about missing keys + logger.info.assert_called() + log_msg = logger.info.call_args[0][0] + assert "MINIMAX_API_KEY" in log_msg or "OPENAI_API_KEY" in log_msg + + def test_minimax_init_failure_handled(self): + """If MiniMax client initialization fails, state.openai_client stays None.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + openai_cls = Mock(side_effect=RuntimeError("connection failed")) + + env = { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "minimax-test-key", + } + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + assert state.openai_client is None + + def test_already_initialized_skips(self): + """If client is already initialized, skip re-initialization.""" + existing_client = Mock() + state = SimpleNamespace(openai_client=existing_client) + logger = Mock() + openai_cls = Mock() + + env = {"LLM_PROVIDER": "minimax", "MINIMAX_API_KEY": "key"} + + init_openai( + state=state, + logger=logger, + openai_cls=openai_cls, + get_env_fn=env.get, + ) + + openai_cls.assert_not_called() + assert state.openai_client is existing_client + + def test_no_openai_package(self): + """If openai package is not installed, log and skip.""" + state = SimpleNamespace(openai_client=None) + logger = Mock() + + env = {"LLM_PROVIDER": "minimax", "MINIMAX_API_KEY": "key"} + + init_openai( + state=state, + logger=logger, + openai_cls=None, + get_env_fn=env.get, + ) + + assert state.openai_client is None + + +# --------------------------------------------------------------------------- +# Temperature clamping for MiniMax models +# --------------------------------------------------------------------------- + + +class TestMiniMaxTemperatureClamping: + """Test that MiniMax models get temperature clamped to (0, 1].""" + + def _make_classifier(self, model: str) -> tuple: + """Create a MemoryClassifier and mock client for testing.""" + mock_client = Mock() + logger = Mock() + + classifier = MemoryClassifier( + normalize_memory_type=lambda t: (t, False) if t else ("", True), + ensure_openai_client=lambda: None, + get_openai_client=lambda: mock_client, + classification_model=model, + logger=logger, + ) + return classifier, mock_client + + def test_minimax_model_temperature_clamped(self): + """MiniMax-M2.7 model should have temperature clamped to (0, 1].""" + classifier, mock_client = self._make_classifier("MiniMax-M2.7") + + # Set up mock response + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content='{"type": "Decision", "confidence": 0.9}' + ) + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + result = classifier.classify("This is a test that won't match patterns", use_llm=True) + + # Verify the call was made with clamped temperature + call_kwargs = mock_client.chat.completions.create.call_args + assert "temperature" in call_kwargs.kwargs + temp = call_kwargs.kwargs["temperature"] + assert 0 < temp <= 1.0, f"Temperature {temp} not in (0, 1]" + assert temp == 0.3 # 0.3 is already in range, so stays 0.3 + + def test_openai_model_no_clamping(self): + """OpenAI models should not have special temperature clamping.""" + classifier, mock_client = self._make_classifier("gpt-4o-mini") + + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content='{"type": "Context", "confidence": 0.7}' + ) + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + # gpt-4o-mini starts with "gpt-4o" which is in MAX_COMPLETION_PREFIXES + # so it uses max_completion_tokens, not temperature + result = classifier.classify("Test content for classification", use_llm=True) + + call_kwargs = mock_client.chat.completions.create.call_args + assert "max_completion_tokens" in call_kwargs.kwargs + + def test_minimax_highspeed_model_clamped(self): + """MiniMax-M2.7-highspeed model should also be temperature clamped.""" + classifier, mock_client = self._make_classifier("MiniMax-M2.7-highspeed") + + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content='{"type": "Insight", "confidence": 0.85}' + ) + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + result = classifier.classify("Unique test content 12345", use_llm=True) + + call_kwargs = mock_client.chat.completions.create.call_args + assert "temperature" in call_kwargs.kwargs + temp = call_kwargs.kwargs["temperature"] + assert 0 < temp <= 1.0 + + +# --------------------------------------------------------------------------- +# Think-tag stripping +# --------------------------------------------------------------------------- + + +class TestThinkTagStripping: + """Test tag stripping from MiniMax responses.""" + + def test_think_tag_regex(self): + """The regex should strip blocks.""" + text = 'I need to classify this...{"type": "Decision", "confidence": 0.9}' + cleaned = _THINK_TAG_RE.sub("", text).strip() + assert cleaned == '{"type": "Decision", "confidence": 0.9}' + + def test_multiline_think_tag(self): + """Multi-line think tags should be stripped.""" + text = ( + "\nLet me think about this.\n" + "The content mentions a decision.\n\n" + '{"type": "Decision", "confidence": 0.85}' + ) + cleaned = _THINK_TAG_RE.sub("", text).strip() + assert cleaned == '{"type": "Decision", "confidence": 0.85}' + + def test_no_think_tag(self): + """Content without think tags should be unchanged.""" + text = '{"type": "Context", "confidence": 0.7}' + cleaned = _THINK_TAG_RE.sub("", text).strip() + assert cleaned == text + + def test_classifier_strips_think_tags(self): + """MemoryClassifier should strip think tags from MiniMax responses.""" + mock_client = Mock() + logger = Mock() + + classifier = MemoryClassifier( + normalize_memory_type=lambda t: (t, False) if t else ("", True), + ensure_openai_client=lambda: None, + get_openai_client=lambda: mock_client, + classification_model="MiniMax-M2.7", + logger=logger, + ) + + # Response with think tags + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content='Analyzing...{"type": "Insight", "confidence": 0.88}' + ) + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + result = classifier.classify("Very unique test xyzzy 99999", use_llm=True) + assert result == ("Insight", 0.88) + + def test_classifier_only_think_tags_returns_none(self): + """If response is only think tags, classifier should return None (fallback).""" + mock_client = Mock() + logger = Mock() + + classifier = MemoryClassifier( + normalize_memory_type=lambda t: (t, False) if t else ("", True), + ensure_openai_client=lambda: None, + get_openai_client=lambda: mock_client, + classification_model="MiniMax-M2.7", + logger=logger, + ) + + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content="Just thinking, no actual response." + ) + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + # Should fall back to default since LLM returns None + result = classifier.classify("Unique content abcdef 11111", use_llm=True) + assert result == ("Memory", 0.3) # fallback + + def test_openai_model_no_think_tag_strip(self): + """Non-MiniMax models should not attempt think-tag stripping.""" + mock_client = Mock() + logger = Mock() + + classifier = MemoryClassifier( + normalize_memory_type=lambda t: (t, False) if t else ("", True), + ensure_openai_client=lambda: None, + get_openai_client=lambda: mock_client, + classification_model="gpt-3.5-turbo", + logger=logger, + ) + + # Response without think tags, normal model + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content='{"type": "Pattern", "confidence": 0.75}' + ) + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + result = classifier.classify("Unique test qwerty 22222", use_llm=True) + assert result == ("Pattern", 0.75) + + +# --------------------------------------------------------------------------- +# Summarization with MiniMax +# --------------------------------------------------------------------------- + + +class TestMiniMaxSummarization: + """Test summarize_content() with MiniMax models.""" + + def test_summarize_minimax_model_temperature_clamped(self): + """summarize_content() should clamp temperature for MiniMax models.""" + mock_client = Mock() + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="Short summary of content.") + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + long_content = "A" * 600 # exceeds default target_length + + result = summarize_content(long_content, mock_client, "MiniMax-M2.7") + + call_kwargs = mock_client.chat.completions.create.call_args + assert "temperature" in call_kwargs.kwargs + temp = call_kwargs.kwargs["temperature"] + assert 0 < temp <= 1.0 + + def test_summarize_minimax_strips_think_tags(self): + """summarize_content() should strip think tags from MiniMax responses.""" + mock_client = Mock() + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content="Let me summarize...Short summary here." + ) + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + long_content = "B" * 600 + + result = summarize_content(long_content, mock_client, "MiniMax-M2.7") + assert result == "Short summary here." + + def test_summarize_non_minimax_no_strip(self): + """Non-MiniMax models should not strip think tags in summarization.""" + mock_client = Mock() + mock_response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="A good summary of the content.") + ) + ] + ) + mock_client.chat.completions.create.return_value = mock_response + + long_content = "C" * 600 + + result = summarize_content(long_content, mock_client, "gpt-3.5-turbo") + assert result == "A good summary of the content." + + +# --------------------------------------------------------------------------- +# Config constants +# --------------------------------------------------------------------------- + + +class TestConfigConstants: + """Test that MiniMax config constants are correct.""" + + def test_minimax_base_url(self): + assert MINIMAX_BASE_URL == "https://api.minimax.io/v1" + + def test_minimax_default_model(self): + assert MINIMAX_DEFAULT_MODEL == "MiniMax-M2.7" + + def test_minimax_prefixes(self): + assert "MiniMax-" in _MINIMAX_PREFIXES + assert "minimax-" in _MINIMAX_PREFIXES + + def test_minimax_model_detected(self): + """MiniMax model names should be detected by the prefix check.""" + for model in ("MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"): + assert model.startswith(_MINIMAX_PREFIXES), f"{model} not detected"