fix: improve MiniMax compatibility

This commit is contained in:
xlrrrr 2026-03-31 17:18:13 +08:00
parent f845c5f7fb
commit 29a3869d49
4 changed files with 153 additions and 8 deletions

View file

@ -120,6 +120,7 @@ class GroundingAgent(BaseAgent):
logger.info(f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)")
_MAX_SINGLE_CONTENT_CHARS = 30_000
_ITERATION_GUIDANCE_PREFIX = "[INTERNAL ORCHESTRATION NOTE]"
@classmethod
def _cap_message_content(cls, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@ -414,13 +415,19 @@ class GroundingAgent(BaseAgent):
# Remove previous iteration guidance to avoid accumulation
messages = [
msg for msg in messages
if not (msg.get("role") == "system" and "Iteration" in msg.get("content", "") and "complete" in msg.get("content", ""))
msg for msg in messages
if not (
isinstance(msg.get("content"), str)
and msg.get("content", "").startswith(self._ITERATION_GUIDANCE_PREFIX)
)
]
# MiniMax rejects system messages injected mid-conversation,
# so runtime guidance is sent as an internal user note.
guidance_msg = {
"role": "system",
"content": f"Iteration {current_iteration} complete. "
"role": "user",
"content": f"{self._ITERATION_GUIDANCE_PREFIX}\n"
f"Iteration {current_iteration} complete. "
f"Check if task is finished - if yes, output {GroundingAgentPrompts.TASK_COMPLETE}. "
f"If not, continue with next action."
}

View file

@ -31,7 +31,7 @@ PROVIDER_REGISTRY: List[tuple] = [
("zhipu", ("zhipu", "glm", "zai"), ""),
("dashscope", ("qwen", "dashscope"), ""),
("moonshot", ("moonshot", "kimi"), "https://api.moonshot.ai/v1"),
("minimax", ("minimax",), "https://api.minimax.io/v1"),
("minimax", ("minimax",), "https://api.minimaxi.com/v1"),
("groq", ("groq",), ""),
]

View file

@ -54,12 +54,20 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
# and the model name doesn't already carry that prefix, prepend
# it so that litellm uses the correct request format (OpenAI-
# compatible for gateways vs native for direct providers).
# Skip when the user explicitly provided a model (via OPENSPACE_MODEL
# or --model) AND explicit OPENSPACE_LLM_* overrides — the user knows
# exactly which endpoint they want to hit.
_GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"}
_has_explicit_llm_override = bool(
os.environ.get("OPENSPACE_LLM_API_BASE")
or os.environ.get("OPENSPACE_LLM_API_KEY")
)
if (
forced_provider
and forced_provider in _GATEWAY_PROVIDERS
and resolved_model
and not resolved_model.lower().startswith(f"{forced_provider}/")
and not (model and _has_explicit_llm_override)
):
resolved_model = f"{forced_provider}/{resolved_model}"
logger.info(
@ -102,6 +110,31 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
if not resolved_model:
resolved_model = "openrouter/anthropic/claude-sonnet-4.5"
# Provider-specific adjustments for litellm routing
if resolved_model and "minimax" in resolved_model.lower():
final_key = kwargs.get("api_key")
final_base = kwargs.get("api_base", "")
if final_key:
os.environ.setdefault("MINIMAX_API_KEY", final_key)
if final_base:
os.environ.setdefault("MINIMAX_API_BASE", final_base)
# api.minimaxi.com (domestic) is OpenAI-compatible but does not fully
# support litellm's minimax-specific request transformations (e.g. tool
# calling format). Switch to the generic openai/ prefix so litellm
# sends standard OpenAI-format requests that minimaxi.com accepts.
if (
resolved_model.lower().startswith("minimax/")
and "minimaxi.com" in final_base
):
original = resolved_model
resolved_model = "openai/" + resolved_model.split("/", 1)[1]
logger.info(
"Switched model prefix for minimaxi.com compat: %s -> %s",
original, resolved_model,
)
if kwargs:
safe = {
k: (v[:8] + "..." if k == "api_key" and isinstance(v, str) and len(v) > 8 else v)

View file

@ -406,6 +406,95 @@ class LLMClient:
self._logger = Logger.get_logger(__name__)
self._last_call_time = 0.0
@staticmethod
def _merge_consecutive_system_messages(messages: List[Dict]) -> List[Dict]:
"""Merge consecutive system messages into one.
Providers like MiniMax reject requests that contain multiple consecutive
messages with the same role (error 2013 "invalid chat setting").
Merging is safe for all providers — it simply concatenates the content.
"""
if not messages:
return messages
merged: List[Dict] = []
for msg in messages:
if (
merged
and msg.get("role") == "system"
and merged[-1].get("role") == "system"
):
merged[-1] = {
"role": "system",
"content": merged[-1].get("content", "") + "\n\n" + msg.get("content", ""),
}
else:
merged.append(msg.copy())
return merged
@staticmethod
def _is_minimax_model(model: str) -> bool:
return isinstance(model, str) and "minimax" in model.lower()
@classmethod
def _rewrite_nonleading_system_messages_for_minimax(
cls,
messages: List[Dict],
) -> List[Dict]:
"""Rewrite non-leading system messages into internal user notes for MiniMax."""
rewritten: List[Dict] = []
rewritten_count = 0
for msg in messages:
msg_copy = msg.copy()
if msg_copy.get("role") == "system" and rewritten:
content = msg_copy.get("content", "")
if isinstance(content, str):
msg_copy["content"] = (
"[INTERNAL ORCHESTRATION NOTE]\n"
"This note was originally injected as a system message by the "
"agent runtime. Treat it as workflow guidance, not as a new "
"end-user request.\n\n"
f"{content}"
)
msg_copy["role"] = "user"
rewritten_count += 1
rewritten.append(msg_copy)
if rewritten_count:
logger.info(
"Rewrote %d non-leading system message(s) for MiniMax compatibility",
rewritten_count,
)
return rewritten
@classmethod
def _normalize_messages_for_model(cls, messages: List[Dict], model: str) -> List[Dict]:
"""Normalize message history only when a provider requires it."""
if not cls._is_minimax_model(model):
return messages
minimized_system_history = cls._merge_consecutive_system_messages(messages)
return cls._rewrite_nonleading_system_messages_for_minimax(
minimized_system_history
)
@staticmethod
def _serialize_response_field(value):
"""Convert provider response fields into plain Python containers."""
if hasattr(value, "model_dump"):
return value.model_dump(exclude_none=True)
if isinstance(value, list):
return [LLMClient._serialize_response_field(item) for item in value]
if isinstance(value, tuple):
return [LLMClient._serialize_response_field(item) for item in value]
if isinstance(value, dict):
return {
key: LLMClient._serialize_response_field(item)
for key, item in value.items()
}
return value
async def _rate_limit(self):
"""Apply rate limiting by adding delay between API calls"""
if self.rate_limit_delay > 0:
@ -539,6 +628,7 @@ class LLMClient:
"model": kwargs.get("model", self.model),
**self.litellm_kwargs,
}
request_model = completion_kwargs["model"]
# Add thinking/reasoning_effort only if explicitly enabled and not using tools
enable_thinking = kwargs.get("enable_thinking", self.enable_thinking)
@ -561,10 +651,16 @@ class LLMClient:
if enable_thinking:
completion_kwargs["reasoning_effort"] = kwargs.get("reasoning_effort", "medium")
# 4. Apply rate limiting
# 4. Normalize messages for providers with stricter role constraints.
current_messages = self._normalize_messages_for_model(
current_messages,
request_model,
)
# 5. Apply rate limiting
await self._rate_limit()
# 5. Call LLM with retry (single round)
# 6. Call LLM with retry (single round)
completion_kwargs["messages"] = current_messages
response = await self._call_with_retry(**completion_kwargs)
@ -578,6 +674,11 @@ class LLMClient:
"role": "assistant",
"content": response_message.content or "",
}
for field_name in ("reasoning_details", "reasoning_content", "name"):
field_value = getattr(response_message, field_name, None)
if field_value:
assistant_message[field_name] = self._serialize_response_field(field_value)
tool_calls = getattr(response_message, 'tool_calls', None)
if tool_calls:
@ -722,6 +823,10 @@ class LLMClient:
"content": summary_prompt
}
current_messages.append(summary_message)
current_messages = self._normalize_messages_for_model(
current_messages,
request_model,
)
# Apply rate limiting before summary call
await self._rate_limit()
@ -729,7 +834,7 @@ class LLMClient:
# Call LLM to generate summary (without tools)
summary_kwargs = {
**self.litellm_kwargs,
"model": self.model,
"model": request_model,
"messages": current_messages,
"tools": [],
"tool_choice": "none",