Detect plan tier from .claude.json; seed sane caps per tier (Pro/Max 5x/Max 20x)

This commit is contained in:
megaproxy 2026-05-09 00:54:50 +01:00
parent 106ad28f9f
commit ef84257ddd
7 changed files with 236 additions and 7 deletions

145
src-tauri/src/tier.rs Normal file
View file

@ -0,0 +1,145 @@
//! Read the user's plan tier from `~/.claude/` and map it to approximate
//! cap values.
//!
//! These caps are *approximations* based on community-shared observations —
//! Anthropic does not publish the exact 5-hour / weekly token caps, and they
//! shift over time. The widget shows a percentage off these caps, but
//! anything within ±20% of "the real number" is fine for a desktop heads-up
//! display. Users tune them in Settings once they hit a real limit.
use crate::settings::Caps;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub enum PlanTier {
Pro,
Max5x,
Max20x,
/// Tier string we recognized as Max but couldn't sub-classify.
MaxOther(String),
/// The on-disk string was something we don't have a mapping for.
Unknown(String),
/// No `.claude.json` (or backup) found, or the field was missing.
NotFound,
}
impl PlanTier {
pub fn caps(&self) -> Caps {
match self {
// Rough numbers, deliberately on the generous side so the ring
// doesn't peg red on day one. Tune in Settings when reality hits.
PlanTier::Max20x => Caps { block_tokens: 200_000_000, weekly_tokens: 1_000_000_000 },
PlanTier::Max5x | PlanTier::MaxOther(_) => Caps {
block_tokens: 50_000_000,
weekly_tokens: 250_000_000,
},
PlanTier::Pro => Caps { block_tokens: 10_000_000, weekly_tokens: 50_000_000 },
// Fall back to the safe (and visibly wrong) placeholders so the
// user knows they need to tune. Better than guessing.
PlanTier::Unknown(_) | PlanTier::NotFound => Caps::default(),
}
}
pub fn label(&self) -> String {
match self {
PlanTier::Pro => "Pro".to_string(),
PlanTier::Max5x => "Max 5×".to_string(),
PlanTier::Max20x => "Max 20×".to_string(),
PlanTier::MaxOther(s) => format!("Max ({s})"),
PlanTier::Unknown(s) => format!("Unknown ({s})"),
PlanTier::NotFound => "Unknown".to_string(),
}
}
}
/// Parse a raw tier string (the value of `organizationRateLimitTier` in
/// `.claude.json`) into a known tier.
fn classify(raw: &str) -> PlanTier {
let l = raw.to_lowercase();
if l.contains("max_20x") || l.contains("max20x") {
PlanTier::Max20x
} else if l.contains("max_5x") || l.contains("max5x") {
PlanTier::Max5x
} else if l.contains("max") {
PlanTier::MaxOther(raw.to_string())
} else if l.contains("pro") {
PlanTier::Pro
} else {
PlanTier::Unknown(raw.to_string())
}
}
/// Find the most recent on-disk source of `organizationRateLimitTier`.
///
/// Search order:
/// 1. `~/.claude/.claude.json` if it exists (Linux / WSL)
/// 2. `%USERPROFILE%\.claude\.claude.json` (Windows native — same logic via dirs::home_dir)
/// 3. The newest `~/.claude/backups/.claude.json.backup.*` (this is what
/// we observed populating in practice — `.claude.json` itself isn't
/// always present, but the backup directory tracks every config change)
fn locate_claude_json() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let primary = home.join(".claude").join(".claude.json");
if primary.is_file() {
return Some(primary);
}
let backups_dir = home.join(".claude").join("backups");
let entries = std::fs::read_dir(&backups_dir).ok()?;
let mut latest: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if !name.starts_with(".claude.json.backup.") {
continue;
}
if let Ok(meta) = entry.metadata() {
if let Ok(mtime) = meta.modified() {
match &latest {
None => latest = Some((mtime, path)),
Some((m, _)) if mtime > *m => latest = Some((mtime, path)),
_ => {}
}
}
}
}
latest.map(|(_, p)| p)
}
pub fn detect() -> PlanTier {
let Some(path) = locate_claude_json() else {
return PlanTier::NotFound;
};
let Ok(bytes) = std::fs::read(&path) else {
return PlanTier::NotFound;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return PlanTier::NotFound;
};
match json.get("organizationRateLimitTier").and_then(|v| v.as_str()) {
Some(raw) => classify(raw),
None => PlanTier::NotFound,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_known_tiers() {
assert_eq!(classify("default_claude_max_5x"), PlanTier::Max5x);
assert_eq!(classify("default_claude_max_20x"), PlanTier::Max20x);
assert_eq!(classify("default_claude_pro"), PlanTier::Pro);
assert!(matches!(classify("default_claude_max_99x"), PlanTier::MaxOther(_)));
assert!(matches!(classify("anything-else"), PlanTier::Unknown(_)));
}
#[test]
fn caps_scale_by_tier() {
assert!(PlanTier::Max20x.caps().block_tokens > PlanTier::Max5x.caps().block_tokens);
assert!(PlanTier::Max5x.caps().block_tokens > PlanTier::Pro.caps().block_tokens);
assert!(PlanTier::Pro.caps().block_tokens > PlanTier::NotFound.caps().block_tokens);
}
}