diff --git a/configs/_base_/default.yaml b/configs/_base_/default.yaml index 4fe64088d..ffb31179e 100644 --- a/configs/_base_/default.yaml +++ b/configs/_base_/default.yaml @@ -67,7 +67,7 @@ model: minimax_region: "" # global_en (default) or cn_zh; selects the base URL minimax_base_url: "" # region base URL if blank minimax_api_key: "" - minimax_model: "MiniMax-M3" + minimax_model: "MiniMax-M3.1-Flash" # MiniMax-M3 is also supported minimax_temperature: "0.7" minimax_max_tokens: "8000" minimax_enable_thinking: "false" diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 43467b22c..1231287f9 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -59,9 +59,16 @@ reports no token counts, so usage totals are zero. It does not support caller-supplied tools or structured tool calls; environments that require those features must use another chat backend. -The current MiniMax adapter has one shared deployment. Set -`model.minimax_model` when MiniMax is the target; a mixed-backend run cannot -independently select a MiniMax optimizer model and a different target model. +MiniMax defaults to `MiniMax-M3.1-Flash`; `MiniMax-M3` is also supported. +Set `model.minimax_model` for the target default, or explicitly select +`model.target` and `model.optimizer` for each role. Explicit legacy and custom +model IDs are preserved. + +Flash always uses adaptive thinking, even when `model.minimax_enable_thinking` +is false. Its `model.reasoning_effort` accepts `low`, `medium`, `high`, `xhigh`, +or `max`; an empty setting leaves the API default (`max`). The shared YAML +configuration sets `medium`. MiniMax-M3 continues to honor +`model.minimax_enable_thinking` and ignores reasoning effort. MiniMax is served from region-specific hosts. Select one with `model.minimax_region` (or `MINIMAX_REGION`) instead of hardcoding a host: diff --git a/docs/reference/config.md b/docs/reference/config.md index 6371f69dd..8a4a7846f 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -22,9 +22,12 @@ selecting the generic OpenAI-compatible backend. | `cursor_exec` | — | ✓ | | `copilot_exec` | — | ✓ | -MiniMax currently has one shared deployment. `model.minimax_model` is applied -when MiniMax is the target; mixed-backend runs cannot independently choose a -MiniMax optimizer model and a different target model. +MiniMax defaults to `MiniMax-M3.1-Flash`, with `MiniMax-M3` available explicitly. +`model.minimax_model` supplies the target default; explicit `model.target` and +`model.optimizer` values take precedence for their respective roles. +Flash always uses adaptive thinking and accepts reasoning effort `low`, `medium`, +`high`, `xhigh`, or `max`; empty effort uses the API default (`max`). MiniMax-M3 +honors `model.minimax_enable_thinking` and ignores effort. `model.minimax_region` selects the service region: `global_en` (default) resolves to `https://api.minimax.io/v1` and `cn_zh` resolves to `https://api.minimaxi.com/v1`. `model.minimax_base_url` overrides it. @@ -97,7 +100,7 @@ winner. Prefer `thinking_mode`; use `disabled` when you need the field sent. | `model.qwen_chat_*` | Shared `base_url`, `api_key`, `temperature`, `timeout_seconds`, `max_tokens`, `thinking_mode`, and the legacy `enable_thinking` | | `model.qwen_chat_thinking_mode` | Wire policy for `chat_template_kwargs.enable_thinking`: `server_default` (default; omit the field), `enabled`, or `disabled`. See [Qwen thinking mode](#qwen-thinking-mode) | | `model.optimizer_qwen_chat_*` / `model.target_qwen_chat_*` | Per-role Qwen overrides, including `*_qwen_chat_thinking_mode` | -| `model.minimax_*` | MiniMax `region`, `base_url`, `api_key`, shared `minimax_model`, `temperature`, `max_tokens`, and `enable_thinking`; `minimax_model` applies when MiniMax is the target | +| `model.minimax_*` | MiniMax `region`, `base_url`, `api_key`, default `minimax_model`, `temperature`, `max_tokens`, and `enable_thinking`; `minimax_model` applies when MiniMax is the target and no explicit target model is set | | `model.codex_exec_*` | Codex path, sandbox, profile, SDK mode, reasoning, network/search, and approval policy; see compatibility notes below | | `model.claude_code_exec_*` | Claude path, profile, SDK mode, effort, and thinking-token cap | | `model.codex_trace_to_optimizer` | When `true` (default) and target is `codex_exec`, inject the agent's codex trace steps into the reflection prompt | diff --git a/scripts/eval_only.py b/scripts/eval_only.py index e92c05c4b..45f3a9ed0 100644 --- a/scripts/eval_only.py +++ b/scripts/eval_only.py @@ -528,6 +528,12 @@ def _set_role(key: str, value: str) -> None: and not _has_model_override("model.optimizer", "optimizer_model") ): cfg["optimizer_model"] = default_model_for_backend("qwen_chat") + if cfg.get("optimizer_backend") == "minimax_chat": + if ( + (not cfg.get("optimizer_model") or str(cfg.get("optimizer_model", "") or "").strip() in _OPENAI_DEFAULT_MODEL_SENTINELS) + and not _has_model_override("model.optimizer", "optimizer_model") + ): + cfg["optimizer_model"] = default_model_for_backend("minimax_chat") if cfg.get("optimizer_backend") == "openai_compatible": if ( (not cfg.get("optimizer_model") or str(cfg.get("optimizer_model", "") or "").strip() in _OPENAI_DEFAULT_MODEL_SENTINELS) @@ -697,7 +703,7 @@ def main() -> None: ) minimax_model_cfg = cfg.get("minimax_model") if minimax_model_cfg and cfg.get("target_backend") == "minimax_chat": - set_target_deployment(str(minimax_model_cfg)) + set_target_deployment(str(cfg.get("target_model") or minimax_model_cfg)) configure_openai_compatible( base_url=cfg.get("openai_compatible_base_url") or None, api_key=cfg.get("openai_compatible_api_key") or None, diff --git a/scripts/train.py b/scripts/train.py index 630f05625..91a1654aa 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -745,6 +745,12 @@ def _set_role(key: str, value: str) -> None: and not _has_model_override("model.optimizer", "optimizer_model") ): flat["optimizer_model"] = default_model_for_backend("qwen_chat") + if flat.get("optimizer_backend") == "minimax_chat": + if ( + (not flat.get("optimizer_model") or str(flat.get("optimizer_model", "") or "").strip() in _OPENAI_DEFAULT_MODEL_SENTINELS) + and not _has_model_override("model.optimizer", "optimizer_model") + ): + flat["optimizer_model"] = default_model_for_backend("minimax_chat") if flat.get("optimizer_backend") == "openai_compatible": if ( (not flat.get("optimizer_model") or str(flat.get("optimizer_model", "") or "").strip() in _OPENAI_DEFAULT_MODEL_SENTINELS) diff --git a/skillopt/engine/trainer.py b/skillopt/engine/trainer.py index 144e37dc2..c65b6acfe 100644 --- a/skillopt/engine/trainer.py +++ b/skillopt/engine/trainer.py @@ -838,7 +838,7 @@ def _build_eval_env(split: str, env_num: int, seed: int): ) minimax_model_cfg = cfg.get("minimax_model") if minimax_model_cfg and cfg.get("target_backend") == "minimax_chat": - set_target_deployment(str(minimax_model_cfg)) + set_target_deployment(str(cfg.get("target_model") or minimax_model_cfg)) configure_openai_compatible( base_url=cfg.get("openai_compatible_base_url") or None, api_key=cfg.get("openai_compatible_api_key") or None, diff --git a/skillopt/envs/officeqa/rollout.py b/skillopt/envs/officeqa/rollout.py index 7d4c8e045..c20745381 100644 --- a/skillopt/envs/officeqa/rollout.py +++ b/skillopt/envs/officeqa/rollout.py @@ -396,7 +396,10 @@ def _run_custom_search_process( response = message.content or "" final_response = response last_response_metadata = _message_debug_metadata(message) - messages.append({"role": "assistant", "content": response}) + assistant_message = {"role": "assistant", "content": response} + if get_target_backend() == "minimax_chat" and getattr(message, "reasoning_content", None) is not None: + assistant_message["reasoning_content"] = message.reasoning_content + messages.append(assistant_message) message_event = {"type": "message", "turn": turn, "content": response} if last_response_metadata: message_event["response_metadata"] = last_response_metadata @@ -663,6 +666,8 @@ def process_one( response = message.content or "" final_response = response assistant_message = {"role": "assistant", "content": response} + if get_target_backend() == "minimax_chat" and getattr(message, "reasoning_content", None) is not None: + assistant_message["reasoning_content"] = message.reasoning_content if getattr(message, "tool_calls", None): assistant_message["tool_calls"] = [tool_call.model_dump(mode="json") for tool_call in message.tool_calls] messages.append(assistant_message) diff --git a/skillopt/envs/spreadsheetbench/react_agent.py b/skillopt/envs/spreadsheetbench/react_agent.py index 61faa1d65..7c69ce144 100644 --- a/skillopt/envs/spreadsheetbench/react_agent.py +++ b/skillopt/envs/spreadsheetbench/react_agent.py @@ -14,7 +14,7 @@ import sys import tempfile -from skillopt.model import chat_target_messages +from skillopt.model import chat_target_messages, get_target_backend from skillopt.prompts import load_prompt from skillopt.envs.spreadsheetbench.executor import generated_code_env @@ -344,6 +344,8 @@ def _react_loop( assistant_text = str(getattr(message, "content", "") or "") tool_calls = _assistant_tool_calls(message) assistant_payload: dict = {"role": "assistant", "content": assistant_text} + if get_target_backend() == "minimax_chat" and getattr(message, "reasoning_content", None) is not None: + assistant_payload["reasoning_content"] = message.reasoning_content if tool_calls: assistant_payload["tool_calls"] = tool_calls messages.append(assistant_payload) diff --git a/skillopt/model/common.py b/skillopt/model/common.py index cb91f415b..3f7a5f261 100644 --- a/skillopt/model/common.py +++ b/skillopt/model/common.py @@ -29,7 +29,7 @@ # deployments (not the CLI model, which uses the copilot_chat model keys) # and keeps the openai_chat optimizer for copilot_exec non-empty. "qwen_chat": "Qwen/Qwen3.5-4B", - "minimax_chat": "MiniMax-M3", + "minimax_chat": "MiniMax-M3.1-Flash", "openai_compatible": "gpt-4o-mini", } @@ -167,10 +167,13 @@ class CompatAssistantMessage: content: str tool_calls: list[CompatToolCall] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict) + reasoning_content: str | None = None def model_dump(self, mode: str = "json") -> dict[str, Any]: del mode data: dict[str, Any] = {"role": "assistant", "content": self.content} + if self.reasoning_content is not None: + data["reasoning_content"] = self.reasoning_content if self.tool_calls: data["tool_calls"] = [tool_call.model_dump() for tool_call in self.tool_calls] return data diff --git a/skillopt/model/minimax_backend.py b/skillopt/model/minimax_backend.py index 04bae635a..e9177508a 100644 --- a/skillopt/model/minimax_backend.py +++ b/skillopt/model/minimax_backend.py @@ -77,12 +77,15 @@ def base_url_for_region(region: str | None) -> str: # keeps thinking on regardless, so sending "disabled" there is a lie we would # then have to reason about downstream. Send the honest value instead. _ALWAYS_THINKING_PREFIXES: tuple[str, ...] = ("MiniMax-M2",) +_FLASH_MODEL = "MiniMax-M3.1-Flash" +_FLASH_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"}) +REASONING_EFFORT: str | None = None def _thinking_is_forced(deployment: str) -> bool: """True when ``deployment`` cannot honor ``thinking: {"type": "disabled"}``.""" name = str(deployment or "").strip() - return any(name.startswith(prefix) for prefix in _ALWAYS_THINKING_PREFIXES) + return name == _FLASH_MODEL or any(name.startswith(prefix) for prefix in _ALWAYS_THINKING_PREFIXES) def _resolve_thinking_type(deployment: str) -> str: @@ -159,6 +162,7 @@ def _compat_message_from_payload(message: dict[str, Any], choice: dict[str, Any] return CompatAssistantMessage( content=content, tool_calls=tool_calls, + reasoning_content=message.get("reasoning_content"), metadata={ "finish_reason": choice.get("finish_reason"), "choice0": _json_safe(choice), @@ -203,14 +207,22 @@ def _chat_messages_impl( tool_choice: str | dict[str, Any] | None = None, return_message: bool = False, deployment: str | None = None, + reasoning_effort: str | None = None, timeout: float | None = None, ) -> tuple[Any, dict[str, int]]: + model = deployment or TARGET_DEPLOYMENT payload: dict[str, Any] = { - "model": deployment or TARGET_DEPLOYMENT, + "model": model, "messages": _json_safe(messages), "max_tokens": min(max_completion_tokens, MAX_TOKENS), } - payload["thinking"] = {"type": _resolve_thinking_type(deployment or TARGET_DEPLOYMENT)} + payload["thinking"] = {"type": _resolve_thinking_type(model)} + if model == _FLASH_MODEL: + effort = REASONING_EFFORT if reasoning_effort is None else reasoning_effort + if effort: + if effort not in _FLASH_EFFORTS: + raise ValueError(f"Unsupported MiniMax-M3.1-Flash reasoning effort: {effort!r}") + payload["reasoning_effort"] = effort if TEMPERATURE is not None: payload["temperature"] = TEMPERATURE if tools: @@ -308,7 +320,6 @@ def chat_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, @@ -316,6 +327,7 @@ def chat_target( retries, stage, deployment=TARGET_DEPLOYMENT, + reasoning_effort=reasoning_effort, timeout=timeout, ) @@ -336,7 +348,6 @@ def chat_optimizer( (Azure) and produced "[skip] no usable patches" for any user running optimizer+target on `minimax_chat`. """ - del reasoning_effort messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] return _chat_messages_impl( messages, @@ -344,6 +355,7 @@ def chat_optimizer( retries, stage, deployment=OPTIMIZER_DEPLOYMENT, + reasoning_effort=reasoning_effort, timeout=timeout, ) @@ -360,7 +372,6 @@ def chat_target_messages( return_message: bool = False, timeout: float | None = None, ) -> tuple[Any, dict[str, int]]: - del reasoning_effort return _chat_messages_impl( messages, max_completion_tokens, @@ -370,6 +381,7 @@ def chat_target_messages( tool_choice=tool_choice, return_message=return_message, deployment=TARGET_DEPLOYMENT, + reasoning_effort=reasoning_effort, timeout=timeout, ) @@ -386,7 +398,6 @@ def chat_optimizer_messages( return_message: bool = False, timeout: float | None = None, ) -> tuple[Any, dict[str, int]]: - del reasoning_effort return _chat_messages_impl( messages, max_completion_tokens, @@ -396,6 +407,7 @@ def chat_optimizer_messages( tool_choice=tool_choice, return_message=return_message, deployment=OPTIMIZER_DEPLOYMENT, + reasoning_effort=reasoning_effort, timeout=timeout, ) @@ -409,7 +421,9 @@ def reset_token_tracker() -> None: def set_reasoning_effort(effort: str | None) -> None: - del effort + global REASONING_EFFORT + # Validate only when sending Flash: shared settings also reach other models. + REASONING_EFFORT = effort def set_target_deployment(deployment: str) -> None: diff --git a/tests/test_minimax_backend.py b/tests/test_minimax_backend.py index 8d3a787b8..f82d1db7b 100644 --- a/tests/test_minimax_backend.py +++ b/tests/test_minimax_backend.py @@ -98,9 +98,9 @@ def test_default_deployment_is_current_model(monkeypatch: pytest.MonkeyPatch) -> from skillopt.model import minimax_backend as backend module = importlib.reload(backend) - assert default_model_for_backend("minimax_chat") == "MiniMax-M3" - assert module.TARGET_DEPLOYMENT == "MiniMax-M3" - assert module.OPTIMIZER_DEPLOYMENT == "MiniMax-M3" + assert default_model_for_backend("minimax_chat") == "MiniMax-M3.1-Flash" + assert module.TARGET_DEPLOYMENT == "MiniMax-M3.1-Flash" + assert module.OPTIMIZER_DEPLOYMENT == "MiniMax-M3.1-Flash" def test_always_on_model_sends_adaptive_not_disabled(monkeypatch: pytest.MonkeyPatch, minimax_backend: Any) -> None: @@ -232,11 +232,11 @@ def test_fresh_import_optimizer_calls_without_setter( text, usage = module.chat_optimizer("system prompt", "user query", retries=1) assert text == "answer" - assert recorder.calls[0]["payload"]["model"] == "MiniMax-M3" + assert recorder.calls[0]["payload"]["model"] == "MiniMax-M3.1-Flash" msg, usage_msg = module.chat_optimizer_messages([{"role": "user", "content": "user query"}], retries=1) assert msg == "answer" - assert recorder.calls[1]["payload"]["model"] == "MiniMax-M3" + assert recorder.calls[1]["payload"]["model"] == "MiniMax-M3.1-Flash" def test_fresh_import_respects_optimizer_deployment_env( diff --git a/tests/test_minimax_flash.py b/tests/test_minimax_flash.py new file mode 100644 index 000000000..3fb40c4ff --- /dev/null +++ b/tests/test_minimax_flash.py @@ -0,0 +1,227 @@ +"""Flash configuration reaches the serialized HTTP request without inference.""" + +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from skillopt.model import minimax_backend as backend + + +@pytest.fixture +def requests(monkeypatch): + calls = [] + monkeypatch.setattr(backend, "TARGET_DEPLOYMENT", "MiniMax-M3.1-Flash") + monkeypatch.setattr(backend, "OPTIMIZER_DEPLOYMENT", "MiniMax-M3.1-Flash") + monkeypatch.setattr(backend, "ENABLE_THINKING", False) + monkeypatch.setattr(backend, "REASONING_EFFORT", None) + monkeypatch.setattr(backend, "API_KEY", "") + + def urlopen(request, timeout=None): + calls.append(json.loads(request.data)) + return io.BytesIO(json.dumps({ + "choices": [{"message": {"content": "answer"}}], + }).encode()) + + monkeypatch.setattr(backend.urllib.request, "urlopen", urlopen) + return calls + + +@pytest.mark.parametrize("method", [ + "chat_target", "chat_optimizer", "chat_target_messages", "chat_optimizer_messages", +]) +@pytest.mark.parametrize("effort", [None, "", "low", "medium", "high", "xhigh", "max"]) +def test_flash_effort_at_http_boundary(requests, method, effort): + args = ([{"role": "user", "content": "hello"}],) if method.endswith("messages") else ("system", "hello") + getattr(backend, method)(*args, reasoning_effort=effort, retries=1) + payload = requests[0] + assert payload["model"] == "MiniMax-M3.1-Flash" + assert payload["thinking"] == {"type": "adaptive"} + assert payload.get("reasoning_effort") == (effort or None) + assert not {"service_tier", "reasoning_split", "chat_template_kwargs"} & payload.keys() + + +def test_shared_effort_and_per_call_override(requests): + backend.set_reasoning_effort("high") + backend.chat_target("system", "hello", retries=1) + backend.chat_target("system", "hello", reasoning_effort="low", retries=1) + backend.chat_target("system", "hello", reasoning_effort="", retries=1) + assert requests[0]["reasoning_effort"] == "high" + assert requests[1]["reasoning_effort"] == "low" + assert "reasoning_effort" not in requests[2] + + +@pytest.mark.parametrize("effort", ["none", "minimal", "invalid"]) +def test_flash_invalid_effort_fails_before_http(requests, effort): + with pytest.raises(ValueError, match="reasoning effort"): + backend.chat_target("system", "hello", reasoning_effort=effort, retries=1) + assert requests == [] + + +@pytest.mark.parametrize("model,thinking", [ + ("MiniMax-M3", "disabled"), + ("MiniMax-M2.7", "adaptive"), + ("saved-custom-model", "disabled"), +]) +def test_saved_models_keep_their_thinking_and_ignore_effort(requests, monkeypatch, model, thinking): + monkeypatch.setattr(backend, "TARGET_DEPLOYMENT", model) + backend.chat_target("system", "hello", reasoning_effort="none", retries=1) + assert requests[0]["model"] == model + assert requests[0]["thinking"] == {"type": thinking} + assert "reasoning_effort" not in requests[0] + + +@pytest.mark.parametrize("loop", ["spreadsheet", "office"]) +def test_tool_loop_replays_flash_reasoning_at_http_boundary(requests, monkeypatch, tmp_path, loop): + calls = [] + reasoning = "Preserve this reasoning exactly." + tool_call = {"id": "call-1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}} + + def urlopen(request, timeout=None): + calls.append(json.loads(request.data)) + message = {"content": "", "reasoning_content": reasoning, "tool_calls": [tool_call]} + if len(calls) > 1: + message = {"content": "done"} + return io.BytesIO(json.dumps({"choices": [{"message": message}]}).encode()) + + monkeypatch.setattr(backend.urllib.request, "urlopen", urlopen) + if loop == "spreadsheet": + from skillopt.envs.spreadsheetbench import react_agent + + monkeypatch.setattr(react_agent, "chat_target_messages", backend.chat_target_messages) + monkeypatch.setattr(react_agent, "get_target_backend", lambda: "minimax_chat") + monkeypatch.setattr(react_agent, "_run_bash", lambda *args: "result") + react_agent._react_loop("system", "user", str(tmp_path), 2, 8000) + else: + from skillopt.envs.officeqa import rollout + + monkeypatch.setattr(rollout, "chat_target_messages", backend.chat_target_messages) + monkeypatch.setattr(rollout, "get_target_backend", lambda: "minimax_chat") + monkeypatch.setattr(rollout, "run_tool", lambda *args, **kwargs: ("lookup", "result")) + rollout.process_one({"id": "case", "question": "question", "answer": "done"}, + str(tmp_path), "skill", data_dirs=[str(tmp_path)], max_tool_turns=2) + assert len(calls) == 2 + assistant = next(message for message in calls[1]["messages"] if message["role"] == "assistant") + assert assistant["reasoning_content"] == reasoning + assert assistant["tool_calls"] == [tool_call] + assert calls[1]["messages"][-1]["tool_call_id"] == "call-1" + + +def test_compat_message_dump_preserves_reasoning(requests, monkeypatch): + message = {"content": "answer", "reasoning_content": "reasoning"} + monkeypatch.setattr(backend.urllib.request, "urlopen", lambda *args, **kwargs: io.BytesIO( + json.dumps({"choices": [{"message": message}]}).encode())) + response, _ = backend.chat_target_messages([], return_message=True, retries=1) + assert response.model_dump() == {"role": "assistant", **message} + + +# Exercise the actual entry-point setup in a fresh process: its global backend +# setters deliberately affect every adapter and must not leak into other tests. +_ENTRYPOINT_PROBE = r''' +import io +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +from scripts import eval_only, train +import skillopt.model as model +from skillopt.engine.trainer import ReflACTTrainer +from skillopt.model import minimax_backend as backend + +entry, config, output, region, expected, explicit = sys.argv[1:] +module = train if entry == "train" else eval_only +sys.argv = [entry, "--config", config, "--backend", "minimax", + "--optimizer_backend", "minimax_chat", "--out_root", output, + "--minimax_region", region] +if entry == "eval": + sys.argv += ["--skill", config] +if explicit == "cli": + sys.argv += ["--target_model", expected, "--optimizer_model", expected] +args = module.parse_args() +cfg = module.load_config(args) +assert cfg["target_model"] == expected, cfg["target_model"] +assert cfg["optimizer_model"] == (expected if explicit in {"cli", "saved"} else "MiniMax-M3.1-Flash") + +messages = [ + {"role": "user", "content": [ + {"type": "text", "text": "describe"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aGVsbG8="}}, + ]}, + {"role": "assistant", "content": "", "reasoning_content": "prior", + "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "result"}, +] +tools = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}] +calls = [] +def urlopen(request, timeout=None): + assert request.full_url == backend.REGION_BASE_URLS[region] + "/chat/completions" + assert request.get_header("Authorization") is None + calls.append(json.loads(request.data)) + return io.BytesIO(b'{"choices":[{"message":{"content":"answer"}}]}') +backend.urllib.request.urlopen = urlopen +backend.API_KEY = "" +class Configured(Exception): + pass + +def probe(*args): + model.chat_target_messages(messages, tools=tools, tool_choice="auto", retries=1) + model.chat_optimizer("system", "hello", retries=1) + assert calls[0]["model"] == expected + assert calls[0]["messages"] == messages + assert calls[0]["tools"] == tools + assert calls[0]["tool_choice"] == "auto" + assert calls[0]["max_tokens"] == 8000 + for payload in calls: + if payload["model"] == "MiniMax-M3.1-Flash": + assert payload["thinking"] == {"type": "adaptive"} + assert payload["reasoning_effort"] == "medium" + else: + assert "reasoning_effort" not in payload + raise Configured + +try: + if entry == "train": + adapter = SimpleNamespace(setup=lambda cfg: None, get_dataloader=lambda: None, requires_ray=probe) + ReflACTTrainer(cfg, adapter).train() + else: + eval_only.get_adapter = probe + eval_only.main() +except Configured: + pass +else: + raise AssertionError("model configuration probe was not reached") +assert len(calls) == 2 +''' + + +@pytest.mark.parametrize("entry", ["train", "eval"]) +@pytest.mark.parametrize("region", ["global_en", "cn_zh"]) +@pytest.mark.parametrize("explicit,expected", [ + ("default", "MiniMax-M3.1-Flash"), + ("provider", "MiniMax-M3"), + ("saved", "MiniMax-M2.7"), + ("cli", "saved-custom-model"), +]) +def test_entrypoint_config_to_http(tmp_path, entry, region, explicit, expected): + base = Path("configs/_base_/default.yaml").resolve() + config = tmp_path / "config.yaml" + extra = "" + if explicit == "provider": + extra = f"model:\n minimax_model: {expected}\n" + elif explicit == "saved": + extra = f"model:\n target: {expected}\n optimizer: {expected}\n" + config.write_text(f"_base_: {base}\n{extra}") + result = subprocess.run( + [sys.executable, "-c", _ENTRYPOINT_PROBE, entry, str(config), str(tmp_path / "output"), + region, expected, explicit], + capture_output=True, text=True, timeout=30, + env={key: os.environ[key] for key in ("PATH", "HOME", "LANG", "SYSTEMROOT", "PYTHONPATH", "PYTHONDONTWRITEBYTECODE") if key in os.environ}, + ) + assert result.returncode == 0, result.stdout + result.stderr