From 459d3677fa23297776cdd5ba93e191340011689a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:50:15 +0700 Subject: [PATCH] Port upstream 0.69.0: show Claude Web limit reset credits --- rust/src/providers/claude/mod.rs | 1 + rust/src/providers/claude/reset_credits.rs | 134 +++++++++++++ .../providers/claude/reset_credits/tests.rs | 187 ++++++++++++++++++ .../providers/claude/reset_opt_in_tests.rs | 143 ++++++++++++++ rust/src/providers/claude/web_api.rs | 85 ++++++-- 5 files changed, 535 insertions(+), 15 deletions(-) create mode 100644 rust/src/providers/claude/reset_credits.rs create mode 100644 rust/src/providers/claude/reset_credits/tests.rs create mode 100644 rust/src/providers/claude/reset_opt_in_tests.rs diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 22da9fe951..c862f75209 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -6,6 +6,7 @@ pub mod claude_swap; mod cli_reset; mod oauth; pub mod quota_history; +mod reset_credits; pub mod reset_observations; mod scoped_weekly; mod web_api; diff --git a/rust/src/providers/claude/reset_credits.rs b/rust/src/providers/claude/reset_credits.rs new file mode 100644 index 0000000000..c8431c60e8 --- /dev/null +++ b/rust/src/providers/claude/reset_credits.rs @@ -0,0 +1,134 @@ +//! Claude usage-limit resets ("Reset for free" in Claude Settings > Usage), +//! read from the `cedar_ember` block of the Claude Web usage response. +//! +//! The result is a display-only [`ProviderInventoryItem`]: a count plus the +//! soonest expiry. Grant identifiers are redemption handles and are never +//! deserialized, so they cannot reach the bridge, CLI output, or any +//! persisted snapshot. Only the Web source reads this block. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer}; + +use crate::core::ProviderInventoryItem; + +const INVENTORY_ID: &str = "reset-credits"; +const INVENTORY_TITLE: &str = "Limit Reset Credits"; + +/// Observed grants hold one reset each; a larger total is treated as +/// malformed instead of allocated. +const MAX_RESETS: usize = 50; +/// Upper bound on grant records, checked before any record is decoded. +const MAX_GRANT_RECORDS: usize = 200; + +/// Build the display inventory from the raw `cedar_ember` value. +/// +/// Anything unreadable yields `None` and leaves the usage windows intact. +pub(super) fn inventory_from_block( + block: &serde_json::Value, + now: DateTime, +) -> Option { + ResetStatus::deserialize(block).ok()?.inventory(now) +} + +/// Raw `cedar_ember` block. Only `eligible: true` yields an inventory. +#[derive(Debug, Deserialize)] +struct ResetStatus { + eligible: bool, + #[serde(default, deserialize_with = "lossy_grants")] + grants: Vec, +} + +/// `resets_left` and `resets_total` are `i64` so negative or oversized +/// values are read (and rejected by the bounds check) instead of coerced. +/// `paused` is required: a grant with unknown pause state is dropped, not +/// counted. `usable_now` is deliberately not read: a saved reset counts +/// even while Claude gates redemption. +#[derive(Debug, Deserialize)] +struct Grant { + resets_left: i64, + #[serde(default)] + resets_total: Option, + #[serde(default, deserialize_with = "optional_bound")] + starts_at: Option>, + #[serde(default, deserialize_with = "optional_bound")] + ends_at: Option>, + paused: bool, +} + +impl Grant { + /// `resets_left` must lie within `0..=resets_total`. + fn is_well_formed(&self) -> bool { + self.resets_left >= 0 + && self + .resets_total + .is_none_or(|total| total >= self.resets_left) + } + + /// Not paused, not used up, started, and not expired at `now`. + fn is_available(&self, now: DateTime) -> bool { + !self.paused + && self.resets_left > 0 + && self.starts_at.is_none_or(|start| start <= now) + && self.ends_at.is_none_or(|end| end > now) + } +} + +impl ResetStatus { + fn inventory(&self, now: DateTime) -> Option { + if !self.eligible { + return None; + } + let mut count = 0usize; + let mut next_expiry: Option> = None; + for grant in self.grants.iter().filter(|grant| grant.is_available(now)) { + let resets = usize::try_from(grant.resets_left).ok()?; + if resets > MAX_RESETS - count { + return None; + } + count += resets; + if let Some(ends_at) = grant.ends_at { + next_expiry = Some(next_expiry.map_or(ends_at, |current| current.min(ends_at))); + } + } + Some(ProviderInventoryItem { + id: INVENTORY_ID.to_string(), + title: INVENTORY_TITLE.to_string(), + available_count: u32::try_from(count).ok().filter(|count| *count > 0)?, + next_expires_at: next_expiry, + }) + } +} + +/// A malformed grant is dropped without hiding the rest. A `grants` value +/// that is not an array yields no grants; more than [`MAX_GRANT_RECORDS`] +/// records fails the whole block. +fn lossy_grants<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let serde_json::Value::Array(records) = serde_json::Value::deserialize(deserializer)? else { + return Ok(Vec::new()); + }; + if records.len() > MAX_GRANT_RECORDS { + return Err(serde::de::Error::custom("too many grant records")); + } + Ok(records + .into_iter() + .filter_map(|record| Grant::deserialize(record).ok()) + .filter(Grant::is_well_formed) + .collect()) +} + +/// An absent or null bound is open; a supplied but unreadable bound makes the +/// grant malformed, never unbounded. +fn optional_bound<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + Option::::deserialize(deserializer)? + .map(|raw| { + DateTime::parse_from_rfc3339(&raw) + .map(|parsed| parsed.with_timezone(&Utc)) + .map_err(|_| serde::de::Error::custom("unreadable ISO-8601 bound")) + }) + .transpose() +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/providers/claude/reset_credits/tests.rs b/rust/src/providers/claude/reset_credits/tests.rs new file mode 100644 index 0000000000..de3628c851 --- /dev/null +++ b/rust/src/providers/claude/reset_credits/tests.rs @@ -0,0 +1,187 @@ +use super::{MAX_GRANT_RECORDS, MAX_RESETS, inventory_from_block}; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use serde_json::{Value, json}; + +const DAY: i64 = 86_400; + +fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 9, 27, 4, 35, 27).unwrap() +} + +fn iso(offset_secs: i64) -> String { + (now() + Duration::seconds(offset_secs)).to_rfc3339() +} + +/// Grant in the observed wire shape, including fields CodexBar must ignore +/// (`id`, `label`, `clears`, `usable_now`). +fn grant(resets_left: i64, ends_in: Option) -> Value { + json!({ + "id": "grant_secret", + "label": "Fixture reset", + "resets_total": resets_left.max(1), + "resets_left": resets_left, + "starts_at": iso(-DAY), + "ends_at": ends_in.map(iso), + "clears": ["five_hour", "seven_day"], + "paused": false, + "usable_now": true, + }) +} + +fn with(mut grant: Value, key: &str, value: Value) -> Value { + grant[key] = value; + grant +} + +fn eligible(grants: Vec) -> Value { + json!({ "eligible": true, "grants": grants }) +} + +fn count_and_expiry(block: &Value) -> Option<(u32, Option>)> { + inventory_from_block(block, now()).map(|item| (item.available_count, item.next_expires_at)) +} + +#[test] +fn sums_remaining_resets_of_started_unpaused_unexpired_grants() { + let block = eligible(vec![ + grant(2, Some(5 * DAY)), + grant(1, Some(DAY)), + grant(1, None), + // `usable_now` is not consulted. + with(grant(1, Some(2 * DAY)), "usable_now", json!(false)), + with(grant(1, Some(DAY)), "paused", json!(true)), + grant(0, Some(DAY)), + grant(1, Some(-60)), + with(grant(1, Some(9 * DAY)), "starts_at", json!(iso(DAY))), + ]); + + let item = inventory_from_block(&block, now()).unwrap(); + + assert_eq!(item.id, "reset-credits"); + assert_eq!(item.title, "Limit Reset Credits"); + assert_eq!(item.available_count, 5); + assert_eq!(item.next_expires_at, Some(now() + Duration::seconds(DAY))); +} + +#[test] +fn no_expiry_grants_sort_last_and_leave_no_next_expiry() { + let mixed = eligible(vec![grant(1, None), grant(1, Some(3 * DAY))]); + assert_eq!( + count_and_expiry(&mixed), + Some((2, Some(now() + Duration::seconds(3 * DAY)))) + ); + + let open_ended = eligible(vec![grant(2, None)]); + assert_eq!(count_and_expiry(&open_ended), Some((2, None))); +} + +#[test] +fn grant_ids_never_reach_the_inventory() { + let item = inventory_from_block(&eligible(vec![grant(1, Some(DAY))]), now()).unwrap(); + assert!(!format!("{item:?}").contains("grant_secret")); +} + +#[test] +fn malformed_grants_are_dropped_without_hiding_valid_grants() { + let block = eligible(vec![ + json!({"resets_left": "many", "paused": false}), + json!({"resets_left": 2, "resets_total": 1, "paused": false}), + json!({"resets_left": -1, "paused": false}), + json!({"resets_left": 1, "resets_total": 1}), + json!({"resets_left": 1, "resets_total": 1, "paused": null}), + json!({"resets_left": 1, "paused": false, "ends_at": "next tuesday"}), + json!({"resets_left": 1, "paused": false, "starts_at": "soon"}), + json!({"resets_left": 1, "resets_total": "one", "paused": false}), + json!("not an object"), + json!({"resets_left": 1, "resets_total": 1, "paused": false, + "starts_at": null, "ends_at": null}), + ]); + + assert_eq!(count_and_expiry(&block), Some((1, None))); +} + +#[test] +fn fractional_second_bounds_are_readable() { + let block = eligible(vec![json!({ + "resets_left": 1, + "paused": false, + "starts_at": "2026-09-26T04:35:27.123456+00:00", + "ends_at": "2026-10-23T00:00:00.500Z", + })]); + + let (count, expiry) = count_and_expiry(&block).unwrap(); + assert_eq!(count, 1); + assert_eq!( + expiry, + Some(Utc.with_ymd_and_hms(2026, 10, 23, 0, 0, 0).unwrap() + Duration::milliseconds(500)) + ); +} + +#[test] +fn implausibly_large_inventory_shows_nothing() { + let limit = i64::try_from(MAX_RESETS).unwrap(); + assert_eq!( + count_and_expiry(&eligible(vec![grant(limit, Some(DAY))])).map(|(count, _)| count), + Some(u32::try_from(MAX_RESETS).unwrap()) + ); + + for grants in [ + vec![grant(limit + 1, Some(DAY))], + vec![grant(i64::MAX, Some(DAY))], + vec![ + grant(limit / 2 + 1, Some(DAY)), + grant(limit / 2 + 1, Some(DAY)), + ], + // More grant records than the cap, even though all but one are used up. + std::iter::repeat_n(grant(0, Some(DAY)), MAX_GRANT_RECORDS) + .chain([grant(1, Some(DAY))]) + .collect(), + ] { + assert_eq!(inventory_from_block(&eligible(grants), now()), None); + } + + // Grants that have not started neither count nor trip the cap. + let with_future = eligible(vec![ + with( + grant(limit + 1, Some(9 * DAY)), + "starts_at", + json!(iso(DAY)), + ), + grant(1, Some(DAY)), + ]); + assert_eq!( + count_and_expiry(&with_future), + Some((1, Some(now() + Duration::seconds(DAY)))) + ); +} + +#[test] +fn grant_record_cap_is_inclusive() { + let at_cap = eligible( + std::iter::repeat_n(grant(0, Some(DAY)), MAX_GRANT_RECORDS - 1) + .chain([grant(1, Some(DAY))]) + .collect(), + ); + assert_eq!( + count_and_expiry(&at_cap), + Some((1, Some(now() + Duration::seconds(DAY)))) + ); +} + +#[test] +fn ineligible_absent_or_unreadable_block_shows_nothing() { + let grants = json!([grant(1, Some(DAY))]); + for block in [ + json!({"eligible": false, "ineligible_reason": "surface", "grants": grants}), + json!({"grants": grants}), + json!({"eligible": null, "grants": grants}), + json!({"eligible": "yes", "grants": grants}), + eligible(vec![]), + json!({"eligible": true}), + json!({"eligible": true, "grants": "none"}), + Value::Null, + json!([]), + ] { + assert_eq!(inventory_from_block(&block, now()), None, "{block}"); + } +} diff --git a/rust/src/providers/claude/reset_opt_in_tests.rs b/rust/src/providers/claude/reset_opt_in_tests.rs new file mode 100644 index 0000000000..de0434b824 --- /dev/null +++ b/rust/src/providers/claude/reset_opt_in_tests.rs @@ -0,0 +1,143 @@ +//! Wire tests for the `cedar_ember` opt-in on the Claude Web usage request. + +use super::ClaudeWebApiFetcher; +use crate::core::ProviderError; +use mockito::{Matcher, Mock, Server, ServerGuard}; + +const USAGE_PATH: &str = "/organizations/org-123/usage"; +const CLOUDFLARE_MESSAGE: &str = crate::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE; + +const USAGE_BODY: &str = r#"{"five_hour": {"utilization": 11}}"#; +const OPTED_IN_BODY: &str = r#"{"five_hour": {"utilization": 11}, + "cedar_ember": {"eligible": true, "grants": [ + {"resets_left": 1, "resets_total": 1, "paused": false, "ends_at": null}]}}"#; + +async fn get_usage(server: &ServerGuard) -> Result { + let fetcher = ClaudeWebApiFetcher::new().with_base_url(server.url()); + let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-fixture-token"); + fetcher.get_usage("org-123", &headers).await +} + +async fn opted_in(server: &mut ServerGuard, status: usize, body: &str) -> Mock { + server + .mock("GET", USAGE_PATH) + .match_query(Matcher::UrlEncoded("cedar_ember".into(), "1".into())) + .match_header("cookie", "sessionKey=sk-ant-fixture-token") + .with_status(status) + .with_body(body) + .expect(1) + .create_async() + .await +} + +async fn plain(server: &mut ServerGuard, status: usize, expected_calls: usize) -> Mock { + server + .mock("GET", USAGE_PATH) + .match_query(Matcher::Missing) + .match_header("cookie", "sessionKey=sk-ant-fixture-token") + .with_status(status) + .with_body(USAGE_BODY) + .expect(expected_calls) + .create_async() + .await +} + +#[tokio::test] +async fn successful_opt_in_keeps_the_reset_block_and_does_not_retry() { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, 200, OPTED_IN_BODY).await; + let retry = plain(&mut server, 200, 0).await; + + let usage = get_usage(&server).await.unwrap(); + + assert!(usage.five_hour.is_some()); + assert!(usage.cedar_ember.is_some()); + first.assert_async().await; + retry.assert_async().await; +} + +#[tokio::test] +async fn rejected_opt_in_retries_once_without_it_and_keeps_usage_windows() { + for status in [400, 403, 404, 422, 500, 503] { + let mut server = Server::new_async().await; + let first = opted_in( + &mut server, + status, + r#"{"error": "unknown query parameter"}"#, + ) + .await; + let retry = plain(&mut server, 200, 1).await; + + let usage = get_usage(&server).await.unwrap(); + + assert!(usage.five_hour.is_some(), "status {status}"); + assert!(usage.cedar_ember.is_none(), "status {status}"); + first.assert_async().await; + retry.assert_async().await; + } +} + +#[tokio::test] +async fn retry_failure_keeps_normal_error_handling() { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, 422, "{}").await; + let retry = plain(&mut server, 500, 1).await; + + let error = get_usage(&server).await.err().unwrap(); + + assert!(matches!(error, ProviderError::Other(message) if message.contains("500"))); + first.assert_async().await; + retry.assert_async().await; +} + +#[tokio::test] +async fn unauthorized_and_rate_limited_responses_are_not_retried() { + for status in [401, 429] { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, status, "{}").await; + let retry = plain(&mut server, 200, 0).await; + + let error = get_usage(&server).await.err().unwrap(); + + match status { + 401 => assert!(matches!(error, ProviderError::AuthRequired)), + _ => assert!(matches!(error, ProviderError::Other(message) if message.contains("429"))), + } + first.assert_async().await; + retry.assert_async().await; + } +} + +#[tokio::test] +async fn cloudflare_challenge_is_not_retried() { + let mut server = Server::new_async().await; + let first = server + .mock("GET", USAGE_PATH) + .match_query(Matcher::UrlEncoded("cedar_ember".into(), "1".into())) + .with_status(403) + .with_header("cf-mitigated", "challenge") + .with_body("challenge page") + .expect(1) + .create_async() + .await; + let retry = plain(&mut server, 200, 0).await; + + let error = get_usage(&server).await.err().unwrap(); + + assert!(matches!(error, ProviderError::Other(message) if message == CLOUDFLARE_MESSAGE)); + first.assert_async().await; + retry.assert_async().await; +} + +#[tokio::test] +async fn ordinary_forbidden_retries_and_a_second_forbidden_is_an_auth_failure() { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, 403, "permission denied").await; + let retry = plain(&mut server, 403, 1).await; + + let error = get_usage(&server).await.err().unwrap(); + + assert!(matches!(error, ProviderError::AuthRequired)); + first.assert_async().await; + retry.assert_async().await; +} diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index faee7e0d37..dad46b0bf0 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -9,9 +9,13 @@ use crate::core::{ }; use super::CLOUDFLARE_CHALLENGE_MESSAGE; +use super::reset_credits; const CLOUDFLARE_BODY_PREFIX_BYTES: usize = 64 * 1024; +/// Query flag that opts the usage request in to the `cedar_ember` block. +const RESET_OPT_IN_QUERY: &str = "cedar_ember=1"; + fn is_cloudflare_challenge_response( status: StatusCode, headers: &header::HeaderMap, @@ -113,6 +117,7 @@ fn json_value_kind(value: &serde_json::Value) -> &'static str { /// Claude Web API fetcher pub struct ClaudeWebApiFetcher { client: Client, + base_url: String, } /// Organization info from Claude API @@ -144,6 +149,9 @@ struct UsageResponse { seven_day_routines: Option, extra_usage: Option, limits: Vec, + /// Raw limit-reset block; decoded leniently by `reset_credits` so an + /// unreadable block never fails the usage windows. + cedar_ember: Option, } impl<'de> Deserialize<'de> for UsageResponse { @@ -219,6 +227,7 @@ impl<'de> Deserialize<'de> for UsageResponse { .map(serde_json::from_value) .transpose() .map_err(serde::de::Error::custom)?, + cedar_ember: map.remove("cedar_ember"), }) } } @@ -286,7 +295,7 @@ impl AccountResponse { } impl ClaudeWebApiFetcher { - const BASE_URL: &'static str = "https://claude.ai/api"; + const DEFAULT_BASE_URL: &'static str = "https://claude.ai/api"; /// Create a new fetcher pub fn new() -> Self { @@ -295,9 +304,16 @@ impl ClaudeWebApiFetcher { .timeout(std::time::Duration::from_secs(30)) .build() .expect("Failed to create HTTP client"), + base_url: Self::DEFAULT_BASE_URL.to_string(), } } + #[cfg(test)] + fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + /// Fetch usage using browser cookies or env-var session key pub async fn fetch_with_cookies(&self) -> Result { if let Some(session_key) = Self::resolve_session_key_from_env() { @@ -396,6 +412,15 @@ impl ClaudeWebApiFetcher { let mut result = ProviderFetchResult::new(snapshot, "web"); + // Web-only, display-only limit-reset inventory (never persisted). + if let Some(item) = usage + .cedar_ember + .as_ref() + .and_then(|block| reset_credits::inventory_from_block(block, Utc::now())) + { + result = result.with_inventory_item(item); + } + // Add cost info if available let mut cost = extra_usage.and_then(|extra| { if !extra.is_enabled.unwrap_or(false) { @@ -506,7 +531,7 @@ impl ClaudeWebApiFetcher { return Ok(org_id); } - let url = format!("{}/organizations", Self::BASE_URL); + let url = format!("{}/organizations", self.base_url); let response = self .client @@ -535,21 +560,44 @@ impl ClaudeWebApiFetcher { .ok_or_else(|| ProviderError::Parse("No organizations found".to_string())) } - /// Get usage data + /// Get usage data. + /// + /// The first request opts in to the `cedar_ember` limit-reset block. A + /// surface-specific rejection may reject only that opt-in, so any other + /// status is retried once on the plain URL. Success, 401, 429, and a + /// Cloudflare challenge keep their normal handling without a retry. async fn get_usage( &self, org_id: &str, headers: &reqwest::header::HeaderMap, ) -> Result { - let url = format!("{}/organizations/{}/usage", Self::BASE_URL, org_id); - - let response = self + let url = format!("{}/organizations/{}/usage", self.base_url, org_id); + let opted_in = self .client - .get(&url) + .get(format!("{url}?{RESET_OPT_IN_QUERY}")) .headers(headers.clone()) .send() .await?; + let response = match opted_in.status() { + StatusCode::OK | StatusCode::UNAUTHORIZED | StatusCode::TOO_MANY_REQUESTS => opted_in, + StatusCode::FORBIDDEN => { + let response_headers = opted_in.headers().clone(); + let body = opted_in.bytes().await?; + if is_cloudflare_challenge_response(StatusCode::FORBIDDEN, &response_headers, &body) + { + return Err(classify_web_http_error( + "usage", + StatusCode::FORBIDDEN, + &response_headers, + &body, + )); + } + self.get_plain_usage(&url, headers).await? + } + _ => self.get_plain_usage(&url, headers).await?, + }; + let status = response.status(); if !status.is_success() { let response_headers = response.headers().clone(); @@ -565,6 +613,14 @@ impl ClaudeWebApiFetcher { parse_json_with_body(response, "usage").await } + async fn get_plain_usage( + &self, + url: &str, + headers: &reqwest::header::HeaderMap, + ) -> Result { + Ok(self.client.get(url).headers(headers.clone()).send().await?) + } + /// Get extra usage (credits) async fn get_extra_usage( &self, @@ -573,8 +629,7 @@ impl ClaudeWebApiFetcher { ) -> Result { let url = format!( "{}/organizations/{}/overage_spend_limit", - Self::BASE_URL, - org_id + self.base_url, org_id ); let response = self @@ -600,11 +655,7 @@ impl ClaudeWebApiFetcher { org_id: &str, headers: &reqwest::header::HeaderMap, ) -> Option { - let url = format!( - "{}/organizations/{}/prepaid/credits", - Self::BASE_URL, - org_id - ); + let url = format!("{}/organizations/{}/prepaid/credits", self.base_url, org_id); let response = self .client @@ -628,7 +679,7 @@ impl ClaudeWebApiFetcher { &self, headers: &reqwest::header::HeaderMap, ) -> Result { - let url = format!("{}/account", Self::BASE_URL); + let url = format!("{}/account", self.base_url); let response = self .client @@ -1333,3 +1384,7 @@ mod tests { #[cfg(test)] #[path = "cloudflare_tests.rs"] mod cloudflare_tests; + +#[cfg(test)] +#[path = "reset_opt_in_tests.rs"] +mod reset_opt_in_tests;