Merge pull request #26 from KovaForge/minimax-backend

feat: add MiniMax as first-class chat backend

Adds skillopt/model/minimax_backend.py (clean port of qwen_backend.py
targeting MiniMax-M2.7 via https://api.minimax.io/v1) and registers it
in the router, backend_config, and common defaults. Existing backends
(openai_chat, claude_chat, qwen_chat, codex_exec, claude_code_exec)
remain bit-for-bit unchanged.

Verified via 10 import / routing / parity subtests; backward-compat
sweep across the 8 shipped configs passes with no regression.

A follow-up commit completes the YAML / CLI plumbing that this PR left
half-wired (FLATTEN_MAP entries, trainer-level configure_minimax_chat
call, and --minimax_* CLI args).
This commit is contained in:
Yif Yang
2026-05-31 08:20:39 +00:00
7 changed files with 366 additions and 8 deletions
+5
View File
@@ -27,3 +27,8 @@ export AZURE_OPENAI_API_KEY=
# ── Qwen Local Model (for qwen_chat backend) ──────────────────────── # ── Qwen Local Model (for qwen_chat backend) ────────────────────────
# export QWEN_CHAT_BASE_URL=http://localhost:8000/v1 # export QWEN_CHAT_BASE_URL=http://localhost:8000/v1
# export QWEN_CHAT_MODEL=Qwen/Qwen3.5-4B # export QWEN_CHAT_MODEL=Qwen/Qwen3.5-4B
# ── MiniMax (for minimax_chat backend) ──────────────────────────────
# export MINIMAX_BASE_URL=https://api.minimax.io/v1
# export MINIMAX_API_KEY=...
# export MINIMAX_MODEL=MiniMax-M2.7
+1
View File
@@ -40,3 +40,4 @@ docs/reflact_overview.html
docs/render_ablation_paper_tables.py docs/render_ablation_paper_tables.py
docs/让* docs/让*
.gradio/ .gradio/
.venv
+12
View File
@@ -44,6 +44,18 @@ model:
target_azure_openai_ad_scope: "https://cognitiveservices.azure.com/.default" target_azure_openai_ad_scope: "https://cognitiveservices.azure.com/.default"
target_azure_openai_managed_identity_client_id: "" target_azure_openai_managed_identity_client_id: ""
# MiniMax backend settings (minimax_chat target)
minimax_base_url: "" # https://api.minimax.io/v1 if blank
minimax_api_key: ""
minimax_model: "MiniMax-M2.7"
minimax_temperature: "0.7"
minimax_max_tokens: "8000"
minimax_enable_thinking: "false"
optimizer_minimax_base_url: "" # per-role override
target_minimax_base_url: "" # per-role override
optimizer_minimax_api_key: ""
target_minimax_api_key: ""
train: train:
num_epochs: 4 num_epochs: 4
train_size: 0 # 0 = derive from dataset split when available train_size: 0 # 0 = derive from dataset split when available
+62 -2
View File
@@ -6,6 +6,7 @@ from typing import Any
from skillopt.model import azure_openai as _openai from skillopt.model import azure_openai as _openai
from skillopt.model import claude_backend as _claude from skillopt.model import claude_backend as _claude
from skillopt.model import minimax_backend as _minimax
from skillopt.model import qwen_backend as _qwen from skillopt.model import qwen_backend as _qwen
from skillopt.model.backend_config import ( # noqa: F401 from skillopt.model.backend_config import ( # noqa: F401
configure_claude_code_exec, configure_claude_code_exec,
@@ -50,6 +51,10 @@ def set_backend(name: str | None) -> str:
set_optimizer_backend("openai_chat") set_optimizer_backend("openai_chat")
set_target_backend("qwen_chat") set_target_backend("qwen_chat")
return "qwen_chat" return "qwen_chat"
if normalized in {"minimax", "minimax_chat"}:
set_optimizer_backend("openai_chat")
set_target_backend("minimax_chat")
return "minimax_chat"
raise ValueError(f"Unsupported legacy backend: {name!r}") raise ValueError(f"Unsupported legacy backend: {name!r}")
@@ -65,6 +70,8 @@ def get_backend_name() -> str:
return "codex" return "codex"
if optimizer == "openai_chat" and target == "qwen_chat": if optimizer == "openai_chat" and target == "qwen_chat":
return "qwen_chat" return "qwen_chat"
if optimizer == "openai_chat" and target == "minimax_chat":
return "minimax_chat"
return f"{optimizer}+{target}" return f"{optimizer}+{target}"
@@ -124,9 +131,18 @@ def chat_target(
stage=stage, stage=stage,
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
) )
if get_target_backend() == "minimax_chat":
return _minimax.chat_target(
system=system,
user=user,
max_completion_tokens=max_completion_tokens,
retries=retries,
stage=stage,
reasoning_effort=reasoning_effort,
)
if not is_target_chat_backend(): if not is_target_chat_backend():
raise NotImplementedError( raise NotImplementedError(
"chat_target is only supported with target_backend=openai_chat, claude_chat, or qwen_chat. " "chat_target is only supported with target_backend=openai_chat, claude_chat, qwen_chat, or minimax_chat. "
"Exec backends are handled in environment-specific rollout code." "Exec backends are handled in environment-specific rollout code."
) )
return _openai.chat_target( return _openai.chat_target(
@@ -210,9 +226,20 @@ def chat_target_messages(
tool_choice=tool_choice, tool_choice=tool_choice,
return_message=return_message, return_message=return_message,
) )
if get_target_backend() == "minimax_chat":
return _minimax.chat_target_messages(
messages=messages,
max_completion_tokens=max_completion_tokens,
retries=retries,
stage=stage,
reasoning_effort=reasoning_effort,
tools=tools,
tool_choice=tool_choice,
return_message=return_message,
)
if not is_target_chat_backend(): if not is_target_chat_backend():
raise NotImplementedError( raise NotImplementedError(
"chat_target_messages is only supported with target_backend=openai_chat, claude_chat, or qwen_chat. " "chat_target_messages is only supported with target_backend=openai_chat, claude_chat, qwen_chat, or minimax_chat. "
"Exec backends are handled in environment-specific rollout code." "Exec backends are handled in environment-specific rollout code."
) )
return _openai.chat_target_messages( return _openai.chat_target_messages(
@@ -301,6 +328,17 @@ def get_token_summary() -> dict:
summary[stage]["prompt_tokens"] += values["prompt_tokens"] summary[stage]["prompt_tokens"] += values["prompt_tokens"]
summary[stage]["completion_tokens"] += values["completion_tokens"] summary[stage]["completion_tokens"] += values["completion_tokens"]
summary[stage]["total_tokens"] += values["total_tokens"] summary[stage]["total_tokens"] += values["total_tokens"]
minimax_summary = _minimax.get_token_summary()
for stage, values in minimax_summary.items():
if stage == "_total":
continue
if stage not in summary:
summary[stage] = values
continue
summary[stage]["calls"] += values["calls"]
summary[stage]["prompt_tokens"] += values["prompt_tokens"]
summary[stage]["completion_tokens"] += values["completion_tokens"]
summary[stage]["total_tokens"] += values["total_tokens"]
total = { total = {
"calls": 0, "calls": 0,
"prompt_tokens": 0, "prompt_tokens": 0,
@@ -322,6 +360,7 @@ def reset_token_tracker() -> None:
_openai.reset_token_tracker() _openai.reset_token_tracker()
_claude.reset_token_tracker() _claude.reset_token_tracker()
_qwen.reset_token_tracker() _qwen.reset_token_tracker()
_minimax.reset_token_tracker()
def configure_azure_openai( def configure_azure_openai(
@@ -386,16 +425,37 @@ def configure_qwen_chat(
) )
def configure_minimax_chat(
*,
base_url: str | None = None,
api_key: str | None = None,
temperature: float | str | None = None,
timeout_seconds: float | str | None = None,
max_tokens: int | str | None = None,
enable_thinking: bool | str | None = None,
) -> None:
_minimax.configure_minimax_chat(
base_url=base_url,
api_key=api_key,
temperature=temperature,
timeout_seconds=timeout_seconds,
max_tokens=max_tokens,
enable_thinking=enable_thinking,
)
def set_reasoning_effort(effort: str | None) -> None: def set_reasoning_effort(effort: str | None) -> None:
_openai.set_reasoning_effort(effort) _openai.set_reasoning_effort(effort)
_claude.set_reasoning_effort(effort) _claude.set_reasoning_effort(effort)
_qwen.set_reasoning_effort(effort) _qwen.set_reasoning_effort(effort)
_minimax.set_reasoning_effort(effort)
def set_target_deployment(deployment: str) -> None: def set_target_deployment(deployment: str) -> None:
_openai.set_target_deployment(deployment) _openai.set_target_deployment(deployment)
_claude.set_target_deployment(deployment) _claude.set_target_deployment(deployment)
_qwen.set_target_deployment(deployment) _qwen.set_target_deployment(deployment)
_minimax.set_target_deployment(deployment)
def set_optimizer_deployment(deployment: str) -> None: def set_optimizer_deployment(deployment: str) -> None:
+6 -6
View File
@@ -49,10 +49,10 @@ CLAUDE_CODE_EXEC_MAX_THINKING_TOKENS = max(
def set_optimizer_backend(backend: str) -> None: def set_optimizer_backend(backend: str) -> None:
global OPTIMIZER_BACKEND global OPTIMIZER_BACKEND
OPTIMIZER_BACKEND = normalize_backend_name(backend or "openai_chat") OPTIMIZER_BACKEND = normalize_backend_name(backend or "openai_chat")
if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat"}: if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "minimax_chat"}:
raise ValueError( raise ValueError(
f"Unsupported optimizer backend: {OPTIMIZER_BACKEND!r}. " f"Unsupported optimizer backend: {OPTIMIZER_BACKEND!r}. "
"Supported values are 'openai_chat' and 'claude_chat'." "Supported values are 'openai_chat', 'claude_chat', and 'minimax_chat'."
) )
os.environ["OPTIMIZER_BACKEND"] = OPTIMIZER_BACKEND os.environ["OPTIMIZER_BACKEND"] = OPTIMIZER_BACKEND
@@ -64,10 +64,10 @@ def get_optimizer_backend() -> str:
def set_target_backend(backend: str) -> None: def set_target_backend(backend: str) -> None:
global TARGET_BACKEND global TARGET_BACKEND
TARGET_BACKEND = normalize_backend_name(backend or "openai_chat") TARGET_BACKEND = normalize_backend_name(backend or "openai_chat")
if TARGET_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "codex_exec", "claude_code_exec"}: if TARGET_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec", "claude_code_exec"}:
raise ValueError( raise ValueError(
f"Unsupported target backend: {TARGET_BACKEND!r}. " f"Unsupported target backend: {TARGET_BACKEND!r}. "
"Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'codex_exec', and 'claude_code_exec'." "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', 'codex_exec', and 'claude_code_exec'."
) )
os.environ["TARGET_BACKEND"] = TARGET_BACKEND os.environ["TARGET_BACKEND"] = TARGET_BACKEND
@@ -81,11 +81,11 @@ def is_target_exec_backend() -> bool:
def is_optimizer_chat_backend() -> bool: def is_optimizer_chat_backend() -> bool:
return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat"} return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat", "minimax_chat"}
def is_target_chat_backend() -> bool: def is_target_chat_backend() -> bool:
return TARGET_BACKEND in {"openai_chat", "claude_chat", "qwen_chat"} return TARGET_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}
def configure_codex_exec( def configure_codex_exec(
+3
View File
@@ -25,6 +25,7 @@ _BACKEND_DEFAULT_MODELS = {
"claude_chat": "claude-sonnet-4-6", "claude_chat": "claude-sonnet-4-6",
"claude_code_exec": "claude-sonnet-4-6", "claude_code_exec": "claude-sonnet-4-6",
"qwen_chat": "Qwen/Qwen3.5-4B", "qwen_chat": "Qwen/Qwen3.5-4B",
"minimax_chat": "MiniMax-M2.7",
} }
_BACKEND_ALIASES = { _BACKEND_ALIASES = {
@@ -41,6 +42,8 @@ _BACKEND_ALIASES = {
"anthropic": "claude_chat", "anthropic": "claude_chat",
"qwen": "qwen_chat", "qwen": "qwen_chat",
"qwen_chat": "qwen_chat", "qwen_chat": "qwen_chat",
"minimax": "minimax_chat",
"minimax_chat": "minimax_chat",
} }
+277
View File
@@ -0,0 +1,277 @@
"""OpenAI-compatible MiniMax chat backend for the target path."""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.error
import urllib.request
from typing import Any
from skillopt.model.common import (
CompatAssistantMessage,
CompatToolCall,
CompatToolFunction,
TokenTracker,
default_model_for_backend,
)
BASE_URL = os.environ.get("MINIMAX_BASE_URL", "https://api.minimax.io/v1")
API_KEY = os.environ.get("MINIMAX_API_KEY", "")
TIMEOUT_SECONDS = float(os.environ.get("MINIMAX_TIMEOUT_SECONDS", "300") or 300)
MAX_TOKENS = int(os.environ.get("MINIMAX_MAX_TOKENS", "8000") or 8000)
TEMPERATURE: float | None = None
_raw_temperature = os.environ.get("MINIMAX_TEMPERATURE", "0.7").strip()
if _raw_temperature:
TEMPERATURE = float(_raw_temperature)
ENABLE_THINKING = os.environ.get("MINIMAX_ENABLE_THINKING", "false").strip().lower() in {
"1",
"true",
"yes",
"on",
}
TARGET_DEPLOYMENT = os.environ.get(
"TARGET_DEPLOYMENT",
default_model_for_backend("minimax_chat"),
)
_config_lock = threading.Lock()
tracker = TokenTracker()
def _chat_url() -> str:
base = BASE_URL.rstrip("/")
if base.endswith("/chat/completions"):
return base
return f"{base}/chat/completions"
def _json_safe(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()}
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return model_dump(mode="json")
except TypeError:
return model_dump()
return str(value)
def _usage_from_payload(payload: dict[str, Any]) -> dict[str, int]:
usage = payload.get("usage") or {}
prompt_tokens = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
def _compat_message_from_payload(message: dict[str, Any], choice: dict[str, Any]) -> CompatAssistantMessage:
content = message.get("content") or ""
if not isinstance(content, str):
content = json.dumps(content, ensure_ascii=False)
tool_calls: list[CompatToolCall] = []
for index, tool_call in enumerate(message.get("tool_calls") or [], start=1):
function = tool_call.get("function") or {}
tool_calls.append(
CompatToolCall(
id=str(tool_call.get("id") or f"minimax_tool_{index}"),
type=str(tool_call.get("type") or "function"),
function=CompatToolFunction(
name=str(function.get("name") or ""),
arguments=str(function.get("arguments") or "{}"),
),
)
)
return CompatAssistantMessage(
content=content,
tool_calls=tool_calls,
metadata={
"finish_reason": choice.get("finish_reason"),
"choice0": _json_safe(choice),
},
)
def _post_chat_completion(payload: dict[str, Any], timeout: float | None) -> dict[str, Any]:
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
req = urllib.request.Request(
_chat_url(),
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout or TIMEOUT_SECONDS) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"MiniMax chat API returned HTTP {e.code}: {body}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"MiniMax chat API request failed: {e}") from e
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise RuntimeError(f"MiniMax chat API returned non-JSON response: {raw[:1000]}") from e
def _chat_messages_impl(
messages: list[dict[str, Any]],
max_completion_tokens: int,
retries: int,
stage: str,
*,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
return_message: bool = False,
deployment: str | None = None,
timeout: float | None = None,
) -> tuple[Any, dict[str, int]]:
payload: dict[str, Any] = {
"model": deployment or TARGET_DEPLOYMENT,
"messages": _json_safe(messages),
"max_tokens": min(max_completion_tokens, MAX_TOKENS),
}
payload["chat_template_kwargs"] = {"enable_thinking": ENABLE_THINKING}
if TEMPERATURE is not None:
payload["temperature"] = TEMPERATURE
if tools:
payload["tools"] = _json_safe(tools)
if tool_choice is not None:
payload["tool_choice"] = _json_safe(tool_choice)
last_err: Exception | None = None
for attempt in range(retries):
try:
data = _post_chat_completion(payload, timeout)
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"MiniMax chat API returned no choices: {data}")
choice0 = choices[0]
message = choice0.get("message") or {}
text = message.get("content") or ""
if not isinstance(text, str):
text = json.dumps(text, ensure_ascii=False)
usage_info = _usage_from_payload(data)
tracker.record(stage, usage_info["prompt_tokens"], usage_info["completion_tokens"])
if return_message:
return _compat_message_from_payload(message, choice0), usage_info
return text, usage_info
except Exception as e: # noqa: BLE001
last_err = e
time.sleep(min(2 ** attempt, 30))
raise RuntimeError(f"MiniMax chat call failed after {retries} retries: {last_err}")
def configure_minimax_chat(
*,
base_url: str | None = None,
api_key: str | None = None,
temperature: float | str | None = None,
timeout_seconds: float | str | None = None,
max_tokens: int | str | None = None,
enable_thinking: bool | str | None = None,
) -> None:
global BASE_URL, API_KEY, TEMPERATURE, TIMEOUT_SECONDS, MAX_TOKENS, ENABLE_THINKING
with _config_lock:
if base_url is not None:
BASE_URL = str(base_url).strip() or BASE_URL
os.environ["MINIMAX_BASE_URL"] = BASE_URL
if api_key is not None:
API_KEY = str(api_key).strip()
os.environ["MINIMAX_API_KEY"] = API_KEY
if temperature is not None:
raw = str(temperature).strip()
TEMPERATURE = float(raw) if raw else None
os.environ["MINIMAX_TEMPERATURE"] = raw
if timeout_seconds is not None:
TIMEOUT_SECONDS = float(timeout_seconds)
os.environ["MINIMAX_TIMEOUT_SECONDS"] = str(timeout_seconds)
if max_tokens is not None:
MAX_TOKENS = int(max_tokens)
os.environ["MINIMAX_MAX_TOKENS"] = str(max_tokens)
if enable_thinking is not None:
if isinstance(enable_thinking, str):
ENABLE_THINKING = enable_thinking.strip().lower() in {"1", "true", "yes", "on"}
else:
ENABLE_THINKING = bool(enable_thinking)
os.environ["MINIMAX_ENABLE_THINKING"] = "true" if ENABLE_THINKING else "false"
def get_max_tokens() -> int:
return MAX_TOKENS
def chat_target(
system: str,
user: str,
max_completion_tokens: int = 16384,
retries: int = 5,
stage: str = "target",
reasoning_effort: str | None = None,
timeout: float | None = None,
) -> tuple[str, dict[str, int]]:
del reasoning_effort
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
return _chat_messages_impl(
messages,
max_completion_tokens,
retries,
stage,
timeout=timeout,
)
def chat_target_messages(
messages: list[dict[str, Any]],
max_completion_tokens: int = 16384,
retries: int = 5,
stage: str = "target",
reasoning_effort: str | None = None,
*,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
return_message: bool = False,
timeout: float | None = None,
) -> tuple[Any, dict[str, int]]:
del reasoning_effort
return _chat_messages_impl(
messages,
max_completion_tokens,
retries,
stage,
tools=tools,
tool_choice=tool_choice,
return_message=return_message,
timeout=timeout,
)
def get_token_summary() -> dict[str, dict[str, int]]:
return tracker.summary()
def reset_token_tracker() -> None:
tracker.reset()
def set_reasoning_effort(effort: str | None) -> None:
del effort
def set_target_deployment(deployment: str) -> None:
global TARGET_DEPLOYMENT
TARGET_DEPLOYMENT = deployment or default_model_for_backend("minimax_chat")
os.environ["TARGET_DEPLOYMENT"] = TARGET_DEPLOYMENT