Detect plan tier from .claude.json; seed sane caps per tier (Pro/Max 5x/Max 20x)
This commit is contained in:
parent
106ad28f9f
commit
ef84257ddd
7 changed files with 236 additions and 7 deletions
|
|
@ -1,8 +1,9 @@
|
|||
//! Tauri command surface — every JS-callable function lives here.
|
||||
|
||||
use crate::paths::{list_wsl_distros, resolve_roots, ResolvedRoots};
|
||||
use crate::settings::{save as save_settings, Settings};
|
||||
use crate::settings::{save as save_settings, Caps, Settings};
|
||||
use crate::state::SharedState;
|
||||
use crate::tier::{detect as detect_tier, PlanTier};
|
||||
use crate::usage::{build_snapshot, UsageSnapshot};
|
||||
use crate::watch::refresh_and_emit;
|
||||
|
||||
|
|
@ -70,3 +71,22 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> {
|
|||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct TierInfo {
|
||||
/// The classified tier — `Pro`, `Max5x`, `Max20x`, etc.
|
||||
pub tier: PlanTier,
|
||||
/// Human-readable label for the UI ("Max 5×" / "Pro" / "Unknown").
|
||||
pub label: String,
|
||||
/// The caps that this tier maps to. The user's current Settings may
|
||||
/// differ if they tuned manually.
|
||||
pub recommended_caps: Caps,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_plan_tier() -> Result<TierInfo, String> {
|
||||
let tier = detect_tier();
|
||||
let label = tier.label();
|
||||
let recommended_caps = tier.caps();
|
||||
Ok(TierInfo { tier, label, recommended_caps })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ mod jsonl;
|
|||
mod paths;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod tier;
|
||||
mod usage;
|
||||
mod watch;
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ pub fn run() {
|
|||
commands::get_roots,
|
||||
commands::force_rescan,
|
||||
commands::quit_app,
|
||||
commands::detect_plan_tier,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -63,7 +63,11 @@ pub fn config_path() -> PathBuf {
|
|||
pub fn load() -> Settings {
|
||||
let path = config_path();
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Settings::default();
|
||||
// First run — seed caps from detected plan tier so the ring
|
||||
// doesn't peg red against the static placeholders.
|
||||
let mut s = Settings::default();
|
||||
s.caps = crate::tier::detect().caps();
|
||||
return s;
|
||||
};
|
||||
match serde_json::from_slice::<Settings>(&bytes) {
|
||||
Ok(s) => s,
|
||||
|
|
|
|||
145
src-tauri/src/tier.rs
Normal file
145
src-tauri/src/tier.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue