diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4e5860420c14..e8e3e4737755 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -236,6 +236,7 @@ def _fixed_temperature_for_model( _PROVIDER_VISION_MODELS: Dict[str, str] = { "xiaomi": "mimo-v2.5", "zai": "glm-5v-turbo", + "nous": "xiaomi/mimo-v2-omni", } # Providers whose endpoint does not accept image input, even though the @@ -1857,6 +1858,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): return AsyncCodexAuxiliaryClient(sync_client), model if isinstance(sync_client, AnthropicAuxiliaryClient): return AsyncAnthropicAuxiliaryClient(sync_client), model + if isinstance(sync_client, _MiniMaxCodingPlanVisionClient): + return _AsyncMiniMaxCodingPlanVisionClient(sync_client), model try: from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient @@ -2432,11 +2435,145 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di _VISION_AUTO_PROVIDER_ORDER = ( + "minimax-coding-plan", "openrouter", "nous", ) +class _MiniMaxCodingPlanVisionClient: + """OpenAI-compatible wrapper for MiniMax Coding Plan VLM API. + + Wraps POST /v1/coding_plan/vlm to look like chat.completions.create(). + Used by vision_analyze when auxiliary.vision.provider=minimax-coding-plan. + """ + + def __init__(self, api_key: str, api_host: str = "https://api.minimax.io"): + import httpx as _httpx + self.api_key = api_key + self.api_host = api_host.rstrip("/") + self.base_url = self.api_host + self._httpx = _httpx + # Nested attribute access for OpenAI-compatible interface + self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create)) + + def _create(self, *, model: str = None, messages: list = None, + temperature: float = None, max_tokens: int = None, + stream: bool = False, **kwargs): + """Emulate OpenAI chat.completions.create() using MiniMax VLM API.""" + if stream: + raise NotImplementedError( + "Streaming is not supported by the MiniMax Coding Plan VLM endpoint. " + "Use stream=False (the default)." + ) + # Extract prompt and image from OpenAI-format messages + prompt_parts = [] + image_url = None + for msg in (messages or []): + content = msg.get("content", "") + if isinstance(content, str): + prompt_parts.append(content) + elif isinstance(content, list): + for part in content: + if part.get("type") == "text": + prompt_parts.append(part.get("text", "")) + elif part.get("type") == "image_url": + url = part.get("image_url", {}) + if isinstance(url, dict): + image_url = url.get("url", "") + else: + image_url = str(url) + + prompt = "\n".join(prompt_parts).strip() or "Describe this image." + if not image_url: + raise ValueError("MiniMax Coding Plan VLM requires an image_url") + + # Convert local file paths to base64 data URIs + if image_url and not image_url.startswith(("data:", "http://", "https://")): + import os as _os + if _os.path.isfile(image_url): + import base64 as _b64 + with open(image_url, "rb") as _f: + raw = _f.read() + ext = _os.path.splitext(image_url)[1].lower() + mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".png": "image/png", ".webp": "image/webp", + ".gif": "image/gif"}.get(ext, "image/jpeg") + image_url = f"data:{mime};base64,{_b64.b64encode(raw).decode()}" + + # Call VLM API + import json as _json + with self._httpx.Client(timeout=120) as _client: + resp = _client.post( + f"{self.api_host}/v1/coding_plan/vlm", + headers={ + "Authorization": f"Bearer {self.api_key}", + "MM-API-Source": "Hermes-Agent", + "Content-Type": "application/json", + }, + content=_json.dumps({"prompt": prompt, "image_url": image_url}), + ) + resp.raise_for_status() + data = resp.json() + content_text = data.get("content", "") + + # Return OpenAI-compatible response object + return SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace( + role="assistant", + content=content_text, + ), + finish_reason="stop", + )], + usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), + ) + + +class _AsyncMiniMaxCodingPlanVisionClient: + """Async wrapper for _MiniMaxCodingPlanVisionClient. + + The async_call_llm() pipeline expects awaitable chat.completions.create(). + This wraps the sync client with asyncio.to_thread so it works with await. + """ + + def __init__(self, sync_client: "_MiniMaxCodingPlanVisionClient"): + self._sync = sync_client + self.api_key = sync_client.api_key + self.base_url = sync_client.base_url + self.chat = SimpleNamespace( + completions=SimpleNamespace(create=self._async_create) + ) + + async def _async_create(self, **kwargs): + import asyncio + return await asyncio.to_thread(self._sync.chat.completions.create, **kwargs) + + +def _try_minimax_coding_plan_vision() -> Tuple[Optional[Any], Optional[str]]: + """Try MiniMax Coding Plan VLM for vision tasks. + + Uses the Coding Plan API key (MINIMAX_API_KEY) and calls + POST /v1/coding_plan/vlm which is NOT OpenAI-compatible. + Returns a wrapper client that emulates the OpenAI interface. + """ + api_key = os.environ.get("MINIMAX_API_KEY", "").strip() + if not api_key: + return None, None + + # Check coding plan env or default + api_host = os.environ.get("MINIMAX_API_HOST", "https://api.minimax.io").strip() + model = "MiniMax-CodingPlan-VLM" + + logger.debug("Auxiliary vision client: minimax-coding-plan VLM at %s", api_host) + try: + client = _MiniMaxCodingPlanVisionClient(api_key=api_key, api_host=api_host) + return client, model + except Exception: + return None, None + + + def _normalize_vision_provider(provider: Optional[str]) -> str: return _normalize_aux_provider(provider) @@ -2448,6 +2585,8 @@ def _resolve_strict_vision_backend( provider = _normalize_vision_provider(provider) if provider == "copilot": return resolve_provider_client("copilot", model, is_vision=True) + if provider == "minimax-coding-plan": + return _try_minimax_coding_plan_vision() if provider == "openrouter": return _try_openrouter() if provider == "nous": @@ -3536,7 +3675,13 @@ def extract_content_or_reasoning(response) -> str: reasoning_parts.append(summary.strip() if isinstance(summary, str) else str(summary)) if reasoning_parts: - return "\n\n".join(reasoning_parts) + joined = "\n\n".join(reasoning_parts) + return re.sub( + r"<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>" + r".*?" + r"", + "", joined, flags=re.DOTALL | re.IGNORECASE, + ).strip() or joined.strip() return "" diff --git a/agent/title_generator.py b/agent/title_generator.py index 3f617093c0b6..91e87503b3e1 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -8,7 +8,7 @@ import threading from typing import Callable, Optional -from agent.auxiliary_client import call_llm +from agent.auxiliary_client import call_llm, extract_content_or_reasoning logger = logging.getLogger(__name__) @@ -61,7 +61,7 @@ def generate_title( timeout=timeout, main_runtime=main_runtime, ) - title = (response.choices[0].message.content or "").strip() + title = extract_content_or_reasoning(response) # Clean up: remove quotes, trailing punctuation, prefixes like "Title: " title = title.strip('"\'') if title.lower().startswith("title:"): diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 7885e99d1e6a..2afe838a929f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -306,6 +306,14 @@ class ProviderConfig: api_key_env_vars=("MINIMAX_CN_API_KEY",), base_url_env_var="MINIMAX_CN_BASE_URL", ), + "minimax-coding-plan": ProviderConfig( + id="minimax-coding-plan", + name="MiniMax Coding Plan", + auth_type="api_key", + inference_base_url="https://api.minimax.io/v1", + api_key_env_vars=("MINIMAX_API_KEY",), + base_url_env_var="MINIMAX_CODING_PLAN_BASE_URL", + ), "deepseek": ProviderConfig( id="deepseek", name="DeepSeek", diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f5ca1a3b2201..c064c94f8ff5 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -298,6 +298,12 @@ def _xai_curated_models() -> list[str]: "MiniMax-M2.1", "MiniMax-M2", ], + "minimax-coding-plan": [ + "MiniMax-M2.7", + "MiniMax-M2.5", + "MiniMax-M2.1", + "MiniMax-M2", + ], "anthropic": [ "claude-opus-4-7", "claude-opus-4-6", @@ -794,6 +800,7 @@ class ProviderEntry(NamedTuple): ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"), ProviderEntry("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"), ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"), + ProviderEntry("minimax-coding-plan", "MiniMax Coding Plan", "MiniMax Coding Plan (api.minimax.io/v1 — OpenAI-compatible)"), ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"), ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models — direct API)"), @@ -3193,16 +3200,23 @@ def validate_requested_model( ), } - # MiniMax providers don't expose a /models endpoint — validate against - # the static catalog instead, similar to openai-codex. - if normalized in ("minimax", "minimax-cn"): + if normalized in ("minimax", "minimax-cn", "minimax-coding-plan"): try: - catalog_models = provider_model_ids(normalized) - except Exception: - catalog_models = [] - if catalog_models: + if normalized == "minimax-coding-plan": + # Local catalog for this provider + catalog = _PROVIDER_MODELS.get("minimax-coding-plan", []) + else: + from agent.models_dev import list_provider_models as _list_models + catalog = _list_models(normalized) or [] + if requested_for_lookup in set(catalog): + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } # Case-insensitive lookup (catalog uses mixed case like MiniMax-M2.7) - catalog_lower = {m.lower(): m for m in catalog_models} + catalog_lower = {m.lower(): m for m in catalog} if requested_for_lookup.lower() in catalog_lower: return { "accepted": True, @@ -3237,6 +3251,8 @@ def validate_requested_model( "\n The model may still work if it exists on the server." ), } + except Exception: + pass # Native Anthropic provider: /v1/models requires x-api-key (or Bearer for # OAuth) plus anthropic-version headers. The generic OpenAI-style probe @@ -3263,13 +3279,10 @@ def validate_requested_model( "corrected_model": auto[0], "message": f"Auto-corrected `{requested}` → `{auto[0]}`", } - suggestions = get_close_matches(requested, anthropic_models, n=3, cutoff=0.5) + suggestions = get_close_matches(requested_for_lookup, anthropic_models, n=3, cutoff=0.5) suggestion_text = "" if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) - # Accept anyway — Anthropic sometimes gates newer/preview models - # (e.g. snapshot IDs, early-access releases) behind accounts - # even though they aren't listed on /v1/models. return { "accepted": True, "persist": True, @@ -3284,7 +3297,6 @@ def validate_requested_model( # network failure. Fall through to the generic warning below. # Anthropic Messages API: many proxies don't implement /v1/models. - # Try probing with correct auth; if it fails, accept with a warning. if api_mode == "anthropic_messages": api_models = fetch_api_models(api_key, base_url, api_mode=api_mode) if api_models is not None: diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 490987095461..9578516371f2 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -120,6 +120,10 @@ class HermesOverlay: transport="anthropic_messages", base_url_env_var="MINIMAX_CN_BASE_URL", ), + "minimax-coding-plan": HermesOverlay( + transport="openai_chat", + base_url_env_var="MINIMAX_CODING_PLAN_BASE_URL", + ), "deepseek": HermesOverlay( transport="openai_chat", base_url_env_var="DEEPSEEK_BASE_URL", diff --git a/tests/agent/test_minimax_coding_plan_vision.py b/tests/agent/test_minimax_coding_plan_vision.py new file mode 100644 index 000000000000..b845028fd055 --- /dev/null +++ b/tests/agent/test_minimax_coding_plan_vision.py @@ -0,0 +1,368 @@ +"""Tests para _MiniMaxCodingPlanVisionClient y _AsyncMiniMaxCodingPlanVisionClient. + +Usa unittest.mock para simular httpx.Client — sin llamadas reales a la API. +""" + +import sys +import os +import asyncio +import unittest.mock +from types import SimpleNamespace + +# Agregar el paquete agent al path para poder importar auxiliary_client +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from agent.auxiliary_client import ( + _MiniMaxCodingPlanVisionClient, + _AsyncMiniMaxCodingPlanVisionClient, +) + + +class TestMiniMaxCodingPlanVisionClient(unittest.TestCase): + """Tests para el cliente síncrono _MiniMaxCodingPlanVisionClient.""" + + def setUp(self): + """Configurar cliente con mocks.""" + self.api_key = "test-api-key-12345" + self.api_host = "https://api.minimax.io" + self.client = _MiniMaxCodingPlanVisionClient( + api_key=self.api_key, api_host=self.api_host + ) + + # ------------------------------------------------------------------ + # Test 1: stream=True levanta NotImplementedError + # ------------------------------------------------------------------ + def test_stream_true_raises_not_implemented_error(self): + """Verifica que stream=True cause NotImplementedError.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe esto."}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + }, + ], + } + ] + with self.assertRaises(NotImplementedError) as ctx: + self.client.chat.completions.create( + model="MiniMax-CodingPlan-VLM", + messages=messages, + stream=True, + ) + self.assertIn( + "Streaming is not supported", str(ctx.exception) + ) + + # ------------------------------------------------------------------ + # Test 2: stream=False (default) no levanta error + # ------------------------------------------------------------------ + def test_stream_false_does_not_raise(self): + """Verifica que stream=False (default) no lance excepciones.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe esto."}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + }, + ], + } + ] + # Mock de httpx.Client para evitar llamada real + mock_response = unittest.mock.MagicMock() + mock_response.raise_for_status = unittest.mock.MagicMock() + mock_response.json.return_value = {"content": "Descripción de prueba."} + + with unittest.mock.patch.object( + self.client._httpx, "Client" + ) as mock_client_class: + mock_client_instance = unittest.mock.MagicMock() + mock_client_instance.post.return_value = mock_response + mock_client_class.return_value.__enter__ = unittest.mock.MagicMock( + return_value=mock_client_instance + ) + mock_client_class.return_value.__exit__ = unittest.mock.MagicMock( + return_value=False + ) + + # No debe lanzar error + result = self.client.chat.completions.create( + model="MiniMax-CodingPlan-VLM", + messages=messages, + stream=False, # default + ) + self.assertIsNotNone(result) + + # ------------------------------------------------------------------ + # Test 3: Sin imagen levanta ValueError + # ------------------------------------------------------------------ + def test_missing_image_raises_value_error(self): + """Sin image_url debe levantar ValueError.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Solo texto, sin imagen."} + ], + } + ] + with self.assertRaises(ValueError) as ctx: + self.client.chat.completions.create( + model="MiniMax-CodingPlan-VLM", + messages=messages, + ) + self.assertIn( + "image_url", str(ctx.exception) + ) + + # ------------------------------------------------------------------ + # Test 4: URL de imagen OpenAI procesada correctamente + # ------------------------------------------------------------------ + def test_openai_image_url_parsed_correctly(self): + """Mensaje con image_url remota se procesa sin conversión local.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "¿Qué hay en esta imagen?"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/foto.jpg" + }, + }, + ], + } + ] + + mock_response = unittest.mock.MagicMock() + mock_response.raise_for_status = unittest.mock.MagicMock() + mock_response.json.return_value = { + "content": "Es una foto de un gato." + } + + with unittest.mock.patch.object( + self.client._httpx, "Client" + ) as mock_client_class: + mock_client_instance = unittest.mock.MagicMock() + mock_client_instance.post.return_value = mock_response + mock_client_class.return_value.__enter__ = unittest.mock.MagicMock( + return_value=mock_client_instance + ) + mock_client_class.return_value.__exit__ = unittest.mock.MagicMock( + return_value=False + ) + + result = self.client.chat.completions.create( + model="MiniMax-CodingPlan-VLM", + messages=messages, + ) + + # Verificar que se hizo el POST con la URL correcta + mock_client_instance.post.assert_called_once() + call_args = mock_client_instance.post.call_args + # post() recibe URL como primer positional arg + call_positional = call_args[0] + self.assertIn("vlm", call_positional[0]) + + # Verificar que la respuesta tiene la estructura esperada + self.assertTrue(hasattr(result, "choices")) + self.assertEqual( + result.choices[0].message.content, "Es una foto de un gato." + ) + + # ------------------------------------------------------------------ + # Test 5: Path de imagen local convertido a data URI + # ------------------------------------------------------------------ + def test_local_image_path_converted_to_data_uri(self): + """Path local se convierte a data URI usando mock de open().""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe esto."}, + { + "type": "image_url", + "image_url": {"url": "/tmp/test_image.png"}, + }, + ], + } + ] + + mock_response = unittest.mock.MagicMock() + mock_response.raise_for_status = unittest.mock.MagicMock() + mock_response.json.return_value = {"content": "Imagen local procesada."} + + # Simular contenido binario de una imagen PNG + fake_image_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00" + + with unittest.mock.patch( + "agent.auxiliary_client.open", + unittest.mock.mock_open(read_data=fake_image_bytes), + ), unittest.mock.patch( + "agent.auxiliary_client.os.path.isfile", return_value=True + ), unittest.mock.patch.object( + self.client._httpx, "Client" + ) as mock_client_class: + mock_client_instance = unittest.mock.MagicMock() + mock_client_instance.post.return_value = mock_response + mock_client_class.return_value.__enter__ = unittest.mock.MagicMock( + return_value=mock_client_instance + ) + mock_client_class.return_value.__exit__ = unittest.mock.MagicMock( + return_value=False + ) + + result = self.client.chat.completions.create( + model="MiniMax-CodingPlan-VLM", + messages=messages, + ) + + # Verificar que se hizo POST + mock_client_instance.post.assert_called_once() + call_args = mock_client_instance.post.call_args + # content se pasa como keyword arg + body_str = call_args[1]["content"] + self.assertIn("data:image/png;base64,", body_str) + + # Verificar respuesta + self.assertEqual( + result.choices[0].message.content, "Imagen local procesada." + ) + + # ------------------------------------------------------------------ + # Test 6: Respuesta con interfaz OpenAI esperada + # ------------------------------------------------------------------ + def test_response_has_expected_interface(self): + """La respuesta debe tener .choices[0].message.content.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Cuenta sobre la imagen."}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/img.jpg" + }, + }, + ], + } + ] + + mock_response = unittest.mock.MagicMock() + mock_response.raise_for_status = unittest.mock.MagicMock() + mock_response.json.return_value = { + "content": "Respuesta del modelo vision." + } + + with unittest.mock.patch.object( + self.client._httpx, "Client" + ) as mock_client_class: + mock_client_instance = unittest.mock.MagicMock() + mock_client_instance.post.return_value = mock_response + mock_client_class.return_value.__enter__ = unittest.mock.MagicMock( + return_value=mock_client_instance + ) + mock_client_class.return_value.__exit__ = unittest.mock.MagicMock( + return_value=False + ) + + result = self.client.chat.completions.create( + model="MiniMax-CodingPlan-VLM", + messages=messages, + ) + + # Interfaz OpenAI completa + self.assertTrue(hasattr(result, "choices")) + self.assertTrue(len(result.choices) == 1) + self.assertTrue(hasattr(result.choices[0], "message")) + self.assertEqual( + result.choices[0].message.role, "assistant" + ) + self.assertEqual( + result.choices[0].message.content, "Respuesta del modelo vision." + ) + self.assertTrue(hasattr(result, "usage")) + self.assertTrue(hasattr(result.usage, "prompt_tokens")) + self.assertTrue(hasattr(result.usage, "completion_tokens")) + self.assertTrue(hasattr(result.usage, "total_tokens")) + + +class TestAsyncMiniMaxCodingPlanVisionClient(unittest.TestCase): + """Tests para el cliente asíncrono _AsyncMiniMaxCodingPlanVisionClient.""" + + def setUp(self): + """Configurar cliente síncrono y su wrapper asíncrono.""" + self.api_key = "test-api-key-async" + self.api_host = "https://api.minimax.io" + self.sync_client = _MiniMaxCodingPlanVisionClient( + api_key=self.api_key, api_host=self.api_host + ) + self.async_client = _AsyncMiniMaxCodingPlanVisionClient( + sync_client=self.sync_client + ) + + # ------------------------------------------------------------------ + # Test 7: Async create delega al cliente síncrono via asyncio.to_thread + # ------------------------------------------------------------------ + def test_async_client_delegates_to_sync(self): + """Verifica que _async_create llama al sync via asyncio.to_thread.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe esto asíncronamente."}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/async_img.png" + }, + }, + ], + } + ] + + mock_response = unittest.mock.MagicMock() + mock_response.raise_for_status = unittest.mock.MagicMock() + mock_response.json.return_value = { + "content": "Respuesta asíncrona." + } + + with unittest.mock.patch.object( + self.sync_client._httpx, "Client" + ) as mock_client_class: + mock_client_instance = unittest.mock.MagicMock() + mock_client_instance.post.return_value = mock_response + mock_client_class.return_value.__enter__ = unittest.mock.MagicMock( + return_value=mock_client_instance + ) + mock_client_class.return_value.__exit__ = unittest.mock.MagicMock( + return_value=False + ) + + # Llamada asíncrona + result = asyncio.run( + self.async_client.chat.completions.create( + model="MiniMax-CodingPlan-VLM", + messages=messages, + ) + ) + + # Verificar resultado + self.assertIsNotNone(result) + self.assertEqual( + result.choices[0].message.content, "Respuesta asíncrona." + ) + + # Verificar que se hizo exactamente una llamada POST (delegada) + mock_client_instance.post.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index e10cba76a89a..ad39a5d74548 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -64,6 +64,30 @@ def test_returns_none_on_exception(self): with patch("agent.title_generator.call_llm", side_effect=RuntimeError("no provider")): assert generate_title("question", "answer") is None + def test_strips_think_block_from_title(self): + """Models that emit blocks should not leak them as the session title.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = ( + "Reasoning about the session topic...\n" + "Docker Setup Troubleshooting" + ) + + with patch("agent.title_generator.call_llm", return_value=mock_response): + title = generate_title("docker build fails", "Here's what I found...") + assert title == "Docker Setup Troubleshooting" + + def test_strips_reasoning_field_from_title(self): + """When content is empty and reasoning field has the title, extract it.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "" + mock_response.choices[0].message.reasoning = "SSH Key Configuration Issue" + + with patch("agent.title_generator.call_llm", return_value=mock_response): + title = generate_title("ssh not working", "Try this...") + assert title == "SSH Key Configuration Issue" + def test_invokes_failure_callback_on_exception(self): """failure_callback must fire so the user sees a warning (issue #15775).""" captured = [] diff --git a/tests/tools/test_llm_content_none_guard.py b/tests/tools/test_llm_content_none_guard.py index b0adea8c7ada..589a9406aa55 100644 --- a/tests/tools/test_llm_content_none_guard.py +++ b/tests/tools/test_llm_content_none_guard.py @@ -292,3 +292,18 @@ def test_content_preferred_over_reasoning(self): """When both content and reasoning exist, content wins.""" response = _make_response("Actual answer", reasoning="Internal reasoning") assert extract_content_or_reasoning(response) == "Actual answer" + + def test_think_block_in_reasoning_field_is_stripped(self): + """Think blocks inside the reasoning field must be stripped before returning.""" + response = _make_response( + None, + reasoning="some reasoning here...\nSession Title Here", + ) + assert extract_content_or_reasoning(response) == "Session Title Here" + + def test_think_block_in_reasoning_details_summary_is_stripped(self): + """Think blocks inside reasoning_details summary must be stripped.""" + response = _make_response(None, reasoning_details=[ + {"type": "reasoning.summary", "summary": "thinking...\nFinal Title"}, + ]) + assert extract_content_or_reasoning(response) == "Final Title" diff --git a/tools/web_tools.py b/tools/web_tools.py index 352b4a55b130..f9ddfb203be6 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -126,7 +126,7 @@ def _get_backend() -> str: keys manually without running setup. """ configured = (_load_web_config().get("backend") or "").lower().strip() - if configured in ("parallel", "firecrawl", "tavily", "exa"): + if configured in ("parallel", "firecrawl", "tavily", "exa", "minimax-coding-plan"): return configured # Fallback for manual / legacy config — pick the highest-priority @@ -137,6 +137,7 @@ def _get_backend() -> str: ("parallel", _has_env("PARALLEL_API_KEY")), ("tavily", _has_env("TAVILY_API_KEY")), ("exa", _has_env("EXA_API_KEY")), + ("minimax-coding-plan", _has_env("MINIMAX_API_KEY")), ) for backend, available in backend_candidates: if available: @@ -155,6 +156,8 @@ def _is_backend_available(backend: str) -> bool: return check_firecrawl_api_key() if backend == "tavily": return _has_env("TAVILY_API_KEY") + if backend == "minimax-coding-plan": + return _has_env("MINIMAX_API_KEY") return False # ─── Firecrawl Client ──────────────────────────────────────────────────────── @@ -995,6 +998,58 @@ def _exa_extract(urls: List[str]) -> List[Dict[str, Any]]: return results +# ─── MiniMax Coding Plan Search ───────────────────────────────────────────── + +def _minimax_coding_plan_search(query: str, limit: int = 5) -> dict: + """Search using the MiniMax Coding Plan API and return normalized results.""" + import requests as _requests + + api_key = os.environ.get("MINIMAX_API_KEY", "").strip() + api_host = os.environ.get("MINIMAX_API_HOST", "https://api.minimax.io").strip().rstrip("/") + + if not api_key: + return {"error": "MINIMAX_API_KEY not set", "success": False} + + try: + import json as _json + resp = _requests.post( + f"{api_host}/v1/coding_plan/search", + headers={ + "Authorization": f"Bearer {api_key}", + "MM-API-Source": "Hermes-Agent", + "Content-Type": "application/json", + }, + data=_json.dumps({"q": query}), + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + + # Normalize to Hermes web search format: {"data": {"web": [...]}} + # MiniMax returns search results in its own format — adapt as needed + raw_results = data.get("results", data.get("web_pages", [])) + if not raw_results and isinstance(data, dict): + # Try common response formats + for key in ("organic", "items", "hits", "data"): + raw_results = data.get(key, []) + if raw_results: + break + + web_results = [] + for item in (raw_results if isinstance(raw_results, list) else [])[:limit]: + if isinstance(item, dict): + web_results.append({ + "url": item.get("url", item.get("link", "")), + "title": item.get("title", ""), + "description": item.get("snippet", item.get("description", item.get("text", ""))), + }) + + return {"data": {"web": web_results}, "success": True} + except Exception as e: + logger.warning("MiniMax Coding Plan search failed: %s", e) + return {"error": str(e), "success": False} + + # ─── Parallel Search & Extract Helpers ──────────────────────────────────────── def _parallel_search(query: str, limit: int = 5) -> dict: @@ -1163,6 +1218,16 @@ def web_search_tool(query: str, limit: int = 5) -> str: _debug.save() return result_json + if backend == "minimax-coding-plan": + logger.info("MiniMax Coding Plan search: '%s' (limit: %d)", query, limit) + response_data = _minimax_coding_plan_search(query, limit) + debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) + result_json = json.dumps(response_data, indent=2, ensure_ascii=False) + debug_call_data["final_response_size"] = len(result_json) + _debug.log_call("web_search_tool", debug_call_data) + _debug.save() + return result_json + logger.info("Searching the web for: '%s' (limit: %d)", query, limit) response = _get_firecrawl_client().search(