mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-10-09 03:17:51 +00:00
fix: improve MiniMax compatibility
This commit is contained in:
parent
f845c5f7fb
commit
29a3869d49
4 changed files with 153 additions and 8 deletions
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",), ""),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue