From e046fe8bebaeb67d6499d4acf0be81085c2c428a Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Wed, 5 Aug 2026 07:46:57 -0400 Subject: [PATCH 1/2] Share one usage builder between Claude's OAuth and web paths Claude's two sources each dropped what the other rendered. They were separate response types with separate snapshot builders, so every window Anthropic added had to be wired twice and never was. OAuth parsed a design window and never rendered it, parsed extra usage and threw the cost away. Web only ever read `seven_day_opus` for the model-specific slot, so an account with no Opus pool showed nothing where Sonnet belonged. Both now deserialize into `ClaudeUsageResponse` and build through `build_snapshot`, which maps every window once. The map-based deserializer that web needed for Anthropic's overlapping alias keys now covers OAuth too. Two behavior changes fall out of the unification: - OAuth's `seven_day_omelette` was aliased onto routines and rendered as "Daily Routines". It maps to the promotional window now, so it reads "Weekly promo" as it already did on web. - Web falls back to the usage payload's embedded extra usage when the dedicated overage endpoint fails, rather than reporting no cost. 807 rust / 474 tauri, clippy and fmt clean on both manifests. Co-Authored-By: Claude Opus 5 (1M context) --- rust/src/providers/claude/mod.rs | 1 + rust/src/providers/claude/oauth/mod.rs | 149 +----------- rust/src/providers/claude/usage_api.rs | 310 +++++++++++++++++++++++++ rust/src/providers/claude/web_api.rs | 278 +++------------------- 4 files changed, 348 insertions(+), 390 deletions(-) create mode 100644 rust/src/providers/claude/usage_api.rs diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index d67770f4..d16360fa 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -4,6 +4,7 @@ mod admin_api; mod cli_reset; mod oauth; mod scoped_weekly; +mod usage_api; mod web_api; use async_trait::async_trait; diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index 35800b1c..33fac0fa 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -5,13 +5,13 @@ use chrono::{DateTime, Utc}; use reqwest::Client; use reqwest::header::{HeaderValue, RETRY_AFTER}; -use serde::Deserialize; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use super::UtilizationScale; -use crate::core::{NamedRateWindow, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot}; +use super::usage_api::{ClaudeUsageResponse, ClaudeUsageWindow}; +use crate::core::{ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot}; mod credentials_store; mod refresh; @@ -53,65 +53,9 @@ impl ClaudeOAuthCredentials { } } -/// OAuth usage response from Claude API -#[derive(Debug, Deserialize)] -pub struct OAuthUsageResponse { - #[serde(rename = "fiveHour", alias = "five_hour")] - pub five_hour: Option, - - #[serde(rename = "sevenDay", alias = "seven_day")] - pub seven_day: Option, - - #[serde(rename = "sevenDaySonnet", alias = "seven_day_sonnet")] - pub seven_day_sonnet: Option, - - #[serde(rename = "sevenDayOpus", alias = "seven_day_opus")] - pub seven_day_opus: Option, - - #[serde( - rename = "sevenDayDesign", - alias = "seven_day_design", - alias = "seven_day_oauth_apps" - )] - pub seven_day_design: Option, - - #[serde( - rename = "sevenDayRoutines", - alias = "seven_day_routines", - alias = "seven_day_omelette" - )] - pub seven_day_routines: Option, - - #[serde(rename = "extraUsage", alias = "extra_usage")] - pub extra_usage: Option, - - #[serde(default)] - limits: Vec, -} - -/// A usage window from the OAuth API -#[derive(Debug, Deserialize)] -pub struct UsageWindow { - pub utilization: Option, - - #[serde(rename = "resetsAt", alias = "resets_at")] - pub resets_at: Option, -} - -/// Extra usage (credits) info -#[derive(Debug, Deserialize)] -pub struct ExtraUsage { - #[serde(rename = "isEnabled", alias = "is_enabled")] - pub is_enabled: Option, - - #[serde(rename = "usedCredits", alias = "used_credits")] - pub used_credits: Option, - - #[serde(rename = "monthlyLimit", alias = "monthly_limit")] - pub monthly_limit: Option, - - pub currency: Option, -} +/// OAuth and web usage now share one normalized response shape. +pub type OAuthUsageResponse = ClaudeUsageResponse; +pub type UsageWindow = ClaudeUsageWindow; /// Claude OAuth fetcher pub struct ClaudeOAuthFetcher { @@ -208,7 +152,11 @@ impl ClaudeOAuthFetcher { } } - Ok(ProviderFetchResult::new(usage, "oauth")) + let mut result = ProviderFetchResult::new(usage, "oauth"); + if let Some(cost) = usage_response.extra_usage_cost() { + result = result.with_cost(cost); + } + Ok(result) } /// Identity of the account this fetcher reads, for labeling and scoping. @@ -416,63 +364,7 @@ impl ClaudeOAuthFetcher { response: &OAuthUsageResponse, credentials: &ClaudeOAuthCredentials, ) -> UsageSnapshot { - // Anthropic mixes fractions and percentages between payloads, so settle - // the unit once from the whole response before reading any window. - let scale = response.utilization_scale(); - - // Primary: 5-hour session window - let primary = response - .five_hour - .as_ref() - .and_then(|w| Self::to_rate_window(w, Some(300), scale)) - .unwrap_or_else(|| RateWindow::new(0.0)); - - let mut usage = UsageSnapshot::new(primary); - - // Secondary: 7-day window - if let Some(weekly) = response - .seven_day - .as_ref() - .and_then(|w| Self::to_rate_window(w, Some(10080), scale)) - { - usage = usage.with_secondary(weekly); - } - - // Model-specific: Opus or Sonnet - if let Some(opus) = response - .seven_day_opus - .as_ref() - .and_then(|w| Self::to_rate_window(w, Some(10080), scale)) - { - usage = usage.with_model_specific(opus); - } else if let Some(sonnet) = response - .seven_day_sonnet - .as_ref() - .and_then(|w| Self::to_rate_window(w, Some(10080), scale)) - { - usage = usage.with_model_specific(sonnet); - } - - let extra_windows = [( - "claude-routines", - "Daily Routines", - response - .seven_day_routines - .as_ref() - .and_then(|w| Self::to_rate_window(w, Some(10080), scale)), - )]; - for (id, title, window) in extra_windows { - if let Some(window) = window { - usage - .extra_rate_windows - .push(NamedRateWindow::new(id, title, window)); - } - usage - .extra_rate_windows - .extend(super::scoped_weekly::scoped_weekly_windows( - &response.limits, - )); - } + let mut usage = response.build_snapshot(Self::to_rate_window); // Plan name from the rate limit tier, falling back to the subscription // type when the tier is shared across plans (Pro and Free both report @@ -518,25 +410,6 @@ impl Default for ClaudeOAuthFetcher { } } -impl OAuthUsageResponse { - /// Decide the utilization unit from every window this response carries. - fn utilization_scale(&self) -> UtilizationScale { - UtilizationScale::detect( - [ - self.five_hour.as_ref(), - self.seven_day.as_ref(), - self.seven_day_sonnet.as_ref(), - self.seven_day_opus.as_ref(), - self.seven_day_design.as_ref(), - self.seven_day_routines.as_ref(), - ] - .into_iter() - .flatten() - .filter_map(|window| window.utilization), - ) - } -} - /// Parse an ISO8601 date string fn parse_iso8601_date(s: &str) -> Option> { // Try parsing with various formats diff --git a/rust/src/providers/claude/usage_api.rs b/rust/src/providers/claude/usage_api.rs new file mode 100644 index 00000000..467a0446 --- /dev/null +++ b/rust/src/providers/claude/usage_api.rs @@ -0,0 +1,310 @@ +use serde::Deserialize; + +use super::UtilizationScale; +use crate::core::{CostSnapshot, NamedRateWindow, RateWindow, UsageSnapshot}; + +/// Usage payload shared by Claude's OAuth and web endpoints. +/// +/// The two endpoints expose the same windows with different casing and have +/// drifted independently in the past. Normalize the wire shape once so every +/// source renders the same set of windows and extra-usage dollars. +#[derive(Debug)] +pub struct ClaudeUsageResponse { + pub five_hour: Option, + pub seven_day: Option, + pub seven_day_opus: Option, + pub seven_day_sonnet: Option, + pub seven_day_oauth_apps: Option, + pub seven_day_design: Option, + pub seven_day_promotional: Option, + pub seven_day_routines: Option, + pub extra_usage: Option, + pub(super) limits: Vec, +} + +impl<'de> Deserialize<'de> for ClaudeUsageResponse { + fn deserialize>(deserializer: D) -> Result { + let mut map: std::collections::HashMap = + std::collections::HashMap::deserialize(deserializer)?; + + let take_window = |map: &mut std::collections::HashMap, + keys: &[&str]| + -> Result, D::Error> { + for key in keys { + if let Some(value) = map.remove(*key) { + if value.is_null() { + continue; + } + return serde_json::from_value(value) + .map(Some) + .map_err(serde::de::Error::custom); + } + } + Ok(None) + }; + + let take_value = |map: &mut std::collections::HashMap, + keys: &[&str]| { + keys.iter() + .find_map(|key| map.remove(*key).filter(|value| !value.is_null())) + }; + + Ok(Self { + five_hour: take_window(&mut map, &["five_hour", "fiveHour"])?, + seven_day: take_window(&mut map, &["seven_day", "sevenDay"])?, + seven_day_opus: take_window(&mut map, &["seven_day_opus", "sevenDayOpus"])?, + seven_day_sonnet: take_window(&mut map, &["seven_day_sonnet", "sevenDaySonnet"])?, + seven_day_oauth_apps: take_window( + &mut map, + &[ + "seven_day_oauth_apps", + "sevenDayOAuthApps", + "seven_day_claude_oauth_apps", + "oauth_apps", + "oauth", + ], + )?, + seven_day_design: take_window( + &mut map, + &[ + "seven_day_design", + "sevenDayDesign", + "seven_day_claude_design", + "claude_design", + "design", + ], + )?, + seven_day_promotional: take_window( + &mut map, + &[ + "omelette_promotional", + "omelettePromotional", + "omelette", + "seven_day_omelette", + "sevenDayOmelette", + ], + )?, + seven_day_routines: take_window( + &mut map, + &[ + "seven_day_routines", + "sevenDayRoutines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "sevenDayCowork", + "cowork", + ], + )?, + limits: take_value(&mut map, &["limits"]) + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(), + extra_usage: take_value(&mut map, &["extra_usage", "extraUsage"]) + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?, + }) + } +} + +#[derive(Debug, Deserialize)] +pub struct ClaudeUsageWindow { + pub utilization: Option, + #[serde(rename = "resets_at", alias = "resetsAt")] + pub resets_at: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ClaudeExtraUsage { + #[serde( + rename = "monthly_credit_limit", + alias = "monthlyCreditLimit", + alias = "monthly_limit", + alias = "monthlyLimit" + )] + pub monthly_limit: Option, + #[serde(rename = "used_credits", alias = "usedCredits")] + pub used_credits: Option, + pub currency: Option, + #[serde(rename = "is_enabled", alias = "isEnabled")] + pub is_enabled: Option, +} + +impl ClaudeUsageResponse { + pub(super) fn utilization_scale(&self) -> UtilizationScale { + UtilizationScale::detect( + [ + self.five_hour.as_ref(), + self.seven_day.as_ref(), + self.seven_day_opus.as_ref(), + self.seven_day_sonnet.as_ref(), + self.seven_day_oauth_apps.as_ref(), + self.seven_day_design.as_ref(), + self.seven_day_promotional.as_ref(), + self.seven_day_routines.as_ref(), + ] + .into_iter() + .flatten() + .filter_map(|window| window.utilization), + ) + } + + pub(super) fn build_snapshot(&self, mut convert: F) -> UsageSnapshot + where + F: FnMut(&ClaudeUsageWindow, Option, UtilizationScale) -> Option, + { + let scale = self.utilization_scale(); + let primary = self + .five_hour + .as_ref() + .and_then(|window| convert(window, Some(300), scale)) + .unwrap_or_else(|| RateWindow::new(0.0)); + let mut snapshot = UsageSnapshot::new(primary); + + if let Some(window) = self + .seven_day + .as_ref() + .and_then(|window| convert(window, Some(10080), scale)) + { + snapshot = snapshot.with_secondary(window); + } + + let model_specific = self + .seven_day_opus + .as_ref() + .and_then(|window| convert(window, Some(10080), scale)) + .or_else(|| { + self.seven_day_sonnet + .as_ref() + .and_then(|window| convert(window, Some(10080), scale)) + }); + if let Some(window) = model_specific { + snapshot = snapshot.with_model_specific(window); + } + + for (id, title, window) in [ + ( + "claude-oauth-apps", + "OAuth apps", + self.seven_day_oauth_apps.as_ref(), + ), + ( + "claude-routines", + "Daily Routines", + self.seven_day_routines.as_ref(), + ), + ("claude-design", "Design", self.seven_day_design.as_ref()), + ( + "claude-weekly-promo", + "Weekly promo", + self.seven_day_promotional.as_ref(), + ), + ] { + if let Some(window) = window.and_then(|window| convert(window, Some(10080), scale)) { + snapshot + .extra_rate_windows + .push(NamedRateWindow::new(id, title, window)); + } + } + + snapshot + .extra_rate_windows + .extend(super::scoped_weekly::scoped_weekly_windows(&self.limits)); + snapshot + } + + pub(super) fn extra_usage_cost(&self) -> Option { + self.extra_usage.as_ref()?.cost_snapshot() + } +} + +impl ClaudeExtraUsage { + pub(super) fn cost_snapshot(&self) -> Option { + let extra = self; + if !extra.is_enabled.unwrap_or(false) { + return None; + } + + let mut cost = CostSnapshot::new( + extra.used_credits.unwrap_or(0.0) / 100.0, + extra.currency.clone().unwrap_or_else(|| "USD".to_string()), + "Monthly", + ); + if let Some(limit) = extra.monthly_limit { + cost = cost.with_limit(limit / 100.0); + } + Some(cost) + } +} + +#[cfg(test)] +mod tests { + use super::ClaudeUsageResponse; + use crate::core::RateWindow; + + fn snapshot(response: &ClaudeUsageResponse) -> crate::core::UsageSnapshot { + response.build_snapshot(|window, minutes, scale| { + Some(RateWindow::with_details( + scale.to_percent(window.utilization?), + minutes, + None, + None, + )) + }) + } + + #[test] + fn oauth_shape_keeps_design_and_extra_usage_dollars() { + let response: ClaudeUsageResponse = serde_json::from_str( + r#"{ + "fiveHour": {"utilization": 12}, + "sevenDayDesign": {"utilization": 34}, + "extraUsage": { + "isEnabled": true, + "usedCredits": 1234, + "monthlyLimit": 5000, + "currency": "USD" + } + }"#, + ) + .expect("OAuth response parses"); + + let usage = snapshot(&response); + let design = usage + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-design") + .expect("Design window survives OAuth mapping"); + assert_eq!(design.window.used_percent, 34.0); + + let cost = response.extra_usage_cost().expect("extra usage cost"); + assert_eq!(cost.used, 12.34); + assert_eq!(cost.limit, Some(50.0)); + assert_eq!(cost.currency_code, "USD"); + } + + #[test] + fn web_shape_keeps_sonnet_as_the_model_specific_window() { + let response: ClaudeUsageResponse = serde_json::from_str( + r#"{ + "five_hour": {"utilization": 12}, + "seven_day": {"utilization": 23}, + "seven_day_sonnet": {"utilization": 45} + }"#, + ) + .expect("web response parses"); + + let usage = snapshot(&response); + assert_eq!( + usage + .model_specific + .expect("Sonnet model-specific window") + .used_percent, + 45.0 + ); + } +} diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index 66f26b4f..5e5c8255 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -6,12 +6,10 @@ use serde::Deserialize; use std::path::PathBuf; use super::UtilizationScale; +use super::usage_api::{ClaudeExtraUsage, ClaudeUsageResponse, ClaudeUsageWindow}; use crate::browser::cookies::{get_cookie_header, get_cookie_header_from_browser}; use crate::browser::detection::{BrowserProfile, BrowserType, DetectedBrowser}; -use crate::core::{ - CostSnapshot, NamedRateWindow, PromoSignal, ProviderError, ProviderFetchResult, RateWindow, - UsageSnapshot, -}; +use crate::core::{PromoSignal, ProviderError, ProviderFetchResult, RateWindow}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClaudeDesktopSessionStatus { @@ -185,130 +183,6 @@ struct Organization { name: Option, } -/// Usage response from Claude API. -/// -/// Anthropic ships overlapping field names for the design and routines -/// windows (e.g. both `seven_day_design` and `seven_day_omelette` may appear -/// in the same payload). Serde aliases can't accept that — it errors with -/// "duplicate field" if more than one alias is present. We deserialize into -/// a generic map and pick the first alias that yields a non-null value. -#[derive(Debug)] -struct UsageResponse { - five_hour: Option, - seven_day: Option, - seven_day_opus: Option, - seven_day_sonnet: Option, - seven_day_oauth_apps: Option, - seven_day_design: Option, - /// Temporary promotional weekly pool when Anthropic reports omelette fields. - seven_day_promotional: Option, - seven_day_routines: Option, - extra_usage: Option, - limits: Vec, -} - -impl<'de> Deserialize<'de> for UsageResponse { - fn deserialize>(deserializer: D) -> Result { - let mut map: std::collections::HashMap = - std::collections::HashMap::deserialize(deserializer)?; - - let take = |map: &mut std::collections::HashMap, - keys: &[&str]| - -> Result, D::Error> { - for key in keys { - if let Some(value) = map.remove(*key) { - if value.is_null() { - continue; - } - let window: UsageWindow = - serde_json::from_value(value).map_err(serde::de::Error::custom)?; - return Ok(Some(window)); - } - } - Ok(None) - }; - - Ok(UsageResponse { - five_hour: take(&mut map, &["five_hour"])?, - seven_day: take(&mut map, &["seven_day"])?, - seven_day_opus: take(&mut map, &["seven_day_opus"])?, - seven_day_sonnet: take(&mut map, &["seven_day_sonnet"])?, - seven_day_oauth_apps: take( - &mut map, - &[ - "seven_day_oauth_apps", - "seven_day_claude_oauth_apps", - "oauth_apps", - "oauth", - ], - )?, - seven_day_design: take( - &mut map, - &[ - "seven_day_design", - "seven_day_claude_design", - "claude_design", - "design", - ], - )?, - seven_day_promotional: take( - &mut map, - &["omelette_promotional", "omelette", "seven_day_omelette"], - )?, - seven_day_routines: take( - &mut map, - &[ - "seven_day_routines", - "seven_day_claude_routines", - "claude_routines", - "routines", - "routine", - "seven_day_cowork", - "cowork", - ], - )?, - limits: map - .get("limits") - .filter(|value| !value.is_null()) - .cloned() - .map(serde_json::from_value) - .transpose() - .map_err(serde::de::Error::custom)? - .unwrap_or_default(), - extra_usage: map - .remove("extra_usage") - .filter(|value| !value.is_null()) - .map(serde_json::from_value) - .transpose() - .map_err(serde::de::Error::custom)?, - }) - } -} - -/// A usage window from the API -#[derive(Debug, Deserialize)] -struct UsageWindow { - utilization: Option, - - #[serde(rename = "resets_at")] - resets_at: Option, -} - -/// Extra usage (credits) response -#[derive(Debug, Clone, Deserialize)] -struct ExtraUsageResponse { - #[serde(rename = "monthly_credit_limit")] - monthly_credit_limit: Option, - - #[serde(rename = "used_credits")] - used_credits: Option, - - currency: Option, - - #[serde(rename = "is_enabled")] - is_enabled: Option, -} - /// Account info response #[derive(Debug, Deserialize)] struct AccountResponse { @@ -435,79 +309,11 @@ impl ClaudeWebApiFetcher { // Step 4: Fetch account info - optional let account = self.get_account_info(&headers).await.ok(); - // Build the result. Anthropic mixes fractions and percentages between - // payloads, so settle the unit once from the whole response first. - let scale = usage.utilization_scale(); - - let primary = usage - .five_hour - .as_ref() - .map(|w| self.to_rate_window(w, Some(300), scale)) // 5 hours = 300 minutes - .unwrap_or_else(|| RateWindow::new(0.0)); - - let secondary = usage - .seven_day - .as_ref() - .map(|w| self.to_rate_window(w, Some(10080), scale)); // 7 days = 10080 minutes - - let model_specific = usage - .seven_day_opus - .as_ref() - .map(|w| self.to_rate_window(w, Some(10080), scale)); - - let mut snapshot = UsageSnapshot::new(primary); - - if let Some(s) = secondary { - snapshot = snapshot.with_secondary(s); - } - - if let Some(m) = model_specific { - snapshot = snapshot.with_model_specific(m); - } - - for (id, title, window) in [ - ( - "claude-oauth-apps", - "OAuth apps", - usage - .seven_day_oauth_apps - .as_ref() - .map(|w| self.to_rate_window(w, Some(10080), scale)), - ), - ( - "claude-routines", - "Daily Routines", - usage - .seven_day_routines - .as_ref() - .map(|w| self.to_rate_window(w, Some(10080), scale)), - ), - ( - "claude-design", - "Design", - usage - .seven_day_design - .as_ref() - .map(|w| self.to_rate_window(w, Some(10080), scale)), - ), - ( - "claude-weekly-promo", - "Weekly promo", - usage - .seven_day_promotional - .as_ref() - .map(|w| self.to_rate_window(w, Some(10080), scale)), - ), - ] { - if let Some(window) = window { - snapshot - .extra_rate_windows - .push(NamedRateWindow::new(id, title, window)); - } - } - snapshot - .extra_rate_windows - .extend(super::scoped_weekly::scoped_weekly_windows(&usage.limits)); + // Build every common Claude usage lane through one normalized path so + // OAuth and web cannot silently drop fields the other source renders. + let mut snapshot = usage.build_snapshot(|window, minutes, scale| { + Some(self.to_rate_window(window, minutes, scale)) + }); if let Some(promo) = usage.seven_day_promotional.as_ref() { let ends_at = promo @@ -534,24 +340,13 @@ impl ClaudeWebApiFetcher { let mut result = ProviderFetchResult::new(snapshot, "web"); - // Add cost info if available - if let Some(extra) = extra_usage - && extra.is_enabled.unwrap_or(false) - { - let used_cents = extra.used_credits.unwrap_or(0.0); - let limit_cents = extra.monthly_credit_limit; - let currency = extra.currency.unwrap_or_else(|| "USD".to_string()); - - let mut cost = CostSnapshot::new( - used_cents / 100.0, // Convert cents to dollars - currency, - "Monthly", - ); - - if let Some(limit) = limit_cents { - cost = cost.with_limit(limit / 100.0); - } - + // Prefer the dedicated extra-usage endpoint; the embedded payload is + // the fallback. Both use the same cents-to-dollars conversion as OAuth. + let cost = extra_usage + .as_ref() + .and_then(ClaudeExtraUsage::cost_snapshot) + .or_else(|| usage.extra_usage_cost()); + if let Some(cost) = cost { result = result.with_cost(cost); } @@ -658,7 +453,7 @@ impl ClaudeWebApiFetcher { &self, org_id: &str, headers: &reqwest::header::HeaderMap, - ) -> Result { + ) -> Result { let url = format!("{}/organizations/{}/usage", Self::BASE_URL, org_id); let response = self @@ -683,7 +478,7 @@ impl ClaudeWebApiFetcher { &self, org_id: &str, headers: &reqwest::header::HeaderMap, - ) -> Result { + ) -> Result { let url = format!( "{}/organizations/{}/overage_spend_limit", Self::BASE_URL, @@ -734,7 +529,7 @@ impl ClaudeWebApiFetcher { /// Convert a usage window to a RateWindow fn to_rate_window( &self, - window: &UsageWindow, + window: &ClaudeUsageWindow, window_minutes: Option, scale: UtilizationScale, ) -> RateWindow { @@ -779,27 +574,6 @@ impl Default for ClaudeWebApiFetcher { } } -impl UsageResponse { - /// Decide the utilization unit from every window this response carries. - fn utilization_scale(&self) -> UtilizationScale { - UtilizationScale::detect( - [ - self.five_hour.as_ref(), - self.seven_day.as_ref(), - self.seven_day_opus.as_ref(), - self.seven_day_sonnet.as_ref(), - self.seven_day_oauth_apps.as_ref(), - self.seven_day_design.as_ref(), - self.seven_day_promotional.as_ref(), - self.seven_day_routines.as_ref(), - ] - .into_iter() - .flatten() - .filter_map(|window| window.utilization), - ) - } -} - fn cookie_value(cookie_header: &str, name: &str) -> Option { cookie_header.split(';').find_map(|part| { let (key, value) = part.trim().split_once('=')?; @@ -818,8 +592,8 @@ fn cookie_value(cookie_header: &str, name: &str) -> Option { #[cfg(test)] mod tests { use super::{ - AccountResponse, ClaudeWebApiFetcher, UsageWindow, UtilizationScale, - claude_desktop_data_dirs_from, cookie_value, + AccountResponse, ClaudeUsageResponse, ClaudeUsageWindow, ClaudeWebApiFetcher, + UtilizationScale, claude_desktop_data_dirs_from, cookie_value, }; use reqwest::header; use std::path::PathBuf; @@ -832,7 +606,7 @@ mod tests { #[test] fn converts_fractional_utilization_to_percent() { - let window = UsageWindow { + let window = ClaudeUsageWindow { utilization: Some(0.23), resets_at: None, }; @@ -848,7 +622,7 @@ mod tests { #[test] fn preserves_existing_percentage_utilization() { - let window = UsageWindow { + let window = ClaudeUsageWindow { utilization: Some(23.0), resets_at: None, }; @@ -998,7 +772,7 @@ mod tests { #[test] fn parses_extra_design_and_routines_aliases() { - let usage: super::UsageResponse = serde_json::from_str( + let usage: ClaudeUsageResponse = serde_json::from_str( r#"{ "five_hour": { "utilization": 0.1 }, "seven_day_design": { "utilization": 31 }, @@ -1032,7 +806,7 @@ mod tests { #[test] fn maps_scoped_weekly_limits_even_when_inactive() { - let usage: super::UsageResponse = serde_json::from_str( + let usage: ClaudeUsageResponse = serde_json::from_str( r#"{ "limits": [{ "kind": "weekly_scoped", @@ -1054,7 +828,7 @@ mod tests { #[test] fn parses_duplicate_design_and_routines_aliases_with_preferred_key() { - let usage: super::UsageResponse = serde_json::from_str( + let usage: ClaudeUsageResponse = serde_json::from_str( r#"{ "seven_day_design": { "utilization": 31 }, "seven_day_omelette": { "utilization": 26 }, @@ -1082,7 +856,7 @@ mod tests { #[test] fn parses_oauth_apps_window_and_embedded_extra_usage() { - let usage: super::UsageResponse = serde_json::from_str( + let usage: ClaudeUsageResponse = serde_json::from_str( r#"{ "five_hour": { "utilization": 0.1 }, "seven_day_oauth_apps": { "utilization": 42 }, @@ -1106,7 +880,7 @@ mod tests { assert!((oauth_apps.used_percent - 42.0).abs() < f64::EPSILON); assert_eq!(extra.is_enabled, Some(true)); - assert_eq!(extra.monthly_credit_limit, Some(2000.0)); + assert_eq!(extra.monthly_limit, Some(2000.0)); assert_eq!(extra.used_credits, Some(550.0)); } } From 261538ce638c66cfd3bca6aaa3a830888bd98907 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Wed, 5 Aug 2026 09:59:56 -0400 Subject: [PATCH 2/2] Don't resurrect a cost the overage endpoint reports as disabled The fetch site already falls back to the usage payload's embedded extra usage when the dedicated overage endpoint fails, so a second fallback at the cost site only fires when that endpoint *succeeded* and reported `is_enabled: false`. That turned a deliberate "no overage" answer into a cost read from the stale embedded copy. Caught by CodeRabbit on #209. Co-Authored-By: Claude Opus 5 (1M context) --- rust/src/providers/claude/web_api.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index 5e5c8255..504f4736 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -340,13 +340,13 @@ impl ClaudeWebApiFetcher { let mut result = ProviderFetchResult::new(snapshot, "web"); - // Prefer the dedicated extra-usage endpoint; the embedded payload is - // the fallback. Both use the same cents-to-dollars conversion as OAuth. - let cost = extra_usage + // `extra_usage` already fell back to the embedded payload when the + // dedicated endpoint failed, so a `None` here means the endpoint was + // read and reported overage as disabled. Do not claim a cost for it. + if let Some(cost) = extra_usage .as_ref() .and_then(ClaudeExtraUsage::cost_snapshot) - .or_else(|| usage.extra_usage_cost()); - if let Some(cost) = cost { + { result = result.with_cost(cost); }