diff --git a/.env.example b/.env.example index e7e10eeb..71a33739 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,10 @@ export AZURE_OPENAI_AUTH_MODE=api_key # model.optimizer/model.target for the selected Qwen roles. # ── MiniMax (for minimax_chat backend) ────────────────────────────── +# Pick the service region instead of hardcoding a host: global_en (default) +# resolves to https://api.minimax.io/v1 and cn_zh resolves to +# https://api.minimaxi.com/v1. MINIMAX_BASE_URL still overrides the region. +# export MINIMAX_REGION=global_en # export MINIMAX_BASE_URL=https://api.minimax.io/v1 # export MINIMAX_API_KEY=... # When MiniMax is the target, set model.minimax_model in YAML. The current diff --git a/configs/_base_/default.yaml b/configs/_base_/default.yaml index ccd12590..3edc85ee 100644 --- a/configs/_base_/default.yaml +++ b/configs/_base_/default.yaml @@ -55,7 +55,8 @@ model: target_azure_openai_managed_identity_client_id: "" # MiniMax backend settings (minimax_chat target) - minimax_base_url: "" # https://api.minimax.io/v1 if blank + 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-M2.7" minimax_temperature: "0.7" diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a7cb3024..9b043da6 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -63,6 +63,12 @@ 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 is served from region-specific hosts. Select one with +`model.minimax_region` (or `MINIMAX_REGION`) instead of hardcoding a host: +`global_en` (default) resolves to `https://api.minimax.io/v1` and `cn_zh` +resolves to `https://api.minimaxi.com/v1`. An explicit +`model.minimax_base_url` or `MINIMAX_BASE_URL` overrides the region default. + For a generic compatible provider, select the role backends explicitly rather than relying on a high-level shorthand: @@ -200,7 +206,8 @@ Model credentials are loaded from environment variables: | `CURSOR_API_KEY` | `cursor_exec` | Optional authentication method understood directly by Cursor Agent | | `QWEN_CHAT_BASE_URL` | `qwen_chat` | Local Qwen/vLLM endpoint | | `QWEN_CHAT_MODEL` | `qwen_chat` | Served model name for direct library use; train/eval YAML role models take precedence | -| `MINIMAX_BASE_URL` | `minimax_chat` | MiniMax-compatible base URL | +| `MINIMAX_REGION` | `minimax_chat` | Service region: `global_en` (default) or `cn_zh`; selects the base URL | +| `MINIMAX_BASE_URL` | `minimax_chat` | MiniMax-compatible base URL; overrides the region default | | `MINIMAX_API_KEY` | `minimax_chat` | MiniMax API key | | `COPILOT_EXEC_PATH` | `copilot_chat`, `copilot_exec` | Optional path to `copilot`; defaults to `copilot` | | `COPILOT_EXEC_HOME` | `copilot_chat`, `copilot_exec` | Optional `COPILOT_HOME` override isolating CLI config; sign-in state lives outside it | diff --git a/docs/reference/config.md b/docs/reference/config.md index c1e8ae53..52be030e 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -25,6 +25,9 @@ selecting the generic OpenAI-compatible backend. 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. +`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. | Parameter | Type | Default | Description | |---|---|---|---| @@ -64,7 +67,7 @@ defaults to `claude` and can be overridden with `CLAUDE_CLI_BIN`. |---|---| | `model.qwen_chat_*` | Shared `base_url`, `api_key`, `temperature`, `timeout_seconds`, `max_tokens`, and `enable_thinking` | | `model.optimizer_qwen_chat_*` / `model.target_qwen_chat_*` | Per-role Qwen overrides | -| `model.minimax_*` | MiniMax `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`, shared `minimax_model`, `temperature`, `max_tokens`, and `enable_thinking`; `minimax_model` applies when MiniMax is the target | | `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.cursor_exec_path` | Cursor Agent executable path; default `cursor-agent` | diff --git a/scripts/eval_only.py b/scripts/eval_only.py index 85b71c7e..ef84eda5 100644 --- a/scripts/eval_only.py +++ b/scripts/eval_only.py @@ -218,6 +218,7 @@ def parse_args() -> argparse.Namespace: p.add_argument("--copilot_chat_optimizer_model", type=str) p.add_argument("--copilot_chat_target_model", type=str) p.add_argument("--copilot_chat_timeout", type=int) + p.add_argument("--minimax_region", type=str) p.add_argument("--minimax_base_url", type=str) p.add_argument("--minimax_api_key", type=str) p.add_argument("--minimax_model", type=str) @@ -307,6 +308,7 @@ def main() -> None: "copilot_chat_optimizer_model": "model.copilot_chat_optimizer_model", "copilot_chat_target_model": "model.copilot_chat_target_model", "copilot_chat_timeout": "model.copilot_chat_timeout", + "minimax_region": "model.minimax_region", "minimax_base_url": "model.minimax_base_url", "minimax_api_key": "model.minimax_api_key", "minimax_model": "model.minimax_model", @@ -531,6 +533,7 @@ def _set_role(key: str, value: str) -> None: target_enable_thinking=cfg.get("target_qwen_chat_enable_thinking"), ) configure_minimax_chat( + region=cfg.get("minimax_region") or None, base_url=cfg.get("minimax_base_url") or None, api_key=cfg.get("minimax_api_key") or None, temperature=cfg.get("minimax_temperature"), diff --git a/scripts/train.py b/scripts/train.py index 977974a4..77e83ffa 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -192,6 +192,7 @@ def parse_args() -> argparse.Namespace: p.add_argument("--target_qwen_chat_timeout_seconds", type=float) p.add_argument("--target_qwen_chat_max_tokens", type=int) p.add_argument("--target_qwen_chat_enable_thinking", type=_BOOL) + p.add_argument("--minimax_region", type=str) p.add_argument("--minimax_base_url", type=str) p.add_argument("--minimax_api_key", type=str) p.add_argument("--minimax_model", type=str) @@ -412,6 +413,7 @@ def _retired_option_sources( "target_qwen_chat_timeout_seconds": "model.target_qwen_chat_timeout_seconds", "target_qwen_chat_max_tokens": "model.target_qwen_chat_max_tokens", "target_qwen_chat_enable_thinking": "model.target_qwen_chat_enable_thinking", + "minimax_region": "model.minimax_region", "minimax_base_url": "model.minimax_base_url", "minimax_api_key": "model.minimax_api_key", "minimax_model": "model.minimax_model", diff --git a/skillopt/config.py b/skillopt/config.py index 4de2cbed..1f232ca3 100644 --- a/skillopt/config.py +++ b/skillopt/config.py @@ -112,6 +112,7 @@ "model.target_qwen_chat_timeout_seconds": "target_qwen_chat_timeout_seconds", "model.target_qwen_chat_max_tokens": "target_qwen_chat_max_tokens", "model.target_qwen_chat_enable_thinking": "target_qwen_chat_enable_thinking", + "model.minimax_region": "minimax_region", "model.minimax_base_url": "minimax_base_url", "model.minimax_api_key": "minimax_api_key", "model.minimax_model": "minimax_model", diff --git a/skillopt/engine/trainer.py b/skillopt/engine/trainer.py index a648f0c6..036445f1 100644 --- a/skillopt/engine/trainer.py +++ b/skillopt/engine/trainer.py @@ -792,6 +792,7 @@ def _build_eval_env(split: str, env_num: int, seed: int): target_enable_thinking=cfg.get("target_qwen_chat_enable_thinking"), ) configure_minimax_chat( + region=cfg.get("minimax_region") or None, base_url=cfg.get("minimax_base_url") or None, api_key=cfg.get("minimax_api_key") or None, temperature=cfg.get("minimax_temperature"), diff --git a/skillopt/model/__init__.py b/skillopt/model/__init__.py index bcde7219..4f150a7f 100644 --- a/skillopt/model/__init__.py +++ b/skillopt/model/__init__.py @@ -685,6 +685,7 @@ def configure_qwen_chat( def configure_minimax_chat( *, + region: str | None = None, base_url: str | None = None, api_key: str | None = None, temperature: float | str | None = None, @@ -693,6 +694,7 @@ def configure_minimax_chat( enable_thinking: bool | str | None = None, ) -> None: _minimax.configure_minimax_chat( + region=region, base_url=base_url, api_key=api_key, temperature=temperature, diff --git a/skillopt/model/minimax_backend.py b/skillopt/model/minimax_backend.py index 7d9a42cb..f1e25b9f 100644 --- a/skillopt/model/minimax_backend.py +++ b/skillopt/model/minimax_backend.py @@ -17,7 +17,36 @@ default_model_for_backend, ) -BASE_URL = os.environ.get("MINIMAX_BASE_URL", "https://api.minimax.io/v1") +# The service is reachable through region-specific hostnames, so the selected +# region decides which OpenAI-compatible base URL the chat calls use. An +# explicit ``MINIMAX_BASE_URL`` still wins over the region default. +REGION_BASE_URLS = { + "global_en": "https://api.minimax.io/v1", + "cn_zh": "https://api.minimaxi.com/v1", +} +DEFAULT_REGION = "global_en" + + +def normalize_region(region: str | None) -> str: + """Return a supported region key, defaulting to the global region.""" + normalized = str(region or "").strip().lower().replace("-", "_") + if not normalized: + return DEFAULT_REGION + if normalized not in REGION_BASE_URLS: + raise ValueError( + f"Unsupported MiniMax region: {region!r}. " + f"Supported values are {sorted(REGION_BASE_URLS)}." + ) + return normalized + + +def base_url_for_region(region: str | None) -> str: + """Return the OpenAI-compatible base URL for a region.""" + return REGION_BASE_URLS[normalize_region(region)] + + +REGION = normalize_region(os.environ.get("MINIMAX_REGION")) +BASE_URL = os.environ.get("MINIMAX_BASE_URL", "").strip() or base_url_for_region(REGION) 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) @@ -177,6 +206,7 @@ def _chat_messages_impl( def configure_minimax_chat( *, + region: str | None = None, base_url: str | None = None, api_key: str | None = None, temperature: float | str | None = None, @@ -184,8 +214,13 @@ def configure_minimax_chat( 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 + global BASE_URL, API_KEY, TEMPERATURE, TIMEOUT_SECONDS, MAX_TOKENS, ENABLE_THINKING, REGION with _config_lock: + if region is not None: + REGION = normalize_region(region) + os.environ["MINIMAX_REGION"] = REGION + BASE_URL = base_url_for_region(REGION) + os.environ["MINIMAX_BASE_URL"] = BASE_URL if base_url is not None: BASE_URL = str(base_url).strip() or BASE_URL os.environ["MINIMAX_BASE_URL"] = BASE_URL @@ -214,6 +249,14 @@ def get_max_tokens() -> int: return MAX_TOKENS +def get_region() -> str: + return REGION + + +def get_base_url() -> str: + return BASE_URL + + def chat_target( system: str, user: str, diff --git a/tests/test_minimax_region.py b/tests/test_minimax_region.py new file mode 100644 index 00000000..3dcf09ef --- /dev/null +++ b/tests/test_minimax_region.py @@ -0,0 +1,118 @@ +"""Tests for MiniMax service-region selection in the minimax_chat backend.""" + +from __future__ import annotations + +import importlib +import os +from collections.abc import Iterator +from typing import Any + +import pytest + +from skillopt.model import minimax_backend + +_GLOBAL_BASE_URL = "https://api.minimax.io/v1" +_CN_BASE_URL = "https://api.minimaxi.com/v1" +_ENV_KEYS = ("MINIMAX_REGION", "MINIMAX_BASE_URL") +_GLOBAL_KEYS = ("REGION", "BASE_URL") + + +@pytest.fixture(autouse=True) +def isolate_minimax_region() -> Iterator[None]: + env_snapshot = {key: os.environ.get(key) for key in _ENV_KEYS} + global_snapshot = {key: getattr(minimax_backend, key) for key in _GLOBAL_KEYS} + try: + yield + finally: + for key, value in global_snapshot.items(): + setattr(minimax_backend, key, value) + for key, value in env_snapshot.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _reload_with_env(monkeypatch: pytest.MonkeyPatch, **env: str | None) -> Any: + for key, value in env.items(): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + return importlib.reload(minimax_backend) + + +def test_supported_regions_expose_both_endpoints() -> None: + assert minimax_backend.REGION_BASE_URLS == { + "global_en": _GLOBAL_BASE_URL, + "cn_zh": _CN_BASE_URL, + } + assert minimax_backend.DEFAULT_REGION == "global_en" + + +def test_blank_region_falls_back_to_global() -> None: + assert minimax_backend.normalize_region(None) == "global_en" + assert minimax_backend.normalize_region(" ") == "global_en" + assert minimax_backend.base_url_for_region(None) == _GLOBAL_BASE_URL + + +def test_region_name_is_normalized() -> None: + assert minimax_backend.normalize_region("CN_ZH") == "cn_zh" + assert minimax_backend.normalize_region(" cn-zh ") == "cn_zh" + assert minimax_backend.base_url_for_region("cn-zh") == _CN_BASE_URL + + +def test_unsupported_region_is_rejected() -> None: + with pytest.raises(ValueError, match="Unsupported MiniMax region"): + minimax_backend.normalize_region("apac") + + +def test_region_env_selects_base_url_at_import(monkeypatch: pytest.MonkeyPatch) -> None: + module = _reload_with_env(monkeypatch, MINIMAX_REGION="cn_zh", MINIMAX_BASE_URL=None) + try: + assert module.REGION == "cn_zh" + assert module.BASE_URL == _CN_BASE_URL + finally: + monkeypatch.undo() + importlib.reload(minimax_backend) + + +def test_explicit_base_url_env_wins_over_region(monkeypatch: pytest.MonkeyPatch) -> None: + module = _reload_with_env( + monkeypatch, + MINIMAX_REGION="cn_zh", + MINIMAX_BASE_URL="https://proxy.internal/v1", + ) + try: + assert module.REGION == "cn_zh" + assert module.BASE_URL == "https://proxy.internal/v1" + finally: + monkeypatch.undo() + importlib.reload(minimax_backend) + + +def test_configure_region_switches_base_url() -> None: + minimax_backend.configure_minimax_chat(region="cn_zh") + assert minimax_backend.get_region() == "cn_zh" + assert minimax_backend.get_base_url() == _CN_BASE_URL + assert minimax_backend._chat_url() == f"{_CN_BASE_URL}/chat/completions" + + minimax_backend.configure_minimax_chat(region="global_en") + assert minimax_backend.get_region() == "global_en" + assert minimax_backend.get_base_url() == _GLOBAL_BASE_URL + + +def test_configure_base_url_overrides_region() -> None: + minimax_backend.configure_minimax_chat( + region="cn_zh", + base_url="https://proxy.internal/v1", + ) + assert minimax_backend.get_region() == "cn_zh" + assert minimax_backend.get_base_url() == "https://proxy.internal/v1" + + +def test_configure_rejects_unsupported_region() -> None: + before = minimax_backend.get_base_url() + with pytest.raises(ValueError, match="Unsupported MiniMax region"): + minimax_backend.configure_minimax_chat(region="apac") + assert minimax_backend.get_base_url() == before