From 22b2a73c5cf6fbc866e8a63659da115dc79d00bf Mon Sep 17 00:00:00 2001 From: jellyjelly814 Date: Fri, 24 Jul 2026 15:14:57 +0800 Subject: [PATCH 1/4] feat(providers): add MiniMax Global and CN OAuth --- README.md | 2 +- README.zh-CN.md | 2 +- docs/dev.md | 2 +- raven/cli/_helpers.py | 7 + raven/cli/onboard_commands.py | 75 ++++- raven/cli/provider_commands.py | 39 ++- raven/config/schema.py | 2 + raven/config/update_providers.py | 41 ++- raven/providers/common_models.py | 10 + raven/providers/minimax_oauth.py | 349 ++++++++++++++++++++++ raven/providers/minimax_oauth_provider.py | 60 ++++ raven/providers/registry.py | 22 ++ raven/tui_rpc/methods/setup.py | 17 +- tests/test_cli_onboard_commands.py | 39 +++ tests/test_cli_provider_commands.py | 55 ++++ tests/test_minimax_oauth.py | 250 ++++++++++++++++ tests/test_provider_catalog.py | 22 +- tests/test_tui_rpc_setup.py | 52 ++++ 18 files changed, 1007 insertions(+), 39 deletions(-) create mode 100644 raven/providers/minimax_oauth.py create mode 100644 raven/providers/minimax_oauth_provider.py create mode 100644 tests/test_minimax_oauth.py diff --git a/README.md b/README.md index 46b3e97..a323f40 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ raven ``` Raven supports OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, GitHub Copilot, -OpenAI Codex OAuth, and custom OpenAI-compatible endpoints. +OpenAI Codex OAuth, MiniMax Global/CN OAuth, and custom OpenAI-compatible endpoints. If setup fails or a provider is not ready, run: diff --git a/README.zh-CN.md b/README.zh-CN.md index 234816f..dd9aa8e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -101,7 +101,7 @@ raven ``` Raven 支持 OpenRouter、OpenAI、Anthropic、Gemini、DeepSeek、GitHub Copilot、 -OpenAI Codex OAuth,以及自定义 OpenAI-compatible endpoints。 +OpenAI Codex OAuth、MiniMax Global/CN OAuth,以及自定义 OpenAI-compatible endpoints。 如果配置失败,或者 provider 还没有准备好,运行: diff --git a/docs/dev.md b/docs/dev.md index 1eae82c..0b10525 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -59,7 +59,7 @@ Creates `~/.raven/config.json` and the workspace directory. Edit the config to a | `raven gateway` | Start full server (all channels + heartbeat + cron) | | `raven status` | Show config path, workspace, and API key status | | `raven channels status` | Show which messaging channels are enabled | -| `raven provider login ` | Authenticate with an OAuth provider (e.g. `openai-codex`) | +| `raven provider login ` | Authenticate with an OAuth provider (for example `openai-codex`, `minimax-global`, or `minimax-cn`) | ### 6. Run tests diff --git a/raven/cli/_helpers.py b/raven/cli/_helpers.py index 55b0110..dae105a 100644 --- a/raven/cli/_helpers.py +++ b/raven/cli/_helpers.py @@ -103,6 +103,13 @@ def make_provider(config: Config): if provider_name == "openai_codex" or model.startswith("openai-codex/"): provider = OpenAICodexProvider(default_model=model) + elif provider_name in {"minimax_global", "minimax_cn"}: + from raven.providers.minimax_oauth_provider import MiniMaxOAuthProvider + + provider = MiniMaxOAuthProvider( + region="global" if provider_name == "minimax_global" else "cn", + default_model=model, + ) elif provider_name == "azure_openai": provider = AzureOpenAIProvider( api_key=p.api_key, diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index f1b94f4..dfecb41 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -123,6 +123,18 @@ def _t(en: str, zh: str) -> str: "label_zh": "Codex(OAuth 登录)", "is_oauth": True, }, + { + "name": "minimax_global", + "label": "MiniMax Global (OAuth)", + "label_zh": "MiniMax Global(OAuth 登录)", + "is_oauth": True, + }, + { + "name": "minimax_cn", + "label": "MiniMax CN (OAuth)", + "label_zh": "MiniMax CN(OAuth 登录)", + "is_oauth": True, + }, { "name": "custom", "label": "Other (OpenAI-compatible endpoint)", @@ -284,10 +296,10 @@ def _load_raw_config() -> dict[str, Any]: def _configured_providers() -> list[str]: - """Names of providers that currently have an api_key set on disk.""" - data = _load_raw_config() - providers = data.get("providers") or {} - return [name for name, p in providers.items() if isinstance(p, dict) and p.get("apiKey")] + """Names of providers with a usable API key or OAuth token.""" + from raven.config.update_providers import list_providers + + return [row["name"] for row in list_providers() if row["configured"]] def _is_config_populated() -> bool: @@ -298,9 +310,11 @@ def _is_config_populated() -> bool: not enough to talk to a model. """ data = _load_raw_config() - providers = data.get("providers") or {} - has_provider = any(isinstance(p, dict) and p.get("apiKey") for p in providers.values()) model = (data.get("agents", {}) or {}).get("defaults", {}).get("model") + configured = _configured_providers() + model_prefix = str(model or "").split("/", 1)[0].replace("-", "_") + has_non_minimax_provider = any(name not in {"minimax_global", "minimax_cn"} for name in configured) + has_provider = has_non_minimax_provider or model_prefix in configured return bool(has_provider and model) @@ -731,6 +745,11 @@ def _format_model_for_provider(spec: Any, model_id: str) -> str: """Apply ``spec.litellm_prefix`` to a raw ``/v1/models`` id when needed.""" if not model_id: return model_id + if spec.name in {"minimax_global", "minimax_cn"}: + public_prefix = spec.name.replace("_", "-") + if model_id.startswith(f"{public_prefix}/"): + return model_id + return f"{public_prefix}/{model_id.split('/')[-1]}" prefix = getattr(spec, "litellm_prefix", "") or "" if not prefix: return model_id @@ -880,7 +899,14 @@ def _failure_choice(options: list[tuple[str, str]], *, non_interactive: bool) -> return chosen -def _run_test_probe(provider: str, *, non_interactive: bool, warnings: list[str], allow_repick: bool = True) -> str: +def _run_test_probe( + provider: str, + *, + non_interactive: bool, + warnings: list[str], + allow_repick: bool = True, + is_oauth: bool = False, +) -> str: """Send a one-shot test message; on failure offer recovery options. Returns one of ``"ok"`` / ``"continue"`` / ``"repick"`` / ``"rekey"`` / @@ -910,17 +936,23 @@ def _run_test_probe(provider: str, *, non_interactive: bool, warnings: list[str] options = [(_t("Retry", "重试"), "retry")] if allow_repick: options.append((_t("Re-pick model", "重新选模型"), "repick")) + options.append( + (_t("Sign in again", "重新登录"), "reauth") if is_oauth else (_t("Re-enter key", "重新填 Key"), "rekey") + ) options += [ - (_t("Re-enter key", "重新填 Key"), "rekey"), (_t("Switch provider", "更换服务商"), "switch"), (_t("Continue anyway", "仍然继续"), "continue"), ] choice = _failure_choice(options, non_interactive=non_interactive) if choice == "retry": return _run_test_probe( - provider, non_interactive=non_interactive, warnings=warnings, allow_repick=allow_repick + provider, + non_interactive=non_interactive, + warnings=warnings, + allow_repick=allow_repick, + is_oauth=is_oauth, ) - if choice in ("repick", "rekey", "switch"): + if choice in ("repick", "rekey", "reauth", "switch"): return choice warnings.append("provider test message") return "continue" @@ -1141,7 +1173,11 @@ def _resolve_model_with_test( [(_t("Retry", "重试"), "retry"), (_t("Continue anyway", "仍然继续"), "continue")] if status == "network_error" else [ - (_t("Re-enter key", "重新填 Key"), "rekey"), + ( + (_t("Sign in again", "重新登录"), "reauth") + if spec.is_oauth + else (_t("Re-enter key", "重新填 Key"), "rekey") + ), (_t("Switch provider", "更换服务商"), "switch"), (_t("Continue anyway", "仍然继续"), "continue"), ] @@ -1152,6 +1188,10 @@ def _resolve_model_with_test( if choice == "rekey" and not non_interactive: _write_provider_fields(spec.name, {"api_key": _prompt_api_key(spec.name)}) continue + if choice == "reauth" and not non_interactive: + if _run_oauth_login(spec.name): + continue + return None if choice == "switch": return None warnings.append("provider connectivity") @@ -1187,7 +1227,12 @@ def _resolve_model_with_test( _persist_default_model(chosen) if skip_test: return chosen - result = _run_test_probe(spec.name, non_interactive=non_interactive, warnings=warnings) + result = _run_test_probe( + spec.name, + non_interactive=non_interactive, + warnings=warnings, + is_oauth=spec.is_oauth, + ) if result == "switch": return None if result == "rekey": @@ -1196,6 +1241,12 @@ def _resolve_model_with_test( current = chosen user_model_flag = None continue + if result == "reauth": + if not _run_oauth_login(spec.name): + return None + current = chosen + user_model_flag = None + continue if result == "repick": current = chosen user_model_flag = None diff --git a/raven/cli/provider_commands.py b/raven/cli/provider_commands.py index 28e9e37..92fbfaf 100644 --- a/raven/cli/provider_commands.py +++ b/raven/cli/provider_commands.py @@ -3,7 +3,7 @@ Lifecycle commands: - ``provider login `` — interactive OAuth login for OAuth-based - providers (openai-codex, github-copilot) + providers (OpenAI Codex, GitHub Copilot, MiniMax Global/CN) Config subcommands: @@ -54,7 +54,7 @@ def decorator(fn): @provider_app.command("login") def provider_login( - provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'minimax-global')"), ): """Authenticate with an OAuth provider.""" from raven.providers.registry import PROVIDERS @@ -123,6 +123,39 @@ async def _trigger(): raise typer.Exit(1) +def _login_minimax(region: str, label: str) -> None: + from raven.providers.minimax_oauth import login + + console.print("[cyan]Starting MiniMax device flow...[/cyan]\n") + try: + token = login( + region, + print_fn=lambda message: console.print(message), + open_browser=( + bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) + if sys.platform.startswith("linux") + else True + ), + ) + except Exception as exc: + console.print(f"[red]Authentication error: {exc}[/red]") + raise typer.Exit(1) + if not token.access: + console.print("[red]Authentication failed[/red]") + raise typer.Exit(1) + console.print(f"[green]Authenticated with {label}[/green]") + + +@_register_login("minimax_global") +def _login_minimax_global() -> None: + _login_minimax("global", "MiniMax Global") + + +@_register_login("minimax_cn") +def _login_minimax_cn() -> None: + _login_minimax("cn", "MiniMax CN") + + def _help_requested(extra_args: list[str]) -> bool: """Detect ``--help`` / ``-h`` inside a free-form ``ctx.args`` list.""" return any(t in ("--help", "-h") or t.startswith("--help=") for t in extra_args) @@ -396,7 +429,7 @@ def provider_reset_cmd( ): """Restore a provider to schema defaults. Key preserved, values reset. - For OAuth providers (openai-codex, github-copilot) the on-disk token + For OAuth providers the on-disk token file written by ``oauth_cli_kit`` is also deleted, so the user is effectively logged out and must re-run ``provider login`` to use it. """ diff --git a/raven/config/schema.py b/raven/config/schema.py index 7fc7170..79eea44 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -378,6 +378,8 @@ class ProvidersConfig(Base): gemini: GeminiProviderConfig = Field(default_factory=GeminiProviderConfig) # Google Gemini / Vertex AI moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) + minimax_global: ProviderConfig = Field(default_factory=ProviderConfig) + minimax_cn: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow diff --git a/raven/config/update_providers.py b/raven/config/update_providers.py index 4f21a1d..6156486 100644 --- a/raven/config/update_providers.py +++ b/raven/config/update_providers.py @@ -5,7 +5,7 @@ functions defined here. Direct ``load_config`` / ``save_config`` on the providers section is forbidden -- see plan rule. -OAuth providers (``openai_codex`` / ``github_copilot``) have a separate +OAuth providers have a separate auth path via ``provider_commands._LOGIN_HANDLERS`` and store tokens via ``oauth_cli_kit``, not in ``config.json``. ``set_provider_fields`` refuses to write ``api_key`` for those providers; callers must invoke @@ -357,7 +357,12 @@ def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]: api_key_list = list(getattr(instance, "api_key_list", []) or []) if is_oauth: - configured = _oauth_token_path(fname).exists() + if fname in {"minimax_global", "minimax_cn"}: + from raven.providers.minimax_oauth import load_token + + configured = load_token("global" if fname == "minimax_global" else "cn") is not None + else: + configured = _oauth_token_path(fname).exists() api_key_redacted = "OAuth token" if configured else "(empty)" elif is_local: configured = bool(api_base) or bool(api_key) @@ -515,13 +520,17 @@ def reset_provider( _write_atomic(path, data) if spec.is_oauth: - token_path = _oauth_token_path(name) try: - token_path.unlink(missing_ok=True) + if name in {"minimax_global", "minimax_cn"}: + from raven.providers.minimax_oauth import delete_token + + delete_token("global" if name == "minimax_global" else "cn") + else: + _oauth_token_path(name).unlink(missing_ok=True) except OSError as exc: logger.warning( - "update_providers: failed to unlink OAuth token {}: {}", - token_path, + "update_providers: failed to unlink OAuth token for {}: {}", + name, exc, ) @@ -621,8 +630,8 @@ def test_provider( Behavior: - 1. Look up the provider's ``api_key`` (or OAuth access token via - ``oauth_cli_kit.get_token()`` for OAuth providers) and ``api_base`` + 1. Look up the provider's ``api_key`` or provider-specific OAuth access + token and ``api_base`` (falling back to ``ProviderSpec.default_api_base`` when unset). 2. ``GET {api_base}/v1/models`` with ``Authorization: Bearer {key}``. 3. Map status code → keyword (see ``_HTTP_STATUS_MAP``). Unknown codes @@ -652,7 +661,15 @@ def test_provider( if spec.is_oauth: try: - from oauth_cli_kit import get_token + if spec.name in {"minimax_global", "minimax_cn"}: + from raven.providers.minimax_oauth import get_token + + token = get_token("global" if spec.name == "minimax_global" else "cn") + api_base = token.resource_url + else: + from oauth_cli_kit import get_token + + token = get_token() except ImportError: return { "ok": False, @@ -661,10 +678,8 @@ def test_provider( "http_status": None, "models_count": None, "model_ids": None, - "error": "oauth_cli_kit not installed", + "error": "OAuth support is not installed", } - try: - token = get_token() except Exception as exc: return { "ok": False, @@ -714,6 +729,8 @@ def test_provider( url = api_base.rstrip("/") + "/v1/models" headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + if spec.name in {"minimax_global", "minimax_cn"} and api_key: + headers["x-api-key"] = api_key start = time.monotonic() client_kwargs: dict[str, Any] = {"timeout": timeout_s} diff --git a/raven/providers/common_models.py b/raven/providers/common_models.py index c61fd64..ee32cf8 100644 --- a/raven/providers/common_models.py +++ b/raven/providers/common_models.py @@ -75,6 +75,16 @@ "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", ], + "minimax_global": [ + "minimax-global/MiniMax-M3", + "minimax-global/MiniMax-M2.7", + "minimax-global/MiniMax-M2.7-highspeed", + ], + "minimax_cn": [ + "minimax-cn/MiniMax-M3", + "minimax-cn/MiniMax-M2.7", + "minimax-cn/MiniMax-M2.7-highspeed", + ], "zhipu": [ "zai/glm-5.2", "zai/glm-5.1", diff --git a/raven/providers/minimax_oauth.py b/raven/providers/minimax_oauth.py new file mode 100644 index 0000000..f5cbb52 --- /dev/null +++ b/raven/providers/minimax_oauth.py @@ -0,0 +1,349 @@ +"""MiniMax Token Plan OAuth device flow and token storage.""" + +from __future__ import annotations + +import json +import os +import tempfile +import time +import webbrowser +from contextlib import contextmanager +from dataclasses import asdict, dataclass +from hashlib import sha256 +from pathlib import Path +from secrets import token_bytes +from typing import Callable, Iterator +from urllib.parse import urlparse + +import httpx +import portalocker +from platformdirs import user_data_dir + +CLIENT_ID = "coding-plan-cli" +DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" +OAUTH_SCOPE = "openid profile coding_plan" +REFRESH_BUFFER_MS = 5 * 60 * 1000 +OAUTH_STORAGE_DIR_ENV = "MINIMAX_OAUTH_TOKEN_DIR" + + +@dataclass(frozen=True) +class MiniMaxOAuthConfig: + provider: str + auth_base_url: str + default_resource_url: str + + +@dataclass(frozen=True) +class MiniMaxOAuthToken: + access: str + refresh: str + expires: int + resource_url: str + + +CONFIGS = { + "global": MiniMaxOAuthConfig( + provider="minimax_global", + auth_base_url="https://account.minimax.io", + default_resource_url="https://api.minimax.io/anthropic/v1", + ), + "cn": MiniMaxOAuthConfig( + provider="minimax_cn", + auth_base_url="https://account.minimaxi.com", + default_resource_url="https://api.minimaxi.com/anthropic/v1", + ), +} + + +def oauth_config(region: str) -> MiniMaxOAuthConfig: + try: + return CONFIGS[region] + except KeyError as exc: + raise ValueError(f"Unsupported MiniMax region: {region}") from exc + + +def _validated_url(value: str, expected_url: str, field: str) -> str: + parsed = urlparse(value) + expected = urlparse(expected_url) + if ( + parsed.scheme != "https" + or parsed.hostname != expected.hostname + or parsed.port not in {None, 443} + or parsed.username is not None + or parsed.password is not None + ): + raise RuntimeError(f"MiniMax returned an invalid {field}") + return value + + +def token_path(region: str) -> Path: + config = oauth_config(region) + base_dir = os.environ.get(OAUTH_STORAGE_DIR_ENV) + auth_dir = Path(base_dir) if base_dir else Path(user_data_dir("oauth-cli-kit", appauthor=False)) / "auth" + return auth_dir / f"{config.provider}.json" + + +def _normalize_expiry(value: object, now_ms: int | None = None) -> int: + try: + raw = int(value) + except (TypeError, ValueError) as exc: + raise RuntimeError("MiniMax returned an invalid expiry") from exc + if raw <= 0: + raise RuntimeError("MiniMax returned an invalid expiry") + now_ms = now_ms if now_ms is not None else int(time.time() * 1000) + if raw < 1_000_000_000: + return now_ms + raw * 1000 + if raw < 1_000_000_000_000: + return raw * 1000 + return raw + + +def load_token(region: str) -> MiniMaxOAuthToken | None: + path = token_path(region) + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + config = oauth_config(region) + resource_url = _validated_url(str(data["resource_url"]), config.default_resource_url, "resource URL") + return MiniMaxOAuthToken( + access=str(data["access"]), + refresh=str(data["refresh"]), + expires=int(data["expires"]), + resource_url=resource_url, + ) + except (KeyError, TypeError, ValueError, RuntimeError, json.JSONDecodeError, OSError): + return None + + +def save_token(region: str, token: MiniMaxOAuthToken) -> None: + path = token_path(region) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(asdict(token), handle, ensure_ascii=True, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp_name, 0o600) + os.replace(tmp_name, path) + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError: + pass + finally: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass + + +@contextmanager +def _token_lock(region: str) -> Iterator[None]: + path = token_path(region).with_suffix(".lock") + path.parent.mkdir(parents=True, exist_ok=True) + with portalocker.Lock(path, mode="a+", timeout=600): + yield + + +def _base64url(value: bytes) -> str: + import base64 + + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _request_refresh( + config: MiniMaxOAuthConfig, + refresh_token: str, + *, + client: httpx.Client, + sleep_fn: Callable[[float], None], +) -> MiniMaxOAuthToken: + last_error: Exception | None = None + for attempt in range(3): + if attempt: + sleep_fn(0.5 * attempt) + try: + response = client.post( + f"{config.auth_base_url}/oauth2/token", + data={ + "grant_type": "refresh_token", + "client_id": CLIENT_ID, + "refresh_token": refresh_token, + }, + ) + except httpx.TransportError as exc: + last_error = exc + continue + if 400 <= response.status_code < 500: + raise RuntimeError(f"MiniMax refresh token rejected: HTTP {response.status_code}") + if response.status_code >= 500: + last_error = RuntimeError(f"MiniMax refresh failed: HTTP {response.status_code}") + continue + response.raise_for_status() + payload = response.json() + if payload.get("status") != "success" or not payload.get("access_token"): + raise RuntimeError("MiniMax refresh returned an invalid response") + expires = _normalize_expiry(payload.get("expired_in")) + if expires <= int(time.time() * 1000): + raise RuntimeError("MiniMax refresh returned an invalid expiry") + resource_url = _validated_url( + str(payload.get("resource_url") or config.default_resource_url), + config.default_resource_url, + "resource URL", + ) + return MiniMaxOAuthToken( + access=str(payload["access_token"]), + refresh=str(payload.get("refresh_token") or refresh_token), + expires=expires, + resource_url=resource_url, + ) + raise RuntimeError("MiniMax refresh failed after transient retries") from last_error + + +def get_token( + region: str, + *, + min_ttl_ms: int = REFRESH_BUFFER_MS, + client: httpx.Client | None = None, + sleep_fn: Callable[[float], None] = time.sleep, +) -> MiniMaxOAuthToken: + token = load_token(region) + if token is None: + raise RuntimeError(f"MiniMax {region} credentials not found. Run raven provider login first.") + if token.expires > int(time.time() * 1000) + min_ttl_ms: + return token + + config = oauth_config(region) + owns_client = client is None + client = client or httpx.Client(timeout=30) + try: + with _token_lock(region): + latest = load_token(region) or token + if latest.expires > int(time.time() * 1000) + min_ttl_ms: + return latest + refreshed = _request_refresh(config, latest.refresh, client=client, sleep_fn=sleep_fn) + save_token(region, refreshed) + return refreshed + finally: + if owns_client: + client.close() + + +def _login_locked( + region: str, + *, + print_fn: Callable[[str], None] = print, + open_browser: bool = True, + client: httpx.Client | None = None, + sleep_fn: Callable[[float], None] = time.sleep, +) -> MiniMaxOAuthToken: + config = oauth_config(region) + verifier = _base64url(token_bytes(32)) + challenge = _base64url(sha256(verifier.encode("ascii")).digest()) + state = _base64url(token_bytes(16)) + owns_client = client is None + client = client or httpx.Client(timeout=30) + try: + response = client.post( + f"{config.auth_base_url}/oauth2/device/code", + data={ + "client_id": CLIENT_ID, + "scope": OAUTH_SCOPE, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + }, + ) + response.raise_for_status() + device = response.json() + if device.get("state") != state: + raise RuntimeError("MiniMax OAuth state mismatch") + verification_uri = str(device.get("verification_uri") or "") + user_code = str(device.get("user_code") or "") + deadline = _normalize_expiry(device.get("expired_in")) + interval_ms = max(2000, int(device.get("interval") or 5000)) + if not verification_uri or not user_code or deadline <= int(time.time() * 1000): + raise RuntimeError("MiniMax device authorization returned an invalid response") + verification_uri = _validated_url(verification_uri, config.auth_base_url, "verification URL") + + print_fn(f"Open {verification_uri}") + print_fn(f"Enter code: {user_code}") + if open_browser: + try: + webbrowser.open(verification_uri) + except Exception: + pass + + while int(time.time() * 1000) < deadline: + sleep_fn((interval_ms + 1000) / 1000) + token_response = client.post( + f"{config.auth_base_url}/oauth2/token", + data={ + "grant_type": DEVICE_GRANT_TYPE, + "client_id": CLIENT_ID, + "user_code": user_code, + "code_verifier": verifier, + }, + ) + try: + payload = token_response.json() + except json.JSONDecodeError: + token_response.raise_for_status() + raise RuntimeError("MiniMax token polling returned invalid JSON") + status = payload.get("status") + error = payload.get("error") + if status == "pending" or error == "authorization_pending": + continue + if error == "slow_down": + interval_ms += 5000 + continue + token_response.raise_for_status() + if status != "success" or not payload.get("access_token") or not payload.get("refresh_token"): + raise RuntimeError("MiniMax authorization failed or returned incomplete credentials") + resource_url = _validated_url( + str(payload.get("resource_url") or config.default_resource_url), + config.default_resource_url, + "resource URL", + ) + token = MiniMaxOAuthToken( + access=str(payload["access_token"]), + refresh=str(payload["refresh_token"]), + expires=_normalize_expiry(payload.get("expired_in")), + resource_url=resource_url, + ) + if token.expires <= int(time.time() * 1000): + raise RuntimeError("MiniMax authorization returned an invalid expiry") + save_token(region, token) + return token + raise RuntimeError("MiniMax device authorization timed out") + finally: + if owns_client: + client.close() + + +def login( + region: str, + *, + print_fn: Callable[[str], None] = print, + open_browser: bool = True, + client: httpx.Client | None = None, + sleep_fn: Callable[[float], None] = time.sleep, +) -> MiniMaxOAuthToken: + with _token_lock(region): + return _login_locked( + region, + print_fn=print_fn, + open_browser=open_browser, + client=client, + sleep_fn=sleep_fn, + ) + + +def delete_token(region: str) -> None: + with _token_lock(region): + token_path(region).unlink(missing_ok=True) diff --git a/raven/providers/minimax_oauth_provider.py b/raven/providers/minimax_oauth_provider.py new file mode 100644 index 0000000..a3473ce --- /dev/null +++ b/raven/providers/minimax_oauth_provider.py @@ -0,0 +1,60 @@ +"""LiteLLM-backed MiniMax Token Plan OAuth provider.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +from raven.providers.base import LLMResponse, StreamDelta +from raven.providers.litellm_provider import LiteLLMProvider +from raven.providers.minimax_oauth import get_token, oauth_config + + +class MiniMaxOAuthProvider(LiteLLMProvider): + def __init__(self, region: str, default_model: str): + self.region = region + config = oauth_config(region) + super().__init__( + api_base=config.default_resource_url, + default_model=default_model, + provider_name=config.provider, + ) + + async def _prepare_token(self) -> None: + token = await asyncio.to_thread(get_token, self.region) + self.api_key = token.access + self.api_base = token.resource_url + self.extra_headers = { + "x-api-key": token.access, + "Authorization": f"Bearer {token.access}", + } + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> LLMResponse: + await self._prepare_token() + return await super().chat(messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice) + + async def chat_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> AsyncIterator[StreamDelta]: + await self._prepare_token() + async for delta in super().chat_stream( + messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice + ): + yield delta diff --git a/raven/providers/registry.py b/raven/providers/registry.py index 07b356c..6a4f0ff 100644 --- a/raven/providers/registry.py +++ b/raven/providers/registry.py @@ -358,6 +358,28 @@ def label(self) -> str: strip_model_prefix=False, model_overrides=(), ), + ProviderSpec( + name="minimax_global", + keywords=("minimax-global",), + env_key="", + display_name="MiniMax Global (OAuth)", + litellm_prefix="anthropic", + skip_prefixes=("anthropic/",), + default_api_base="https://api.minimax.io/anthropic/v1", + is_oauth=True, + default_model="minimax-global/MiniMax-M3", + ), + ProviderSpec( + name="minimax_cn", + keywords=("minimax-cn",), + env_key="", + display_name="MiniMax CN (OAuth)", + litellm_prefix="anthropic", + skip_prefixes=("anthropic/",), + default_api_base="https://api.minimaxi.com/anthropic/v1", + is_oauth=True, + default_model="minimax-cn/MiniMax-M3", + ), # === Local deployment (matched by config key, NOT by api_base) ========= # vLLM / any OpenAI-compatible local server. # Detected when config key is "vllm" (provider_name="vllm"). diff --git a/raven/tui_rpc/methods/setup.py b/raven/tui_rpc/methods/setup.py index bd1d8b8..2b9b182 100644 --- a/raven/tui_rpc/methods/setup.py +++ b/raven/tui_rpc/methods/setup.py @@ -66,11 +66,24 @@ def _detect_provider_configured(payload: dict) -> bool: provider = defaults.get("provider") if isinstance(provider, str) and provider and provider != _AUTO_SENTINEL: + if provider in {"minimax_global", "minimax_cn"}: + from raven.providers.minimax_oauth import load_token + + region = "global" if provider == "minimax_global" else "cn" + return load_token(region) is not None return True providers = payload.get("providers") - if isinstance(providers, dict) and any(isinstance(v, dict) and v.get("apiKey") for v in providers.values()): - return True + if isinstance(providers, dict): + if any(isinstance(v, dict) and v.get("apiKey") for v in providers.values()): + return True + + model_prefix = model.split("/", 1)[0] + if model_prefix in {"minimax-global", "minimax-cn"}: + from raven.providers.minimax_oauth import load_token + + region = "global" if model_prefix == "minimax-global" else "cn" + return load_token(region) is not None return False diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 82f6dbb..e9c28d5 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -774,12 +774,27 @@ def test_registry_default_models_present() -> None: "deepseek", "github_copilot", "openai_codex", + "minimax_global", + "minimax_cn", ): spec = find_by_name(name) assert spec is not None, f"missing provider in registry: {name}" assert spec.default_model, f"{name} has empty default_model" +@pytest.mark.parametrize( + ("provider", "model", "expected"), + [ + ("minimax_global", "MiniMax-M3", "minimax-global/MiniMax-M3"), + ("minimax_cn", "MiniMax-M3", "minimax-cn/MiniMax-M3"), + ], +) +def test_minimax_catalog_models_keep_public_provider_prefix(provider: str, model: str, expected: str) -> None: + from raven.providers.registry import find_by_name + + assert onboard_commands._format_model_for_provider(find_by_name(provider), model) == expected + + # --------------------------------------------------------------------------- fixtures (5-step) @@ -820,6 +835,30 @@ def test_is_config_populated_requires_provider_and_model(tmp_env: Path) -> None: assert onboard_commands._is_config_populated() is True +def test_is_config_populated_accepts_minimax_oauth_token( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from raven.config.update import set_default_model + from raven.providers.minimax_oauth import MiniMaxOAuthToken, save_token + + monkeypatch.setenv("MINIMAX_OAUTH_TOKEN_DIR", str(tmp_path)) + save_token( + "global", + MiniMaxOAuthToken( + "access", + "refresh", + 4_000_000_000_000, + "https://api.minimax.io/anthropic/v1", + ), + ) + set_default_model("minimax-global/MiniMax-M3") + + assert "minimax_global" in onboard_commands._configured_providers() + assert onboard_commands._is_config_populated() is True + + def test_ensure_configured_short_circuits_when_complete(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The gate returns True (no wizard) when config is already complete.""" _seed_provider() diff --git a/tests/test_cli_provider_commands.py b/tests/test_cli_provider_commands.py index e545fae..9a9c648 100644 --- a/tests/test_cli_provider_commands.py +++ b/tests/test_cli_provider_commands.py @@ -150,6 +150,36 @@ async def boom(**_): assert "Authentication error" in r.stdout +@pytest.mark.parametrize( + ("provider", "region", "label"), + [ + ("minimax-global", "global", "MiniMax Global"), + ("minimax-cn", "cn", "MiniMax CN"), + ], +) +def test_provider_login_minimax_success( + provider: str, + region: str, + label: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from types import SimpleNamespace + + seen: dict[str, object] = {} + + def fake_login(actual_region: str, **kwargs): + seen["region"] = actual_region + seen.update(kwargs) + return SimpleNamespace(access="access") + + monkeypatch.setattr("raven.providers.minimax_oauth.login", fake_login) + r = runner.invoke(app, ["provider", "login", provider]) + + assert r.exit_code == 0 + assert seen["region"] == region + assert f"Authenticated with {label}" in r.stdout + + def test_provider_help_lists_all_subcommands() -> None: r = runner.invoke(app, ["provider", "--help"]) assert r.exit_code == 0 @@ -261,6 +291,31 @@ def test_reset_oauth_idempotent_when_no_token_file( assert r.exit_code == 0, r.stdout +def test_reset_clears_minimax_oauth_token( + tmp_config: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from raven.providers.minimax_oauth import MiniMaxOAuthToken, save_token + + token_file = tmp_path / "minimax_global.json" + monkeypatch.setenv("MINIMAX_OAUTH_TOKEN_DIR", str(tmp_path)) + save_token( + "global", + MiniMaxOAuthToken( + "access", + "refresh", + 4_000_000_000_000, + "https://api.minimax.io/anthropic/v1", + ), + ) + + r = runner.invoke(app, ["provider", "reset", "minimax_global", "--yes"]) + + assert r.exit_code == 0, r.stdout + assert not token_file.exists() + + def test_get_unknown_provider_exits_1(tmp_config: Path) -> None: r = runner.invoke(app, ["provider", "get", "no-such-provider"]) assert r.exit_code == 1 diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py new file mode 100644 index 0000000..307813c --- /dev/null +++ b/tests/test_minimax_oauth.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import time +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest + +from raven.providers.minimax_oauth import ( + CLIENT_ID, + DEVICE_GRANT_TYPE, + OAUTH_SCOPE, + MiniMaxOAuthToken, + _normalize_expiry, + get_token, + load_token, + login, + save_token, +) +from raven.providers.minimax_oauth_provider import MiniMaxOAuthProvider + + +@pytest.fixture +def token_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("MINIMAX_OAUTH_TOKEN_DIR", str(tmp_path)) + return tmp_path / "minimax_global.json" + + +def test_login_device_flow_persists_complete_token(token_file: Path) -> None: + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + form = dict(httpx.QueryParams(request.content.decode())) + if request.url.path.endswith("/device/code"): + assert form["client_id"] == CLIENT_ID + assert form["scope"] == OAUTH_SCOPE + assert form["code_challenge_method"] == "S256" + assert "response_type" not in form + return httpx.Response( + 200, + json={ + "verification_uri": "https://account.minimax.io/device", + "user_code": "ABCD", + "expired_in": int(time.time() * 1000) + 60_000, + "interval": 2_000, + "state": form["state"], + }, + ) + assert form["grant_type"] == DEVICE_GRANT_TYPE + assert form["client_id"] == CLIENT_ID + assert form["user_code"] == "ABCD" + if calls == 2: + return httpx.Response(400, json={"error": "authorization_pending"}) + return httpx.Response( + 200, + json={ + "status": "success", + "access_token": "access", + "refresh_token": "refresh", + "expired_in": int(time.time() * 1000) + 3_600_000, + "resource_url": "https://api.minimax.io/anthropic/v1", + }, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + token = login("global", client=client, sleep_fn=lambda _: None, open_browser=False) + + assert calls == 3 + assert token.access == "access" + assert load_token("global") == token + assert token_file.stat().st_mode & 0o777 == 0o600 + + +def test_login_rejects_state_mismatch(token_file: Path) -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "verification_uri": "https://account.minimax.io/device", + "user_code": "ABCD", + "expired_in": int(time.time() * 1000) + 60_000, + "interval": 2_000, + "state": "wrong", + }, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(RuntimeError, match="state mismatch"): + login("global", client=client, sleep_fn=lambda _: None, open_browser=False) + + +def test_login_rejects_success_without_refresh_token(token_file: Path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + form = dict(httpx.QueryParams(request.content.decode())) + if request.url.path.endswith("/device/code"): + return httpx.Response( + 200, + json={ + "verification_uri": "https://account.minimax.io/device", + "user_code": "ABCD", + "expired_in": int(time.time() * 1000) + 60_000, + "interval": 2_000, + "state": form["state"], + }, + ) + return httpx.Response(200, json={"status": "success", "access_token": "access"}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(RuntimeError, match="incomplete credentials"): + login("global", client=client, sleep_fn=lambda _: None, open_browser=False) + assert not token_file.exists() + + +def test_refresh_retries_5xx_and_persists_rotated_token(token_file: Path) -> None: + save_token( + "global", + MiniMaxOAuthToken("old-access", "old-refresh", 0, "https://api.minimax.io/anthropic/v1"), + ) + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + form = dict(httpx.QueryParams(request.content.decode())) + assert form["grant_type"] == "refresh_token" + assert form["refresh_token"] == "old-refresh" + if calls == 1: + return httpx.Response(503) + return httpx.Response( + 200, + json={ + "status": "success", + "access_token": "new-access", + "refresh_token": "new-refresh", + "expired_in": int(time.time() * 1000) + 3_600_000, + "resource_url": "https://api.minimax.io/anthropic/v1", + }, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + token = get_token("global", client=client, sleep_fn=lambda _: None) + + assert calls == 2 + assert token.refresh == "new-refresh" + assert load_token("global") == token + + +def test_refresh_does_not_retry_4xx(token_file: Path) -> None: + save_token( + "cn", + MiniMaxOAuthToken("old-access", "old-refresh", 0, "https://api.minimaxi.com/anthropic/v1"), + ) + calls = 0 + + def handler(_: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(400) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(RuntimeError, match="rejected"): + get_token("cn", client=client, sleep_fn=lambda _: None) + assert calls == 1 + + +def test_refresh_rejects_expired_result_without_overwriting_token(token_file: Path) -> None: + original = MiniMaxOAuthToken( + "old-access", + "old-refresh", + 0, + "https://api.minimax.io/anthropic/v1", + ) + save_token("global", original) + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "status": "success", + "access_token": "new-access", + "refresh_token": "new-refresh", + "expired_in": 0, + }, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(RuntimeError, match="invalid expiry"): + get_token("global", client=client, sleep_fn=lambda _: None) + + assert load_token("global") == original + + +def test_expiry_normalizes_duration_epoch_seconds_and_epoch_milliseconds() -> None: + now_ms = 2_000_000_000_000 + assert _normalize_expiry(3600, now_ms) == now_ms + 3_600_000 + assert _normalize_expiry(2_100_000_000, now_ms) == 2_100_000_000_000 + assert _normalize_expiry(2_100_000_000_000, now_ms) == 2_100_000_000_000 + + +def test_global_and_cn_tokens_use_distinct_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_OAUTH_TOKEN_DIR", str(tmp_path)) + global_token = MiniMaxOAuthToken("global-a", "global-r", 4_000_000_000_000, "https://api.minimax.io/anthropic/v1") + cn_token = MiniMaxOAuthToken("cn-a", "cn-r", 4_000_000_000_000, "https://api.minimaxi.com/anthropic/v1") + + save_token("global", global_token) + save_token("cn", cn_token) + + assert load_token("global") == global_token + assert load_token("cn") == cn_token + + +@pytest.mark.asyncio +async def test_provider_refreshes_and_injects_headers(monkeypatch: pytest.MonkeyPatch) -> None: + token = MiniMaxOAuthToken( + "access", + "refresh", + int(time.time() * 1000) + 3_600_000, + "https://api.minimax.io/anthropic/v1", + ) + seen: dict[str, object] = {} + + monkeypatch.setattr("raven.providers.minimax_oauth_provider.get_token", lambda _: token) + + async def fake_completion(**kwargs): + seen.update(kwargs) + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="ok", tool_calls=[]), + finish_reason="stop", + ) + ], + usage=None, + ) + + monkeypatch.setattr("raven.providers.litellm_provider.acompletion", fake_completion) + provider = MiniMaxOAuthProvider("global", "minimax-global/MiniMax-M3") + response = await provider.chat([{"role": "user", "content": "hello"}]) + + assert response.content == "ok" + assert seen["model"] == "anthropic/MiniMax-M3" + assert seen["api_key"] == "access" + assert seen["api_base"] == "https://api.minimax.io/anthropic/v1" + assert seen["extra_headers"] == { + "x-api-key": "access", + "Authorization": "Bearer access", + } diff --git a/tests/test_provider_catalog.py b/tests/test_provider_catalog.py index af0ecc3..e6b9022 100644 --- a/tests/test_provider_catalog.py +++ b/tests/test_provider_catalog.py @@ -31,15 +31,17 @@ "dashscope", "moonshot", "minimax", + "minimax_global", + "minimax_cn", "vllm", "ollama", "groq", } -def test_registry_has_exactly_19_providers() -> None: - assert len(PROVIDERS) == 19 - assert len(EXPECTED_PROVIDER_NAMES) == 19 +def test_registry_has_exactly_21_providers() -> None: + assert len(PROVIDERS) == 21 + assert len(EXPECTED_PROVIDER_NAMES) == 21 def test_registry_provider_name_set_is_pinned() -> None: @@ -62,6 +64,8 @@ def test_provider_names_are_unique() -> None: "zhipu", "dashscope", "groq", + "minimax_global", + "minimax_cn", ] @@ -78,7 +82,9 @@ def _concrete_provider_subclasses() -> set[type]: import raven.providers.azure_openai_provider # noqa: F401 import raven.providers.custom_provider # noqa: F401 import raven.providers.litellm_provider # noqa: F401 + import raven.providers.minimax_oauth_provider # noqa: F401 import raven.providers.openai_codex_provider # noqa: F401 + import raven.providers.per_model_provider # noqa: F401 seen: set[type] = set() stack = list(LLMProvider.__subclasses__()) @@ -92,20 +98,22 @@ def _concrete_provider_subclasses() -> set[type]: return seen -def test_exactly_four_concrete_backend_classes() -> None: - # Only 3 are runtime-dispatched via cli/_helpers.py `_get_provider` - # (LiteLLM / AzureOpenAI / OpenAICodex); CustomProvider is legacy and not - # wired. This asserts class existence only, not the dispatch wiring. +def test_exactly_six_concrete_backend_classes() -> None: + # This asserts class existence only, not the dispatch wiring. from raven.providers.azure_openai_provider import AzureOpenAIProvider from raven.providers.custom_provider import CustomProvider from raven.providers.litellm_provider import LiteLLMProvider + from raven.providers.minimax_oauth_provider import MiniMaxOAuthProvider from raven.providers.openai_codex_provider import OpenAICodexProvider + from raven.providers.per_model_provider import PerModelProvider expected = { LiteLLMProvider, AzureOpenAIProvider, OpenAICodexProvider, CustomProvider, + MiniMaxOAuthProvider, + PerModelProvider, } assert _concrete_provider_subclasses() == expected for cls in expected: diff --git a/tests/test_tui_rpc_setup.py b/tests/test_tui_rpc_setup.py index fd4b57a..685f97a 100644 --- a/tests/test_tui_rpc_setup.py +++ b/tests/test_tui_rpc_setup.py @@ -68,6 +68,58 @@ async def test_setup_status_provider_auto_returns_false(fake_home: Path) -> None assert result == {"provider_configured": False} +async def test_setup_status_minimax_oauth_token_returns_true( + fake_home: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from raven.providers.minimax_oauth import MiniMaxOAuthToken, save_token + + cfg_dir = fake_home / ".raven" + cfg_dir.mkdir() + (cfg_dir / "config.json").write_text( + json.dumps({"agents": {"defaults": {"provider": "auto", "model": "minimax-global/MiniMax-M3"}}}) + ) + monkeypatch.setenv("MINIMAX_OAUTH_TOKEN_DIR", str(tmp_path)) + save_token( + "global", + MiniMaxOAuthToken( + "access", + "refresh", + 4_000_000_000_000, + "https://api.minimax.io/anthropic/v1", + ), + ) + + assert await setup_status({}) == {"provider_configured": True} + + +async def test_setup_status_ignores_minimax_token_for_other_model( + fake_home: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from raven.providers.minimax_oauth import MiniMaxOAuthToken, save_token + + cfg_dir = fake_home / ".raven" + cfg_dir.mkdir() + (cfg_dir / "config.json").write_text( + json.dumps({"agents": {"defaults": {"provider": "auto", "model": "anthropic/claude-sonnet-4-5"}}}) + ) + monkeypatch.setenv("MINIMAX_OAUTH_TOKEN_DIR", str(tmp_path)) + save_token( + "global", + MiniMaxOAuthToken( + "access", + "refresh", + 4_000_000_000_000, + "https://api.minimax.io/anthropic/v1", + ), + ) + + assert await setup_status({}) == {"provider_configured": False} + + async def test_setup_status_registered_via_helper(fake_home: Path) -> None: cfg_dir = fake_home / ".raven" cfg_dir.mkdir() From fa283571b5d2debcecfb42f3a3455e319bd436f9 Mon Sep 17 00:00:00 2001 From: jellyjelly814 Date: Fri, 24 Jul 2026 20:00:44 +0800 Subject: [PATCH 2/4] fix(providers): allow MiniMax hosted OAuth pages --- raven/providers/minimax_oauth.py | 5 ++++- tests/test_minimax_oauth.py | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/raven/providers/minimax_oauth.py b/raven/providers/minimax_oauth.py index f5cbb52..d0c00a9 100644 --- a/raven/providers/minimax_oauth.py +++ b/raven/providers/minimax_oauth.py @@ -31,6 +31,7 @@ class MiniMaxOAuthConfig: provider: str auth_base_url: str default_resource_url: str + verification_url: str @dataclass(frozen=True) @@ -46,11 +47,13 @@ class MiniMaxOAuthToken: provider="minimax_global", auth_base_url="https://account.minimax.io", default_resource_url="https://api.minimax.io/anthropic/v1", + verification_url="https://platform.minimax.io", ), "cn": MiniMaxOAuthConfig( provider="minimax_cn", auth_base_url="https://account.minimaxi.com", default_resource_url="https://api.minimaxi.com/anthropic/v1", + verification_url="https://platform.minimaxi.com", ), } @@ -269,7 +272,7 @@ def _login_locked( interval_ms = max(2000, int(device.get("interval") or 5000)) if not verification_uri or not user_code or deadline <= int(time.time() * 1000): raise RuntimeError("MiniMax device authorization returned an invalid response") - verification_uri = _validated_url(verification_uri, config.auth_base_url, "verification URL") + verification_uri = _validated_url(verification_uri, config.verification_url, "verification URL") print_fn(f"Open {verification_uri}") print_fn(f"Enter code: {user_code}") diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index 307813c..0f549e8 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -42,7 +42,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "verification_uri": "https://account.minimax.io/device", + "verification_uri": "https://platform.minimax.io/oauth-authorize", "user_code": "ABCD", "expired_in": int(time.time() * 1000) + 60_000, "interval": 2_000, @@ -79,7 +79,7 @@ def handler(_: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "verification_uri": "https://account.minimax.io/device", + "verification_uri": "https://platform.minimax.io/oauth-authorize", "user_code": "ABCD", "expired_in": int(time.time() * 1000) + 60_000, "interval": 2_000, @@ -99,7 +99,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "verification_uri": "https://account.minimax.io/device", + "verification_uri": "https://platform.minimax.io/oauth-authorize", "user_code": "ABCD", "expired_in": int(time.time() * 1000) + 60_000, "interval": 2_000, From 68dabc8d2f6d46dcc4239459b829b08391a9a115 Mon Sep 17 00:00:00 2001 From: jellyjelly814 Date: Fri, 24 Jul 2026 20:20:16 +0800 Subject: [PATCH 3/4] fix(onboard): choose model for existing OAuth providers --- raven/cli/onboard_commands.py | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index dfecb41..9f12c71 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -1254,6 +1254,52 @@ def _resolve_model_with_test( return chosen # ok / continue +def _configure_existing_provider_model(*, non_interactive: bool) -> bool: + """Choose a model for an already-authenticated provider without re-login.""" + if non_interactive: + return False + questionary = _require_questionary() + from raven.cli._styles import RAVEN_STYLE + from raven.providers.registry import find_by_name + + choices = [ + questionary.Choice(_provider_label(name), value=name) + for name in _configured_providers() + if find_by_name(name) is not None + ] + if not choices: + return False + provider = questionary.select( + _t("Choose the provider for the default model:", "选择默认模型对应的服务商:"), + choices=choices, + style=RAVEN_STYLE, + qmark=_QMARK, + ).ask() + if not provider: + raise typer.Exit(1) + spec = find_by_name(provider) + ok, _status, model_ids = _verify_provider(provider) + if not ok: + return False + chosen = _pick_model( + spec, + current_model=None, + model_ids=model_ids, + user_provided_model=None, + non_interactive=False, + ) + _persist_default_model(chosen) + result = _run_test_probe( + provider, + non_interactive=False, + warnings=[], + is_oauth=spec.is_oauth, + ) + if result == "reauth": + return _run_oauth_login(provider) + return result in {"ok", "continue"} + + # --------------------------------------------------------------------------- # Step 1 — multi-provider entry (existing-config branch: done / add / edit) # --------------------------------------------------------------------------- @@ -1386,6 +1432,7 @@ def _step1_provider( ), choices=[ questionary.Choice(_t("Done, continue", "完成,继续"), value="done"), + questionary.Choice(_t("Choose default model", "选择默认模型"), value="model"), questionary.Choice(_t("Add another provider", "新增一个服务商"), value="add"), questionary.Choice(_t("Edit / remove a provider", "编辑 / 移除服务商"), value="edit"), ], @@ -1406,6 +1453,15 @@ def _step1_provider( ) continue return None + if action == "model": + if _configure_existing_provider_model(non_interactive=False): + continue + console.print( + _t( + " [yellow]Could not configure a default model. Choose a provider and try again.[/yellow]", + " [yellow]无法配置默认模型,请重新选择服务商。[/yellow]", + ) + ) if action == "add": _configure_one_provider( provider=None, From 1256a1b708076bd03020700813652420f6ffcf0d Mon Sep 17 00:00:00 2001 From: jellyjelly814 Date: Fri, 24 Jul 2026 20:28:49 +0800 Subject: [PATCH 4/4] fix(providers): normalize MiniMax OAuth resource URLs --- raven/providers/minimax_oauth.py | 25 +++++++++++++---------- raven/providers/minimax_oauth_provider.py | 9 ++++++-- tests/test_minimax_oauth.py | 8 +++++++- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/raven/providers/minimax_oauth.py b/raven/providers/minimax_oauth.py index d0c00a9..a31cfff 100644 --- a/raven/providers/minimax_oauth.py +++ b/raven/providers/minimax_oauth.py @@ -79,6 +79,17 @@ def _validated_url(value: str, expected_url: str, field: str) -> str: return value +def _resource_url(value: object, config: MiniMaxOAuthConfig) -> str: + raw = str(value or config.default_resource_url).rstrip("/") + validated = _validated_url(raw, config.default_resource_url, "resource URL") + path = urlparse(validated).path.rstrip("/") + if path in {"", "/"}: + return config.default_resource_url + if path.endswith("/anthropic"): + return f"{validated}/v1" + return validated + + def token_path(region: str) -> Path: config = oauth_config(region) base_dir = os.environ.get(OAUTH_STORAGE_DIR_ENV) @@ -108,7 +119,7 @@ def load_token(region: str) -> MiniMaxOAuthToken | None: try: data = json.loads(path.read_text(encoding="utf-8")) config = oauth_config(region) - resource_url = _validated_url(str(data["resource_url"]), config.default_resource_url, "resource URL") + resource_url = _resource_url(data["resource_url"], config) return MiniMaxOAuthToken( access=str(data["access"]), refresh=str(data["refresh"]), @@ -194,11 +205,7 @@ def _request_refresh( expires = _normalize_expiry(payload.get("expired_in")) if expires <= int(time.time() * 1000): raise RuntimeError("MiniMax refresh returned an invalid expiry") - resource_url = _validated_url( - str(payload.get("resource_url") or config.default_resource_url), - config.default_resource_url, - "resource URL", - ) + resource_url = _resource_url(payload.get("resource_url"), config) return MiniMaxOAuthToken( access=str(payload["access_token"]), refresh=str(payload.get("refresh_token") or refresh_token), @@ -308,11 +315,7 @@ def _login_locked( token_response.raise_for_status() if status != "success" or not payload.get("access_token") or not payload.get("refresh_token"): raise RuntimeError("MiniMax authorization failed or returned incomplete credentials") - resource_url = _validated_url( - str(payload.get("resource_url") or config.default_resource_url), - config.default_resource_url, - "resource URL", - ) + resource_url = _resource_url(payload.get("resource_url"), config) token = MiniMaxOAuthToken( access=str(payload["access_token"]), refresh=str(payload["refresh_token"]), diff --git a/raven/providers/minimax_oauth_provider.py b/raven/providers/minimax_oauth_provider.py index a3473ce..b1cc5f0 100644 --- a/raven/providers/minimax_oauth_provider.py +++ b/raven/providers/minimax_oauth_provider.py @@ -11,12 +11,17 @@ from raven.providers.minimax_oauth import get_token, oauth_config +def _litellm_api_base(resource_url: str) -> str: + base = resource_url.rstrip("/") + return base[:-3] if base.endswith("/v1") else base + + class MiniMaxOAuthProvider(LiteLLMProvider): def __init__(self, region: str, default_model: str): self.region = region config = oauth_config(region) super().__init__( - api_base=config.default_resource_url, + api_base=_litellm_api_base(config.default_resource_url), default_model=default_model, provider_name=config.provider, ) @@ -24,7 +29,7 @@ def __init__(self, region: str, default_model: str): async def _prepare_token(self) -> None: token = await asyncio.to_thread(get_token, self.region) self.api_key = token.access - self.api_base = token.resource_url + self.api_base = _litellm_api_base(token.resource_url) self.extra_headers = { "x-api-key": token.access, "Authorization": f"Bearer {token.access}", diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index 0f549e8..88ce2b0 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -200,6 +200,12 @@ def test_expiry_normalizes_duration_epoch_seconds_and_epoch_milliseconds() -> No assert _normalize_expiry(2_100_000_000_000, now_ms) == 2_100_000_000_000 +def test_resource_url_root_falls_back_to_anthropic_v1() -> None: + from raven.providers.minimax_oauth import _resource_url, oauth_config + + assert _resource_url("https://api.minimax.io", oauth_config("global")) == ("https://api.minimax.io/anthropic/v1") + + def test_global_and_cn_tokens_use_distinct_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MINIMAX_OAUTH_TOKEN_DIR", str(tmp_path)) global_token = MiniMaxOAuthToken("global-a", "global-r", 4_000_000_000_000, "https://api.minimax.io/anthropic/v1") @@ -243,7 +249,7 @@ async def fake_completion(**kwargs): assert response.content == "ok" assert seen["model"] == "anthropic/MiniMax-M3" assert seen["api_key"] == "access" - assert seen["api_base"] == "https://api.minimax.io/anthropic/v1" + assert seen["api_base"] == "https://api.minimax.io/anthropic" assert seen["extra_headers"] == { "x-api-key": "access", "Authorization": "Bearer access",