Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rust/src/providers/claude/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
134 changes: 134 additions & 0 deletions rust/src/providers/claude/reset_credits.rs
Original file line number Diff line number Diff line change
@@ -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<Utc>,
) -> Option<ProviderInventoryItem> {
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<Grant>,
}

/// `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<i64>,
#[serde(default, deserialize_with = "optional_bound")]
starts_at: Option<DateTime<Utc>>,
#[serde(default, deserialize_with = "optional_bound")]
ends_at: Option<DateTime<Utc>>,
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<Utc>) -> 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<Utc>) -> Option<ProviderInventoryItem> {
if !self.eligible {
return None;
}
let mut count = 0usize;
let mut next_expiry: Option<DateTime<Utc>> = 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<Vec<Grant>, 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<Option<DateTime<Utc>>, D::Error> {
Option::<String>::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;
187 changes: 187 additions & 0 deletions rust/src/providers/claude/reset_credits/tests.rs
Original file line number Diff line number Diff line change
@@ -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> {
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<i64>) -> 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>) -> Value {
json!({ "eligible": true, "grants": grants })
}

fn count_and_expiry(block: &Value) -> Option<(u32, Option<DateTime<Utc>>)> {
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}");
}
}
Loading