feat(providers): add MiniMax OAuth credential flow

This commit is contained in:
Chummy 2026-02-18 22:10:25 +08:00
parent e3c949b637
commit 0bd2fbba2a
4 changed files with 309 additions and 7 deletions

View file

@ -46,7 +46,10 @@ PROVIDER=openrouter
# COHERE_API_KEY=... # COHERE_API_KEY=...
# MOONSHOT_API_KEY=... # MOONSHOT_API_KEY=...
# GLM_API_KEY=... # GLM_API_KEY=...
# MINIMAX_OAUTH_TOKEN=...
# MINIMAX_API_KEY=... # MINIMAX_API_KEY=...
# MINIMAX_OAUTH_REFRESH_TOKEN=...
# MINIMAX_OAUTH_REGION=global # optional: global|cn
# QIANFAN_API_KEY=... # QIANFAN_API_KEY=...
# DASHSCOPE_API_KEY=... # DASHSCOPE_API_KEY=...
# ZAI_API_KEY=... # ZAI_API_KEY=...

View file

@ -36,7 +36,7 @@ Runtime resolution order is:
| `opencode` | `opencode-zen` | No | `OPENCODE_API_KEY` | | `opencode` | `opencode-zen` | No | `OPENCODE_API_KEY` |
| `zai` | `z.ai` | No | `ZAI_API_KEY` | | `zai` | `z.ai` | No | `ZAI_API_KEY` |
| `glm` | `zhipu` | No | `GLM_API_KEY` | | `glm` | `zhipu` | No | `GLM_API_KEY` |
| `minimax` | — | No | `MINIMAX_API_KEY` | | `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | No | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |
| `bedrock` | `aws-bedrock` | No | (use config/`API_KEY` fallback) | | `bedrock` | `aws-bedrock` | No | (use config/`API_KEY` fallback) |
| `qianfan` | `baidu` | No | `QIANFAN_API_KEY` | | `qianfan` | `baidu` | No | `QIANFAN_API_KEY` |
| `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us` | No | `DASHSCOPE_API_KEY` | | `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us` | No | `DASHSCOPE_API_KEY` |
@ -73,6 +73,26 @@ default_provider = "custom:https://your-api.example.com"
default_provider = "anthropic-custom:https://your-api.example.com" default_provider = "anthropic-custom:https://your-api.example.com"
``` ```
## MiniMax OAuth Setup (config.toml)
Set the MiniMax provider and OAuth placeholder in config:
```toml
default_provider = "minimax-oauth"
api_key = "minimax-oauth"
```
Then provide one of the following credentials via environment variables:
- `MINIMAX_OAUTH_TOKEN` (preferred, direct access token)
- `MINIMAX_API_KEY` (legacy/static token)
- `MINIMAX_OAUTH_REFRESH_TOKEN` (auto-refreshes access token at startup)
Optional:
- `MINIMAX_OAUTH_REGION=global` or `cn` (defaults by provider alias)
- `MINIMAX_OAUTH_CLIENT_ID` to override the default OAuth client id
## Model Routing (`hint:<name>`) ## Model Routing (`hint:<name>`)
You can route model calls by hint using `[[model_routes]]`: You can route model calls by hint using `[[model_routes]]`:

View file

@ -992,6 +992,8 @@ fn fetch_live_models_for_provider(provider_name: &str, api_key: &str) -> Result<
// Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN // Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN
if provider_name == "anthropic" { if provider_name == "anthropic" {
std::env::var("ANTHROPIC_OAUTH_TOKEN").ok() std::env::var("ANTHROPIC_OAUTH_TOKEN").ok()
} else if provider_name == "minimax" {
std::env::var("MINIMAX_OAUTH_TOKEN").ok()
} else { } else {
None None
} }
@ -1881,7 +1883,11 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio
let has_api_key = !api_key.trim().is_empty() let has_api_key = !api_key.trim().is_empty()
|| std::env::var(provider_env_var(provider_name)) || std::env::var(provider_env_var(provider_name))
.ok() .ok()
.is_some_and(|value| !value.trim().is_empty()); .is_some_and(|value| !value.trim().is_empty())
|| (provider_name == "minimax"
&& std::env::var("MINIMAX_OAUTH_TOKEN")
.ok()
.is_some_and(|value| !value.trim().is_empty()));
if can_fetch_without_key || has_api_key { if can_fetch_without_key || has_api_key {
if let Some(cached) = if let Some(cached) =
@ -4901,6 +4907,8 @@ mod tests {
assert_eq!(provider_env_var("kimi-code"), "KIMI_CODE_API_KEY"); assert_eq!(provider_env_var("kimi-code"), "KIMI_CODE_API_KEY");
assert_eq!(provider_env_var("kimi_coding"), "KIMI_CODE_API_KEY"); assert_eq!(provider_env_var("kimi_coding"), "KIMI_CODE_API_KEY");
assert_eq!(provider_env_var("kimi_for_coding"), "KIMI_CODE_API_KEY"); assert_eq!(provider_env_var("kimi_for_coding"), "KIMI_CODE_API_KEY");
assert_eq!(provider_env_var("minimax-oauth"), "MINIMAX_API_KEY");
assert_eq!(provider_env_var("minimax-oauth-cn"), "MINIMAX_API_KEY");
assert_eq!(provider_env_var("moonshot-intl"), "MOONSHOT_API_KEY"); assert_eq!(provider_env_var("moonshot-intl"), "MOONSHOT_API_KEY");
assert_eq!(provider_env_var("zai-cn"), "ZAI_API_KEY"); assert_eq!(provider_env_var("zai-cn"), "ZAI_API_KEY");
assert_eq!(provider_env_var("nvidia"), "NVIDIA_API_KEY"); assert_eq!(provider_env_var("nvidia"), "NVIDIA_API_KEY");

View file

@ -18,11 +18,22 @@ pub use traits::{
use compatible::{AuthStyle, OpenAiCompatibleProvider}; use compatible::{AuthStyle, OpenAiCompatibleProvider};
use reliable::ReliableProvider; use reliable::ReliableProvider;
use serde::Deserialize;
use std::path::PathBuf; use std::path::PathBuf;
const MAX_API_ERROR_CHARS: usize = 200; const MAX_API_ERROR_CHARS: usize = 200;
const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1"; const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
const MINIMAX_CN_BASE_URL: &str = "https://api.minimaxi.com/v1"; const MINIMAX_CN_BASE_URL: &str = "https://api.minimaxi.com/v1";
const MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT: &str = "https://api.minimax.io/oauth/token";
const MINIMAX_OAUTH_CN_TOKEN_ENDPOINT: &str = "https://api.minimaxi.com/oauth/token";
const MINIMAX_OAUTH_PLACEHOLDER: &str = "minimax-oauth";
const MINIMAX_OAUTH_CN_PLACEHOLDER: &str = "minimax-oauth-cn";
const MINIMAX_OAUTH_TOKEN_ENV: &str = "MINIMAX_OAUTH_TOKEN";
const MINIMAX_API_KEY_ENV: &str = "MINIMAX_API_KEY";
const MINIMAX_OAUTH_REFRESH_TOKEN_ENV: &str = "MINIMAX_OAUTH_REFRESH_TOKEN";
const MINIMAX_OAUTH_REGION_ENV: &str = "MINIMAX_OAUTH_REGION";
const MINIMAX_OAUTH_CLIENT_ID_ENV: &str = "MINIMAX_OAUTH_CLIENT_ID";
const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4"; const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
const GLM_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4"; const GLM_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1"; const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
@ -36,12 +47,22 @@ const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
pub(crate) fn is_minimax_intl_alias(name: &str) -> bool { pub(crate) fn is_minimax_intl_alias(name: &str) -> bool {
matches!( matches!(
name, name,
"minimax" | "minimax-intl" | "minimax-io" | "minimax-global" "minimax"
| "minimax-intl"
| "minimax-io"
| "minimax-global"
| "minimax-oauth"
| "minimax-portal"
| "minimax-oauth-global"
| "minimax-portal-global"
) )
} }
pub(crate) fn is_minimax_cn_alias(name: &str) -> bool { pub(crate) fn is_minimax_cn_alias(name: &str) -> bool {
matches!(name, "minimax-cn" | "minimaxi") matches!(
name,
"minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
)
} }
pub(crate) fn is_minimax_alias(name: &str) -> bool { pub(crate) fn is_minimax_alias(name: &str) -> bool {
@ -110,6 +131,152 @@ pub(crate) fn is_qianfan_alias(name: &str) -> bool {
matches!(name, "qianfan" | "baidu") matches!(name, "qianfan" | "baidu")
} }
#[derive(Clone, Copy, Debug)]
enum MinimaxOauthRegion {
Global,
Cn,
}
impl MinimaxOauthRegion {
fn token_endpoint(self) -> &'static str {
match self {
Self::Global => MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT,
Self::Cn => MINIMAX_OAUTH_CN_TOKEN_ENDPOINT,
}
}
}
#[derive(Debug, Deserialize)]
struct MinimaxOauthRefreshResponse {
#[serde(default)]
status: Option<String>,
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
base_resp: Option<MinimaxOauthBaseResponse>,
}
#[derive(Debug, Deserialize)]
struct MinimaxOauthBaseResponse {
#[serde(default)]
status_msg: Option<String>,
}
fn read_non_empty_env(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn is_minimax_oauth_placeholder(value: &str) -> bool {
value.eq_ignore_ascii_case(MINIMAX_OAUTH_PLACEHOLDER)
|| value.eq_ignore_ascii_case(MINIMAX_OAUTH_CN_PLACEHOLDER)
}
fn minimax_oauth_region(name: &str) -> MinimaxOauthRegion {
if let Some(region) = read_non_empty_env(MINIMAX_OAUTH_REGION_ENV) {
let normalized = region.to_ascii_lowercase();
if matches!(normalized.as_str(), "cn" | "china") {
return MinimaxOauthRegion::Cn;
}
if matches!(normalized.as_str(), "global" | "intl" | "international") {
return MinimaxOauthRegion::Global;
}
}
if is_minimax_cn_alias(name) {
MinimaxOauthRegion::Cn
} else {
MinimaxOauthRegion::Global
}
}
fn minimax_oauth_client_id() -> String {
read_non_empty_env(MINIMAX_OAUTH_CLIENT_ID_ENV)
.unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
}
fn resolve_minimax_static_credential() -> Option<String> {
read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
}
fn refresh_minimax_oauth_access_token(name: &str, refresh_token: &str) -> anyhow::Result<String> {
let region = minimax_oauth_region(name);
let endpoint = region.token_endpoint();
let client_id = minimax_oauth_client_id();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
let response = client
.post(endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", client_id.as_str()),
])
.send()
.map_err(|error| anyhow::anyhow!("MiniMax OAuth refresh request failed: {error}"))?;
let status = response.status();
let body = response
.text()
.unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
if !status.is_success() {
let detail = parsed
.as_ref()
.and_then(|payload| payload.base_resp.as_ref())
.and_then(|base| base.status_msg.as_deref())
.filter(|msg| !msg.trim().is_empty())
.unwrap_or(body.as_str());
anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
}
if let Some(payload) = parsed {
if let Some(status_text) = payload.status.as_deref() {
if !status_text.eq_ignore_ascii_case("success") {
let detail = payload
.base_resp
.as_ref()
.and_then(|base| base.status_msg.as_deref())
.unwrap_or(status_text);
anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
}
}
if let Some(token) = payload
.access_token
.as_deref()
.map(str::trim)
.filter(|token| !token.is_empty())
{
return Ok(token.to_string());
}
}
anyhow::bail!("MiniMax OAuth refresh response missing access_token");
}
fn resolve_minimax_oauth_refresh_token(name: &str) -> Option<String> {
let refresh_token = read_non_empty_env(MINIMAX_OAUTH_REFRESH_TOKEN_ENV)?;
match refresh_minimax_oauth_access_token(name, &refresh_token) {
Ok(token) => Some(token),
Err(error) => {
tracing::warn!(provider = name, error = %error, "MiniMax OAuth refresh failed");
None
}
}
}
pub(crate) fn canonical_china_provider_name(name: &str) -> Option<&'static str> { pub(crate) fn canonical_china_provider_name(name: &str) -> Option<&'static str> {
if is_qwen_alias(name) { if is_qwen_alias(name) {
Some("qwen") Some("qwen")
@ -291,13 +458,29 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E
/// ///
/// For Anthropic, the provider-specific env var is `ANTHROPIC_OAUTH_TOKEN` (for setup-tokens) /// For Anthropic, the provider-specific env var is `ANTHROPIC_OAUTH_TOKEN` (for setup-tokens)
/// followed by `ANTHROPIC_API_KEY` (for regular API keys). /// followed by `ANTHROPIC_API_KEY` (for regular API keys).
///
/// For MiniMax, OAuth mode supports `api_key = "minimax-oauth"`, resolving credentials from
/// `MINIMAX_OAUTH_TOKEN` first, then `MINIMAX_API_KEY`, and finally
/// `MINIMAX_OAUTH_REFRESH_TOKEN` (automatic access-token refresh).
fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> Option<String> { fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> Option<String> {
let mut minimax_oauth_placeholder_requested = false;
if let Some(raw_override) = credential_override { if let Some(raw_override) = credential_override {
let trimmed_override = raw_override.trim(); let trimmed_override = raw_override.trim();
if !trimmed_override.is_empty() { if !trimmed_override.is_empty() {
if is_minimax_alias(name) && is_minimax_oauth_placeholder(trimmed_override) {
minimax_oauth_placeholder_requested = true;
if let Some(credential) = resolve_minimax_static_credential() {
return Some(credential);
}
if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
return Some(credential);
}
} else {
return Some(trimmed_override.to_owned()); return Some(trimmed_override.to_owned());
} }
} }
}
let provider_env_candidates: Vec<&str> = match name { let provider_env_candidates: Vec<&str> = match name {
"anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
@ -318,7 +501,7 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
vec!["KIMI_CODE_API_KEY", "MOONSHOT_API_KEY"] vec!["KIMI_CODE_API_KEY", "MOONSHOT_API_KEY"]
} }
name if is_glm_alias(name) => vec!["GLM_API_KEY"], name if is_glm_alias(name) => vec!["GLM_API_KEY"],
name if is_minimax_alias(name) => vec!["MINIMAX_API_KEY"], name if is_minimax_alias(name) => vec![MINIMAX_OAUTH_TOKEN_ENV, MINIMAX_API_KEY_ENV],
name if is_qianfan_alias(name) => vec!["QIANFAN_API_KEY"], name if is_qianfan_alias(name) => vec!["QIANFAN_API_KEY"],
name if is_qwen_alias(name) => vec!["DASHSCOPE_API_KEY"], name if is_qwen_alias(name) => vec!["DASHSCOPE_API_KEY"],
name if is_zai_alias(name) => vec!["ZAI_API_KEY"], name if is_zai_alias(name) => vec!["ZAI_API_KEY"],
@ -341,6 +524,16 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
} }
} }
if is_minimax_alias(name) {
if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
return Some(credential);
}
}
if minimax_oauth_placeholder_requested && is_minimax_alias(name) {
return None;
}
for env_var in ["ZEROCLAW_API_KEY", "API_KEY"] { for env_var in ["ZEROCLAW_API_KEY", "API_KEY"] {
if let Ok(value) = std::env::var(env_var) { if let Ok(value) = std::env::var(env_var) {
let value = value.trim(); let value = value.trim();
@ -832,7 +1025,17 @@ pub fn list_providers() -> Vec<ProviderInfo> {
ProviderInfo { ProviderInfo {
name: "minimax", name: "minimax",
display_name: "MiniMax", display_name: "MiniMax",
aliases: &[], aliases: &[
"minimax-intl",
"minimax-io",
"minimax-global",
"minimax-cn",
"minimaxi",
"minimax-oauth",
"minimax-oauth-cn",
"minimax-portal",
"minimax-portal-cn",
],
local: false, local: false,
}, },
ProviderInfo { ProviderInfo {
@ -938,12 +1141,73 @@ pub fn list_providers() -> Vec<ProviderInfo> {
mod tests { mod tests {
use super::*; use super::*;
struct EnvGuard {
key: &'static str,
original: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var(key).ok();
match value {
Some(next) => std::env::set_var(key, next),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(original) = self.original.as_deref() {
std::env::set_var(self.key, original);
} else {
std::env::remove_var(self.key);
}
}
}
#[test] #[test]
fn resolve_provider_credential_prefers_explicit_argument() { fn resolve_provider_credential_prefers_explicit_argument() {
let resolved = resolve_provider_credential("openrouter", Some(" explicit-key ")); let resolved = resolve_provider_credential("openrouter", Some(" explicit-key "));
assert_eq!(resolved, Some("explicit-key".to_string())); assert_eq!(resolved, Some("explicit-key".to_string()));
} }
#[test]
fn resolve_provider_credential_uses_minimax_oauth_env_for_placeholder() {
let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, Some("oauth-token"));
let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
assert_eq!(resolved.as_deref(), Some("oauth-token"));
}
#[test]
fn resolve_provider_credential_falls_back_to_minimax_api_key_for_placeholder() {
let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
assert_eq!(resolved.as_deref(), Some("api-key"));
}
#[test]
fn resolve_provider_credential_placeholder_ignores_generic_api_key_fallback() {
let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, None);
let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
assert!(resolved.is_none());
}
#[test] #[test]
fn regional_alias_predicates_cover_expected_variants() { fn regional_alias_predicates_cover_expected_variants() {
assert!(is_moonshot_alias("moonshot")); assert!(is_moonshot_alias("moonshot"));
@ -952,6 +1216,8 @@ mod tests {
assert!(is_glm_alias("bigmodel")); assert!(is_glm_alias("bigmodel"));
assert!(is_minimax_alias("minimax-io")); assert!(is_minimax_alias("minimax-io"));
assert!(is_minimax_alias("minimaxi")); assert!(is_minimax_alias("minimaxi"));
assert!(is_minimax_alias("minimax-oauth"));
assert!(is_minimax_alias("minimax-portal-cn"));
assert!(is_qwen_alias("dashscope")); assert!(is_qwen_alias("dashscope"));
assert!(is_qwen_alias("qwen-us")); assert!(is_qwen_alias("qwen-us"));
assert!(is_zai_alias("z.ai")); assert!(is_zai_alias("z.ai"));
@ -1128,8 +1394,13 @@ mod tests {
assert!(create_provider("minimax", Some("key")).is_ok()); assert!(create_provider("minimax", Some("key")).is_ok());
assert!(create_provider("minimax-intl", Some("key")).is_ok()); assert!(create_provider("minimax-intl", Some("key")).is_ok());
assert!(create_provider("minimax-io", Some("key")).is_ok()); assert!(create_provider("minimax-io", Some("key")).is_ok());
assert!(create_provider("minimax-global", Some("key")).is_ok());
assert!(create_provider("minimax-cn", Some("key")).is_ok()); assert!(create_provider("minimax-cn", Some("key")).is_ok());
assert!(create_provider("minimaxi", Some("key")).is_ok()); assert!(create_provider("minimaxi", Some("key")).is_ok());
assert!(create_provider("minimax-oauth", Some("key")).is_ok());
assert!(create_provider("minimax-oauth-cn", Some("key")).is_ok());
assert!(create_provider("minimax-portal", Some("key")).is_ok());
assert!(create_provider("minimax-portal-cn", Some("key")).is_ok());
} }
#[test] #[test]