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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
src/types.ts
10
src/types.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue