Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
287 changes: 280 additions & 7 deletions rust/src/providers/antigravity/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>> {
let mut targets = BTreeMap::<&'static str, HashSet<String>>::new();
for model in &estimate.unpriced_models {
Expand Down Expand Up @@ -60,28 +72,63 @@ pub(super) fn estimate_cost_usd(
cache_read: u64,
cache_write: u64,
output: u64,
) -> Option<f64> {
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<f64>,
) -> Option<f64> {
let model = model.map(str::trim).filter(|value| !value.is_empty())?;
let input = i32::try_from(input).ok()?;
let cache_read = i32::try_from(cache_read).ok()?;
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)]
Expand Down Expand Up @@ -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<f64> + '_ {
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<f64>, 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::<Vec<_>>(), ["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::<HashSet<_>>()
);
}

#[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())]
);
}
}