From be37dbd34e27ebc1e9abb10198ae5dc4f8b9f958 Mon Sep 17 00:00:00 2001 From: xyuai <281015099+xyuai@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:04:45 -0700 Subject: [PATCH] feat(config): add Xiaomi MiMo token plan mode Harvested from PR #2627 by @xyuai. Refs #2621 reported by @springeye. --- CHANGELOG.md | 4 + README.md | 6 +- README.zh-CN.md | 6 +- config.example.toml | 7 +- crates/config/src/lib.rs | 273 ++++++++++++++++++++++++++++++++++- crates/tui/CHANGELOG.md | 4 + crates/tui/src/config.rs | 302 ++++++++++++++++++++++++++++++++++++--- docs/CONFIGURATION.md | 3 +- docs/PROVIDERS.md | 6 +- 9 files changed, 580 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 853b9c57..929cdbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather than model-provider routing (#2790). Thanks @yusufgurdogan for the implementation. +- Added Xiaomi MiMo `mode` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` selection for + Token Plan region endpoints and pay-as-you-go routing, plus dedicated Token + Plan env keys for `tp-*` subscriptions (#2621, #2627). Thanks @springeye for + the request and @xyuai for the implementation. ### Changed diff --git a/README.md b/README.md index 508113f5..e777577e 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ codewhale --provider arcee --model trinity-large-thinking codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" codewhale --provider xiaomi-mimo --model mimo-v2.5-pro codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav +XIAOMI_MIMO_TOKEN_PLAN_API_KEY="tp-..." XIAOMI_MIMO_MODE="token-plan-sgp" \ + codewhale --provider xiaomi-mimo --model mimo-v2.5-pro codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" OPENAI_BASE_URL="https://openai-compatible.example/v4" \ @@ -503,13 +505,13 @@ Key environment variables: | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `XIAOMI_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `ARCEE_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | Provider auth | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_TOKEN_PLAN_API_KEY` / `MIMO_TOKEN_PLAN_API_KEY` / `XIAOMI_MIMO_API_KEY` / `XIAOMI_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `ARCEE_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | Provider auth | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override | | `VOLCENGINE_BASE_URL` / `VOLCENGINE_ARK_BASE_URL` / `ARK_BASE_URL` / `VOLCENGINE_MODEL` / `VOLCENGINE_ARK_MODEL` | Volcengine Ark endpoint and model override | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | -| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo endpoint and model override; Token Plan default is `https://token-plan-sgp.xiaomimimo.com/v1` | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` | Xiaomi MiMo endpoint, model, and Token Plan mode override; Token Plan default is `https://token-plan-sgp.xiaomimimo.com/v1` | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | | `SILICONFLOW_BASE_URL` / `SILICONFLOW_MODEL` | SiliconFlow endpoint and model override | diff --git a/README.zh-CN.md b/README.zh-CN.md index 7740cb4b..dc2148cb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -272,6 +272,8 @@ codewhale --provider openrouter --model qwen/qwen3.7-max codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY" codewhale --provider xiaomi-mimo --model mimo-v2.5-pro codewhale --provider xiaomi-mimo speech "???MiMo" --model tts -o hello.wav +XIAOMI_MIMO_TOKEN_PLAN_API_KEY="tp-..." XIAOMI_MIMO_MODE="token-plan-sgp" \ + codewhale --provider xiaomi-mimo --model mimo-v2.5-pro # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" @@ -427,13 +429,13 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 | `DEEPSEEK_PROFILE` | 配置 profile 名称 | | `DEEPSEEK_MEMORY` | 设为 `on` 启用用户记忆 | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 在可信网络上允许非本机 `http://` API base URL | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | 提供商认证 | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_TOKEN_PLAN_API_KEY` / `MIMO_TOKEN_PLAN_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | 提供商认证 | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | 通用 OpenAI 兼容端点和模型 ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud 端点和模型覆盖 | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark 端点和模型覆盖 | | `VOLCENGINE_BASE_URL` / `ARK_BASE_URL` / `VOLCENGINE_MODEL` / `ARK_MODEL` | Volcengine Ark 端点和模型覆盖 | | `OPENROUTER_BASE_URL` | OpenRouter 端点覆盖 | -| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo 端点和模型覆盖 | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` | Xiaomi MiMo 端点、模型和 Token Plan 模式覆盖 | | `NOVITA_BASE_URL` | Novita 端点覆盖 | | `FIREWORKS_BASE_URL` | Fireworks 端点覆盖 | | `SILICONFLOW_BASE_URL` / `SILICONFLOW_MODEL` | SiliconFlow 端点和模型覆盖 | diff --git a/config.example.toml b/config.example.toml index e8ff484c..d8c3c53d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -239,7 +239,7 @@ max_subagents = 10 # optional (1-20) # Volcengine Ark: VOLCENGINE_API_KEY (or VOLCENGINE_ARK_API_KEY / ARK_API_KEY), VOLCENGINE_BASE_URL, VOLCENGINE_MODEL # OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL # Xiaomi MiMo: XIAOMI_MIMO_API_KEY (or XIAOMI_API_KEY / MIMO_API_KEY), XIAOMI_MIMO_BASE_URL, XIAOMI_MIMO_MODEL -# Token Plan keys (`tp-...`) default to https://token-plan-sgp.xiaomimimo.com/v1. +# Token Plan: XIAOMI_MIMO_TOKEN_PLAN_API_KEY (or MIMO_TOKEN_PLAN_API_KEY), XIAOMI_MIMO_MODE/MIMO_MODE # Novita: NOVITA_API_KEY, NOVITA_BASE_URL, NOVITA_MODEL # Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL # SiliconFlow: SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, SILICONFLOW_MODEL @@ -329,6 +329,11 @@ max_subagents = 10 # optional (1-20) # # base_url = "https://api.xiaomimimo.com/v1" # Pay-as-you-go / sk- keys # model = "mimo-v2.5-pro" # chat/reasoning # Chat model IDs: mimo-v2.5-pro, mimo-v2.5 +# Token Plan subscriptions use separate tp-* API keys plus api-key auth. +# mode = "token-plan-sgp" # default Token Plan endpoint +# mode = "token-plan-cn" # China cluster +# mode = "token-plan-ams" # Europe cluster +# mode = "pay-as-you-go" # standard API / sk- keys # TTS aliases are also accepted by `codewhale speech`: tts, voice-design, voice-clone # TTS model IDs: mimo-v2.5-tts, mimo-v2.5-tts-voicedesign, mimo-v2.5-tts-voiceclone, mimo-v2-tts diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index de8f28e3..df472807 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -70,6 +70,9 @@ const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1"; +const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1"; +const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL; +const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1"; const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1"; @@ -196,6 +199,7 @@ pub struct ProviderConfigToml { pub api_key: Option, pub base_url: Option, pub model: Option, + pub mode: Option, pub auth_mode: Option, #[serde(default)] pub http_headers: BTreeMap, @@ -828,6 +832,7 @@ impl ConfigToml { "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key.clone(), "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url.clone(), "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model.clone(), + "providers.xiaomi_mimo.mode" => self.providers.xiaomi_mimo.mode.clone(), "providers.xiaomi_mimo.http_headers" => { serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) } @@ -1020,6 +1025,9 @@ impl ConfigToml { "providers.xiaomi_mimo.model" => { self.providers.xiaomi_mimo.model = Some(value.to_string()); } + "providers.xiaomi_mimo.mode" => { + self.providers.xiaomi_mimo.mode = Some(value.to_string()); + } "providers.xiaomi_mimo.http_headers" => { self.providers.xiaomi_mimo.http_headers = parse_http_headers(value)?; } @@ -1208,6 +1216,7 @@ impl ConfigToml { "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key = None, "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url = None, "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model = None, + "providers.xiaomi_mimo.mode" => self.providers.xiaomi_mimo.mode = None, "providers.xiaomi_mimo.http_headers" => { self.providers.xiaomi_mimo.http_headers.clear(); } @@ -1403,6 +1412,9 @@ impl ConfigToml { if let Some(v) = self.providers.xiaomi_mimo.model.as_ref() { out.insert("providers.xiaomi_mimo.model".to_string(), v.clone()); } + if let Some(v) = self.providers.xiaomi_mimo.mode.as_ref() { + out.insert("providers.xiaomi_mimo.mode".to_string(), v.clone()); + } if let Some(v) = serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) { out.insert("providers.xiaomi_mimo.http_headers".to_string(), v); } @@ -1573,15 +1585,38 @@ impl ConfigToml { .or_else(|| provider_cfg.auth_mode.clone()) .or_else(|| self.auth_mode.clone()); let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); - let explicit_api_key_for_endpoint = cli.api_key.as_deref().or(from_file.as_deref()); let configured_base_url = cli .base_url .clone() .or_else(|| env.base_url_for(provider)) .or_else(|| provider_cfg.base_url.clone()) .or(root_deepseek_base_url); + let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo { + env.xiaomi_mimo_mode + .clone() + .or_else(|| provider_cfg.mode.clone()) + } else { + None + }; + let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo { + xiaomi_mimo_env_api_key_for_runtime( + xiaomi_mimo_mode.as_deref(), + configured_base_url.as_deref(), + ) + } else { + None + }; + let explicit_api_key_for_endpoint = cli + .api_key + .as_deref() + .or(from_file.as_deref()) + .or(xiaomi_mimo_env_api_key.as_deref()); let base_url = if provider == ProviderKind::XiaomiMimo { - resolve_xiaomi_mimo_base_url(configured_base_url, explicit_api_key_for_endpoint) + resolve_xiaomi_mimo_base_url( + configured_base_url, + explicit_api_key_for_endpoint, + xiaomi_mimo_mode.as_deref(), + ) } else { configured_base_url.unwrap_or_else(|| match provider { ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(), @@ -1623,6 +1658,8 @@ impl ConfigToml { (None, None) } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { (Some(value), Some(RuntimeApiKeySource::ConfigFile)) + } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) { + (Some(value), Some(RuntimeApiKeySource::Env)) } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { match codewhale_secrets::env_for(provider.as_str()) { Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)), @@ -2051,15 +2088,124 @@ fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { || normalized.starts_with("https://api.kimi.com/coding/") } -fn resolve_xiaomi_mimo_base_url(configured: Option, api_key: Option<&str>) -> String { +fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> { + let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-"); + if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) { + return None; + } + Some(match normalized.as_str() { + "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => { + DEFAULT_XIAOMI_MIMO_BASE_URL + } + "token-plan-cn" + | "token-plan-china" + | "token-plan-mainland" + | "token-plan-mainland-china" + | "cn" + | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL, + "token-plan-sgp" + | "token-plan-sg" + | "token-plan-singapore" + | "sgp" + | "sg" + | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL, + "token-plan-ams" + | "token-plan-eu" + | "token-plan-europe" + | "token-plan-amsterdam" + | "ams" + | "eu" + | "europe" + | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL, + _ => DEFAULT_XIAOMI_MIMO_BASE_URL, + }) +} + +fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool { + matches!( + normalized_mode, + "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go" + ) +} + +fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { + let normalized = base_url.trim_end_matches('/').to_ascii_lowercase(); + normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL +} + +fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option { + candidates.iter().find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +fn xiaomi_mimo_env_api_key_for_runtime( + mode: Option<&str>, + base_url: Option<&str>, +) -> Option { + const TOKEN_PLAN_ENV_VARS: &[&str] = + &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"]; + const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]; + + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let standard_selected = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint) + || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go); + if standard_selected { + return xiaomi_mimo_env_var(STANDARD_ENV_VARS); + } + + let token_plan_selected = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode) + .is_some() + || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan); + if token_plan_selected { + return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS); + } + + xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS)) +} + +fn resolve_xiaomi_mimo_base_url( + configured: Option, + api_key: Option<&str>, + mode: Option<&str>, +) -> String { + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let uses_standard_mode = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint); + let mode_base_url = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode); let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key); match configured { + Some(base_url) if uses_standard_mode => base_url, Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => { - DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + mode_base_url + .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL) + .to_string() } Some(base_url) => base_url, - None if uses_token_plan || api_key.is_none() => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(), - None => XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string(), + None => { + if let Some(base_url) = mode_base_url { + base_url.to_string() + } else if uses_standard_mode { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } else if uses_token_plan || api_key.is_none() { + DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + } else { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } + } } } @@ -2078,6 +2224,12 @@ fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bo if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) { return false; } + if provider == ProviderKind::XiaomiMimo + && (xiaomi_mimo_base_url_uses_token_plan(base_url) + || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)) + { + return false; + } let actual = base_url.trim_end_matches('/'); let default = default_base_url_for_provider(provider).trim_end_matches('/'); actual != default @@ -2633,6 +2785,7 @@ struct EnvRuntimeOverrides { openrouter_model: Option, moonshot_model: Option, xiaomi_mimo_model: Option, + xiaomi_mimo_mode: Option, novita_model: Option, fireworks_model: Option, arcee_model: Option, @@ -2698,6 +2851,10 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("MIMO_MODEL")) .ok() .filter(|v| !v.trim().is_empty()), + xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE") + .or_else(|_| std::env::var("MIMO_MODE")) + .ok() + .filter(|v| !v.trim().is_empty()), novita_model: std::env::var("NOVITA_MODEL") .ok() .filter(|v| !v.trim().is_empty()), @@ -3011,6 +3168,8 @@ mod tests { openrouter_api_key: Option, openrouter_base_url: Option, openrouter_model: Option, + xiaomi_mimo_token_plan_api_key: Option, + mimo_token_plan_api_key: Option, xiaomi_mimo_api_key: Option, xiaomi_api_key: Option, mimo_api_key: Option, @@ -3018,6 +3177,8 @@ mod tests { mimo_base_url: Option, xiaomi_mimo_model: Option, mimo_model: Option, + xiaomi_mimo_mode: Option, + mimo_mode: Option, wanjie_ark_api_key: Option, volcengine_api_key: Option, volcengine_ark_api_key: Option, @@ -3084,6 +3245,8 @@ mod tests { openrouter_api_key: env::var_os("OPENROUTER_API_KEY"), openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"), openrouter_model: env::var_os("OPENROUTER_MODEL"), + xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"), + mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"), xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"), xiaomi_api_key: env::var_os("XIAOMI_API_KEY"), mimo_api_key: env::var_os("MIMO_API_KEY"), @@ -3091,6 +3254,8 @@ mod tests { mimo_base_url: env::var_os("MIMO_BASE_URL"), xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"), mimo_model: env::var_os("MIMO_MODEL"), + xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"), + mimo_mode: env::var_os("MIMO_MODE"), wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"), volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"), volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"), @@ -3152,6 +3317,8 @@ mod tests { env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENROUTER_BASE_URL"); env::remove_var("OPENROUTER_MODEL"); + env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); + env::remove_var("MIMO_TOKEN_PLAN_API_KEY"); env::remove_var("XIAOMI_MIMO_API_KEY"); env::remove_var("XIAOMI_API_KEY"); env::remove_var("MIMO_API_KEY"); @@ -3159,6 +3326,8 @@ mod tests { env::remove_var("MIMO_BASE_URL"); env::remove_var("XIAOMI_MIMO_MODEL"); env::remove_var("MIMO_MODEL"); + env::remove_var("XIAOMI_MIMO_MODE"); + env::remove_var("MIMO_MODE"); env::remove_var("WANJIE_ARK_API_KEY"); env::remove_var("VOLCENGINE_API_KEY"); env::remove_var("VOLCENGINE_ARK_API_KEY"); @@ -3237,6 +3406,14 @@ mod tests { Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take()); + Self::restore_var( + "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + self.xiaomi_mimo_token_plan_api_key.take(), + ); + Self::restore_var( + "MIMO_TOKEN_PLAN_API_KEY", + self.mimo_token_plan_api_key.take(), + ); Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); @@ -3244,6 +3421,8 @@ mod tests { Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take()); Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take()); Self::restore_var("MIMO_MODEL", self.mimo_model.take()); + Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take()); + Self::restore_var("MIMO_MODE", self.mimo_mode.take()); Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take()); Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take()); Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take()); @@ -4305,6 +4484,46 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); } + #[test] + fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-ams" +"#, + ) + .expect("xiaomi token-plan region config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL); + } + + #[test] + fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-usa" +"#, + ) + .expect("xiaomi token-plan unknown mode config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); + } + #[test] fn xiaomi_mimo_aliases_resolve_to_canonical_models() { assert_eq!( @@ -4772,6 +4991,48 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, "mimo-v2.5"); } + #[test] + fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL); + } + + #[test] + fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL); + } + #[test] fn novita_env_overrides_key_and_model_when_config_missing() { let _lock = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 853b9c57..929cdbd5 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -65,6 +65,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather than model-provider routing (#2790). Thanks @yusufgurdogan for the implementation. +- Added Xiaomi MiMo `mode` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` selection for + Token Plan region endpoints and pay-as-you-go routing, plus dedicated Token + Plan env keys for `tp-*` subscriptions (#2621, #2627). Thanks @springeye for + the request and @xyuai for the implementation. ### Changed diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index a6647637..4c3df332 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -99,6 +99,9 @@ pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; pub const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; pub const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1"; +pub const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1"; +pub const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL; +pub const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1"; pub const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5"; pub const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr"; pub const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts"; @@ -1883,6 +1886,7 @@ pub struct ProviderConfig { pub api_key: Option, pub base_url: Option, pub model: Option, + pub mode: Option, pub auth_mode: Option, pub http_headers: Option>, pub path_suffix: Option, @@ -2405,12 +2409,16 @@ impl Config { }; let configured_base_url = provider_base.or(root_base); let base = if provider == ApiProvider::XiaomiMimo { - let env_api_key = xiaomi_mimo_env_api_key_for_base_url(); let config_api_key = self .provider_config_for(provider) .and_then(|provider| provider.api_key.as_deref()); + let mode = self + .provider_config_for(provider) + .and_then(|provider| provider.mode.as_deref()); + let env_api_key = + xiaomi_mimo_env_api_key_for_runtime(mode, configured_base_url.as_deref()); let api_key = config_api_key.or(env_api_key.as_deref()); - resolve_xiaomi_mimo_base_url(configured_base_url, api_key) + resolve_xiaomi_mimo_base_url(configured_base_url, api_key, mode) } else { configured_base_url.unwrap_or_else(|| { match provider { @@ -2522,6 +2530,17 @@ impl Config { // 2. Environment variables. Do not query platform credential stores // here; routine startup and doctor checks must stay prompt-free. + if provider == ApiProvider::XiaomiMimo { + let mode = self + .provider_config_for(provider) + .and_then(|provider| provider.mode.as_deref()); + if let Some(value) = + xiaomi_mimo_env_api_key_for_runtime(mode, Some(&self.deepseek_base_url())) + && !value.trim().is_empty() + { + return Ok(value); + } + } if let Some(value) = codewhale_secrets::env_for(slot) && !value.trim().is_empty() { @@ -3459,6 +3478,16 @@ fn apply_env_overrides(config: &mut Config) { .xiaomi_mimo .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::XiaomiMimo) + && let Ok(value) = std::env::var("XIAOMI_MIMO_MODE").or_else(|_| std::env::var("MIMO_MODE")) + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .xiaomi_mimo + .mode = Some(value); + } if matches!(config.api_provider(), ApiProvider::WanjieArk) && let Ok(value) = std::env::var("WANJIE_ARK_BASE_URL") .or_else(|_| std::env::var("WANJIE_BASE_URL")) @@ -4117,24 +4146,125 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { } } -fn resolve_xiaomi_mimo_base_url(configured: Option, api_key: Option<&str>) -> String { - let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key); - match configured { - Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => { - DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() - } - Some(base_url) => base_url, - None if uses_token_plan || api_key.is_none() => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(), - None => XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string(), +fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> { + let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-"); + if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) { + return None; } + Some(match normalized.as_str() { + "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => { + DEFAULT_XIAOMI_MIMO_BASE_URL + } + "token-plan-cn" + | "token-plan-china" + | "token-plan-mainland" + | "token-plan-mainland-china" + | "cn" + | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL, + "token-plan-sgp" + | "token-plan-sg" + | "token-plan-singapore" + | "sgp" + | "sg" + | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL, + "token-plan-ams" + | "token-plan-eu" + | "token-plan-europe" + | "token-plan-amsterdam" + | "ams" + | "eu" + | "europe" + | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL, + _ => DEFAULT_XIAOMI_MIMO_BASE_URL, + }) } -fn xiaomi_mimo_env_api_key_for_base_url() -> Option { - std::env::var("XIAOMI_MIMO_API_KEY") - .or_else(|_| std::env::var("XIAOMI_API_KEY")) - .or_else(|_| std::env::var("MIMO_API_KEY")) - .ok() - .filter(|key| !key.trim().is_empty()) +fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool { + matches!( + normalized_mode, + "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go" + ) +} + +fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { + let normalized = normalize_base_url(base_url).to_ascii_lowercase(); + normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL +} + +fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option { + candidates.iter().find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +fn xiaomi_mimo_env_api_key_for_runtime( + mode: Option<&str>, + base_url: Option<&str>, +) -> Option { + const TOKEN_PLAN_ENV_VARS: &[&str] = + &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"]; + const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]; + + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let standard_selected = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint) + || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go); + if standard_selected { + return xiaomi_mimo_env_var(STANDARD_ENV_VARS); + } + + let token_plan_selected = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode) + .is_some() + || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan); + if token_plan_selected { + return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS); + } + + xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS)) +} + +fn resolve_xiaomi_mimo_base_url( + configured: Option, + api_key: Option<&str>, + mode: Option<&str>, +) -> String { + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let uses_standard_mode = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint); + let mode_base_url = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode); + let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key); + match configured { + Some(base_url) if uses_standard_mode => base_url, + Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => { + mode_base_url + .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL) + .to_string() + } + Some(base_url) => base_url, + None => { + if let Some(base_url) = mode_base_url { + base_url.to_string() + } else if uses_standard_mode { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } else if uses_token_plan || api_key.is_none() { + DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + } else { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } + } + } } fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool { @@ -4154,6 +4284,12 @@ fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> boo { return false; } + if provider == ApiProvider::XiaomiMimo + && (xiaomi_mimo_base_url_uses_token_plan(base_url) + || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)) + { + return false; + } normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider)) } @@ -4389,6 +4525,7 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> api_key: override_cfg.api_key.or(base.api_key), base_url: override_cfg.base_url.or(base.base_url), model: override_cfg.model.or(base.model), + mode: override_cfg.mode.or(base.mode), auth_mode: override_cfg.auth_mode.or(base.auth_mode), http_headers: override_cfg.http_headers.or(base.http_headers), path_suffix: override_cfg.path_suffix.or(base.path_suffix), @@ -5891,6 +6028,8 @@ mod tests { ark_base_url: Option, volcengine_model: Option, volcengine_ark_model: Option, + xiaomi_mimo_token_plan_api_key: Option, + mimo_token_plan_api_key: Option, xiaomi_mimo_api_key: Option, xiaomi_api_key: Option, mimo_api_key: Option, @@ -5898,6 +6037,8 @@ mod tests { mimo_base_url: Option, xiaomi_mimo_model: Option, mimo_model: Option, + xiaomi_mimo_mode: Option, + mimo_mode: Option, novita_api_key: Option, novita_base_url: Option, novita_model: Option, @@ -5990,6 +6131,8 @@ mod tests { let ark_base_url_prev = env::var_os("ARK_BASE_URL"); let volcengine_model_prev = env::var_os("VOLCENGINE_MODEL"); let volcengine_ark_model_prev = env::var_os("VOLCENGINE_ARK_MODEL"); + let xiaomi_mimo_token_plan_api_key_prev = env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); + let mimo_token_plan_api_key_prev = env::var_os("MIMO_TOKEN_PLAN_API_KEY"); let xiaomi_mimo_api_key_prev = env::var_os("XIAOMI_MIMO_API_KEY"); let xiaomi_api_key_prev = env::var_os("XIAOMI_API_KEY"); let mimo_api_key_prev = env::var_os("MIMO_API_KEY"); @@ -5997,6 +6140,8 @@ mod tests { let mimo_base_url_prev = env::var_os("MIMO_BASE_URL"); let xiaomi_mimo_model_prev = env::var_os("XIAOMI_MIMO_MODEL"); let mimo_model_prev = env::var_os("MIMO_MODEL"); + let xiaomi_mimo_mode_prev = env::var_os("XIAOMI_MIMO_MODE"); + let mimo_mode_prev = env::var_os("MIMO_MODE"); let novita_api_key_prev = env::var_os("NOVITA_API_KEY"); let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); let novita_model_prev = env::var_os("NOVITA_MODEL"); @@ -6084,6 +6229,8 @@ mod tests { env::remove_var("ARK_BASE_URL"); env::remove_var("VOLCENGINE_MODEL"); env::remove_var("VOLCENGINE_ARK_MODEL"); + env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); + env::remove_var("MIMO_TOKEN_PLAN_API_KEY"); env::remove_var("XIAOMI_MIMO_API_KEY"); env::remove_var("XIAOMI_API_KEY"); env::remove_var("MIMO_API_KEY"); @@ -6091,6 +6238,8 @@ mod tests { env::remove_var("MIMO_BASE_URL"); env::remove_var("XIAOMI_MIMO_MODEL"); env::remove_var("MIMO_MODEL"); + env::remove_var("XIAOMI_MIMO_MODE"); + env::remove_var("MIMO_MODE"); env::remove_var("NOVITA_API_KEY"); env::remove_var("NOVITA_BASE_URL"); env::remove_var("NOVITA_MODEL"); @@ -6178,6 +6327,8 @@ mod tests { ark_base_url: ark_base_url_prev, volcengine_model: volcengine_model_prev, volcengine_ark_model: volcengine_ark_model_prev, + xiaomi_mimo_token_plan_api_key: xiaomi_mimo_token_plan_api_key_prev, + mimo_token_plan_api_key: mimo_token_plan_api_key_prev, xiaomi_mimo_api_key: xiaomi_mimo_api_key_prev, xiaomi_api_key: xiaomi_api_key_prev, mimo_api_key: mimo_api_key_prev, @@ -6185,6 +6336,8 @@ mod tests { mimo_base_url: mimo_base_url_prev, xiaomi_mimo_model: xiaomi_mimo_model_prev, mimo_model: mimo_model_prev, + xiaomi_mimo_mode: xiaomi_mimo_mode_prev, + mimo_mode: mimo_mode_prev, novita_api_key: novita_api_key_prev, novita_base_url: novita_base_url_prev, novita_model: novita_model_prev, @@ -6290,6 +6443,14 @@ mod tests { Self::restore_var("ARK_BASE_URL", self.ark_base_url.take()); Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take()); + Self::restore_var( + "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + self.xiaomi_mimo_token_plan_api_key.take(), + ); + Self::restore_var( + "MIMO_TOKEN_PLAN_API_KEY", + self.mimo_token_plan_api_key.take(), + ); Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); @@ -6297,6 +6458,8 @@ mod tests { Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take()); Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take()); Self::restore_var("MIMO_MODEL", self.mimo_model.take()); + Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take()); + Self::restore_var("MIMO_MODE", self.mimo_mode.take()); Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); Self::restore_var("NOVITA_MODEL", self.novita_model.take()); @@ -8190,6 +8353,43 @@ model = "mimo-v2.5-pro" Ok(()) } + #[test] + fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() -> Result<()> { + let config: Config = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-ams" +"#, + )?; + + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!( + config.deepseek_base_url(), + XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL + ); + Ok(()) + } + + #[test] + fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() -> Result<()> { + let config: Config = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-usa" +"#, + )?; + + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!(config.deepseek_base_url(), DEFAULT_XIAOMI_MIMO_BASE_URL); + Ok(()) + } + #[test] fn xiaomi_mimo_env_overrides_provider_base_url_model_and_key() -> Result<()> { let _lock = lock_test_env(); @@ -8224,6 +8424,74 @@ model = "mimo-v2.5-pro" Ok(()) } + #[test] + fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-xiaomi-mimo-token-plan-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + env::set_var("XIAOMI_MIMO_MODEL", "voiceclone"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!(config.deepseek_api_key()?, "tp-env-key"); + assert_eq!( + config.deepseek_base_url(), + XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL + ); + assert_eq!(config.default_model(), "voiceclone"); + Ok(()) + } + + #[test] + fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-xiaomi-mimo-payg-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!(config.deepseek_api_key()?, "sk-env-key"); + assert_eq!( + config.deepseek_base_url(), + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL + ); + Ok(()) + } + #[test] fn atlascloud_provider_uses_documented_defaults() -> Result<()> { let config = Config { diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cd5082a4..7fda78c4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -434,9 +434,10 @@ Remaining variables: - `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL` - `OPENROUTER_API_KEY` - `OPENROUTER_BASE_URL` -- `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY` +- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY` - `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL` - `XIAOMI_MIMO_MODEL` or `MIMO_MODEL` +- `XIAOMI_MIMO_MODE` or `MIMO_MODE` - `NOVITA_API_KEY` - `NOVITA_BASE_URL` - `FIREWORKS_API_KEY` diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index d2f89f56..9b319e8a 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -118,7 +118,7 @@ endpoint. | `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. | | `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. | | `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | -| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. Token Plan keys (`tp-...`) use the token-plan endpoint by default; pay-as-you-go keys can set `base_url = "https://api.xiaomimimo.com/v1"`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. `codewhale speech` / `tts` uses the TTS models. | +| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. `codewhale speech` / `tts` uses the TTS models. | | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | @@ -141,7 +141,9 @@ Agent/YOLO mode. Token Plan keys default to the Singapore endpoint `https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned for the China region, set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` -explicitly in `[providers.xiaomi_mimo]`. +explicitly in `[providers.xiaomi_mimo]` or set `mode = "token-plan-cn"`. Europe +Token Plan accounts can use `mode = "token-plan-ams"`; `mode = "pay-as-you-go"` +selects the standard API endpoint and standard MiMo key family. Voice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and `mimo-v2.5-tts-voiceclone`. Xiaomi's current