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

View file

@ -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 })
}

View file

@ -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");

View file

@ -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
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);
}
}

View file

@ -5,14 +5,21 @@
disable as disableAutostart,
isEnabled as isAutostartEnabled,
} from "@tauri-apps/plugin-autostart";
import { getSettings, setSettings, listDistros, getRoots } from "../ipc";
import type { Settings, ResolvedRoots } from "../types";
import {
getSettings,
setSettings,
listDistros,
getRoots,
detectPlanTier,
} from "../ipc";
import type { Settings, ResolvedRoots, TierInfo } from "../types";
let { onClose }: { onClose: () => void } = $props();
let settings = $state<Settings | null>(null);
let distros = $state<string[]>([]);
let roots = $state<ResolvedRoots | null>(null);
let tier = $state<TierInfo | null>(null);
let busy = $state(false);
let err = $state<string | null>(null);
@ -21,6 +28,7 @@
settings = await getSettings();
distros = await listDistros();
roots = await getRoots();
tier = await detectPlanTier();
// Sync displayed autostart from the plugin's actual state.
const enabled = await isAutostartEnabled();
if (settings && settings.autostart !== enabled) {
@ -31,6 +39,17 @@
}
});
function applyTierCaps() {
if (!settings || !tier) return;
settings = {
...settings,
caps: {
block_tokens: tier.recommended_caps.block_tokens,
weekly_tokens: tier.recommended_caps.weekly_tokens,
},
};
}
async function save() {
if (!settings) return;
busy = true;
@ -59,12 +78,30 @@
{#if err}<div class="error">{err}</div>{/if}
{#if settings}
{#if tier}
<div class="tier-card">
<div class="row spread">
<div>
<div class="label" style="margin:0">Plan tier</div>
<div>{tier.label}</div>
</div>
<button onclick={applyTierCaps} title="Set caps to recommended values for this tier">
Use recommended
</button>
</div>
<div class="muted hint">
Approximate — Anthropic doesn't publish exact caps. Tune below
once you actually hit a limit.
</div>
</div>
{/if}
<div class="field">
<div class="label">5-hour block cap (tokens)</div>
<input
type="number"
min="0"
step="10000"
step="100000"
bind:value={settings.caps.block_tokens}
/>
</div>
@ -74,7 +111,7 @@
<input
type="number"
min="0"
step="100000"
step="1000000"
bind:value={settings.caps.weekly_tokens}
/>
</div>
@ -147,6 +184,16 @@
.actions { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border); }
.roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; }
.roots code { font-size: 10px; word-break: break-all; }
.tier-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.hint { font-size: 10px; line-height: 1.3; }
.error {
background: rgba(255, 107, 107, 0.15);
border: 1px solid var(--danger);

View file

@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { ResolvedRoots, Settings, UsageSnapshot } from "./types";
import type { ResolvedRoots, Settings, TierInfo, UsageSnapshot } from "./types";
export const getSnapshot = (): Promise<UsageSnapshot> => invoke("get_snapshot");
export const getSettings = (): Promise<Settings> => invoke("get_settings");
@ -10,6 +10,7 @@ export const listDistros = (): Promise<string[]> => invoke("list_distros");
export const getRoots = (): Promise<ResolvedRoots> => invoke("get_roots");
export const forceRescan = (): Promise<void> => invoke("force_rescan");
export const quitApp = (): Promise<void> => invoke("quit_app");
export const detectPlanTier = (): Promise<TierInfo> => invoke("detect_plan_tier");
export const onUsageUpdated = (
cb: (snap: UsageSnapshot) => void,

View file

@ -58,3 +58,13 @@ export interface ResolvedRoots {
wsl_distro: string | null;
native_present: boolean;
}
// Mirrors PlanTier in src-tauri/src/tier.rs. The Rust enum is serde-tagged
// externally, so the wire shape is `"Max5x"` for unit variants and
// `{ MaxOther: "raw-string" }` for tuple variants — we just keep it as
// `unknown` here and rely on `label` for display.
export interface TierInfo {
tier: unknown;
label: string;
recommended_caps: Caps;
}