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 01f562e111..0c6df8fb4b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -460,7 +460,7 @@ fn build_usage_spend_summary( if include_opencodex { // OpenCodex is an enrichment source, never a standalone provider row. // Publish routed subscriptions even when no live provider snapshot exists. - for id in ["codex", "opencodego", "kimi", "deepseek"] { + for id in ["codex", "opencodego", "kimi", "deepseek", "nous"] { let contract = match id { "codex" => None, _ => Some(build_local_spend_contract(id, 30, true)), @@ -536,7 +536,7 @@ fn build_usage_spend_summary( refreshing: !pi_30_summary.history_coverage_established, stale_updated_at: None, }, - "opencodego" | "kimi" | "deepseek" if include_opencodex => { + "opencodego" | "kimi" | "deepseek" | "nous" if include_opencodex => { let seven = build_local_spend_contract(&provider_id, 7, true); let thirty = build_local_spend_contract(&provider_id, 30, true); if !thirty.imports.is_empty() { diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 237f7dab3d..88043bd617 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -71,7 +71,7 @@ Optional status polling (provider status pages) is available via CLI `--status` ## Usage & Spend -Desktop tab id: `usageSpend`. The desktop and Overview consume one shared spend catalog. Codex and Claude local logs are first-class; routed OpenCodex usage enriches the matching Codex, OpenCode Go, Kimi, or DeepSeek subscription instead of appearing as a second fake provider. xAI and OpenRouter can publish exact provider-metered daily USD spend when their management credentials are configured, while Grok local sessions contribute tokens only. Missing spend sources remain unknown rather than becoming a false `$0`. Do not invent cross-currency totals. +Desktop tab id: `usageSpend`. The desktop and Overview consume one shared spend catalog. Codex and Claude local logs are first-class; routed OpenCodex usage enriches the matching Codex, OpenCode Go, Kimi, DeepSeek, or Nous Portal subscription instead of appearing as a second fake provider. Ledger rows with `provider: "nous"` are priced only from a custom pricing override (`nous/` or the bare model id) or an exact Nous models.dev entry, never from another vendor's rates, and never touch Portal credit meters; a row missing input or output tokens, or consuming a cache lane its catalog entry does not price, stays unpriced. xAI and OpenRouter can publish exact provider-metered daily USD spend when their management credentials are configured, while Grok local sessions contribute tokens only. Missing spend sources remain unknown rather than becoming a false `$0`. Do not invent cross-currency totals. ### AWS Bedrock monitoring diff --git a/rust/src/core/cost_pricing/codex.rs b/rust/src/core/cost_pricing/codex.rs index 8b9a20baaa..97ae798f66 100644 --- a/rust/src/core/cost_pricing/codex.rs +++ b/rust/src/core/cost_pricing/codex.rs @@ -168,42 +168,49 @@ impl CostUsagePricing { Some(snapshot) => snapshot.lookup(provider_id, lookup_model), None => models_dev_pricing::lookup(provider_id, lookup_model), }?; - let use_tier = pricing + Some(Self::models_dev_cost_usd( + &pricing, + input_tokens, + cached_input_tokens, + cache_write_input_tokens, + output_tokens, + )) + } + + /// Upstream `codexCostUSD(pricing:)` for one models.dev entry. + /// `input_tokens` is the inclusive prompt size (cache reads and writes are + /// subsets of it) and also selects the long-context tier. A cache lane + /// without its own rate falls back to the tier's input rate. + pub(crate) fn models_dev_cost_usd( + pricing: &models_dev_pricing::DynamicModelPricing, + input_tokens: u64, + cached_input_tokens: u64, + cache_write_input_tokens: u64, + output_tokens: u64, + ) -> f64 { + let long = pricing .threshold_tokens .is_some_and(|threshold| input_tokens > threshold); - let input_rate = if use_tier { - pricing - .input_cost_per_token_above_threshold - .unwrap_or(pricing.input_cost_per_token) - } else { - pricing.input_cost_per_token - }; - let cache_read_rate = if use_tier { - pricing - .cache_read_input_cost_per_token_above_threshold - .or(pricing.cache_read_input_cost_per_token) - .unwrap_or(pricing.input_cost_per_token) - } else { - pricing - .cache_read_input_cost_per_token - .unwrap_or(pricing.input_cost_per_token) - }; - let output_rate = if use_tier { - pricing - .output_cost_per_token_above_threshold - .unwrap_or(pricing.output_cost_per_token) - } else { - pricing.output_cost_per_token - }; - Some(codex_cost_from_rates_with_cache_write( + let above = |rate: Option| rate.filter(|_| long); + let input_rate = above(pricing.input_cost_per_token_above_threshold) + .unwrap_or(pricing.input_cost_per_token); + let output_rate = above(pricing.output_cost_per_token_above_threshold) + .unwrap_or(pricing.output_cost_per_token); + let cache_read_rate = above(pricing.cache_read_input_cost_per_token_above_threshold) + .or(pricing.cache_read_input_cost_per_token) + .unwrap_or(input_rate); + let cache_write_rate = above(pricing.cache_write_input_cost_per_token_above_threshold) + .or(pricing.cache_write_input_cost_per_token) + .unwrap_or(input_rate); + codex_cost_from_rates_with_cache_write( input_tokens, cached_input_tokens, cache_write_input_tokens, output_tokens, input_rate, cache_read_rate, - input_rate, + cache_write_rate, output_rate, - )) + ) } } diff --git a/rust/src/core/cost_pricing_tests.rs b/rust/src/core/cost_pricing_tests.rs index 9984061644..106f3b09e0 100644 --- a/rust/src/core/cost_pricing_tests.rs +++ b/rust/src/core/cost_pricing_tests.rs @@ -332,6 +332,76 @@ fn codex_routed_provider_returns_none_for_unknown_and_unrouted() { assert!(codex_routed_pricing::codex_routed_provider("openai/gpt-5").is_none()); } +#[test] +fn native_codex_nous_prefix_stays_unpriced() { + // Upstream `codexModelsDevProviderIDs` has no `nous`: only OpenCodex + // ledger rows reach the Nous catalog. + let snapshot = crate::core::ModelsDevPricingSnapshot::from_catalog_json_for_tests( + r#"{"nous":{"models":{"z-ai/glm-5":{"id":"z-ai/glm-5","cost":{"input":2,"output":8}}}}}"#, + ) + .expect("catalog"); + assert!(codex_routed_pricing::codex_routed_provider("nous/z-ai/glm-5").is_none()); + assert!( + CostUsagePricing::codex_cost_usd_with_pricing_snapshot( + "nous/z-ai/glm-5", + 1_000, + 0, + 500, + Some(&snapshot) + ) + .is_none() + ); +} + +#[test] +fn models_dev_rates_follow_upstream_codex_cost_semantics() { + let snapshot = crate::core::ModelsDevPricingSnapshot::from_catalog_json_for_tests( + r#"{"deepseek":{"models":{ + "full":{"id":"full","cost":{"input":2,"output":8,"cache_read":0.5,"cache_write":3, + "context_over_200k":{"input":4,"output":16,"cache_read":1,"cache_write":6}}}, + "bare":{"id":"bare","cost":{"input":2,"output":8, + "context_over_200k":{"input":4,"output":16}}}}}}"#, + ) + .expect("catalog"); + let full = snapshot.lookup_exact("deepseek", "full").expect("full"); + let bare = snapshot.lookup_exact("deepseek", "bare").expect("bare"); + let close = |actual: f64, expected: f64| { + assert!((actual - expected).abs() < 1e-12, "{actual} vs {expected}"); + }; + + // Short context: each cache lane uses its own catalog rate. + close( + CostUsagePricing::models_dev_cost_usd(&full, 1_000, 200, 300, 100), + 500.0 * 2e-6 + 200.0 * 0.5e-6 + 300.0 * 3e-6 + 100.0 * 8e-6, + ); + // Inclusive input above 200k selects the long-context rate of every lane. + close( + CostUsagePricing::models_dev_cost_usd(&full, 200_001, 100_000, 50_000, 1_000), + 50_001.0 * 4e-6 + 100_000.0 * 1e-6 + 50_000.0 * 6e-6 + 1_000.0 * 16e-6, + ); + // A lane without a catalog rate falls back to the tier's input rate. + close( + CostUsagePricing::models_dev_cost_usd(&bare, 1_000, 200, 300, 100), + 1_000.0 * 2e-6 + 100.0 * 8e-6, + ); + close( + CostUsagePricing::models_dev_cost_usd(&bare, 200_001, 100_000, 50_000, 1_000), + 200_001.0 * 4e-6 + 1_000.0 * 16e-6, + ); + // Routed Codex rows share these rates. + close( + CostUsagePricing::codex_cost_usd_with_pricing_snapshot( + "deepseek/bare", + 200_001, + 100_000, + 1_000, + Some(&snapshot), + ) + .expect("routed"), + 200_001.0 * 4e-6 + 1_000.0 * 16e-6, + ); +} + #[test] fn codex_routed_model_with_unknown_prefix_stays_unpriced() { // An unknown provider/ prefix must NOT fall back to the OpenAI catalog diff --git a/rust/src/core/models_dev_pricing.rs b/rust/src/core/models_dev_pricing.rs index 3e46c93eb2..e35a8d6e84 100644 --- a/rust/src/core/models_dev_pricing.rs +++ b/rust/src/core/models_dev_pricing.rs @@ -89,6 +89,36 @@ mod tests { ); } + #[test] + fn exact_lookup_matches_only_the_trimmed_key_or_model_id() { + let catalog = ModelsDevCatalog::decode( + r#"{ + "Nous": { + "models": { + "z-ai/glm-5": {"id": "z-ai/glm-5", "cost": {"input": 1, "output": 2}}, + "catalog-key": {"id": "deepseek/deepseek-v4", "cost": {"input": 3, "output": 4}}, + "gpt-5": {"id": "gpt-5", "cost": {"input": 5, "output": 6}} + } + } + }"#, + ) + .expect("catalog"); + let input_rate = |model: &str| { + catalog + .lookup_exact(" NOUS ", model) + .map(|pricing| pricing.input_cost_per_token) + }; + + assert_eq!(input_rate(" z-ai/glm-5 "), Some(1e-6)); + assert_eq!(input_rate("deepseek/deepseek-v4"), Some(3e-6)); + assert_eq!(input_rate("Z-AI/GLM-5"), None); + // Aliases the fuzzy lookup resolves never supply an exact price. + for alias in ["z-ai/glm-5@20260101", "z-ai/glm-5-20260101", "openai/gpt-5"] { + assert!(catalog.lookup("nous", alias).is_some(), "{alias}"); + assert_eq!(input_rate(alias), None, "{alias}"); + } + } + #[test] fn cache_artifact_is_versioned_and_expires_after_one_day() { let catalog = ModelsDevCatalog::decode( @@ -223,6 +253,15 @@ impl ModelsDevPricingSnapshot { .and_then(|artifact| artifact.catalog.lookup(provider_id, model_id)) } + /// Exact-id lookup (upstream `exactModelID: true`): the trimmed id must + /// equal a catalog key or model id. No dated, `@`, or vendor-prefix alias + /// of another model can supply the price. + pub fn lookup_exact(&self, provider_id: &str, model_id: &str) -> Option { + self.artifact + .as_ref() + .and_then(|artifact| artifact.catalog.lookup_exact(provider_id, model_id)) + } + #[cfg(test)] pub(crate) fn from_catalog_json_for_tests(json: &str) -> Option { let catalog = ModelsDevCatalog::decode(json)?; @@ -293,6 +332,22 @@ impl ModelsDevCatalog { }) } + fn lookup_exact(&self, provider_id: &str, model_id: &str) -> Option { + let provider = self.providers.get(&normalize_provider_id(provider_id))?; + let model_id = normalize_model_id(model_id); + provider + .models + .get(&model_id) + .and_then(DynamicModelPricing::from_model) + .or_else(|| { + provider.models.values().find_map(|model| { + (normalize_model_id(&model.id) == model_id) + .then(|| DynamicModelPricing::from_model(model)) + .flatten() + }) + }) + } + fn is_plausible_refresh(&self) -> bool { ["openai", "anthropic"].into_iter().all(|provider_id| { self.providers diff --git a/rust/src/spend_contract/opencodex.rs b/rust/src/spend_contract/opencodex.rs index 6649e07e87..481956a652 100644 --- a/rust/src/spend_contract/opencodex.rs +++ b/rust/src/spend_contract/opencodex.rs @@ -29,6 +29,9 @@ struct OpenCodexEntry { } mod cache; +mod nous; +#[cfg(test)] +mod nous_tests; #[derive(Default)] struct ModelAccumulator { @@ -62,6 +65,7 @@ fn route_provider(provider: &str) -> RouteTarget { "opencode-go" => RouteTarget::Subscription("opencodego"), "kimi-coding" | "kimi-for-coding" => RouteTarget::Subscription("kimi"), "deepseek" => RouteTarget::Subscription("deepseek"), + "nous" => RouteTarget::Subscription(nous::SUBSCRIPTION_ID), "opencode-free" | "opencode" => RouteTarget::TokenOnly, _ => RouteTarget::Unknown, } @@ -153,9 +157,9 @@ fn aggregate( let pricing_snapshot = crate::core::pricing_snapshot(); for entry in &entries { - if let Some(conversation) = entry.conversation_id.as_ref() { - conversations.insert(conversation.clone()); - } + // A row without a conversationId is its own session (upstream 0.68.0). + let session = entry.conversation_id.as_ref().unwrap_or(&entry.request_id); + conversations.insert(session.clone()); token_mix.input_tokens = add_optional(token_mix.input_tokens, entry.input_tokens); token_mix.output_tokens = add_optional(token_mix.output_tokens, entry.output_tokens); token_mix.cache_read_tokens = @@ -231,7 +235,7 @@ fn aggregate( if let Some(cost) = cost { model.cost = Some(model.cost.unwrap_or(0.0) + cost); } - model.custom_pricing |= custom.rates(&entry.provider, &entry.model).is_some(); + model.custom_pricing |= has_custom_rates(entry, custom); } let mut model_rows: Vec<_> = models @@ -322,6 +326,9 @@ fn entry_cost( if !has_usage { return None; } + if route_entry(entry) == RouteTarget::Subscription(nous::SUBSCRIPTION_ID) { + return nous::cost(entry, custom, pricing_snapshot); + } let input = entry.input_tokens.unwrap_or(0); let output = entry.output_tokens.unwrap_or(0); let cache_read = entry.cache_read_tokens.unwrap_or(0); @@ -358,6 +365,14 @@ fn pricing_model(entry: &OpenCodexEntry) -> Option { } } +/// Whether a custom override covers `entry`, resolved as its cost resolves it. +fn has_custom_rates(entry: &OpenCodexEntry, custom: &CustomPricing) -> bool { + if route_entry(entry) == RouteTarget::Subscription(nous::SUBSCRIPTION_ID) { + return nous::custom_rates(entry, custom).is_some(); + } + custom.rates(&entry.provider, &entry.model).is_some() +} + fn provider_model_id(entry: &OpenCodexEntry, target: RouteTarget) -> String { let model = entry.model.trim(); let Some((model_prefix, model_tail)) = model.split_once('/') else { @@ -429,9 +444,9 @@ fn parse_line(line: &str) -> Option { .and_then(|object| nonnegative_u64(object.get("cacheCreationInputTokens"))), reasoning_tokens: usage .and_then(|object| nonnegative_u64(object.get("reasoningOutputTokens"))), - total_tokens: value - .get("totalTokens") - .and_then(|value| nonnegative_u64(Some(value))), + // The Hermes extractor reports the total inside `usage`. + total_tokens: nonnegative_u64(value.get("totalTokens")) + .or_else(|| usage.and_then(|object| nonnegative_u64(object.get("totalTokens")))), }) } diff --git a/rust/src/spend_contract/opencodex/cache.rs b/rust/src/spend_contract/opencodex/cache.rs index 73fca32e72..dc8eaa0d2a 100644 --- a/rust/src/spend_contract/opencodex/cache.rs +++ b/rust/src/spend_contract/opencodex/cache.rs @@ -9,7 +9,7 @@ use sha2::{Digest, Sha256}; use super::{OpenCodexEntry, parse_line}; -const CACHE_SCHEMA_VERSION: i64 = 2; +const CACHE_SCHEMA_VERSION: i64 = 3; const PREFIX_DIGEST_BYTES: u64 = 64 * 1024; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/rust/src/spend_contract/opencodex/nous.rs b/rust/src/spend_contract/opencodex/nous.rs new file mode 100644 index 0000000000..dea1a2f16a --- /dev/null +++ b/rust/src/spend_contract/opencodex/nous.rs @@ -0,0 +1,85 @@ +//! Nous Portal rows in the OpenCodex ledger (upstream 0.68.0 #4008). +//! +//! The Hermes extractor records `provider: "nous"` with the exact inference +//! model id (`anthropic/claude-sonnet-4.6`), whatever vendor it names. Pricing +//! never falls back to that vendor's rates: only a custom pricing override or +//! an exact Nous models.dev entry prices a row. +//! +//! Ledger convention: `inputTokens` excludes cache reads and writes (a row may +//! carry more cache-read than input tokens). A custom override bills input, +//! cache reads and cache writes as separate lanes, as the upstream Nous tests +//! expect; the catalog path adds both back to form the inclusive prompt size +//! that upstream `providerCostUSD` prices. + +use crate::core::{CostUsagePricing, ModelsDevPricingSnapshot}; + +use super::super::CustomRates; +use super::{CustomPricing, OpenCodexEntry}; + +pub(super) const SUBSCRIPTION_ID: &str = "nous"; + +pub(super) fn cost( + entry: &OpenCodexEntry, + custom: &CustomPricing, + pricing_snapshot: &ModelsDevPricingSnapshot, +) -> Option { + // Upstream `listPriceUSD`: a row without both input and output is unpriced. + let input = entry.input_tokens?; + let output = entry.output_tokens?; + let cache_read = entry.cache_read_tokens.unwrap_or(0); + let cache_write = entry.cache_creation_tokens.unwrap_or(0); + if let Some(rates) = custom_rates(entry, custom) { + return rates.cost_parts( + input.checked_add(cache_read)?, + output, + cache_read, + cache_write, + ); + } + let pricing = pricing_snapshot.lookup_exact(SUBSCRIPTION_ID, catalog_model_id(entry)?)?; + // A consumed cache lane the catalog does not price keeps the row unknown; + // it never borrows the input rate. + if (cache_read > 0 && pricing.cache_read_input_cost_per_token.is_none()) + || (cache_write > 0 && pricing.cache_write_input_cost_per_token.is_none()) + { + return None; + } + let inclusive_input = input.checked_add(cache_read)?.checked_add(cache_write)?; + Some(CostUsagePricing::models_dev_cost_usd( + &pricing, + inclusive_input, + cache_read, + cache_write, + output, + )) +} + +/// The custom override for a Nous row, shared by its cost and the model's +/// custom-pricing flag: the recorded identity (`nous/`, then the bare +/// model key), then the catalog id when the model repeats the `nous/` prefix. +pub(super) fn custom_rates<'a>( + entry: &OpenCodexEntry, + custom: &'a CustomPricing, +) -> Option<&'a CustomRates> { + custom + .rates(&entry.provider, &entry.model) + .or_else(|| catalog_model_id(entry).and_then(|model| custom.rates(SUBSCRIPTION_ID, model))) +} + +/// models.dev id of a row recorded under `provider: "nous"`: the model without +/// a repeated `nous/` prefix (upstream `ModelsDevPricingTargetResolver`). +/// Legacy OpenAI-transport rows (`provider: "openai"`, model `nous/`) keep +/// upstream's OpenAI pricing route, which has no Nous catalog: only a custom +/// override prices them. +fn catalog_model_id(entry: &OpenCodexEntry) -> Option<&str> { + if !entry.provider.trim().eq_ignore_ascii_case(SUBSCRIPTION_ID) { + return None; + } + let model = entry.model.trim(); + let model = match model.split_once('/') { + Some((prefix, rest)) if prefix.trim().eq_ignore_ascii_case(SUBSCRIPTION_ID) => rest, + _ => model, + }; + // Every id the resolver rejects before stripping is also rejected here. + (!model.is_empty() && !model.starts_with('/') && !model.ends_with('/')).then_some(model) +} diff --git a/rust/src/spend_contract/opencodex/nous_tests.rs b/rust/src/spend_contract/opencodex/nous_tests.rs new file mode 100644 index 0000000000..5a93e5a83c --- /dev/null +++ b/rust/src/spend_contract/opencodex/nous_tests.rs @@ -0,0 +1,359 @@ +//! Nous Portal ledger rows (upstream 0.68.0 `OpenCodexNousUsageTests`). + +use super::super::CustomRates; +use super::*; +use crate::core::ModelsDevPricingSnapshot; + +/// Sanitized Hermes extractor rows, verbatim from the upstream fixture. Their +/// redacted request ids collide, so each row is aggregated on its own. +const FIXTURE: &str = concat!( + r#"{"requestId":"nous-XXXXXXXXXXXXXXXX","timestamp":1777362346.46,"provider":"nous","model":"anthropic/claude-sonnet-4.6","usageStatus":"estimated","usage":{"inputTokens":22411,"outputTokens":5,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"reasoningOutputTokens":0,"totalTokens":22416},"surface":"hermes-gateway","_meta":{"apiCalls":1,"hermesEstimatedCostUSD":0.067,"costSource":"provider_models_api"}}"#, + "\n", + r#"{"requestId":"nous-XXXXXXXXXXXXXXXX","timestamp":1788143082.41,"provider":"nous","model":"z-ai/glm-5.3-flash","usageStatus":"estimated","usage":{"inputTokens":28341,"outputTokens":77,"cacheReadInputTokens":3520,"cacheCreationInputTokens":0,"reasoningOutputTokens":66,"totalTokens":31998},"surface":"hermes-gateway","_meta":{"apiCalls":1,"hermesEstimatedCostUSD":0.002,"costSource":"provider_models_api"}}"#, + "\n", + r#"{"requestId":"nous-XXXXXXXXXXXXXXXX","timestamp":1785982323.12,"provider":"nous","model":"deepseek/deepseek-v4-flash-0731","usageStatus":"estimated","usage":{"inputTokens":754741,"outputTokens":28710,"cacheReadInputTokens":7528192,"cacheCreationInputTokens":0,"reasoningOutputTokens":17239,"totalTokens":8303882},"surface":"hermes-gateway","_meta":{"apiCalls":32,"hermesEstimatedCostUSD":0.008,"costSource":"provider_models_api"}}"#, + "\n", +); + +const UNREPORTED: &str = r#"{"requestId":"nous-synthetic-unreported","timestamp":1777362346.46,"provider":"nous","model":"fixture-model","usageStatus":"unreported","usage":{"inputTokens":10,"outputTokens":2,"cacheReadInputTokens":3,"cacheCreationInputTokens":4,"reasoningOutputTokens":1,"totalTokens":15},"surface":"hermes-gateway","conversationID":"synthetic-session","_meta":{"apiCalls":1,"hermesEstimatedCostUSD":0,"costSource":null}}"#; + +fn fixture_entries() -> Vec { + FIXTURE + .lines() + .map(|line| parse_line(line).expect("fixture row parses")) + .collect() +} + +/// One 2/8/0.5/3 override under `key`, trimmed and lowercased as the loader +/// stores it. +fn custom_key(key: &str) -> CustomPricing { + CustomPricing { + entries: HashMap::from([( + key.to_string(), + CustomRates { + input: Some(2.0), + output: Some(8.0), + cache_read: Some(0.5), + cache_write: Some(3.0), + }, + )]), + } +} + +fn custom_pricing(model: &str) -> CustomPricing { + custom_key(&format!("nous/{model}")) +} + +const SONNET_2_8: &str = r#""anthropic/claude-sonnet-4.6":{"id":"anthropic/claude-sonnet-4.6","cost":{"input":2,"output":8}}"#; + +fn assert_cost(cost: Option, expected: f64) { + let cost = cost.expect("priced"); + assert!((cost - expected).abs() < 1e-10, "{cost} vs {expected}"); +} + +fn aggregate_one(entry: &OpenCodexEntry, custom: &CustomPricing) -> ImportedSpendSource { + aggregate(vec![entry.clone()], entry.timestamp, 7, custom).expect("source") +} + +#[test] +fn fixture_rows_keep_the_ledger_schema_and_ignore_extractor_meta() { + let entries = fixture_entries(); + assert_eq!( + entries + .iter() + .map(|entry| entry.provider.as_str()) + .collect::>(), + ["nous"; 3] + ); + assert_eq!( + entries + .iter() + .map(|entry| entry.model.as_str()) + .collect::>(), + [ + "anthropic/claude-sonnet-4.6", + "z-ai/glm-5.3-flash", + "deepseek/deepseek-v4-flash-0731" + ] + ); + assert!( + entries + .iter() + .all(|entry| entry.usage_status == "estimated") + ); + assert!(entries.iter().all(|entry| entry.conversation_id.is_none())); + assert_eq!( + entries + .iter() + .map(|entry| entry.timestamp.timestamp()) + .collect::>(), + [1_777_362_346, 1_788_143_082, 1_785_982_323] + ); + // Totals come from `usage.totalTokens` when the row has no top-level total. + assert_eq!( + entries + .iter() + .map(OpenCodexEntry::resolved_total_tokens) + .collect::>(), + [Some(22_416), Some(31_998), Some(8_303_882)] + ); +} + +#[test] +fn nous_rows_route_to_the_nous_subscription_whatever_vendor_the_model_names() { + for entry in fixture_entries() { + assert_eq!( + route_entry(&entry), + RouteTarget::Subscription("nous"), + "{}", + entry.model + ); + } + assert_eq!( + route_entry(&entry_for("NOUS ", "openai/gpt-5")), + RouteTarget::Subscription("nous") + ); +} + +#[test] +fn custom_nous_pricing_prices_each_fixture_row_with_input_excluding_cache() { + let expected = [0.044_862, 0.059_058, 5.503_258]; + for (entry, expected) in fixture_entries().iter().zip(expected) { + let source = aggregate_one(entry, &custom_pricing(&entry.model)); + let cost = source.known_cost_usd.expect("priced"); + assert!( + (cost - expected).abs() < 1e-10, + "{}: {cost} vs {expected}", + entry.model + ); + assert_eq!(source.display_name, "OpenCodex"); + assert_eq!(source.token_mix.input_tokens, entry.input_tokens); + assert_eq!(source.coverage.estimated, 1); + assert_eq!(source.coverage.priced, 0); + assert_eq!(source.provenance, CostProvenance::ListPriceEstimate); + assert_eq!(source.models[0].model, entry.model); + assert!(source.models[0].custom_pricing); + assert_eq!( + source.models[0].total_tokens, + entry.resolved_total_tokens().unwrap() + ); + // A ledger row without conversationId is its own session. + assert_eq!(source.conversation_count, 1); + } +} + +fn catalog(nous_models: &str) -> ModelsDevPricingSnapshot { + ModelsDevPricingSnapshot::from_catalog_json_for_tests(&format!( + r#"{{"nous":{{"models":{{{nous_models}}}}}, + "anthropic":{{"models":{{"claude-sonnet-4.6":{{ + "id":"claude-sonnet-4.6","cost":{{"input":20,"output":80}}}}}}}}}}"# + )) + .expect("catalog") +} + +#[test] +fn nous_catalog_price_requires_the_exact_nous_model_id() { + let entry = &fixture_entries()[0]; + let priced = catalog( + r#""anthropic/claude-sonnet-4.6":{"id":"anthropic/claude-sonnet-4.6","cost":{"input":2,"output":8}}"#, + ); + let cost = entry_cost(entry, &CustomPricing::default(), &priced).expect("nous catalog price"); + assert!((cost - 0.044_862).abs() < 1e-10, "{cost}"); + + // Only another vendor's rates exist for the bare model id: stay unpriced. + let vendor_only = catalog( + r#""claude-sonnet-4.6":{"id":"claude-sonnet-4.6","cost":{"input":20,"output":80}}"#, + ); + assert_eq!( + entry_cost(entry, &CustomPricing::default(), &vendor_only), + None + ); +} + +#[test] +fn nous_catalog_cache_reads_are_added_back_to_the_inclusive_input() { + let entry = &fixture_entries()[1]; + let snapshot = catalog( + r#""z-ai/glm-5.3-flash":{"id":"z-ai/glm-5.3-flash","cost":{"input":2,"output":8,"cache_read":0.5}}"#, + ); + let cost = entry_cost(entry, &CustomPricing::default(), &snapshot).expect("priced"); + assert!((cost - 0.059_058).abs() < 1e-10, "{cost}"); +} + +#[test] +fn nous_catalog_cache_writes_use_the_catalog_cache_write_rate() { + let mut entry = parse_line(UNREPORTED).expect("row parses"); + entry.usage_status = "estimated".to_string(); + let snapshot = catalog( + r#""fixture-model":{"id":"fixture-model","cost":{"input":2,"output":8,"cache_read":0.5,"cache_write":3}}"#, + ); + let cost = entry_cost(&entry, &CustomPricing::default(), &snapshot).expect("priced"); + assert!((cost - 0.000_049_5).abs() < 1e-12, "{cost}"); +} + +#[test] +fn nous_custom_pricing_accepts_the_bare_model_key() { + // Upstream `rates(providerID:model:)` matches the unqualified key too. + let entry = &fixture_entries()[0]; + let custom = custom_key("anthropic/claude-sonnet-4.6"); + assert_cost(entry_cost(entry, &custom, &catalog("")), 0.044_862); + assert!(aggregate_one(entry, &custom).models[0].custom_pricing); +} + +#[test] +fn nous_rows_need_both_input_and_output_to_be_priced() { + let entry = &fixture_entries()[0]; + let custom = custom_pricing(&entry.model); + let snapshot = catalog(SONNET_2_8); + assert_cost(entry_cost(entry, &custom, &snapshot), 0.044_862); + assert_cost( + entry_cost(entry, &CustomPricing::default(), &snapshot), + 0.044_862, + ); + + let mut no_input = entry.clone(); + no_input.input_tokens = None; + let mut no_output = entry.clone(); + no_output.output_tokens = None; + for partial in [&no_input, &no_output] { + assert_eq!(entry_cost(partial, &custom, &snapshot), None); + assert_eq!( + entry_cost(partial, &CustomPricing::default(), &snapshot), + None + ); + } +} + +#[test] +fn nous_catalog_never_bills_a_consumed_cache_lane_at_the_input_rate() { + // Row 1 reads 3,520 cached tokens; its catalog entry has no cache-read rate. + let entry = &fixture_entries()[1]; + let no_cache_read = catalog( + r#""z-ai/glm-5.3-flash":{"id":"z-ai/glm-5.3-flash","cost":{"input":2,"output":8}}"#, + ); + assert_eq!( + entry_cost(entry, &CustomPricing::default(), &no_cache_read), + None + ); + + let mut entry = parse_line(UNREPORTED).expect("row parses"); + entry.usage_status = "estimated".to_string(); + let no_cache_write = catalog( + r#""fixture-model":{"id":"fixture-model","cost":{"input":2,"output":8,"cache_read":0.5}}"#, + ); + assert_eq!( + entry_cost(&entry, &CustomPricing::default(), &no_cache_write), + None + ); + // Without cache writes the same entry prices: 10 input, 3 cached, 2 output. + entry.cache_creation_tokens = Some(0); + let cost = entry_cost(&entry, &CustomPricing::default(), &no_cache_write).expect("priced"); + assert!((cost - 0.000_037_5).abs() < 1e-12, "{cost}"); +} + +#[test] +fn nous_catalog_ignores_dated_and_versioned_aliases() { + let undated = catalog(SONNET_2_8); + for alias in [ + "anthropic/claude-sonnet-4.6@20260101", + "anthropic/claude-sonnet-4.6-20260101", + ] { + assert_eq!( + entry_cost( + &entry_for("nous", alias), + &CustomPricing::default(), + &undated + ), + None, + "{alias}" + ); + } + let dated = catalog( + r#""anthropic/claude-sonnet-4.6-20260101":{"id":"anthropic/claude-sonnet-4.6-20260101","cost":{"input":2,"output":8}}"#, + ); + assert_eq!( + entry_cost(&fixture_entries()[0], &CustomPricing::default(), &dated), + None + ); +} + +#[test] +fn self_prefixed_nous_models_resolve_to_the_catalog_id() { + let entry = entry_for("nous", "Nous/anthropic/claude-sonnet-4.6"); + assert_cost( + entry_cost(&entry, &CustomPricing::default(), &catalog(SONNET_2_8)), + 0.044_862, + ); + + // An override keyed by the catalog id prices the row and flags its model. + let custom = custom_key("anthropic/claude-sonnet-4.6"); + assert_cost(entry_cost(&entry, &custom, &catalog("")), 0.044_862); + assert!(aggregate_one(&entry, &custom).models[0].custom_pricing); +} + +#[test] +fn legacy_openai_transport_nous_rows_are_priced_only_by_custom_overrides() { + let entry = entry_for("openai", "nous/anthropic/claude-sonnet-4.6"); + assert_eq!(route_entry(&entry), RouteTarget::Subscription("nous")); + // Upstream keeps their OpenAI pricing route, which has no Nous catalog. + assert_eq!( + entry_cost(&entry, &CustomPricing::default(), &catalog(SONNET_2_8)), + None + ); + + let custom = custom_key("nous/anthropic/claude-sonnet-4.6"); + assert_cost(entry_cost(&entry, &custom, &catalog("")), 0.044_862); + assert!(aggregate_one(&entry, &custom).models[0].custom_pricing); +} + +#[test] +fn malformed_nous_model_ids_stay_unpriced() { + let snapshot = catalog(concat!( + r#""x":{"id":"x","cost":{"input":2,"output":8}},"#, + r#""/x":{"id":"/x","cost":{"input":2,"output":8}},"#, + r#""x/":{"id":"x/","cost":{"input":2,"output":8}}"#, + )); + assert!( + entry_cost( + &entry_for("nous", "nous/x"), + &CustomPricing::default(), + &snapshot + ) + .is_some() + ); + for model in ["nous/", "nous//x", "/x", "x/", "nous/x/"] { + assert_eq!( + entry_cost( + &entry_for("nous", model), + &CustomPricing::default(), + &snapshot + ), + None, + "{model}" + ); + } +} + +#[test] +fn unreported_rows_keep_tokens_without_dollars() { + let entry = parse_line(UNREPORTED).expect("row parses"); + assert_eq!( + entry.conversation_id, None, + "conversationID is not the schema field" + ); + let source = aggregate_one(&entry, &custom_pricing("fixture-model")); + assert_eq!(source.known_cost_usd, None); + assert_eq!(source.coverage.unpriced, 1); + assert_eq!(source.coverage.estimated, 0); + assert_eq!(source.daily[0].total_tokens, Some(15)); + assert_eq!(source.daily[0].cost_usd, None); + assert_eq!(source.token_mix.cache_creation_tokens, Some(4)); + assert_eq!(source.token_mix.cache_read_tokens, Some(3)); +} + +fn entry_for(provider: &str, model: &str) -> OpenCodexEntry { + let mut entry = fixture_entries().remove(0); + entry.provider = provider.to_string(); + entry.model = model.to_string(); + entry +}