Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

## [Windows] Unreleased

- Charts: the Muse token chart, the OpenAI API daily history and the Groq console history keep every day of the 30-day window, so idle days show as zero bars instead of disappearing. Unpriced or incomplete days stay unknown (upstream #4689).
- Overview: disabling a provider, in Settings or from the tray menu, removes its card and account rows at once instead of after a relaunch. claude-swap and tcr Overview rows now fill from a background read during refresh (at most every 5 minutes), so you no longer need to open Settings first.
- **Claude**: built-in price rows choose one long-context tier per request from input plus all cache tokens and bill the whole request at that tier, as upstream does. Opus 4.6 and Sonnet 4.6 use long-context rates only for usage dated before the upstream cutoff.
- Tray and Settings: percentages always say "left" or "used" and follow **Show usage as used** (the tray tooltip, tray status rows, the Claude accounts menu and the claude-swap/tcr list). "1 minute ago" is singular in every language. Under **Hide personal information**, Claude and Grok accounts are numbered in list order, and the claude-swap list masks emails as soon as the setting changes.
Expand Down
10 changes: 3 additions & 7 deletions apps/desktop-tauri/src-tauri/src/commands/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,9 @@ fn build_provider_chart_data_with_cancel(
(Vec::new(), Vec::new(), true, None)
} else {
let report = muse_local_usage::scan(30, cancel.as_deref());
let tokens_history = report
.daily
.iter()
.map(|day| DailyTokenPoint {
date: day.day.clone(),
tokens: day.total_tokens,
})
let tokens_history = muse_local_usage::token_history_slots(&report, 30)
.into_iter()
.map(|(date, tokens)| DailyTokenPoint { date, tokens })
.collect();
let period = current_reporting_period();
let period_report = muse_period_report(period, &report, cancel.as_deref());
Expand Down
3 changes: 2 additions & 1 deletion rust/src/core/usage_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ pub struct OpenAiApiUsageHistory {
pub history_days: u32,
/// Project the Admin queries were scoped to, when one is configured.
pub project_id: Option<String>,
/// One bucket per UTC day that has data, ascending by `start_time`.
/// One bucket per UTC day in the window, ascending by `start_time`. A day with no
/// usage is a zero bucket once the fetch that produced the history has data.
pub daily: Vec<OpenAiApiDailyUsage>,
}

Expand Down
19 changes: 18 additions & 1 deletion rust/src/providers/groq/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::core::{
CostSnapshot, OpenAiApiDailyUsage, OpenAiApiModelUsage, OpenAiApiUsageHistory,
ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot,
};
use crate::providers::openaiapi::history::fill_missing_days;

pub(super) const SESSION_COOKIE: &str = "stytch_session";
pub(super) const JWT_COOKIE: &str = "stytch_session_jwt";
Expand Down Expand Up @@ -229,7 +230,12 @@ async fn fetch_usage(
let url = activity_url(&endpoints.api_base, &org, start, end)?;
let rows = fetch_activity(client, url, &jwt).await?;
let organization = organization_name(&rows);
let daily = daily_usage(&rows, &local);
let mut daily = daily_usage(&rows, &local);
// The activity fetch covered the whole window, so a local day with no rows is zero.
fill_missing_days(
&mut daily,
history_day_starts(&now.with_timezone(&local), HISTORY_DAYS),
);
Ok(build_result(daily, organization, now))
}

Expand Down Expand Up @@ -393,6 +399,17 @@ pub(super) fn activity_url(
Ok(url)
}

/// UTC-midnight stamps of the last `days` local calendar days, today included, newest
/// first. Groq stamps each local day at UTC midnight of its local date, so these
/// match the daily buckets.
pub(super) fn history_day_starts<Tz: TimeZone>(now: &DateTime<Tz>, days: u32) -> Vec<i64> {
let today = now.date_naive();
(0..days.clamp(1, 365))
.filter_map(|ago| today.checked_sub_days(Days::new(u64::from(ago))))
.map(|day| day.and_time(NaiveTime::MIN).and_utc().timestamp())
.collect()
}

/// Unix seconds bounding the last `days` local calendar days, today included.
pub(super) fn history_window<Tz: TimeZone>(now: &DateTime<Tz>, days: u32) -> (i64, i64) {
let tz = now.timezone();
Expand Down
39 changes: 39 additions & 0 deletions rust/src/providers/groq/console_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use super::console::{
parse_activity,
};
use crate::core::{FetchContext, ProviderError, ProviderFetchResult, SourceMode};
use crate::providers::openaiapi::history::fill_missing_days;

const ACTIVITY: &str = include_str!("fixtures/activity.json");
/// The pack's unsigned session JWT: org `org_parity_0008`.
Expand Down Expand Up @@ -263,6 +264,44 @@ fn days_are_bucketed_by_local_date_and_labelled_with_it() {
assert_eq!(days, vec![(oct9, oct10, 1), (oct10, oct10 + 86_400, 2)]);
}

#[test]
fn groq_history_keeps_every_local_day_of_the_window_in_a_non_utc_zone() {
// Tokyo is ahead of UTC, so its local dates and UTC days differ; each day is
// stamped at UTC midnight of its local date and the idle days stay as zero slots.
let tokyo = chrono::FixedOffset::east_opt(9 * 3600).unwrap();
let now_local = now().with_timezone(&tokyo);
let today = now_local.date_naive();
let used_day = today.checked_sub_days(chrono::Days::new(3)).unwrap();
let used = used_day.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp();
let mut daily = vec![crate::core::OpenAiApiDailyUsage {
start_time: used,
end_time: used + 86_400,
cost_usd: 1.0,
requests: 1,
input_tokens: 1,
cached_input_tokens: 0,
output_tokens: 0,
total_tokens: 1,
line_items: Vec::new(),
models: Vec::new(),
}];

fill_missing_days(&mut daily, console::history_day_starts(&now_local, 30));

assert_eq!(daily.len(), 30);
assert_eq!(daily.iter().filter(|day| day.total_tokens > 0).count(), 1);
assert_eq!(
daily[daily.len() - 1].start_time,
today.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp()
);
assert!(
daily
.windows(2)
.all(|pair| pair[0].start_time < pair[1].start_time)
);
assert!(daily.iter().all(|day| day.start_time % 86_400 == 0));
}

#[test]
fn history_window_covers_thirty_local_days_including_today() {
assert_eq!(
Expand Down
104 changes: 103 additions & 1 deletion rust/src/providers/muse/local_usage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use chrono::{TimeZone, Utc};
use chrono::{NaiveDate, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -156,6 +156,45 @@ fn cache_root() -> PathBuf {
.join("CodexBar")
}

/// Daily token totals for the last `days` calendar days, ascending. A complete
/// scan gets a zero slot for each day that has no events. A partial scan keeps
/// only the reported days, because an unscanned day is unknown, not idle. An
/// empty report stays empty, so the chart keeps its empty state when no local
/// history exists.
pub fn token_history_slots(report: &Report, days: u32) -> Vec<(String, u64)> {
let end = crate::cost_reporting_period::cost_bucket_zone().date(Utc::now());
fill_token_slots(&report.daily, report.is_complete(), end, days)
}

fn fill_token_slots(
daily: &[DailyUsage],
complete: bool,
end: NaiveDate,
days: u32,
) -> Vec<(String, u64)> {
if !complete {
return daily
.iter()
.map(|day| (day.day.clone(), day.total_tokens))
.collect();
}
if daily.is_empty() {
return Vec::new();
}
let tokens_by_day: HashMap<&str, u64> = daily
.iter()
.map(|day| (day.day.as_str(), day.total_tokens))
.collect();
(0..days)
.rev()
.map(|days_ago| {
let day = (end - chrono::Duration::days(i64::from(days_ago))).to_string();
let tokens = tokens_by_day.get(day.as_str()).copied().unwrap_or(0);
(day, tokens)
})
.collect()
}

fn day_window(days: u32) -> (String, String) {
let days = crate::cost_reporting_period::clamp_window_days(days);
let today = crate::cost_reporting_period::cost_bucket_zone().date(Utc::now());
Expand Down Expand Up @@ -509,3 +548,66 @@ pub fn scan_in(
coverage,
}
}

#[cfg(test)]
mod slot_tests {
use super::*;

fn day(day: &str, total_tokens: u64) -> DailyUsage {
DailyUsage {
day: day.to_string(),
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
total_tokens,
request_count: 1,
models: Vec::new(),
}
}

#[test]
fn token_slots_keep_every_calendar_day_with_zero_for_idle_days() {
let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let daily = [day("2026-09-05", 100), day("2026-09-08", 50)];

let slots = fill_token_slots(&daily, true, end, 7);

assert_eq!(
slots,
vec![
("2026-09-04".to_string(), 0),
("2026-09-05".to_string(), 100),
("2026-09-06".to_string(), 0),
("2026-09-07".to_string(), 0),
("2026-09-08".to_string(), 50),
("2026-09-09".to_string(), 0),
("2026-09-10".to_string(), 0),
]
);
}

#[test]
fn token_slots_stay_empty_without_local_history() {
let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();

assert!(fill_token_slots(&[], true, end, 30).is_empty());
}

#[test]
fn partial_scan_keeps_only_reported_days() {
let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let daily = [day("2026-09-05", 100), day("2026-09-08", 50)];

let slots = fill_token_slots(&daily, false, end, 7);

assert_eq!(
slots,
vec![
("2026-09-05".to_string(), 100),
("2026-09-08".to_string(), 50),
]
);
}
}
38 changes: 38 additions & 0 deletions rust/src/providers/openaiapi/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::core::{
const MAX_CARD_ENTRIES: usize = 10_000;
/// Largest integer a JavaScript number holds exactly; upstream rejects anything above it.
const MAX_SAFE_COUNT: u64 = 9_007_199_254_740_991;
const SECONDS_PER_DAY: i64 = 86_400;
const DEFAULT_LINE_ITEM: &str = "API";
const DEFAULT_MODEL: &str = "Responses and Chat Completions";

Expand Down Expand Up @@ -164,6 +165,43 @@ pub(super) fn daily_usage(
Ok(daily)
}

/// Add a zero bucket for every day in `day_starts` that has no bucket yet, then keep
/// the list ascending. Days match by UTC day, so a bucket that starts mid-day still
/// counts as its day. An empty `daily` stays empty: the chart keeps its empty state
/// when the API reported nothing at all, and only a fetch with data proves the
/// missing days are zero.
pub(crate) fn fill_missing_days(
daily: &mut Vec<OpenAiApiDailyUsage>,
day_starts: impl IntoIterator<Item = i64>,
) {
if daily.is_empty() {
return;
}
for start in day_starts {
let day = start.div_euclid(SECONDS_PER_DAY);
if daily
.iter()
.any(|existing| existing.start_time.div_euclid(SECONDS_PER_DAY) == day)
{
continue;
}
let start_time = day * SECONDS_PER_DAY;
daily.push(OpenAiApiDailyUsage {
start_time,
end_time: start_time + SECONDS_PER_DAY,
cost_usd: 0.0,
requests: 0,
input_tokens: 0,
cached_input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
line_items: Vec::new(),
models: Vec::new(),
});
}
daily.sort_by_key(|day| day.start_time);
}

/// The card payload for `daily`, or `None` when it breaks an upstream card bound (a day
/// that does not end after it starts, or more than [`MAX_CARD_ENTRIES`] breakdown rows).
/// The chart is auxiliary, so an out-of-bounds history is dropped and the spend summary
Expand Down
39 changes: 38 additions & 1 deletion rust/src/providers/openaiapi/history_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,34 @@ fn daily(costs: &[CostBucket], completions: &[CompletionsUsageBucket]) -> Vec<Op
history::daily_usage(costs, completions, now(), HISTORY_DAYS).unwrap()
}

#[test]
fn fill_missing_days_adds_zero_slots_for_idle_utc_days() {
// A mid-day bucket on day 1 keeps its own start; idle days 0 and 2 become zero slots.
let day_one = NOW - DAY;
let mut buckets = daily(&[cost_bucket_at(day_one + 3_600, 1.0, None)], &[]);
assert_eq!(buckets.len(), 1);

history::fill_missing_days(&mut buckets, [NOW - 2 * DAY, day_one, NOW]);

let starts: Vec<i64> = buckets.iter().map(|day| day.start_time).collect();
assert_eq!(starts, vec![NOW - 2 * DAY, day_one + 3_600, NOW]);
assert_eq!(buckets[0].end_time, NOW - DAY);
assert_eq!(buckets[0].cost_usd, 0.0);
assert_eq!(buckets[0].total_tokens, 0);
assert!(buckets[0].models.is_empty());
assert_eq!(buckets[1].cost_usd, 1.0);
assert_eq!(buckets[2].cost_usd, 0.0);
}

#[test]
fn fill_missing_days_keeps_an_empty_history_empty() {
let mut buckets: Vec<OpenAiApiDailyUsage> = Vec::new();

history::fill_missing_days(&mut buckets, [NOW - DAY, NOW]);

assert!(buckets.is_empty());
}

#[test]
fn upstream_fixture_buckets_into_two_days() {
let daily = daily(&upstream_costs(), &upstream_completions());
Expand Down Expand Up @@ -150,7 +178,16 @@ fn upstream_fixture_result_carries_history_and_summary() {
let history = result.open_ai_api_usage.as_ref().unwrap();
assert_eq!(history.history_days, 30);
assert_eq!(history.project_id.as_deref(), Some("proj_abc"));
assert_eq!(history.daily.len(), 2);
// Two fixture days carry usage; the other 28 days of the window are zero slots.
assert_eq!(history.daily.len(), 30);
assert_eq!(
history
.daily
.iter()
.filter(|day| day.total_tokens > 0)
.count(),
2
);
assert_eq!(result.cost.as_ref().unwrap().used, 18.75);
assert_eq!(result.cost.as_ref().unwrap().period, "Last 30 days");
}
Expand Down
11 changes: 9 additions & 2 deletions rust/src/providers/openaiapi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::time::Duration;

mod history;
pub(crate) mod history;
pub mod live;
pub mod org_limits;

Expand Down Expand Up @@ -479,7 +479,7 @@ fn result_from_admin_usage(
now: DateTime<Utc>,
project_id: Option<&str>,
) -> Result<ProviderFetchResult, ProviderError> {
let daily = history::daily_usage(costs, completions, now, HISTORY_DAYS)?;
let mut daily = history::daily_usage(costs, completions, now, HISTORY_DAYS)?;

let cost_total: f64 = daily.iter().map(|day| day.cost_usd).sum();
let request_total: u128 = daily.iter().map(|day| u128::from(day.requests)).sum();
Expand Down Expand Up @@ -550,6 +550,13 @@ fn result_from_admin_usage(
"USD",
format!("Last {HISTORY_DAYS} days"),
));
// Every UTC day of the window gets a slot: a day without a bucket is zero, since
// the fetch above returned data for the whole window (upstream keeps calendar days).
let today_start = now.timestamp().div_euclid(SECONDS_PER_DAY) * SECONDS_PER_DAY;
history::fill_missing_days(
&mut daily,
(0..i64::from(HISTORY_DAYS)).map(|ago| today_start - ago * SECONDS_PER_DAY),
);
if let Some(history) = history::usage_history(daily, HISTORY_DAYS, project_id) {
result = result.with_open_ai_api_usage(history);
}
Expand Down
Loading