Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ ATLAS_API_KEY=
# ATLAS_BASE_URL=https://api.atlascloud.ai/v1
# ATLAS_MODEL=anthropic/claude-sonnet-4.6

# --- Optional: MiniMax (OpenAI- and Anthropic-compatible) -------------------
# OpenAI SDK: global https://api.minimax.io/v1
# OpenAI SDK: China https://api.minimaxi.com/v1
# Anthropic SDK: global https://api.minimax.io/anthropic
# Anthropic SDK: China https://api.minimaxi.com/anthropic
MINIMAX_API_KEY=
# MINIMAX_MODEL=MiniMax-M3 # Also supports MiniMax-M2.7
# MINIMAX_BASE_URL=https://api.minimax.io/v1

# ─── ECC agent data (multi-harness isolation) ───────────────────────────────
# Memory hooks (sessions, learned skills, aliases, metrics). Default: ~/.claude
# Use a separate root when running ECC in Cursor alongside Claude Code:
Expand Down
1 change: 1 addition & 0 deletions src/llm/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ProviderType(str, Enum):
ASTRAFLOW = "astraflow"
ASTRAFLOW_CN = "astraflow_cn"
ATLAS = "atlas"
MINIMAX = "minimax"


@dataclass(frozen=True)
Expand Down
2 changes: 2 additions & 0 deletions src/llm/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider
from llm.providers.atlas import AtlasProvider
from llm.providers.claude import ClaudeProvider
from llm.providers.minimax import MiniMaxProvider
from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider
from llm.providers.resolver import get_provider, register_provider
Expand All @@ -12,6 +13,7 @@
"AstraflowProvider",
"AtlasProvider",
"ClaudeProvider",
"MiniMaxProvider",
"OpenAIProvider",
"OllamaProvider",
"get_provider",
Expand Down
258 changes: 258 additions & 0 deletions src/llm/providers/minimax.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
"""MiniMax OpenAI- and Anthropic-compatible provider adapter."""

from __future__ import annotations

import json
import os
from typing import Any
from urllib.parse import urlsplit

from anthropic import Anthropic
from openai import OpenAI

from llm.core.interface import (
AuthenticationError,
ContextLengthError,
LLMProvider,
RateLimitError,
)
from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, Role, ToolCall
from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR

MINIMAX_BASE_URL = "https://api.minimax.io/v1"
MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimax.io/anthropic"
DEFAULT_MINIMAX_MODEL = "MiniMax-M3"
MINIMAX_M2_7_MODEL = "MiniMax-M2.7"
DEFAULT_MINIMAX_ANTHROPIC_MAX_TOKENS = 16_000


def _uses_anthropic_messages(base_url: str) -> bool:
path = urlsplit(base_url).path.rstrip("/")
return path.endswith("/anthropic")


def _parse_tool_arguments(raw_arguments: str | None) -> dict[str, Any]:
if not raw_arguments:
return {}

try:
arguments = json.loads(raw_arguments)
except json.JSONDecodeError:
return {"raw": raw_arguments}

if isinstance(arguments, dict):
return arguments
return {"value": arguments}


class MiniMaxProvider(LLMProvider):
"""MiniMax endpoint using OpenAI chat completions or Anthropic messages."""

provider_type = ProviderType.MINIMAX
api_key_env = "MINIMAX_API_KEY"
base_url_env = "MINIMAX_BASE_URL"
model_env = "MINIMAX_MODEL"
default_base_url = MINIMAX_BASE_URL

def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
default_model: str | None = None,
) -> None:
self.api_key = api_key or os.environ.get(self.api_key_env) or ""

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.

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Missing secret raises KeyError — falls back to empty string instead.

self.api_key silently becomes "" when MINIMAX_API_KEY is unset, deferring failure to a later API call rather than failing fast.

As per coding guidelines, "Retrieve secrets and API keys from environment variables using os.environ with error handling (raise KeyError if missing) rather than hardcoding credentials." Note this mirrors the existing pattern in AtlasProvider/ClaudeProvider/OpenAIProvider, so a fix here alone would be inconsistent unless applied repo-wide; flagging on the new file for visibility.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/llm/providers/minimax.py` at line 63, Update the Minimax provider
initialization around self.api_key to retrieve the environment-backed secret
with the repository’s established required-key pattern, raising KeyError when
neither the explicit api_key nor MINIMAX_API_KEY is available instead of
defaulting to an empty string. Apply the same missing-secret behavior
consistently in the existing AtlasProvider, ClaudeProvider, and OpenAIProvider
implementations.

Source: Coding guidelines

configured_base_url = base_url or os.environ.get(
self.base_url_env, self.default_base_url
)
self._uses_anthropic = _uses_anthropic_messages(configured_base_url)
self.base_url = configured_base_url
self.default_model = (
default_model or os.environ.get(self.model_env) or DEFAULT_MINIMAX_MODEL
)
self.client: Any
if self._uses_anthropic:
self.client = Anthropic(api_key=self.api_key, base_url=self.base_url)
else:
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url,
_enforce_credentials=False,
)
self._models = [
ModelInfo(
name=DEFAULT_MINIMAX_MODEL,
provider=self.provider_type,
supports_tools=True,
supports_vision=True,
max_tokens=524_288,
context_window=1_000_000,
),
ModelInfo(
name=MINIMAX_M2_7_MODEL,
provider=self.provider_type,
supports_tools=True,
supports_vision=False,
max_tokens=204_800,
context_window=204_800,
),
]
if self.default_model not in {model.name for model in self._models}:
self._models.append(
ModelInfo(
name=self.default_model,
provider=self.provider_type,
supports_tools=True,
)
)

def generate(self, llm_input: LLMInput) -> LLMOutput:
try:
if self._uses_anthropic:
return self._generate_anthropic(llm_input)
return self._generate_openai(llm_input)
except Exception as e:
msg = str(e)
if "401" in msg or "authentication" in msg.lower():
raise AuthenticationError(msg, provider=self.provider_type) from e
if "429" in msg or "rate_limit" in msg.lower():
raise RateLimitError(msg, provider=self.provider_type) from e
if "context" in msg.lower() and "length" in msg.lower():
raise ContextLengthError(msg, provider=self.provider_type) from e
raise
Comment on lines +224 to +237

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No server-side logging of translated error context.

generate() catches and re-classifies exceptions but never logs the original error/context before raising the translated exception, making production debugging harder.

As per coding guidelines for **/{server,backend,api,src}/**/*.py: "Log detailed error context on the server side." Add a logging.getLogger(__name__).error(...) call (with model/provider context) before re-raising translated errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/llm/providers/minimax.py` around lines 108 - 121, Update generate in the
exception handler to log the original exception and relevant model/provider
context via logging.getLogger(__name__).error(...) before raising any translated
AuthenticationError, RateLimitError, or ContextLengthError, while preserving the
existing classification and re-raise behavior.

Source: Coding guidelines


def _generate_openai(self, llm_input: LLMInput) -> LLMOutput:
params: dict[str, Any] = {
"model": llm_input.model or self.default_model,
"messages": [msg.to_dict() for msg in llm_input.messages],
}
if llm_input.temperature != 1.0:
params["temperature"] = llm_input.temperature
if llm_input.max_tokens is not None:
params["max_tokens"] = llm_input.max_tokens
if llm_input.tools:
params["tools"] = [tool.to_openai_tool() for tool in llm_input.tools]

extra_body = {
key: llm_input.metadata[key]
for key in ("thinking", "reasoning_split", "service_tier")
if key in llm_input.metadata
}
if extra_body:
params["extra_body"] = extra_body

response = self.client.chat.completions.create(**params)
if not response.choices or response.choices[0].message is None:
raise ValueError(EMPTY_FILTERED_RESPONSE_ERROR)
choice = response.choices[0]

tool_calls = None
if choice.message.tool_calls:
tool_calls = [
ToolCall(
id=tc.id or "",
name=tc.function.name,
arguments=_parse_tool_arguments(tc.function.arguments),
)
for tc in choice.message.tool_calls
]

usage = None
if response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}

return LLMOutput(
content=choice.message.content or "",
tool_calls=tool_calls,
model=response.model,
usage=usage,
stop_reason=choice.finish_reason,
)
Comment on lines +239 to +297

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

No max_tokens floor on the OpenAI-compatible path for reasoning models.

Unlike AtlasProvider.generate, which floors max_tokens because reasoning models "spend tokens on a thinking budget before the answer," _generate_openai only sets max_tokens when the caller explicitly supplies it (line 130-131). If a caller enables thinking/reasoning_split via extra_body without also setting max_tokens, the provider's own default could truncate the thinking budget before any visible answer is produced, especially for the 524,288 max-output MiniMax-M3 model.

🎯 Suggested floor for reasoning completions
-        if llm_input.max_tokens is not None:
-            params["max_tokens"] = llm_input.max_tokens
+        max_tokens = llm_input.max_tokens
+        if max_tokens is None or max_tokens < DEFAULT_MINIMAX_MAX_TOKENS:
+            max_tokens = DEFAULT_MINIMAX_MAX_TOKENS
+        params["max_tokens"] = max_tokens
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _generate_openai(self, llm_input: LLMInput) -> LLMOutput:
params: dict[str, Any] = {
"model": llm_input.model or self.default_model,
"messages": [msg.to_dict() for msg in llm_input.messages],
}
if llm_input.temperature != 1.0:
params["temperature"] = llm_input.temperature
if llm_input.max_tokens is not None:
params["max_tokens"] = llm_input.max_tokens
if llm_input.tools:
params["tools"] = [tool.to_openai_tool() for tool in llm_input.tools]
extra_body = {
key: llm_input.metadata[key]
for key in ("thinking", "reasoning_split", "service_tier")
if key in llm_input.metadata
}
if extra_body:
params["extra_body"] = extra_body
response = self.client.chat.completions.create(**params)
if not response.choices or response.choices[0].message is None:
raise ValueError(EMPTY_FILTERED_RESPONSE_ERROR)
choice = response.choices[0]
tool_calls = None
if choice.message.tool_calls:
tool_calls = [
ToolCall(
id=tc.id or "",
name=tc.function.name,
arguments=_parse_tool_arguments(tc.function.arguments),
)
for tc in choice.message.tool_calls
]
usage = None
if response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
return LLMOutput(
content=choice.message.content or "",
tool_calls=tool_calls,
model=response.model,
usage=usage,
stop_reason=choice.finish_reason,
)
def _generate_openai(self, llm_input: LLMInput) -> LLMOutput:
params: dict[str, Any] = {
"model": llm_input.model or self.default_model,
"messages": [msg.to_dict() for msg in llm_input.messages],
}
max_tokens = llm_input.max_tokens
if max_tokens is None or max_tokens < DEFAULT_MINIMAX_MAX_TOKENS:
max_tokens = DEFAULT_MINIMAX_MAX_TOKENS
params["max_tokens"] = max_tokens
if llm_input.tools:
params["tools"] = [tool.to_openai_tool() for tool in llm_input.tools]
extra_body = {
key: llm_input.metadata[key]
for key in ("thinking", "reasoning_split", "service_tier")
if key in llm_input.metadata
}
if extra_body:
params["extra_body"] = extra_body
response = self.client.chat.completions.create(**params)
if not response.choices or response.choices[0].message is None:
raise ValueError(EMPTY_FILTERED_RESPONSE_ERROR)
choice = response.choices[0]
tool_calls = None
if choice.message.tool_calls:
tool_calls = [
ToolCall(
id=tc.id or "",
name=tc.function.name,
arguments=_parse_tool_arguments(tc.function.arguments),
)
for tc in choice.message.tool_calls
]
usage = None
if response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
return LLMOutput(
content=choice.message.content or "",
tool_calls=tool_calls,
model=response.model,
usage=usage,
stop_reason=choice.finish_reason,
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/llm/providers/minimax.py` around lines 123 - 173, Update _generate_openai
so reasoning-enabled requests identified by thinking or reasoning_split in
extra_body apply the same max_tokens floor used by AtlasProvider.generate when
llm_input.max_tokens is unset or below that floor. Preserve explicitly larger
max_tokens values, while keeping the existing behavior for non-reasoning
requests.


def _generate_anthropic(self, llm_input: LLMInput) -> LLMOutput:
system_parts = [
msg.content for msg in llm_input.messages if msg.role == Role.SYSTEM
]
params: dict[str, Any] = {
"model": llm_input.model or self.default_model,
"messages": [
msg.to_dict() for msg in llm_input.messages if msg.role != Role.SYSTEM
],
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
"max_tokens": (
llm_input.max_tokens
if llm_input.max_tokens is not None
else DEFAULT_MINIMAX_ANTHROPIC_MAX_TOKENS
),
}
if system_parts:
params["system"] = "\n\n".join(system_parts)
if llm_input.temperature != 1.0:
params["temperature"] = llm_input.temperature
if llm_input.tools:
params["tools"] = [tool.to_anthropic_tool() for tool in llm_input.tools]
for key in ("thinking", "service_tier"):
if key in llm_input.metadata:
params[key] = llm_input.metadata[key]

response = self.client.messages.create(**params)
if not response.content:
raise ValueError(EMPTY_FILTERED_RESPONSE_ERROR)
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
for block in response.content or []:
block_type = getattr(block, "type", None)
if block_type == "text":
text = getattr(block, "text", "")
if text:
text_parts.append(text)
elif block_type == "tool_use":
raw_arguments = getattr(block, "input", {})
arguments = (
raw_arguments.copy()
if isinstance(raw_arguments, dict)
else getattr(raw_arguments, "__dict__", {}).copy()
)
tool_calls.append(
ToolCall(
id=getattr(block, "id", ""),
name=getattr(block, "name", ""),
arguments=arguments,
)
)

usage = None
if response.usage:
usage = {
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
"cache_creation_input_tokens": getattr(
response.usage,
"cache_creation_input_tokens",
0,
),
"cache_read_input_tokens": getattr(
response.usage,
"cache_read_input_tokens",
0,
),
}

return LLMOutput(
content="".join(text_parts),
tool_calls=tool_calls or None,
model=response.model,
usage=usage,
stop_reason=response.stop_reason,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def list_models(self) -> list[ModelInfo]:
return self._models.copy()

def validate_config(self) -> bool:
return bool(self.api_key)

def get_default_model(self) -> str:
return self.default_model
2 changes: 2 additions & 0 deletions src/llm/providers/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider
from llm.providers.atlas import AtlasProvider
from llm.providers.claude import ClaudeProvider
from llm.providers.minimax import MiniMaxProvider
from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider

Expand All @@ -19,6 +20,7 @@
ProviderType.ASTRAFLOW_CN: AstraflowCNProvider,
ProviderType.ATLAS: AtlasProvider,
ProviderType.CLAUDE: ClaudeProvider,
ProviderType.MINIMAX: MiniMaxProvider,
ProviderType.OPENAI: OpenAIProvider,
ProviderType.OLLAMA: OllamaProvider,
}
Expand Down
Loading