-
Notifications
You must be signed in to change notification settings - Fork 137
Port Mistral subscription allowances from 0.61.0 #559
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -9,16 +9,24 @@ use reqwest::Client; | |||||
| use serde::Deserialize; | ||||||
| use std::collections::HashMap; | ||||||
|
|
||||||
| mod subscription; | ||||||
| mod token_math; | ||||||
|
|
||||||
| use subscription::{SubscriptionBudget, SubscriptionBudgets}; | ||||||
|
|
||||||
| use crate::core::{ | ||||||
| CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, | ||||||
| ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, | ||||||
| CostSnapshot, FetchContext, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, | ||||||
| ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, | ||||||
| }; | ||||||
|
|
||||||
| const BASE_URL: &str = "https://admin.mistral.ai"; | ||||||
| const COOKIE_DOMAINS: [&str; 3] = ["admin.mistral.ai", "mistral.ai", "auth.mistral.ai"]; | ||||||
| const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; | ||||||
| const CLIENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); | ||||||
| /// Optional subscription-page enrichment joins on a fast deadline so a slow | ||||||
| /// `/subscription` render can never stall the refresh; degraded enrichment is | ||||||
| /// logged and skipped, never fatal. | ||||||
| const SUBSCRIPTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); | ||||||
|
|
||||||
| #[derive(Debug, Deserialize)] | ||||||
| struct BillingResponse { | ||||||
|
|
@@ -168,7 +176,7 @@ impl MistralProvider { | |||||
| status_page_url: Some("https://status.mistral.ai"), | ||||||
| }, | ||||||
| client: crate::core::credentialed_http_client_builder() | ||||||
| .timeout(std::time::Duration::from_secs(30)) | ||||||
| .timeout(CLIENT_TIMEOUT) | ||||||
| .build() | ||||||
| .unwrap_or_else(|_| Client::new()), | ||||||
| } | ||||||
|
|
@@ -224,7 +232,48 @@ impl MistralProvider { | |||||
| .map_err(|e| ProviderError::Parse(format!("Failed to parse Mistral usage: {e}")))?; | ||||||
|
|
||||||
| let summary = Self::summarize_billing(billing)?; | ||||||
| Ok(Self::build_result(summary)) | ||||||
| let budgets = match self.fetch_subscription_budgets(cookie_header).await { | ||||||
| Ok(budgets) => Some(budgets), | ||||||
| Err(error) => { | ||||||
| tracing::debug!(error = %error, "Mistral subscription allowance enrichment unavailable"); | ||||||
| None | ||||||
| } | ||||||
| }; | ||||||
| Ok(Self::build_result(summary, budgets)) | ||||||
| } | ||||||
|
|
||||||
| async fn fetch_subscription_budgets( | ||||||
| &self, | ||||||
| cookie_header: &str, | ||||||
| ) -> Result<SubscriptionBudgets, ProviderError> { | ||||||
| let response = self | ||||||
| .client | ||||||
| .get(format!("{BASE_URL}/subscription")) | ||||||
| .timeout(SUBSCRIPTION_TIMEOUT) | ||||||
| .header("Accept", "text/html") | ||||||
| .header("Accept-Language", "en-US,en;q=0.9") | ||||||
| .header("Cookie", cookie_header) | ||||||
| .header("Referer", format!("{BASE_URL}/subscription")) | ||||||
| .header("User-Agent", USER_AGENT) | ||||||
| .send() | ||||||
| .await?; | ||||||
| let status = response.status(); | ||||||
| if status.as_u16() == 401 || status.as_u16() == 403 { | ||||||
| return Err(ProviderError::AuthRequired); | ||||||
| } | ||||||
| if !status.is_success() { | ||||||
| return Err(ProviderError::Other(format!( | ||||||
| "Mistral subscription API returned {status}" | ||||||
| ))); | ||||||
| } | ||||||
| let final_url = response.url(); | ||||||
| if final_url.scheme() != "https" || final_url.host_str() != Some("admin.mistral.ai") { | ||||||
| return Err(ProviderError::Parse( | ||||||
| "Mistral subscription response came from an unexpected host".into(), | ||||||
| )); | ||||||
| } | ||||||
| let body = response.text().await?; | ||||||
| subscription::parse(&body).map_err(ProviderError::Parse) | ||||||
| } | ||||||
|
|
||||||
| fn summarize_billing(billing: BillingResponse) -> Result<MistralUsageSummary, ProviderError> { | ||||||
|
|
@@ -305,7 +354,10 @@ impl MistralProvider { | |||||
| }) | ||||||
| } | ||||||
|
|
||||||
| fn build_result(summary: MistralUsageSummary) -> ProviderFetchResult { | ||||||
| fn build_result( | ||||||
| summary: MistralUsageSummary, | ||||||
| budgets: Option<SubscriptionBudgets>, | ||||||
| ) -> ProviderFetchResult { | ||||||
| let reset_date = summary.end_date.map(|dt| dt + chrono::Duration::seconds(1)); | ||||||
| let cost_description = if summary.total_cost > 0.0 { | ||||||
| format!( | ||||||
|
|
@@ -338,9 +390,39 @@ impl MistralProvider { | |||||
| token_detail | ||||||
| )); | ||||||
|
|
||||||
| if let Some(budgets) = budgets { | ||||||
| if let Some(api) = budgets.api { | ||||||
| usage.primary = Self::budget_window(&api); | ||||||
| usage.primary_label = Some("Included API".to_string()); | ||||||
| } | ||||||
| if let Some(vibe) = budgets.vibe { | ||||||
| usage.extra_rate_windows.push(NamedRateWindow::new( | ||||||
| "mistral-monthly-plan", | ||||||
| "Monthly Plan", | ||||||
| Self::budget_window(&vibe), | ||||||
| )); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| ProviderFetchResult::new(usage, "web").with_cost(cost) | ||||||
| } | ||||||
|
|
||||||
| fn budget_window(budget: &SubscriptionBudget) -> RateWindow { | ||||||
| let used = budget.used_amount(); | ||||||
| let remaining = budget.remaining_amount(); | ||||||
| let description = format!( | ||||||
| "{used:.2} {currency} / {limit:.2} {currency} · {remaining:.2} {currency} remaining", | ||||||
| currency = budget.currency, | ||||||
| limit = budget.limit, | ||||||
| ); | ||||||
| RateWindow::with_details( | ||||||
| budget.used_percent, | ||||||
| None, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '70,115p' rust/src/core/rate_window.rs
sed -n '380,430p' rust/src/providers/mistral/mod.rs
rg -n 'window_minutes|monthly_window_minutes|resets_at|reset_at' rust/src apps/desktop-tauri/src-tauri/src | head -240Repository: nesszer/Win-CodexBar Length of output: 26687 🏁 Script executed: set -eu
printf '%s\n' '--- rate_window.rs ---'
sed -n '130,230p' rust/src/core/rate_window.rs
printf '%s\n' '--- usage_pace.rs ---'
sed -n '80,125p' rust/src/core/usage_pace.rs
printf '%s\n' '--- rust/src/cli/usage.rs consumers ---'
sed -n '350,400p' rust/src/cli/usage.rs
sed -n '525,625p' rust/src/cli/usage.rs
printf '%s\n' '--- desktop bridge consumers ---'
sed -n '350,410p' apps/desktop-tauri/src-tauri/src/commands/bridge.rs
sed -n '1050,1110p' apps/desktop-tauri/src-tauri/src/commands/providers.rs
printf '%s\n' '--- Mistral definitions, call sites, and tests ---'
rg -n -C 4 'budget_window|SubscriptionBudget|monthly_window_minutes|mistral-monthly-plan|Mistral' rust/src/providers/mistral apps/desktop-tauri/src-tauri/src rust/src/coreRepository: nesszer/Win-CodexBar Length of output: 50376 🏁 Script executed: set -eu
sed -n '130,230p' rust/src/core/rate_window.rs
sed -n '80,125p' rust/src/core/usage_pace.rs
sed -n '350,400p' rust/src/cli/usage.rs
sed -n '525,625p' rust/src/cli/usage.rs
sed -n '350,410p' apps/desktop-tauri/src-tauri/src/commands/bridge.rs
sed -n '1050,1110p' apps/desktop-tauri/src-tauri/src/commands/providers.rs
rg -n -C 4 'budget_window|SubscriptionBudget|monthly_window_minutes|mistral-monthly-plan|Mistral' rust/src/providers/mistral apps/desktop-tauri/src-tauri/src rust/src/coreRepository: nesszer/Win-CodexBar Length of output: 50376 Set the monthly window duration when a reset is known.
Proposed fix RateWindow::with_details(
budget.used_percent,
- None,
+ RateWindow::monthly_window_minutes(budget.resets_at),
budget.resets_at,
Some(description),
)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| budget.resets_at, | ||||||
| Some(description), | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| fn build_price_index(prices: Vec<MistralPrice>) -> HashMap<String, f64> { | ||||||
| prices | ||||||
| .into_iter() | ||||||
|
|
@@ -502,7 +584,7 @@ mod tests { | |||||
| assert!((summary.total_cost - 0.005).abs() < 0.000001); | ||||||
| assert_eq!(summary.model_count, 1); | ||||||
|
|
||||||
| let result = MistralProvider::build_result(summary); | ||||||
| let result = MistralProvider::build_result(summary, None); | ||||||
| assert_eq!( | ||||||
| result.cost.as_ref().map(|c| c.currency_code.as_str()), | ||||||
| Some("EUR") | ||||||
|
|
@@ -518,6 +600,46 @@ mod tests { | |||||
| ); | ||||||
| } | ||||||
|
|
||||||
| #[test] | ||||||
| fn attaches_subscription_allowances_without_replacing_billing_cost() { | ||||||
| let summary = MistralUsageSummary { | ||||||
| total_cost: 12.5, | ||||||
| currency: "EUR".to_string(), | ||||||
| currency_symbol: "€".to_string(), | ||||||
| total_input_tokens: 100, | ||||||
| total_output_tokens: 50, | ||||||
| total_cached_tokens: 0, | ||||||
| model_count: 1, | ||||||
| end_date: None, | ||||||
| }; | ||||||
| let result = MistralProvider::build_result( | ||||||
| summary, | ||||||
| Some(SubscriptionBudgets { | ||||||
| api: Some(SubscriptionBudget { | ||||||
| used_percent: 25.0, | ||||||
| limit: 100.0, | ||||||
| currency: "USD".to_string(), | ||||||
| resets_at: None, | ||||||
| }), | ||||||
| vibe: Some(SubscriptionBudget { | ||||||
| used_percent: 50.0, | ||||||
| limit: 20.0, | ||||||
| currency: "EUR".to_string(), | ||||||
| resets_at: None, | ||||||
| }), | ||||||
| }), | ||||||
| ); | ||||||
|
|
||||||
| assert_eq!(result.usage.primary.used_percent, 25.0); | ||||||
| assert_eq!(result.usage.primary_label.as_deref(), Some("Included API")); | ||||||
| assert_eq!(result.usage.extra_rate_windows.len(), 1); | ||||||
| assert_eq!( | ||||||
| result.usage.extra_rate_windows[0].id, | ||||||
| "mistral-monthly-plan" | ||||||
| ); | ||||||
| assert_eq!(result.cost.as_ref().map(|cost| cost.used), Some(12.5)); | ||||||
| } | ||||||
|
|
||||||
| #[test] | ||||||
| fn extracts_csrf_token_from_cookie_header() { | ||||||
| assert_eq!( | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: nesszer/Win-CodexBar
Length of output: 9636
🌐 Web query:
reqwest 0.12.28 redirect Cookie header removed host port scheme change redirect policy implementation💡 Result:
<search_synthesis>
In reqwest version 0.12.28, the removal of sensitive headers (such as Cookie and Authorization) during redirects is handled by the
remove_sensitive_headersfunction insrc/redirect.rs[1][2]. This function ensures that sensitive headers are stripped when a redirect crosses an origin boundary [1][2]. Specifically, it checks for changes in the host, effective port, or scheme [1][2]. If any of these components differ between the previous URL and the next URL, the following headers are removed: - Authorization [1][2] - Cookie [1][2] - cookie2 [1][2] - Proxy-Authorization [1][2] - WWW-Authenticate [1][2] A security issue (tracked as issue#2985) previously existed where sensitive headers were retained during HTTPS to HTTP redirects if the host and port remained the same [3]. This was addressed in pull request#3034, which updated theremove_sensitive_headerslogic to explicitly include a scheme comparison (next.scheme() != previous.scheme()) in the cross-origin check [4][3]. This fix ensures that transport downgrades (e.g., HTTPS to HTTP) correctly trigger the removal of sensitive headers, even if the host and port are identical [4][3]. Thereqwestredirect policy implementation uses this function to maintain security when following redirects automatically [1][5]. Users can also define custom redirect policies usingredirect::Policy::custom, which provides access to the redirect attempt information, though they must ensure they handle sensitive header security if they override default behaviors [6].</search_synthesis>
<source_evidence>
Citations:
🌐 Web query:
site:docs.rs/reqwest/0.12.28 reqwest redirect.rs remove_sensitive_headers Cookie scheme💡 Result:
<search_synthesis>
The reqwest crate documentation for version 0.12.28 does not contain a feature or method explicitly named remove_sensitive_headers [1]. While reqwest provides a customizable redirect policy via redirect::Policy and supports cookie session management through the cookie_store method on ClientBuilder, there is no documented mechanism that automatically strips sensitive headers like Cookie during a redirect [1]. Users requiring specific header handling during redirects must implement custom logic using the available redirect policy configuration [1].
</search_synthesis>
<source_evidence>
Citations:
🌐 Web query:
reqwest 0.12.28 GitHub tag redirect.rs remove_sensitive_headers source💡 Result:
<search_synthesis>
In reqwest version 0.12.28, the remove_sensitive_headers function in src/redirect.rs was responsible for stripping sensitive headers (such as Authorization, Cookie, and Proxy-Authorization) when a redirect crossed an origin boundary [1][2]. At the time of the 0.12.28 release (December 2025), this function determined whether a redirect was "cross-host" by comparing only the host and the effective port of the next and previous URLs [1][2]. It did not explicitly compare the URL scheme [3][4]. Consequently, this implementation allowed sensitive headers to be retained during HTTPS-to-HTTP protocol downgrades if the host and port remained the same [3][4]. This behavior was later identified as a security issue (tracked in issue
#2985) and was resolved in a subsequent release via pull request#3034, which updated the logic to include a scheme comparison in the cross-origin check [3][4]. The source code for remove_sensitive_headers in versions prior to the fix (including 0.12.28) generally followed this pattern [1][2]: pub(crate) fn remove_sensitive_headers(headers: &mut HeaderMap, next: &Url, previous: &[Url]) { if let Some(previous) = previous.last { let cross_host = next.host_str!= previous.host_str || next.port_or_known_default!= previous.port_or_known_default || next.scheme!= previous.scheme; // Note: The fix added this check if cross_host { headers.remove(AUTHORIZATION); headers.remove(COOKIE); headers.remove("cookie2"); headers.remove(PROXY_AUTHORIZATION); headers.remove(WWW_AUTHENTICATE); } } } (Note: In 0.12.28, the line checking next.scheme!= previous.scheme was absent, which is why the downgrade vulnerability existed [3][4].)</search_synthesis>
<source_evidence>
Citations:
Sensitive Data Exposure
Reachability: External
Exploitability: Difficult
CWE: CWE-319 — Cleartext Transmission of Sensitive Information
Reject non-HTTPS redirects before reqwest follows them.
reqwest 0.12.28stripsCookiewhen the host or effective port changes, but its redirect handling does not treat a scheme-only change as sensitive. A redirect fromhttps://admin.mistral.aitohttp://admin.mistral.ai:443can therefore forward the cookie over cleartext beforeresponse.url()is checked. Configure the redirect policy to reject every non-HTTPS redirect and any host other thanadmin.mistral.aibefore sending the next request. Keep the final URL check as defense in depth.🤖 Prompt for AI Agents