Skip to content
Closed
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
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ pub async fn get_spend_contract(
}
let days = history_days.unwrap_or(30);
let include_import = include_open_codex.unwrap_or(false) && provider == "codex";
if include_import {
// Upstream 0.60.4: price imported OpenCodex rows from a fresh catalog.
codexbar::spend_contract::refresh_opencodex_pricing_if_needed().await;
}
tauri::async_runtime::spawn_blocking(move || {
let history_days = if days == 0 { 365 } else { days.clamp(1, 365) };
let scanner = CostScanner::new(history_days);
Expand Down
77 changes: 73 additions & 4 deletions apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ impl UsageSpendCoordinator {
self.current = None;
true
}

/// The cached summary for `key`, unless the caller forces a rebuild.
fn reusable(&self, key: &str, force_refresh: bool) -> Option<&CachedUsageSpendSummary> {
self.cache
.as_ref()
.filter(|existing| !force_refresh && existing.key == key)
}
}

static USAGE_SPEND_COORDINATOR: OnceLock<Mutex<UsageSpendCoordinator>> = OnceLock::new();
Expand Down Expand Up @@ -191,6 +198,7 @@ pub async fn get_usage_spend_summary(

let selected_days = history_days.unwrap_or(30);
let force_refresh = force_refresh.unwrap_or(false);
refresh_opencodex_pricing_before_build(&cached, selected_days, force_refresh).await;
let built = tauri::async_runtime::spawn_blocking(move || {
build_usage_spend_summary_cached(&cached, selected_days, force_refresh)
})
Expand All @@ -216,6 +224,27 @@ pub async fn get_usage_spend_summary(
Ok(built.summary)
}

/// Refreshes models.dev prices for the OpenCodex ledger when this request
/// will rebuild the summary (upstream 0.60.4 fresh-load refresh). A cached
/// summary read never starts network work.
async fn refresh_opencodex_pricing_before_build(
cached: &[ProviderUsageSnapshot],
selected_days: u32,
force_refresh: bool,
) {
let settings = codexbar::settings::Settings::load();
if !settings.open_codex_usage_logs_enabled {
return;
}
let key = usage_spend_cache_key(cached, selected_days, &settings);
let rebuilds = usage_spend_coordinator()
.lock()
.is_ok_and(|coordinator| coordinator.reusable(&key, force_refresh).is_none());
if rebuilds {
codexbar::spend_contract::refresh_opencodex_pricing_if_needed().await;
}
}

#[tauri::command]
pub fn write_usage_spend_export(path: String, payload: String) -> Result<(), String> {
const MAX_EXPORT_BYTES: usize = 8 * 1024 * 1024;
Expand All @@ -240,10 +269,7 @@ fn build_usage_spend_summary_cached(
let guard = usage_spend_coordinator()
.lock()
.map_err(|error| error.to_string())?;
if !force_refresh
&& let Some(existing) = guard.cache.as_ref()
&& existing.key == key
{
if let Some(existing) = guard.reusable(&key, force_refresh) {
return Ok(BuiltUsageSpendSummary {
key: existing.key.clone(),
summary: existing.summary.clone(),
Expand Down Expand Up @@ -820,6 +846,49 @@ mod cache_key_tests {
);
}

fn empty_summary() -> UsageSpendSummary {
UsageSpendSummary {
rows: Vec::new(),
contract: SpendContract {
provider_id: "codex".to_string(),
history_days: 30,
known_cost_usd: None,
known_zero: false,
provenance: codexbar::spend_contract::CostProvenance::Unknown,
price_coverage: Default::default(),
price_coverage_ratio: None,
history_coverage_established: false,
token_mix: Default::default(),
conversation_count: 0,
models: Vec::new(),
projects: Vec::new(),
conversations: Vec::new(),
daily: Vec::new(),
hourly_activity: Vec::new(),
project_source_status: None,
custom_pricing_active: false,
imports: Vec::new(),
},
reporting_day: "2026-10-01".to_string(),
dashboard_timezone: "UTC".to_string(),
}
}

#[test]
fn only_a_summary_rebuild_may_refresh_opencodex_pricing() {
let mut coordinator = UsageSpendCoordinator::default();
assert!(coordinator.reusable("day|30", false).is_none());
coordinator.cache = Some(CachedUsageSpendSummary {
key: "day|30".to_string(),
summary: empty_summary(),
refresh_owner: None,
});
// A cached read starts no network work; a forced or new key rebuilds.
assert!(coordinator.reusable("day|30", false).is_some());
assert!(coordinator.reusable("day|30", true).is_none());
assert!(coordinator.reusable("day|7", false).is_none());
}

#[test]
fn privacy_mode_is_part_of_usage_spend_cache_identity() {
let public = usage_spend_cache_key_with_privacy(&[], 30, false, false, false);
Expand Down
7 changes: 7 additions & 0 deletions rust/src/cli/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> {
print_text_output(&results, use_color, args.days, group_by);
}
OutputFormat::Json => {
// Upstream 0.60.4 refreshes OpenCodex prices before the JSON
// payload; on Windows the import lives in the Codex contract.
if results.iter().any(|result| result.provider == "codex")
&& Settings::load().open_codex_usage_logs_enabled
{
crate::spend_contract::refresh_opencodex_pricing_if_needed().await;
}
print_json_output(&results, args.pretty, args.days)?;
}
}
Expand Down
51 changes: 43 additions & 8 deletions rust/src/core/cost_pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,38 +747,73 @@ impl CostUsagePricing {
output_tokens: u64,
pricing_date: NaiveDate,
pricing_snapshot: Option<&models_dev_pricing::ModelsDevPricingSnapshot>,
) -> Option<f64> {
Self::codex_cost_usd_at_date_with_cache_write_and_pricing_snapshot(
model,
input_tokens,
cached_input_tokens,
0,
output_tokens,
pricing_date,
pricing_snapshot,
)
}

/// Codex cost on a historical usage day when the prompt also wrote cache
/// tokens. `input_tokens` is the inclusive prompt size: cache reads and
/// writes are subsets of it. The pre-cutoff GPT-5.6 Terra/Luna rates carry
/// their own 1.25x cache-write rate (upstream `codexHistoricalPricing`).
pub fn codex_cost_usd_at_date_with_cache_write_and_pricing_snapshot(
model: &str,
input_tokens: u64,
cached_input_tokens: u64,
cache_write_input_tokens: u64,
output_tokens: u64,
pricing_date: NaiveDate,
pricing_snapshot: Option<&models_dev_pricing::ModelsDevPricingSnapshot>,
) -> Option<f64> {
let key = Self::normalize_codex_model(model);
let cutoff = NaiveDate::from_ymd_opt(2026, 7, 30).expect("valid pricing cutoff");
if pricing_date < cutoff {
let long = input_tokens > codex_pricing::CODEX_LONG_CONTEXT_THRESHOLD;
// (input, cache read, cache write, output) per token.
let rates = match (key.as_str(), long) {
("gpt-5.6-terra", false) => Some((2.5e-6, 2.5e-7, 1.5e-5)),
("gpt-5.6-terra", true) => Some((5e-6, 5e-7, 2.25e-5)),
("gpt-5.6-luna", false) => Some((1e-6, 1e-7, 6e-6)),
("gpt-5.6-luna", true) => Some((2e-6, 2e-7, 9e-6)),
("gpt-5.6-terra", false) => Some((2.5e-6, 2.5e-7, 3.125e-6, 1.5e-5)),
("gpt-5.6-terra", true) => Some((5e-6, 5e-7, 6.25e-6, 2.25e-5)),
("gpt-5.6-luna", false) => Some((1e-6, 1e-7, 1.25e-6, 6e-6)),
("gpt-5.6-luna", true) => Some((2e-6, 2e-7, 2.5e-6, 9e-6)),
_ => None,
};
if let Some((input_rate, cache_rate, output_rate)) = rates {
return Some(codex_pricing::codex_cost_from_rates(
if let Some((input_rate, cache_read_rate, cache_write_rate, output_rate)) = rates {
return Some(codex_pricing::codex_cost_from_rates_with_cache_write(
input_tokens,
cached_input_tokens,
cache_write_input_tokens,
output_tokens,
input_rate,
cache_rate,
cache_read_rate,
cache_write_rate,
output_rate,
));
}
}
Self::codex_cost_usd_with_pricing_snapshot(
Self::codex_cost_usd_with_cache_write_and_pricing_snapshot(
model,
input_tokens,
cached_input_tokens,
cache_write_input_tokens,
output_tokens,
pricing_snapshot,
)
}

/// True when the bundled Codex table prices `model`. Windows resolves the
/// bundled rates before any models.dev entry, so such a model never needs
/// a catalog refresh.
pub fn has_bundled_codex_pricing(model: &str) -> bool {
CODEX_PRICING.contains_key(Self::normalize_codex_model(model).as_str())
}

pub fn codex_fast_cost_usd_at_date(
model: &str,
input: u64,
Expand Down
4 changes: 2 additions & 2 deletions rust/src/core/cost_pricing/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub(super) fn codex_cost_from_rates(
clippy::too_many_arguments,
reason = "Arguments mirror independent token classes and their corresponding pricing rates."
)]
fn codex_cost_from_rates_with_cache_write(
pub(super) fn codex_cost_from_rates_with_cache_write(
input_tokens: u64,
cached_input_tokens: u64,
cache_write_input_tokens: u64,
Expand Down Expand Up @@ -95,7 +95,7 @@ impl CostUsagePricing {
)
}

fn codex_cost_usd_with_cache_write_and_pricing_snapshot(
pub(super) fn codex_cost_usd_with_cache_write_and_pricing_snapshot(
model: &str,
input_tokens: u64,
cached_input_tokens: u64,
Expand Down
2 changes: 2 additions & 0 deletions rust/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod http;
mod http_proxy;
mod jsonl_scanner;
mod models_dev_pricing;
mod models_dev_targets;
mod openai_dashboard;
mod provider;
mod provider_factory;
Expand Down Expand Up @@ -43,6 +44,7 @@ pub use http::*;
pub use http_proxy::*;
pub use jsonl_scanner::*;
pub use models_dev_pricing::*;
pub use models_dev_targets::*;
pub use openai_dashboard::*;
pub use provider::*;
pub use provider_factory::instantiate as instantiate_provider;
Expand Down
Loading