Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ pub struct UsageSection {
pub struct UsageData {
pub session: UsageSection,
pub weekly: UsageSection,
/// Weekly, model-scoped usage for Fable (Claude Code only). Present only
/// when the usage endpoint reports a Fable-scoped weekly limit.
pub fable: Option<UsageSection>,
}

#[derive(Clone, Debug, Default)]
Expand Down
98 changes: 97 additions & 1 deletion src/poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ pub type CredentialWatchSnapshot = Vec<String>;
struct UsageResponse {
five_hour: Option<UsageBucket>,
seven_day: Option<UsageBucket>,
/// Per-limit breakdown. The Fable weekly meter arrives here as a
/// `weekly_scoped` entry whose scope model display name is "Fable".
limits: Option<Vec<UsageLimit>>,
}

#[derive(Deserialize)]
Expand All @@ -55,6 +58,24 @@ struct UsageBucket {
resets_at: Option<String>,
}

#[derive(Deserialize)]
struct UsageLimit {
percent: Option<f64>,
resets_at: Option<String>,
scope: Option<UsageLimitScope>,
}

#[derive(Deserialize)]
struct UsageLimitScope {
model: Option<UsageLimitModel>,
}

#[derive(Deserialize)]
struct UsageLimitModel {
#[serde(rename = "display_name")]
display_name: Option<String>,
}

#[derive(Deserialize)]
struct CodexAuthFile {
tokens: Option<CodexTokenData>,
Expand Down Expand Up @@ -719,9 +740,30 @@ fn try_usage_endpoint(token: &str) -> Result<Option<UsageData>, PollError> {
data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref());
}

data.fable = extract_fable_section(response.limits.as_deref());

Ok(Some(data))
}

/// Locate the Fable weekly meter within the usage endpoint's `limits` array.
/// It is exposed as a `weekly_scoped` limit whose scope model display name is
/// "Fable". Returns None when the account has no Fable-scoped limit.
fn extract_fable_section(limits: Option<&[UsageLimit]>) -> Option<UsageSection> {
let limit = limits?.iter().find(|limit| {
limit
.scope
.as_ref()
.and_then(|scope| scope.model.as_ref())
.and_then(|model| model.display_name.as_deref())
.is_some_and(|name| name.eq_ignore_ascii_case("fable"))
})?;

Some(UsageSection {
percentage: limit.percent.unwrap_or(0.0),
resets_at: parse_iso8601(limit.resets_at.as_deref()),
})
}

fn fetch_usage_via_messages(token: &str) -> Result<UsageData, PollError> {
let agent = build_agent()?;

Expand Down Expand Up @@ -909,7 +951,11 @@ fn fetch_antigravity_usage_from_endpoint(
let session = fetch_antigravity_model_quota(base_url, token, project.as_deref())?;
let weekly = UsageSection::default();

Ok(UsageData { session, weekly })
Ok(UsageData {
session,
weekly,
fable: None,
})
}

fn fetch_antigravity_project(base_url: &str, token: &str) -> Result<Option<String>, PollError> {
Expand Down Expand Up @@ -1611,6 +1657,7 @@ mod tests {
resets_at: None,
},
weekly: UsageSection::default(),
fable: None,
}
}

Expand Down Expand Up @@ -1677,6 +1724,55 @@ mod tests {
assert_eq!(data.codex.unwrap().session.percentage, 42.0);
}

#[test]
fn extracts_fable_weekly_scoped_limit() {
let response: UsageResponse = serde_json::from_str(
r#"{
"five_hour": { "utilization": 9.0, "resets_at": "2026-07-02T02:09:59.4+00:00" },
"seven_day": { "utilization": 1.0, "resets_at": "2026-07-04T13:59:59.4+00:00" },
"limits": [
{
"kind": "session", "group": "session", "percent": 9,
"resets_at": "2026-07-02T02:09:59.4+00:00", "scope": null, "is_active": true
},
{
"kind": "weekly_all", "group": "weekly", "percent": 1,
"resets_at": "2026-07-04T13:59:59.4+00:00", "scope": null, "is_active": false
},
{
"kind": "weekly_scoped", "group": "weekly", "percent": 2,
"resets_at": "2026-07-04T14:00:00.4+00:00",
"scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null },
"is_active": false
}
]
}"#,
)
.expect("usage response should deserialize");

let fable =
extract_fable_section(response.limits.as_deref()).expect("Fable limit should be found");
assert_eq!(fable.percentage, 2.0);
assert!(fable.resets_at.is_some());
}

#[test]
fn no_fable_section_when_absent() {
let response: UsageResponse = serde_json::from_str(
r#"{
"five_hour": { "utilization": 9.0, "resets_at": null },
"seven_day": { "utilization": 1.0, "resets_at": null },
"limits": [
{ "kind": "session", "group": "session", "percent": 9, "resets_at": null, "scope": null }
]
}"#,
)
.expect("usage response should deserialize");

assert!(extract_fable_section(response.limits.as_deref()).is_none());
assert!(extract_fable_section(None).is_none());
}

#[test]
fn antigravity_summary_prefers_gemini_group() {
let response: AntigravityQuotaSummaryResponse = serde_json::from_str(
Expand Down
Loading