From f292961c521d8337e29fc53118f8aa19310e9755 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:34:58 +0700 Subject: [PATCH] Port upstream 0.64.0: Antigravity cost follow-ups (lower bounds, withheld reads, --refresh) Partial Antigravity history never replaces a previously complete read, contradicted or budget-truncated reads are withheld, and trustworthy partial reads are published as lower bounds (tokensAreLowerBound / costIsLowerBound). Adds cost --provider antigravity --refresh for one bounded pricing refresh; routine reads never download pricing. Ports upstream steipete/CodexBar PR #3757 (7eebfd4f1) in part. --- .../src-tauri/src/commands/usage_spend.rs | 35 ++++- .../src/lib/usageSpendSharing.test.ts | 8 + .../src/lib/usageSpendSharing.ts | 5 +- .../surfaces/settings/tabs/UsageSpendTab.tsx | 2 + apps/desktop-tauri/src/types/bridge.ts | 3 + docs/CLI.md | 2 + rust/src/cli/cost.rs | 33 +++- rust/src/cli/serve/data.rs | 2 + rust/src/providers/antigravity/cost.rs | 62 +++++++- .../providers/antigravity/local_history.rs | 107 ++++++++++++- .../providers/antigravity/local_sessions.rs | 5 +- .../antigravity/local_sessions_reader.rs | 34 +++- .../src/providers/antigravity/local_sqlite.rs | 74 ++++++--- .../antigravity/local_sqlite_tests.rs | 64 ++++++++ rust/src/providers/muse/local_usage/mod.rs | 1 + rust/src/spend_contract.rs | 109 +------------ rust/src/spend_contract/local_history.rs | 147 ++++++++++++++++++ rust/src/spend_contract/tests.rs | 81 ++++++++++ 18 files changed, 623 insertions(+), 151 deletions(-) create mode 100644 rust/src/spend_contract/local_history.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 4114cccc53..5608e8c833 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -34,6 +34,11 @@ pub struct UsageSpendRow { pub thirty_day_estimate: Option, pub seven_day_tokens: Option, pub thirty_day_tokens: Option, + /// The token figure is a floor from an incomplete scan ("at least N"). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub seven_day_tokens_lower_bound: bool, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub thirty_day_tokens_lower_bound: bool, pub currency: String, pub source: String, /// Included in the shared Overview spend denominator. @@ -547,6 +552,8 @@ fn build_usage_spend_summary_in_containment_proof( thirty_day_estimate, seven_day_tokens: spend.seven_day_tokens, thirty_day_tokens: spend.thirty_day_tokens, + seven_day_tokens_lower_bound: seven.lower_bound, + thirty_day_tokens_lower_bound: thirty.lower_bound, currency, source: spend.source, included_in_overview: true, @@ -709,6 +716,7 @@ fn build_usage_spend_summary( .unwrap_or_else(|| provider_id.clone()); let mut local_cost_estimates = None; + let mut token_lower_bounds = (false, false); let spend = match provider_id.as_str() { "codex" => SpendValues { seven_day: codex_7_contract.known_cost_usd, @@ -803,6 +811,7 @@ fn build_usage_spend_summary( let thirty = codexbar::providers::antigravity::local_sessions::summarize(30); let spend = antigravity_spend_values(cached_spend(cached_snapshot), &seven, &thirty); + token_lower_bounds = (seven.lower_bound, thirty.lower_bound); local_cost_estimates = Some((seven.cost_estimate, thirty.cost_estimate)); spend } @@ -837,6 +846,8 @@ fn build_usage_spend_summary( thirty_day_estimate, seven_day_tokens: spend.seven_day_tokens, thirty_day_tokens: spend.thirty_day_tokens, + seven_day_tokens_lower_bound: token_lower_bounds.0, + thirty_day_tokens_lower_bound: token_lower_bounds.1, currency, source: spend.source, included_in_overview: include_in_shared_overview( @@ -922,10 +933,9 @@ fn antigravity_spend_values( spend.seven_day = seven.total_usd(); spend.thirty_day = thirty.total_usd(); - spend.seven_day_tokens = - (seven.coverage == LocalHistoryCoverage::Complete).then_some(seven.total_tokens); - spend.thirty_day_tokens = - (thirty.coverage == LocalHistoryCoverage::Complete).then_some(thirty.total_tokens); + // Exact for a complete scan, a floor for a lower bound, unknown otherwise. + spend.seven_day_tokens = seven.published_tokens(); + spend.thirty_day_tokens = thirty.published_tokens(); if spend.thirty_day.is_some() { spend.source = "local Antigravity history · API list-price estimate".to_string(); } else if thirty.cost_estimate.known_subtotal_usd.is_some() { @@ -1031,7 +1041,9 @@ mod cache_key_tests { unpriced, ..Default::default() }, + ..Default::default() }, + ..Default::default() } } @@ -1107,6 +1119,21 @@ mod cache_key_tests { assert!(spend.source.contains("known API list-price subtotal")); } + #[test] + fn antigravity_lower_bound_history_publishes_floors_not_exact_totals() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let mut seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); + seven.lower_bound = true; + let withheld = local_history(0, LocalHistoryCoverage::Partial, None, 0); + let spend = antigravity_spend_values(cached_spend(None), &seven, &withheld); + + assert_eq!(spend.seven_day, None); + assert_eq!(spend.seven_day_tokens, Some(100)); + assert_eq!(spend.thirty_day, None); + assert_eq!(spend.thirty_day_tokens, None); + } + #[test] fn antigravity_complete_empty_history_is_a_known_zero() { use codexbar::spend_contract::LocalHistoryCoverage; diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts index 39c95c9a3f..c1eac606b1 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts @@ -15,6 +15,14 @@ describe("usage spend sharing", () => { expect(formatSpendMetric(null, 1_500, "USD", "tokens", 0.0125)).toMatch(/^≥.* known/); }); + it("marks a lower-bound token count with a floor sign and leaves exact counts bare", () => { + const floor = formatSpendMetric(null, 1_500, "USD", "tokens", 0.0125, true); + expect(floor).toContain("≥1,500 tokens"); + expect(floor).toMatch(/^≥.* known/); + const exact = formatSpendMetric(null, 1_500, "USD", "tokens", null, false); + expect(exact).toBe("1,500 tokens"); + }); + it("renders a complete known-zero total instead of a subtotal", () => { const metric = formatSpendMetric(0, 0, "USD", "tokens", 9); expect(metric).not.toBe("—"); diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.ts index 4045eb9199..a792d0c53b 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.ts @@ -131,6 +131,7 @@ export function formatSpendMetric( currency: string, tokenLabel: string, knownSubtotal?: number | null, + tokensAreLowerBound = false, ): string { const parts: string[] = []; if (cost != null && Number.isFinite(cost)) { @@ -139,7 +140,7 @@ export function formatSpendMetric( parts.push(`≥${formatUsd(knownSubtotal, currency)} known`); } if (tokens != null && Number.isFinite(tokens)) { - parts.push(`${Math.max(0, tokens).toLocaleString()} ${tokenLabel}`); + parts.push(`${tokensAreLowerBound ? "≥" : ""}${Math.max(0, tokens).toLocaleString()} ${tokenLabel}`); } return parts.length > 0 ? parts.join(" · ") : "—"; } @@ -213,6 +214,7 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri row.currency, "tokens", row.sevenDayEstimate?.knownSubtotalUsd, + row.sevenDayTokensLowerBound, ), formatSpendMetric( row.thirtyDay, @@ -220,6 +222,7 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri row.currency, "tokens", row.thirtyDayEstimate?.knownSubtotalUsd, + row.thirtyDayTokensLowerBound, ), row.currency || "USD", row.source, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index e7ee282ece..bd3ec36c19 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -241,6 +241,7 @@ export default function UsageSpendTab(_props: TabProps) { row.currency, t("UsageSpendTokens"), row.sevenDayEstimate?.knownSubtotalUsd, + row.sevenDayTokensLowerBound, )} @@ -250,6 +251,7 @@ export default function UsageSpendTab(_props: TabProps) { row.currency, t("UsageSpendTokens"), row.thirtyDayEstimate?.knownSubtotalUsd, + row.thirtyDayTokensLowerBound, )} {row.currency || "USD"} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index fd8dd55109..0ce0c4abe7 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -387,6 +387,9 @@ export interface UsageSpendRow { thirtyDayEstimate?: LocalCostEstimate; sevenDayTokens?: number | null; thirtyDayTokens?: number | null; + /** The token figure is a floor from an incomplete scan ("at least N"). */ + sevenDayTokensLowerBound?: boolean; + thirtyDayTokensLowerBound?: boolean; currency: string; source: string; includedInOverview: boolean; diff --git a/docs/CLI.md b/docs/CLI.md index a9c25bcdcc..9fea657f54 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -68,6 +68,8 @@ codexbar cost -p codex --format json --summary-only --provider-native-only --day Claude/Codex costs come from local session logs. Antigravity exposes local **token history only** through `cost`; dollar cost remains unknown rather than becoming a false `$0`. Other providers may differ; do not assume upstream Cursor dashboard cost behavior unless implemented in this tree. +Antigravity history that stopped short is never shown as exact. A scan whose decoded rows are trustworthy reports `tokensAreLowerBound` / `costIsLowerBound` (text: "at least N"); a scan cut off by a hard limit, or one whose sources contradict each other, is withheld and publishes no total. A later partial read does not replace an earlier complete read of the same window and roots within one process. Routine reads never download pricing; `codexbar cost --provider antigravity --refresh` runs one bounded models.dev pricing refresh when a recorded model has no known public price, then rescans. + `--remote` adds one separate native Codex report fetched through non-interactive SSH; overlapping local and remote histories are never combined. `--summary-only` emits the versioned, path-free JSON contract used by the remote comparison and accepts only `--provider codex --format json`. Both modes reject session grouping and other provider selections. Codex local-history scans use a 60-second scanner-side debounce for ordinary disk-cache reads. This is separate from the desktop provider refresh setting. With Adaptive refresh off, **Manual** (`refresh_interval_secs = 0`) disables the recurring desktop refresh timer, but it does not forbid startup/stale-aware reads, explicit refreshes, or pending Codex catch-up scans. Low Power Mode floors recurring automatic refreshes to 30 minutes; explicit/manual work remains immediate. diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 819b7abb59..1ff9788bc8 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -62,6 +62,12 @@ pub struct CostArgs { /// Emit the versioned native Codex summary contract as JSON. #[arg(long = "summary-only")] pub summary_only: bool, + + /// Antigravity only: when a recorded model has no known public price, run + /// one bounded models.dev pricing refresh and rescan. Routine reads never + /// download pricing. + #[arg(long)] + pub refresh: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -169,9 +175,13 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { display_name: provider.display_name().to_string(), summary: CostSummary::default(), supported: true, - token_history: Some(crate::providers::antigravity::local_sessions::summarize( - args.days, - )), + token_history: Some( + crate::providers::antigravity::local_sessions::summarize_with_pricing_refresh( + args.days, + args.refresh, + ) + .await, + ), }); } ProviderId::Muse => { @@ -375,6 +385,13 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b fn print_local_token_history(history: &crate::spend_contract::LocalTokenHistorySummary, days: u32) { use crate::spend_contract::LocalHistoryCoverage; match history.coverage { + LocalHistoryCoverage::Partial if history.lower_bound => { + println!( + " Tokens: at least {} (lower bound; scan stopped short)", + format_number(history.total_tokens) + ); + println!(" Sessions: at least {}", history.session_count); + } LocalHistoryCoverage::Complete if history.total_tokens == 0 => { println!(" No token usage in the last {days} days (scan complete)"); } @@ -395,7 +412,7 @@ fn print_local_token_history(history: &crate::spend_contract::LocalTokenHistoryS history.cost_estimate.coverage.unpriced ); } else { - println!(" Known API list-price subtotal: ${cost:.2} (history incomplete)"); + println!(" Known API list-price subtotal: at least ${cost:.2} (history incomplete)"); } } else { println!(" Local token history; dollar costs unavailable"); @@ -639,6 +656,7 @@ mod tests { session_count: 2, coverage: LocalHistoryCoverage::Complete, cost_estimate: Default::default(), + ..Default::default() }, 30, ); @@ -654,6 +672,7 @@ mod tests { session_count: 1, coverage: LocalHistoryCoverage::Partial, cost_estimate: Default::default(), + ..Default::default() }, 30, ); @@ -677,7 +696,9 @@ mod tests { estimated: 1, ..Default::default() }, + ..Default::default() }, + ..Default::default() }, 30, ); @@ -704,7 +725,9 @@ mod tests { estimated: 1, ..Default::default() }, + ..Default::default() }, + ..Default::default() }, 30, ); @@ -754,7 +777,9 @@ mod tests { unpriced: 1, ..Default::default() }, + ..Default::default() }, + ..Default::default() }, 30, ); diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index 53e0f62d07..10af1b74d2 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -164,6 +164,7 @@ mod tests { session_count: 1, coverage: LocalHistoryCoverage::Complete, cost_estimate: Default::default(), + ..Default::default() }, 30, ); @@ -178,6 +179,7 @@ mod tests { session_count: 1, coverage: LocalHistoryCoverage::Partial, cost_estimate: Default::default(), + ..Default::default() }, 30, ); diff --git a/rust/src/providers/antigravity/cost.rs b/rust/src/providers/antigravity/cost.rs index f8eb38f24a..7254245ca4 100644 --- a/rust/src/providers/antigravity/cost.rs +++ b/rust/src/providers/antigravity/cost.rs @@ -1,4 +1,42 @@ +use std::collections::HashSet; + use crate::core::CostUsagePricing; +use crate::spend_contract::LocalCostEstimate; + +/// Antigravity records routing variants of a vendor model that bill at the base +/// model's public price. The alias stays provider-local so shared Claude pricing +/// keeps reporting unknown Claude variants as unpriced. +fn pricing_base_model(model: &str) -> Option<&str> { + let lowered = model.to_ascii_lowercase(); + ["-tiered", "-low", "-thinking"] + .iter() + .find(|suffix| lowered.ends_with(**suffix)) + .map(|suffix| &model[..model.len() - suffix.len()]) + .filter(|base| !base.is_empty()) +} + +/// Models worth fetching for an explicit pricing refresh: each unpriced model +/// and, for a routing variant, its base model. +fn refresh_model_ids(estimate: &LocalCostEstimate) -> HashSet { + estimate + .unpriced_models + .iter() + .flat_map(|model| { + [Some(model.as_str()), pricing_base_model(model)] + .into_iter() + .flatten() + .map(str::to_string) + }) + .collect() +} + +/// Run one bounded models.dev refresh for unpriced models. Returns true when a +/// rescan may now price them. Empty or fully priced history never downloads. +pub(super) async fn refresh_unpriced_model_pricing(estimate: &LocalCostEstimate) -> bool { + let model_ids = refresh_model_ids(estimate); + !model_ids.is_empty() + && crate::core::refresh_unknown_models_if_needed("anthropic", &model_ids).await +} pub(super) fn estimate_cost_usd( model: Option<&str>, @@ -16,18 +54,26 @@ pub(super) fn estimate_cost_usd( CostUsagePricing::claude_cost_usd(candidate, input, cache_read, cache_write, output) .filter(|cost| cost.is_finite() && *cost >= 0.0) }; - resolve(model).or_else(|| { - ["-tiered", "-low", "-thinking"] - .iter() - .find_map(|suffix| model.strip_suffix(suffix)) - .filter(|base| !base.is_empty()) - .and_then(resolve) - }) + resolve(model).or_else(|| pricing_base_model(model).and_then(resolve)) } #[cfg(test)] mod tests { - use super::estimate_cost_usd; + use super::{estimate_cost_usd, refresh_model_ids}; + use crate::spend_contract::LocalCostEstimate; + + #[test] + fn refresh_targets_unpriced_models_and_their_routing_base() { + let mut estimate = LocalCostEstimate::default(); + estimate.record_list_price(Some("claude-future-9-thinking"), None); + estimate.record_list_price(Some("claude-future-9-thinking"), None); + estimate.record_list_price(None, None); + let ids = refresh_model_ids(&estimate); + assert_eq!(ids.len(), 2); + assert!(ids.contains("claude-future-9-thinking")); + assert!(ids.contains("claude-future-9")); + assert!(refresh_model_ids(&LocalCostEstimate::default()).is_empty()); + } #[test] fn prices_known_models_and_provider_local_routing_variants() { diff --git a/rust/src/providers/antigravity/local_history.rs b/rust/src/providers/antigravity/local_history.rs index 464201feab..39de8dd482 100644 --- a/rust/src/providers/antigravity/local_history.rs +++ b/rust/src/providers/antigravity/local_history.rs @@ -1,10 +1,12 @@ use super::{local_sessions_reader as local_sessions, local_sqlite}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; use chrono::{DateTime, Utc}; -use crate::spend_contract::LocalTokenHistorySummary; +use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; fn clean_env_path(value: Option<&str>) -> Option { value @@ -51,6 +53,42 @@ pub(super) fn summarize_local_usage_from_explicit_roots( }) } +/// Last complete summary per scan scope (roots plus window). A later partial +/// or withheld read of the same scope must not replace previously complete +/// history; only a newer complete read does. +#[derive(Default)] +struct CompleteHistoryRetention { + complete: Mutex>, +} + +impl CompleteHistoryRetention { + fn resolve(&self, scope: &str, fresh: LocalTokenHistorySummary) -> LocalTokenHistorySummary { + let mut complete = self + .complete + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match fresh.coverage { + LocalHistoryCoverage::Complete => { + complete.insert(scope.to_string(), fresh.clone()); + fresh + } + LocalHistoryCoverage::Partial => complete.get(scope).cloned().unwrap_or(fresh), + LocalHistoryCoverage::Unavailable => fresh, + } + } +} + +static COMPLETE_HISTORY: LazyLock = LazyLock::new(Default::default); + +fn retention_scope(roots: &[PathBuf], tokscale_sessions: &Path, days: u32) -> String { + let roots = roots + .iter() + .map(|root| root.to_string_lossy()) + .collect::>() + .join("|"); + format!("{days}|{roots}|{}", tokscale_sessions.to_string_lossy()) +} + pub fn summarize_local_usage(days: u32) -> LocalTokenHistorySummary { let now = Utc::now(); let Some(home) = dirs::home_dir() else { @@ -58,7 +96,29 @@ pub fn summarize_local_usage(days: u32) -> LocalTokenHistorySummary { }; let roots = configured_database_roots(&home); let tokscale_sessions = local_sessions::configured_tokscale_sessions(&home); - summarize_local_usage_from_explicit_roots(&roots, &tokscale_sessions, now, days) + let fresh = summarize_local_usage_from_explicit_roots(&roots, &tokscale_sessions, now, days); + COMPLETE_HISTORY.resolve(&retention_scope(&roots, &tokscale_sessions, days), fresh) +} + +/// CLI `cost --refresh`: when a recorded model has no known public price, run +/// one bounded pricing refresh and rescan. Routine reads never download pricing. +pub async fn summarize_local_usage_with_pricing_refresh( + days: u32, + refresh: bool, +) -> LocalTokenHistorySummary { + let first = summarize_local_usage(days); + if !refresh || !super::cost::refresh_unpriced_model_pricing(&first.cost_estimate).await { + return first; + } + let rescanned = summarize_local_usage(days); + // A pricing download must not replace a complete scan with one that became + // partial in the meantime. + if first.coverage == LocalHistoryCoverage::Complete + && rescanned.coverage != LocalHistoryCoverage::Complete + { + return first; + } + rescanned } /// Count local Antigravity conversation artifacts for the quota provider's @@ -107,7 +167,6 @@ fn count_extension(root: &Path, extension: &str) -> usize { #[cfg(test)] mod tests { use super::*; - use crate::spend_contract::LocalHistoryCoverage; use chrono::TimeZone; use rusqlite::Connection; @@ -225,4 +284,46 @@ mod tests { fs::write(cache.join("one.jsonl"), b"{}\n").unwrap(); assert_eq!(offline_conversation_count_in(dir.path()), 1); } + + #[test] + fn partial_read_never_replaces_previously_complete_history() { + let retention = CompleteHistoryRetention::default(); + let complete = LocalTokenHistorySummary { + total_tokens: 500, + session_count: 3, + coverage: LocalHistoryCoverage::Complete, + ..Default::default() + }; + let partial = LocalTokenHistorySummary { + total_tokens: 20, + session_count: 1, + coverage: LocalHistoryCoverage::Partial, + lower_bound: true, + ..Default::default() + }; + + // Nothing complete yet: the partial read is reported as it is. + assert_eq!(retention.resolve("scope", partial.clone()), partial); + assert_eq!(retention.resolve("scope", complete.clone()), complete); + // A later partial or withheld read keeps the complete history. + assert_eq!(retention.resolve("scope", partial.clone()), complete); + assert_eq!( + retention.resolve("scope", LocalTokenHistorySummary::withheld()), + complete + ); + // Another scope is unaffected. + assert_eq!(retention.resolve("other", partial.clone()), partial); + // A newer complete read replaces the retained one. + let newer = LocalTokenHistorySummary { + total_tokens: 700, + ..complete.clone() + }; + assert_eq!(retention.resolve("scope", newer.clone()), newer); + assert_eq!(retention.resolve("scope", partial), newer); + // Source absence is reported honestly, not masked by old history. + assert_eq!( + retention.resolve("scope", LocalTokenHistorySummary::default()), + LocalTokenHistorySummary::default() + ); + } } diff --git a/rust/src/providers/antigravity/local_sessions.rs b/rust/src/providers/antigravity/local_sessions.rs index d2d1be7b74..ee3e0c8a2b 100644 --- a/rust/src/providers/antigravity/local_sessions.rs +++ b/rust/src/providers/antigravity/local_sessions.rs @@ -1,4 +1,7 @@ -pub use super::local_history::{offline_conversation_count, summarize_local_usage as summarize}; +pub use super::local_history::{ + offline_conversation_count, summarize_local_usage as summarize, + summarize_local_usage_with_pricing_refresh as summarize_with_pricing_refresh, +}; pub use crate::spend_contract::{ LocalHistoryCoverage, LocalTokenHistorySummary as LocalSessionSummary, }; diff --git a/rust/src/providers/antigravity/local_sessions_reader.rs b/rust/src/providers/antigravity/local_sessions_reader.rs index cbc3297cb4..6b494f628e 100644 --- a/rust/src/providers/antigravity/local_sessions_reader.rs +++ b/rust/src/providers/antigravity/local_sessions_reader.rs @@ -145,11 +145,15 @@ fn summarize_paths_with_budget( let mut sessions_with_usage = HashSet::new(); let mut seen_response_ids = HashSet::new(); let mut complete = !truncated; + // A hard discovery or byte budget stops the read; such a truncated scan is + // withheld instead of being published as a lower bound. + let mut exhausted = truncated || paths.len() > MAX_SESSION_FILES; let mut remaining_total_bytes = total_byte_budget; for path in paths.iter().take(MAX_SESSION_FILES) { if remaining_total_bytes == 0 { complete = false; + exhausted = true; break; } let file = match File::open(path) { @@ -181,6 +185,7 @@ fn summarize_paths_with_budget( } Ok(Some(BoundedJsonlLine::Truncated)) => { complete = false; + exhausted = true; break; } Ok(None) => break, @@ -266,13 +271,16 @@ fn summarize_paths_with_budget( continue; }; total_tokens = next_total_tokens; - cost_estimate.record_list_price(estimate_cost_usd( + cost_estimate.record_list_price( model.as_deref(), - input, - cache_read, - cache_write, - output.saturating_add(reasoning), - )); + estimate_cost_usd( + model.as_deref(), + input, + cache_read, + cache_write, + output.saturating_add(reasoning), + ), + ); path_had_usage = true; } if path_had_usage { @@ -280,6 +288,10 @@ fn summarize_paths_with_budget( } } + if exhausted { + return LocalTokenHistorySummary::withheld(); + } + LocalTokenHistorySummary { total_tokens, session_count: sessions_with_usage.len(), @@ -291,7 +303,9 @@ fn summarize_paths_with_budget( LocalHistoryCoverage::Partial }, cost_estimate, + lower_bound: false, } + .with_lower_bound_if_partial() } fn read_bounded_jsonl_line( @@ -536,6 +550,9 @@ mod tests { assert_eq!(summary.total_tokens, 15); assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); + // Decoded rows survive as a floor, never as an exact total. + assert!(summary.lower_bound); + assert_eq!(summary.published_tokens(), Some(15)); } #[test] @@ -555,8 +572,11 @@ mod tests { let summary = summarize_paths_with_budget(&[first_path, second_path], now, 7, false, first.len()); - assert_eq!(summary.total_tokens, 10); + // A hard budget stop is withheld: no total is published from it. + assert_eq!(summary.total_tokens, 0); assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); + assert!(!summary.lower_bound); + assert_eq!(summary.published_tokens(), None); } #[test] diff --git a/rust/src/providers/antigravity/local_sqlite.rs b/rust/src/providers/antigravity/local_sqlite.rs index bc71f794d7..cbe52ec19a 100644 --- a/rust/src/providers/antigravity/local_sqlite.rs +++ b/rust/src/providers/antigravity/local_sqlite.rs @@ -60,6 +60,9 @@ struct Budget { bytes: usize, schema_bytes: usize, deadline: Instant, + /// A hard scan limit (databases, rows, bytes, duration) stopped the read. + /// A truncated read is withheld rather than published as a lower bound. + exhausted: bool, } impl Budget { @@ -75,6 +78,7 @@ impl Budget { bytes: 0, schema_bytes: 0, deadline, + exhausted: false, } } @@ -151,11 +155,13 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL for path in &paths { if !budget.check() { complete = false; + budget.exhausted = true; break; } budget.databases += 1; if budget.databases > MAX_DATABASES { complete = false; + budget.exhausted = true; break; } match read_database(path, &mut budget) { @@ -178,6 +184,7 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL complete &= budget.check(); if budget.rows >= MAX_ROWS || budget.bytes >= MAX_TOTAL_BYTES { complete = false; + budget.exhausted = true; break; } } @@ -186,6 +193,13 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL return SQLiteScan::Unsupported; } + // A truncated read or two sources disagreeing about one request means the + // surviving rows may be wrong, not merely incomplete: withhold them. + if budget.exhausted || !budget.check() { + return SQLiteScan::Summary(LocalTokenHistorySummary::withheld()); + } + + let mut contradicted = false; let mut total_tokens = 0_u64; let mut cost_estimate = crate::spend_contract::LocalCostEstimate::default(); let mut sessions = HashSet::new(); @@ -212,6 +226,7 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL if let Some(prior) = rows.get(&row_key) { if prior != &event { complete = false; + contradicted = true; } continue; } @@ -226,6 +241,7 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL if let Some(prior) = responses.get(&response_key) { if prior.turn != event.turn { complete = false; + contradicted = true; } else { rows.insert(row_key, event); } @@ -254,15 +270,15 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL } } if event.total > 0 { + let inherited_model = event.turn.label.as_ref().and_then(|label| { + let key = (event.session.clone(), label.clone()); + (!conflicting_labels.contains(&key)) + .then(|| label_models.get(&key)) + .flatten() + .map(String::as_str) + }); + let model = event.turn.model.as_deref().or(inherited_model); let estimated_cost = event.turn.usage.as_ref().and_then(|usage| { - let inherited_model = event.turn.label.as_ref().and_then(|label| { - let key = (event.session.clone(), label.clone()); - (!conflicting_labels.contains(&key)) - .then(|| label_models.get(&key)) - .flatten() - .map(String::as_str) - }); - let model = event.turn.model.as_deref().or(inherited_model); let input = usage.system_prompt.checked_add(usage.new_input); let output = usage.output.checked_add(usage.reasoning); if let (Some(input), Some(output)) = (input, output) { @@ -271,21 +287,29 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL None } }); - cost_estimate.record_list_price(estimated_cost); + cost_estimate.record_list_price(model, estimated_cost); } sessions.insert(event.session); } - SQLiteScan::Summary(LocalTokenHistorySummary { - total_tokens, - session_count: sessions.len(), - coverage: if complete { - LocalHistoryCoverage::Complete - } else { - LocalHistoryCoverage::Partial - }, - cost_estimate, - }) + if contradicted { + return SQLiteScan::Summary(LocalTokenHistorySummary::withheld()); + } + + SQLiteScan::Summary( + LocalTokenHistorySummary { + total_tokens, + session_count: sessions.len(), + coverage: if complete { + LocalHistoryCoverage::Complete + } else { + LocalHistoryCoverage::Partial + }, + cost_estimate, + lower_bound: false, + } + .with_lower_bound_if_partial(), + ) } fn discover_databases(roots: &[PathBuf], budget: &mut Budget) -> (Vec, bool) { @@ -294,6 +318,7 @@ fn discover_databases(roots: &[PathBuf], budget: &mut Budget) -> (Vec, for root in roots { if !budget.check() { + budget.exhausted = true; return (paths, false); } let resolved_root = match fs::canonicalize(root) { @@ -320,10 +345,12 @@ fn discover_databases(roots: &[PathBuf], budget: &mut Budget) -> (Vec, }; for entry in entries { if !budget.check() { + budget.exhausted = true; return (paths, false); } budget.directory_entries += 1; if budget.directory_entries > MAX_DIRECTORY_ENTRIES { + budget.exhausted = true; return (paths, false); } let entry = match entry { @@ -363,6 +390,7 @@ fn discover_databases(roots: &[PathBuf], budget: &mut Budget) -> (Vec, } } if paths.len() >= MAX_DATABASES { + budget.exhausted = true; return (paths, false); } paths.push(resolved); @@ -509,12 +537,14 @@ fn read_generation_rows( while let Some(row) = query.next()? { if !budget.check() { complete = false; + budget.exhausted = true; break; } database_rows += 1; budget.rows += 1; if database_rows > MAX_ROWS_PER_DATABASE || budget.rows > MAX_ROWS { complete = false; + budget.exhausted = true; break; } @@ -534,6 +564,7 @@ fn read_generation_rows( Some(value) if value <= MAX_DATABASE_BYTES => value, _ => { complete = false; + budget.exhausted = true; break; } }; @@ -541,6 +572,7 @@ fn read_generation_rows( Some(value) if value <= MAX_TOTAL_BYTES => value, _ => { complete = false; + budget.exhausted = true; break; } }; @@ -639,11 +671,13 @@ fn read_step_timestamps( while let Some(row) = query.next()? { if !budget.check() { complete = false; + budget.exhausted = true; break; } budget.rows += 1; if budget.rows > MAX_ROWS { complete = false; + budget.exhausted = true; break; } @@ -663,6 +697,7 @@ fn read_step_timestamps( Some(value) if value <= MAX_DATABASE_BYTES => value, _ => { complete = false; + budget.exhausted = true; break; } }; @@ -670,6 +705,7 @@ fn read_step_timestamps( Some(value) if value <= MAX_TOTAL_BYTES => value, _ => { complete = false; + budget.exhausted = true; break; } }; diff --git a/rust/src/providers/antigravity/local_sqlite_tests.rs b/rust/src/providers/antigravity/local_sqlite_tests.rs index 1846bd5e26..73f7bd2591 100644 --- a/rust/src/providers/antigravity/local_sqlite_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_tests.rs @@ -278,3 +278,67 @@ fn schema_entry_budget_is_incomplete_not_foreign() { SchemaInspection::Incomplete ); } + +#[test] +fn undecodable_row_beside_valid_rows_yields_a_lower_bound() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join(".gemini/antigravity-cli/conversations"); + fs::create_dir_all(&root).unwrap(); + let timestamp = u64::try_from(Utc::now().timestamp()).unwrap(); + let conn = Connection::open(root.join("one.db")).unwrap(); + conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) + .unwrap(); + conn.execute( + "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", + [valid_turn_blob(100, timestamp)], + ) + .unwrap(); + conn.execute( + "INSERT INTO gen_metadata(idx, data) VALUES(2, ?1)", + params!["not-a-blob"], + ) + .unwrap(); + drop(conn); + + let SQLiteScan::Summary(summary) = + summarize(&database_roots(&dir.path().join(".gemini")), Utc::now(), 30) + else { + panic!("supported database should produce coverage"); + }; + + assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); + assert!(summary.total_tokens > 0); + assert!(summary.lower_bound); + assert_eq!(summary.published_tokens(), Some(summary.total_tokens)); + assert_eq!(summary.total_usd(), None); +} + +#[test] +fn contradicting_rows_for_one_index_are_withheld_not_published() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join(".gemini/antigravity-cli/conversations"); + fs::create_dir_all(&root).unwrap(); + let timestamp = u64::try_from(Utc::now().timestamp()).unwrap(); + let conn = Connection::open(root.join("one.db")).unwrap(); + conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) + .unwrap(); + for input in [100_u64, 900_u64] { + conn.execute( + "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", + [valid_turn_blob(input, timestamp)], + ) + .unwrap(); + } + drop(conn); + + let SQLiteScan::Summary(summary) = + summarize(&database_roots(&dir.path().join(".gemini")), Utc::now(), 30) + else { + panic!("supported database should produce coverage"); + }; + + assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); + assert_eq!(summary.total_tokens, 0); + assert!(!summary.lower_bound); + assert_eq!(summary.published_tokens(), None); +} diff --git a/rust/src/providers/muse/local_usage/mod.rs b/rust/src/providers/muse/local_usage/mod.rs index 24e84b2473..004079a624 100644 --- a/rust/src/providers/muse/local_usage/mod.rs +++ b/rust/src/providers/muse/local_usage/mod.rs @@ -65,6 +65,7 @@ impl From for crate::spend_contract::LocalTokenHistorySummary { session_count: report.session_count, coverage: report.coverage, cost_estimate: Default::default(), + ..Default::default() } } } diff --git a/rust/src/spend_contract.rs b/rust/src/spend_contract.rs index 722acec630..282919c7d3 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -1,8 +1,13 @@ //! Unified Usage & Spend accounting contract for upstream 0.53 parity. //! Accounting semantics live here so UI/CLI never infer unknown vs zero. +mod local_history; mod opencodex; +pub use local_history::{ + LocalCostEstimate, LocalHistoryCoverage, LocalTokenHistorySummary, local_token_history_json, +}; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::PathBuf; @@ -70,110 +75,6 @@ impl CostProvenance { } } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LocalHistoryCoverage { - Complete, - Partial, - #[default] - Unavailable, -} - -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LocalTokenHistorySummary { - pub total_tokens: u64, - pub session_count: usize, - pub coverage: LocalHistoryCoverage, - pub cost_estimate: LocalCostEstimate, -} - -impl LocalTokenHistorySummary { - /// Return a complete list-price total only when both the history scan and - /// pricing coverage are complete. A complete scan with no token usage is - /// a known zero even though there were no requests to price. - pub fn total_usd(&self) -> Option { - if self.coverage != LocalHistoryCoverage::Complete { - return None; - } - if self.total_tokens == 0 { - return Some(0.0); - } - self.cost_estimate.complete_total_usd() - } -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LocalCostEstimate { - /// Sum of requests whose models have public API list prices. This remains - /// a subtotal when one or more requests are unpriced. - pub known_subtotal_usd: Option, - pub coverage: CostCoverageCounts, -} - -impl LocalCostEstimate { - fn complete_total_usd(&self) -> Option { - if self.coverage.unpriced == 0 && self.coverage.unmetered == 0 { - self.known_subtotal_usd - } else { - None - } - } - - pub(crate) fn record_list_price(&mut self, cost: Option) { - let Some(cost) = cost.filter(|value| value.is_finite() && *value >= 0.0) else { - self.coverage.unpriced = self.coverage.unpriced.saturating_add(1); - return; - }; - let next = self.known_subtotal_usd.unwrap_or(0.0) + cost; - if next.is_finite() { - self.known_subtotal_usd = Some(next); - self.coverage.estimated = self.coverage.estimated.saturating_add(1); - } else { - self.coverage.unpriced = self.coverage.unpriced.saturating_add(1); - } - } -} - -pub fn local_token_history_json( - provider: &str, - history: &LocalTokenHistorySummary, - days: u32, -) -> serde_json::Value { - let complete = history.coverage == LocalHistoryCoverage::Complete; - let total_usd = history.total_usd(); - let known_subtotal_usd = history.cost_estimate.known_subtotal_usd; - let note = if total_usd.is_some() { - "Local token history estimated at public API list prices; not billed spend" - } else if known_subtotal_usd.is_some() && !complete { - "Known public API list-price subtotal; local history is incomplete" - } else if known_subtotal_usd.is_some() { - "Known public API list-price subtotal; some local requests are unpriced" - } else { - "Local token history; dollar costs unavailable" - }; - serde_json::json!({ - "provider": provider, - "supported": true, - "days_scanned": days, - "cost": { - "total_usd": total_usd, - "known_subtotal_usd": known_subtotal_usd, - "currency": total_usd.or(known_subtotal_usd).map(|_| "USD"), - "pricingCoverage": &history.cost_estimate.coverage, - }, - "daily": [], - "tokens": {"total": complete.then_some(history.total_tokens)}, - "sessions_count": complete.then_some(history.session_count), - "historyCoverage": match history.coverage { - LocalHistoryCoverage::Complete => "complete", - LocalHistoryCoverage::Partial => "partial", - LocalHistoryCoverage::Unavailable => "unavailable", - }, - "knownZero": complete && history.total_tokens == 0, - "note": note, - }) -} #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CostCoverageCounts { diff --git a/rust/src/spend_contract/local_history.rs b/rust/src/spend_contract/local_history.rs new file mode 100644 index 0000000000..745b45f676 --- /dev/null +++ b/rust/src/spend_contract/local_history.rs @@ -0,0 +1,147 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use super::CostCoverageCounts; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocalHistoryCoverage { + Complete, + Partial, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LocalTokenHistorySummary { + pub total_tokens: u64, + pub session_count: usize, + pub coverage: LocalHistoryCoverage, + pub cost_estimate: LocalCostEstimate, + /// The scan stopped short but every row it decoded is trustworthy, so the + /// totals are floors ("at least N"), never exact. Readers that cannot tell a + /// trustworthy subset from contradicted evidence leave this `false`. + pub lower_bound: bool, +} + +impl LocalTokenHistorySummary { + /// A read that stopped short in a way that makes its rows untrustworthy + /// (hard budget exhausted, or sources contradicting each other). The scan is + /// known to be incomplete, but no total is published from it. + pub(crate) fn withheld() -> Self { + Self { + coverage: LocalHistoryCoverage::Partial, + ..Self::default() + } + } + + /// Mark a partial scan that still decoded rows as a lower bound. + pub(crate) fn with_lower_bound_if_partial(mut self) -> Self { + self.lower_bound = self.coverage == LocalHistoryCoverage::Partial && self.total_tokens > 0; + self + } + + /// Token total safe to publish: exact for a complete scan, a floor for a + /// marked lower bound, unknown otherwise. + pub fn published_tokens(&self) -> Option { + (self.coverage == LocalHistoryCoverage::Complete || self.lower_bound) + .then_some(self.total_tokens) + } + + /// Return a complete list-price total only when both the history scan and + /// pricing coverage are complete. A complete scan with no token usage is + /// a known zero even though there were no requests to price. + pub fn total_usd(&self) -> Option { + if self.coverage != LocalHistoryCoverage::Complete { + return None; + } + if self.total_tokens == 0 { + return Some(0.0); + } + self.cost_estimate.complete_total_usd() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalCostEstimate { + /// Sum of requests whose models have public API list prices. This remains + /// a subtotal when one or more requests are unpriced. + pub known_subtotal_usd: Option, + pub coverage: CostCoverageCounts, + /// Recorded model names with no public price. Local-only input for an + /// explicit pricing refresh; never part of the wire contract. + #[serde(skip)] + pub unpriced_models: BTreeSet, +} + +impl LocalCostEstimate { + fn complete_total_usd(&self) -> Option { + if self.coverage.unpriced == 0 && self.coverage.unmetered == 0 { + self.known_subtotal_usd + } else { + None + } + } + + pub(crate) fn record_list_price(&mut self, model: Option<&str>, cost: Option) { + let Some(cost) = cost.filter(|value| value.is_finite() && *value >= 0.0) else { + self.coverage.unpriced = self.coverage.unpriced.saturating_add(1); + if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) { + self.unpriced_models.insert(model.to_string()); + } + return; + }; + let next = self.known_subtotal_usd.unwrap_or(0.0) + cost; + if next.is_finite() { + self.known_subtotal_usd = Some(next); + self.coverage.estimated = self.coverage.estimated.saturating_add(1); + } else { + self.coverage.unpriced = self.coverage.unpriced.saturating_add(1); + } + } +} + +pub fn local_token_history_json( + provider: &str, + history: &LocalTokenHistorySummary, + days: u32, +) -> serde_json::Value { + let complete = history.coverage == LocalHistoryCoverage::Complete; + let total_usd = history.total_usd(); + let known_subtotal_usd = history.cost_estimate.known_subtotal_usd; + let published_tokens = history.published_tokens(); + let note = if total_usd.is_some() { + "Local token history estimated at public API list prices; not billed spend" + } else if known_subtotal_usd.is_some() && !complete { + "Known public API list-price subtotal; local history is incomplete (lower bound)" + } else if known_subtotal_usd.is_some() { + "Known public API list-price subtotal; some local requests are unpriced" + } else { + "Local token history; dollar costs unavailable" + }; + serde_json::json!({ + "provider": provider, + "supported": true, + "days_scanned": days, + "cost": { + "total_usd": total_usd, + "known_subtotal_usd": known_subtotal_usd, + "currency": total_usd.or(known_subtotal_usd).map(|_| "USD"), + "pricingCoverage": &history.cost_estimate.coverage, + }, + "daily": [], + "tokens": {"total": published_tokens}, + "sessions_count": (complete || history.lower_bound).then_some(history.session_count), + "tokensAreLowerBound": history.lower_bound, + "costIsLowerBound": known_subtotal_usd.is_some() && total_usd.is_none(), + "historyCoverage": match history.coverage { + LocalHistoryCoverage::Complete => "complete", + LocalHistoryCoverage::Partial => "partial", + LocalHistoryCoverage::Unavailable => "unavailable", + }, + "knownZero": complete && history.total_tokens == 0, + "note": note, + }) +} diff --git a/rust/src/spend_contract/tests.rs b/rust/src/spend_contract/tests.rs index ac32e2e5eb..9595cfc8dc 100644 --- a/rust/src/spend_contract/tests.rs +++ b/rust/src/spend_contract/tests.rs @@ -8,12 +8,14 @@ fn local_history_total_requires_complete_scan_and_pricing() { estimated: 1, ..Default::default() }, + ..Default::default() }; let partial_history = LocalTokenHistorySummary { total_tokens: 100, session_count: 1, coverage: LocalHistoryCoverage::Partial, cost_estimate: priced.clone(), + ..Default::default() }; assert_eq!(partial_history.total_usd(), None); assert_eq!(partial_history.cost_estimate.known_subtotal_usd, Some(1.25)); @@ -29,7 +31,9 @@ fn local_history_total_requires_complete_scan_and_pricing() { unpriced: 1, ..Default::default() }, + ..Default::default() }, + ..Default::default() }; assert_eq!(mixed_pricing.total_usd(), None); assert_eq!(mixed_pricing.cost_estimate.known_subtotal_usd, Some(1.25)); @@ -39,6 +43,7 @@ fn local_history_total_requires_complete_scan_and_pricing() { session_count: 1, coverage: LocalHistoryCoverage::Complete, cost_estimate: priced, + ..Default::default() }; assert_eq!(complete.total_usd(), Some(1.25)); } @@ -571,3 +576,79 @@ fn coverage_for_models_counts_priced_rows_as_estimated() { assert_eq!(coverage.unpriced, 1); assert_eq!(coverage.total(), 3); } + +#[test] +fn lower_bound_history_publishes_floors_and_never_an_exact_total() { + let priced = LocalCostEstimate { + known_subtotal_usd: Some(0.5), + coverage: CostCoverageCounts { + estimated: 1, + ..Default::default() + }, + ..Default::default() + }; + let scanned = LocalTokenHistorySummary { + total_tokens: 900, + session_count: 2, + coverage: LocalHistoryCoverage::Partial, + cost_estimate: priced, + ..Default::default() + }; + let floor = scanned.with_lower_bound_if_partial(); + assert!(floor.lower_bound); + assert_eq!(floor.published_tokens(), Some(900)); + assert_eq!(floor.total_usd(), None); + + let payload = local_token_history_json("antigravity", &floor, 30); + assert_eq!(payload["tokens"]["total"], 900); + assert_eq!(payload["sessions_count"], 2); + assert_eq!(payload["tokensAreLowerBound"], true); + assert_eq!(payload["costIsLowerBound"], true); + assert!(payload["cost"]["total_usd"].is_null()); + assert_eq!(payload["cost"]["known_subtotal_usd"], 0.5); +} + +#[test] +fn withheld_history_is_partial_with_no_published_numbers() { + let withheld = LocalTokenHistorySummary::withheld(); + assert_eq!(withheld.coverage, LocalHistoryCoverage::Partial); + assert!(!withheld.lower_bound); + assert_eq!(withheld.published_tokens(), None); + assert!(!withheld.clone().with_lower_bound_if_partial().lower_bound); + + let payload = local_token_history_json("antigravity", &withheld, 30); + assert!(payload["tokens"]["total"].is_null()); + assert!(payload["sessions_count"].is_null()); + assert_eq!(payload["tokensAreLowerBound"], false); + assert_eq!(payload["costIsLowerBound"], false); + assert_eq!(payload["historyCoverage"], "partial"); +} + +#[test] +fn complete_history_is_never_marked_as_a_lower_bound() { + let complete = LocalTokenHistorySummary { + total_tokens: 10, + session_count: 1, + coverage: LocalHistoryCoverage::Complete, + ..Default::default() + } + .with_lower_bound_if_partial(); + assert!(!complete.lower_bound); + assert_eq!(complete.published_tokens(), Some(10)); +} + +#[test] +fn unpriced_model_names_are_recorded_but_not_serialized() { + let mut estimate = LocalCostEstimate::default(); + estimate.record_list_price(Some(" mystery-model "), None); + estimate.record_list_price(None, None); + estimate.record_list_price(Some("known"), Some(1.0)); + assert_eq!(estimate.coverage.unpriced, 2); + assert_eq!(estimate.coverage.estimated, 1); + assert_eq!( + estimate.unpriced_models.iter().collect::>(), + vec!["mystery-model"] + ); + let json = serde_json::to_value(&estimate).unwrap(); + assert!(json.get("unpricedModels").is_none()); +}