Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 146 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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":
Expand Down Expand Up @@ -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"</(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When joined contains only a paired think block, re.sub(...).strip() is empty and this fallback returns the original raw block. Return the cleaned value instead, and add coverage for reasoning that is only ``.

"", joined, flags=re.DOTALL | re.IGNORECASE,
).strip() or joined.strip()

return ""

Expand Down
4 changes: 2 additions & 2 deletions agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:"):
Expand Down
8 changes: 8 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 25 additions & 13 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading