From 14056e740a986b8bc78a431e649b416c2aeb29ec Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Sun, 6 Sep 2026 19:59:20 -0300 Subject: [PATCH] fix(minimax): fetch real quota via Bearer API key, not just cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiniMax redesigned the console: /console/usage and /console/plan are now client-rendered Next.js pages loaded with `ssr:false`, so the server never emits real numbers in the initial HTML — not even with a valid, authenticated cookie. Every cookie/HTML-scraping path is structurally unable to read this data, so MiniMax usage fell through to the always-0% "configured" stub whenever cookie scraping failed. The coding-plan `remains` endpoint the HTML scraper already falls back to also accepts a plain `Authorization: Bearer ` with no cookie at all, and returns the exact `model_remains` JSON shape the existing parser (coding_plan.rs) already understands. Add a Bearer-authenticated path that tries this endpoint first, using an API key from Settings or `MINIMAX_API_KEY`, before falling back to the legacy group_id+api_key billing endpoint. Also register MiniMax in `get_api_key_providers()` so the API key can be entered through Settings/`config set-api-key` like other providers — previously there was no supported way to configure just an API key (only a paired group_id+api_key via env vars or a local `minimax`-CLI-style config file). Fixes #425 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L23pzyCfMvfbMQwnHXmKCp --- rust/src/providers/minimax/mod.rs | 145 +++++++++++++++++++++++++++++- rust/src/settings/api_keys.rs | 13 +++ 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 03dbe5a0b8..3cf85d5267 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -267,10 +267,26 @@ impl MiniMaxProvider { /// Fetch usage via MiniMax API with region fallback async fn fetch_via_web( &self, + ctx: &FetchContext, region: MiniMaxRegion, ) -> Result { - let (group_id, api_key) = self.read_api_key().await?; + // Prefer the coding-plan remains endpoint (Win-CodexBar #425): the + // console's usage/plan pages are client-rendered (Next.js `ssr:false`), + // so no server HTML ever contains real numbers, even with a valid + // cookie. The underlying `coding_plan/remains` endpoint instead + // accepts a plain `Authorization: Bearer ` with no cookie at + // all (no group_id needed), and returns the same `model_remains` + // shape the cookie-based parser already understands. This key can + // come from Settings (GUI-stored) or the environment, independent of + // the dual group_id+api_key credential the legacy billing endpoint + // below requires. + if let Some(key) = Self::read_plain_api_key(ctx) + && let Ok(result) = self.fetch_remains_via_api_key(&key, region).await + { + return Ok(result); + } + let (group_id, api_key) = self.read_api_key().await?; match self.fetch_from_region(&group_id, &api_key, region).await { Ok(result) => Ok(result), Err(ProviderError::AuthRequired) if region == MiniMaxRegion::Global => { @@ -281,6 +297,88 @@ impl MiniMaxProvider { } } + /// A plain MiniMax API key from Settings (GUI-stored, via `ctx.api_key`) + /// or the `MINIMAX_API_KEY` environment variable. Unlike `read_api_key`, + /// this does not require a paired group_id. + fn read_plain_api_key(ctx: &FetchContext) -> Option { + ctx.api_key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) + .or_else(|| { + std::env::var("MINIMAX_API_KEY") + .ok() + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) + }) + } + + /// Fetch coding-plan quota from the remains endpoint using a Bearer API + /// key instead of a browser cookie. Tries the platform-host URL, then the + /// www-host URL, mirroring the cookie-based fallback chain. + async fn fetch_remains_via_api_key( + &self, + api_key: &str, + region: MiniMaxRegion, + ) -> Result { + let now = Utc::now(); + let urls = [region.coding_plan_remains_url(), region.www_remains_url()]; + let mut last_err: Option = None; + for url in urls { + match self.fetch_remains_once_via_api_key(api_key, &url).await { + Ok(snapshot) => { + let usage = coding_plan_html::to_usage_snapshot(&snapshot, now)?; + return Ok(ProviderFetchResult::new(usage, "api")); + } + Err(err @ ProviderError::Parse(_)) => { + last_err = Some(err); + } + Err(err) => return Err(err), + } + } + Err(last_err.unwrap_or_else(|| ProviderError::Parse("Missing MiniMax remains URL.".into()))) + } + + /// One Bearer-authenticated remains-API request, returning the parsed snapshot. + async fn fetch_remains_once_via_api_key( + &self, + api_key: &str, + url: &str, + ) -> Result { + let client = crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| ProviderError::Other(e.to_string()))?; + + let response = client + .get(url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json, text/plain, */*") + .send() + .await?; + + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + let msg = format!("MiniMax remains (api key) returned status {status}"); + if status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::METHOD_NOT_ALLOWED + { + return Err(ProviderError::Parse(msg)); + } + return Err(ProviderError::Other(msg)); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| ProviderError::Parse(format!("Failed to parse remains JSON: {e}")))?; + coding_plan::parse_coding_plan_value(&json, Utc::now()) + } + /// Fetch from a specific region endpoint async fn fetch_from_region( &self, @@ -1019,7 +1117,7 @@ impl Provider for MiniMaxProvider { return Ok(result); } // Fall through to API keys. - if let Ok(result) = self.fetch_via_web(region).await { + if let Ok(result) = self.fetch_via_web(ctx, region).await { return Ok(result); } let usage = self.probe_cli().await?; @@ -1027,7 +1125,7 @@ impl Provider for MiniMaxProvider { } SourceMode::Web => match self.resolve_web_cookie(ctx, region)? { Some(cookie) => self.fetch_with_cookie(&cookie, region).await, - None => self.fetch_via_web(region).await, + None => self.fetch_via_web(ctx, region).await, }, SourceMode::Cli => { let usage = self.probe_cli().await?; @@ -1054,6 +1152,47 @@ impl Provider for MiniMaxProvider { mod tests { use super::*; + #[test] + fn read_plain_api_key_prefers_ctx_then_env_then_none() { + let ctx_with_key = FetchContext { + api_key: Some(" ctx-key ".to_string()), + ..FetchContext::default() + }; + assert_eq!( + MiniMaxProvider::read_plain_api_key(&ctx_with_key).as_deref(), + Some("ctx-key") + ); + + // Isolate from any MINIMAX_API_KEY already set in the ambient + // environment (e.g. a developer's own shell), and restore it after. + let previous = std::env::var("MINIMAX_API_KEY").ok(); + // SAFETY: test-only env var; saved above and restored below within + // this single test, with no other test reading MINIMAX_API_KEY. + unsafe { std::env::remove_var("MINIMAX_API_KEY") }; + + let ctx_empty = FetchContext { + api_key: Some(" ".to_string()), + ..FetchContext::default() + }; + assert_eq!(MiniMaxProvider::read_plain_api_key(&ctx_empty), None); + + // SAFETY: see above. + unsafe { std::env::set_var("MINIMAX_API_KEY", "env-key") }; + let result = MiniMaxProvider::read_plain_api_key(&FetchContext::default()); + assert_eq!(result.as_deref(), Some("env-key")); + + match previous { + Some(value) => { + // SAFETY: see above. + unsafe { std::env::set_var("MINIMAX_API_KEY", value) } + } + None => { + // SAFETY: see above. + unsafe { std::env::remove_var("MINIMAX_API_KEY") } + } + } + } + #[test] fn minimax_region_defaults_to_global_io_urls() { let region = MiniMaxRegion::from_settings_value(None); diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 07ba5b1c8d..c396e8207b 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -206,6 +206,19 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://ollama.com/settings"), }, + ProviderConfigInfo { + id: ProviderId::MiniMax, + name: "MiniMax", + requires_api_key: false, + api_key_env_var: Some("MINIMAX_API_KEY"), + api_key_help: Some( + "Optional: a MiniMax API key reads real coding-plan quota via the console's remains API, bypassing the client-rendered usage/plan pages that browser cookies alone cannot scrape.", + ), + config_file_path: None, + dashboard_url: Some( + "https://platform.minimax.io/user-center/basic-information/interface-key", + ), + }, ProviderConfigInfo { id: ProviderId::AzureOpenAI, name: "Azure OpenAI",