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 ee7ae528c8..1bc4a2d4fe 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -32,6 +32,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. @@ -501,6 +506,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, @@ -591,10 +597,17 @@ fn build_usage_spend_summary( spend } "antigravity" => { - let seven = codexbar::providers::antigravity::local_sessions::summarize(7); - let thirty = codexbar::providers::antigravity::local_sessions::summarize(30); + use codexbar::providers::antigravity::local_sessions; + let seven = local_sessions::summarize(7); + let thirty = local_sessions::summarize(30); + // Upstream 0.64: the app refreshes unknown-model pricing in the + // background; a later read (provider refresh or Refresh) reprices. + if let Some(refresh) = local_sessions::background_pricing_refresh(&thirty) { + tauri::async_runtime::spawn(refresh); + } 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 } @@ -629,6 +642,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( @@ -714,10 +729,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() { @@ -822,7 +836,9 @@ mod cache_key_tests { unpriced, ..Default::default() }, + ..Default::default() }, + ..Default::default() } } @@ -898,6 +914,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 13d30f1fdf..6dcc9f7f2b 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts @@ -16,6 +16,19 @@ 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, undefined, true); + expect(floor).toContain(`≥${(1_500).toLocaleString()} tokens`); + expect(floor).toMatch(/^≥.* known/); + const exact = formatSpendMetric(null, 1_500, "USD", "tokens", null, undefined, false); + expect(exact).toBe(`${(1_500).toLocaleString()} tokens`); + }); + + it("keeps the localized subtotal label next to a lower-bound token count", () => { + const metric = formatSpendMetric(null, 500, "USD", "Token", 6, "{} (teilweise)", true); + expect(metric).toBe(`${formatUsd(6, "USD")} (teilweise) · ≥500 Token`); + }); + it("uses the caller's localized subtotal label", () => { const metric = formatSpendMetric(null, 500, "USD", "Token", 6, "{} (teilweise)"); expect(metric).toBe(`${formatUsd(6, "USD")} (teilweise) · 500 Token`); diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.ts index 353686f540..be61faf622 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.ts @@ -127,6 +127,7 @@ export function formatUsd(value: number | null | undefined, currency: string): s /** * Canonical "cost · tokens" cell for spend tables and share renders. * `knownSubtotalTemplate` is the localized subtotal label; `{}` is the amount. + * `tokensAreLowerBound` marks a token floor from an incomplete scan ("≥N"). */ export function formatSpendMetric( cost: number | null | undefined, @@ -135,6 +136,7 @@ export function formatSpendMetric( tokenLabel: string, knownSubtotal?: number | null, knownSubtotalTemplate = "≥{} known", + tokensAreLowerBound = false, ): string { const parts: string[] = []; if (cost != null && Number.isFinite(cost)) { @@ -143,7 +145,7 @@ export function formatSpendMetric( parts.push(knownSubtotalTemplate.replace("{}", formatUsd(knownSubtotal, currency))); } 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(" · ") : "—"; } @@ -217,6 +219,8 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri row.currency, "tokens", row.sevenDayEstimate?.knownSubtotalUsd, + undefined, + row.sevenDayTokensLowerBound, ), formatSpendMetric( row.thirtyDay, @@ -224,6 +228,8 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri row.currency, "tokens", row.thirtyDayEstimate?.knownSubtotalUsd, + undefined, + 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 a037d978e5..ad1f71ccf4 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -242,6 +242,7 @@ export default function UsageSpendTab(_props: TabProps) { t("UsageSpendTokens"), row.sevenDayEstimate?.knownSubtotalUsd, t("UsageSpendKnownSubtotal"), + row.sevenDayTokensLowerBound, )} @@ -252,6 +253,7 @@ export default function UsageSpendTab(_props: TabProps) { t("UsageSpendTokens"), row.thirtyDayEstimate?.knownSubtotalUsd, t("UsageSpendKnownSubtotal"), + 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..be8feb4856 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -66,7 +66,9 @@ codexbar cost -p codex --remote user@mac-host codexbar cost -p codex --format json --summary-only --provider-native-only --days 30 ``` -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. +Claude/Codex costs come from local session logs. Antigravity reads supported local token history; known models receive API list-price estimates from the bundled price table or the models.dev pricing catalog, and unknown models stay unpriced rather than becoming a false `$0`. These estimates are not Antigravity charges or credit deductions. 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. Reading the history never waits on the network. When a recorded model has no known public price, the desktop Usage & Spend view and `serve /cost` start one bounded models.dev pricing refresh in the background and a later read picks up the new prices; `codexbar cost --provider antigravity --refresh` waits for that refresh and rescans, while a plain `codexbar cost` starts no download. The request carries no account identity or usage data, and a failure leaves the affected models unpriced. Empty or absent history never starts a pricing download. `--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. diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 819b7abb59..42cd09fca1 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, wait + /// for one bounded models.dev pricing refresh and rescan. Without it the + /// CLI starts no pricing download. + #[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..569b901cb2 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -68,7 +68,13 @@ pub async fn cost_response(provider: Option<&str>) -> String { let mut results = Vec::new(); for provider_id in selection.as_list() { if provider_id == ProviderId::Antigravity { - let history = crate::providers::antigravity::local_sessions::summarize(30); + use crate::providers::antigravity::local_sessions; + let history = local_sessions::summarize(30); + // Upstream 0.64 `serve` refreshes unknown-model pricing in the + // background; a later `/cost` read picks up the new prices. + if let Some(refresh) = local_sessions::background_pricing_refresh(&history) { + tokio::spawn(refresh); + } results.push(crate::spend_contract::local_token_history_json( "antigravity", &history, @@ -164,6 +170,7 @@ mod tests { session_count: 1, coverage: LocalHistoryCoverage::Complete, cost_estimate: Default::default(), + ..Default::default() }, 30, ); @@ -178,6 +185,7 @@ mod tests { session_count: 1, coverage: LocalHistoryCoverage::Partial, cost_estimate: Default::default(), + ..Default::default() }, 30, ); diff --git a/rust/src/core/claude_routed_pricing.rs b/rust/src/core/claude_routed_pricing.rs index 1f6a750b69..3abbe09a0d 100644 --- a/rust/src/core/claude_routed_pricing.rs +++ b/rust/src/core/claude_routed_pricing.rs @@ -60,7 +60,7 @@ pub fn models_dev_target(model: &str, normalized: String) -> Option<(&'static st Some((provider, normalized)) } -fn models_dev_targets(model: &str, normalized: String) -> Vec<(&'static str, String)> { +pub(crate) fn models_dev_targets(model: &str, normalized: String) -> Vec<(&'static str, String)> { let Some(primary) = models_dev_target(model, normalized) else { return Vec::new(); }; diff --git a/rust/src/core/cost_pricing/claude.rs b/rust/src/core/cost_pricing/claude.rs index 32e4aa4e36..ef83d09172 100644 --- a/rust/src/core/cost_pricing/claude.rs +++ b/rust/src/core/cost_pricing/claude.rs @@ -174,6 +174,13 @@ impl CostUsagePricing { claude_routed_pricing::models_dev_target(model, Self::normalize_claude_model(model)) } + /// models.dev `(provider, model)` entries the routed Claude resolver tries + /// for `model`, in order. A pricing refresh for unpriced history targets + /// exactly the entries a rescan prices from. + pub(crate) fn claude_models_dev_pricing_targets(model: &str) -> Vec<(&'static str, String)> { + claude_routed_pricing::models_dev_targets(model, Self::normalize_claude_model(model)) + } + /// Calculate cost for Claude usage in USD pub fn claude_cost_usd( model: &str, diff --git a/rust/src/providers/antigravity/cost.rs b/rust/src/providers/antigravity/cost.rs index 60ac3bfe8d..f8f58fa521 100644 --- a/rust/src/providers/antigravity/cost.rs +++ b/rust/src/providers/antigravity/cost.rs @@ -1,9 +1,59 @@ +use std::collections::{BTreeMap, HashSet}; +use std::future::Future; + 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 unknown variants unpriced. const ROUTING_VARIANT_SUFFIXES: [&str; 3] = ["-tiered", "-low", "-thinking"]; +/// models.dev entries worth refreshing for unpriced history, grouped by +/// models.dev provider: each unpriced model and, for a routing variant, its +/// base model, routed the way a rescan prices them (Gemini models through +/// `google`, Claude models through `anthropic`, GPT models through `openai`). +fn refresh_targets(estimate: &LocalCostEstimate) -> BTreeMap<&'static str, HashSet> { + let mut targets = BTreeMap::<&'static str, HashSet>::new(); + for model in &estimate.unpriced_models { + for name in [Some(model.as_str()), pricing_base_model(model)] + .into_iter() + .flatten() + { + for (provider, model_id) in CostUsagePricing::claude_models_dev_pricing_targets(name) { + targets.entry(provider).or_default().insert(model_id); + } + } + } + targets +} + +/// One bounded models.dev refresh for the unpriced models of a scan, or None +/// when nothing recorded lacks a price: empty or fully priced history never +/// downloads. The refresh resolves to true when a rescan can now price at +/// least one of those models. +pub(super) fn unpriced_model_pricing_refresh( + estimate: &LocalCostEstimate, +) -> Option + Send + use<>> { + let targets = refresh_targets(estimate); + (!targets.is_empty()).then(|| refresh_pricing_targets(targets)) +} + +async fn refresh_pricing_targets(targets: BTreeMap<&'static str, HashSet>) -> bool { + for (provider, model_ids) in &targets { + if crate::core::refresh_unknown_models_if_needed(provider, model_ids).await { + return true; + } + } + // An earlier provider group may refresh the shared catalog without + // resolving its own models while a later group's models became priced. + let snapshot = crate::core::pricing_snapshot(); + targets.iter().any(|(provider, model_ids)| { + model_ids + .iter() + .any(|model_id| snapshot.lookup(provider, model_id).is_some()) + }) +} + pub(super) fn estimate_cost_usd( model: Option<&str>, input: u64, @@ -36,7 +86,59 @@ fn pricing_base_model(model: &str) -> Option<&str> { #[cfg(test)] mod tests { - use super::{estimate_cost_usd, pricing_base_model}; + use std::collections::HashSet; + + use super::{ + estimate_cost_usd, pricing_base_model, refresh_targets, unpriced_model_pricing_refresh, + }; + use crate::spend_contract::LocalCostEstimate; + + fn ids(values: &[&str]) -> HashSet { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn refresh_targets_route_unpriced_models_and_their_routing_base_by_vendor() { + 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(Some("gemini-future-pro-low"), None); + estimate.record_list_price(Some("gpt-future"), None); + estimate.record_list_price(None, None); + let targets = refresh_targets(&estimate); + assert_eq!( + targets.keys().copied().collect::>(), + ["anthropic", "google", "openai"] + ); + assert_eq!( + targets["anthropic"], + ids(&["claude-future-9-thinking", "claude-future-9"]) + ); + assert_eq!( + targets["google"], + ids(&["gemini-future-pro-low", "gemini-future-pro"]) + ); + assert_eq!(targets["openai"], ids(&["gpt-future"])); + assert!(refresh_targets(&LocalCostEstimate::default()).is_empty()); + } + + #[test] + fn pricing_refresh_is_offered_only_for_named_unpriced_models() { + assert!(unpriced_model_pricing_refresh(&LocalCostEstimate::default()).is_none()); + + let mut priced = LocalCostEstimate::default(); + priced.record_list_price(Some("claude-sonnet-4-6"), Some(0.25)); + assert!(unpriced_model_pricing_refresh(&priced).is_none()); + + let mut unnamed = priced.clone(); + unnamed.record_list_price(None, None); + assert_eq!(unnamed.coverage.unpriced, 1); + assert!(unpriced_model_pricing_refresh(&unnamed).is_none()); + + let mut unpriced = priced; + unpriced.record_list_price(Some("gemini-future-pro"), None); + assert!(unpriced_model_pricing_refresh(&unpriced).is_some()); + } #[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 00519d93ca..5581c65618 100644 --- a/rust/src/providers/antigravity/local_history.rs +++ b/rust/src/providers/antigravity/local_history.rs @@ -1,10 +1,13 @@ use super::{local_sessions_reader as local_sessions, local_sqlite}; +use std::collections::HashMap; use std::fs; +use std::future::Future; 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 @@ -40,6 +43,42 @@ fn summarize_local_usage_from( } } +/// 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 { @@ -47,9 +86,61 @@ 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(&roots, now, days, || { + let fresh = summarize_local_usage_from(&roots, now, days, || { local_sessions::summarize_jsonl_at(&tokscale_sessions, now, days) - }) + }); + COMPLETE_HISTORY.resolve(&retention_scope(&roots, &tokscale_sessions, days), fresh) +} + +/// Pricing refresh for a routine read, which never waits on the network +/// (upstream 0.64 app and `serve`). When the scanned history records a model +/// with no known public price, returns one bounded models.dev refresh for the +/// caller to spawn; the next read picks up the new prices. Empty or fully +/// priced history returns None and never starts a download. +pub fn background_pricing_refresh( + history: &LocalTokenHistorySummary, +) -> Option + Send + use<>> { + super::cost::unpriced_model_pricing_refresh(&history.cost_estimate) +} + +/// CLI `cost`: with `refresh`, a scan with unpriced models waits for one +/// bounded models.dev refresh and rescans (upstream `--refresh`). Without it +/// the CLI starts no download, because the process exits before a background +/// refresh could finish. +pub async fn summarize_local_usage_with_pricing_refresh( + days: u32, + refresh: bool, +) -> LocalTokenHistorySummary { + let first = summarize_local_usage(days); + if !refresh { + return first; + } + let pricing_refresh = super::cost::unpriced_model_pricing_refresh(&first.cost_estimate); + rescan_after_pricing_refresh(first, pricing_refresh, || summarize_local_usage(days)).await +} + +async fn rescan_after_pricing_refresh( + first: LocalTokenHistorySummary, + pricing_refresh: Option>, + rescan: impl FnOnce() -> LocalTokenHistorySummary, +) -> LocalTokenHistorySummary { + let Some(pricing_refresh) = pricing_refresh else { + return first; + }; + // Offline or still unknown: keep the unpriced usage as scanned. + if !pricing_refresh.await { + return first; + } + let rescanned = rescan(); + // A pricing download must not replace a scan with vanished history, or a + // complete scan with one that became partial in the meantime. + if rescanned.coverage == LocalHistoryCoverage::Unavailable + || (first.coverage == LocalHistoryCoverage::Complete + && rescanned.coverage != LocalHistoryCoverage::Complete) + { + return first; + } + rescanned } /// Count local Antigravity conversation artifacts for the quota provider's @@ -98,7 +189,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; @@ -216,4 +306,106 @@ 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() + ); + } + + /// Upstream 0.64 `AntigravityPricingRefreshTests`: an explicit refresh + /// reprices through a rescan, offline pricing keeps the unpriced usage, + /// and a rescan cannot replace a complete first scan with a partial one. + #[tokio::test] + async fn explicit_refresh_rescans_only_when_pricing_became_available() { + use crate::spend_contract::LocalCostEstimate; + let unpriced = LocalTokenHistorySummary { + total_tokens: 396, + session_count: 1, + coverage: LocalHistoryCoverage::Complete, + ..Default::default() + }; + let repriced = LocalTokenHistorySummary { + cost_estimate: LocalCostEstimate { + known_subtotal_usd: Some(0.5), + ..Default::default() + }, + ..unpriced.clone() + }; + let partial = LocalTokenHistorySummary { + total_tokens: 198, + session_count: 1, + coverage: LocalHistoryCoverage::Partial, + lower_bound: true, + ..Default::default() + }; + let no_rescan = || -> LocalTokenHistorySummary { panic!("must not rescan") }; + + // Nothing unpriced: no refresh and no rescan. + let kept = rescan_after_pricing_refresh( + unpriced.clone(), + None::>, + no_rescan, + ) + .await; + assert_eq!(kept, unpriced); + // Offline pricing keeps the unpriced usage without a rescan. + let offline = + rescan_after_pricing_refresh(unpriced.clone(), Some(async { false }), no_rescan).await; + assert_eq!(offline, unpriced); + // Pricing became available: the rescan's prices are published. + let refreshed = + rescan_after_pricing_refresh(unpriced.clone(), Some(async { true }), || { + repriced.clone() + }) + .await; + assert_eq!(refreshed, repriced); + // A rescan that became partial or lost its source keeps the complete scan. + for rescanned in [partial.clone(), LocalTokenHistorySummary::default()] { + let kept = + rescan_after_pricing_refresh(unpriced.clone(), Some(async { true }), || rescanned) + .await; + assert_eq!(kept, unpriced); + } + // A partial first scan takes a complete rescan. + let upgraded = + rescan_after_pricing_refresh(partial, Some(async { true }), || repriced.clone()).await; + assert_eq!(upgraded, repriced); + } } diff --git a/rust/src/providers/antigravity/local_sessions.rs b/rust/src/providers/antigravity/local_sessions.rs index 4efb9af063..464547b3b2 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::{ + background_pricing_refresh, 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 afdded2d5e..a8a93a1103 100644 --- a/rust/src/providers/antigravity/local_sqlite_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_tests.rs @@ -279,6 +279,70 @@ fn schema_entry_budget_is_incomplete_not_foreign() { ); } +#[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); +} + #[test] fn list_price_uses_prompt_plus_input_and_output_plus_reasoning() { // Upstream AntigravityLocalReaderTests: a known model gets a list-price estimate, a routing @@ -338,4 +402,66 @@ fn list_price_uses_prompt_plus_input_and_output_plus_reasoning() { summary.cost_estimate.known_subtotal_usd, Some(per_request * 2.0) ); + assert_eq!( + summary + .cost_estimate + .unpriced_models + .iter() + .collect::>(), + ["fixture-unpriced"] + ); +} + +/// Upstream 0.64 `AntigravityPricingRefreshTests` ("routine local reads do not +/// wait for pricing", "empty history starts no download"): a routine read +/// returns its scan as is and offers a background pricing refresh only when +/// the history records a model with no known public price. +#[test] +fn routine_read_offers_background_pricing_only_for_unpriced_history() { + use crate::providers::antigravity::local_sessions::background_pricing_refresh; + + let now = Utc::now(); + let timestamp = u64::try_from(now.timestamp()).unwrap(); + // `None`: no database at all; `Some(None)`: a supported database without + // rows; `Some(Some(model))`: one recorded request for `model`. + let routine_read = |database: Option>| { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join(".gemini/antigravity-cli/conversations"); + fs::create_dir_all(&root).unwrap(); + if let Some(model) = database { + let conn = Connection::open(root.join("one.db")).unwrap(); + conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) + .unwrap(); + if let Some(model) = model { + conn.execute( + "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", + [valid_turn_blob_with_model(100, timestamp, Some(model))], + ) + .unwrap(); + } + } + match summarize(&[root], now, 30) { + SQLiteScan::Summary(summary) => summary, + SQLiteScan::NoDatabases | SQLiteScan::Unsupported => { + LocalTokenHistorySummary::default() + } + } + }; + + for empty in [routine_read(None), routine_read(Some(None))] { + assert_eq!(empty.total_tokens, 0); + assert!(empty.cost_estimate.unpriced_models.is_empty()); + assert!(background_pricing_refresh(&empty).is_none()); + } + + let known = routine_read(Some(Some("claude-sonnet-4-6"))); + assert_eq!(known.total_tokens, 198); + assert!(known.total_usd().is_some()); + assert!(background_pricing_refresh(&known).is_none()); + + let unknown = routine_read(Some(Some("gemini-fixture-unpriced"))); + assert_eq!(unknown.coverage, LocalHistoryCoverage::Complete); + assert_eq!(unknown.total_tokens, 198); + assert_eq!(unknown.total_usd(), None); + assert!(background_pricing_refresh(&unknown).is_some()); } 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..4b0d896e38 --- /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 a + /// 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()); +}