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
118 changes: 98 additions & 20 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,37 @@ pub(crate) fn upsert_provider_cache(
}
}

/// Drop cached snapshots for providers that are no longer enabled.
pub(crate) fn prune_provider_cache_to_enabled(
cache: &mut Vec<ProviderUsageSnapshot>,
enabled_ids: &[ProviderId],
) {
cache.retain(|snapshot| {
enabled_ids
.iter()
.any(|id| id.cli_name() == snapshot.provider_id)
});
}

/// Invalidate in-flight publish work and remove disabled providers from cache.
pub(crate) fn invalidate_provider_refresh_and_prune_disabled(
state: &tauri::State<'_, Mutex<AppState>>,
enabled_ids: &[ProviderId],
) -> Result<(), String> {
let mut guard = state.lock().map_err(|e| e.to_string())?;
guard.provider_refresh_generation = guard.provider_refresh_generation.wrapping_add(1);
prune_provider_cache_to_enabled(&mut guard.provider_cache, enabled_ids);
// Drop transient-failure counters for providers that left the enabled set.
guard
.transient_provider_failure_counts
.retain(|id, _| enabled_ids.contains(id));
Ok(())
}

pub(crate) fn is_current_provider_refresh_generation(guard: &AppState, generation: u64) -> bool {
guard.provider_refresh_generation == generation
}

/// Core refresh logic, usable from both the Tauri command and tray menu actions.
pub(crate) async fn do_refresh_providers(app: &tauri::AppHandle) -> Result<(), String> {
do_refresh_providers_with_policy(app, true).await
Expand All @@ -165,11 +196,18 @@ async fn do_refresh_providers_with_policy(
) -> Result<(), String> {
let state = app.state::<Mutex<AppState>>();

if !begin_provider_refresh(&state, force)? {
let Some(generation) = begin_provider_refresh(&state, force)? else {
return Ok(());
}
};

let inputs = ProviderRefreshInputs::load();
// Ensure cache only contains currently enabled providers for this generation.
if let Ok(mut guard) = state.lock()
&& is_current_provider_refresh_generation(&guard, generation)
{
prune_provider_cache_to_enabled(&mut guard.provider_cache, &inputs.enabled_ids);
}

events::emit_refresh_started(
app,
inputs
Expand All @@ -180,10 +218,10 @@ async fn do_refresh_providers_with_policy(
);
let enabled_count = inputs.enabled_ids.len();

let handles = spawn_provider_refreshes(app, &inputs);
let handles = spawn_provider_refreshes(app, &inputs, generation);
await_provider_refreshes(handles).await;

let error_count = finish_provider_refresh(&state)?;
let error_count = finish_provider_refresh(&state, generation)?;
update_tray_and_notifications(app, &state, &inputs.settings, &inputs.token_accounts)?;

events::emit_refresh_complete(app, enabled_count, error_count);
Expand All @@ -195,18 +233,20 @@ async fn do_refresh_providers_with_policy(
fn begin_provider_refresh(
state: &tauri::State<'_, Mutex<AppState>>,
force: bool,
) -> Result<bool, String> {
) -> Result<Option<u64>, String> {
let mut guard = state.lock().map_err(|e| e.to_string())?;
if guard.is_refreshing {
return Ok(false);
return Ok(None);
}
if provider_cache_can_skip_refresh(&guard, force) {
return Ok(false);
return Ok(None);
}

guard.provider_refresh_generation = guard.provider_refresh_generation.wrapping_add(1);
let generation = guard.provider_refresh_generation;
guard.is_refreshing = true;
guard.provider_refresh_started_at = Some(std::time::Instant::now());
Ok(true)
Ok(Some(generation))
}

fn provider_cache_can_skip_refresh(guard: &AppState, force: bool) -> bool {
Expand Down Expand Up @@ -247,6 +287,7 @@ impl ProviderRefreshInputs {
fn spawn_provider_refreshes(
app: &tauri::AppHandle,
inputs: &ProviderRefreshInputs,
generation: u64,
) -> Vec<tokio::task::JoinHandle<()>> {
let mut handles = Vec::with_capacity(inputs.enabled_ids.len());
let fetch_permits = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROVIDER_FETCHES));
Expand All @@ -267,25 +308,43 @@ fn spawn_provider_refreshes(
let Ok(_permit) = fetch_permits.acquire_owned().await else {
return;
};
refresh_provider(app_handle, id, ctx).await;
refresh_provider(app_handle, id, ctx, generation).await;
}));
}

handles
}

async fn refresh_provider(app: tauri::AppHandle, id: ProviderId, ctx: FetchContext) {
async fn refresh_provider(
app: tauri::AppHandle,
id: ProviderId,
ctx: FetchContext,
generation: u64,
) {
let snapshot = fetch_provider_snapshot(id, ctx).await;

let state = app.state::<Mutex<AppState>>();
let snapshot = if let Ok(mut guard) = state.lock() {
let snapshot = preserve_last_good_transient_failure(&mut guard, id, snapshot);
upsert_provider_cache(&mut guard.provider_cache, snapshot.clone());
snapshot
let published = if let Ok(mut guard) = state.lock() {
if !is_current_provider_refresh_generation(&guard, generation) {
tracing::debug!(
provider = id.cli_name(),
generation,
current = guard.provider_refresh_generation,
"dropping superseded provider refresh result"
);
None
} else {
let snapshot = preserve_last_good_transient_failure(&mut guard, id, snapshot);
upsert_provider_cache(&mut guard.provider_cache, snapshot.clone());
Some(snapshot)
}
} else {
snapshot
None
};
events::emit_provider_updated(&app, &snapshot);

if let Some(snapshot) = published {
events::emit_provider_updated(&app, &snapshot);
}
}

pub(super) fn preserve_last_good_transient_failure(
Expand Down Expand Up @@ -384,11 +443,30 @@ async fn await_provider_refreshes(handles: Vec<tokio::task::JoinHandle<()>>) {
}
}

fn finish_provider_refresh(state: &tauri::State<'_, Mutex<AppState>>) -> Result<usize, String> {
fn finish_provider_refresh(
state: &tauri::State<'_, Mutex<AppState>>,
generation: u64,
) -> Result<usize, String> {
let mut guard = state.lock().map_err(|e| e.to_string())?;
guard.is_refreshing = false;
guard.provider_cache_updated_at = Some(std::time::Instant::now());
guard.provider_refresh_started_at = None;
// Always release the refresh lock when this batch ends so a later
// invalidate (enablement change) cannot leave is_refreshing stuck.
if guard.is_refreshing
&& (is_current_provider_refresh_generation(&guard, generation)
|| guard.provider_refresh_started_at.is_some())
{
// Only clear the lock if we still own it or no newer refresh has begun
// (newer begin bumps generation AND sets is_refreshing under the same
// ownership — currently concurrent begin is blocked).
if is_current_provider_refresh_generation(&guard, generation) {
guard.is_refreshing = false;
guard.provider_refresh_started_at = None;
guard.provider_cache_updated_at = Some(std::time::Instant::now());
} else {
// Superseded: release lock without stamping a "fresh" cache time.
guard.is_refreshing = false;
guard.provider_refresh_started_at = None;
}
}
Ok(guard
.provider_cache
.iter()
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,15 @@ pub async fn update_settings(
crate::commands::clear_provider_local_usage_cache();
}

if refresh_provider_data {
// Invalidate any in-flight publish work and drop disabled providers from
// the live cache before a follow-up refresh starts.
let enabled_ids = settings.get_enabled_provider_ids();
let state = app.state::<Mutex<AppState>>();
let _ =
crate::commands::invalidate_provider_refresh_and_prune_disabled(&state, &enabled_ids);
}

crate::floatbar::after_settings_saved(&app, &float_bar_patch, &settings, notify_float_bar);
if rebuild_tray_menu {
crate::tray_bridge::rebuild_tray_menu(&app);
Expand All @@ -383,7 +392,9 @@ pub async fn update_settings(
}
if tray_promotion_changed {
let new_promoted = settings.promote_tray_icon;
if new_promoted || crate::tray_visibility::should_write_demotion(previous_promoted, new_promoted) {
if new_promoted
|| crate::tray_visibility::should_write_demotion(previous_promoted, new_promoted)
{
crate::tray_visibility::apply_promotion(new_promoted);
}
}
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use super::{
NamedRateWindowSnapshot, ProviderSummary, ProviderUsageSnapshot, provider_cookie_source_lookup,
provider_region_lookup, validate_external_url, validate_surface_target,
};
use crate::state::AppState;
use crate::surface::SurfaceMode;
use crate::surface_target::SurfaceTarget;
use codexbar::core::{
Expand Down Expand Up @@ -696,6 +697,35 @@ fn provider_cache_upsert_replaces_existing_provider() {
assert_eq!(cache[0].error.as_deref(), Some("new"));
}

#[test]
fn provider_cache_prunes_disabled_providers() {
let metadata = instantiate_provider(ProviderId::Codex).metadata().clone();
let result = ProviderFetchResult {
usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)),
cost: None,
wayfinder_usage: None,
source_label: "CLI".to_string(),
};
let codex = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result);
let claude_meta = instantiate_provider(ProviderId::Claude).metadata().clone();
let claude =
ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &claude_meta, &result);

let mut cache = vec![codex, claude];
super::prune_provider_cache_to_enabled(&mut cache, &[ProviderId::Codex]);

assert_eq!(cache.len(), 1);
assert_eq!(cache[0].provider_id, "codex");
}

#[test]
fn superseded_refresh_generation_is_not_current() {
let mut state = AppState::new();
state.provider_refresh_generation = 3;
assert!(super::is_current_provider_refresh_generation(&state, 3));
assert!(!super::is_current_provider_refresh_generation(&state, 2));
}

#[test]
fn hiding_codex_spark_rows_preserves_other_extra_usage() {
let metadata = instantiate_provider(ProviderId::Codex).metadata().clone();
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ pub struct AppState {
pub transient_provider_failure_counts: HashMap<ProviderId, u8>,
pub provider_cache_updated_at: Option<std::time::Instant>,
pub provider_refresh_started_at: Option<std::time::Instant>,
/// Monotonic generation for in-flight provider fetches. Bumped when a new
/// refresh starts or enablement changes so superseded results are ignored.
pub provider_refresh_generation: u64,
pub is_refreshing: bool,
pub update_state: UpdateState,
/// Full update metadata from the last successful check.
Expand Down Expand Up @@ -185,6 +188,7 @@ impl AppState {
transient_provider_failure_counts: HashMap::new(),
provider_cache_updated_at: None,
provider_refresh_started_at: None,
provider_refresh_generation: 0,
is_refreshing: false,
update_state: UpdateState::Idle,
update_info: None,
Expand Down
69 changes: 60 additions & 9 deletions rust/src/codex_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,29 +101,46 @@ fn add_codex_tokens_to_summary(
return None;
}

let model_key = if CostUsagePricing::is_codex_unattributed_model(model) {
CostUsagePricing::CODEX_UNATTRIBUTED_MODEL.to_string()
} else {
model.to_string()
};

// Unattributed usage is visible but never priced and must not trigger a
// models.dev catalog refresh (it is deliberately unpriced, not "unknown yet").
if CostUsagePricing::is_codex_unattributed_model(&model_key) {
summary.input_tokens += tokens.input;
summary.cached_tokens += tokens.cached;
summary.output_tokens += tokens.output;
summary.by_model.entry(model_key.clone()).or_insert(0.0);
add_tokens(
summary.by_model_tokens.entry(model_key).or_default(),
tokens,
);
return Some(0.0);
}

let uses_fallback_pricing =
CostUsagePricing::codex_cost_usd(model, tokens.input, tokens.cached, tokens.output)
CostUsagePricing::codex_cost_usd(&model_key, tokens.input, tokens.cached, tokens.output)
.is_none();
let cost = codex_cost_usd(model, tokens.input, tokens.cached, tokens.output);
let cost = codex_cost_usd(&model_key, tokens.input, tokens.cached, tokens.output);
if uses_fallback_pricing {
summary.unknown_models.insert(model.to_string());
summary.unknown_models.insert(model_key.clone());
}

summary.input_tokens += tokens.input;
summary.cached_tokens += tokens.cached;
summary.output_tokens += tokens.output;
*summary.by_model.entry(model.to_string()).or_insert(0.0) += cost;
*summary.by_model.entry(model_key.clone()).or_insert(0.0) += cost;

let speed_bucket = codex_speed_bucket(model);
let speed_bucket = codex_speed_bucket(&model_key);
*summary
.by_speed
.entry(speed_bucket.to_string())
.or_insert(0.0) += cost;
add_tokens(
summary
.by_model_tokens
.entry(model.to_string())
.or_default(),
summary.by_model_tokens.entry(model_key).or_default(),
tokens,
);
add_tokens(
Expand All @@ -142,6 +159,9 @@ fn codex_records_cost(records: &[CodexUsageRecord], range: &CostUsageDayRange) -
for record in records.iter().filter(|record| {
CostUsageDayRange::is_in_range(&record.day_key, &range.since_key, &range.until_key)
}) {
if CostUsagePricing::is_codex_unattributed_model(&record.model) {
continue;
}
let tokens = CodexTokenCounts::from_values(record.input, record.cached, record.output);
if !tokens.is_empty() {
total_cost += codex_cost_usd(&record.model, tokens.input, tokens.cached, tokens.output);
Expand All @@ -165,6 +185,9 @@ fn codex_speed_bucket(model: &str) -> &'static str {
}

fn codex_cost_usd(model: &str, input: u64, cached: u64, output: u64) -> f64 {
if CostUsagePricing::is_codex_unattributed_model(model) {
return 0.0;
}
if let Some(cost) = CostUsagePricing::codex_cost_usd(model, input, cached, output) {
return cost;
}
Expand Down Expand Up @@ -246,6 +269,34 @@ mod tests {
assert!((cost - 2.0).abs() < f64::EPSILON);
}

#[test]
fn model_less_codex_usage_is_visible_but_unpriced() {
let target = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
let range = CostUsageDayRange::new(target, target);
let records = vec![CodexUsageRecord {
day_key: "2026-05-31".to_string(),
model: CostUsagePricing::CODEX_UNATTRIBUTED_MODEL.to_string(),
input: 55_000_000,
cached: 0,
output: 0,
}];
let mut summary = CostSummary::default();

let (cost, has_tokens) = add_codex_records_to_summary(&mut summary, &records, &range);

assert!(has_tokens);
assert_eq!(cost, 0.0);
assert_eq!(summary.input_tokens, 55_000_000);
assert_eq!(
summary
.by_model
.get(CostUsagePricing::CODEX_UNATTRIBUTED_MODEL)
.copied(),
Some(0.0)
);
assert!(summary.unknown_models.is_empty());
}

#[test]
fn records_unknown_codex_model_while_using_fallback_cost() {
let target = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
Expand Down
Loading
Loading