diff --git a/CLAUDE.md b/CLAUDE.md index 8136bbe..f4c0bec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Commands: `today`, `week`, `month`, `models`, `sessions`, `projects`, `hours`, `watch`, `export` - `watch` takes a sub-view (`limits|today|week|models|sessions|hours`, default `limits`) and refreshes it on an interval - `watch` (default `--view limits`) continuously refreshes "current session" (5h) / "this week" usage with reset times (`--interval`, `--session-reset `, `--week-reset `; both persist to `%LOCALAPPDATA%\aimon\limits-settings.json`, and `r` re-prompts for both reset times while watching; the session reset is auto-computed at startup and never prompted for there, only the week reset is) + - The usage-progress bar of `watch --view limits` is measured in `Commands/QuotaCost` (the estimated cost with the cache-read part scaled by `--cache-read-weight`, default 0.1, persisted as `CacheReadWeight` only when passed; `--reset-cache-read-weight` forgets it), not in plain API cost: the subscription quota does not weigh cache reads at their list price. `UsageWindowSummary.CostBreakdown` carries the cost per token kind (`EstimatedCost` is its total) and the limits view prints the resulting split. What is persisted for the limits is the reading, not the limit: `UsageCalibration` (the percentage read off Claude plus the cost breakdown at that moment) in `SessionCalibration`/`WeekCalibration`, and the limit is derived from it under the weight in effect, so the weight can be tuned without reading `/usage` again. The default weight is a heuristic - tune it against Claude's own percentage. The startup decisions (weight, readings, what to save, notices) live in `Commands/LimitsStartup` so they can be tested without a terminal; the console is cleared right after startup and after each `r` recalibration, so a message for the user must be added to the notices list that `SpectreRenderer.WithNotices` draws in the view, not printed with `AnsiConsole` (only a message that is followed by a prompt, like the ignored saved weekly reset in `LimitsAnchors.TryResolve`, may be printed). - `export` supports `--format json|csv` and `--output ` (defaults to stdout, JSON) - Report commands (`today|week|month|models|hours|sessions`) take `--json` (machine-readable stdout, no progress bar, update check skipped). `today` takes `--date yyyy-MM-dd`; `week`/`month` take `--from [--to]` or `--last ` (`month` also `--month yyyy-MM`); `models` takes `--model `. `projects` and `sessions --list` (default last 30 days, same range options) take `--top`, `--sort`, `--project`, `--model` (`Commands/ListOptions`, ordering in `Commands/UsageSorting`, JSON in `Rendering/UsageReports`; the range / load / sort / JSON-or-hint-or-table flow both commands share is `Commands/UsageList.Run`, so a new list command should reuse it); `sessions` without `--list` is the unchanged aggregate view and rejects those options. Date arguments are validated by `Commands/DateRangeResolver` (max 3650 days, years 2000-2999) and a bad one prints to stderr and exits 1; `Commands/DateRangeOptions` only defines/reads the options, and `Commands/JsonOutput` holds the shared `--json` plumbing (JSON shapes are in `Rendering/CliJsonContext`, durations as seconds). - Run WPF app (Windows-only): `dotnet run --project src/AIUsageMonitor.WPF` diff --git a/README.md b/README.md index 439a9ed..d7fc78b 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ The real reset times and token limits live on your Anthropic account, not locall - `--session-reset "HH:mm"` — the session reset time Claude shows (e.g. `"18:30"`) - `--week-reset "Ddd HH:mm"` — the weekly reset (e.g. `"Mon 09:00"`); fixed per account, so it only needs entering once, unlike the session reset -- `--session-token-progress ` / `--week-token-progress ` — the usage % Claude shows (e.g. `32`), used to back out a token budget +- `--session-token-progress ` / `--week-token-progress ` — the usage % Claude shows (e.g. `32`), used to back out a usage budget. Each percentage is saved together with the cost counted at that moment, and the budget is derived from it; a percentage given while nothing is counted for the window yet is ignored (the saved one is kept) +- `--cache-read-weight <0-1>` — what share of the list price of cache-read tokens counts toward the usage bar (default `0.1`, a heuristic: the subscription quota is not metered at API prices, and cache reads dominate long sessions). Lower it if the bar runs ahead of Claude's own percentage, raise it if it runs behind; the saved percentages are kept and the budgets re-derived, so there is no need to read `/usage` again. It is saved when given; `--reset-cache-read-weight` forgets it. A line below the table shows what the bar is made of (cache read / output / cache write / input) Without these, values are estimated from local transcript timestamps, and a row shows as **unknown** rather than a guessed countdown when there isn't enough local evidence (no activity in the last 5 hours, or no weekly anchor at all). @@ -206,7 +207,8 @@ aimon <命令> - `--session-reset "HH:mm"` — Claude 显示的会话重置时间(如 `"18:30"`) - `--week-reset "Ddd HH:mm"` — 每周重置时刻(如 `"Mon 09:00"`);账号固定不变,只需录入一次,不会像会话那样过期 -- `--session-token-progress <百分比>` / `--week-token-progress <百分比>` — Claude 显示的用量百分比(如 `32`),用来反推出 token 预算 +- `--session-token-progress <百分比>` / `--week-token-progress <百分比>` — Claude 显示的用量百分比(如 `32`),用来反推出用量预算。百分比会连同当时统计到的成本一起保存,预算由它推算;窗口内还没有统计到用量时给出的百分比会被忽略(保留已保存的) +- `--cache-read-weight <0-1>` — cache read token 按标价的多大比例计入用量进度条(默认 `0.1`,是经验值:订阅额度不按 API 标价计量,而 cache read 在长会话里占绝大多数)。进度条比 Claude 显示的快就调小,慢就调大;已保存的百分比会保留并按新权重重新推算预算,无需再看 `/usage`。传入后会被保存,`--reset-cache-read-weight` 可忘记它。表格下方有一行说明进度条由哪几类 token 构成(cache read / output / cache write / input) 不配置这些参数时,会根据本地会话记录估算;当本地证据不足时(近 5 小时无活动,或压根没有周期锚点),对应行会显示为**未知**,而不是编造一个倒计时。 diff --git a/src/AIUsageMonitor.Cli/Commands/CostShares.cs b/src/AIUsageMonitor.Cli/Commands/CostShares.cs new file mode 100644 index 0000000..a529a56 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/CostShares.cs @@ -0,0 +1,10 @@ +namespace AIUsageMonitor.Cli.Commands; + +/// +/// How much of a quota-weighted cost each kind of token accounts for. The figures are fractions of 1, not dollars. +/// +/// The share of the uncached input tokens. +/// The share of the output tokens, including thinking. +/// The share of the tokens read from the prompt cache, after weighting. +/// The share of the tokens written to the prompt cache. +internal readonly record struct CostShares(decimal Input, decimal Output, decimal CacheRead, decimal CacheWrite); diff --git a/src/AIUsageMonitor.Cli/Commands/LimitsAnchors.cs b/src/AIUsageMonitor.Cli/Commands/LimitsAnchors.cs index aab76a8..38f1bda 100644 --- a/src/AIUsageMonitor.Cli/Commands/LimitsAnchors.cs +++ b/src/AIUsageMonitor.Cli/Commands/LimitsAnchors.cs @@ -16,6 +16,13 @@ namespace AIUsageMonitor.Cli.Commands; internal static class LimitsAnchors { private const string SessionResetFormat = "HH:mm"; + + /// + /// The text that tells a user which usage percentages are accepted. + /// + private static readonly string PercentRangeHint = string.Create( + CultureInfo.InvariantCulture, $"Expected a percentage between {UsageCalibration.MinPercent} and {UsageCalibration.MaxPercent:0}, e.g. 32."); + private static readonly TimeSpan SessionWindowDuration = TimeSpan.FromHours(5); /// @@ -37,17 +44,17 @@ internal static (Option SessionReset, Option WeekReset) Create /// /// Creates the shared --session-token-progress/--week-token-progress options. These /// take a *percentage* (what Claude itself displays), not a token count - see - /// for how that percentage is turned into a token limit. + /// for how that percentage is turned into a calibration. /// internal static (Option SessionTokenProgress, Option WeekTokenProgress) CreateTokenProgressOptions() { var sessionTokenProgressOption = new Option("--session-token-progress") { - Description = "Current session usage percentage as shown on Claude's own usage page (Settings > Usage, or /usage in Claude Code), e.g. 32 for '32%'. Combined with the estimated cost accrued so far this session to derive a usage budget, which is then used to track the usage-progress bar live. Only used when --view limits." + Description = "Current session usage percentage as shown on Claude's own usage page (Settings > Usage, or /usage in Claude Code), e.g. 32 for '32%'. Combined with the quota-weighted cost (see --cache-read-weight) accrued so far this session to derive a usage budget, which is then used to track the usage-progress bar live. Only used when --view limits." }; var weekTokenProgressOption = new Option("--week-token-progress") { - Description = "Current weekly usage percentage as shown on Claude's own usage page, e.g. 32 for '32%'. Combined with the estimated cost accrued so far this week to derive a usage budget. Only used when --view limits." + Description = "Current weekly usage percentage as shown on Claude's own usage page, e.g. 32 for '32%'. Combined with the quota-weighted cost (see --cache-read-weight) accrued so far this week to derive a usage budget. Only used when --view limits." }; return (sessionTokenProgressOption, weekTokenProgressOption); } @@ -63,17 +70,21 @@ internal static (Option SessionTokenProgress, Option WeekToken /// /// Computes the estimated session and week cost accrued so far for windows pinned to the reset /// times just entered. It is called after the reset prompts and before the percentage prompts, - /// because the limit is derived as cost / percent and the cost of the window that was + /// because the limit is derived from cost / percent and the cost of the window that was /// on screen before recalibrating (pinned to the old reset times) differs from the cost of the /// window the new reset times select. /// /// The still-upcoming session reset currently shown (confirmed or locally estimated), offered as the prompt's default. Pressing Enter accepts it and pins it as a confirmed anchor. - /// The resolved session/weekly reset times and session/weekly usage (cost) limits. + /// The cache read weight in effect, used to show the percentage a kept calibration currently implies. The saved weight setting is carried over unchanged. + /// Receives the plain-text notices that explain why a typed percentage was not used, for the caller to show in its view, because anything printed here is cleared with the screen right away. + /// The resolved session/weekly reset times and session/weekly usage calibrations. internal static ( DateTimeOffset? SessionResetAt, (DayOfWeek Day, TimeSpan TimeOfDay)? WeekResetAt, - decimal? SessionCostLimit, decimal? WeekCostLimit) PromptAndSaveBoth( - Func costSoFarFor, - DateTimeOffset? currentSessionResetAt) + UsageCalibration? SessionCalibration, UsageCalibration? WeekCalibration) PromptAndSaveBoth( + Func costSoFarFor, + DateTimeOffset? currentSessionResetAt, + decimal cacheReadWeight, + ICollection notices) { var saved = LimitsSettingsStore.Load(); @@ -82,15 +93,15 @@ internal static ( var (weekResetAt, weekResetRaw) = PromptForWeekReset(saved.WeekResetAt); var (sessionCostSoFar, weekCostSoFar) = costSoFarFor(sessionResetAt, weekResetAt); - var sessionCostLimit = PromptForCostLimit( - saved.SessionCostLimit, sessionCostSoFar, + var sessionCalibration = PromptForCalibration( + UsageCalibration.OrNullIfInvalid(saved.SessionCalibration), sessionCostSoFar, cacheReadWeight, "Session", notices, "[yellow]Session usage %[/] (from /usage), e.g. 32, Enter to keep:"); - var weekCostLimit = PromptForCostLimit( - saved.WeekCostLimit, weekCostSoFar, + var weekCalibration = PromptForCalibration( + UsageCalibration.OrNullIfInvalid(saved.WeekCalibration), weekCostSoFar, cacheReadWeight, "Weekly", notices, "[yellow]Weekly usage %[/] (from /usage), e.g. 32, Enter to keep:"); - LimitsSettingsStore.SaveOrWarn(new(sessionResetAt, weekResetRaw, sessionCostLimit, weekCostLimit)); - return (sessionResetAt, weekResetAt, sessionCostLimit, weekCostLimit); + LimitsSettingsStore.SaveOrWarn(new(sessionResetAt, weekResetRaw, sessionCalibration, weekCalibration, saved.CacheReadWeight)); + return (sessionResetAt, weekResetAt, sessionCalibration, weekCalibration); } /// @@ -118,51 +129,64 @@ internal static ( => window?.ResetsAt is { } shown && shown > now ? shown : null; /// - /// Resolves the usage (cost) limit for one usage window (session or week) from a usage + /// Resolves the usage calibration for one usage window (session or week) from a usage /// *percentage* - either an explicit CLI argument or (in an interactive terminal) a prompt - - /// combined with the estimated cost this tool has computed for that window so far: - /// limit = costSoFar / (percent / 100). Cost already weights tokens by model and type + /// paired with the cost this tool has computed for that window so far. The limit is later derived + /// from it as limit = quotaCost / (percent / 100) (see and + /// ). Cost already weights tokens by model and type /// (an Opus token costs far more toward the real limit than a Haiku one), which tracks Anthropic's /// actual usage gating much more closely than a flat token count would. Claude itself never shows /// the underlying limit, only a percentage, so this is the only way to derive one locally. The - /// derived limit (not the percentage) is what gets persisted and returned, so future refreshes can - /// keep tracking the usage-progress bar live against it without asking again - until the user - /// recalibrates (e.g. because the account's limit changed). + /// calibration (not the derived limit) is what gets persisted, so future refreshes keep tracking + /// the usage-progress bar live against it without asking again, and the cache read weight can be + /// tuned without reading the percentage again - until the user recalibrates (e.g. because the + /// account's limit changed). /// /// The raw --session-token-progress/--week-token-progress argument, if provided. - /// The previously persisted cost limit for this window, if any. - /// The estimated cost this tool has computed for the window so far. + /// The previously persisted calibration for this window, if any. + /// The estimated cost this tool has computed for the window so far, by kind of token. + /// The cache read weight in effect, used for the percentage shown as the prompt's default. /// A short label (e.g. "Session", "Weekly") used in the interactive prompt text. - /// The resolved cost limit, or if not configured. + /// Receives a plain-text notice when a percentage is not used because it cannot be calibrated against the window. + /// Whether there is no interactive terminal to prompt in; then a missing percentage is simply left unset. + /// The resolved calibration, or if not configured. /// An error message describing why resolution failed, if it did. /// if resolution succeeded; if an explicit argument was invalid. - internal static bool ResolveTokenLimit( - double? progressPercentArg, decimal? savedLimit, decimal costSoFar, string promptLabel, - out decimal? costLimit, out string? error) + internal static bool ResolveCalibration( + double? progressPercentArg, UsageCalibration? saved, CostBreakdown costSoFar, decimal cacheReadWeight, + string promptLabel, ICollection notices, bool inputRedirected, out UsageCalibration? calibration, out string? error) { error = null; if (progressPercentArg is not null) { - if (progressPercentArg <= 0) + if (ValidatePercentArgument(progressPercentArg) is { } invalid) { - costLimit = null; - error = $"Invalid usage percentage '{progressPercentArg}'. Expected a percentage greater than 0."; + calibration = null; + error = invalid; return false; } - costLimit = DeriveCostLimit(costSoFar, progressPercentArg.Value); + if (!HasCountedCost(costSoFar, cacheReadWeight)) + { + // A reading taken while nothing is counted cannot give a limit, and must not replace one that works. + notices.Add(DescribeNothingCounted(promptLabel, costSoFar, cacheReadWeight)); + calibration = saved; + return true; + } + + calibration = new UsageCalibration(costSoFar, progressPercentArg.Value); return true; } - if (savedLimit is not null || Console.IsInputRedirected) + if (saved is not null || inputRedirected) { - costLimit = savedLimit; + calibration = saved; return true; } - costLimit = PromptForCostLimit( - currentLimit: null, costSoFar, + calibration = PromptForCalibration( + current: null, costSoFar, cacheReadWeight, promptLabel, notices, $"[yellow]{promptLabel} usage %[/] (from /usage), e.g. 32, Enter to skip:"); return true; } @@ -283,7 +307,7 @@ internal static bool TryResolve( if (explicitlyProvided || promptedForAnything) { - // Token limits are resolved separately (see ResolveTokenLimit), since deriving them needs + // Token limits are resolved separately (see ResolveCalibration), since deriving them needs // the tokens counted for the window - which isn't known until after the reset times below // are settled. Carry the currently saved values through unchanged. LimitsSettingsStore.SaveOrWarn(saved with @@ -299,14 +323,45 @@ internal static bool TryResolve( } /// - /// Derives a cost limit from the estimated cost accrued so far and the percentage of that limit - /// it represents, as read off Claude's own usage display. Never returns less than a cent, since a - /// 0 limit would later be divided into and would also get persisted as savedLimit, silently - /// breaking the usage-progress bar until the user recalibrates - most commonly when costSoFar - /// is still 0 right after the window has reset. + /// Checks a --session-token-progress/--week-token-progress argument without resolving anything, so that both can be + /// checked before the first one prompts for anything. + /// + /// The raw argument, if provided. + /// An error message if the argument is given and is not a usable percentage; otherwise . + internal static string? ValidatePercentArgument(double? percent) + => percent is { } value && !UsageCalibration.IsValidPercent(value) + ? string.Create(CultureInfo.InvariantCulture, $"Invalid usage percentage '{value}'. {PercentRangeHint}") + : null; + + /// + /// Determines whether a window has any cost that counts toward the quota, which a usage percentage needs to be + /// calibrated against. /// - internal static decimal DeriveCostLimit(decimal costSoFar, double progressPercent) - => Math.Max(0.01m, costSoFar / (decimal)(progressPercent / 100)); + /// The estimated cost accrued for the window so far, by kind of token. + /// The cache read weight in effect. + /// if the weighted cost is above zero. + private static bool HasCountedCost(CostBreakdown costSoFar, decimal cacheReadWeight) + => QuotaCost.Compute(costSoFar, cacheReadWeight) > 0m; + + /// + /// Explains why a usage percentage cannot be calibrated against a window that has no cost counting toward the quota. + /// The cause is either that the window has no usage yet, or that the cache read weight makes all of its usage count as zero. + /// + /// A short label (e.g. "Session", "Weekly") naming the window. + /// The estimated cost accrued for the window so far, by kind of token. + /// The cache read weight in effect. + /// The plain-text sentence. + internal static string DescribeNothingCounted(string label, CostBreakdown costSoFar, decimal cacheReadWeight) + { + var hasUsage = costSoFar.Total > 0m; + var reason = hasUsage + ? $"all of its usage counts as zero under a cache read weight of {cacheReadWeight:0.##}; raise --cache-read-weight to calibrate" + : "nothing has been counted for it yet; try again once it has usage"; + + return string.Create( + CultureInfo.InvariantCulture, + $"{label}: the usage percentage was not used, because {reason}. The saved reading, if any, is kept."); + } /// /// Prompts for a string with as the value Enter returns (so a @@ -360,40 +415,50 @@ private static string Ask(string promptText, string defaultText) } /// - /// Prompts for a usage percentage and derives a cost limit from it and . - /// The prompt's default is the percentage currently implies (given - /// ), so pressing Enter keeps the existing limit unchanged rather - /// than re-deriving it from a rounded default percentage. + /// Prompts for a usage percentage and pairs it with as a new calibration. + /// The prompt's default is the percentage currently implies (given + /// ), so pressing Enter keeps the existing calibration unchanged rather + /// than re-deriving it from a rounded default percentage. A blank answer keeps it too, and so does a percentage + /// typed while nothing is counted for the window, which could not be calibrated against (the reason goes to + /// ). /// - /// The currently configured cost limit, if any. - /// The estimated cost accrued for the window so far. + /// The currently configured calibration, if any. + /// The estimated cost accrued for the window so far, by kind of token. + /// The cache read weight in effect. + /// A short label (e.g. "Session", "Weekly") naming the window in notices. + /// Receives a plain-text notice when a typed percentage is not used. /// The prompt text to display. - private static decimal? PromptForCostLimit(decimal? currentLimit, decimal costSoFar, string promptText) + private static UsageCalibration? PromptForCalibration( + UsageCalibration? current, CostBreakdown costSoFar, decimal cacheReadWeight, string label, + ICollection notices, string promptText) { - var defaultText = currentLimit is { } limit and > 0 - ? (costSoFar * 100 / limit).ToString("0.##", CultureInfo.InvariantCulture) + var defaultText = current?.PercentOf(costSoFar, cacheReadWeight) is { } implied + ? implied.ToString("0.##", CultureInfo.InvariantCulture) : ""; while (true) { var input = Ask(promptText, defaultText); - if (string.IsNullOrWhiteSpace(input)) + // Enter always keeps what is there, even when no percentage can be shown as the default. + if (string.IsNullOrWhiteSpace(input) || (input == defaultText && current is not null)) { - return null; + return current; } - if (input == defaultText && currentLimit is not null) + if (double.TryParse(input, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + && UsageCalibration.IsValidPercent(parsed)) { - return currentLimit; - } + if (!HasCountedCost(costSoFar, cacheReadWeight)) + { + notices.Add(DescribeNothingCounted(label, costSoFar, cacheReadWeight)); + return current; + } - if (double.TryParse(input, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && parsed > 0) - { - return DeriveCostLimit(costSoFar, parsed); + return new UsageCalibration(costSoFar, parsed); } - AnsiConsole.MarkupLine("[red]Invalid value. Expected a percentage greater than 0, e.g. 32.[/]"); + AnsiConsole.MarkupLine(ConsoleMarkup.Red($"Invalid value. {PercentRangeHint}")); } } diff --git a/src/AIUsageMonitor.Cli/Commands/LimitsStartup.cs b/src/AIUsageMonitor.Cli/Commands/LimitsStartup.cs new file mode 100644 index 0000000..7a8b818 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/LimitsStartup.cs @@ -0,0 +1,116 @@ +using AIUsageMonitor.Core.Models; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// Works out, at the start of watch --view limits, which cache read weight and usage calibrations apply and what +/// to save, without reading the settings file or the terminal's state so that it can be tested. The only console +/// interaction left is the prompt for a missing percentage, which is skipped when the caller says there is no interactive +/// terminal. +/// +internal static class LimitsStartup +{ + /// + /// The result of resolving the limits at startup. + /// + /// The cache read weight in effect. + /// The session usage calibration in effect, if any. + /// The weekly usage calibration in effect, if any. + /// The settings to persist. + /// Plain-text notices for the user to read in the view. + /// An error message if an argument was invalid; the other values are not meaningful then. + internal sealed record Result( + decimal CacheReadWeight, + UsageCalibration? SessionCalibration, + UsageCalibration? WeekCalibration, + LimitsSettings ToSave, + IReadOnlyList Notices, + string? Error); + + /// + /// Resolves the weight and the calibrations from the arguments, the saved settings and the cost of the two windows. + /// A usage percentage that is given prompts for nothing; one that is missing is asked for unless the input is redirected. + /// + /// The settings as saved. + /// The --session-token-progress argument, if given. + /// The --week-token-progress argument, if given. + /// The --cache-read-weight argument, if given. + /// Whether --reset-cache-read-weight was given. + /// The estimated cost of the session window so far, by kind of token. + /// The estimated cost of the weekly window so far, by kind of token. + /// Whether there is no interactive terminal, so that nothing can be prompted for. + /// What applies and what to save, or an error. + internal static Result Resolve( + LimitsSettings saved, double? sessionPercentArg, double? weekPercentArg, decimal? weightArg, bool resetWeight, + CostBreakdown sessionCost, CostBreakdown weekCost, bool inputRedirected) + { + var weight = QuotaCost.ResolveWeight(weightArg, saved.CacheReadWeight, resetWeight); + var notices = new List(); + + // Both arguments are checked before the first window resolves, so that a bad one is not found after the user was + // already prompted for the other. + var argumentError = LimitsAnchors.ValidatePercentArgument(sessionPercentArg) ?? LimitsAnchors.ValidatePercentArgument(weekPercentArg); + if (argumentError is not null) + { + return new(weight, null, null, saved, notices, argumentError); + } + + if (!LimitsAnchors.ResolveCalibration( + sessionPercentArg, UsageCalibration.OrNullIfInvalid(saved.SessionCalibration), sessionCost, weight, + "Session", notices, inputRedirected, out var sessionCalibration, out var error) + || !LimitsAnchors.ResolveCalibration( + weekPercentArg, UsageCalibration.OrNullIfInvalid(saved.WeekCalibration), weekCost, weight, + "Weekly", notices, inputRedirected, out var weekCalibration, out error)) + { + return new(weight, null, null, saved, notices, error); + } + + AddLegacyLimitsNotice(saved, sessionCalibration, weekCalibration, inputRedirected, notices); + + var toSave = saved with + { + SessionCalibration = sessionCalibration, + WeekCalibration = weekCalibration, + CacheReadWeight = QuotaCost.WeightToSave(weightArg, saved.CacheReadWeight, resetWeight), + SessionCostLimit = null, + WeekCostLimit = null, + }; + + return new(weight, sessionCalibration, weekCalibration, toSave, notices, null); + } + + /// + /// Tells the user that limits saved by an older version were not carried over, when that leaves a window without a + /// calibration and nothing was prompted for. It is told once: the legacy limits are cleared in the settings to save. + /// + /// The settings as saved. + /// The session calibration that resulted, if any. + /// The weekly calibration that resulted, if any. + /// Whether there is no interactive terminal; with one, the user was just asked. + /// Receives the notice. + private static void AddLegacyLimitsNotice( + LimitsSettings saved, UsageCalibration? sessionCalibration, UsageCalibration? weekCalibration, + bool inputRedirected, ICollection notices) + { + if (!saved.HasLegacyLimits || !inputRedirected) + { + return; + } + + var missing = new List(); + if (sessionCalibration is null) + { + missing.Add("--session-token-progress"); + } + + if (weekCalibration is null) + { + missing.Add("--week-token-progress"); + } + + if (missing.Count > 0) + { + notices.Add($"The usage limits saved by an older version cannot be carried over; pass {string.Join(" and ", missing)} to set them again."); + } + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/QuotaCost.cs b/src/AIUsageMonitor.Cli/Commands/QuotaCost.cs new file mode 100644 index 0000000..28154c7 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/QuotaCost.cs @@ -0,0 +1,145 @@ +using AIUsageMonitor.Core.Models; +using System.CommandLine; +using System.Globalization; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// The cost figure the usage-progress bar of watch --view limits is measured in. It is the estimated API cost +/// with the cache reads scaled down: the usage quota of a subscription is not metered at API list prices, and cache +/// reads are far more numerous than any other kind of token in an agentic session (the whole context is read again on +/// every turn), so counting them at their full list price makes the local bar run ahead of the percentage Claude shows. +/// +internal static class QuotaCost +{ + /// + /// The default share of the list price of a cache read that counts toward the quota. This is a heuristic, not a + /// measured value: Anthropic does not publish how the quota weighs the kinds of token, so it can be tuned with + /// --cache-read-weight by comparing the local bar with Claude's own percentage. + /// + internal const decimal DefaultCacheReadWeight = 0.1m; + + /// + /// Creates the --cache-read-weight option, which accepts a value from 0 to 1. + /// + /// The option. + internal static Option CreateOption() + { + var option = new Option("--cache-read-weight") + { + Description = $"Share (0 to 1, with '.' as the decimal separator) of the list price of cache-read tokens that counts toward the usage quota, default {DefaultCacheReadWeight} (a heuristic, not a published figure). Lower it if the usage-progress bar runs ahead of Claude's own percentage, raise it if it runs behind; the saved usage percentages are kept and the limits are derived again under the new weight. Only used when --view limits.", + CustomParser = result => + { + if (decimal.TryParse(result.Tokens.Single().Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var value) + && IsValidWeight(value)) + { + return value; + } + + result.AddError("--cache-read-weight must be a number between 0 and 1."); + return null; + } + }; + return option; + } + + /// + /// Creates the --reset-cache-read-weight flag, which forgets the saved weight. + /// + /// The option. + internal static Option CreateResetOption() + { + return new Option("--reset-cache-read-weight") + { + Description = $"Forget the saved --cache-read-weight and use the default ({DefaultCacheReadWeight}) again, including in future runs. Cannot be combined with --cache-read-weight. Only used when --view limits." + }; + } + + /// + /// Makes parsing fail when both the weight and its reset are given, before the command does anything. + /// + /// The command that has both options. + /// The --cache-read-weight option. + /// The --reset-cache-read-weight option. + internal static void AddConflictValidator(Command command, Option weightOption, Option resetOption) + { + command.Validators.Add(result => + { + if (result.GetResult(weightOption) is not null && result.GetResult(resetOption) is not null) + { + result.AddError("--reset-cache-read-weight cannot be combined with --cache-read-weight."); + } + }); + } + + /// + /// Determines whether a cache read weight is allowed. + /// + /// The weight to check. + /// if the weight lies between 0 and 1, inclusive. + internal static bool IsValidWeight(decimal weight) => weight is >= 0m and <= 1m; + + /// + /// Resolves the weight in effect: the command-line argument, else the persisted one, else the default. A persisted + /// weight that is not valid (the file was edited by hand) is ignored. + /// + /// The --cache-read-weight argument, if given. + /// The persisted weight, if any. + /// Whether --reset-cache-read-weight was given, which makes the default apply. + /// The cache read weight to use. + internal static decimal ResolveWeight(decimal? argument, decimal? saved, bool reset = false) + => WeightToSave(argument, saved, reset) is { } value && IsValidWeight(value) ? value : DefaultCacheReadWeight; + + /// + /// Decides which weight to persist: only a weight the user chose is kept, so that the default can change in a + /// later version, and resetting forgets it. + /// + /// The --cache-read-weight argument, if given. + /// The persisted weight, if any. + /// Whether --reset-cache-read-weight was given. + /// + /// The weight to save, or for none (the default applies). A saved weight that is not valid is + /// not kept, since it is ignored when resolving the weight. + /// + internal static decimal? WeightToSave(decimal? argument, decimal? saved, bool reset) + => reset ? null : argument ?? (saved is { } value && IsValidWeight(value) ? value : null); + + /// + /// Computes the cost that counts toward the quota. + /// + /// The estimated cost of a window by kind of token. + /// The share of the cache-read cost that counts. + /// The weighted cost, in US dollars. + internal static decimal Compute(CostBreakdown breakdown, decimal cacheReadWeight) + => Weighted(breakdown, cacheReadWeight).Total; + + /// + /// Scales the cache-read part of a cost breakdown by the weight, leaving the other kinds as they are. This is + /// the one place that decides which kinds count and how much. + /// + /// The estimated cost of a window by kind of token. + /// The share of the cache-read cost that counts. + /// The breakdown of the cost that counts toward the quota. + internal static CostBreakdown Weighted(CostBreakdown breakdown, decimal cacheReadWeight) + => breakdown with { CacheRead = breakdown.CacheRead * cacheReadWeight }; + + /// + /// Computes how much of the weighted cost each kind of token accounts for. + /// + /// The estimated cost of a window by kind of token. + /// The share of the cache-read cost that counts. + /// + /// The share of each kind, or when nothing counts toward the quota. + /// + internal static CostShares? Shares(CostBreakdown breakdown, decimal cacheReadWeight) + { + var weighted = Weighted(breakdown, cacheReadWeight); + var total = weighted.Total; + if (total <= 0m) + { + return null; + } + + return new(weighted.Input / total, weighted.Output / total, weighted.CacheRead / total, weighted.CacheWrite / total); + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs index 863a437..632124e 100644 --- a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs @@ -34,12 +34,17 @@ public static Command Create(DataService dataService, ILogger? logger = null) }; var (sessionResetOption, weekResetOption) = LimitsAnchors.CreateOptions(); var (sessionTokenProgressOption, weekTokenProgressOption) = LimitsAnchors.CreateTokenProgressOptions(); + var cacheReadWeightOption = QuotaCost.CreateOption(); + var resetCacheReadWeightOption = QuotaCost.CreateResetOption(); command.Options.Add(viewOption); command.Options.Add(intervalOption); command.Options.Add(sessionResetOption); command.Options.Add(weekResetOption); command.Options.Add(sessionTokenProgressOption); command.Options.Add(weekTokenProgressOption); + command.Options.Add(cacheReadWeightOption); + command.Options.Add(resetCacheReadWeightOption); + QuotaCost.AddConflictValidator(command, cacheReadWeightOption, resetCacheReadWeightOption); command.SetAction(async (parseResult, ct) => { @@ -52,8 +57,12 @@ public static Command Create(DataService dataService, ILogger? logger = null) DateTimeOffset? effectiveSessionResetAt = null; (DayOfWeek Day, TimeSpan TimeOfDay)? effectiveWeekResetAt = null; - decimal? effectiveSessionTokenLimit = null; - decimal? effectiveWeekTokenLimit = null; + UsageCalibration? effectiveSessionCalibration = null; + UsageCalibration? effectiveWeekCalibration = null; + var cacheReadWeight = QuotaCost.DefaultCacheReadWeight; + + // Notices about the limits, shown in the view because anything printed is cleared with the screen. + var limitsNotices = new List(); UsageWindowSummary? lastSessionWindow = null; UsageWindowSummary? lastWeekWindow = null; @@ -89,22 +98,23 @@ public static Command Create(DataService dataService, ILogger? logger = null) lastSessionWindow = dataService.GetCurrentSessionWindow(effectiveSessionResetAt); lastWeekWindow = dataService.GetWeekWindow(effectiveWeekResetAt); - if (!LimitsAnchors.ResolveTokenLimit( - sessionTokenProgressArg, saved.SessionCostLimit, lastSessionWindow.EstimatedCost, "Session", - out effectiveSessionTokenLimit, out error) - || !LimitsAnchors.ResolveTokenLimit( - weekTokenProgressArg, saved.WeekCostLimit, lastWeekWindow.EstimatedCost, "Weekly", - out effectiveWeekTokenLimit, out error)) + // The weight is tuned by trial, so what is saved is the reading each limit is derived from (see + // UsageCalibration), not the limit: changing the weight then re-derives the limits without asking again. + var startup = LimitsStartup.Resolve( + saved, sessionTokenProgressArg, weekTokenProgressArg, + parseResult.GetValue(cacheReadWeightOption), parseResult.GetValue(resetCacheReadWeightOption), + lastSessionWindow.CostBreakdown, lastWeekWindow.CostBreakdown, Console.IsInputRedirected); + if (startup.Error is not null) { - AnsiConsole.MarkupLine(ConsoleMarkup.Red(error!)); + AnsiConsole.MarkupLine(ConsoleMarkup.Red(startup.Error)); return 1; } - LimitsSettingsStore.SaveOrWarn(saved with - { - SessionCostLimit = effectiveSessionTokenLimit, - WeekCostLimit = effectiveWeekTokenLimit - }); + cacheReadWeight = startup.CacheReadWeight; + effectiveSessionCalibration = startup.SessionCalibration; + effectiveWeekCalibration = startup.WeekCalibration; + limitsNotices.AddRange(startup.Notices); + LimitsSettingsStore.SaveOrWarn(startup.ToSave); } var recalibrationEnabled = view == "limits" && !Console.IsInputRedirected; @@ -144,30 +154,20 @@ IRenderable BuildLimits(IProgress? progress) return SpectreRenderer.BuildUsageLimits( lastSessionWindow, lastWeekWindow, - effectiveSessionTokenLimit, - effectiveWeekTokenLimit, + effectiveSessionCalibration, + effectiveWeekCalibration, recalibrationEnabled, - effectiveSessionResetAt is not null); + effectiveSessionResetAt is not null, + cacheReadWeight); } ClearScreen(); IRenderable WithUpdateNotice(IRenderable content) - { - var rows = new List { content }; - if (updateNotice is not null) - { - rows.Add(updateNotice); - } - if (versionFooter is not null) - { - rows.Add(versionFooter); - } - return rows.Count == 1 ? content : new Rows(rows); - } + => SpectreRenderer.WithNotices(content, limitsNotices, updateNotice, versionFooter); var current = view == "limits" - ? WithUpdateNotice(SpectreRenderer.BuildUsageLimits(lastSessionWindow!, lastWeekWindow!, effectiveSessionTokenLimit, effectiveWeekTokenLimit, recalibrationEnabled, effectiveSessionResetAt is not null)) + ? WithUpdateNotice(SpectreRenderer.BuildUsageLimits(lastSessionWindow!, lastWeekWindow!, effectiveSessionCalibration, effectiveWeekCalibration, recalibrationEnabled, effectiveSessionResetAt is not null, cacheReadWeight)) : ProgressReporter.Run("Loading usage data...", BuildCurrent); while (!ct.IsCancellationRequested) @@ -198,15 +198,18 @@ await AnsiConsole.Live(current) } ClearScreen(); + limitsNotices.Clear(); AnsiConsole.MarkupLine("[grey]Press Enter on any prompt to keep using a local estimate or the current value.[/]"); - (effectiveSessionResetAt, effectiveWeekResetAt, effectiveSessionTokenLimit, effectiveWeekTokenLimit) = LimitsAnchors.PromptAndSaveBoth( + (effectiveSessionResetAt, effectiveWeekResetAt, effectiveSessionCalibration, effectiveWeekCalibration) = LimitsAnchors.PromptAndSaveBoth( (sessionReset, weekReset) => ProgressReporter.Run("Loading usage data...", p => { lastSessionWindow = dataService.GetCurrentSessionWindow(sessionReset, p); lastWeekWindow = dataService.GetWeekWindow(weekReset, p); - return (lastSessionWindow.EstimatedCost, lastWeekWindow.EstimatedCost); + return (lastSessionWindow.CostBreakdown, lastWeekWindow.CostBreakdown); }), - LimitsAnchors.SelectShownSessionReset(lastSessionWindow, DateTimeOffset.Now)); + LimitsAnchors.SelectShownSessionReset(lastSessionWindow, DateTimeOffset.Now), + cacheReadWeight, + limitsNotices); ClearScreen(); current = ProgressReporter.Run("Loading usage data...", BuildCurrent); } diff --git a/src/AIUsageMonitor.Cli/LimitsSettings.cs b/src/AIUsageMonitor.Cli/LimitsSettings.cs index ae3877a..05537fa 100644 --- a/src/AIUsageMonitor.Cli/LimitsSettings.cs +++ b/src/AIUsageMonitor.Cli/LimitsSettings.cs @@ -1,6 +1,7 @@ using AIUsageMonitor.Cli.Rendering; using Spectre.Console; using System.Text.Json; +using System.Text.Json.Serialization; namespace AIUsageMonitor.Cli; @@ -10,26 +11,50 @@ namespace AIUsageMonitor.Cli; /// /// The real reset time of the current session window, if configured. /// The real weekly reset time, as originally typed (e.g. "Mon 09:00"), if configured. -/// -/// The account's session (5h) usage limit, expressed as an estimated USD cost budget rather than a -/// raw token count - Anthropic's real usage gating weights tokens by model and type (an Opus token -/// is far more "expensive" toward the limit than a Haiku one), which estimated cost already -/// approximates far better than a flat token sum. Claude itself only ever shows a usage -/// *percentage*, never the underlying limit, so this value is not typed in directly - it is derived -/// once (currentCost / (enteredPercent / 100)) from the percentage the user read off -/// Claude's usage display and the estimated cost this tool had computed for the window at that moment. -/// Once derived it is persisted and reused so the usage-progress bar can keep tracking live as -/// cost accrues, without asking the user again every refresh. +/// +/// The reading the account's session (5h) usage limit is derived from: the usage *percentage* the user read off +/// Claude's usage display, together with the cost this tool had counted for the window at that moment (see +/// ). Claude itself never shows the underlying limit, so it cannot be typed in +/// directly. The limit is derived from the reading on demand as a quota-weighted USD cost budget (see +/// ) rather than stored, so the usage-progress bar keeps tracking live as cost accrues +/// without asking the user again, and the can be tuned without reading the percentage again. +/// +/// The reading the account's weekly usage limit is derived from, kept the same way as . +/// +/// The share of the list price of cache-read tokens that counts toward the usage quota (see +/// ), as last chosen with --cache-read-weight. means the +/// default. /// -/// The account's weekly usage limit, derived the same way as . public sealed record LimitsSettings( DateTimeOffset? SessionResetAt, string? WeekResetAt, - decimal? SessionCostLimit = null, - decimal? WeekCostLimit = null) + UsageCalibration? SessionCalibration = null, + UsageCalibration? WeekCalibration = null, + decimal? CacheReadWeight = null) { - /// An empty settings instance with no configured reset times or usage limits. - public static readonly LimitsSettings Empty = new(null, null, null, null); + /// + /// An empty settings instance with no configured reset times or usage limits. + /// + public static readonly LimitsSettings Empty = new(null, null, null, null, null); + + /// + /// Gets the session usage limit that an older version saved. It is only read, to tell the user it could not be + /// carried over (a limit alone cannot be re-derived under another cache read weight), and is never written back. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public decimal? SessionCostLimit { get; init; } + + /// + /// Gets the weekly usage limit that an older version saved; see . + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public decimal? WeekCostLimit { get; init; } + + /// + /// Gets a value indicating whether the file still holds a usage limit saved by an older version. + /// + [JsonIgnore] + public bool HasLegacyLimits => SessionCostLimit is not null || WeekCostLimit is not null; } /// diff --git a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs index 2e1d335..14b6687 100644 --- a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs +++ b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs @@ -389,16 +389,17 @@ public static IRenderable BuildSessionStats(SessionStats stats) /// /// The current 5-hour session window data to render. /// The current weekly window data to render. - /// - /// The account's session usage limit, expressed as an estimated USD cost budget, or + /// + /// The reading the account's session usage limit is derived from (see ), or /// if not configured (in which case the usage-progress column is left - /// blank for that row). Cost already weights tokens by model and type, which tracks Anthropic's - /// real usage gating far more closely than a flat token count. Claude itself only ever shows a - /// usage *percentage*, never this limit, so it is derived once from a percentage the user read - /// off Claude's usage display - see - and - /// then used here to track 's live estimated cost against it. + /// blank for that row). The limit is a quota-weighted USD cost budget (see ); + /// cost already weights tokens by model and type, which tracks Anthropic's real usage gating far more closely + /// than a flat token count. Claude itself only ever shows a usage *percentage*, never this limit, so it is + /// derived from a percentage the user read off Claude's usage display - see + /// - and then used here to track + /// 's live weighted cost against it. /// - /// The account's weekly usage (cost) limit, or if not configured. + /// The reading the account's weekly usage limit is derived from, or if not configured. /// /// Whether the "press r" hotkey hint applies, i.e. whether the caller is actually watching for /// it (it does nothing outside an interactive watch session). @@ -409,11 +410,15 @@ public static IRenderable BuildSessionStats(SessionStats stats) /// idle account (no reset time known at all) apart from a session that just reset and is /// waiting for its first message. /// + /// + /// The share of the list price of cache-read tokens that counts toward the usage quota (see + /// ). The usage-progress bar and the limits it is measured against use this weighted cost. + /// /// An representing both usage windows. public static IRenderable BuildUsageLimits( UsageWindowSummary sessionWindow, UsageWindowSummary weekWindow, - decimal? sessionTokenLimit = null, decimal? weekTokenLimit = null, bool recalibrationAvailable = true, - bool sessionResetConfigured = false) + UsageCalibration? sessionCalibration = null, UsageCalibration? weekCalibration = null, bool recalibrationAvailable = true, + bool sessionResetConfigured = false, decimal cacheReadWeight = Commands.QuotaCost.DefaultCacheReadWeight) { var table = new Table().Border(TableBorder.Rounded).Title("[bold yellow]Usage Limits[/]").ShowRowSeparators(); table.AddColumn(HeaderColumn("Window", Justify.Left)); @@ -425,11 +430,19 @@ public static IRenderable BuildUsageLimits( table.AddColumn(HeaderColumn("Time Progress", Justify.Center)); table.AddColumn(HeaderColumn("Usage Progress", Justify.Center)); - var sessionFractions = AddRow(table, "Session (5h)", sessionWindow, sessionTokenLimit); - AddRow(table, "Week", weekWindow, weekTokenLimit); + var sessionFractions = AddRow(table, "Session (5h)", sessionWindow, sessionCalibration, cacheReadWeight); + AddRow(table, "Week", weekWindow, weekCalibration, cacheReadWeight); var notes = new List { table }; + foreach (var (label, window) in new[] { ("Session", sessionWindow), ("Week", weekWindow) }) + { + if (FormatCostSplit(label, window, cacheReadWeight) is { } split) + { + notes.Add(new Markup(split)); + } + } + if (sessionFractions is { } fractions && FormatPaceHint(fractions.Time, fractions.Token) is { } paceHint) { notes.Add(paceHint); @@ -466,14 +479,15 @@ public static IRenderable BuildUsageLimits( return notes.Count == 1 ? table : new Rows(notes); - static (double Time, double Token)? AddRow(Table table, string label, UsageWindowSummary window, decimal? costLimit) + static (double Time, double Token)? AddRow( + Table table, string label, UsageWindowSummary window, UsageCalibration? calibration, decimal cacheReadWeight) { var (tokens, messages, cost) = window.WindowStart is null ? ("—", "—", "—") : (FormatTokens(window.TotalTokens), FormatCount(window.Messages), FormatCost(window.EstimatedCost)); - var tokenFraction = window.WindowStart is not null && costLimit is { } limit and > 0 - ? (double)(window.EstimatedCost / limit) + var tokenFraction = window.WindowStart is not null && calibration?.DeriveLimit(cacheReadWeight) is { } limit + ? (double)(Commands.QuotaCost.Compute(window.CostBreakdown, cacheReadWeight) / limit) : (double?)null; var tokenProgress = tokenFraction is { } tf ? FormatProgressBar(tf) : "—"; @@ -507,6 +521,57 @@ public static IRenderable BuildUsageLimits( } } + /// + /// Puts a view together with the notices that go under it: the limits notices, then the update notice and the version footer. + /// Notices are drawn in the view because anything printed to the console is cleared with the screen right away. + /// + /// The view itself. + /// Plain-text notices, drawn in yellow. + /// The update notice, if there is a newer version. + /// The version footer, if it applies. + /// The view with everything under it, or itself when there is nothing to add. + public static IRenderable WithNotices( + IRenderable content, IEnumerable notices, IRenderable? updateNotice, IRenderable? versionFooter) + { + var rows = new List { content }; + rows.AddRange(notices.Select(notice => new Markup(ConsoleMarkup.Colored("yellow", notice)))); + if (updateNotice is not null) + { + rows.Add(updateNotice); + } + + if (versionFooter is not null) + { + rows.Add(versionFooter); + } + + return rows.Count == 1 ? content : new Rows(rows); + } + + /// + /// Formats the line that shows which kinds of token the usage-progress bar of a window is made of, so the bar can be + /// compared with Claude's own percentage and the cache read weight tuned. + /// + /// The window's name, such as "Session". + /// The window to describe. + /// The share of the cache-read cost that counts toward the quota. + /// The markup of the line, or if the window has no usage that counts. + internal static string? FormatCostSplit(string label, UsageWindowSummary window, decimal cacheReadWeight) + { + if (window.WindowStart is null || Commands.QuotaCost.Shares(window.CostBreakdown, cacheReadWeight) is not { } shares) + { + return null; + } + + var counted = Commands.QuotaCost.Compute(window.CostBreakdown, cacheReadWeight); + + return string.Create(CultureInfo.InvariantCulture, + $"[grey]{label} usage split: cache read {shares.CacheRead:0%} · output {shares.Output:0%} · " + + $"cache write {shares.CacheWrite:0%} · input {shares.Input:0%} " + + $"(the bar counts {FormatCost(counted)} of the {FormatCost(window.EstimatedCost)} list-price cost, " + + $"cache reads at {cacheReadWeight:0.##} of list price)[/]"); + } + /// /// Renders the daily summary directly to the console. /// diff --git a/src/AIUsageMonitor.Cli/UsageCalibration.cs b/src/AIUsageMonitor.Cli/UsageCalibration.cs new file mode 100644 index 0000000..3dbc8ff --- /dev/null +++ b/src/AIUsageMonitor.Cli/UsageCalibration.cs @@ -0,0 +1,69 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Core.Models; + +namespace AIUsageMonitor.Cli; + +/// +/// One reading of Claude's own usage percentage together with the cost this tool had counted for the window at that +/// moment. The usage limit of the account is never shown by Claude, only a percentage, so the limit is derived from +/// such a reading - and it is derived on demand rather than stored, because the cost it divides depends on the cache +/// read weight (see ). Keeping the reading lets the weight be tuned without reading the +/// percentage off Claude again. +/// +/// The estimated cost of the window by kind of token when the percentage was read. +/// The usage percentage Claude showed at that moment, for example 32 for '32%'. +public sealed record UsageCalibration(CostBreakdown Breakdown, double Percent) +{ + /// + /// The smallest usage percentage that can be calibrated against. Anything lower rounds to zero when divided into a + /// cost, so the limit could not be derived. + /// + public const double MinPercent = 0.01; + + /// + /// The largest usage percentage that can be calibrated against. Claude can show a little over 100%; much more than that is a typo, + /// and a value far beyond it would overflow the cost arithmetic. + /// + public const double MaxPercent = 100_000; + + /// + /// Determines whether a usage percentage can be calibrated against. NaN and infinity are not. + /// + /// The usage percentage, for example 32 for '32%'. + /// if the percentage lies between and . + public static bool IsValidPercent(double percent) => percent is >= MinPercent and <= MaxPercent; + + /// + /// Drops a calibration that cannot be divided by, as a hand-edited settings file may hold one. + /// + /// The calibration read from the settings, if any. + /// The calibration if its percentage is valid (see ); otherwise . + public static UsageCalibration? OrNullIfInvalid(UsageCalibration? calibration) + => calibration is not null && IsValidPercent(calibration.Percent) ? calibration : null; + + /// + /// Derives the usage limit this reading implies under a cache read weight. + /// + /// The share of the cache-read cost that counts toward the quota. + /// + /// The limit, as a quota-weighted cost in US dollars (the cost at the reading divided by the percentage), or + /// when nothing counted at the reading under this weight (for example a reading taken right + /// after a reset, or one made only of cache reads with a weight of 0) or the percentage is not usable, because then + /// the reading says nothing about the size of the limit. + /// + public decimal? DeriveLimit(decimal cacheReadWeight) + { + var cost = QuotaCost.Compute(Breakdown, cacheReadWeight); + + return cost > 0m && IsValidPercent(Percent) ? cost / (decimal)(Percent / 100) : null; + } + + /// + /// Computes the percentage of the limit that some cost represents, which is what Claude would show for it. + /// + /// The estimated cost of a window by kind of token. + /// The share of the cache-read cost that counts toward the quota. + /// The usage percentage, for example 32 for '32%', or when there is no limit to measure against. + public decimal? PercentOf(CostBreakdown breakdown, decimal cacheReadWeight) + => DeriveLimit(cacheReadWeight) is { } limit ? QuotaCost.Compute(breakdown, cacheReadWeight) * 100 / limit : null; +} diff --git a/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs b/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs index 6f6deb5..3664f5c 100644 --- a/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs +++ b/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs @@ -1,3 +1,4 @@ +using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers.Claude.Models; namespace AIUsageMonitor.Core.Analytics; @@ -76,18 +77,35 @@ public decimal EstimateCost(string modelName, long inputTokens, long outputToken /// The estimated cost in USD, or 0 if the model's pricing could not be resolved. public decimal EstimateCost(string modelName, long inputTokens, long outputTokens, long cacheReadTokens, long cacheCreation5mTokens, long cacheCreation1hTokens) + => EstimateCostBreakdown( + modelName, inputTokens, outputTokens, cacheReadTokens, cacheCreation5mTokens, cacheCreation1hTokens).Total; + + /// + /// Estimates the cost in USD for a request like the six-argument EstimateCost, but keeps the cost of each + /// kind of token apart. + /// + /// The model name (or a string containing it) used to resolve pricing. + /// Number of input tokens consumed. + /// Number of output tokens generated. + /// Number of tokens read from cache. + /// Number of tokens used to create 5-minute-TTL cache entries. + /// Number of tokens used to create 1-hour-TTL cache entries. + /// The estimated cost by kind of token, or all zero if the model's pricing could not be resolved. + public CostBreakdown EstimateCostBreakdown(string modelName, long inputTokens, long outputTokens, + long cacheReadTokens, long cacheCreation5mTokens, long cacheCreation1hTokens) { var pricing = ResolvePricing(modelName); if (pricing is null) { - return 0m; + return default; } - return (inputTokens * pricing.InputPerMTok - + outputTokens * pricing.OutputPerMTok - + cacheReadTokens * pricing.CacheReadPerMTok - + cacheCreation5mTokens * pricing.CacheCreation5mPerMTok - + cacheCreation1hTokens * pricing.CacheCreation1hPerMTok) / 1_000_000m; + return new( + inputTokens * pricing.InputPerMTok / 1_000_000m, + outputTokens * pricing.OutputPerMTok / 1_000_000m, + cacheReadTokens * pricing.CacheReadPerMTok / 1_000_000m, + (cacheCreation5mTokens * pricing.CacheCreation5mPerMTok + + cacheCreation1hTokens * pricing.CacheCreation1hPerMTok) / 1_000_000m); } public decimal EstimateCost(string modelName, ModelUsageEntry usage) diff --git a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs index 6cffd53..bde9b53 100644 --- a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs +++ b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs @@ -1,4 +1,5 @@ using AIUsageMonitor.Core.Providers.Claude.Models; +using System.Text.Json.Serialization; namespace AIUsageMonitor.Core.Models; @@ -157,16 +158,52 @@ public enum WindowConfidence /// The total number of messages sent within the window. /// The total number of tokens used within the window. /// A mapping of model name to the number of tokens consumed by that model. -/// The estimated monetary cost of usage within the window. /// How much the window boundaries can be trusted. +/// +/// The estimated monetary cost of usage within the window, split by kind of token. Lets a caller weight the kinds +/// differently, because the usage quota of a subscription does not follow the API price of each kind. It is the only +/// record of the cost, so cannot disagree with it. +/// public sealed record UsageWindowSummary( DateTimeOffset? WindowStart, DateTimeOffset? ResetsAt, int Messages, long TotalTokens, Dictionary TokensByModel, - decimal EstimatedCost, - WindowConfidence Confidence); + WindowConfidence Confidence, + CostBreakdown CostBreakdown) +{ + /// + /// Gets the estimated monetary cost of usage within the window, at API list prices. + /// + public decimal EstimatedCost => CostBreakdown.Total; +} + +/// +/// The estimated cost of some usage, split by kind of token. Every figure is in US dollars at API list prices. +/// +/// The cost of the uncached input tokens. +/// The cost of the output tokens, including thinking. +/// The cost of the tokens read from the prompt cache. +/// The cost of the tokens written to the prompt cache, of both lifetimes. +public readonly record struct CostBreakdown(decimal Input, decimal Output, decimal CacheRead, decimal CacheWrite) +{ + /// + /// Gets the cost of all kinds of token together. It is derived from the four amounts, so it is not serialized. + /// + [JsonIgnore] + public decimal Total => Input + Output + CacheRead + CacheWrite; + + /// + /// Adds two breakdowns kind by kind. + /// + /// The first breakdown. + /// The second breakdown. + /// The breakdown of the combined usage. + public static CostBreakdown operator +(CostBreakdown left, CostBreakdown right) + => new(left.Input + right.Input, left.Output + right.Output, + left.CacheRead + right.CacheRead, left.CacheWrite + right.CacheWrite); +} /// /// Represents aggregated statistics across all sessions. diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs b/src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs index db37350..f255abe 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs @@ -1,4 +1,5 @@ using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers.Claude.Models; namespace AIUsageMonitor.Core.Providers.Claude; @@ -74,6 +75,17 @@ public decimal EstimateCost(string model, CostCalculator costCalculator) return costCalculator.EstimateCost(model, Input, Output, CacheRead, CacheWrite5m, CacheWrite1h); } + /// + /// Estimates the cost of the accumulated usage, split by kind of token. + /// + /// The model the usage belongs to. + /// The calculator that prices the tokens. + /// The estimated cost by kind of token, in US dollars. + public CostBreakdown EstimateCostBreakdown(string model, CostCalculator costCalculator) + { + return costCalculator.EstimateCostBreakdown(model, Input, Output, CacheRead, CacheWrite5m, CacheWrite1h); + } + /// /// Adds the usage of one response to the running totals of its model. /// @@ -101,6 +113,23 @@ public static long Record(Dictionary byModel, string m /// The summed estimated cost, in US dollars. public static decimal EstimateCost(Dictionary byModel, CostCalculator costCalculator) { - return byModel.Sum(pair => pair.Value.EstimateCost(pair.Key, costCalculator)); + return EstimateCostBreakdown(byModel, costCalculator).Total; + } + + /// + /// Estimates the cost of the usage of several models, split by kind of token. + /// + /// The running totals per model. + /// The calculator that prices the tokens. + /// The summed estimated cost by kind of token, in US dollars. + public static CostBreakdown EstimateCostBreakdown(Dictionary byModel, CostCalculator costCalculator) + { + var breakdown = default(CostBreakdown); + foreach (var (model, totals) in byModel) + { + breakdown += totals.EstimateCostBreakdown(model, costCalculator); + } + + return breakdown; } } diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs index 30c8baa..c987ced 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs @@ -86,7 +86,7 @@ public UsageWindowSummary BuildCurrentSessionWindow( if (scanStart is null) { - return new(null, null, 0, 0, [], 0m, WindowConfidence.Unknown); + return new(null, null, 0, 0, [], WindowConfidence.Unknown, default); } var flooredStart = FloorToTenMinutes(scanStart.Value); @@ -95,7 +95,7 @@ public UsageWindowSummary BuildCurrentSessionWindow( // Judged against the floored reset time that is displayed, so a window is never reported with a reset in the past. if (now >= estimatedResetsAt) { - return new(null, null, 0, 0, [], 0m, WindowConfidence.Unknown); + return new(null, null, 0, 0, [], WindowConfidence.Unknown, default); } // Flooring may reach back before a known reset, but activity before it belongs to the previous window. @@ -360,9 +360,9 @@ private UsageWindowSummary Summarize( tokensByModel[model] = tokensByModel.GetValueOrDefault(model) + tokens; } - var estimatedCost = ModelTokenTotals.EstimateCost(modelUsage, costCalculator); + var costBreakdown = ModelTokenTotals.EstimateCostBreakdown(modelUsage, costCalculator); - return new(windowStart, resetsAt, messageCount, totalTokens, tokensByModel, estimatedCost, confidence); + return new(windowStart, resetsAt, messageCount, totalTokens, tokensByModel, confidence, costBreakdown); } private readonly record struct Message(DateTimeOffset Timestamp, SessionMessage Data); diff --git a/tests/AIUsageMonitor.Cli.Tests/LimitsAnchorsTests.cs b/tests/AIUsageMonitor.Cli.Tests/LimitsAnchorsTests.cs index 40e5efb..e74280c 100644 --- a/tests/AIUsageMonitor.Cli.Tests/LimitsAnchorsTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/LimitsAnchorsTests.cs @@ -80,7 +80,7 @@ public void SelectSessionResetDefault_NothingAvailable_ReturnsNull() /// Builds a session window with the given reset time and confidence. /// private static UsageWindowSummary Window(DateTimeOffset? resetsAt, WindowConfidence confidence) - => new(null, resetsAt, 0, 0, [], 0m, confidence); + => new(null, resetsAt, 0, 0, [], confidence, default); /// /// Verifies that an upcoming estimated reset is offered as the default. @@ -131,50 +131,153 @@ public void SelectShownSessionReset_NoResetOrNoWindow_ReturnsNull() } /// - /// Verifies the limit is cost divided by the entered fraction. + /// A breakdown of what was counted when 40% was read off Claude: 8 dollars of cache reads and 8 of everything else. + /// + private static readonly CostBreakdown AtReading = new(Input: 1m, Output: 2m, CacheRead: 8m, CacheWrite: 5m); + + /// + /// Verifies that an explicit percentage becomes a calibration with the cost of the window, whatever was saved. /// [Fact] - public void DeriveCostLimit_CostAndPercent_ReturnsCostOverFraction() + public void ResolveCalibration_ExplicitPercent_PairsItWithTheCostSoFar() { - var result = LimitsAnchors.DeriveCostLimit(costSoFar: 8m, progressPercent: 32); + var saved = new UsageCalibration(default, 10); + + var ok = LimitsAnchors.ResolveCalibration(50, saved, AtReading, 0.1m, "Session", new List(), true, out var calibration, out var error); - Assert.Equal(25m, result); + Assert.True(ok); + Assert.Null(error); + Assert.Equal(new UsageCalibration(AtReading, 50), calibration); } /// - /// Verifies the limit never drops below a cent when no cost has accrued yet. + /// Verifies that a non-positive percentage is rejected with an error. /// [Fact] - public void DeriveCostLimit_ZeroCost_ReturnsOneCentFloor() + public void ResolveCalibration_NonPositivePercent_ReturnsError() { - var result = LimitsAnchors.DeriveCostLimit(costSoFar: 0m, progressPercent: 5); + var ok = LimitsAnchors.ResolveCalibration(0, null, AtReading, 0.1m, "Session", new List(), true, out var calibration, out var error); - Assert.Equal(0.01m, result); + Assert.False(ok); + Assert.Null(calibration); + Assert.NotNull(error); } /// - /// Verifies an explicit percentage argument is converted using the supplied cost. + /// Verifies that a saved calibration is used as it is when no percentage is given, without asking. /// [Fact] - public void ResolveTokenLimit_ExplicitPercent_DerivesLimitFromCost() + public void ResolveCalibration_NoArgumentAndSavedCalibration_KeepsTheSavedOne() { - var ok = LimitsAnchors.ResolveTokenLimit(50, savedLimit: 99m, costSoFar: 4m, "Session", out var limit, out var error); + var saved = new UsageCalibration(AtReading, 40); + + var ok = LimitsAnchors.ResolveCalibration(null, saved, default, 0.1m, "Session", new List(), true, out var calibration, out _); Assert.True(ok); - Assert.Null(error); - Assert.Equal(8m, limit); + Assert.Same(saved, calibration); } /// - /// Verifies a non-positive percentage is rejected with an error. + /// Verifies that a percentage that cannot be divided by is rejected as an argument instead of crashing later. /// - [Fact] - public void ResolveTokenLimit_NonPositivePercent_ReturnsError() + /// The percentage given on the command line. + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(-1)] + [InlineData(1e9)] + public void ResolveCalibration_UnusablePercent_ReturnsError(double percent) { - var ok = LimitsAnchors.ResolveTokenLimit(0, savedLimit: null, costSoFar: 4m, "Session", out var limit, out var error); + var ok = LimitsAnchors.ResolveCalibration(percent, null, AtReading, 0.1m, "Session", new List(), true, out var calibration, out var error); Assert.False(ok); - Assert.Null(limit); + Assert.Null(calibration); Assert.NotNull(error); } + + /// + /// Verifies that a percentage given while nothing is counted for the window is ignored and the saved calibration is kept. + /// + [Fact] + public void ResolveCalibration_NothingCountedYet_KeepsTheSavedCalibration() + { + var saved = new UsageCalibration(AtReading, 40); + var notices = new List(); + + var ok = LimitsAnchors.ResolveCalibration(30, saved, default, 0.1m, "Session", notices, true, out var calibration, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Same(saved, calibration); + Assert.Single(notices); + Assert.Contains("nothing has been counted", notices[0]); + } + + /// + /// Verifies that with nothing counted yet and nothing saved there is no calibration, rather than a useless one. + /// + [Fact] + public void ResolveCalibration_NothingCountedYetAndNothingSaved_ReturnsNoCalibration() + { + var onlyCacheReads = new CostBreakdown(0m, 0m, 5m, 0m); + + var notices = new List(); + + var ok = LimitsAnchors.ResolveCalibration(30, null, onlyCacheReads, 0m, "Session", notices, true, out var calibration, out _); + + Assert.True(ok); + Assert.Null(calibration); + Assert.Single(notices); + } + + /// + /// Verifies that the notice names the real cause: no usage yet, or a cache read weight that zeroes the usage there is. + /// + [Fact] + public void DescribeNothingCounted_NoUsageOrZeroWeight_NamesTheCause() + { + var empty = LimitsAnchors.DescribeNothingCounted("Session", default, 0.1m); + var zeroed = LimitsAnchors.DescribeNothingCounted("Weekly", new CostBreakdown(0m, 0m, 5m, 0m), 0m); + + Assert.StartsWith("Session:", empty); + Assert.Contains("nothing has been counted", empty); + Assert.StartsWith("Weekly:", zeroed); + Assert.Contains("counts as zero under a cache read weight of 0", zeroed); + Assert.Contains("--cache-read-weight", zeroed); + } + + /// + /// Verifies that without an interactive terminal a missing percentage is left unset instead of prompted for. + /// + [Fact] + public void ResolveCalibration_NoArgumentNothingSavedAndInputRedirected_LeavesItUnsetWithoutPrompting() + { + var ok = LimitsAnchors.ResolveCalibration(null, null, AtReading, 0.1m, "Session", new List(), true, out var calibration, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Null(calibration); + } + + /// + /// Verifies that only a given, unusable percentage argument is reported. + /// + /// The percentage given on the command line, if any. + /// Whether it is acceptable. + [Theory] + [InlineData(null, true)] + [InlineData(32.0, true)] + [InlineData(0.01, true)] + [InlineData(100000.0, true)] + [InlineData(0.0, false)] + [InlineData(double.NaN, false)] + [InlineData(double.PositiveInfinity, false)] + [InlineData(100001.0, false)] + public void ValidatePercentArgument_AnyArgument_ReportsOnlyUnusableOnes(double? percent, bool valid) + { + var error = LimitsAnchors.ValidatePercentArgument(percent); + + Assert.Equal(valid, error is null); + } } diff --git a/tests/AIUsageMonitor.Cli.Tests/LimitsSettingsStoreTests.cs b/tests/AIUsageMonitor.Cli.Tests/LimitsSettingsStoreTests.cs index 665ad37..345e02c 100644 --- a/tests/AIUsageMonitor.Cli.Tests/LimitsSettingsStoreTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/LimitsSettingsStoreTests.cs @@ -1,4 +1,5 @@ using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Core.Models; using Xunit; namespace AIUsageMonitor.Cli.Tests; @@ -31,7 +32,8 @@ public void Dispose() public void Save_ThenLoad_RoundTripsAndLeavesNoTemporaryFile() { var path = Path.Combine(_dir, "limits-settings.json"); - var settings = new LimitsSettings(new DateTimeOffset(2026, 10, 1, 18, 30, 0, TimeSpan.Zero), "Mon 09:00", 12.5m, 80m); + var settings = new LimitsSettings(new DateTimeOffset(2026, 10, 1, 18, 30, 0, TimeSpan.Zero), "Mon 09:00", + new UsageCalibration(new CostBreakdown(1m, 2m, 3m, 4m), 32), new UsageCalibration(new CostBreakdown(5m, 6m, 7m, 8m), 12.5), 0.1m); var saved = LimitsSettingsStore.Save(settings, path); var loaded = LimitsSettingsStore.Load(path); @@ -41,6 +43,43 @@ public void Save_ThenLoad_RoundTripsAndLeavesNoTemporaryFile() Assert.Equal([path], Directory.GetFiles(_dir)); } + /// + /// Verifies that a settings file written when the derived limits were stored keeps its reset times, exposes the old + /// limits only so they can be reported, and does not write them back once they are cleared. + /// + [Fact] + public void Load_FileWithDerivedLimitsFromAnOlderVersion_ReportsThemAndDropsThemOnSave() + { + Directory.CreateDirectory(_dir); + var path = Path.Combine(_dir, "limits-settings.json"); + File.WriteAllText(path, """{"SessionResetAt":null,"WeekResetAt":"Mon 09:00","SessionCostLimit":12.5,"WeekCostLimit":80}"""); + + var loaded = LimitsSettingsStore.Load(path); + LimitsSettingsStore.Save(loaded with { SessionCostLimit = null, WeekCostLimit = null }, path); + var reloaded = LimitsSettingsStore.Load(path); + + Assert.Equal("Mon 09:00", loaded.WeekResetAt); + Assert.True(loaded.HasLegacyLimits); + Assert.Null(loaded.SessionCalibration); + Assert.Null(loaded.WeekCalibration); + Assert.False(reloaded.HasLegacyLimits); + Assert.DoesNotContain("CostLimit", File.ReadAllText(path)); + } + + /// + /// Verifies that settings without any legacy limit never write the legacy properties. + /// + [Fact] + public void Save_SettingsWithoutLegacyLimits_DoesNotWriteThem() + { + var path = Path.Combine(_dir, "limits-settings.json"); + + LimitsSettingsStore.Save(LimitsSettings.Empty with { CacheReadWeight = 0.2m }, path); + + Assert.DoesNotContain("CostLimit", File.ReadAllText(path)); + Assert.False(LimitsSettingsStore.Load(path).HasLegacyLimits); + } + /// /// Verifies that a path that cannot be written reports failure instead of throwing. /// @@ -113,4 +152,20 @@ public void TryParseWeekReset_AmbiguousDay_IsRejected(string text) Assert.False(parsed); } + + /// + /// Verifies that a calibration is saved as its four amounts and the percentage only, with the derived total left out, + /// and is read back unchanged. + /// + [Fact] + public void Save_Calibration_WritesTheAmountsWithoutTheDerivedTotalAndReadsThemBack() + { + var path = Path.Combine(_dir, "limits-settings.json"); + var settings = LimitsSettings.Empty with { SessionCalibration = new UsageCalibration(new CostBreakdown(1m, 2m, 3m, 4m), 32) }; + + LimitsSettingsStore.Save(settings, path); + + Assert.DoesNotContain("Total", File.ReadAllText(path)); + Assert.Equal(settings, LimitsSettingsStore.Load(path)); + } } diff --git a/tests/AIUsageMonitor.Cli.Tests/LimitsStartupTests.cs b/tests/AIUsageMonitor.Cli.Tests/LimitsStartupTests.cs new file mode 100644 index 0000000..54d7260 --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/LimitsStartupTests.cs @@ -0,0 +1,188 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Core.Models; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Tests for , which decides the weight, the calibrations and the settings to save when +/// watch --view limits starts. They all run as if the input were redirected, so nothing can prompt. +/// +public class LimitsStartupTests +{ + /// + /// The cost of a window that has usage of every kind. + /// + private static readonly CostBreakdown Used = new(Input: 1m, Output: 2m, CacheRead: 10m, CacheWrite: 3m); + + /// + /// Settings that already hold both calibrations. + /// + private static readonly LimitsSettings WithCalibrations = LimitsSettings.Empty with + { + SessionCalibration = new UsageCalibration(Used, 40), + WeekCalibration = new UsageCalibration(Used, 10), + }; + + /// + /// Verifies that explicit percentages become calibrations with the cost of their windows, and a given weight is applied and saved. + /// + [Fact] + public void Resolve_PercentagesAndWeightGiven_CalibratesAndSavesTheWeight() + { + var result = LimitsStartup.Resolve(LimitsSettings.Empty, 25, 5, 0.3m, false, Used, Used, true); + + Assert.Null(result.Error); + Assert.Equal(0.3m, result.CacheReadWeight); + Assert.Equal(new UsageCalibration(Used, 25), result.SessionCalibration); + Assert.Equal(new UsageCalibration(Used, 5), result.WeekCalibration); + Assert.Equal(0.3m, result.ToSave.CacheReadWeight); + Assert.Equal(result.SessionCalibration, result.ToSave.SessionCalibration); + Assert.Equal(result.WeekCalibration, result.ToSave.WeekCalibration); + Assert.Empty(result.Notices); + } + + /// + /// Verifies that saved calibrations and a saved weight are kept when nothing is given, and the default is not saved. + /// + [Fact] + public void Resolve_NothingGiven_KeepsWhatIsSaved() + { + var saved = WithCalibrations with { CacheReadWeight = 0.4m }; + + var result = LimitsStartup.Resolve(saved, null, null, null, false, Used, Used, true); + + Assert.Equal(0.4m, result.CacheReadWeight); + Assert.Equal(saved.SessionCalibration, result.SessionCalibration); + Assert.Equal(saved.WeekCalibration, result.WeekCalibration); + Assert.Equal(saved, result.ToSave); + } + + /// + /// Verifies that with no weight given or saved the default applies without being saved. + /// + [Fact] + public void Resolve_NoWeightGivenOrSaved_UsesTheDefaultAndDoesNotSaveIt() + { + var result = LimitsStartup.Resolve(WithCalibrations, null, null, null, false, Used, Used, true); + + Assert.Equal(QuotaCost.DefaultCacheReadWeight, result.CacheReadWeight); + Assert.Null(result.ToSave.CacheReadWeight); + } + + /// + /// Verifies that resetting forgets the saved weight, uses the default and keeps the calibrations. + /// + [Fact] + public void Resolve_Reset_ForgetsTheSavedWeightAndKeepsTheCalibrations() + { + var saved = WithCalibrations with { CacheReadWeight = 0.4m }; + + var result = LimitsStartup.Resolve(saved, null, null, null, true, Used, Used, true); + + Assert.Equal(QuotaCost.DefaultCacheReadWeight, result.CacheReadWeight); + Assert.Null(result.ToSave.CacheReadWeight); + Assert.Equal(saved.SessionCalibration, result.ToSave.SessionCalibration); + Assert.Equal(saved.WeekCalibration, result.ToSave.WeekCalibration); + } + + /// + /// Verifies that limits saved by an older version are reported once, naming what has to be passed again, and are not saved again. + /// + [Fact] + public void Resolve_LegacyLimitsAndNoCalibration_ReportsThemAndClearsThemInTheSettingsToSave() + { + var saved = LimitsSettings.Empty with { SessionCostLimit = 12.5m, WeekCostLimit = 80m }; + + var result = LimitsStartup.Resolve(saved, null, null, null, false, Used, Used, true); + + Assert.Single(result.Notices); + Assert.Contains("older version", result.Notices[0]); + Assert.Contains("--session-token-progress and --week-token-progress", result.Notices[0]); + Assert.False(result.ToSave.HasLegacyLimits); + } + + /// + /// Verifies that the older-version notice names only the window that is still without a calibration. + /// + [Fact] + public void Resolve_LegacyLimitsAndOneCalibrationGiven_NamesOnlyTheOtherWindow() + { + var saved = LimitsSettings.Empty with { SessionCostLimit = 12.5m }; + + var result = LimitsStartup.Resolve(saved, 30, null, null, false, Used, Used, true); + + Assert.Single(result.Notices); + Assert.Contains("--week-token-progress", result.Notices[0]); + Assert.DoesNotContain("--session-token-progress", result.Notices[0]); + } + + /// + /// Verifies that no older-version notice is shown when both windows end up calibrated, and the legacy limits are still cleared. + /// + [Fact] + public void Resolve_LegacyLimitsAndBothCalibrationsGiven_HasNoNoticeButClearsThem() + { + var saved = LimitsSettings.Empty with { SessionCostLimit = 12.5m, WeekCostLimit = 80m }; + + var result = LimitsStartup.Resolve(saved, 30, 10, null, false, Used, Used, true); + + Assert.Empty(result.Notices); + Assert.False(result.ToSave.HasLegacyLimits); + } + + /// + /// Verifies that a percentage given while nothing is counted keeps the saved calibration and says why. + /// + [Fact] + public void Resolve_PercentageWhileNothingCounted_KeepsTheSavedCalibrationAndReportsIt() + { + var result = LimitsStartup.Resolve(WithCalibrations, 30, null, null, false, default, Used, true); + + Assert.Equal(WithCalibrations.SessionCalibration, result.SessionCalibration); + Assert.Single(result.Notices); + Assert.StartsWith("Session:", result.Notices[0]); + } + + /// + /// Verifies that an invalid percentage is an error and nothing is changed in the settings to save. + /// + [Fact] + public void Resolve_InvalidPercentage_ReturnsAnErrorAndTheSettingsUnchanged() + { + var result = LimitsStartup.Resolve(WithCalibrations, double.NaN, null, null, false, Used, Used, true); + + Assert.NotNull(result.Error); + Assert.Equal(WithCalibrations, result.ToSave); + } + + /// + /// Verifies that a calibration saved with an unusable percentage is dropped rather than used. + /// + [Fact] + public void Resolve_SavedCalibrationWithUnusablePercent_IsDropped() + { + var saved = LimitsSettings.Empty with { SessionCalibration = new UsageCalibration(Used, 0), WeekCalibration = new UsageCalibration(Used, 10) }; + + var result = LimitsStartup.Resolve(saved, null, null, null, false, Used, Used, true); + + Assert.Null(result.SessionCalibration); + Assert.Equal(saved.WeekCalibration, result.WeekCalibration); + } + + /// + /// Verifies that a bad weekly percentage is found before the session window is resolved, so nothing is prompted for or + /// noted about the session first. + /// + [Fact] + public void Resolve_BadWeekPercentageAfterAGoodSessionOne_FailsBeforeResolvingTheSession() + { + // The good session percentage meets an empty window, which would add a notice if the session were resolved first. + var result = LimitsStartup.Resolve(LimitsSettings.Empty, 30, double.NaN, null, false, default, Used, true); + + Assert.NotNull(result.Error); + Assert.Empty(result.Notices); + Assert.Null(result.SessionCalibration); + Assert.Equal(LimitsSettings.Empty, result.ToSave); + } +} diff --git a/tests/AIUsageMonitor.Cli.Tests/QuotaCostTests.cs b/tests/AIUsageMonitor.Cli.Tests/QuotaCostTests.cs new file mode 100644 index 0000000..154cb17 --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/QuotaCostTests.cs @@ -0,0 +1,269 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Core.Models; +using System.CommandLine; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Tests for , the weighted cost the usage-progress bar is measured in. +/// +public class QuotaCostTests +{ + /// + /// A breakdown with a different cost for every kind of token, so a mix-up between them changes the result. + /// + private static readonly CostBreakdown Sample = new(Input: 1m, Output: 2m, CacheRead: 10m, CacheWrite: 3m); + + /// + /// Verifies that a weight of 1 reproduces the plain estimated cost. + /// + [Fact] + public void Compute_WeightOfOne_EqualsTheTotalCost() + { + var cost = QuotaCost.Compute(Sample, 1m); + + Assert.Equal(Sample.Total, cost); + } + + /// + /// Verifies that only the cache reads are scaled by the weight. + /// + /// The cache read weight. + /// The expected weighted cost. + [Theory] + [InlineData(0, 6)] + [InlineData(0.1, 7)] + [InlineData(0.5, 11)] + public void Compute_PartialWeight_ScalesOnlyTheCacheReads(double weight, int expected) + { + var cost = QuotaCost.Compute(Sample, (decimal)weight); + + Assert.Equal(expected, cost); + } + + /// + /// Verifies which weights are accepted. + /// + /// The weight to check. + /// Whether it is valid. + [Theory] + [InlineData(0, true)] + [InlineData(0.1, true)] + [InlineData(1, true)] + [InlineData(-0.1, false)] + [InlineData(1.5, false)] + public void IsValidWeight_AnyValue_AcceptsOnlyZeroToOne(double weight, bool expected) + { + var valid = QuotaCost.IsValidWeight((decimal)weight); + + Assert.Equal(expected, valid); + } + + /// + /// Verifies that the argument wins over the persisted weight, which wins over the default. + /// + [Fact] + public void ResolveWeight_ArgumentSavedAndNeither_PicksInThatOrder() + { + Assert.Equal(0.3m, QuotaCost.ResolveWeight(0.3m, 0.2m)); + Assert.Equal(0.2m, QuotaCost.ResolveWeight(null, 0.2m)); + Assert.Equal(QuotaCost.DefaultCacheReadWeight, QuotaCost.ResolveWeight(null, null)); + } + + /// + /// Verifies that a persisted weight that was edited into an invalid value is ignored. + /// + [Fact] + public void ResolveWeight_InvalidSavedWeight_FallsBackToTheDefault() + { + var weight = QuotaCost.ResolveWeight(null, 7m); + + Assert.Equal(QuotaCost.DefaultCacheReadWeight, weight); + } + + /// + /// Verifies that the shares of the kinds are fractions of the weighted cost and add up to the whole. + /// + [Fact] + public void Shares_AnyBreakdown_SumToOne() + { + var shares = QuotaCost.Shares(Sample, 0.1m); + + Assert.NotNull(shares); + Assert.Equal(1m / 7, shares.Value.CacheRead); + Assert.Equal(2m / 7, shares.Value.Output); + Assert.Equal(3m / 7, shares.Value.CacheWrite); + Assert.Equal(1m / 7, shares.Value.Input); + Assert.Equal(1m, shares.Value.Input + shares.Value.Output + shares.Value.CacheRead + shares.Value.CacheWrite, precision: 9); + } + + /// + /// Verifies that an empty window has no shares, so nothing is divided by zero. + /// + [Fact] + public void Shares_NothingCounts_ReturnsNull() + { + Assert.Null(QuotaCost.Shares(default, 0.1m)); + Assert.Null(QuotaCost.Shares(new CostBreakdown(0m, 0m, 5m, 0m), 0m)); + } + + /// + /// Verifies that only the cache-read part of the breakdown is scaled. + /// + [Fact] + public void Weighted_PartialWeight_ScalesOnlyTheCacheReadPart() + { + var weighted = QuotaCost.Weighted(Sample, 0.1m); + + Assert.Equal(new CostBreakdown(Input: 1m, Output: 2m, CacheRead: 1m, CacheWrite: 3m), weighted); + } + + /// + /// Parses arguments against a command that only has the cache read weight option. + /// + /// The command-line arguments. + /// The parse result and the option, to read the value back. + private static (ParseResult Result, Option Option) ParseOption(params string[] args) + { + var option = QuotaCost.CreateOption(); + var command = new Command("watch"); + command.Options.Add(option); + + return (command.Parse(args), option); + } + + /// + /// Verifies that a weight from 0 to 1 is parsed. + /// + /// The argument text. + /// The expected weight. + [Theory] + [InlineData("0", 0)] + [InlineData("0.25", 0.25)] + [InlineData("1", 1)] + public void CreateOption_WeightInRange_ParsesWithoutErrors(string text, double expected) + { + var (result, option) = ParseOption("--cache-read-weight", text); + + Assert.Empty(result.Errors); + Assert.Equal((decimal)expected, result.GetValue(option)); + } + + /// + /// Verifies that a weight outside 0 to 1, or text that is not a number, is rejected. + /// + /// The argument text. + [Theory] + [InlineData("1.5")] + [InlineData("-0.1")] + [InlineData("abc")] + public void CreateOption_WeightOutOfRangeOrNotANumber_ReportsAnError(string text) + { + var (result, _) = ParseOption("--cache-read-weight", text); + + Assert.NotEmpty(result.Errors); + } + + /// + /// Verifies that leaving the option out gives no value, so the saved weight or the default applies. + /// + [Fact] + public void CreateOption_OptionOmitted_HasNoValue() + { + var (result, option) = ParseOption(); + + Assert.Empty(result.Errors); + Assert.Null(result.GetValue(option)); + } + + /// + /// Verifies that resetting makes the default apply even when a weight was saved or given. + /// + [Fact] + public void ResolveWeight_Reset_UsesTheDefault() + { + Assert.Equal(QuotaCost.DefaultCacheReadWeight, QuotaCost.ResolveWeight(null, 0.3m, reset: true)); + Assert.Equal(QuotaCost.DefaultCacheReadWeight, QuotaCost.ResolveWeight(0.4m, 0.3m, reset: true)); + } + + /// + /// Verifies that only a weight the user chose is persisted: the argument, else the saved one, and nothing after a reset. + /// + [Fact] + public void WeightToSave_ArgumentSavedAndReset_KeepsOnlyWhatTheUserChose() + { + Assert.Equal(0.4m, QuotaCost.WeightToSave(0.4m, 0.3m, reset: false)); + Assert.Equal(0.3m, QuotaCost.WeightToSave(null, 0.3m, reset: false)); + Assert.Null(QuotaCost.WeightToSave(null, null, reset: false)); + Assert.Null(QuotaCost.WeightToSave(null, 0.3m, reset: true)); + } + + /// + /// Verifies that a saved weight that is not valid is not kept, since resolving the weight ignores it. + /// + [Fact] + public void WeightToSave_InvalidSavedWeight_IsNotKept() + { + Assert.Null(QuotaCost.WeightToSave(null, 7m, reset: false)); + Assert.Equal(0.4m, QuotaCost.WeightToSave(0.4m, 7m, reset: false)); + } + + /// + /// Verifies that the reset flag is off unless it is given. + /// + [Fact] + public void CreateResetOption_GivenOrOmitted_IsTrueOnlyWhenGiven() + { + var option = QuotaCost.CreateResetOption(); + var command = new Command("watch"); + command.Options.Add(option); + + Assert.True(command.Parse("--reset-cache-read-weight").GetValue(option)); + Assert.False(command.Parse(Array.Empty()).GetValue(option)); + } + + /// + /// Builds a command with both weight options and the conflict validator. + /// + /// The command and its two options. + private static (Command Command, Option Weight, Option Reset) CommandWithBothOptions() + { + var weight = QuotaCost.CreateOption(); + var reset = QuotaCost.CreateResetOption(); + var command = new Command("watch"); + command.Options.Add(weight); + command.Options.Add(reset); + QuotaCost.AddConflictValidator(command, weight, reset); + + return (command, weight, reset); + } + + /// + /// Verifies that giving the weight and its reset together fails at parse time. + /// + [Fact] + public void AddConflictValidator_BothOptionsGiven_ReportsAnError() + { + var (command, _, _) = CommandWithBothOptions(); + + var result = command.Parse(["--cache-read-weight", "0.2", "--reset-cache-read-weight"]); + + Assert.Contains(result.Errors, error => error.Message.Contains("cannot be combined")); + } + + /// + /// Verifies that either option alone, or neither, is accepted. + /// + /// The command-line arguments. + [Theory] + [InlineData("--cache-read-weight", "0.2")] + [InlineData("--reset-cache-read-weight")] + public void AddConflictValidator_OneOrNoOption_ReportsNoError(params string[] args) + { + var (command, _, _) = CommandWithBothOptions(); + + Assert.Empty(command.Parse(args).Errors); + Assert.Empty(command.Parse(Array.Empty()).Errors); + } +} diff --git a/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs b/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs index c5991b9..09aa5fa 100644 --- a/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs @@ -1,5 +1,8 @@ using AIUsageMonitor.Cli.Rendering; +using AIUsageMonitor.Cli.Commands; using AIUsageMonitor.Core.Models; +using Spectre.Console; +using Spectre.Console.Rendering; using System.Globalization; using Xunit; @@ -10,6 +13,163 @@ namespace AIUsageMonitor.Cli.Tests; /// public class SpectreRendererTests { + /// + /// Renders a renderable to plain text on a wide console, so nothing is wrapped or truncated. + /// + /// The renderable to draw. + /// The text drawn. + private static string RenderToText(IRenderable renderable) + { + using var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(writer), + }); + console.Profile.Width = 250; + console.Write(renderable); + + return writer.ToString(); + } + + /// + /// A window with the given cost, five hours long and one hour in, so its time progress is 20%. + /// + /// The cost of the window by kind of token. + /// The window. + private static UsageWindowSummary SessionWith(CostBreakdown breakdown) + { + var now = DateTimeOffset.Now; + + return new UsageWindowSummary(now.AddHours(-1), now.AddHours(4), 1, 100, [], WindowConfidence.Confirmed, breakdown); + } + + /// + /// A weekly window that is unknown, so it adds nothing to the text being checked. + /// + private static readonly UsageWindowSummary UnknownWeek = new(null, null, 0, 0, [], WindowConfidence.Unknown, default); + + /// + /// Verifies that the usage-progress bar measures the cache-read-weighted cost against the limit derived from the + /// calibration, not the plain list-price cost. + /// + [Fact] + public void BuildUsageLimits_WeightedCost_FillsTheBarWithTheWeightedShareOfTheLimit() + { + // Weighted under 0.1: 1 + 2 + 1 + 3 = 7 of the 16 list-price dollars. The reading says 25% at that same cost, so + // the limit is 28 and the bar must show 25%, while the list-price cost would give 57%. + var breakdown = new CostBreakdown(Input: 1m, Output: 2m, CacheRead: 10m, CacheWrite: 3m); + var calibration = new UsageCalibration(breakdown, 25); + + var text = RenderToText(SpectreRenderer.BuildUsageLimits( + SessionWith(breakdown), UnknownWeek, calibration, null, recalibrationAvailable: false, cacheReadWeight: 0.1m)); + + Assert.Contains("25%", text); + Assert.DoesNotContain("57%", text); + } + + /// + /// Verifies that the bar still shows the percentage that was read when the window has the cost it had at the reading, + /// whichever weight is in effect, because the limit is derived again under that weight. + /// + /// The cache read weight. + [Theory] + [InlineData(0.1)] + [InlineData(1)] + public void BuildUsageLimits_CostAtTheReading_ShowsThePercentageReadUnderAnyWeight(double weight) + { + var breakdown = new CostBreakdown(Input: 1m, Output: 2m, CacheRead: 10m, CacheWrite: 3m); + var calibration = new UsageCalibration(breakdown, 40); + + var text = RenderToText(SpectreRenderer.BuildUsageLimits( + SessionWith(breakdown), UnknownWeek, calibration, null, recalibrationAvailable: false, cacheReadWeight: (decimal)weight)); + + Assert.Contains("40%", text); + } + + /// + /// Verifies that the bar is left blank when the calibration gives no limit under the weight in effect. + /// + [Fact] + public void BuildUsageLimits_CalibrationWithoutLimit_LeavesTheBarBlank() + { + var onlyCacheReads = new CostBreakdown(0m, 0m, 5m, 0m); + + var text = RenderToText(SpectreRenderer.BuildUsageLimits( + SessionWith(onlyCacheReads), UnknownWeek, new UsageCalibration(onlyCacheReads, 40), null, recalibrationAvailable: false, cacheReadWeight: 0m)); + + Assert.DoesNotContain("40%", text); + } + + /// + /// Verifies that the notices are drawn in the view, in yellow text, between the content and the update notice. + /// + [Fact] + public void WithNotices_NoticesAndUpdateNotice_DrawsThemInOrderAfterTheContent() + { + var text = RenderToText(SpectreRenderer.WithNotices( + new Markup("CONTENT"), ["first notice", "second [notice]"], new Markup("UPDATE"), new Markup("FOOTER"))); + + var order = new[] { "CONTENT", "first notice", "second [notice]", "UPDATE", "FOOTER" }.Select(part => text.IndexOf(part, StringComparison.Ordinal)).ToArray(); + Assert.All(order, index => Assert.True(index >= 0)); + Assert.Equal(order.OrderBy(index => index), order); + } + + /// + /// Verifies that with nothing to add the content itself is returned. + /// + [Fact] + public void WithNotices_NothingToAdd_ReturnsTheContent() + { + var content = new Markup("CONTENT"); + + var result = SpectreRenderer.WithNotices(content, [], null, null); + + Assert.Same(content, result); + } + + /// + /// Verifies that the usage split names each kind of token with its share of the weighted cost, in any culture. + /// + [Fact] + public void FormatCostSplit_WindowWithUsage_ShowsTheShareOfEachKindInvariantly() + { + var original = CultureInfo.CurrentCulture; + var window = new UsageWindowSummary( + DateTimeOffset.Now, DateTimeOffset.Now.AddHours(1), 1, 100, [], WindowConfidence.Confirmed, + new CostBreakdown(Input: 1m, Output: 2m, CacheRead: 10m, CacheWrite: 3m)); + + string? text; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + text = SpectreRenderer.FormatCostSplit("Session", window, 0.1m); + } + finally + { + CultureInfo.CurrentCulture = original; + } + + Assert.Equal( + "[grey]Session usage split: cache read 14% · output 29% · cache write 43% · input 14% (the bar counts $7.00 of the $16.00 list-price cost, cache reads at 0.1 of list price)[/]", + text); + } + + /// + /// Verifies that a window without usage, or without a start, has no split line. + /// + [Fact] + public void FormatCostSplit_NoUsageOrNoWindow_ReturnsNull() + { + var empty = new UsageWindowSummary(DateTimeOffset.Now, null, 0, 0, [], WindowConfidence.Confirmed, default); + var unknown = new UsageWindowSummary(null, null, 0, 0, [], WindowConfidence.Unknown, default); + + Assert.Null(SpectreRenderer.FormatCostSplit("Session", empty, 0.1m)); + Assert.Null(SpectreRenderer.FormatCostSplit("Session", unknown, 0.1m)); + } + /// /// Verifies that cost is always shown in US dollars, whatever the current culture's currency is. /// diff --git a/tests/AIUsageMonitor.Cli.Tests/UsageCalibrationTests.cs b/tests/AIUsageMonitor.Cli.Tests/UsageCalibrationTests.cs new file mode 100644 index 0000000..d72cc3c --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/UsageCalibrationTests.cs @@ -0,0 +1,137 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Core.Models; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Tests for , the reading a usage limit is derived from. +/// +public class UsageCalibrationTests +{ + /// + /// A breakdown of what was counted when 40% was read off Claude: 8 dollars of cache reads and 8 of everything else. + /// + private static readonly CostBreakdown AtReading = new(Input: 1m, Output: 2m, CacheRead: 8m, CacheWrite: 5m); + + /// + /// Verifies that the limit is the weighted cost at the reading divided by the percentage read. + /// + [Fact] + public void DeriveLimit_FullWeight_IsTotalCostOverFraction() + { + var calibration = new UsageCalibration(AtReading, 40); + + var limit = calibration.DeriveLimit(1m); + + Assert.Equal(40m, limit); + } + + /// + /// Verifies that changing the weight re-derives the limit from the same reading, with no new percentage. + /// + [Fact] + public void DeriveLimit_LowerWeight_ShrinksTheLimitFromTheSameReading() + { + var calibration = new UsageCalibration(AtReading, 40); + + var limit = calibration.DeriveLimit(0.5m); + + Assert.Equal(30m, limit); + } + + /// + /// Verifies that the reading itself is the percentage under any weight, so the bar agrees with Claude at the moment it was read. + /// + /// The cache read weight. + [Theory] + [InlineData(0)] + [InlineData(0.1)] + [InlineData(1)] + public void PercentOf_CostAtTheReading_IsThePercentageRead(double weight) + { + var calibration = new UsageCalibration(AtReading, 40); + + var percent = calibration.PercentOf(AtReading, (decimal)weight); + + Assert.NotNull(percent); + Assert.Equal(40m, percent.Value, precision: 6); + } + + /// + /// Verifies that calibrations whose percentage cannot be divided by are dropped. + /// + /// The percentage held by the calibration. + [Theory] + [InlineData(0)] + [InlineData(-5)] + [InlineData(0.001)] + [InlineData(1e9)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void OrNullIfInvalid_UnusablePercent_ReturnsNull(double percent) + { + var result = UsageCalibration.OrNullIfInvalid(new UsageCalibration(AtReading, percent)); + + Assert.Null(result); + } + + /// + /// Verifies that a usable calibration, and no calibration at all, pass through unchanged. + /// + [Fact] + public void OrNullIfInvalid_UsableOrMissing_ReturnsItUnchanged() + { + var calibration = new UsageCalibration(AtReading, 40); + + Assert.Same(calibration, UsageCalibration.OrNullIfInvalid(calibration)); + Assert.Null(UsageCalibration.OrNullIfInvalid(null)); + } + + /// + /// Verifies that no limit is derived when nothing counted at the reading under the weight, instead of a one-cent limit. + /// + [Fact] + public void DeriveLimit_NothingCountedAtTheReading_ReturnsNull() + { + var onlyCacheReads = new UsageCalibration(new CostBreakdown(0m, 0m, 5m, 0m), 40); + + Assert.Null(onlyCacheReads.DeriveLimit(0m)); + Assert.Null(new UsageCalibration(default, 40).DeriveLimit(0.1m)); + Assert.Equal(12.5m, onlyCacheReads.DeriveLimit(1m)); + } + + /// + /// Verifies that there is no percentage to show for a cost while there is no limit to measure it against. + /// + [Fact] + public void PercentOf_NoLimit_ReturnsNull() + { + var onlyCacheReads = new UsageCalibration(new CostBreakdown(0m, 0m, 5m, 0m), 40); + + Assert.Null(onlyCacheReads.PercentOf(AtReading, 0m)); + } + + /// + /// Verifies that a small cost is not raised to a floor, so the percentage at the reading stays the one that was read. + /// + [Fact] + public void DeriveLimit_SmallCostAtTheReading_IsNotRaisedToAFloor() + { + var calibration = new UsageCalibration(new CostBreakdown(Input: 0.002m, Output: 0m, CacheRead: 0m, CacheWrite: 0m), 50); + + Assert.Equal(0.004m, calibration.DeriveLimit(0.1m)); + Assert.Equal(50m, calibration.PercentOf(calibration.Breakdown, 0.1m)!.Value, precision: 6); + } + + /// + /// Verifies that a calibration whose percentage cannot be divided by gives no limit instead of throwing. + /// + [Fact] + public void DeriveLimit_UnusablePercent_ReturnsNull() + { + Assert.Null(new UsageCalibration(AtReading, 0).DeriveLimit(1m)); + Assert.Null(new UsageCalibration(AtReading, double.NaN).DeriveLimit(1m)); + } +} diff --git a/tests/AIUsageMonitor.Core.Tests/Analytics/CostCalculatorTests.cs b/tests/AIUsageMonitor.Core.Tests/Analytics/CostCalculatorTests.cs index 1ce8a88..dfe226b 100644 --- a/tests/AIUsageMonitor.Core.Tests/Analytics/CostCalculatorTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Analytics/CostCalculatorTests.cs @@ -1,4 +1,5 @@ using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers.Claude.Models; using Xunit; @@ -8,6 +9,32 @@ public class CostCalculatorTests { private readonly CostCalculator _sut = new(); + /// + /// Verifies that every kind of token is priced on its own and that the parts add up to the plain estimate. + /// + [Fact] + public void EstimateCostBreakdown_AllKindsOfToken_PricesEachKindAndSumsToEstimateCost() + { + var breakdown = _sut.EstimateCostBreakdown("sonnet-5", 1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000); + + Assert.Equal(2m, breakdown.Input); + Assert.Equal(10m, breakdown.Output); + Assert.Equal(0.20m, breakdown.CacheRead); + Assert.Equal(2.5m + 4m, breakdown.CacheWrite); + Assert.Equal(_sut.EstimateCost("sonnet-5", 1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000), breakdown.Total); + } + + /// + /// Verifies that a model without a price has no cost in any kind. + /// + [Fact] + public void EstimateCostBreakdown_UnknownModel_IsAllZero() + { + var breakdown = _sut.EstimateCostBreakdown("no-such-model", 1_000, 1_000, 1_000, 1_000, 1_000); + + Assert.Equal(default, breakdown); + } + [Fact] public void EstimateCost_FromModelUsageEntry_EstimatesWhenRecordedCostIsZero() { diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs index 1c73ba5..8d99d7c 100644 --- a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs @@ -102,4 +102,26 @@ public void EstimateCost_TwoModels_SumsTheCalculatorPrices() Assert.Equal(costs.EstimateCost("sonnet-5", 100, 50, 1000, 200, 300) + costs.EstimateCost("claude-opus-5-5", 10, 20, 0, 0, 0), cost); Assert.Equal(costs.EstimateCost("sonnet-5", 100, 50, 1000, 200, 300), byModel["sonnet-5"].EstimateCost("sonnet-5", costs)); } + + /// + /// Verifies that the breakdown of several models adds up kind by kind and equals the total cost. + /// + [Fact] + public void EstimateCostBreakdown_TwoModels_SumsEachKindAndMatchesTheTotalCost() + { + var costs = new CostCalculator(); + var byModel = new Dictionary(); + ModelTokenTotals.Record(byModel, "sonnet-5", SplitUsage()); + ModelTokenTotals.Record(byModel, "claude-opus-5-5", new TokenUsage { InputTokens = 10, OutputTokens = 20 }); + + var breakdown = ModelTokenTotals.EstimateCostBreakdown(byModel, costs); + + var sonnet = costs.EstimateCostBreakdown("sonnet-5", 100, 50, 1000, 200, 300); + var opus = costs.EstimateCostBreakdown("claude-opus-5-5", 10, 20, 0, 0, 0); + Assert.Equal(sonnet.Input + opus.Input, breakdown.Input); + Assert.Equal(sonnet.Output + opus.Output, breakdown.Output); + Assert.Equal(sonnet.CacheRead, breakdown.CacheRead); + Assert.Equal(sonnet.CacheWrite, breakdown.CacheWrite); + Assert.Equal(ModelTokenTotals.EstimateCost(byModel, costs), breakdown.Total); + } } diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionBlockBuilderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionBlockBuilderTests.cs index f7e5461..56d6c64 100644 --- a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionBlockBuilderTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionBlockBuilderTests.cs @@ -59,6 +59,24 @@ public void BuildCurrentSessionWindow_NoResetTime_SplitsOnIdleGapAndKeepsOnlyLat Assert.Equal(expectedStart.AddHours(5), result.ResetsAt); } + /// + /// Verifies that the window reports its cost per kind of token and that the parts add up to the estimated cost. + /// + [Fact] + public void BuildCurrentSessionWindow_WithUsage_BreaksTheCostDownByKindOfToken() + { + var now = DateTimeOffset.Now; + File.WriteAllLines(_tempFile, [BuildLine(now.AddMinutes(-5), "req-1", inputTokens: 1_000_000, outputTokens: 1_000_000)]); + + var result = _sut.BuildCurrentSessionWindow([_tempFile], sessionResetAt: null); + + Assert.Equal(2m, result.CostBreakdown.Input); + Assert.Equal(10m, result.CostBreakdown.Output); + Assert.Equal(0m, result.CostBreakdown.CacheRead); + Assert.Equal(0m, result.CostBreakdown.CacheWrite); + Assert.Equal(result.EstimatedCost, result.CostBreakdown.Total); + } + [Fact] public void BuildCurrentSessionWindow_NoResetTimeAndBlockWindowElapsed_ReportsUnknownEvenWithRecentActivity() { diff --git a/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs b/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs index 080a319..bc86dee 100644 --- a/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs @@ -294,13 +294,13 @@ public StatsCache GetStatsCache(IProgress? progress = null) public UsageWindowSummary GetCurrentSessionWindow(DateTimeOffset? sessionResetAt, IProgress? progress = null) { LastSessionResetAt = sessionResetAt; - return new(null, null, 0, 0, [], 0m, WindowConfidence.Unknown); + return new(null, null, 0, 0, [], WindowConfidence.Unknown, default); } public UsageWindowSummary GetWeekWindow((DayOfWeek Day, TimeSpan TimeOfDay)? anchor, IProgress? progress = null) { LastWeekAnchor = anchor; - return new(null, null, 0, 0, [], 0m, WindowConfidence.Unknown); + return new(null, null, 0, 0, [], WindowConfidence.Unknown, default); } } } diff --git a/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs b/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs index a4d2442..267bfb9 100644 --- a/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs +++ b/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs @@ -263,13 +263,13 @@ public StatsCache GetStatsCache(IProgress? progress = null) /// public UsageWindowSummary GetCurrentSessionWindow(DateTimeOffset? sessionResetAt, IProgress? progress = null) { - return new(null, null, 0, 0, [], 0m, WindowConfidence.Unknown); + return new(null, null, 0, 0, [], WindowConfidence.Unknown, default); } /// public UsageWindowSummary GetWeekWindow((DayOfWeek Day, TimeSpan TimeOfDay)? anchor, IProgress? progress = null) { - return new(null, null, 0, 0, [], 0m, WindowConfidence.Unknown); + return new(null, null, 0, 0, [], WindowConfidence.Unknown, default); } } }