From b6f397150f3aa26cc775b3eb5dd4151934c6644e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:37:32 +0700 Subject: [PATCH 1/3] Port upstream 0.68.0: attribute Nous OpenCodex ledger rows to the Nous Portal spend row --- .../src-tauri/src/commands/usage_spend.rs | 4 +- docs/PROVIDERS.md | 2 +- rust/src/core/codex_routed_pricing.rs | 4 +- rust/src/core/cost_pricing/codex.rs | 2 +- rust/src/core/cost_pricing_tests.rs | 10 + rust/src/spend_contract/opencodex.rs | 24 ++- rust/src/spend_contract/opencodex/cache.rs | 2 +- rust/src/spend_contract/opencodex/nous.rs | 42 ++++ .../spend_contract/opencodex/nous_tests.rs | 191 ++++++++++++++++++ 9 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 rust/src/spend_contract/opencodex/nous.rs create mode 100644 rust/src/spend_contract/opencodex/nous_tests.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 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..e980e25438 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 an exact Nous models.dev entry or a `nous/` custom pricing override, never from another vendor's rates, and never touch Portal credit meters. 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/codex_routed_pricing.rs b/rust/src/core/codex_routed_pricing.rs index 5d75656691..e259ac573b 100644 --- a/rust/src/core/codex_routed_pricing.rs +++ b/rust/src/core/codex_routed_pricing.rs @@ -9,7 +9,7 @@ /// return the matching models.dev provider id (upstream 0.50.1 #2946). /// /// Known routes: `deepseek/` → "deepseek", `kimi/` → "kimi", -/// `opencode/` → "opencode". The `openai/` prefix is stripped by +/// `opencode/` → "opencode", `nous/` → "nous". The `openai/` prefix is stripped by /// [`normalize_codex_model`] and priced against the OpenAI catalog as /// before. Unknown `provider/` prefixes return `None` here so the caller /// leaves them unpriced rather than guessing. @@ -20,6 +20,8 @@ pub fn codex_routed_provider(model: &str) -> Option<&'static str> { "deepseek" => Some("deepseek"), "kimi" => Some("kimi"), "opencode" => Some("opencode"), + // Nous Portal keeps the vendor-qualified model id whole (`nous/anthropic/claude-...`). + "nous" => Some("nous"), _ => None, } } diff --git a/rust/src/core/cost_pricing/codex.rs b/rust/src/core/cost_pricing/codex.rs index 8b9a20baaa..770e96e36b 100644 --- a/rust/src/core/cost_pricing/codex.rs +++ b/rust/src/core/cost_pricing/codex.rs @@ -95,7 +95,7 @@ impl CostUsagePricing { ) } - fn codex_cost_usd_with_cache_write_and_pricing_snapshot( + pub(crate) fn codex_cost_usd_with_cache_write_and_pricing_snapshot( model: &str, input_tokens: u64, cached_input_tokens: u64, diff --git a/rust/src/core/cost_pricing_tests.rs b/rust/src/core/cost_pricing_tests.rs index 9984061644..83170913fd 100644 --- a/rust/src/core/cost_pricing_tests.rs +++ b/rust/src/core/cost_pricing_tests.rs @@ -322,6 +322,16 @@ fn codex_routed_provider_detects_known_routes() { codex_routed_pricing::codex_routed_provider("DeepSeek/deepseek-chat"), Some("deepseek") ); + // Nous keeps the vendor-qualified model id whole after its own prefix. + let nous = "nous/anthropic/claude-sonnet-4.6"; + assert_eq!( + codex_routed_pricing::codex_routed_provider(nous), + Some("nous") + ); + assert_eq!( + codex_routed_pricing::strip_route_prefix(nous), + "anthropic/claude-sonnet-4.6" + ); } #[test] diff --git a/rust/src/spend_contract/opencodex.rs b/rust/src/spend_contract/opencodex.rs index 6649e07e87..f3cc692368 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,14 @@ 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). + conversations.insert( + entry + .conversation_id + .as_ref() + .unwrap_or(&entry.request_id) + .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 = @@ -322,6 +331,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); @@ -429,9 +441,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..701ee6bb9f --- /dev/null +++ b/rust/src/spend_contract/opencodex/nous.rs @@ -0,0 +1,42 @@ +//! 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 an exact Nous catalog entry +//! or a `nous/` custom override prices a row. + +use crate::core::{CostUsagePricing, ModelsDevPricingSnapshot}; + +use super::{CustomPricing, OpenCodexEntry}; + +pub(super) const SUBSCRIPTION_ID: &str = "nous"; + +/// Ledger convention for Nous rows: `inputTokens` excludes cache reads and +/// writes (a row may carry more cache-read than input tokens), so both are +/// added back for helpers that treat input as the inclusive total. +pub(super) fn cost( + entry: &OpenCodexEntry, + custom: &CustomPricing, + pricing_snapshot: &ModelsDevPricingSnapshot, +) -> Option { + 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); + let cache_write = entry.cache_creation_tokens.unwrap_or(0); + if let Some(rates) = custom.rates(SUBSCRIPTION_ID, &entry.model) { + return rates.cost_parts( + input.saturating_add(cache_read), + output, + cache_read, + cache_write, + ); + } + CostUsagePricing::codex_cost_usd_with_cache_write_and_pricing_snapshot( + &format!("{SUBSCRIPTION_ID}/{}", entry.model.trim()), + input.saturating_add(cache_read).saturating_add(cache_write), + cache_read, + cache_write, + output, + Some(pricing_snapshot), + ) +} 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..a6f55a3ad7 --- /dev/null +++ b/rust/src/spend_contract/opencodex/nous_tests.rs @@ -0,0 +1,191 @@ +//! 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() +} + +fn custom_pricing(model: &str) -> CustomPricing { + CustomPricing { + entries: HashMap::from([( + format!("nous/{model}"), + CustomRates { + input: Some(2.0), + output: Some(8.0), + cache_read: Some(0.5), + cache_write: Some(3.0), + }, + )]), + } +} + +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_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 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 +} From 2d2b882095cfa406f7241e43bf670810ee195887 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:46:22 +0700 Subject: [PATCH 2/3] Address thermo review --- rust/src/core/codex_routed_pricing.rs | 8 +++--- rust/src/core/cost_pricing/codex.rs | 12 ++++++++- rust/src/spend_contract.rs | 6 +++++ rust/src/spend_contract/opencodex/nous.rs | 2 +- .../spend_contract/opencodex/nous_tests.rs | 25 +++++++++++++++++++ 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/rust/src/core/codex_routed_pricing.rs b/rust/src/core/codex_routed_pricing.rs index e259ac573b..2acfec15f9 100644 --- a/rust/src/core/codex_routed_pricing.rs +++ b/rust/src/core/codex_routed_pricing.rs @@ -1,7 +1,7 @@ //! Codex routed-model pricing (upstream 0.50.1 #2946). //! //! Codex rollouts routed through a non-OpenAI backend (DeepSeek, Kimi, -//! OpenCode) carry the provider as a `provider/model` prefix. This module +//! OpenCode and Nous) carry the provider as a `provider/model` prefix. This module //! detects the route and strips the prefix so the cost lookup prices //! against the right models.dev catalog instead of falling back to OpenAI. @@ -9,9 +9,9 @@ /// return the matching models.dev provider id (upstream 0.50.1 #2946). /// /// Known routes: `deepseek/` → "deepseek", `kimi/` → "kimi", -/// `opencode/` → "opencode", `nous/` → "nous". The `openai/` prefix is stripped by -/// [`normalize_codex_model`] and priced against the OpenAI catalog as -/// before. Unknown `provider/` prefixes return `None` here so the caller +/// `opencode/` → "opencode", `nous/` → "nous". The `openai/` prefix is +/// stripped by [`normalize_codex_model`] and priced against the OpenAI catalog +/// as before. Unknown `provider/` prefixes return `None` here so the caller /// leaves them unpriced rather than guessing. pub fn codex_routed_provider(model: &str) -> Option<&'static str> { let trimmed = model.trim(); diff --git a/rust/src/core/cost_pricing/codex.rs b/rust/src/core/cost_pricing/codex.rs index 770e96e36b..62b59ff846 100644 --- a/rust/src/core/cost_pricing/codex.rs +++ b/rust/src/core/cost_pricing/codex.rs @@ -195,6 +195,16 @@ impl CostUsagePricing { } else { pricing.output_cost_per_token }; + let cache_write_rate = if use_tier { + pricing + .cache_write_input_cost_per_token_above_threshold + .or(pricing.cache_write_input_cost_per_token) + .unwrap_or(pricing.input_cost_per_token) + } else { + pricing + .cache_write_input_cost_per_token + .unwrap_or(pricing.input_cost_per_token) + }; Some(codex_cost_from_rates_with_cache_write( input_tokens, cached_input_tokens, @@ -202,7 +212,7 @@ impl CostUsagePricing { output_tokens, input_rate, cache_read_rate, - input_rate, + cache_write_rate, output_rate, )) } diff --git a/rust/src/spend_contract.rs b/rust/src/spend_contract.rs index af9cdfef34..8fb1c593e7 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -289,6 +289,12 @@ impl CustomPricing { .get(&provider_key) .or_else(|| self.entries.get(&model_key)) } + + fn provider_rates(&self, provider_id: &str, model: &str) -> Option<&CustomRates> { + let model_key = model.trim().to_ascii_lowercase(); + let provider_key = format!("{}/{}", provider_id.trim().to_ascii_lowercase(), model_key); + self.entries.get(&provider_key) + } } impl CustomRates { diff --git a/rust/src/spend_contract/opencodex/nous.rs b/rust/src/spend_contract/opencodex/nous.rs index 701ee6bb9f..5c5cb9d543 100644 --- a/rust/src/spend_contract/opencodex/nous.rs +++ b/rust/src/spend_contract/opencodex/nous.rs @@ -23,7 +23,7 @@ pub(super) fn cost( let output = entry.output_tokens.unwrap_or(0); 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(SUBSCRIPTION_ID, &entry.model) { + if let Some(rates) = custom.provider_rates(SUBSCRIPTION_ID, &entry.model) { return rates.cost_parts( input.saturating_add(cache_read), output, diff --git a/rust/src/spend_contract/opencodex/nous_tests.rs b/rust/src/spend_contract/opencodex/nous_tests.rs index a6f55a3ad7..f87adff66f 100644 --- a/rust/src/spend_contract/opencodex/nous_tests.rs +++ b/rust/src/spend_contract/opencodex/nous_tests.rs @@ -119,6 +119,7 @@ fn custom_nous_pricing_prices_each_fixture_row_with_input_excluding_cache() { 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() @@ -166,6 +167,30 @@ fn nous_catalog_cache_reads_are_added_back_to_the_inclusive_input() { 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_requires_a_provider_qualified_override() { + let entry = &fixture_entries()[0]; + let mut custom = custom_pricing(&entry.model); + let rates = custom + .entries + .remove(&format!("nous/{}", entry.model)) + .expect("provider-qualified override"); + custom.entries.insert(entry.model.clone(), rates); + + assert_eq!(entry_cost(entry, &custom, &catalog("")), None); +} + #[test] fn unreported_rows_keep_tokens_without_dollars() { let entry = parse_line(UNREPORTED).expect("row parses"); From 1539d22ffe6c970d916da63976a677985eecd035 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:19:12 +0700 Subject: [PATCH 3/3] Align Nous ledger pricing with upstream --- docs/PROVIDERS.md | 2 +- rust/src/core/codex_routed_pricing.rs | 10 +- rust/src/core/cost_pricing/codex.rs | 73 ++++---- rust/src/core/cost_pricing_tests.rs | 80 +++++++-- rust/src/core/models_dev_pricing.rs | 55 ++++++ rust/src/spend_contract.rs | 6 - rust/src/spend_contract/opencodex.rs | 19 +- rust/src/spend_contract/opencodex/nous.rs | 71 ++++++-- .../spend_contract/opencodex/nous_tests.rs | 165 ++++++++++++++++-- 9 files changed, 387 insertions(+), 94 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index e980e25438..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, DeepSeek, or Nous Portal subscription instead of appearing as a second fake provider. Ledger rows with `provider: "nous"` are priced only from an exact Nous models.dev entry or a `nous/` custom pricing override, never from another vendor's rates, and never touch Portal credit meters. 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/codex_routed_pricing.rs b/rust/src/core/codex_routed_pricing.rs index 2acfec15f9..5d75656691 100644 --- a/rust/src/core/codex_routed_pricing.rs +++ b/rust/src/core/codex_routed_pricing.rs @@ -1,7 +1,7 @@ //! Codex routed-model pricing (upstream 0.50.1 #2946). //! //! Codex rollouts routed through a non-OpenAI backend (DeepSeek, Kimi, -//! OpenCode and Nous) carry the provider as a `provider/model` prefix. This module +//! OpenCode) carry the provider as a `provider/model` prefix. This module //! detects the route and strips the prefix so the cost lookup prices //! against the right models.dev catalog instead of falling back to OpenAI. @@ -9,9 +9,9 @@ /// return the matching models.dev provider id (upstream 0.50.1 #2946). /// /// Known routes: `deepseek/` → "deepseek", `kimi/` → "kimi", -/// `opencode/` → "opencode", `nous/` → "nous". The `openai/` prefix is -/// stripped by [`normalize_codex_model`] and priced against the OpenAI catalog -/// as before. Unknown `provider/` prefixes return `None` here so the caller +/// `opencode/` → "opencode". The `openai/` prefix is stripped by +/// [`normalize_codex_model`] and priced against the OpenAI catalog as +/// before. Unknown `provider/` prefixes return `None` here so the caller /// leaves them unpriced rather than guessing. pub fn codex_routed_provider(model: &str) -> Option<&'static str> { let trimmed = model.trim(); @@ -20,8 +20,6 @@ pub fn codex_routed_provider(model: &str) -> Option<&'static str> { "deepseek" => Some("deepseek"), "kimi" => Some("kimi"), "opencode" => Some("opencode"), - // Nous Portal keeps the vendor-qualified model id whole (`nous/anthropic/claude-...`). - "nous" => Some("nous"), _ => None, } } diff --git a/rust/src/core/cost_pricing/codex.rs b/rust/src/core/cost_pricing/codex.rs index 62b59ff846..97ae798f66 100644 --- a/rust/src/core/cost_pricing/codex.rs +++ b/rust/src/core/cost_pricing/codex.rs @@ -95,7 +95,7 @@ impl CostUsagePricing { ) } - pub(crate) fn codex_cost_usd_with_cache_write_and_pricing_snapshot( + fn codex_cost_usd_with_cache_write_and_pricing_snapshot( model: &str, input_tokens: u64, cached_input_tokens: u64, @@ -168,44 +168,41 @@ 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 - }; - let cache_write_rate = if use_tier { - pricing - .cache_write_input_cost_per_token_above_threshold - .or(pricing.cache_write_input_cost_per_token) - .unwrap_or(pricing.input_cost_per_token) - } else { - pricing - .cache_write_input_cost_per_token - .unwrap_or(pricing.input_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, @@ -214,6 +211,6 @@ impl CostUsagePricing { cache_read_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 83170913fd..106f3b09e0 100644 --- a/rust/src/core/cost_pricing_tests.rs +++ b/rust/src/core/cost_pricing_tests.rs @@ -322,16 +322,6 @@ fn codex_routed_provider_detects_known_routes() { codex_routed_pricing::codex_routed_provider("DeepSeek/deepseek-chat"), Some("deepseek") ); - // Nous keeps the vendor-qualified model id whole after its own prefix. - let nous = "nous/anthropic/claude-sonnet-4.6"; - assert_eq!( - codex_routed_pricing::codex_routed_provider(nous), - Some("nous") - ); - assert_eq!( - codex_routed_pricing::strip_route_prefix(nous), - "anthropic/claude-sonnet-4.6" - ); } #[test] @@ -342,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.rs b/rust/src/spend_contract.rs index 8fb1c593e7..af9cdfef34 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -289,12 +289,6 @@ impl CustomPricing { .get(&provider_key) .or_else(|| self.entries.get(&model_key)) } - - fn provider_rates(&self, provider_id: &str, model: &str) -> Option<&CustomRates> { - let model_key = model.trim().to_ascii_lowercase(); - let provider_key = format!("{}/{}", provider_id.trim().to_ascii_lowercase(), model_key); - self.entries.get(&provider_key) - } } impl CustomRates { diff --git a/rust/src/spend_contract/opencodex.rs b/rust/src/spend_contract/opencodex.rs index f3cc692368..481956a652 100644 --- a/rust/src/spend_contract/opencodex.rs +++ b/rust/src/spend_contract/opencodex.rs @@ -158,13 +158,8 @@ fn aggregate( for entry in &entries { // A row without a conversationId is its own session (upstream 0.68.0). - conversations.insert( - entry - .conversation_id - .as_ref() - .unwrap_or(&entry.request_id) - .clone(), - ); + 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 = @@ -240,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 @@ -370,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 { diff --git a/rust/src/spend_contract/opencodex/nous.rs b/rust/src/spend_contract/opencodex/nous.rs index 5c5cb9d543..dea1a2f16a 100644 --- a/rust/src/spend_contract/opencodex/nous.rs +++ b/rust/src/spend_contract/opencodex/nous.rs @@ -2,41 +2,84 @@ //! //! 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 an exact Nous catalog entry -//! or a `nous/` custom override prices a row. +//! 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"; -/// Ledger convention for Nous rows: `inputTokens` excludes cache reads and -/// writes (a row may carry more cache-read than input tokens), so both are -/// added back for helpers that treat input as the inclusive total. pub(super) fn cost( entry: &OpenCodexEntry, custom: &CustomPricing, pricing_snapshot: &ModelsDevPricingSnapshot, ) -> Option { - let input = entry.input_tokens.unwrap_or(0); - let output = entry.output_tokens.unwrap_or(0); + // 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.provider_rates(SUBSCRIPTION_ID, &entry.model) { + if let Some(rates) = custom_rates(entry, custom) { return rates.cost_parts( - input.saturating_add(cache_read), + input.checked_add(cache_read)?, output, cache_read, cache_write, ); } - CostUsagePricing::codex_cost_usd_with_cache_write_and_pricing_snapshot( - &format!("{SUBSCRIPTION_ID}/{}", entry.model.trim()), - input.saturating_add(cache_read).saturating_add(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, - Some(pricing_snapshot), - ) + )) +} + +/// 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 index f87adff66f..5a93e5a83c 100644 --- a/rust/src/spend_contract/opencodex/nous_tests.rs +++ b/rust/src/spend_contract/opencodex/nous_tests.rs @@ -24,10 +24,12 @@ fn fixture_entries() -> Vec { .collect() } -fn custom_pricing(model: &str) -> CustomPricing { +/// 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([( - format!("nous/{model}"), + key.to_string(), CustomRates { input: Some(2.0), output: Some(8.0), @@ -38,6 +40,17 @@ fn custom_pricing(model: &str) -> CustomPricing { } } +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") } @@ -179,16 +192,146 @@ fn nous_catalog_cache_writes_use_the_catalog_cache_write_rate() { } #[test] -fn nous_custom_pricing_requires_a_provider_qualified_override() { +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 mut custom = custom_pricing(&entry.model); - let rates = custom - .entries - .remove(&format!("nous/{}", entry.model)) - .expect("provider-qualified override"); - custom.entries.insert(entry.model.clone(), rates); - - assert_eq!(entry_cost(entry, &custom, &catalog("")), None); + 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]