diff --git a/docs/CLI.md b/docs/CLI.md index be8feb4856..5d7d06e3ad 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -66,7 +66,7 @@ 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 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. +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`. Antigravity's Gemini 3.1 Pro aliases (`gemini-pro-default`, `gemini-pro-agent`, and the `gemini-3.1-pro` effort tiers) price as `gemini-3.1-pro-preview`, the only catalogued Gemini 3.1 Pro row; the alias is provider-local and recorded model names stay unchanged. 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. diff --git a/rust/src/providers/antigravity/cost.rs b/rust/src/providers/antigravity/cost.rs index f8f58fa521..0a69995544 100644 --- a/rust/src/providers/antigravity/cost.rs +++ b/rust/src/providers/antigravity/cost.rs @@ -8,10 +8,22 @@ use crate::spend_contract::LocalCostEstimate; /// price. The alias stays provider-local so shared Claude pricing keeps unknown variants unpriced. const ROUTING_VARIANT_SUFFIXES: [&str; 3] = ["-tiered", "-low", "-thinking"]; +/// Gemini 3.1 Pro is catalogued only as `gemini-3.1-pro-preview`. Antigravity records it under +/// product aliases and effort tiers that name no catalogued model (upstream 0.70.0 #4094; +/// ccusage's Antigravity adapter maps the same IDs). Keys match case-insensitively. +const PRICING_MODEL_ALIASES: [(&str, &str); 5] = [ + ("gemini-pro-default", "gemini-3.1-pro-preview"), + ("gemini-pro-agent", "gemini-3.1-pro-preview"), + ("gemini-3.1-pro", "gemini-3.1-pro-preview"), + ("gemini-3.1-pro-high", "gemini-3.1-pro-preview"), + ("gemini-3.1-pro-low", "gemini-3.1-pro-preview"), +]; + /// 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`). +/// models.dev provider: each unpriced model and, for a routing variant or a +/// product alias, its pricing 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 { @@ -60,6 +72,27 @@ pub(super) fn estimate_cost_usd( cache_read: u64, cache_write: u64, output: u64, +) -> Option { + estimate_cost_usd_with( + model, + input, + cache_read, + cache_write, + output, + CostUsagePricing::claude_cost_usd, + ) +} + +/// Prices the exact recorded model first, so an explicitly catalogued variant keeps its own +/// price, then the pricing base model of a known routing variant or product alias. The recorded +/// model name itself is never rewritten; only the price lookup resolves. +fn estimate_cost_usd_with( + model: Option<&str>, + input: u64, + cache_read: u64, + cache_write: u64, + output: u64, + price: impl Fn(&str, i32, i32, i32, i32) -> Option, ) -> Option { let model = model.map(str::trim).filter(|value| !value.is_empty())?; let input = i32::try_from(input).ok()?; @@ -67,21 +100,35 @@ pub(super) fn estimate_cost_usd( let cache_write = i32::try_from(cache_write).ok()?; let output = i32::try_from(output).ok()?; let resolve = |candidate: &str| { - CostUsagePricing::claude_cost_usd(candidate, input, cache_read, cache_write, output) + price(candidate, input, cache_read, cache_write, output) .filter(|cost| cost.is_finite() && *cost >= 0.0) }; resolve(model).or_else(|| pricing_base_model(model).and_then(resolve)) } -/// Matches the routing suffix case-insensitively and keeps the recorded spelling of the base. +/// Pricing base for a recorded model: a product alias's catalogued model, or the base of a +/// routing variant (itself resolved through the alias table, so `gemini-3.1-pro-thinking` +/// prices as `gemini-3.1-pro-preview`). Matches case-insensitively and keeps the recorded +/// spelling of a non-alias base. fn pricing_base_model(model: &str) -> Option<&str> { + if let Some(alias) = pricing_model_alias(model) { + return Some(alias); + } let lowered = model.to_ascii_lowercase(); let suffix = ROUTING_VARIANT_SUFFIXES .iter() .find(|suffix| lowered.ends_with(*suffix))?; - model + let base = model .get(..model.len() - suffix.len()) - .filter(|base| !base.is_empty()) + .filter(|base| !base.is_empty())?; + Some(pricing_model_alias(base).unwrap_or(base)) +} + +fn pricing_model_alias(model: &str) -> Option<&'static str> { + PRICING_MODEL_ALIASES + .iter() + .find(|(alias, _)| alias.eq_ignore_ascii_case(model)) + .map(|(_, catalogued)| *catalogued) } #[cfg(test)] @@ -188,3 +235,229 @@ mod tests { ); } } + +#[cfg(test)] +mod pricing_alias_tests { + use std::collections::HashSet; + + use super::{estimate_cost_usd_with, pricing_base_model, refresh_targets}; + use crate::core::{CostUsagePricing, ModelsDevPricingSnapshot}; + use crate::spend_contract::LocalCostEstimate; + + /// Upstream AntigravityLocalReaderTests catalog: only the preview row is catalogued. + const PREVIEW_CATALOG: &str = r#"{ + "google": { + "id": "google", + "name": "Google", + "models": { + "gemini-3.1-pro-preview": { + "id": "gemini-3.1-pro-preview", + "cost": {"input": 1, "output": 2, "cache_read": 0.2} + } + } + } + }"#; + + /// Prices through the same built-in-then-models.dev resolution as production, against an + /// injected catalog instead of the per-user models.dev cache. + fn snapshot_price( + snapshot: &ModelsDevPricingSnapshot, + ) -> impl Fn(&str, i32, i32, i32, i32) -> Option + '_ { + move |model, input, cache_read, cache_write, output| { + let normalized = CostUsagePricing::normalize_claude_model(model); + CostUsagePricing::resolve_claude_pricing(model, &normalized, Some(snapshot)).map( + |resolution| { + CostUsagePricing::claude_cost_usd_from_resolution( + resolution, + input, + cache_read, + cache_write, + output, + ) + }, + ) + } + } + + fn assert_close(actual: Option, expected: f64, model: &str) { + let actual = actual.unwrap_or_else(|| panic!("{model} should be priced")); + assert!( + (actual - expected).abs() <= expected * 1e-12, + "{model}: {actual} != {expected}" + ); + } + + #[test] + fn pricing_base_model_maps_gemini_pro_aliases_and_keeps_routing_bases() { + // Upstream v0.70.0 AntigravityLocalReaderTests expectations. + assert_eq!( + pricing_base_model("gemini-3.8-flash-tiered"), + Some("gemini-3.8-flash") + ); + assert_eq!( + pricing_base_model("gemini-3.1-pro-low"), + Some("gemini-3.1-pro-preview") + ); + assert_eq!( + pricing_base_model("claude-opus-4-6-thinking"), + Some("claude-opus-4-6") + ); + assert_eq!(pricing_base_model("gemini-3.8-flash"), None); + assert_eq!(pricing_base_model("-low"), None); + for alias in [ + "gemini-pro-default", + "gemini-pro-agent", + "gemini-3.1-pro", + "gemini-3.1-pro-high", + "gemini-3.1-pro-thinking", + ] { + assert_eq!( + pricing_base_model(alias), + Some("gemini-3.1-pro-preview"), + "{alias}" + ); + } + assert_eq!( + pricing_base_model("Gemini-Pro-Default"), + Some("gemini-3.1-pro-preview") + ); + + // An alias reached through a routing suffix resolves the same way, in any case. + for variant in [ + "gemini-pro-agent-tiered", + "GEMINI-3.1-PRO-HIGH-Thinking", + "gemini-3.1-pro-low-low", + ] { + assert_eq!( + pricing_base_model(variant), + Some("gemini-3.1-pro-preview"), + "{variant}" + ); + } + // Near misses stay unaliased: the catalogued row needs no base, other Pro names are + // not inferred. + assert_eq!(pricing_base_model("gemini-3.1-pro-preview"), None); + assert_eq!(pricing_base_model("gemini-pro"), None); + assert_eq!(pricing_base_model("gemini-3.1-pro-max"), None); + assert_eq!( + pricing_base_model("gemini-3-pro-thinking"), + Some("gemini-3-pro") + ); + } + + #[test] + fn gemini_pro_product_aliases_price_from_the_catalogued_preview_model() { + let snapshot = + ModelsDevPricingSnapshot::from_catalog_json_for_tests(PREVIEW_CATALOG).unwrap(); + let price = snapshot_price(&snapshot); + // Upstream fixture turn: system prompt 11 + input 100, cache read 50, output 30 + + // reasoning 7, priced at input 1, cache read 0.2 and output 2 USD per million tokens. + let expected = 111e-6 + 50.0 * 0.2e-6 + 37.0 * 2e-6; + + for model in [ + "gemini-3.1-pro-preview", + "gemini-pro-default", + "Gemini-Pro-Default", + "gemini-pro-agent", + "gemini-3.1-pro", + "gemini-3.1-pro-high", + "gemini-3.1-pro-low", + "gemini-3.1-pro-thinking", + "gemini-pro-agent-tiered", + ] { + assert_close( + estimate_cost_usd_with(Some(model), 111, 50, 0, 37, &price), + expected, + model, + ); + } + + // Unknown models stay unpriced instead of borrowing the preview rate. + for model in [ + "gemini-pro", + "gemini-3.1-flash", + "claude-fixture-9-thinking", + ] { + assert_eq!( + estimate_cost_usd_with(Some(model), 111, 50, 0, 37, &price), + None, + "{model}" + ); + } + } + + #[test] + fn an_explicitly_catalogued_alias_keeps_its_own_price() { + let snapshot = ModelsDevPricingSnapshot::from_catalog_json_for_tests( + r#"{ + "google": { + "id": "google", + "models": { + "gemini-3.1-pro-preview": { + "id": "gemini-3.1-pro-preview", + "cost": {"input": 1, "output": 2, "cache_read": 0.2} + }, + "gemini-3.1-pro": { + "id": "gemini-3.1-pro", + "cost": {"input": 3, "output": 6, "cache_read": 0.6} + } + } + } + }"#, + ) + .unwrap(); + let price = snapshot_price(&snapshot); + + assert_close( + estimate_cost_usd_with(Some("gemini-3.1-pro"), 111, 50, 0, 37, &price), + 111.0 * 3e-6 + 50.0 * 0.6e-6 + 37.0 * 6e-6, + "gemini-3.1-pro", + ); + assert_close( + estimate_cost_usd_with(Some("gemini-pro-default"), 111, 50, 0, 37, &price), + 111e-6 + 50.0 * 0.2e-6 + 37.0 * 2e-6, + "gemini-pro-default", + ); + } + + #[test] + fn unpriced_aliases_refresh_the_catalogued_preview_row() { + let mut estimate = LocalCostEstimate::default(); + estimate.record_list_price(Some("gemini-pro-default"), None); + estimate.record_list_price(Some("gemini-3.1-pro-high-thinking"), None); + + let targets = refresh_targets(&estimate); + + assert_eq!(targets.keys().copied().collect::>(), ["google"]); + assert_eq!( + targets["google"], + [ + "gemini-pro-default", + "gemini-3.1-pro-high-thinking", + "gemini-3.1-pro-preview", + ] + .into_iter() + .map(str::to_string) + .collect::>() + ); + } + + #[test] + fn shared_claude_pricing_targets_stay_free_of_provider_local_bases() { + // Upstream: the shared resolver keeps reporting an unknown variant as unpriced rather + // than silently billing it at the base model's rate. + let claude_targets = + CostUsagePricing::claude_models_dev_pricing_targets("claude-fixture-9-thinking"); + assert!(!claude_targets.is_empty()); + assert!( + !claude_targets + .iter() + .any(|(_, model_id)| model_id == "claude-fixture-9") + ); + // The Gemini Pro alias table is Antigravity's; shared pricing never borrows it. + assert_eq!( + CostUsagePricing::claude_models_dev_pricing_targets("gemini-pro-default"), + [("google", "gemini-pro-default".to_string())] + ); + } +}