| 1 | //! Cost estimation for DeepSeek API usage. |
| 2 | //! |
| 3 | //! Pricing based on DeepSeek's published rates (per million tokens). |
| 4 | |
| 5 | use chrono::{DateTime, TimeZone, Utc}; |
| 6 | |
| 7 | use crate::models::Usage; |
| 8 | |
| 9 | /// Cost display currency. |
| 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 11 | pub enum CostCurrency { |
| 12 | Usd, |
| 13 | Cny, |
| 14 | } |
| 15 | |
| 16 | impl CostCurrency { |
| 17 | pub fn from_setting(value: &str) -> Option<Self> { |
| 18 | match value.trim().to_ascii_lowercase().as_str() { |
| 19 | "usd" | "dollar" | "dollars" | "$" => Some(Self::Usd), |
| 20 | "cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny), |
| 21 | _ => None, |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | fn symbol(self) -> &'static str { |
| 26 | match self { |
| 27 | Self::Usd => "$", |
| 28 | Self::Cny => "¥", |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /// Cost estimate in the two official DeepSeek pricing currencies. |
| 34 | #[derive(Debug, Clone, Copy, Default, PartialEq)] |
| 35 | pub struct CostEstimate { |
| 36 | pub usd: f64, |
| 37 | pub cny: f64, |
| 38 | } |
| 39 | |
| 40 | impl CostEstimate { |
| 41 | #[allow(dead_code)] |
| 42 | pub fn usd_only(usd: f64) -> Self { |
| 43 | Self { usd, cny: 0.0 } |
| 44 | } |
| 45 | |
| 46 | pub fn is_positive(self) -> bool { |
| 47 | self.usd > 0.0 || self.cny > 0.0 |
| 48 | } |
| 49 | |
| 50 | pub fn amount(self, currency: CostCurrency) -> f64 { |
| 51 | match currency { |
| 52 | CostCurrency::Usd => self.usd, |
| 53 | CostCurrency::Cny => self.cny, |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | /// Per-million-token pricing for a model. |
| 59 | #[derive(Debug, Clone, Copy)] |
| 60 | struct CurrencyPricing { |
| 61 | input_cache_hit_per_million: f64, |
| 62 | input_cache_miss_per_million: f64, |
| 63 | output_per_million: f64, |
| 64 | } |
| 65 | |
| 66 | /// Per-million-token pricing for a model in both official currencies. |
| 67 | #[derive(Debug, Clone, Copy)] |
| 68 | struct ModelPricing { |
| 69 | usd: CurrencyPricing, |
| 70 | cny: CurrencyPricing, |
| 71 | } |
| 72 | |
| 73 | fn v4_pro_discount_ends_at() -> DateTime<Utc> { |
| 74 | Utc.with_ymd_and_hms(2026, 5, 31, 15, 59, 0) |
| 75 | .single() |
| 76 | .expect("valid DeepSeek V4 Pro discount end timestamp") |
| 77 | } |
| 78 | |
| 79 | /// Look up pricing for a model name. |
| 80 | fn pricing_for_model(model: &str) -> Option<ModelPricing> { |
| 81 | pricing_for_model_at(model, Utc::now()) |
| 82 | } |
| 83 | |
| 84 | fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing> { |
| 85 | let lower = model.to_lowercase(); |
| 86 | if lower.starts_with("deepseek-ai/") { |
| 87 | // NVIDIA NIM-hosted DeepSeek uses NVIDIA's catalog/account terms, not |
| 88 | // DeepSeek Platform pricing. Avoid showing misleading DeepSeek costs. |
| 89 | return None; |
| 90 | } |
| 91 | if !lower.contains("deepseek") { |
| 92 | return None; |
| 93 | } |
| 94 | if lower.contains("v4-pro") || lower.contains("v4pro") { |
| 95 | if now <= v4_pro_discount_ends_at() { |
| 96 | // DeepSeek lists these as a limited-time 75% discount through |
| 97 | // 2026-05-31 15:59 UTC. |
| 98 | return Some(ModelPricing { |
| 99 | usd: CurrencyPricing { |
| 100 | input_cache_hit_per_million: 0.003625, |
| 101 | input_cache_miss_per_million: 0.435, |
| 102 | output_per_million: 0.87, |
| 103 | }, |
| 104 | cny: CurrencyPricing { |
| 105 | input_cache_hit_per_million: 0.025, |
| 106 | input_cache_miss_per_million: 3.0, |
| 107 | output_per_million: 6.0, |
| 108 | }, |
| 109 | }); |
| 110 | } |
| 111 | Some(ModelPricing { |
| 112 | usd: CurrencyPricing { |
| 113 | input_cache_hit_per_million: 0.0145, |
| 114 | input_cache_miss_per_million: 1.74, |
| 115 | output_per_million: 3.48, |
| 116 | }, |
| 117 | cny: CurrencyPricing { |
| 118 | input_cache_hit_per_million: 0.1, |
| 119 | input_cache_miss_per_million: 12.0, |
| 120 | output_per_million: 24.0, |
| 121 | }, |
| 122 | }) |
| 123 | } else { |
| 124 | // deepseek-v4-flash pricing. |
| 125 | Some(ModelPricing { |
| 126 | usd: CurrencyPricing { |
| 127 | input_cache_hit_per_million: 0.0028, |
| 128 | input_cache_miss_per_million: 0.14, |
| 129 | output_per_million: 0.28, |
| 130 | }, |
| 131 | cny: CurrencyPricing { |
| 132 | input_cache_hit_per_million: 0.02, |
| 133 | input_cache_miss_per_million: 1.0, |
| 134 | output_per_million: 2.0, |
| 135 | }, |
| 136 | }) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// Calculate cost for a turn given token usage and model. |
| 141 | #[must_use] |
| 142 | #[allow(dead_code)] |
| 143 | pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> { |
| 144 | calculate_turn_cost_estimate(model, input_tokens, output_tokens).map(|estimate| estimate.usd) |
| 145 | } |
| 146 | |
| 147 | /// Calculate cost for a turn in both official currencies. |
| 148 | #[must_use] |
| 149 | pub fn calculate_turn_cost_estimate( |
| 150 | model: &str, |
| 151 | input_tokens: u32, |
| 152 | output_tokens: u32, |
| 153 | ) -> Option<CostEstimate> { |
| 154 | let pricing = pricing_for_model(model)?; |
| 155 | Some(CostEstimate { |
| 156 | usd: calculate_turn_cost_with_pricing(pricing.usd, input_tokens, output_tokens), |
| 157 | cny: calculate_turn_cost_with_pricing(pricing.cny, input_tokens, output_tokens), |
| 158 | }) |
| 159 | } |
| 160 | |
| 161 | fn calculate_turn_cost_with_pricing( |
| 162 | pricing: CurrencyPricing, |
| 163 | input_tokens: u32, |
| 164 | output_tokens: u32, |
| 165 | ) -> f64 { |
| 166 | let input_cost = (input_tokens as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million; |
| 167 | let output_cost = (output_tokens as f64 / 1_000_000.0) * pricing.output_per_million; |
| 168 | input_cost + output_cost |
| 169 | } |
| 170 | |
| 171 | /// Calculate cost from provider usage, honoring DeepSeek context-cache fields. |
| 172 | #[must_use] |
| 173 | pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64> { |
| 174 | calculate_turn_cost_estimate_from_usage(model, usage).map(|estimate| estimate.usd) |
| 175 | } |
| 176 | |
| 177 | /// Calculate cost from provider usage in both official currencies. |
| 178 | #[must_use] |
| 179 | pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Option<CostEstimate> { |
| 180 | let pricing = pricing_for_model(model)?; |
| 181 | Some(CostEstimate { |
| 182 | usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), |
| 183 | cny: calculate_turn_cost_from_usage_with_pricing(pricing.cny, usage), |
| 184 | }) |
| 185 | } |
| 186 | |
| 187 | fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: &Usage) -> f64 { |
| 188 | let hit_tokens = usage.prompt_cache_hit_tokens.unwrap_or(0); |
| 189 | let miss_tokens = usage |
| 190 | .prompt_cache_miss_tokens |
| 191 | .unwrap_or_else(|| usage.input_tokens.saturating_sub(hit_tokens)); |
| 192 | let accounted_input = hit_tokens.saturating_add(miss_tokens); |
| 193 | let uncategorized_input = usage.input_tokens.saturating_sub(accounted_input); |
| 194 | |
| 195 | let hit_cost = (hit_tokens as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million; |
| 196 | let miss_cost = ((miss_tokens.saturating_add(uncategorized_input)) as f64 / 1_000_000.0) |
| 197 | * pricing.input_cache_miss_per_million; |
| 198 | let output_cost = (usage.output_tokens as f64 / 1_000_000.0) * pricing.output_per_million; |
| 199 | hit_cost + miss_cost + output_cost |
| 200 | } |
| 201 | |
| 202 | /// Format a USD cost for compact display. |
| 203 | #[must_use] |
| 204 | #[allow(dead_code)] |
| 205 | pub fn format_cost(cost: f64) -> String { |
| 206 | format_cost_amount(cost, CostCurrency::Usd) |
| 207 | } |
| 208 | |
| 209 | /// Format a cost amount for compact display in the chosen currency. |
| 210 | #[must_use] |
| 211 | pub fn format_cost_amount(cost: f64, currency: CostCurrency) -> String { |
| 212 | let symbol = currency.symbol(); |
| 213 | if cost < 0.0001 { |
| 214 | format!("<{symbol}0.0001") |
| 215 | } else if cost < 0.01 { |
| 216 | format!("{symbol}{cost:.4}") |
| 217 | } else { |
| 218 | format!("{symbol}{cost:.2}") |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | /// Format a cost amount for detailed reports in the chosen currency. |
| 223 | #[must_use] |
| 224 | pub fn format_cost_amount_precise(cost: f64, currency: CostCurrency) -> String { |
| 225 | let symbol = currency.symbol(); |
| 226 | if cost < 0.0001 { |
| 227 | format!("<{symbol}0.0001") |
| 228 | } else { |
| 229 | format!("{symbol}{cost:.4}") |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | /// Format a dual-currency estimate using the selected display currency. |
| 234 | #[must_use] |
| 235 | pub fn format_cost_estimate(estimate: CostEstimate, currency: CostCurrency) -> String { |
| 236 | format_cost_amount(estimate.amount(currency), currency) |
| 237 | } |
| 238 | |
| 239 | #[cfg(test)] |
| 240 | mod tests { |
| 241 | use super::*; |
| 242 | |
| 243 | #[test] |
| 244 | fn nvidia_nim_deepseek_model_does_not_use_deepseek_platform_pricing() { |
| 245 | assert!(calculate_turn_cost("deepseek-ai/deepseek-v4-pro", 1_000, 1_000).is_none()); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn v4_pro_uses_limited_time_discount_before_expiry() { |
| 250 | let before_expiry = Utc |
| 251 | .with_ymd_and_hms(2026, 5, 31, 15, 58, 59) |
| 252 | .single() |
| 253 | .unwrap(); |
| 254 | let pricing = pricing_for_model_at("deepseek-v4-pro", before_expiry).unwrap(); |
| 255 | |
| 256 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625); |
| 257 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435); |
| 258 | assert_eq!(pricing.usd.output_per_million, 0.87); |
| 259 | assert_eq!(pricing.cny.input_cache_hit_per_million, 0.025); |
| 260 | assert_eq!(pricing.cny.input_cache_miss_per_million, 3.0); |
| 261 | assert_eq!(pricing.cny.output_per_million, 6.0); |
| 262 | } |
| 263 | |
| 264 | #[test] |
| 265 | fn v4_pro_returns_to_base_rates_after_discount_expiry() { |
| 266 | let after_expiry = Utc |
| 267 | .with_ymd_and_hms(2026, 5, 31, 16, 0, 0) |
| 268 | .single() |
| 269 | .unwrap(); |
| 270 | let pricing = pricing_for_model_at("deepseek-v4-pro", after_expiry).unwrap(); |
| 271 | |
| 272 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.0145); |
| 273 | assert_eq!(pricing.usd.input_cache_miss_per_million, 1.74); |
| 274 | assert_eq!(pricing.usd.output_per_million, 3.48); |
| 275 | assert_eq!(pricing.cny.input_cache_hit_per_million, 0.1); |
| 276 | assert_eq!(pricing.cny.input_cache_miss_per_million, 12.0); |
| 277 | assert_eq!(pricing.cny.output_per_million, 24.0); |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn v4_pro_discount_still_applies_just_before_old_may5_expiry() { |
| 282 | // Regression for #267: extension to 2026-05-31 15:59 UTC. |
| 283 | let after_old_expiry = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).single().unwrap(); |
| 284 | let pricing = pricing_for_model_at("deepseek-v4-pro", after_old_expiry).unwrap(); |
| 285 | |
| 286 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625); |
| 287 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435); |
| 288 | assert_eq!(pricing.usd.output_per_million, 0.87); |
| 289 | } |
| 290 | |
| 291 | #[test] |
| 292 | fn v4_flash_keeps_current_published_rates() { |
| 293 | let now = Utc.with_ymd_and_hms(2026, 4, 25, 0, 0, 0).single().unwrap(); |
| 294 | let pricing = pricing_for_model_at("deepseek-v4-flash", now).unwrap(); |
| 295 | |
| 296 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.0028); |
| 297 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.14); |
| 298 | assert_eq!(pricing.usd.output_per_million, 0.28); |
| 299 | assert_eq!(pricing.cny.input_cache_hit_per_million, 0.02); |
| 300 | assert_eq!(pricing.cny.input_cache_miss_per_million, 1.0); |
| 301 | assert_eq!(pricing.cny.output_per_million, 2.0); |
| 302 | } |
| 303 | |
| 304 | #[test] |
| 305 | fn cost_estimate_calculates_usd_and_cny() { |
| 306 | let estimate = calculate_turn_cost_estimate("deepseek-v4-flash", 1_000_000, 500_000) |
| 307 | .expect("estimate"); |
| 308 | |
| 309 | assert_eq!(estimate.usd, 0.28); |
| 310 | assert_eq!(estimate.cny, 2.0); |
| 311 | } |
| 312 | |
| 313 | #[test] |
| 314 | fn cost_currency_accepts_yuan_aliases() { |
| 315 | assert_eq!(CostCurrency::from_setting("usd"), Some(CostCurrency::Usd)); |
| 316 | assert_eq!(CostCurrency::from_setting("yuan"), Some(CostCurrency::Cny)); |
| 317 | assert_eq!(CostCurrency::from_setting("rmb"), Some(CostCurrency::Cny)); |
| 318 | assert_eq!(CostCurrency::from_setting("cny"), Some(CostCurrency::Cny)); |
| 319 | assert_eq!(CostCurrency::from_setting("eur"), None); |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn format_cost_amount_uses_selected_symbol() { |
| 324 | assert_eq!(format_cost_amount(0.42, CostCurrency::Usd), "$0.42"); |
| 325 | assert_eq!(format_cost_amount(2.0, CostCurrency::Cny), "¥2.00"); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn format_cost_amount_precise_keeps_report_precision() { |
| 330 | assert_eq!( |
| 331 | format_cost_amount_precise(0.1234, CostCurrency::Usd), |
| 332 | "$0.1234" |
| 333 | ); |
| 334 | assert_eq!( |
| 335 | format_cost_amount_precise(0.1234, CostCurrency::Cny), |
| 336 | "¥0.1234" |
| 337 | ); |
| 338 | } |
| 339 | } |
| 340 |