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.
|
//! Tauri command surface — every JS-callable function lives here.
|
||||||
|
|
||||||
use crate::paths::{list_wsl_distros, resolve_roots, ResolvedRoots};
|
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::state::SharedState;
|
||||||
|
use crate::tier::{detect as detect_tier, PlanTier};
|
||||||
use crate::usage::{build_snapshot, UsageSnapshot};
|
use crate::usage::{build_snapshot, UsageSnapshot};
|
||||||
use crate::watch::refresh_and_emit;
|
use crate::watch::refresh_and_emit;
|
||||||
|
|
||||||
|
|
@ -70,3 +71,22 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
Ok(())
|
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 paths;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod tier;
|
||||||
mod usage;
|
mod usage;
|
||||||
mod watch;
|
mod watch;
|
||||||
|
|
||||||
|
|
@ -66,6 +67,7 @@ pub fn run() {
|
||||||
commands::get_roots,
|
commands::get_roots,
|
||||||
commands::force_rescan,
|
commands::force_rescan,
|
||||||
commands::quit_app,
|
commands::quit_app,
|
||||||
|
commands::detect_plan_tier,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,11 @@ pub fn config_path() -> PathBuf {
|
||||||
pub fn load() -> Settings {
|
pub fn load() -> Settings {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let Ok(bytes) = std::fs::read(&path) else {
|
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) {
|
match serde_json::from_slice::<Settings>(&bytes) {
|
||||||
Ok(s) => s,
|
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,
|
disable as disableAutostart,
|
||||||
isEnabled as isAutostartEnabled,
|
isEnabled as isAutostartEnabled,
|
||||||
} from "@tauri-apps/plugin-autostart";
|
} from "@tauri-apps/plugin-autostart";
|
||||||
import { getSettings, setSettings, listDistros, getRoots } from "../ipc";
|
import {
|
||||||
import type { Settings, ResolvedRoots } from "../types";
|
getSettings,
|
||||||
|
setSettings,
|
||||||
|
listDistros,
|
||||||
|
getRoots,
|
||||||
|
detectPlanTier,
|
||||||
|
} from "../ipc";
|
||||||
|
import type { Settings, ResolvedRoots, TierInfo } from "../types";
|
||||||
|
|
||||||
let { onClose }: { onClose: () => void } = $props();
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
let settings = $state<Settings | null>(null);
|
let settings = $state<Settings | null>(null);
|
||||||
let distros = $state<string[]>([]);
|
let distros = $state<string[]>([]);
|
||||||
let roots = $state<ResolvedRoots | null>(null);
|
let roots = $state<ResolvedRoots | null>(null);
|
||||||
|
let tier = $state<TierInfo | null>(null);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -21,6 +28,7 @@
|
||||||
settings = await getSettings();
|
settings = await getSettings();
|
||||||
distros = await listDistros();
|
distros = await listDistros();
|
||||||
roots = await getRoots();
|
roots = await getRoots();
|
||||||
|
tier = await detectPlanTier();
|
||||||
// Sync displayed autostart from the plugin's actual state.
|
// Sync displayed autostart from the plugin's actual state.
|
||||||
const enabled = await isAutostartEnabled();
|
const enabled = await isAutostartEnabled();
|
||||||
if (settings && settings.autostart !== enabled) {
|
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() {
|
async function save() {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
|
|
@ -59,12 +78,30 @@
|
||||||
{#if err}<div class="error">{err}</div>{/if}
|
{#if err}<div class="error">{err}</div>{/if}
|
||||||
|
|
||||||
{#if settings}
|
{#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="field">
|
||||||
<div class="label">5-hour block cap (tokens)</div>
|
<div class="label">5-hour block cap (tokens)</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="10000"
|
step="100000"
|
||||||
bind:value={settings.caps.block_tokens}
|
bind:value={settings.caps.block_tokens}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,7 +111,7 @@
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="100000"
|
step="1000000"
|
||||||
bind:value={settings.caps.weekly_tokens}
|
bind:value={settings.caps.weekly_tokens}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,6 +184,16 @@
|
||||||
.actions { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border); }
|
.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 { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; }
|
||||||
.roots code { font-size: 10px; word-break: break-all; }
|
.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 {
|
.error {
|
||||||
background: rgba(255, 107, 107, 0.15);
|
background: rgba(255, 107, 107, 0.15);
|
||||||
border: 1px solid var(--danger);
|
border: 1px solid var(--danger);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
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 getSnapshot = (): Promise<UsageSnapshot> => invoke("get_snapshot");
|
||||||
export const getSettings = (): Promise<Settings> => invoke("get_settings");
|
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 getRoots = (): Promise<ResolvedRoots> => invoke("get_roots");
|
||||||
export const forceRescan = (): Promise<void> => invoke("force_rescan");
|
export const forceRescan = (): Promise<void> => invoke("force_rescan");
|
||||||
export const quitApp = (): Promise<void> => invoke("quit_app");
|
export const quitApp = (): Promise<void> => invoke("quit_app");
|
||||||
|
export const detectPlanTier = (): Promise<TierInfo> => invoke("detect_plan_tier");
|
||||||
|
|
||||||
export const onUsageUpdated = (
|
export const onUsageUpdated = (
|
||||||
cb: (snap: UsageSnapshot) => void,
|
cb: (snap: UsageSnapshot) => void,
|
||||||
|
|
|
||||||
10
src/types.ts
10
src/types.ts
|
|
@ -58,3 +58,13 @@ export interface ResolvedRoots {
|
||||||
wsl_distro: string | null;
|
wsl_distro: string | null;
|
||||||
native_present: boolean;
|
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