| 1 | //! Cost estimation for API usage. |
| 2 | //! |
| 3 | //! Pricing is stored per million tokens. DeepSeek rows include their published |
| 4 | //! CNY rates; OpenRouter-curated rows are USD-only. Direct Xiaomi MiMo Token |
| 5 | //! Plan usage is credit/quota based and is intentionally left unknown until a |
| 6 | //! reliable balance endpoint exists. |
| 7 | |
| 8 | use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike, Utc, Weekday}; |
| 9 | use codewhale_config::pricing::{ |
| 10 | Currency, LIVE_PRICING_MAX_AGE_SECS, LivePricingDefect, OfferingPricing, PricingProvenance, |
| 11 | TokenClass, TokenUsage, |
| 12 | }; |
| 13 | |
| 14 | #[cfg(test)] |
| 15 | use crate::config::DEFAULT_STEPFUN_MODEL; |
| 16 | use crate::config::{ |
| 17 | DEEPSEEK_ALIAS_REPLACEMENT, DEEPSEEK_ALIAS_RETIREMENT_UTC, DEFAULT_STEPFUN_BASE_URL, |
| 18 | DEFAULT_STEPFUN_PLAN_BASE_URL, ProviderKind, canonical_model_id_for_provider, |
| 19 | }; |
| 20 | use codewhale_models::{Usage, has_date_snapshot_suffix}; |
| 21 | |
| 22 | /// Cost display currency. |
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 24 | pub enum CostCurrency { |
| 25 | Usd, |
| 26 | Cny, |
| 27 | } |
| 28 | |
| 29 | impl CostCurrency { |
| 30 | pub fn from_setting(value: &str) -> Option<Self> { |
| 31 | match value.trim().to_ascii_lowercase().as_str() { |
| 32 | "usd" | "dollar" | "dollars" | "$" => Some(Self::Usd), |
| 33 | "cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny), |
| 34 | _ => None, |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | fn symbol(self) -> &'static str { |
| 39 | match self { |
| 40 | Self::Usd => "$", |
| 41 | Self::Cny => "¥", |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /// Cost estimate in displayable currencies. |
| 47 | #[derive(Debug, Clone, Copy, Default, PartialEq)] |
| 48 | pub struct CostEstimate { |
| 49 | pub usd: f64, |
| 50 | pub cny: f64, |
| 51 | } |
| 52 | |
| 53 | impl CostEstimate { |
| 54 | pub fn usd_only(usd: f64) -> Self { |
| 55 | Self { usd, cny: 0.0 } |
| 56 | } |
| 57 | |
| 58 | pub fn is_positive(self) -> bool { |
| 59 | self.is_finite_nonnegative() && (self.usd > 0.0 || self.cny > 0.0) |
| 60 | } |
| 61 | |
| 62 | /// A cost is safe to persist/display only when both carried currencies are |
| 63 | /// finite and nonnegative. |
| 64 | #[must_use] |
| 65 | pub fn is_finite_nonnegative(self) -> bool { |
| 66 | self.usd.is_finite() && self.usd >= 0.0 && self.cny.is_finite() && self.cny >= 0.0 |
| 67 | } |
| 68 | |
| 69 | #[must_use] |
| 70 | pub fn sanitized(self) -> Self { |
| 71 | Self { |
| 72 | usd: if self.usd.is_finite() && self.usd >= 0.0 { |
| 73 | self.usd |
| 74 | } else { |
| 75 | 0.0 |
| 76 | }, |
| 77 | cny: if self.cny.is_finite() && self.cny >= 0.0 { |
| 78 | self.cny |
| 79 | } else { |
| 80 | 0.0 |
| 81 | }, |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// Add cost without ever producing NaN, infinity, or a negative total. |
| 86 | /// Individual pricing rows are validated earlier; the saturation protects |
| 87 | /// long-running accumulation from floating-point overflow. |
| 88 | #[must_use] |
| 89 | pub fn saturating_add(self, rhs: Self) -> Self { |
| 90 | fn component(left: f64, right: f64) -> f64 { |
| 91 | let sum = left + right; |
| 92 | if sum.is_finite() { sum } else { f64::MAX } |
| 93 | } |
| 94 | let left = self.sanitized(); |
| 95 | let right = rhs.sanitized(); |
| 96 | Self { |
| 97 | usd: component(left.usd, right.usd), |
| 98 | cny: component(left.cny, right.cny), |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | pub fn amount(self, currency: CostCurrency) -> f64 { |
| 103 | match currency { |
| 104 | CostCurrency::Usd => self.usd, |
| 105 | CostCurrency::Cny => self.cny, |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // === Provider Account Balance === |
| 111 | |
| 112 | /// Response from DeepSeek `GET /user/balance`. Other prepaid providers are |
| 113 | /// mapped onto [`BalanceInfo`] at the fetch seam. |
| 114 | #[derive(Debug, Clone, Default, serde::Deserialize)] |
| 115 | pub struct BalanceResponse { |
| 116 | #[cfg_attr(not(test), expect(dead_code))] |
| 117 | pub is_available: bool, |
| 118 | pub balance_infos: Vec<BalanceInfo>, |
| 119 | } |
| 120 | |
| 121 | /// Per-currency remaining-credit entry shown by `/balance` and the status chip. |
| 122 | #[derive(Debug, Clone, Default, serde::Deserialize)] |
| 123 | pub struct BalanceInfo { |
| 124 | pub currency: String, |
| 125 | #[serde(default)] |
| 126 | pub total_balance: String, |
| 127 | #[serde(default)] |
| 128 | pub topped_up_balance: String, |
| 129 | #[serde(default)] |
| 130 | pub granted_balance: String, |
| 131 | } |
| 132 | |
| 133 | impl BalanceInfo { |
| 134 | /// Compact ledger chip, e.g. `$12.50` or `¥123.45`. |
| 135 | #[must_use] |
| 136 | pub fn chip_label(&self) -> Option<String> { |
| 137 | let amount = self.total_balance.trim(); |
| 138 | if amount.is_empty() { |
| 139 | return None; |
| 140 | } |
| 141 | Some(format_balance_amount(amount, &self.currency)) |
| 142 | } |
| 143 | |
| 144 | /// Full `/balance` report for one prepaid provider. |
| 145 | #[must_use] |
| 146 | pub fn report(&self, provider_name: &str) -> String { |
| 147 | let amount = self |
| 148 | .chip_label() |
| 149 | .unwrap_or_else(|| self.total_balance.trim().to_string()); |
| 150 | let mut report = if amount.is_empty() { |
| 151 | format!("{provider_name} account balance is unknown") |
| 152 | } else { |
| 153 | format!("{provider_name} account balance: {amount}") |
| 154 | }; |
| 155 | let topped = self.topped_up_balance.trim(); |
| 156 | let granted = self.granted_balance.trim(); |
| 157 | if !topped.is_empty() || !granted.is_empty() { |
| 158 | let mut parts = Vec::new(); |
| 159 | if !topped.is_empty() { |
| 160 | parts.push(format!("topped up {topped}")); |
| 161 | } |
| 162 | if !granted.is_empty() { |
| 163 | parts.push(format!("granted {granted}")); |
| 164 | } |
| 165 | report.push_str(&format!(" ({})", parts.join(", "))); |
| 166 | } |
| 167 | report |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | fn format_balance_amount(amount: &str, currency: &str) -> String { |
| 172 | match currency.trim().to_ascii_uppercase().as_str() { |
| 173 | "CNY" | "RMB" | "¥" => format!("¥{amount}"), |
| 174 | "USD" | "US$" | "$" => format!("${amount}"), |
| 175 | "" => amount.to_string(), |
| 176 | other => format!("{amount} {other}"), |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /// How a hand-sourced row bills cache-creation (cache-write) tokens. |
| 181 | /// |
| 182 | /// The distinction matters because "no separate write rate published" and |
| 183 | /// "documented to cost the same as ordinary input" are different facts that used |
| 184 | /// to collapse onto the same `None`. Folding the unknown case into the input |
| 185 | /// rate invents a price; this enum keeps the invention impossible (#4318). |
| 186 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 187 | enum CacheWritePolicy { |
| 188 | /// The provider publishes a distinct cache-creation rate (per million). |
| 189 | Rate(f64), |
| 190 | /// Provider documentation states that cache creation carries **no separate |
| 191 | /// charge** beyond the ordinary cache-miss input rate, so the miss rate is |
| 192 | /// the published write rate rather than a substitute for a missing one. |
| 193 | /// |
| 194 | /// The `&'static str` is the documentation receipt this claim rests on, so |
| 195 | /// the policy is auditable instead of asserted. |
| 196 | DocumentedAsInputRate(&'static str), |
| 197 | /// No published cache-write rate was found for this row. A turn that |
| 198 | /// actually wrote to cache fails closed rather than being billed at a rate |
| 199 | /// CodeWhale made up. |
| 200 | Unpublished, |
| 201 | } |
| 202 | |
| 203 | /// DeepSeek's context-caching docs: tokens that miss the cache are billed once |
| 204 | /// at the cache-miss rate and writing them into the cache costs nothing extra. |
| 205 | /// <https://api-docs.deepseek.com/guides/kv_cache> |
| 206 | const DEEPSEEK_CACHE_WRITE_IS_FREE: &str = "deepseek-kv-cache-no-write-charge"; |
| 207 | |
| 208 | impl CacheWritePolicy { |
| 209 | /// The rate to bill cache-write tokens at, given the row's input rate. |
| 210 | /// |
| 211 | /// `None` means the row cannot price cache-write tokens at all. |
| 212 | fn rate(self, input_cache_miss_per_million: f64) -> Option<f64> { |
| 213 | match self { |
| 214 | Self::Rate(rate) => Some(rate), |
| 215 | Self::DocumentedAsInputRate(_) => Some(input_cache_miss_per_million), |
| 216 | Self::Unpublished => None, |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | /// Per-million-token pricing for a model. |
| 222 | #[derive(Debug, Clone, Copy)] |
| 223 | struct CurrencyPricing { |
| 224 | input_cache_hit_per_million: f64, |
| 225 | input_cache_miss_per_million: f64, |
| 226 | output_per_million: f64, |
| 227 | /// How cache-creation tokens are billed on this row. |
| 228 | cache_write: CacheWritePolicy, |
| 229 | } |
| 230 | |
| 231 | /// Per-million-token pricing for a model. |
| 232 | #[derive(Debug, Clone, Copy)] |
| 233 | struct ModelPricing { |
| 234 | usd: CurrencyPricing, |
| 235 | cny: Option<CurrencyPricing>, |
| 236 | } |
| 237 | |
| 238 | pub(crate) const STEPFUN_PAYG_BILLING_SURFACE: &str = "stepfun-payg"; |
| 239 | pub(crate) const STEPFUN_PLAN_BILLING_SURFACE: &str = "stepfun-plan"; |
| 240 | const LEGACY_STEPFUN_PLAN_BASE_URL: &str = "https://api.stepfun.com/step_plan/v1"; |
| 241 | |
| 242 | /// Z.ai's dedicated Coding endpoint — the GLM Coding Plan subscription route. |
| 243 | pub(crate) const ZAI_CODING_PLAN_BILLING_SURFACE: &str = "zai-coding-plan"; |
| 244 | /// Z.ai's ordinary public per-token API. |
| 245 | pub(crate) const ZAI_PAYG_BILLING_SURFACE: &str = "zai-payg"; |
| 246 | /// Moonshot's Kimi Code subscription endpoint. |
| 247 | pub(crate) const MOONSHOT_KIMI_CODE_BILLING_SURFACE: &str = "moonshot-kimi-code"; |
| 248 | /// Moonshot's ordinary public per-token API. |
| 249 | pub(crate) const MOONSHOT_PAYG_BILLING_SURFACE: &str = "moonshot-payg"; |
| 250 | /// MiniMax's prepaid Token Plan endpoint. |
| 251 | pub(crate) const MINIMAX_TOKEN_PLAN_BILLING_SURFACE: &str = "minimax-token-plan"; |
| 252 | /// MiniMax's ordinary public per-token API. |
| 253 | pub(crate) const MINIMAX_PAYG_BILLING_SURFACE: &str = "minimax-payg"; |
| 254 | /// Xiaomi MiMo's prepaid token-plan endpoint. |
| 255 | pub(crate) const XIAOMI_TOKEN_PLAN_BILLING_SURFACE: &str = "xiaomi-mimo-token-plan"; |
| 256 | /// Xiaomi MiMo's ordinary public per-token API. |
| 257 | pub(crate) const XIAOMI_PAYG_BILLING_SURFACE: &str = "xiaomi-mimo-payg"; |
| 258 | /// An OAuth/subscription-brokered endpoint (Codex, Claude OAuth, Grok OAuth, |
| 259 | /// OpenCode Go). Never per-token metered from CodeWhale's side. |
| 260 | pub(crate) const OAUTH_SUBSCRIPTION_BILLING_SURFACE: &str = "oauth-subscription"; |
| 261 | /// A loopback / self-hosted endpoint with no provider bill at all. |
| 262 | pub(crate) const LOCAL_BILLING_SURFACE: &str = "local-no-bill"; |
| 263 | /// A provider's own first-party public per-token API, on its documented host. |
| 264 | pub(crate) const FIRST_PARTY_PAYG_BILLING_SURFACE: &str = "first-party-payg"; |
| 265 | /// An aggregator/reseller endpoint: metered, but priced by the aggregator's own |
| 266 | /// catalog rather than by the upstream model owner's published rates. |
| 267 | pub(crate) const AGGREGATOR_BILLING_SURFACE: &str = "aggregator-payg"; |
| 268 | pub(crate) const MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE: &str = "modelstudio-token-plan"; |
| 269 | pub(crate) const MODELSTUDIO_CODING_PLAN_BILLING_SURFACE: &str = "modelstudio-coding-plan"; |
| 270 | pub(crate) const VOLCENGINE_CODING_PLAN_BILLING_SURFACE: &str = "volcengine-coding-plan"; |
| 271 | /// CSDN 星图's Coding Plan subscription product (the `glm_for_coding` route). |
| 272 | pub(crate) const CSDN_CODING_PLAN_BILLING_SURFACE: &str = "csdn-coding-plan"; |
| 273 | /// CSDN 星图's ordinary metered marketplace access on the same endpoint. |
| 274 | pub(crate) const CSDN_PAYG_BILLING_SURFACE: &str = "csdn-payg"; |
| 275 | /// A reachable endpoint CodeWhale could not match to any known billing surface. |
| 276 | /// Distinct from "not classified yet": this is a positive statement that the |
| 277 | /// surface is unknown, and it fails closed everywhere it is consumed. |
| 278 | pub(crate) const UNCLASSIFIED_BILLING_SURFACE: &str = "unclassified"; |
| 279 | |
| 280 | /// How a classified billing surface meters money. |
| 281 | /// |
| 282 | /// This is the fact every cost surface actually needs: whether a dollar figure |
| 283 | /// is even the right unit for the route. `Unknown` is a real answer and is |
| 284 | /// treated as *possibly* metered — it is counted as missing spend rather than |
| 285 | /// excused as a subscription (#4318). |
| 286 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 287 | pub enum EndpointMetering { |
| 288 | /// Per-token money, priced against published rates. |
| 289 | Money, |
| 290 | /// An exactly-identified subscription or prepaid-quota endpoint. Money is |
| 291 | /// the wrong unit here, so these turns are excluded from money coverage. |
| 292 | ExactSubscription, |
| 293 | /// Local/self-hosted: there is no provider bill. |
| 294 | LocalNoBill, |
| 295 | /// Could not be established. Fails closed as possibly-money. |
| 296 | Unknown, |
| 297 | } |
| 298 | |
| 299 | /// Classify a billing-surface id into its metering shape. |
| 300 | /// |
| 301 | /// Unrecognized ids — including ones written by a newer build — resolve to |
| 302 | /// [`EndpointMetering::Unknown`] rather than being guessed into a bucket. |
| 303 | #[must_use] |
| 304 | pub fn endpoint_metering_for_billing_surface(billing_surface: Option<&str>) -> EndpointMetering { |
| 305 | let Some(surface) = billing_surface.map(str::trim).filter(|s| !s.is_empty()) else { |
| 306 | return EndpointMetering::Unknown; |
| 307 | }; |
| 308 | // Exact, case-insensitive matches only. A prefix/substring rule here would |
| 309 | // let an unrecognized future surface impersonate a known one. |
| 310 | for (known, metering) in [ |
| 311 | (STEPFUN_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 312 | (ZAI_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 313 | (MOONSHOT_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 314 | (MINIMAX_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 315 | (XIAOMI_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 316 | (CSDN_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 317 | (FIRST_PARTY_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 318 | (AGGREGATOR_BILLING_SURFACE, EndpointMetering::Money), |
| 319 | ( |
| 320 | MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE, |
| 321 | EndpointMetering::ExactSubscription, |
| 322 | ), |
| 323 | ( |
| 324 | MODELSTUDIO_CODING_PLAN_BILLING_SURFACE, |
| 325 | EndpointMetering::ExactSubscription, |
| 326 | ), |
| 327 | ( |
| 328 | VOLCENGINE_CODING_PLAN_BILLING_SURFACE, |
| 329 | EndpointMetering::ExactSubscription, |
| 330 | ), |
| 331 | ( |
| 332 | STEPFUN_PLAN_BILLING_SURFACE, |
| 333 | EndpointMetering::ExactSubscription, |
| 334 | ), |
| 335 | ( |
| 336 | ZAI_CODING_PLAN_BILLING_SURFACE, |
| 337 | EndpointMetering::ExactSubscription, |
| 338 | ), |
| 339 | ( |
| 340 | MOONSHOT_KIMI_CODE_BILLING_SURFACE, |
| 341 | EndpointMetering::ExactSubscription, |
| 342 | ), |
| 343 | ( |
| 344 | MINIMAX_TOKEN_PLAN_BILLING_SURFACE, |
| 345 | EndpointMetering::ExactSubscription, |
| 346 | ), |
| 347 | ( |
| 348 | XIAOMI_TOKEN_PLAN_BILLING_SURFACE, |
| 349 | EndpointMetering::ExactSubscription, |
| 350 | ), |
| 351 | ( |
| 352 | CSDN_CODING_PLAN_BILLING_SURFACE, |
| 353 | EndpointMetering::ExactSubscription, |
| 354 | ), |
| 355 | ( |
| 356 | OAUTH_SUBSCRIPTION_BILLING_SURFACE, |
| 357 | EndpointMetering::ExactSubscription, |
| 358 | ), |
| 359 | (LOCAL_BILLING_SURFACE, EndpointMetering::LocalNoBill), |
| 360 | (UNCLASSIFIED_BILLING_SURFACE, EndpointMetering::Unknown), |
| 361 | ] { |
| 362 | if surface.eq_ignore_ascii_case(known) { |
| 363 | return metering; |
| 364 | } |
| 365 | } |
| 366 | EndpointMetering::Unknown |
| 367 | } |
| 368 | |
| 369 | /// A base URL reduced to the non-secret parts a billing classification may |
| 370 | /// depend on: scheme, host, normalized path. `None` when the URL carries |
| 371 | /// embedded credentials, a query, a fragment, a non-default port, or is not |
| 372 | /// HTTPS — any of which means CodeWhale cannot vouch for which surface it is. |
| 373 | struct EndpointShape { |
| 374 | host: String, |
| 375 | path: String, |
| 376 | } |
| 377 | |
| 378 | fn endpoint_shape(base_url: &str) -> Option<EndpointShape> { |
| 379 | let parsed = reqwest::Url::parse(base_url.trim()).ok()?; |
| 380 | if parsed.scheme() != "https" |
| 381 | || !parsed.username().is_empty() |
| 382 | || parsed.password().is_some() |
| 383 | || parsed.query().is_some() |
| 384 | || parsed.fragment().is_some() |
| 385 | || parsed.port_or_known_default() != Some(443) |
| 386 | { |
| 387 | return None; |
| 388 | } |
| 389 | Some(EndpointShape { |
| 390 | host: parsed.host_str()?.to_ascii_lowercase(), |
| 391 | path: parsed.path().trim_end_matches('/').to_string(), |
| 392 | }) |
| 393 | } |
| 394 | |
| 395 | fn host_of(url: &str) -> Option<String> { |
| 396 | reqwest::Url::parse(url) |
| 397 | .ok()? |
| 398 | .host_str() |
| 399 | .map(str::to_ascii_lowercase) |
| 400 | } |
| 401 | |
| 402 | /// Historical Codex backend receipts retain their original quota basis. |
| 403 | /// New Sign in with ChatGPT requests use the public API and additionally need |
| 404 | /// captured grant provenance; this endpoint check never proves that grant. |
| 405 | pub(crate) fn is_chatgpt_codex_backend(base_url: &str) -> bool { |
| 406 | let Some(shape) = endpoint_shape(base_url) else { |
| 407 | return false; |
| 408 | }; |
| 409 | shape.host == "chatgpt.com" |
| 410 | && (shape.path == "/backend-api" || shape.path.starts_with("/backend-api/")) |
| 411 | } |
| 412 | |
| 413 | /// The public API base documented for official ChatGPT plan inference. |
| 414 | /// The same endpoint accepts metered API keys: this shape alone is not a |
| 415 | /// subscription claim. [`crate::route_billing`] also requires a verified grant. |
| 416 | pub(crate) fn is_official_chatgpt_api(base_url: &str) -> bool { |
| 417 | endpoint_shape(base_url) |
| 418 | .is_some_and(|shape| shape.host == "api.openai.com" && shape.path == "/v1") |
| 419 | } |
| 420 | |
| 421 | /// Reduce a concrete request endpoint to non-secret billing provenance. |
| 422 | /// |
| 423 | /// Every reachable endpoint now gets a positive classification, including |
| 424 | /// [`UNCLASSIFIED_BILLING_SURFACE`] for one CodeWhale cannot place. `None` is |
| 425 | /// reserved for "no endpoint was supplied", which is a different failure and is |
| 426 | /// also treated as unknown downstream. Nothing here consults credentials or |
| 427 | /// echoes a URL, so the result is safe to persist and log. |
| 428 | pub(crate) fn billing_surface_for_route( |
| 429 | provider: ProviderKind, |
| 430 | base_url: Option<&str>, |
| 431 | ) -> Option<&'static str> { |
| 432 | // Routes whose billing shape is a property of the provider itself, not of |
| 433 | // the endpoint spelling. |
| 434 | match provider { |
| 435 | ProviderKind::Ollama | ProviderKind::Sglang | ProviderKind::Vllm => { |
| 436 | return Some(LOCAL_BILLING_SURFACE); |
| 437 | } |
| 438 | // Ollama Cloud publishes plan/account terms, not a Codewhale-owned |
| 439 | // per-token rate. Hosted is not local/free, but it is also not proof |
| 440 | // of PAYG dollars: keep it in money coverage as unclassified until an |
| 441 | // authoritative billing surface is available. |
| 442 | ProviderKind::OllamaCloud => return Some(UNCLASSIFIED_BILLING_SURFACE), |
| 443 | ProviderKind::OpencodeGo => return Some(OAUTH_SUBSCRIPTION_BILLING_SURFACE), |
| 444 | // Preserve historical backend provenance, but public API plan access |
| 445 | // is credential-shaped. Without the captured official grant it stays |
| 446 | // unknown, including when no endpoint was supplied. |
| 447 | ProviderKind::OpenaiCodex => { |
| 448 | return Some(if base_url.is_some_and(is_chatgpt_codex_backend) { |
| 449 | OAUTH_SUBSCRIPTION_BILLING_SURFACE |
| 450 | } else { |
| 451 | UNCLASSIFIED_BILLING_SURFACE |
| 452 | }); |
| 453 | } |
| 454 | // A named custom endpoint is never assumed to be metered; the billing |
| 455 | // presentation layer decides that from explicit config. |
| 456 | ProviderKind::Custom => return Some(UNCLASSIFIED_BILLING_SURFACE), |
| 457 | _ => {} |
| 458 | } |
| 459 | |
| 460 | let base_url = base_url.map(str::trim).filter(|url| !url.is_empty())?; |
| 461 | let Some(shape) = endpoint_shape(base_url) else { |
| 462 | return Some(UNCLASSIFIED_BILLING_SURFACE); |
| 463 | }; |
| 464 | |
| 465 | let surface = match provider { |
| 466 | ProviderKind::Stepfun => stepfun_surface(&shape), |
| 467 | ProviderKind::Zai => zai_surface(&shape), |
| 468 | ProviderKind::Moonshot => moonshot_surface(&shape), |
| 469 | ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => minimax_surface(&shape), |
| 470 | ProviderKind::Csdn => csdn_surface(&shape), |
| 471 | ProviderKind::XiaomiMimo => xiaomi_surface(&shape), |
| 472 | ProviderKind::ModelstudioTokenPlan |
| 473 | | ProviderKind::ModelstudioTokenPlanAnthropic |
| 474 | | ProviderKind::ModelstudioCodingPlan |
| 475 | | ProviderKind::ModelstudioCodingPlanAnthropic => modelstudio_surface(&shape), |
| 476 | ProviderKind::Volcengine => volcengine_surface(&shape), |
| 477 | ProviderKind::Openrouter |
| 478 | | ProviderKind::NvidiaNim |
| 479 | | ProviderKind::OpencodeZen |
| 480 | | ProviderKind::Orcarouter => { |
| 481 | is_official_default_endpoint(provider, &shape).then_some(AGGREGATOR_BILLING_SURFACE) |
| 482 | } |
| 483 | _ => is_official_default_endpoint(provider, &shape) |
| 484 | .then_some(FIRST_PARTY_PAYG_BILLING_SURFACE), |
| 485 | }; |
| 486 | Some(surface.unwrap_or(UNCLASSIFIED_BILLING_SURFACE)) |
| 487 | } |
| 488 | |
| 489 | // Token Plan and Coding Plan keys/endpoints are isolated from PAYG. |
| 490 | // https://www.alibabacloud.com/help/en/model-studio/token-plan-quick-start |
| 491 | // https://www.alibabacloud.com/help/en/model-studio/coding-plan-faq |
| 492 | fn modelstudio_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 493 | match (shape.host.as_str(), shape.path.as_str()) { |
| 494 | ( |
| 495 | "token-plan.ap-southeast-1.maas.aliyuncs.com", |
| 496 | "/compatible-mode/v1" | "/apps/anthropic" | "/apps/anthropic/v1", |
| 497 | ) => Some(MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE), |
| 498 | ( |
| 499 | "coding-intl.dashscope.aliyuncs.com" | "coding.dashscope.aliyuncs.com", |
| 500 | "/v1" | "/apps/anthropic" | "/apps/anthropic/v1", |
| 501 | ) => Some(MODELSTUDIO_CODING_PLAN_BILLING_SURFACE), |
| 502 | ("dashscope-intl.aliyuncs.com" | "dashscope.aliyuncs.com", "/compatible-mode/v1") => { |
| 503 | Some(FIRST_PARTY_PAYG_BILLING_SURFACE) |
| 504 | } |
| 505 | _ => None, |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | // The Coding Plan gateway consumes plan quota; /api/v3 is billed separately. |
| 510 | // https://www.volcengine.com/docs/82379/1925114 |
| 511 | fn volcengine_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 512 | match (shape.host.as_str(), shape.path.as_str()) { |
| 513 | ("ark.cn-beijing.volces.com", "/api/coding" | "/api/coding/v3") => { |
| 514 | Some(VOLCENGINE_CODING_PLAN_BILLING_SURFACE) |
| 515 | } |
| 516 | ("ark.cn-beijing.volces.com", "/api/v3") => Some(FIRST_PARTY_PAYG_BILLING_SURFACE), |
| 517 | _ => None, |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | fn stepfun_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 522 | if host_of(DEFAULT_STEPFUN_BASE_URL).is_some_and(|official| shape.host == official) |
| 523 | && matches!(shape.path.as_str(), "" | "/v1") |
| 524 | { |
| 525 | return Some(STEPFUN_PAYG_BILLING_SURFACE); |
| 526 | } |
| 527 | let plan_host = [DEFAULT_STEPFUN_PLAN_BASE_URL, LEGACY_STEPFUN_PLAN_BASE_URL] |
| 528 | .iter() |
| 529 | .filter_map(|url| host_of(url)) |
| 530 | .any(|plan| plan == shape.host); |
| 531 | if plan_host && matches!(shape.path.as_str(), "/step_plan" | "/step_plan/v1") { |
| 532 | return Some(STEPFUN_PLAN_BILLING_SURFACE); |
| 533 | } |
| 534 | None |
| 535 | } |
| 536 | |
| 537 | fn zai_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 538 | // The Coding Plan contract is the exact shipped Z.ai endpoint. Do not let |
| 539 | // arbitrary future `/api/coding/*` paths, or the separate BigModel host, |
| 540 | // inherit a subscription classification. |
| 541 | if shape.host == "api.z.ai" && shape.path == "/api/coding/paas/v4" { |
| 542 | Some(ZAI_CODING_PLAN_BILLING_SURFACE) |
| 543 | } else if matches!(shape.host.as_str(), "api.z.ai" | "open.bigmodel.cn") |
| 544 | && matches!( |
| 545 | shape.path.as_str(), |
| 546 | "/api/paas/v4" | "/api/anthropic" | "/v1" | "" |
| 547 | ) |
| 548 | { |
| 549 | Some(ZAI_PAYG_BILLING_SURFACE) |
| 550 | } else { |
| 551 | None |
| 552 | } |
| 553 | } |
| 554 | |
| 555 | fn moonshot_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 556 | // Kimi Code is a distinct membership product on api.kimi.com. Accept the |
| 557 | // exact shipped endpoint as well as its slash-normalized parent; do not |
| 558 | // infer a plan from a model id or from an arbitrary host carrying a |
| 559 | // `/coding` path. |
| 560 | if shape.host == "api.kimi.com" && matches!(shape.path.as_str(), "/coding" | "/coding/v1") { |
| 561 | Some(MOONSHOT_KIMI_CODE_BILLING_SURFACE) |
| 562 | } else if matches!(shape.host.as_str(), "api.moonshot.ai" | "api.moonshot.cn") |
| 563 | && matches!(shape.path.as_str(), "" | "/v1" | "/anthropic") |
| 564 | { |
| 565 | Some(MOONSHOT_PAYG_BILLING_SURFACE) |
| 566 | } else { |
| 567 | None |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | fn minimax_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 572 | // MiniMax API keys and subscription-plan keys use the same normal |
| 573 | // endpoints. The URL therefore proves neither PAYG nor plan billing; only |
| 574 | // an explicit saved mode may produce a concrete MiniMax surface. |
| 575 | let _is_supported_endpoint = matches!( |
| 576 | shape.host.as_str(), |
| 577 | "api.minimax.io" | "api.minimaxi.com" | "api.minimax.chat" |
| 578 | ) && matches!(shape.path.as_str(), "" | "/v1" | "/anthropic"); |
| 579 | None |
| 580 | } |
| 581 | |
| 582 | fn csdn_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 583 | // Coding Plan keys and general marketplace keys share the one |
| 584 | // ai.csdn.net/api/model/v1 endpoint, so the URL proves neither product; |
| 585 | // only the captured credential product can produce a concrete surface. |
| 586 | let _is_supported_endpoint = shape.host == "ai.csdn.net" |
| 587 | && matches!(shape.path.as_str(), "/api/model" | "/api/model/v1"); |
| 588 | None |
| 589 | } |
| 590 | |
| 591 | fn xiaomi_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 592 | if matches!( |
| 593 | shape.host.as_str(), |
| 594 | "token-plan-cn.xiaomimimo.com" |
| 595 | | "token-plan-sgp.xiaomimimo.com" |
| 596 | | "token-plan-ams.xiaomimimo.com" |
| 597 | ) && shape.path == "/v1" |
| 598 | { |
| 599 | return Some(XIAOMI_TOKEN_PLAN_BILLING_SURFACE); |
| 600 | } |
| 601 | if shape.host == "api.xiaomimimo.com" && shape.path == "/v1" { |
| 602 | return Some(XIAOMI_PAYG_BILLING_SURFACE); |
| 603 | } |
| 604 | None |
| 605 | } |
| 606 | |
| 607 | /// Exact default endpoint match for built-in providers whose billing surface |
| 608 | /// has no provider-specific split above. |
| 609 | /// |
| 610 | /// A provider enum is not proof that a configured URL is that provider's own |
| 611 | /// billing surface. This allowlist keeps `https://proxy.example/v1` from |
| 612 | /// inheriting OpenAI/Anthropic/DeepSeek/OpenRouter prices merely because the |
| 613 | /// selected protocol/provider name is familiar. |
| 614 | fn is_official_default_endpoint(provider: ProviderKind, shape: &EndpointShape) -> bool { |
| 615 | let Some(default) = |
| 616 | endpoint_shape(codewhale_config::descriptors::compatibility_for_kind(provider).base_url) |
| 617 | else { |
| 618 | return false; |
| 619 | }; |
| 620 | if shape.host != default.host { |
| 621 | return false; |
| 622 | } |
| 623 | if shape.path == default.path { |
| 624 | return true; |
| 625 | } |
| 626 | match provider { |
| 627 | ProviderKind::Deepseek => { |
| 628 | matches!(shape.path.as_str(), "" | "/v1" | "/beta") |
| 629 | } |
| 630 | ProviderKind::DeepseekAnthropic => shape.path == "/anthropic", |
| 631 | ProviderKind::Openai => matches!(shape.path.as_str(), "" | "/v1"), |
| 632 | ProviderKind::Anthropic => matches!(shape.path.as_str(), "" | "/v1"), |
| 633 | _ => false, |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | // Official PAYG rates; Step Plan consumes subscription quota instead. |
| 638 | // https://platform.stepfun.ai/docs/en/guides/pricing/details (2026-09-19). |
| 639 | fn stepfun_payg_pricing(model: &str) -> Option<ModelPricing> { |
| 640 | match model.trim().to_ascii_lowercase().as_str() { |
| 641 | "step-5-preview" => Some(usd_pricing( |
| 642 | 0.05, |
| 643 | 1.00, |
| 644 | 2.70, |
| 645 | CacheWritePolicy::DocumentedAsInputRate( |
| 646 | "https://platform.stepfun.ai/docs/en/guides/pricing/details", |
| 647 | ), |
| 648 | )), |
| 649 | "step-3.7-flash" => Some(usd_only_pricing(0.04, 0.20, 1.15)), |
| 650 | "step-3.5-flash" | "step-3.5-flash-2603" => Some(usd_only_pricing(0.02, 0.10, 0.30)), |
| 651 | _ => None, |
| 652 | } |
| 653 | } |
| 654 | |
| 655 | fn pricing_for_billing_surface( |
| 656 | provider: ProviderKind, |
| 657 | model: &str, |
| 658 | billing_surface: Option<&str>, |
| 659 | ) -> Option<ModelPricing> { |
| 660 | if provider == ProviderKind::Stepfun |
| 661 | && billing_surface |
| 662 | .is_some_and(|surface| surface.eq_ignore_ascii_case(STEPFUN_PAYG_BILLING_SURFACE)) |
| 663 | { |
| 664 | stepfun_payg_pricing(model) |
| 665 | } else { |
| 666 | None |
| 667 | } |
| 668 | } |
| 669 | |
| 670 | fn route_requires_billing_surface(provider: ProviderKind, model: &str) -> bool { |
| 671 | provider == ProviderKind::Stepfun || stepfun_payg_pricing(model).is_some() |
| 672 | } |
| 673 | |
| 674 | /// Look up pricing for a model name. |
| 675 | fn pricing_for_model(model: &str) -> Option<ModelPricing> { |
| 676 | pricing_for_model_at(model, Utc::now()) |
| 677 | } |
| 678 | |
| 679 | /// Return whether a model has a row in the pricing table. |
| 680 | #[must_use] |
| 681 | pub fn has_pricing_for_model(model: &str) -> bool { |
| 682 | pricing_for_model(model).is_some() |
| 683 | } |
| 684 | |
| 685 | /// Return whether the selected provider route exposes authoritative dollar |
| 686 | /// pricing for this model without endpoint provenance. ChatGPT/Codex OAuth is |
| 687 | /// subscription/account scoped, while StepFun needs PAYG-vs-Plan provenance. |
| 688 | #[must_use] |
| 689 | pub fn has_pricing_for_provider(provider: ProviderKind, model: &str) -> bool { |
| 690 | calculate_turn_cost_estimate_for_provider(provider, model, &Usage::default()).is_some() |
| 691 | } |
| 692 | |
| 693 | /// Return whether a provider/model route has authoritative pricing for an |
| 694 | /// already-classified billing surface. |
| 695 | #[cfg(test)] |
| 696 | #[must_use] |
| 697 | pub(crate) fn has_pricing_for_billing_surface( |
| 698 | provider: ProviderKind, |
| 699 | model: &str, |
| 700 | billing_surface: Option<&str>, |
| 701 | ) -> bool { |
| 702 | pricing_for_billing_surface(provider, model, billing_surface).is_some() |
| 703 | } |
| 704 | |
| 705 | fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing> { |
| 706 | let lower = model.to_lowercase(); |
| 707 | if lower.starts_with("deepseek-ai/") { |
| 708 | // NVIDIA NIM-hosted DeepSeek uses NVIDIA's catalog/account terms, not |
| 709 | // DeepSeek Platform pricing. Avoid showing misleading DeepSeek costs. |
| 710 | return None; |
| 711 | } |
| 712 | if lower == "claude-sonnet-5" { |
| 713 | // Resolved ahead of the catalog through the recorded-time helper so |
| 714 | // the first-party Anthropic override path (`hand_priced_audit`) |
| 715 | // and this metadata lookup stay one contract (see |
| 716 | // `claude_sonnet_5_pricing`). |
| 717 | return Some(claude_sonnet_5_pricing(now)); |
| 718 | } |
| 719 | if let Some(pricing) = known_pricing_for_model(&lower) { |
| 720 | return Some(pricing); |
| 721 | } |
| 722 | // A new or expiring model ID does not inherit a neighboring model's |
| 723 | // rates. Keep this metadata lookup as exact as the billing route owner. |
| 724 | match lower.as_str() { |
| 725 | "deepseek-v4-pro" => Some(deepseek_v4_pro_pricing(now)), |
| 726 | "deepseek-v4-flash" => Some(deepseek_v4_flash_pricing(now)), |
| 727 | "deepseek-flash" => Some(deepseek_flash_pricing(now)), |
| 728 | _ => None, |
| 729 | } |
| 730 | } |
| 731 | |
| 732 | fn known_pricing_for_model(model_lower: &str) -> Option<ModelPricing> { |
| 733 | let reviewed = codewhale_config::catalog::reviewed::bundled_reviewed(); |
| 734 | if let Some(policy) = reviewed.reference_price_policies.get(model_lower) { |
| 735 | return match policy.as_str() { |
| 736 | "grok" => grok_tiered_pricing(model_lower, false), |
| 737 | "minimax_m3" => Some(minimax_m3_standard_pricing(false)), |
| 738 | _ => None, |
| 739 | }; |
| 740 | } |
| 741 | reviewed.reference_prices.get(model_lower).map(|row| { |
| 742 | usd_pricing( |
| 743 | row.cache_read, |
| 744 | row.input, |
| 745 | row.output, |
| 746 | row.cache_write |
| 747 | .map_or(CacheWritePolicy::Unpublished, CacheWritePolicy::Rate), |
| 748 | ) |
| 749 | }) |
| 750 | } |
| 751 | |
| 752 | /// A USD row whose provider publishes input/cache-read/output rates but **no** |
| 753 | /// cache-creation rate. Cache-write tokens on such a row are unpriced, not free |
| 754 | /// and not silently charged at the input rate (#4318). |
| 755 | fn usd_only_pricing( |
| 756 | input_cache_hit_per_million: f64, |
| 757 | input_cache_miss_per_million: f64, |
| 758 | output_per_million: f64, |
| 759 | ) -> ModelPricing { |
| 760 | usd_pricing( |
| 761 | input_cache_hit_per_million, |
| 762 | input_cache_miss_per_million, |
| 763 | output_per_million, |
| 764 | CacheWritePolicy::Unpublished, |
| 765 | ) |
| 766 | } |
| 767 | |
| 768 | fn usd_pricing_with_write( |
| 769 | input_cache_hit_per_million: f64, |
| 770 | input_cache_miss_per_million: f64, |
| 771 | output_per_million: f64, |
| 772 | cache_write_per_million: f64, |
| 773 | ) -> ModelPricing { |
| 774 | usd_pricing( |
| 775 | input_cache_hit_per_million, |
| 776 | input_cache_miss_per_million, |
| 777 | output_per_million, |
| 778 | CacheWritePolicy::Rate(cache_write_per_million), |
| 779 | ) |
| 780 | } |
| 781 | |
| 782 | fn usd_pricing( |
| 783 | input_cache_hit_per_million: f64, |
| 784 | input_cache_miss_per_million: f64, |
| 785 | output_per_million: f64, |
| 786 | cache_write: CacheWritePolicy, |
| 787 | ) -> ModelPricing { |
| 788 | ModelPricing { |
| 789 | usd: CurrencyPricing { |
| 790 | input_cache_hit_per_million, |
| 791 | input_cache_miss_per_million, |
| 792 | output_per_million, |
| 793 | cache_write, |
| 794 | }, |
| 795 | cny: None, |
| 796 | } |
| 797 | } |
| 798 | |
| 799 | const MINIMAX_M3_LONG_CONTEXT_THRESHOLD: u32 = 512_000; |
| 800 | const GROK_4_6_LONG_CONTEXT_THRESHOLD: u32 = 200_000; |
| 801 | const OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD: u32 = 272_000; |
| 802 | |
| 803 | /// OpenAI applies a higher price to the full request once these models exceed |
| 804 | /// 272K input tokens. Until the pricing layer can represent request-wide tiers, |
| 805 | /// refuse to report the lower static catalog price (#4317). |
| 806 | /// <https://developers.openai.com/api/docs/models/gpt-5.4> |
| 807 | /// <https://developers.openai.com/api/docs/models/gpt-5.5> |
| 808 | /// <https://developers.openai.com/api/docs/models/gpt-5.6-sol> |
| 809 | fn direct_openai_long_context_tier_is_unpriced( |
| 810 | provider: ProviderKind, |
| 811 | model: &str, |
| 812 | input_tokens: u32, |
| 813 | ) -> bool { |
| 814 | let model_lower = model.trim().to_ascii_lowercase(); |
| 815 | let affected_model = matches!( |
| 816 | model_lower.as_str(), |
| 817 | "gpt-5.4" |
| 818 | | "gpt-5.4-pro" |
| 819 | | "gpt-5.5" |
| 820 | | "gpt-5.6" |
| 821 | | "gpt-5.6-sol" |
| 822 | | "gpt-5.6-terra" |
| 823 | | "gpt-5.6-luna" |
| 824 | ) || has_date_snapshot_suffix(&model_lower, "gpt-5.4-") |
| 825 | || has_date_snapshot_suffix(&model_lower, "gpt-5.4-pro-") |
| 826 | || has_date_snapshot_suffix(&model_lower, "gpt-5.5-"); |
| 827 | provider == ProviderKind::Openai |
| 828 | && input_tokens > OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD |
| 829 | && affected_model |
| 830 | } |
| 831 | |
| 832 | fn minimax_m3_standard_pricing(long_context: bool) -> ModelPricing { |
| 833 | if long_context { |
| 834 | usd_only_pricing(0.12, 0.60, 2.40) |
| 835 | } else { |
| 836 | usd_only_pricing(0.06, 0.30, 1.20) |
| 837 | } |
| 838 | } |
| 839 | |
| 840 | fn is_minimax_m3(model: &str) -> bool { |
| 841 | matches!( |
| 842 | model.trim().to_ascii_lowercase().as_str(), |
| 843 | "minimax-m3" | "minimax/minimax-m3" |
| 844 | ) |
| 845 | } |
| 846 | |
| 847 | /// xAI Grok standard-tier rates (cache-read, input, output per 1M) and the |
| 848 | /// doubled tier once a prompt reaches 200K tokens. Verified 2026-08-17 against |
| 849 | /// the model pages, whose embedded price tables carry both the standard and |
| 850 | /// `LongContext` columns at exactly 2x: |
| 851 | /// - <https://docs.x.ai/docs/models/grok-4.7>: 0.50 / 2.00 / 6.00 (verified |
| 852 | /// 2026-09-23: $4.00 / $1.00 / $12.00 at or above 200K) |
| 853 | /// - <https://docs.x.ai/docs/models/grok-4.6>: 0.50 / 2.00 / 6.00 |
| 854 | /// - <https://docs.x.ai/docs/models/grok-4.5>: 0.30 / 2.00 / 6.00 |
| 855 | /// - <https://docs.x.ai/docs/models/grok-4.3>: 0.20 / 1.25 / 2.50 |
| 856 | fn grok_tiered_pricing(model_lower: &str, long_context: bool) -> Option<ModelPricing> { |
| 857 | let (cache_read, input, output) = match model_lower { |
| 858 | "grok-4.7" | "grok-4.6" => (0.50, 2.00, 6.00), |
| 859 | "grok-4.5" => (0.30, 2.00, 6.00), |
| 860 | "grok-4.3" => (0.20, 1.25, 2.50), |
| 861 | _ => return None, |
| 862 | }; |
| 863 | let multiplier = if long_context { 2.0 } else { 1.0 }; |
| 864 | Some(usd_only_pricing( |
| 865 | cache_read * multiplier, |
| 866 | input * multiplier, |
| 867 | output * multiplier, |
| 868 | )) |
| 869 | } |
| 870 | |
| 871 | fn is_grok_tiered(model: &str) -> bool { |
| 872 | matches!( |
| 873 | model.trim().to_ascii_lowercase().as_str(), |
| 874 | "grok-4.7" | "grok-4.6" | "grok-4.5" | "grok-4.3" |
| 875 | ) |
| 876 | } |
| 877 | |
| 878 | fn pricing_for_model_and_usage(model: &str, usage: &Usage) -> Option<ModelPricing> { |
| 879 | if is_minimax_m3(model) { |
| 880 | return Some(minimax_m3_standard_pricing( |
| 881 | usage.input_tokens > MINIMAX_M3_LONG_CONTEXT_THRESHOLD, |
| 882 | )); |
| 883 | } |
| 884 | if is_grok_tiered(model) { |
| 885 | return grok_tiered_pricing( |
| 886 | &model.trim().to_ascii_lowercase(), |
| 887 | usage.input_tokens >= GROK_4_6_LONG_CONTEXT_THRESHOLD, |
| 888 | ); |
| 889 | } |
| 890 | pricing_for_model(model) |
| 891 | } |
| 892 | |
| 893 | /// Claude Sonnet 5 pricing (<https://platform.claude.com/docs/en/about-claude/pricing>, |
| 894 | /// re-verified 2026-08-17): 2.00 / 10.00 (cache-read 0.20, 5m cache-write |
| 895 | /// 2.50) is now the standard price. Anthropic's pricing page states the |
| 896 | /// previously scheduled increase to 3.00 / 15.00 on 2026-09-01 "will not |
| 897 | /// occur" (release notes, 2026-08-10), so the former time-windowed flip is |
| 898 | /// gone; the recorded-time signature is kept so callers that price turns at |
| 899 | /// their recorded time (scorecard, usage aggregation) keep one contract for |
| 900 | /// every first-party time-aware row. |
| 901 | fn claude_sonnet_5_pricing(_now: DateTime<Utc>) -> ModelPricing { |
| 902 | usd_pricing_with_write(0.20, 2.00, 10.00, 2.50) |
| 903 | } |
| 904 | |
| 905 | /// DeepSeek publishes only cache-hit and cache-miss input rates *because* its |
| 906 | /// context cache charges nothing extra to write: a token that misses the cache |
| 907 | /// is billed once at the miss rate and is cached as a side effect. That makes |
| 908 | /// the miss rate the documented write rate, not a stand-in for a missing one. |
| 909 | /// |
| 910 | /// Peak/off-peak tiers (verified against |
| 911 | /// <https://api-docs.deepseek.com/quick_start/pricing> on 2026-08-17): |
| 912 | /// off-peak rates are half the peak rates, and peak hours are 01:00–04:00 |
| 913 | /// and 06:00–10:00 UTC (half-open). From 00:00 Beijing time on 2026-08-23 the |
| 914 | /// whole of Saturday and Sunday bills off-peak, peak hours included. Each turn |
| 915 | /// resolves its tier from its own recorded time, mirroring |
| 916 | /// `claude_sonnet_5_pricing`'s time-aware precedent. |
| 917 | fn deepseek_peak_hour(hour_utc: u32) -> bool { |
| 918 | (1..4).contains(&hour_utc) || (6..10).contains(&hour_utc) |
| 919 | } |
| 920 | |
| 921 | /// Beijing time, the zone DeepSeek states its weekend rule in. China has run a |
| 922 | /// fixed UTC+08:00 with no daylight saving since 1991, so a fixed offset is |
| 923 | /// exact here and needs no tzdata on the host. |
| 924 | fn deepseek_billing_offset() -> FixedOffset { |
| 925 | FixedOffset::east_opt(8 * 3600).expect("+08:00 is a valid UTC offset") |
| 926 | } |
| 927 | |
| 928 | /// The instant the weekend-wide off-peak rule takes effect: 00:00 Beijing time |
| 929 | /// on Sunday 2026-08-23, which is 2026-08-22T16:00Z. |
| 930 | fn deepseek_weekend_off_peak_from() -> DateTime<Utc> { |
| 931 | deepseek_billing_offset() |
| 932 | .with_ymd_and_hms(2026, 8, 23, 0, 0, 0) |
| 933 | .single() |
| 934 | .expect("2026-08-23 00:00 exists in a fixed offset") |
| 935 | .with_timezone(&Utc) |
| 936 | } |
| 937 | |
| 938 | /// Whether `now` falls on a Beijing-time Saturday or Sunday with the weekend-wide |
| 939 | /// off-peak rule already in force. |
| 940 | /// |
| 941 | /// The weekend is bounded in Beijing time, so it runs 16:00Z Friday to 16:00Z |
| 942 | /// Sunday; `now.weekday()` taken in UTC covers a different 48 hours. Both |
| 943 | /// spellings agree on today's tiers, because the peak windows sit entirely |
| 944 | /// outside the 16 hours they disagree over. This one keeps agreeing if the |
| 945 | /// windows move. |
| 946 | fn deepseek_weekend_off_peak(now: DateTime<Utc>) -> bool { |
| 947 | now >= deepseek_weekend_off_peak_from() |
| 948 | && matches!( |
| 949 | now.with_timezone(&deepseek_billing_offset()).weekday(), |
| 950 | Weekday::Sat | Weekday::Sun |
| 951 | ) |
| 952 | } |
| 953 | |
| 954 | /// Whether a turn recorded at `now` is billed at DeepSeek's peak tier. |
| 955 | fn deepseek_is_peak(now: DateTime<Utc>) -> bool { |
| 956 | !deepseek_weekend_off_peak(now) && deepseek_peak_hour(now.hour()) |
| 957 | } |
| 958 | |
| 959 | /// The clock-dependent tier a DeepSeek route bills at right now: `Some(true)` |
| 960 | /// at peak, `Some(false)` off-peak, `None` for a model whose rates do not move |
| 961 | /// with the clock. The model set is exactly the time-aware arm of |
| 962 | /// [`pricing_for_model_at`], so the chip that shows this and the receipt that |
| 963 | /// prices the turn can never disagree about which routes are tiered. |
| 964 | #[must_use] |
| 965 | pub(crate) fn deepseek_time_tier(model: &str, now: DateTime<Utc>) -> Option<bool> { |
| 966 | let lower = model.trim().to_ascii_lowercase(); |
| 967 | matches!( |
| 968 | lower.as_str(), |
| 969 | "deepseek-v4-pro" | "deepseek-v4-flash" | "deepseek-flash" |
| 970 | ) |
| 971 | .then(|| deepseek_is_peak(now)) |
| 972 | } |
| 973 | |
| 974 | fn deepseek_v4_pro_pricing(now: DateTime<Utc>) -> ModelPricing { |
| 975 | // September 11 vendor reversal: Pro remains available at its own rates. |
| 976 | let peak = deepseek_is_peak(now); |
| 977 | let (hit, miss, out) = if peak { |
| 978 | (0.044, 1.32, 3.96) |
| 979 | } else { |
| 980 | (0.022, 0.66, 1.98) |
| 981 | }; |
| 982 | let (cny_hit, cny_miss, cny_out) = if peak { |
| 983 | (0.30, 9.0, 27.0) |
| 984 | } else { |
| 985 | (0.15, 4.5, 13.5) |
| 986 | }; |
| 987 | ModelPricing { |
| 988 | usd: CurrencyPricing { |
| 989 | input_cache_hit_per_million: hit, |
| 990 | input_cache_miss_per_million: miss, |
| 991 | output_per_million: out, |
| 992 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 993 | }, |
| 994 | cny: Some(CurrencyPricing { |
| 995 | input_cache_hit_per_million: cny_hit, |
| 996 | input_cache_miss_per_million: cny_miss, |
| 997 | output_per_million: cny_out, |
| 998 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 999 | }), |
| 1000 | } |
| 1001 | } |
| 1002 | |
| 1003 | /// DeepSeek V4.1 Flash, shipped as the unversioned id `deepseek-flash`. |
| 1004 | /// |
| 1005 | /// Rates and effective time are the vendor's own 2026-09-10 notice, not a |
| 1006 | /// relay: cache hit $0.003, cache miss $0.15, output $0.60 per 1M off-peak, |
| 1007 | /// doubling at peak, effective 04:00 UTC on 2026-09-10. |
| 1008 | /// V4 Pro remains available at its own rates after the September 11 reversal. |
| 1009 | /// |
| 1010 | /// CNY rates are the vendor's own Chinese-language notice: cache hit 0.02 元, |
| 1011 | /// cache miss 1 元, output 4 元 off-peak, doubling at peak. Taken from the |
| 1012 | /// published table rather than converted from USD — a converted rate would be |
| 1013 | /// a receipt the vendor never issued. |
| 1014 | /// |
| 1015 | /// That notice states the peak windows in Beijing time (Mon-Fri 09:00-12:00 and |
| 1016 | /// 14:00-18:00), which is UTC+8 and therefore exactly the 01:00-04:00 and |
| 1017 | /// 06:00-10:00 UTC the English notice gives. Both agree, so `deepseek_is_peak` |
| 1018 | /// needs no change. |
| 1019 | fn deepseek_flash_pricing(now: DateTime<Utc>) -> ModelPricing { |
| 1020 | let peak = deepseek_is_peak(now); |
| 1021 | let (hit, miss, out) = if peak { |
| 1022 | (0.006, 0.30, 1.20) |
| 1023 | } else { |
| 1024 | (0.003, 0.15, 0.60) |
| 1025 | }; |
| 1026 | let (cny_hit, cny_miss, cny_out) = if peak { |
| 1027 | (0.04, 2.0, 8.0) |
| 1028 | } else { |
| 1029 | (0.02, 1.0, 4.0) |
| 1030 | }; |
| 1031 | ModelPricing { |
| 1032 | usd: CurrencyPricing { |
| 1033 | input_cache_hit_per_million: hit, |
| 1034 | input_cache_miss_per_million: miss, |
| 1035 | output_per_million: out, |
| 1036 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 1037 | }, |
| 1038 | cny: Some(CurrencyPricing { |
| 1039 | input_cache_hit_per_million: cny_hit, |
| 1040 | input_cache_miss_per_million: cny_miss, |
| 1041 | output_per_million: cny_out, |
| 1042 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 1043 | }), |
| 1044 | } |
| 1045 | } |
| 1046 | |
| 1047 | fn deepseek_v4_flash_pricing(now: DateTime<Utc>) -> ModelPricing { |
| 1048 | let peak = deepseek_is_peak(now); |
| 1049 | let (hit, miss, out) = if peak { |
| 1050 | (0.014, 0.44, 1.32) |
| 1051 | } else { |
| 1052 | (0.007, 0.22, 0.66) |
| 1053 | }; |
| 1054 | let (cny_hit, cny_miss, cny_out) = if peak { |
| 1055 | (0.10, 3.0, 9.0) |
| 1056 | } else { |
| 1057 | (0.05, 1.5, 4.5) |
| 1058 | }; |
| 1059 | ModelPricing { |
| 1060 | usd: CurrencyPricing { |
| 1061 | input_cache_hit_per_million: hit, |
| 1062 | input_cache_miss_per_million: miss, |
| 1063 | output_per_million: out, |
| 1064 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 1065 | }, |
| 1066 | cny: Some(CurrencyPricing { |
| 1067 | input_cache_hit_per_million: cny_hit, |
| 1068 | input_cache_miss_per_million: cny_miss, |
| 1069 | output_per_million: cny_out, |
| 1070 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 1071 | }), |
| 1072 | } |
| 1073 | } |
| 1074 | |
| 1075 | /// Calculate cost from provider usage, honoring DeepSeek context-cache fields. |
| 1076 | #[must_use] |
| 1077 | #[cfg(test)] |
| 1078 | pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64> { |
| 1079 | calculate_turn_cost_estimate_from_usage(model, usage).map(|estimate| estimate.usd) |
| 1080 | } |
| 1081 | |
| 1082 | /// Calculate cost from provider usage in both official currencies. |
| 1083 | #[must_use] |
| 1084 | #[cfg(test)] |
| 1085 | pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Option<CostEstimate> { |
| 1086 | let pricing = pricing_for_model_and_usage(model, usage)?; |
| 1087 | Some(cost_estimate_with_pricing(pricing, usage)) |
| 1088 | } |
| 1089 | |
| 1090 | /// Cost from a hand-sourced row, or `None` when the row cannot price a class |
| 1091 | /// this turn actually used. |
| 1092 | /// |
| 1093 | /// Only cache-write can fail here: input, cache-read, and output rates are |
| 1094 | /// mandatory on every hand row, while a cache-creation rate exists only where a |
| 1095 | /// provider publishes one or documents that writes cost nothing extra. |
| 1096 | fn cost_estimate_with_pricing_checked( |
| 1097 | pricing: ModelPricing, |
| 1098 | usage: &Usage, |
| 1099 | ) -> Result<CostEstimate, Vec<TokenClass>> { |
| 1100 | let classes = token_usage_for_pricing(usage); |
| 1101 | if classes.cache_write > 0 |
| 1102 | && pricing |
| 1103 | .usd |
| 1104 | .cache_write |
| 1105 | .rate(pricing.usd.input_cache_miss_per_million) |
| 1106 | .is_none() |
| 1107 | { |
| 1108 | return Err(vec![TokenClass::CacheWrite]); |
| 1109 | } |
| 1110 | Ok(CostEstimate { |
| 1111 | usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), |
| 1112 | cny: pricing |
| 1113 | .cny |
| 1114 | .map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage)) |
| 1115 | .unwrap_or(0.0), |
| 1116 | }) |
| 1117 | } |
| 1118 | |
| 1119 | /// Unchecked projection for the legacy model-only test helpers, which construct |
| 1120 | /// usage they have already established the row can price. |
| 1121 | /// |
| 1122 | /// Production paths must use [`cost_estimate_with_pricing_checked`] so an |
| 1123 | /// unpublished cache-write rate fails closed instead of billing writes at the |
| 1124 | /// input rate. |
| 1125 | #[cfg(test)] |
| 1126 | fn cost_estimate_with_pricing(pricing: ModelPricing, usage: &Usage) -> CostEstimate { |
| 1127 | CostEstimate { |
| 1128 | usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), |
| 1129 | cny: pricing |
| 1130 | .cny |
| 1131 | .map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage)) |
| 1132 | .unwrap_or(0.0), |
| 1133 | } |
| 1134 | } |
| 1135 | |
| 1136 | /// Calculate cost from provider/model usage when that pair identifies a single |
| 1137 | /// billing surface. ChatGPT/Codex OAuth has no authoritative API dollar price, |
| 1138 | /// while StepFun needs endpoint-derived PAYG-vs-Plan provenance; both stay |
| 1139 | /// unpriced here rather than fabricating spend. |
| 1140 | #[must_use] |
| 1141 | pub fn calculate_turn_cost_estimate_for_provider( |
| 1142 | provider: ProviderKind, |
| 1143 | model: &str, |
| 1144 | usage: &Usage, |
| 1145 | ) -> Option<CostEstimate> { |
| 1146 | calculate_turn_cost_estimate_for_provider_at(provider, model, usage, Utc::now()) |
| 1147 | } |
| 1148 | |
| 1149 | /// Calculate cost only for routes that are actually money-metered. OAuth and |
| 1150 | /// token-plan routes deliberately return `None` even when the underlying model |
| 1151 | /// also exists behind a separately-priced public API. |
| 1152 | /// |
| 1153 | /// Production callers use [`audit_turn_cost_for_route`] instead: a caller that |
| 1154 | /// adds to a total must also record why a turn was left out of it. |
| 1155 | #[must_use] |
| 1156 | #[cfg(test)] |
| 1157 | pub fn calculate_turn_cost_estimate_for_route( |
| 1158 | provider: ProviderKind, |
| 1159 | model: &str, |
| 1160 | usage: &Usage, |
| 1161 | billing: crate::route_billing::BillingPresentation, |
| 1162 | ) -> Option<CostEstimate> { |
| 1163 | audit_turn_cost_for_route(provider, model, None, usage, Utc::now(), billing).estimate |
| 1164 | } |
| 1165 | |
| 1166 | /// Estimate a turn when endpoint-derived billing provenance is available. |
| 1167 | /// StepFun's standard API and Step Plan share provider/model text but not a |
| 1168 | /// billing system, so that route fails closed unless the PAYG surface is known. |
| 1169 | #[must_use] |
| 1170 | #[cfg(test)] |
| 1171 | pub(crate) fn calculate_turn_cost_estimate_for_billing_surface( |
| 1172 | provider: ProviderKind, |
| 1173 | model: &str, |
| 1174 | billing_surface: Option<&str>, |
| 1175 | usage: &Usage, |
| 1176 | ) -> Option<CostEstimate> { |
| 1177 | calculate_turn_cost_estimate_for_route_at(provider, model, billing_surface, usage, Utc::now()) |
| 1178 | } |
| 1179 | |
| 1180 | /// Deterministic provider-aware estimate at the turn's recorded time. |
| 1181 | #[must_use] |
| 1182 | pub(crate) fn calculate_turn_cost_estimate_for_provider_at( |
| 1183 | provider: ProviderKind, |
| 1184 | model: &str, |
| 1185 | usage: &Usage, |
| 1186 | recorded_at: DateTime<Utc>, |
| 1187 | ) -> Option<CostEstimate> { |
| 1188 | audit_turn_cost_for_provider_at(provider, model, usage, recorded_at).estimate |
| 1189 | } |
| 1190 | |
| 1191 | /// Why a route produced no cost estimate. |
| 1192 | /// |
| 1193 | /// Every `None` from the estimator carries one of these so `/cost`, `/cache`, |
| 1194 | /// and the scorecard can say *why* a turn is missing from a total instead of |
| 1195 | /// letting the total read as complete. |
| 1196 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 1197 | pub enum UnpricedReason { |
| 1198 | /// The route is **exactly identified** as one where money is not the unit: |
| 1199 | /// a named OAuth subscription, a named prepaid token plan, or a local |
| 1200 | /// endpoint with no provider bill. Only this reason excuses a turn from |
| 1201 | /// money coverage, and only exact evidence may produce it (#4318). |
| 1202 | NotMoneyMetered, |
| 1203 | /// The route may or may not meter money and CodeWhale could not establish |
| 1204 | /// which. Distinct from [`Self::NotMoneyMetered`] on purpose: an unknown |
| 1205 | /// basis is counted as *possibly missing spend*, never waved through as a |
| 1206 | /// subscription. A cross-provider child route with no dispatch config is |
| 1207 | /// the common case. |
| 1208 | UnknownBillingBasis, |
| 1209 | /// One provider/model pair spans several billing systems and the non-secret |
| 1210 | /// endpoint provenance needed to pick one is missing. |
| 1211 | AmbiguousBillingSurface, |
| 1212 | /// No endpoint classification was supplied for the route at all. |
| 1213 | /// |
| 1214 | /// Distinct from [`Self::UnknownBillingBasis`], which means an endpoint was |
| 1215 | /// classified and could not be placed. This means none was offered, so |
| 1216 | /// there is no evidence the turn was served by the provider's own official |
| 1217 | /// surface rather than a proxy, a gateway, or a self-hosted clone that |
| 1218 | /// happens to speak the same protocol. A provider enum plus a familiar |
| 1219 | /// model id is not that evidence (#4318). |
| 1220 | UnestablishedEndpoint, |
| 1221 | /// The turn's endpoint classified as a per-token surface, but the pricing |
| 1222 | /// layer holds no rates for that specific surface (as opposed to no rates |
| 1223 | /// for the model at all). |
| 1224 | UnpricedBillingSurface, |
| 1225 | /// The only pricing row found claims live provider provenance but is stale |
| 1226 | /// or was fetched from a different endpoint, so it is not authoritative for |
| 1227 | /// this turn. Never silently downgraded to "authoritative anyway". |
| 1228 | UnverifiedLivePricing, |
| 1229 | /// A compatibility alias whose published rate has been retired. |
| 1230 | RetiredAlias, |
| 1231 | /// The turn crossed a request-wide pricing tier the pricing layer cannot |
| 1232 | /// represent yet (for example OpenAI's >272K long-context surcharge). |
| 1233 | UnrepresentedTier, |
| 1234 | /// No pricing row exists for this provider/model route. |
| 1235 | NoPricingRow, |
| 1236 | /// Automatic gateway routing has not identified the upstream rate owner. |
| 1237 | RoutingDependentPrice, |
| 1238 | /// Saved usage predates cost coverage, or carries an unknown reason code. |
| 1239 | UnrecordedCoverage, |
| 1240 | /// Saved background accounting could not be recovered. |
| 1241 | LateUsageUnavailable, |
| 1242 | /// The bounded saved accounting ledger reached its capacity. |
| 1243 | LateUsageOverflow, |
| 1244 | /// A row exists, but a token class this turn actually used has no published |
| 1245 | /// price, so the estimate fails closed rather than under-reporting. |
| 1246 | MissingClassPrice, |
| 1247 | /// A catalog row contains a NaN, infinite, or negative rate. The whole row |
| 1248 | /// is rejected at the trust boundary rather than partially billed. |
| 1249 | InvalidPricingRow, |
| 1250 | /// The row is denominated in a currency CodeWhale does not carry. No |
| 1251 | /// conversion is invented. |
| 1252 | UnsupportedCurrency, |
| 1253 | /// Provider telemetry assigns more cache-hit/miss/write tokens than the |
| 1254 | /// reported input total. Pricing that contradictory partition would |
| 1255 | /// over-count input, so the call is retained but fails closed. |
| 1256 | InconsistentUsage, |
| 1257 | } |
| 1258 | |
| 1259 | impl UnpricedReason { |
| 1260 | /// Decode persisted receipts without guessing from the current provider. |
| 1261 | /// Older or future reason codes remain explicitly unrecorded coverage. |
| 1262 | #[must_use] |
| 1263 | pub fn from_label(label: &str) -> Self { |
| 1264 | match label { |
| 1265 | "not_money_metered" => Self::NotMoneyMetered, |
| 1266 | "unknown_billing_basis" => Self::UnknownBillingBasis, |
| 1267 | "ambiguous_billing_surface" => Self::AmbiguousBillingSurface, |
| 1268 | "unestablished_endpoint" => Self::UnestablishedEndpoint, |
| 1269 | "unpriced_billing_surface" => Self::UnpricedBillingSurface, |
| 1270 | "unverified_live_pricing" => Self::UnverifiedLivePricing, |
| 1271 | "retired_alias" => Self::RetiredAlias, |
| 1272 | "unrepresented_pricing_tier" => Self::UnrepresentedTier, |
| 1273 | "no_pricing_row" => Self::NoPricingRow, |
| 1274 | "routing_dependent_price" => Self::RoutingDependentPrice, |
| 1275 | "missing_class_price" => Self::MissingClassPrice, |
| 1276 | "invalid_pricing_row" => Self::InvalidPricingRow, |
| 1277 | "unsupported_currency" | "currency_not_published" => Self::UnsupportedCurrency, |
| 1278 | "inconsistent_usage" => Self::InconsistentUsage, |
| 1279 | "late_usage_ledger_unavailable" => Self::LateUsageUnavailable, |
| 1280 | "late_usage_ledger_overflow" => Self::LateUsageOverflow, |
| 1281 | _ => Self::UnrecordedCoverage, |
| 1282 | } |
| 1283 | } |
| 1284 | |
| 1285 | #[must_use] |
| 1286 | pub const fn message_id(self) -> codewhale_localization::MessageId { |
| 1287 | use codewhale_localization::MessageId; |
| 1288 | match self { |
| 1289 | Self::NotMoneyMetered => MessageId::CostReasonNotMoney, |
| 1290 | Self::UnknownBillingBasis => MessageId::CostReasonBillingUnknown, |
| 1291 | Self::AmbiguousBillingSurface | Self::UnestablishedEndpoint => { |
| 1292 | MessageId::CostReasonEndpointUnknown |
| 1293 | } |
| 1294 | Self::UnpricedBillingSurface | Self::NoPricingRow => MessageId::CostReasonRateMissing, |
| 1295 | Self::UnverifiedLivePricing => MessageId::CostReasonLiveUnverified, |
| 1296 | Self::RetiredAlias => MessageId::CostReasonRetiredAlias, |
| 1297 | Self::UnrepresentedTier => MessageId::CostReasonTierMissing, |
| 1298 | Self::RoutingDependentPrice => MessageId::CostReasonRoutingDependent, |
| 1299 | Self::UnrecordedCoverage | Self::LateUsageUnavailable | Self::LateUsageOverflow => { |
| 1300 | MessageId::CostReasonCoverageMissing |
| 1301 | } |
| 1302 | Self::MissingClassPrice => MessageId::CostReasonTokenRateMissing, |
| 1303 | Self::InvalidPricingRow => MessageId::CostReasonInvalidRate, |
| 1304 | Self::UnsupportedCurrency => MessageId::CostReasonCurrencyMissing, |
| 1305 | Self::InconsistentUsage => MessageId::CostReasonUsageConflict, |
| 1306 | } |
| 1307 | } |
| 1308 | |
| 1309 | /// Stable, non-localized identifier for logs, JSON, and scorecards. |
| 1310 | #[must_use] |
| 1311 | pub fn label(self) -> &'static str { |
| 1312 | match self { |
| 1313 | Self::NotMoneyMetered => "not_money_metered", |
| 1314 | Self::UnknownBillingBasis => "unknown_billing_basis", |
| 1315 | Self::AmbiguousBillingSurface => "ambiguous_billing_surface", |
| 1316 | Self::UnestablishedEndpoint => "unestablished_endpoint", |
| 1317 | Self::UnpricedBillingSurface => "unpriced_billing_surface", |
| 1318 | Self::UnverifiedLivePricing => "unverified_live_pricing", |
| 1319 | Self::RetiredAlias => "retired_alias", |
| 1320 | Self::UnrepresentedTier => "unrepresented_pricing_tier", |
| 1321 | Self::NoPricingRow => "no_pricing_row", |
| 1322 | Self::RoutingDependentPrice => "routing_dependent_price", |
| 1323 | Self::UnrecordedCoverage => "unrecorded_coverage", |
| 1324 | Self::LateUsageUnavailable => "late_usage_ledger_unavailable", |
| 1325 | Self::LateUsageOverflow => "late_usage_ledger_overflow", |
| 1326 | Self::MissingClassPrice => "missing_class_price", |
| 1327 | Self::InvalidPricingRow => "invalid_pricing_row", |
| 1328 | Self::UnsupportedCurrency => "unsupported_currency", |
| 1329 | Self::InconsistentUsage => "inconsistent_usage", |
| 1330 | } |
| 1331 | } |
| 1332 | |
| 1333 | /// Whether a turn with this reason belongs in the money-metered coverage |
| 1334 | /// denominator `/cost` reports against its dollar total. |
| 1335 | /// |
| 1336 | /// Only [`Self::NotMoneyMetered`] — an *exactly* identified subscription, |
| 1337 | /// token plan, or local route — is excluded. Everything else, including an |
| 1338 | /// unknown billing basis, counts as spend the total is missing, because |
| 1339 | /// treating "don't know" as "not billed" is what let unpriced turns |
| 1340 | /// disappear from a total that then read as complete (#4318). |
| 1341 | #[must_use] |
| 1342 | pub fn counts_toward_money_coverage(self) -> bool { |
| 1343 | self != Self::NotMoneyMetered |
| 1344 | } |
| 1345 | } |
| 1346 | |
| 1347 | /// A turn cost plus the provenance and completeness needed to audit it. |
| 1348 | /// |
| 1349 | /// `estimate.is_some()` and `unpriced_reason.is_none()` always agree: this type |
| 1350 | /// is produced by the same code path that computes the estimate, so an audit |
| 1351 | /// can never disagree with the number a total was built from. |
| 1352 | #[derive(Debug, Clone, PartialEq)] |
| 1353 | pub struct TurnCostAudit { |
| 1354 | /// The cost, when the route is priced for every class this turn used. |
| 1355 | pub estimate: Option<CostEstimate>, |
| 1356 | /// Where the applied (or attempted) pricing row came from. |
| 1357 | pub provenance: Option<PricingProvenance>, |
| 1358 | /// Classes this turn used that carry no published price. |
| 1359 | pub unpriced_classes: Vec<TokenClass>, |
| 1360 | /// Why the estimate is absent, when it is. |
| 1361 | pub unpriced_reason: Option<UnpricedReason>, |
| 1362 | /// Set when a live catalog row could not be verified as authoritative for |
| 1363 | /// this route. Present both when the row was *degraded* to the bundled |
| 1364 | /// snapshot (the estimate is still priced, from the bundled row) and when |
| 1365 | /// there was no fallback at all. It is the receipt for the downgrade, so a |
| 1366 | /// `provider_live` label is never claimed for an unproven row. |
| 1367 | pub live_pricing_defect: Option<LivePricingDefect>, |
| 1368 | /// Whether the estimate is authoritative in each carried currency. A zero |
| 1369 | /// amount is still priced when usage is zero; these flags therefore cannot |
| 1370 | /// be inferred from `estimate > 0`. |
| 1371 | pub usd_priced: bool, |
| 1372 | pub cny_priced: bool, |
| 1373 | } |
| 1374 | |
| 1375 | impl TurnCostAudit { |
| 1376 | fn priced( |
| 1377 | estimate: CostEstimate, |
| 1378 | provenance: PricingProvenance, |
| 1379 | usd_priced: bool, |
| 1380 | cny_priced: bool, |
| 1381 | ) -> Self { |
| 1382 | Self { |
| 1383 | estimate: Some(estimate), |
| 1384 | provenance: Some(provenance), |
| 1385 | unpriced_classes: Vec::new(), |
| 1386 | unpriced_reason: None, |
| 1387 | live_pricing_defect: None, |
| 1388 | usd_priced, |
| 1389 | cny_priced, |
| 1390 | } |
| 1391 | } |
| 1392 | |
| 1393 | pub(crate) fn unpriced(reason: UnpricedReason) -> Self { |
| 1394 | Self { |
| 1395 | estimate: None, |
| 1396 | provenance: None, |
| 1397 | unpriced_classes: Vec::new(), |
| 1398 | unpriced_reason: Some(reason), |
| 1399 | live_pricing_defect: None, |
| 1400 | usd_priced: false, |
| 1401 | cny_priced: false, |
| 1402 | } |
| 1403 | } |
| 1404 | |
| 1405 | fn missing_classes(provenance: PricingProvenance, classes: Vec<TokenClass>) -> Self { |
| 1406 | Self { |
| 1407 | estimate: None, |
| 1408 | provenance: Some(provenance), |
| 1409 | unpriced_classes: classes, |
| 1410 | unpriced_reason: Some(UnpricedReason::MissingClassPrice), |
| 1411 | live_pricing_defect: None, |
| 1412 | usd_priced: false, |
| 1413 | cny_priced: false, |
| 1414 | } |
| 1415 | } |
| 1416 | |
| 1417 | fn unverified_live(defect: LivePricingDefect) -> Self { |
| 1418 | Self { |
| 1419 | estimate: None, |
| 1420 | // Deliberately not `ProviderLive`: an unverified row must never be |
| 1421 | // labelled with authoritative live provenance. |
| 1422 | provenance: Some(PricingProvenance::Unknown), |
| 1423 | unpriced_classes: Vec::new(), |
| 1424 | unpriced_reason: Some(UnpricedReason::UnverifiedLivePricing), |
| 1425 | live_pricing_defect: Some(defect), |
| 1426 | usd_priced: false, |
| 1427 | cny_priced: false, |
| 1428 | } |
| 1429 | } |
| 1430 | |
| 1431 | /// Attach a live-pricing downgrade receipt to an otherwise complete audit. |
| 1432 | fn with_live_defect(mut self, defect: Option<LivePricingDefect>) -> Self { |
| 1433 | if let Some(defect) = defect { |
| 1434 | self.live_pricing_defect = Some(defect); |
| 1435 | } |
| 1436 | self |
| 1437 | } |
| 1438 | |
| 1439 | /// Whether this turn contributed an authoritative number to a total. |
| 1440 | #[must_use] |
| 1441 | #[cfg(test)] |
| 1442 | pub fn is_priced(&self) -> bool { |
| 1443 | self.estimate.is_some() |
| 1444 | } |
| 1445 | |
| 1446 | /// Whether the estimate is authoritative in the requested display |
| 1447 | /// currency. Exact zero remains priced; the boolean provenance flags are |
| 1448 | /// intentionally not inferred from the numeric amount. |
| 1449 | #[must_use] |
| 1450 | pub fn is_priced_in(&self, currency: CostCurrency) -> bool { |
| 1451 | self.estimate.is_some() |
| 1452 | && match currency { |
| 1453 | CostCurrency::Usd => self.usd_priced, |
| 1454 | CostCurrency::Cny => self.cny_priced, |
| 1455 | } |
| 1456 | } |
| 1457 | |
| 1458 | /// Whether this turn belongs in the money-metered coverage denominator. |
| 1459 | /// |
| 1460 | /// Priced turns always do. Unpriced ones do unless the route was *exactly* |
| 1461 | /// identified as non-metered. |
| 1462 | #[must_use] |
| 1463 | pub fn counts_toward_money_coverage(&self) -> bool { |
| 1464 | self.unpriced_reason |
| 1465 | .is_none_or(UnpricedReason::counts_toward_money_coverage) |
| 1466 | } |
| 1467 | } |
| 1468 | |
| 1469 | /// Audit a turn on a provider/model route, without knowing which endpoint served |
| 1470 | /// it. A live catalog row cannot be *confirmed* for an unknown endpoint, so this |
| 1471 | /// path degrades to the bundled published snapshot; use |
| 1472 | /// [`audit_turn_cost_for_provider_on_endpoint_at`] when the base URL is known. |
| 1473 | #[must_use] |
| 1474 | pub(crate) fn audit_turn_cost_for_provider_at( |
| 1475 | provider: ProviderKind, |
| 1476 | model: &str, |
| 1477 | usage: &Usage, |
| 1478 | recorded_at: DateTime<Utc>, |
| 1479 | ) -> TurnCostAudit { |
| 1480 | audit_turn_cost_for_provider_on_endpoint_at(provider, model, None, usage, recorded_at) |
| 1481 | } |
| 1482 | |
| 1483 | /// Audit a turn's cost on a provider/model route at its recorded time. |
| 1484 | /// |
| 1485 | /// This is the single implementation; `calculate_turn_cost_estimate_*` are thin |
| 1486 | /// projections of it, so no caller can build a total from one rule set while |
| 1487 | /// reporting completeness from another. |
| 1488 | #[must_use] |
| 1489 | pub(crate) fn audit_turn_cost_for_provider_on_endpoint_at( |
| 1490 | provider: ProviderKind, |
| 1491 | model: &str, |
| 1492 | endpoint_fingerprint: Option<&str>, |
| 1493 | usage: &Usage, |
| 1494 | recorded_at: DateTime<Utc>, |
| 1495 | ) -> TurnCostAudit { |
| 1496 | audit_turn_cost_for_provider_on_endpoint_for_identity_at( |
| 1497 | provider, |
| 1498 | None, |
| 1499 | model, |
| 1500 | endpoint_fingerprint, |
| 1501 | usage, |
| 1502 | recorded_at, |
| 1503 | true, |
| 1504 | None, |
| 1505 | ) |
| 1506 | } |
| 1507 | |
| 1508 | /// Identity-aware provider audit for named compatible routes. |
| 1509 | /// |
| 1510 | /// `ProviderKind::Custom` is only a transport family, so it is never sufficient |
| 1511 | /// pricing provenance on its own. Baseten is the first reviewed compatible |
| 1512 | /// provider whose authenticated live catalog can price actual usage; every |
| 1513 | /// other custom identity stays unknown until it receives an equivalent |
| 1514 | /// provider/endpoint contract. |
| 1515 | #[must_use] |
| 1516 | fn audit_turn_cost_for_provider_on_endpoint_for_identity_at( |
| 1517 | provider: ProviderKind, |
| 1518 | provider_identity: Option<&str>, |
| 1519 | model: &str, |
| 1520 | endpoint_fingerprint: Option<&str>, |
| 1521 | usage: &Usage, |
| 1522 | recorded_at: DateTime<Utc>, |
| 1523 | allow_cloud_catalog: bool, |
| 1524 | frozen_cloud_pricing: Option<OfferingPricing>, |
| 1525 | ) -> TurnCostAudit { |
| 1526 | if !usage_cache_partition_is_consistent(usage) { |
| 1527 | return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage); |
| 1528 | } |
| 1529 | if provider == ProviderKind::OpenaiCodex { |
| 1530 | return TurnCostAudit::unpriced(UnpricedReason::AmbiguousBillingSurface); |
| 1531 | } |
| 1532 | if provider == ProviderKind::Custom { |
| 1533 | // A transport family plus current mutable catalog state is not a |
| 1534 | // billing receipt. Reviewed custom routes are priced only by the |
| 1535 | // frozen dispatch quote handled in the route-audit path below. |
| 1536 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 1537 | } |
| 1538 | if route_requires_billing_surface(provider, model) { |
| 1539 | return TurnCostAudit::unpriced(UnpricedReason::AmbiguousBillingSurface); |
| 1540 | } |
| 1541 | let normalized_model = model.trim(); |
| 1542 | let model_lower = normalized_model.to_ascii_lowercase(); |
| 1543 | let direct_deepseek = matches!( |
| 1544 | provider, |
| 1545 | ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic |
| 1546 | ); |
| 1547 | let Some(canonical_model) = canonical_model_id_for_provider(provider, normalized_model) else { |
| 1548 | return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); |
| 1549 | }; |
| 1550 | let catalog_model = if direct_deepseek |
| 1551 | && matches!(model_lower.as_str(), "deepseek-chat" | "deepseek-reasoner") |
| 1552 | { |
| 1553 | let Ok(retirement) = DateTime::parse_from_rfc3339(DEEPSEEK_ALIAS_RETIREMENT_UTC) else { |
| 1554 | return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); |
| 1555 | }; |
| 1556 | if recorded_at >= retirement.with_timezone(&Utc) { |
| 1557 | return TurnCostAudit::unpriced(UnpricedReason::RetiredAlias); |
| 1558 | } |
| 1559 | DEEPSEEK_ALIAS_REPLACEMENT.to_string() |
| 1560 | } else { |
| 1561 | canonical_model |
| 1562 | }; |
| 1563 | |
| 1564 | if direct_openai_long_context_tier_is_unpriced(provider, &catalog_model, usage.input_tokens) { |
| 1565 | return TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier); |
| 1566 | } |
| 1567 | |
| 1568 | // MiniMax-M3 doubles its published rates above 512K total input. The |
| 1569 | // catalog row is necessarily static, so retain the usage-aware first-party |
| 1570 | // table for both direct wire protocols after provider/model provenance has |
| 1571 | // been canonicalized. |
| 1572 | if matches!( |
| 1573 | provider, |
| 1574 | ProviderKind::Minimax | ProviderKind::MinimaxAnthropic |
| 1575 | ) && catalog_model.eq_ignore_ascii_case("minimax-m3") |
| 1576 | { |
| 1577 | return hand_priced_audit(pricing_for_model_and_usage(&catalog_model, usage), usage); |
| 1578 | } |
| 1579 | |
| 1580 | // xAI doubles Grok 4.6 / 4.5 / 4.3 input, cached-input, and output rates |
| 1581 | // once the prompt reaches 200K tokens. Keep this provider-owned and |
| 1582 | // usage-aware so a third-party route reusing the model slug never |
| 1583 | // inherits xAI billing. |
| 1584 | if provider == ProviderKind::Xai && is_grok_tiered(&catalog_model) { |
| 1585 | return hand_priced_audit(pricing_for_model_and_usage(&catalog_model, usage), usage); |
| 1586 | } |
| 1587 | |
| 1588 | // Direct DeepSeek pricing carries an authoritative CNY row and recorded-time |
| 1589 | // peak/off-peak tiers that a static catalog row cannot represent; Sonnet 5 |
| 1590 | // keeps riding the same recorded-time hand row (its rate is flat again |
| 1591 | // since Anthropic cancelled the 2026-09-01 increase, but the contract that |
| 1592 | // first-party Anthropic prices Sonnet 5 from its own row stays). These |
| 1593 | // exact first-party routes intentionally override the catalog; no other |
| 1594 | // provider/model text match is allowed to do so. |
| 1595 | if direct_deepseek |
| 1596 | || (provider == ProviderKind::Anthropic |
| 1597 | && catalog_model.eq_ignore_ascii_case("claude-sonnet-5")) |
| 1598 | { |
| 1599 | return hand_priced_audit( |
| 1600 | provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at), |
| 1601 | usage, |
| 1602 | ); |
| 1603 | } |
| 1604 | |
| 1605 | if let Some(pricing) = frozen_cloud_pricing { |
| 1606 | return audit_offering_pricing(pricing, usage); |
| 1607 | } |
| 1608 | let classes = token_usage_for_pricing(usage); |
| 1609 | // A live catalog row is only authoritative when it is fresh *and* was |
| 1610 | // fetched from the endpoint this turn was served on. When it is not, degrade |
| 1611 | // to the bundled published snapshot and receipt the defect; only if there is |
| 1612 | // no bundled row at all does the turn fail closed (#4318). |
| 1613 | let mut live_defect = None; |
| 1614 | let offering = match verified_catalog_offering( |
| 1615 | provider, |
| 1616 | provider_identity, |
| 1617 | &catalog_model, |
| 1618 | endpoint_fingerprint, |
| 1619 | recorded_at, |
| 1620 | allow_cloud_catalog, |
| 1621 | ) { |
| 1622 | VerifiedOffering::Usable(offering) => Some(offering), |
| 1623 | VerifiedOffering::DegradedToBundled { offering, defect } => { |
| 1624 | live_defect = Some(defect); |
| 1625 | Some(offering) |
| 1626 | } |
| 1627 | VerifiedOffering::Unusable(defect) => { |
| 1628 | live_defect = Some(defect); |
| 1629 | None |
| 1630 | } |
| 1631 | VerifiedOffering::FutureEffective => { |
| 1632 | return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); |
| 1633 | } |
| 1634 | VerifiedOffering::Absent => None, |
| 1635 | }; |
| 1636 | |
| 1637 | if let Some(audit) = offering.as_ref().and_then(invalid_catalog_pricing_audit) { |
| 1638 | return audit.with_live_defect(live_defect); |
| 1639 | } |
| 1640 | |
| 1641 | if let Some(offering) = offering.as_ref() |
| 1642 | && let Some(pricing) = |
| 1643 | effective_offering_pricing(provider, &catalog_model, offering, &classes) |
| 1644 | { |
| 1645 | if let Some(estimate) = |
| 1646 | catalog_cost_estimate_for_route(provider, &catalog_model, offering, usage) |
| 1647 | { |
| 1648 | let (usd_priced, cny_priced) = match pricing.currency { |
| 1649 | Currency::Usd => (true, false), |
| 1650 | Currency::Cny => (false, true), |
| 1651 | Currency::Other(_) => (false, false), |
| 1652 | }; |
| 1653 | return TurnCostAudit::priced( |
| 1654 | estimate, |
| 1655 | pricing.provenance.clone(), |
| 1656 | usd_priced, |
| 1657 | cny_priced, |
| 1658 | ) |
| 1659 | .with_live_defect(live_defect); |
| 1660 | } |
| 1661 | let classes = pricing.unpriced_used_classes(&classes); |
| 1662 | if classes.is_empty() { |
| 1663 | // Every used class is priced, so the only way the estimate failed |
| 1664 | // is a currency CodeWhale does not carry. Never convert. |
| 1665 | return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency) |
| 1666 | .with_live_defect(live_defect); |
| 1667 | } |
| 1668 | return TurnCostAudit::missing_classes(pricing.provenance, classes) |
| 1669 | .with_live_defect(live_defect); |
| 1670 | } |
| 1671 | |
| 1672 | // A few first-party rows predate or intentionally omit a Models.dev entry |
| 1673 | // (for example OpenAI API `gpt-5-codex` and MiniMax `minimax-m2.7`). |
| 1674 | // Preserve only an explicit provider-owned allowlist here; |
| 1675 | // a costless foreign/catalog route must remain unpriced. |
| 1676 | let hand_row = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at); |
| 1677 | |
| 1678 | // An unverifiable live row with no bundled fallback and no hand row is a |
| 1679 | // route CodeWhale cannot price truthfully. Say which, rather than reporting |
| 1680 | // the unverified rate or a bare "no pricing row". |
| 1681 | match (live_defect, hand_row) { |
| 1682 | (Some(defect), None) => TurnCostAudit::unverified_live(defect), |
| 1683 | // Concentrate publishes different upstream rates, and even a requested |
| 1684 | // provider/model can fail over. A slash is not a billing receipt. Keep |
| 1685 | // verified scoped offerings and operator overrides above authoritative; |
| 1686 | // absent those, do not inherit a model owner's or aggregate rate. |
| 1687 | // https://concentrate.ai/docs/api-reference/endpoint/auto-routing |
| 1688 | (None, None) if provider == ProviderKind::Concentrate => { |
| 1689 | TurnCostAudit::unpriced(UnpricedReason::RoutingDependentPrice) |
| 1690 | } |
| 1691 | (defect, hand_row) => hand_priced_audit(hand_row, usage).with_live_defect(defect), |
| 1692 | } |
| 1693 | } |
| 1694 | |
| 1695 | /// Convert malformed catalog numerics into an explicit runtime audit reason. |
| 1696 | /// Keeping this distinct from the ordinary `None` projection prevents a bad |
| 1697 | /// published row from becoming indistinguishable from an absent price. |
| 1698 | fn invalid_catalog_pricing_audit( |
| 1699 | offering: &codewhale_config::catalog::CatalogOffering, |
| 1700 | ) -> Option<TurnCostAudit> { |
| 1701 | offering |
| 1702 | .cost |
| 1703 | .as_ref() |
| 1704 | .is_some_and(|cost| !codewhale_config::pricing::catalog_cost_is_valid(cost)) |
| 1705 | .then(|| TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow)) |
| 1706 | } |
| 1707 | |
| 1708 | /// Outcome of checking a catalog row's pricing provenance against the route. |
| 1709 | enum VerifiedOffering { |
| 1710 | /// The row is authoritative as-is (bundled, user override, or a live row |
| 1711 | /// proven fresh and endpoint-matched). |
| 1712 | Usable(codewhale_config::catalog::CatalogOffering), |
| 1713 | /// The live row could not be verified, so the bundled published row is used |
| 1714 | /// instead. The defect is retained as the receipt for why. |
| 1715 | DegradedToBundled { |
| 1716 | offering: codewhale_config::catalog::CatalogOffering, |
| 1717 | defect: LivePricingDefect, |
| 1718 | }, |
| 1719 | /// The live row could not be verified and no bundled row exists. |
| 1720 | Unusable(LivePricingDefect), |
| 1721 | /// The row claims it was fetched after this turn was dispatched. Clock |
| 1722 | /// saturation must never turn a future price into an age-zero price. |
| 1723 | FutureEffective, |
| 1724 | /// No catalog row for this provider/model at all. |
| 1725 | Absent, |
| 1726 | } |
| 1727 | |
| 1728 | /// Resolve the catalog row to price against, refusing to treat an unverifiable |
| 1729 | /// live row as authoritative. |
| 1730 | /// |
| 1731 | /// `endpoint_fingerprint` is the non-secret SHA-256 digest of the base URL the turn |
| 1732 | /// was actually served on (see [`codewhale_config::catalog::base_url_fingerprint`]). |
| 1733 | /// Callers that do not know the endpoint pass `None`, which cannot *confirm* a |
| 1734 | /// live row — so those callers degrade to the bundled snapshot rather than |
| 1735 | /// billing against a rate whose endpoint scope is unproven. |
| 1736 | fn verified_catalog_offering( |
| 1737 | provider: ProviderKind, |
| 1738 | provider_identity: Option<&str>, |
| 1739 | catalog_model: &str, |
| 1740 | endpoint_fingerprint: Option<&str>, |
| 1741 | recorded_at: DateTime<Utc>, |
| 1742 | allow_cloud_catalog: bool, |
| 1743 | ) -> VerifiedOffering { |
| 1744 | let Some(offering) = crate::provider_lake::catalog_offering_for_model_identity( |
| 1745 | provider, |
| 1746 | provider_identity, |
| 1747 | catalog_model, |
| 1748 | ) else { |
| 1749 | return VerifiedOffering::Absent; |
| 1750 | }; |
| 1751 | if allow_cloud_catalog |
| 1752 | && let codewhale_config::catalog::CatalogSource::CloudFacts { |
| 1753 | fetched_at, |
| 1754 | valid_until, |
| 1755 | .. |
| 1756 | } = offering.pricing_source() |
| 1757 | { |
| 1758 | let at = u64::try_from(recorded_at.timestamp()).unwrap_or(0); |
| 1759 | if *fetched_at > at { |
| 1760 | return VerifiedOffering::FutureEffective; |
| 1761 | } |
| 1762 | if valid_until.is_some_and(|expires| at > expires) { |
| 1763 | return crate::provider_lake::bundled_catalog_offering_for_model( |
| 1764 | provider, |
| 1765 | catalog_model, |
| 1766 | ) |
| 1767 | .map(VerifiedOffering::Usable) |
| 1768 | .unwrap_or(VerifiedOffering::Absent); |
| 1769 | } |
| 1770 | } |
| 1771 | let cloud_price = matches!( |
| 1772 | offering.pricing_source(), |
| 1773 | codewhale_config::catalog::CatalogSource::CloudFacts { .. } |
| 1774 | ); |
| 1775 | if cloud_price |
| 1776 | && (!allow_cloud_catalog |
| 1777 | || endpoint_fingerprint.is_some_and(|fingerprint| { |
| 1778 | codewhale_config::catalog::base_url_fingerprint( |
| 1779 | provider.provider().default_base_url(), |
| 1780 | ) != fingerprint |
| 1781 | })) |
| 1782 | { |
| 1783 | return crate::provider_lake::bundled_catalog_offering_for_model(provider, catalog_model) |
| 1784 | .map(VerifiedOffering::Usable) |
| 1785 | .unwrap_or(VerifiedOffering::Absent); |
| 1786 | } |
| 1787 | // Models.dev is a capabilities catalog. A live overlay from that fetch |
| 1788 | // must never be treated as a rate source — leftover `cost` fields are |
| 1789 | // not provider prices, and `https://api.codewhale.net/session` 503 |
| 1790 | // (`control_plane_not_attached`) is not a healthy live price list |
| 1791 | // (#5241). Prefer the bundled snapshot (curated in-repo rates, when |
| 1792 | // present) and otherwise ignore live cost so hand/bundled fallbacks |
| 1793 | // can restore a usable session total. |
| 1794 | if matches!( |
| 1795 | offering.pricing_source(), |
| 1796 | codewhale_config::catalog::CatalogSource::ModelsDevLive { .. } |
| 1797 | ) || (!cloud_price |
| 1798 | && crate::provider_lake::live_catalog_origin(provider, catalog_model) |
| 1799 | == Some(crate::provider_lake::LiveSource::ModelsDev)) |
| 1800 | { |
| 1801 | let offering = |
| 1802 | crate::provider_lake::bundled_catalog_offering_for_model(provider, catalog_model) |
| 1803 | .unwrap_or_else(|| capabilities_only_offering(offering)); |
| 1804 | return VerifiedOffering::Usable(offering); |
| 1805 | } |
| 1806 | let Some(pricing) = OfferingPricing::from_catalog_offering_at( |
| 1807 | &offering, |
| 1808 | u64::try_from(recorded_at.timestamp()).unwrap_or(0), |
| 1809 | ) else { |
| 1810 | // No priced row to verify; downstream treats this as unpriced. |
| 1811 | return VerifiedOffering::Usable(offering); |
| 1812 | }; |
| 1813 | // `recorded_at` is the turn's own clock, which is the right reference for |
| 1814 | // "was this price current when the turn happened". |
| 1815 | let now_unix = u64::try_from(recorded_at.timestamp()).ok(); |
| 1816 | if pricing.provenance == PricingProvenance::ProviderLive |
| 1817 | && pricing |
| 1818 | .effective_at |
| 1819 | .zip(now_unix) |
| 1820 | .is_some_and(|(effective_at, dispatched_at)| effective_at > dispatched_at) |
| 1821 | { |
| 1822 | return VerifiedOffering::FutureEffective; |
| 1823 | } |
| 1824 | let Some(defect) = |
| 1825 | pricing.live_pricing_defect(endpoint_fingerprint, now_unix, LIVE_PRICING_MAX_AGE_SECS) |
| 1826 | else { |
| 1827 | return VerifiedOffering::Usable(offering); |
| 1828 | }; |
| 1829 | match crate::provider_lake::bundled_catalog_offering_for_model(provider, catalog_model) { |
| 1830 | Some(bundled) => VerifiedOffering::DegradedToBundled { |
| 1831 | offering: bundled, |
| 1832 | defect, |
| 1833 | }, |
| 1834 | None => VerifiedOffering::Unusable(defect), |
| 1835 | } |
| 1836 | } |
| 1837 | |
| 1838 | /// Drop any cost on a Models.dev live overlay so leftover price fields cannot |
| 1839 | /// be billed as `provider_live` (#5241). |
| 1840 | fn capabilities_only_offering( |
| 1841 | mut offering: codewhale_config::catalog::CatalogOffering, |
| 1842 | ) -> codewhale_config::catalog::CatalogOffering { |
| 1843 | offering.cost = None; |
| 1844 | offering |
| 1845 | } |
| 1846 | |
| 1847 | /// Project a hand-sourced provider row into an audit. |
| 1848 | /// |
| 1849 | /// A hand row always publishes input, cache-read, and output rates. Cache-write |
| 1850 | /// is the one class that can be genuinely absent: only providers that publish a |
| 1851 | /// write premium, or document that cache creation carries no separate charge, |
| 1852 | /// can price it. A turn that wrote to cache on a row with neither fact fails |
| 1853 | /// closed and names the class, rather than being billed at the input rate on the |
| 1854 | /// strength of an assumption (#4318). |
| 1855 | fn hand_priced_audit(pricing: Option<ModelPricing>, usage: &Usage) -> TurnCostAudit { |
| 1856 | let Some(pricing) = pricing else { |
| 1857 | return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); |
| 1858 | }; |
| 1859 | let has_cny = pricing.cny.is_some(); |
| 1860 | match cost_estimate_with_pricing_checked(pricing, usage) { |
| 1861 | Ok(estimate) => { |
| 1862 | TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, has_cny) |
| 1863 | } |
| 1864 | Err(classes) => TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes), |
| 1865 | } |
| 1866 | } |
| 1867 | |
| 1868 | /// Recorded-time variant with explicit billing-surface provenance. |
| 1869 | #[must_use] |
| 1870 | #[cfg(test)] |
| 1871 | pub(crate) fn calculate_turn_cost_estimate_for_route_at( |
| 1872 | provider: ProviderKind, |
| 1873 | model: &str, |
| 1874 | billing_surface: Option<&str>, |
| 1875 | usage: &Usage, |
| 1876 | recorded_at: DateTime<Utc>, |
| 1877 | ) -> Option<CostEstimate> { |
| 1878 | audit_turn_cost_for_route_at(provider, model, billing_surface, usage, recorded_at).estimate |
| 1879 | } |
| 1880 | |
| 1881 | /// Audit a turn's cost with endpoint-derived billing provenance. |
| 1882 | #[must_use] |
| 1883 | pub(crate) fn audit_turn_cost_for_route_at( |
| 1884 | provider: ProviderKind, |
| 1885 | model: &str, |
| 1886 | billing_surface: Option<&str>, |
| 1887 | usage: &Usage, |
| 1888 | recorded_at: DateTime<Utc>, |
| 1889 | ) -> TurnCostAudit { |
| 1890 | audit_turn_cost_for_route_on_endpoint_at( |
| 1891 | provider, |
| 1892 | model, |
| 1893 | billing_surface, |
| 1894 | None, |
| 1895 | usage, |
| 1896 | recorded_at, |
| 1897 | ) |
| 1898 | } |
| 1899 | |
| 1900 | /// Audit a turn's cost with both endpoint-derived billing provenance and the |
| 1901 | /// endpoint fingerprint needed to verify live catalog pricing. |
| 1902 | #[must_use] |
| 1903 | pub(crate) fn audit_turn_cost_for_route_on_endpoint_at( |
| 1904 | provider: ProviderKind, |
| 1905 | model: &str, |
| 1906 | billing_surface: Option<&str>, |
| 1907 | endpoint_fingerprint: Option<&str>, |
| 1908 | usage: &Usage, |
| 1909 | recorded_at: DateTime<Utc>, |
| 1910 | ) -> TurnCostAudit { |
| 1911 | audit_turn_cost_for_route_on_endpoint_for_identity_at( |
| 1912 | provider, |
| 1913 | None, |
| 1914 | model, |
| 1915 | billing_surface, |
| 1916 | endpoint_fingerprint, |
| 1917 | None, |
| 1918 | usage, |
| 1919 | recorded_at, |
| 1920 | ) |
| 1921 | } |
| 1922 | |
| 1923 | /// Identity-aware route audit for an immutable dispatch receipt. |
| 1924 | #[must_use] |
| 1925 | pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at( |
| 1926 | provider: ProviderKind, |
| 1927 | provider_identity: Option<&str>, |
| 1928 | model: &str, |
| 1929 | billing_surface: Option<&str>, |
| 1930 | endpoint_fingerprint: Option<&str>, |
| 1931 | provider_live_pricing: Option<&crate::provider_catalog_live::ProviderLivePricingQuote>, |
| 1932 | usage: &Usage, |
| 1933 | recorded_at: DateTime<Utc>, |
| 1934 | ) -> TurnCostAudit { |
| 1935 | let declared_pricing = match provider_live_pricing { |
| 1936 | Some(quote) if quote.provenance == PricingProvenance::UserOverride => { |
| 1937 | let pricing = provider_identity |
| 1938 | .zip(endpoint_fingerprint) |
| 1939 | .zip(u64::try_from(recorded_at.timestamp()).ok()) |
| 1940 | .and_then(|((identity, fingerprint), at)| { |
| 1941 | quote.pricing_for_route(provider, identity, model, fingerprint, at) |
| 1942 | }); |
| 1943 | let Some(pricing) = pricing else { |
| 1944 | return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); |
| 1945 | }; |
| 1946 | Some(pricing) |
| 1947 | } |
| 1948 | _ => None, |
| 1949 | }; |
| 1950 | let reviewed_custom_metered = reviewed_custom_route_is_metered(provider, endpoint_fingerprint); |
| 1951 | let reviewed_provider_live = |
| 1952 | reviewed_provider_live_route_is_metered(provider, provider_identity, endpoint_fingerprint); |
| 1953 | // An explicitly recorded surface is evidence. Exact non-metered surfaces |
| 1954 | // override provider guesses; an explicit unknown/unrecognized surface must |
| 1955 | // fail closed and may never fall through to a familiar model's hand row. |
| 1956 | match endpoint_metering_for_billing_surface(billing_surface) { |
| 1957 | EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill => { |
| 1958 | return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); |
| 1959 | } |
| 1960 | EndpointMetering::Unknown |
| 1961 | if billing_surface.is_some() |
| 1962 | && !reviewed_custom_metered |
| 1963 | && declared_pricing.is_none() => |
| 1964 | { |
| 1965 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 1966 | } |
| 1967 | EndpointMetering::Unknown | EndpointMetering::Money => {} |
| 1968 | } |
| 1969 | if !usage_cache_partition_is_consistent(usage) { |
| 1970 | return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage); |
| 1971 | } |
| 1972 | if let Some(pricing) = declared_pricing { |
| 1973 | return audit_offering_pricing(pricing, usage); |
| 1974 | } |
| 1975 | if provider == ProviderKind::Stepfun { |
| 1976 | return match pricing_for_billing_surface(provider, model, billing_surface) { |
| 1977 | // Each model keeps its documented cache-write policy; unpublished |
| 1978 | // write rates fail closed instead of borrowing another model's rate. |
| 1979 | Some(pricing) => match cost_estimate_with_pricing_checked(pricing, usage) { |
| 1980 | Ok(estimate) => { |
| 1981 | TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, false) |
| 1982 | } |
| 1983 | Err(classes) => { |
| 1984 | TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes) |
| 1985 | } |
| 1986 | }, |
| 1987 | // The surface classified as per-token but no rates exist for it, or |
| 1988 | // no surface was established at all. |
| 1989 | None => TurnCostAudit::unpriced(match billing_surface { |
| 1990 | Some(_) => UnpricedReason::UnpricedBillingSurface, |
| 1991 | None => UnpricedReason::AmbiguousBillingSurface, |
| 1992 | }), |
| 1993 | }; |
| 1994 | } |
| 1995 | if stepfun_payg_pricing(model).is_some() { |
| 1996 | return TurnCostAudit::unpriced(UnpricedReason::AmbiguousBillingSurface); |
| 1997 | } |
| 1998 | // This is the *route* audit: the caller is asserting it knows which |
| 1999 | // endpoint served the turn. With no classification at all, nothing |
| 2000 | // distinguishes the provider's own official surface from a proxy, a |
| 2001 | // gateway, or a self-hosted clone speaking the same protocol — a provider |
| 2002 | // enum plus a familiar model id is not evidence of an official endpoint. |
| 2003 | // So the turn prices as unknown rather than at official rates. |
| 2004 | // |
| 2005 | // Callers that genuinely hold only a provider and a model use |
| 2006 | // `audit_turn_cost_for_provider_*`, which says so in its name and carries |
| 2007 | // its own weaker claim. |
| 2008 | if billing_surface.is_none() { |
| 2009 | return TurnCostAudit::unpriced(UnpricedReason::UnestablishedEndpoint); |
| 2010 | } |
| 2011 | if reviewed_provider_live { |
| 2012 | let Some(provider_identity) = provider_identity.map(str::trim).filter(|id| !id.is_empty()) |
| 2013 | else { |
| 2014 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 2015 | }; |
| 2016 | let Some(endpoint_fingerprint) = endpoint_fingerprint else { |
| 2017 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 2018 | }; |
| 2019 | let Some(dispatched_at_unix) = u64::try_from(recorded_at.timestamp()).ok() else { |
| 2020 | return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); |
| 2021 | }; |
| 2022 | let pricing = match provider_live_pricing { |
| 2023 | Some(quote) => { |
| 2024 | let Some(pricing) = quote.pricing_for_route( |
| 2025 | provider, |
| 2026 | provider_identity, |
| 2027 | model, |
| 2028 | endpoint_fingerprint, |
| 2029 | dispatched_at_unix, |
| 2030 | ) else { |
| 2031 | return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); |
| 2032 | }; |
| 2033 | pricing |
| 2034 | } |
| 2035 | None if provider == ProviderKind::Openrouter => { |
| 2036 | // An offline/startup OpenRouter dispatch has no mutable live |
| 2037 | // quote to freeze. Audit it only against the immutable bundled |
| 2038 | // snapshot (and provider-owned hand rows, if one is added), so |
| 2039 | // a refresh that lands after dispatch cannot retro-price it. |
| 2040 | return audit_openrouter_immutable_pricing(model, usage, recorded_at); |
| 2041 | } |
| 2042 | None => { |
| 2043 | // Baseten has no reviewed immutable price card. Its compatible |
| 2044 | // custom route therefore requires the exact frozen live quote. |
| 2045 | return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); |
| 2046 | } |
| 2047 | }; |
| 2048 | return audit_offering_pricing(pricing, usage); |
| 2049 | } |
| 2050 | if provider == ProviderKind::Openrouter && provider_identity.is_some() { |
| 2051 | // A persisted built-in OpenRouter receipt that is missing the exact |
| 2052 | // official identity/endpoint binding (or its frozen quote) must not |
| 2053 | // fall through to the mutable process-wide provider lake. |
| 2054 | return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); |
| 2055 | } |
| 2056 | if provider == ProviderKind::Custom { |
| 2057 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 2058 | } |
| 2059 | let frozen_cloud_pricing = match provider_live_pricing { |
| 2060 | Some(quote) if quote.provenance == PricingProvenance::CloudFacts => { |
| 2061 | let pricing = provider_identity |
| 2062 | .zip(endpoint_fingerprint) |
| 2063 | .zip(u64::try_from(recorded_at.timestamp()).ok()) |
| 2064 | .and_then(|((identity, fingerprint), dispatched)| { |
| 2065 | quote.pricing_for_route(provider, identity, model, fingerprint, dispatched) |
| 2066 | }); |
| 2067 | let Some(pricing) = pricing else { |
| 2068 | return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); |
| 2069 | }; |
| 2070 | Some(pricing) |
| 2071 | } |
| 2072 | _ => None, |
| 2073 | }; |
| 2074 | audit_turn_cost_for_provider_on_endpoint_for_identity_at( |
| 2075 | provider, |
| 2076 | provider_identity, |
| 2077 | model, |
| 2078 | endpoint_fingerprint, |
| 2079 | usage, |
| 2080 | recorded_at, |
| 2081 | false, |
| 2082 | frozen_cloud_pricing, |
| 2083 | ) |
| 2084 | } |
| 2085 | |
| 2086 | fn audit_offering_pricing(pricing: OfferingPricing, usage: &Usage) -> TurnCostAudit { |
| 2087 | let classes = token_usage_for_pricing(usage); |
| 2088 | let unpriced = pricing.unpriced_used_classes(&classes); |
| 2089 | if !unpriced.is_empty() { |
| 2090 | return TurnCostAudit::missing_classes(pricing.provenance, unpriced); |
| 2091 | } |
| 2092 | let Some(amount) = pricing.estimate_cost(&classes) else { |
| 2093 | return TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow); |
| 2094 | }; |
| 2095 | let (estimate, usd, cny) = match pricing.currency { |
| 2096 | Currency::Usd => (CostEstimate::usd_only(amount), true, false), |
| 2097 | Currency::Cny => ( |
| 2098 | CostEstimate { |
| 2099 | usd: 0.0, |
| 2100 | cny: amount, |
| 2101 | }, |
| 2102 | false, |
| 2103 | true, |
| 2104 | ), |
| 2105 | Currency::Other(_) => return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency), |
| 2106 | }; |
| 2107 | TurnCostAudit::priced(estimate, pricing.provenance, usd, cny) |
| 2108 | } |
| 2109 | |
| 2110 | /// Price an exact official OpenRouter route without consulting mutable live |
| 2111 | /// catalog state. This is the no-quote application-dispatch fallback used when |
| 2112 | /// CodeWhale starts offline or the provider refresh has not completed yet. |
| 2113 | fn audit_openrouter_immutable_pricing( |
| 2114 | model: &str, |
| 2115 | usage: &Usage, |
| 2116 | recorded_at: DateTime<Utc>, |
| 2117 | ) -> TurnCostAudit { |
| 2118 | let Some(canonical_model) = canonical_model_id_for_provider(ProviderKind::Openrouter, model) |
| 2119 | else { |
| 2120 | return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); |
| 2121 | }; |
| 2122 | let classes = token_usage_for_pricing(usage); |
| 2123 | if let Some(offering) = crate::provider_lake::bundled_catalog_offering_for_model( |
| 2124 | ProviderKind::Openrouter, |
| 2125 | &canonical_model, |
| 2126 | ) { |
| 2127 | if let Some(audit) = invalid_catalog_pricing_audit(&offering) { |
| 2128 | return audit; |
| 2129 | } |
| 2130 | if let Some(pricing) = effective_offering_pricing( |
| 2131 | ProviderKind::Openrouter, |
| 2132 | &canonical_model, |
| 2133 | &offering, |
| 2134 | &classes, |
| 2135 | ) { |
| 2136 | let unpriced_classes = pricing.unpriced_used_classes(&classes); |
| 2137 | if !unpriced_classes.is_empty() { |
| 2138 | return TurnCostAudit::missing_classes(pricing.provenance, unpriced_classes); |
| 2139 | } |
| 2140 | let Some(estimate) = catalog_cost_estimate_for_route( |
| 2141 | ProviderKind::Openrouter, |
| 2142 | &canonical_model, |
| 2143 | &offering, |
| 2144 | usage, |
| 2145 | ) else { |
| 2146 | return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency); |
| 2147 | }; |
| 2148 | let (usd_priced, cny_priced) = match pricing.currency { |
| 2149 | Currency::Usd => (true, false), |
| 2150 | Currency::Cny => (false, true), |
| 2151 | Currency::Other(_) => { |
| 2152 | return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency); |
| 2153 | } |
| 2154 | }; |
| 2155 | return TurnCostAudit::priced(estimate, pricing.provenance, usd_priced, cny_priced); |
| 2156 | } |
| 2157 | } |
| 2158 | |
| 2159 | hand_priced_audit( |
| 2160 | provider_owned_hand_pricing_at(ProviderKind::Openrouter, &canonical_model, recorded_at), |
| 2161 | usage, |
| 2162 | ) |
| 2163 | } |
| 2164 | |
| 2165 | /// Whether a named custom route has a reviewed per-token billing contract. |
| 2166 | /// |
| 2167 | /// Baseten is accepted only through the fingerprint of its documented Model |
| 2168 | /// APIs endpoint (#6289). The table name is irrelevant: a Baseten identity |
| 2169 | /// pointed at another host cannot become metered, and any table pointed at |
| 2170 | /// Baseten carries Baseten's billing contract. A priced `/models` row alone |
| 2171 | /// never mints metering. |
| 2172 | #[must_use] |
| 2173 | pub(crate) fn reviewed_custom_route_is_metered( |
| 2174 | provider: ProviderKind, |
| 2175 | endpoint_fingerprint: Option<&str>, |
| 2176 | ) -> bool { |
| 2177 | if provider != ProviderKind::Custom { |
| 2178 | return false; |
| 2179 | } |
| 2180 | endpoint_fingerprint.is_some_and(|fingerprint| { |
| 2181 | fingerprint |
| 2182 | == codewhale_config::catalog::base_url_fingerprint( |
| 2183 | codewhale_config::catalog::BASETEN_BASE_URL, |
| 2184 | ) |
| 2185 | }) |
| 2186 | } |
| 2187 | |
| 2188 | /// Exact routes whose mutable provider-live rates must be frozen at the |
| 2189 | /// pre-permit application-dispatch boundary. |
| 2190 | /// |
| 2191 | /// OpenRouter is accepted only as the built-in identity on its official API; |
| 2192 | /// a custom table shadowing that name or an endpoint override is a different |
| 2193 | /// billing contract. Custom tables are metered only on Baseten's endpoint |
| 2194 | /// fingerprint and retain their exact, case-sensitive cache ownership. |
| 2195 | #[must_use] |
| 2196 | fn reviewed_provider_live_route_is_metered( |
| 2197 | provider: ProviderKind, |
| 2198 | provider_identity: Option<&str>, |
| 2199 | endpoint_fingerprint: Option<&str>, |
| 2200 | ) -> bool { |
| 2201 | match provider { |
| 2202 | ProviderKind::Openrouter => { |
| 2203 | provider_identity.map(str::trim) == Some(ProviderKind::Openrouter.as_str()) |
| 2204 | && endpoint_fingerprint.is_some_and(|fingerprint| { |
| 2205 | fingerprint |
| 2206 | == codewhale_config::catalog::base_url_fingerprint( |
| 2207 | crate::config::DEFAULT_OPENROUTER_BASE_URL, |
| 2208 | ) |
| 2209 | }) |
| 2210 | } |
| 2211 | ProviderKind::Custom => reviewed_custom_route_is_metered(provider, endpoint_fingerprint), |
| 2212 | _ => false, |
| 2213 | } |
| 2214 | } |
| 2215 | |
| 2216 | /// Audit a turn against the route's billing presentation. |
| 2217 | /// |
| 2218 | /// The three non-metered presentations are **not** interchangeable, and |
| 2219 | /// collapsing them was the bug (#4318): |
| 2220 | /// |
| 2221 | /// - [`BillingPresentation::Subscription`] and [`BillingPresentation::Local`] |
| 2222 | /// are exact evidence that money is the wrong unit, so those turns are |
| 2223 | /// `NotMoneyMetered` and drop out of the coverage denominator. |
| 2224 | /// - [`BillingPresentation::Unknown`] is *not* such evidence. It means CodeWhale |
| 2225 | /// could not establish the basis, so the turn is `UnknownBillingBasis`: still |
| 2226 | /// unpriced, but counted as spend the total may be missing. |
| 2227 | /// |
| 2228 | /// [`BillingPresentation::Subscription`]: crate::route_billing::BillingPresentation::Subscription |
| 2229 | /// [`BillingPresentation::Local`]: crate::route_billing::BillingPresentation::Local |
| 2230 | /// [`BillingPresentation::Unknown`]: crate::route_billing::BillingPresentation::Unknown |
| 2231 | #[must_use] |
| 2232 | #[cfg(test)] |
| 2233 | pub fn audit_turn_cost_for_route( |
| 2234 | provider: ProviderKind, |
| 2235 | model: &str, |
| 2236 | billing_surface: Option<&str>, |
| 2237 | usage: &Usage, |
| 2238 | recorded_at: DateTime<Utc>, |
| 2239 | billing: crate::route_billing::BillingPresentation, |
| 2240 | ) -> TurnCostAudit { |
| 2241 | audit_turn_cost_for_route_on_endpoint( |
| 2242 | provider, |
| 2243 | model, |
| 2244 | billing_surface, |
| 2245 | None, |
| 2246 | usage, |
| 2247 | recorded_at, |
| 2248 | billing, |
| 2249 | ) |
| 2250 | } |
| 2251 | |
| 2252 | /// [`audit_turn_cost_for_route`] plus the endpoint fingerprint that lets live |
| 2253 | /// catalog pricing be verified for this exact route. |
| 2254 | #[must_use] |
| 2255 | #[cfg(test)] |
| 2256 | pub fn audit_turn_cost_for_route_on_endpoint( |
| 2257 | provider: ProviderKind, |
| 2258 | model: &str, |
| 2259 | billing_surface: Option<&str>, |
| 2260 | endpoint_fingerprint: Option<&str>, |
| 2261 | usage: &Usage, |
| 2262 | recorded_at: DateTime<Utc>, |
| 2263 | billing: crate::route_billing::BillingPresentation, |
| 2264 | ) -> TurnCostAudit { |
| 2265 | use crate::route_billing::BillingPresentation; |
| 2266 | match billing { |
| 2267 | BillingPresentation::Subscription(_) | BillingPresentation::Local => { |
| 2268 | return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); |
| 2269 | } |
| 2270 | BillingPresentation::Unknown => { |
| 2271 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 2272 | } |
| 2273 | BillingPresentation::Metered => {} |
| 2274 | } |
| 2275 | // A metered presentation still has to survive the endpoint classification: |
| 2276 | // an endpoint that classifies as an exact subscription surface overrides a |
| 2277 | // metered guess, and an unclassifiable one fails closed. |
| 2278 | match endpoint_metering_for_billing_surface(billing_surface) { |
| 2279 | EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill => { |
| 2280 | return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); |
| 2281 | } |
| 2282 | // `Unknown` here is the common, benign case of a caller that has no |
| 2283 | // endpoint to classify; the provider/model path below still decides. |
| 2284 | EndpointMetering::Unknown if billing_surface.is_some() => { |
| 2285 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 2286 | } |
| 2287 | EndpointMetering::Unknown | EndpointMetering::Money => {} |
| 2288 | } |
| 2289 | audit_turn_cost_for_route_on_endpoint_at( |
| 2290 | provider, |
| 2291 | model, |
| 2292 | billing_surface, |
| 2293 | endpoint_fingerprint, |
| 2294 | usage, |
| 2295 | recorded_at, |
| 2296 | ) |
| 2297 | } |
| 2298 | |
| 2299 | fn provider_owned_hand_pricing_at( |
| 2300 | provider: ProviderKind, |
| 2301 | model: &str, |
| 2302 | recorded_at: DateTime<Utc>, |
| 2303 | ) -> Option<ModelPricing> { |
| 2304 | let model_lower = model.trim().to_ascii_lowercase(); |
| 2305 | // Hosted Fireworks / OpenCode Zen rates are provider-owned docs rows, not |
| 2306 | // first-party DeepSeek's $0.0028 cache-hit card and not Models.dev. |
| 2307 | if provider == ProviderKind::Fireworks { |
| 2308 | return fireworks_bundled_fallback_pricing(&model_lower); |
| 2309 | } |
| 2310 | if provider == ProviderKind::OpencodeZen { |
| 2311 | return opencode_zen_bundled_fallback_pricing(&model_lower); |
| 2312 | } |
| 2313 | let provider_owns_row = match provider { |
| 2314 | ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => { |
| 2315 | // `deepseek-flash` is V4.1 Flash on the first-party API. Only the |
| 2316 | // first-party family gains it: third-party hosts below keep their |
| 2317 | // own published tables and must not be assumed to serve a model |
| 2318 | // just because DeepSeek does. |
| 2319 | matches!( |
| 2320 | model_lower.as_str(), |
| 2321 | "deepseek-v4-pro" | "deepseek-v4-flash" | "deepseek-flash" |
| 2322 | ) |
| 2323 | } |
| 2324 | ProviderKind::Openai => matches!( |
| 2325 | model_lower.as_str(), |
| 2326 | "gpt-5-codex" |
| 2327 | | "gpt-5.3-codex" |
| 2328 | | "gpt-5.5" |
| 2329 | | "gpt-5.5-pro" |
| 2330 | | "gpt-5.6" |
| 2331 | | "gpt-5.6-sol" |
| 2332 | | "gpt-5.6-terra" |
| 2333 | | "gpt-5.6-luna" |
| 2334 | ), |
| 2335 | ProviderKind::Anthropic => matches!( |
| 2336 | model_lower.as_str(), |
| 2337 | "claude-opus-4-8" |
| 2338 | | "claude-sonnet-4-6" |
| 2339 | | "claude-haiku-4-5" |
| 2340 | | "claude-fable-5" |
| 2341 | | "claude-sonnet-5" |
| 2342 | | "claude-opus-5" |
| 2343 | ), |
| 2344 | ProviderKind::Xai => is_grok_tiered(&model_lower), |
| 2345 | // GLM-5.3 is deliberately absent: this allowlist declares that Z.ai |
| 2346 | // owns a *hand-written price row* for the model, and no GLM-5.3 rate |
| 2347 | // has been published. An absent price is honest; an owned-but-empty |
| 2348 | // row is not. See `glm_5_3_has_no_hardcoded_price` below. |
| 2349 | // GLM-5.3-Flash *does* have a published USD list (2026-08-26). |
| 2350 | ProviderKind::Zai => matches!( |
| 2351 | model_lower.as_str(), |
| 2352 | "glm-5.1" | "glm-5.2" | "glm-5.3-flash" | "glm-5-turbo" |
| 2353 | ), |
| 2354 | // `k3` (Kimi Code membership) is deliberately absent: it is quota |
| 2355 | // billed and must never inherit the direct-platform kimi-k3 rate. |
| 2356 | ProviderKind::Moonshot => matches!( |
| 2357 | model_lower.as_str(), |
| 2358 | "kimi-k2.6" | "kimi-k2.7-code" | "kimi-k2.7-code-highspeed" | "kimi-k3" |
| 2359 | ), |
| 2360 | ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => matches!( |
| 2361 | model_lower.as_str(), |
| 2362 | "minimax-m3" | "minimax-m2.7" | "minimax-m2.7-highspeed" |
| 2363 | ), |
| 2364 | ProviderKind::Mistral => matches!( |
| 2365 | model_lower.as_str(), |
| 2366 | "mistral-medium-latest" |
| 2367 | | "mistral-medium-3-5" |
| 2368 | | "mistral-medium-3.5" |
| 2369 | | "mistral-medium-2604" |
| 2370 | | "mistral-large-latest" |
| 2371 | | "mistral-large-2512" |
| 2372 | | "mistral-small-latest" |
| 2373 | | "mistral-small-2603" |
| 2374 | | "mistral-code-latest" |
| 2375 | | "codestral-latest" |
| 2376 | | "codestral" |
| 2377 | ), |
| 2378 | ProviderKind::Arcee => model_lower == "trinity-large-thinking", |
| 2379 | // 1.2 and its contributor tier own hand-written rows the same way 1.1 |
| 2380 | // does (see `pricing_for_model_at`). 1.2 is now `DEFAULT_META_MODEL`, |
| 2381 | // so omitting them here left the default Meta route without a |
| 2382 | // provider-owned fallback row. |
| 2383 | ProviderKind::Meta => matches!( |
| 2384 | model_lower.as_str(), |
| 2385 | "muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" |
| 2386 | ), |
| 2387 | // Deployment-style ids (Fireworks account prefix, OpenCode Zen |
| 2388 | // gateway) have no Models.dev cost fields. When the live control |
| 2389 | // plane 503s, these bundled family rates keep the session priced |
| 2390 | // instead of `unverified_live_pricing` forever (#5241). |
| 2391 | ProviderKind::Fireworks => { |
| 2392 | let bare = model_lower |
| 2393 | .strip_prefix("accounts/fireworks/models/") |
| 2394 | .unwrap_or(model_lower.as_str()); |
| 2395 | matches!(bare, "deepseek-v4-flash" | "deepseek-v4-pro") |
| 2396 | } |
| 2397 | ProviderKind::OpencodeZen => { |
| 2398 | matches!( |
| 2399 | model_lower.as_str(), |
| 2400 | "deepseek-v4-flash" | "deepseek-v4-pro" |
| 2401 | ) |
| 2402 | } |
| 2403 | _ => false, |
| 2404 | }; |
| 2405 | let lookup = if provider == ProviderKind::Fireworks { |
| 2406 | model_lower |
| 2407 | .strip_prefix("accounts/fireworks/models/") |
| 2408 | .unwrap_or(model_lower.as_str()) |
| 2409 | .to_string() |
| 2410 | } else { |
| 2411 | model_lower |
| 2412 | }; |
| 2413 | provider_owns_row |
| 2414 | .then(|| pricing_for_model_at(&lookup, recorded_at)) |
| 2415 | .flatten() |
| 2416 | } |
| 2417 | |
| 2418 | /// Fireworks serverless Standard rates (2026-08-15 audit). |
| 2419 | /// <https://docs.fireworks.ai/serverless/pricing> |
| 2420 | /// |
| 2421 | /// Cache-write is unpublished on that table. Do not inherit first-party |
| 2422 | /// DeepSeek's $0.0028 cache-hit card — Fireworks publishes $0.028. |
| 2423 | fn fireworks_bundled_fallback_pricing(model_lower: &str) -> Option<ModelPricing> { |
| 2424 | match fireworks_deployment_id(model_lower) { |
| 2425 | "deepseek-v4-flash" | "deepseek-v4-flash-0731" => { |
| 2426 | Some(hosted_deepseek_v4_flash_standard_pricing()) |
| 2427 | } |
| 2428 | "deepseek-v4-pro" => Some(hosted_deepseek_v4_pro_standard_pricing()), |
| 2429 | // kimi-k3 stays unpriced until Fireworks publishes a rate for it |
| 2430 | // (see `fireworks_and_zen_flash_use_bundled_family_rates`). |
| 2431 | _ => None, |
| 2432 | } |
| 2433 | } |
| 2434 | |
| 2435 | fn fireworks_deployment_id(model_lower: &str) -> &str { |
| 2436 | model_lower |
| 2437 | .strip_prefix("accounts/fireworks/models/") |
| 2438 | .or_else(|| model_lower.strip_prefix("accounts/fireworks/routers/")) |
| 2439 | .unwrap_or(model_lower) |
| 2440 | } |
| 2441 | |
| 2442 | /// OpenCode Zen PAYG rates (2026-08-15 audit). |
| 2443 | /// <https://opencode.ai/docs/zen/> |
| 2444 | /// |
| 2445 | /// Cached write is unpublished (`-` on the Zen table). Flash cache-read is |
| 2446 | /// $0.028, not first-party DeepSeek's $0.0028. |
| 2447 | fn opencode_zen_bundled_fallback_pricing(model_lower: &str) -> Option<ModelPricing> { |
| 2448 | match model_lower { |
| 2449 | "deepseek-v4-flash" | "deepseek-v4-flash-0731" => { |
| 2450 | Some(hosted_deepseek_v4_flash_standard_pricing()) |
| 2451 | } |
| 2452 | _ => None, |
| 2453 | } |
| 2454 | } |
| 2455 | |
| 2456 | fn hosted_deepseek_v4_flash_standard_pricing() -> ModelPricing { |
| 2457 | usd_only_pricing(0.028, 0.14, 0.28) |
| 2458 | } |
| 2459 | |
| 2460 | fn hosted_deepseek_v4_pro_standard_pricing() -> ModelPricing { |
| 2461 | usd_only_pricing(0.145, 1.74, 3.48) |
| 2462 | } |
| 2463 | |
| 2464 | /// The offering's pricing row as it actually applies to this route. |
| 2465 | /// |
| 2466 | /// Two documented first-party routes publish no separate cache rate *because* |
| 2467 | /// cache tokens are billed at the plain input rate; that substitution happens |
| 2468 | /// here so cost estimation and the unpriced-class audit read the same row. |
| 2469 | fn effective_offering_pricing( |
| 2470 | provider: ProviderKind, |
| 2471 | model: &str, |
| 2472 | offering: &codewhale_config::catalog::CatalogOffering, |
| 2473 | classes: &TokenUsage, |
| 2474 | ) -> Option<OfferingPricing> { |
| 2475 | let mut pricing = OfferingPricing::from_catalog_offering(offering)?; |
| 2476 | let model_lower = model.trim().to_ascii_lowercase(); |
| 2477 | let cache_uses_input_rate = matches!( |
| 2478 | (provider, model_lower.as_str()), |
| 2479 | (ProviderKind::Openai, "gpt-5.5-pro") | (ProviderKind::Arcee, "trinity-large-thinking") |
| 2480 | ); |
| 2481 | if cache_uses_input_rate { |
| 2482 | if classes.cache_read > 0 && pricing.cache_read_per_million.is_none() { |
| 2483 | pricing.cache_read_per_million = pricing.input_per_million; |
| 2484 | } |
| 2485 | if classes.cache_write > 0 && pricing.cache_write_per_million.is_none() { |
| 2486 | pricing.cache_write_per_million = pricing.input_per_million; |
| 2487 | } |
| 2488 | } |
| 2489 | Some(pricing) |
| 2490 | } |
| 2491 | |
| 2492 | /// Estimate usage only from the exact provider offering. Missing prices for a |
| 2493 | /// used token class fail closed, except on the two documented first-party |
| 2494 | /// routes where cache tokens are explicitly billed at the input rate. |
| 2495 | fn catalog_cost_estimate_for_route( |
| 2496 | provider: ProviderKind, |
| 2497 | model: &str, |
| 2498 | offering: &codewhale_config::catalog::CatalogOffering, |
| 2499 | usage: &Usage, |
| 2500 | ) -> Option<CostEstimate> { |
| 2501 | let classes = token_usage_for_pricing(usage); |
| 2502 | let pricing = effective_offering_pricing(provider, model, offering, &classes)?; |
| 2503 | |
| 2504 | let amount = pricing.estimate_cost(&classes)?; |
| 2505 | match pricing.currency { |
| 2506 | Currency::Usd => Some(CostEstimate::usd_only(amount)), |
| 2507 | Currency::Cny => Some(CostEstimate { |
| 2508 | usd: 0.0, |
| 2509 | cny: amount, |
| 2510 | }), |
| 2511 | Currency::Other(_) => None, |
| 2512 | } |
| 2513 | } |
| 2514 | |
| 2515 | /// Project provider-normalized turn usage into canonical billable token |
| 2516 | /// classes for the shared config pricing layer (#2961 / #4318). |
| 2517 | /// |
| 2518 | /// `Usage::prompt_cache_miss_tokens` is billed as ordinary non-cached input. |
| 2519 | /// `Usage::prompt_cache_write_tokens` maps to `TokenUsage::cache_write` so |
| 2520 | /// providers that publish a write premium (Anthropic 1.25x–2x) are not |
| 2521 | /// undercounted. |
| 2522 | /// |
| 2523 | /// `Usage::reasoning_tokens` is deliberately **not** added to the billable |
| 2524 | /// output. Every provider CodeWhale normalizes reports reasoning as a *subset* |
| 2525 | /// of the completion count it already bills — OpenAI Responses nests |
| 2526 | /// `reasoning_tokens` under `output_tokens_details` while `output_tokens` is |
| 2527 | /// the total, and Chat Completions nests it under `completion_tokens_details` |
| 2528 | /// while `completion_tokens` is the total. Adding it charged reasoning turns |
| 2529 | /// twice for the same tokens (up to 2x on reasoning-heavy turns). It stays on |
| 2530 | /// `Usage` as informational telemetry (`/usage`, hooks, sub-agent metadata). |
| 2531 | #[must_use] |
| 2532 | pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage { |
| 2533 | // `input_tokens` is the authoritative total. Even malformed provider |
| 2534 | // telemetry must never produce token classes whose sum exceeds it. The |
| 2535 | // audit path rejects contradictory partitions; this bounded projection |
| 2536 | // keeps token-only displays truthful while retaining deterministic class |
| 2537 | // priority (read, write, then miss/unclassified input). |
| 2538 | let total_input = usage.input_tokens; |
| 2539 | let cache_read = usage.prompt_cache_hit_tokens.unwrap_or(0).min(total_input); |
| 2540 | let after_read = total_input.saturating_sub(cache_read); |
| 2541 | let cache_write = usage.prompt_cache_write_tokens.unwrap_or(0).min(after_read); |
| 2542 | let after_write = after_read.saturating_sub(cache_write); |
| 2543 | let non_cached_reported = usage |
| 2544 | .prompt_cache_miss_tokens |
| 2545 | .unwrap_or(after_write) |
| 2546 | .min(after_write); |
| 2547 | let uncategorized_input = after_write.saturating_sub(non_cached_reported); |
| 2548 | let input = non_cached_reported.saturating_add(uncategorized_input); |
| 2549 | // Reasoning tokens are already inside `output_tokens`; see the doc comment. |
| 2550 | let output = usage.output_tokens; |
| 2551 | |
| 2552 | TokenUsage { |
| 2553 | input: u64::from(input), |
| 2554 | output: u64::from(output), |
| 2555 | cache_read: u64::from(cache_read), |
| 2556 | cache_write: u64::from(cache_write), |
| 2557 | } |
| 2558 | } |
| 2559 | |
| 2560 | fn usage_cache_partition_is_consistent(usage: &Usage) -> bool { |
| 2561 | let reported = u64::from(usage.prompt_cache_hit_tokens.unwrap_or(0)) |
| 2562 | + u64::from(usage.prompt_cache_miss_tokens.unwrap_or(0)) |
| 2563 | + u64::from(usage.prompt_cache_write_tokens.unwrap_or(0)); |
| 2564 | reported <= u64::from(usage.input_tokens) |
| 2565 | } |
| 2566 | |
| 2567 | fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: &Usage) -> f64 { |
| 2568 | let usage = token_usage_for_pricing(usage); |
| 2569 | let hit_cost = (usage.cache_read as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million; |
| 2570 | let miss_cost = (usage.input as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million; |
| 2571 | // An unpublished write policy is only reachable here for usage with zero |
| 2572 | // cache-write tokens; `cost_estimate_with_pricing_checked` rejects the rest |
| 2573 | // before any money is computed. |
| 2574 | let write_rate = pricing |
| 2575 | .cache_write |
| 2576 | .rate(pricing.input_cache_miss_per_million) |
| 2577 | .unwrap_or(0.0); |
| 2578 | let write_cost = (usage.cache_write as f64 / 1_000_000.0) * write_rate; |
| 2579 | let output_cost = (usage.output as f64 / 1_000_000.0) * pricing.output_per_million; |
| 2580 | hit_cost + miss_cost + write_cost + output_cost |
| 2581 | } |
| 2582 | |
| 2583 | /// Estimate how much money was saved by serving `cache_hit_tokens` from the |
| 2584 | /// prefix cache instead of billing them at the cache-miss rate. Returns `None` |
| 2585 | /// when the model's pricing is unknown or the number of cache-hit tokens is |
| 2586 | /// zero (nothing to save). |
| 2587 | #[must_use] |
| 2588 | #[cfg(test)] |
| 2589 | pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option<CostEstimate> { |
| 2590 | if cache_hit_tokens == 0 { |
| 2591 | return None; |
| 2592 | } |
| 2593 | // M3's cache-read savings depend on whether total input crosses 512k; |
| 2594 | // this helper receives only cache-hit tokens, so an estimate would guess |
| 2595 | // the tier. The full turn-cost path has total input and remains precise. |
| 2596 | if is_minimax_m3(model) { |
| 2597 | return None; |
| 2598 | } |
| 2599 | let pricing = pricing_for_model(model)?; |
| 2600 | let tokens = cache_hit_tokens as f64 / 1_000_000.0; |
| 2601 | Some(CostEstimate { |
| 2602 | usd: tokens |
| 2603 | * (pricing.usd.input_cache_miss_per_million - pricing.usd.input_cache_hit_per_million), |
| 2604 | cny: pricing |
| 2605 | .cny |
| 2606 | .map(|pricing| { |
| 2607 | tokens |
| 2608 | * (pricing.input_cache_miss_per_million - pricing.input_cache_hit_per_million) |
| 2609 | }) |
| 2610 | .unwrap_or(0.0), |
| 2611 | }) |
| 2612 | } |
| 2613 | |
| 2614 | /// The route's list price per million tokens, `in $X · out $Y`, when this |
| 2615 | /// provider/model pair has authoritative pricing without endpoint |
| 2616 | /// provenance. `None` otherwise — the price view omits the row rather than |
| 2617 | /// quoting a rate the session is not actually billed at. |
| 2618 | #[must_use] |
| 2619 | pub(crate) fn model_rate_label( |
| 2620 | provider: ProviderKind, |
| 2621 | model: &str, |
| 2622 | currency: CostCurrency, |
| 2623 | ) -> Option<String> { |
| 2624 | if !has_pricing_for_provider(provider, model) { |
| 2625 | return None; |
| 2626 | } |
| 2627 | let pricing = pricing_for_model(model)?; |
| 2628 | let rates = match currency { |
| 2629 | CostCurrency::Usd => pricing.usd, |
| 2630 | CostCurrency::Cny => pricing.cny?, |
| 2631 | }; |
| 2632 | Some(format!( |
| 2633 | "in {} · out {}", |
| 2634 | format_cost_amount(rates.input_cache_miss_per_million, currency), |
| 2635 | format_cost_amount(rates.output_per_million, currency), |
| 2636 | )) |
| 2637 | } |
| 2638 | |
| 2639 | /// Format a cost amount for compact display in the chosen currency. |
| 2640 | #[must_use] |
| 2641 | pub fn format_cost_amount(cost: f64, currency: CostCurrency) -> String { |
| 2642 | let symbol = currency.symbol(); |
| 2643 | if cost == 0.0 { |
| 2644 | format!("{symbol}0.00") |
| 2645 | } else if cost > 0.0 && cost < 0.0001 { |
| 2646 | format!("<{symbol}0.0001") |
| 2647 | } else if cost < 0.01 { |
| 2648 | format!("{symbol}{cost:.4}") |
| 2649 | } else { |
| 2650 | format!("{symbol}{cost:.2}") |
| 2651 | } |
| 2652 | } |
| 2653 | |
| 2654 | /// Format a cost amount for detailed reports in the chosen currency. |
| 2655 | #[must_use] |
| 2656 | pub fn format_cost_amount_precise(cost: f64, currency: CostCurrency) -> String { |
| 2657 | let selected = match currency { |
| 2658 | CostCurrency::Usd => codewhale_command_contract::types::CommandCurrency::Usd, |
| 2659 | CostCurrency::Cny => codewhale_command_contract::types::CommandCurrency::Cny, |
| 2660 | }; |
| 2661 | crate::diagnostics_reports::format_cost_amount_precise(cost, selected) |
| 2662 | } |
| 2663 | |
| 2664 | /// Format a dual-currency estimate using the selected display currency. |
| 2665 | #[must_use] |
| 2666 | pub fn format_cost_estimate(estimate: CostEstimate, currency: CostCurrency) -> String { |
| 2667 | format_cost_amount(estimate.amount(currency), currency) |
| 2668 | } |
| 2669 | |
| 2670 | #[cfg(test)] |
| 2671 | mod default_coverage_tests; |
| 2672 | |
| 2673 | #[cfg(test)] |
| 2674 | mod tests { |
| 2675 | use super::*; |
| 2676 | use chrono::TimeZone; |
| 2677 | |
| 2678 | #[test] |
| 2679 | fn malformed_catalog_row_has_an_explicit_runtime_reason() { |
| 2680 | let offering = codewhale_config::catalog::CatalogOffering { |
| 2681 | provider: "openrouter".to_string(), |
| 2682 | wire_model_id: "openai/gpt-5.5".to_string(), |
| 2683 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 2684 | input: Some(f64::NAN), |
| 2685 | output: Some(30.0), |
| 2686 | cache_read: Some(0.05), |
| 2687 | cache_write: None, |
| 2688 | }), |
| 2689 | ..Default::default() |
| 2690 | }; |
| 2691 | |
| 2692 | let audit = invalid_catalog_pricing_audit(&offering) |
| 2693 | .expect("malformed row must become an explicit failed-closed audit"); |
| 2694 | assert!(!audit.is_priced()); |
| 2695 | assert_eq!( |
| 2696 | audit.unpriced_reason, |
| 2697 | Some(UnpricedReason::InvalidPricingRow) |
| 2698 | ); |
| 2699 | assert_eq!( |
| 2700 | audit.unpriced_reason.unwrap().label(), |
| 2701 | "invalid_pricing_row" |
| 2702 | ); |
| 2703 | } |
| 2704 | |
| 2705 | /// A hand-sourced row with **no published** cache-write rate must fail closed |
| 2706 | /// for a turn that wrote to cache, while a row whose provider *documents* |
| 2707 | /// that writes carry no separate charge prices it at the input rate. |
| 2708 | /// |
| 2709 | /// Both used to be `None` and both silently billed writes at the input rate, |
| 2710 | /// which invented a price for the first case (#4318). |
| 2711 | #[test] |
| 2712 | fn unpublished_cache_write_fails_closed_but_documented_same_rate_prices() { |
| 2713 | let write_heavy = Usage { |
| 2714 | input_tokens: 1_000_000, |
| 2715 | output_tokens: 0, |
| 2716 | prompt_cache_hit_tokens: Some(0), |
| 2717 | prompt_cache_miss_tokens: Some(900_000), |
| 2718 | prompt_cache_write_tokens: Some(100_000), |
| 2719 | ..Usage::default() |
| 2720 | }; |
| 2721 | // Pinned off-peak (12:00 UTC) so the DeepSeek tier is deterministic. |
| 2722 | let now = Utc |
| 2723 | .with_ymd_and_hms(2026, 8, 17, 12, 0, 0) |
| 2724 | .single() |
| 2725 | .unwrap(); |
| 2726 | |
| 2727 | // DeepSeek documents that a cache miss is billed once and cached for |
| 2728 | // free, so the miss rate *is* the published write rate. The policy |
| 2729 | // carries the documentation receipt rather than being an assumption. |
| 2730 | let deepseek = deepseek_v4_flash_pricing(now); |
| 2731 | assert_eq!( |
| 2732 | deepseek.usd.cache_write, |
| 2733 | CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE) |
| 2734 | ); |
| 2735 | let priced = audit_turn_cost_for_provider_at( |
| 2736 | ProviderKind::Deepseek, |
| 2737 | "deepseek-v4-flash", |
| 2738 | &write_heavy, |
| 2739 | now, |
| 2740 | ); |
| 2741 | assert!(priced.is_priced(), "{priced:?}"); |
| 2742 | // 900k miss + 100k write, both at the off-peak 0.22/M miss rate. |
| 2743 | let expected = (0.9 + 0.1) * 0.22; |
| 2744 | assert!( |
| 2745 | (priced.estimate.expect("priced").usd - expected).abs() < 1e-12, |
| 2746 | "{priced:?}" |
| 2747 | ); |
| 2748 | |
| 2749 | // StepFun's hand row publishes input/cache-read/output only. A write |
| 2750 | // turn is unpriced and names the class instead of borrowing the input |
| 2751 | // rate. |
| 2752 | let stepfun = pricing_for_billing_surface( |
| 2753 | ProviderKind::Stepfun, |
| 2754 | DEFAULT_STEPFUN_MODEL, |
| 2755 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2756 | ) |
| 2757 | .expect("StepFun PAYG row"); |
| 2758 | assert_eq!(stepfun.usd.cache_write, CacheWritePolicy::Unpublished); |
| 2759 | let failed = audit_turn_cost_for_route_at( |
| 2760 | ProviderKind::Stepfun, |
| 2761 | DEFAULT_STEPFUN_MODEL, |
| 2762 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2763 | &write_heavy, |
| 2764 | now, |
| 2765 | ); |
| 2766 | assert!(!failed.is_priced(), "{failed:?}"); |
| 2767 | assert_eq!( |
| 2768 | failed.unpriced_reason, |
| 2769 | Some(UnpricedReason::MissingClassPrice) |
| 2770 | ); |
| 2771 | assert_eq!(failed.unpriced_classes, vec![TokenClass::CacheWrite]); |
| 2772 | |
| 2773 | // The same route with no cache-write tokens prices normally, proving the |
| 2774 | // gap is class-scoped rather than route-scoped. |
| 2775 | let no_write = Usage { |
| 2776 | prompt_cache_write_tokens: None, |
| 2777 | ..write_heavy.clone() |
| 2778 | }; |
| 2779 | assert!( |
| 2780 | audit_turn_cost_for_route_at( |
| 2781 | ProviderKind::Stepfun, |
| 2782 | DEFAULT_STEPFUN_MODEL, |
| 2783 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2784 | &no_write, |
| 2785 | now, |
| 2786 | ) |
| 2787 | .is_priced() |
| 2788 | ); |
| 2789 | } |
| 2790 | |
| 2791 | /// Every exact billing surface a route can carry must be understood, and |
| 2792 | /// anything unrecognized must fail closed as unknown rather than defaulting |
| 2793 | /// into per-token dollars (#4318). |
| 2794 | #[test] |
| 2795 | fn official_chatgpt_api_requires_the_exact_secure_base() { |
| 2796 | for endpoint in ["https://api.openai.com/v1", "https://api.openai.com/v1/"] { |
| 2797 | assert!(is_official_chatgpt_api(endpoint), "{endpoint}"); |
| 2798 | } |
| 2799 | for endpoint in [ |
| 2800 | "http://api.openai.com/v1", |
| 2801 | "https://api.openai.com:444/v1", |
| 2802 | "https://api.openai.com.example.net/v1", |
| 2803 | "https://api.openai.com/v1/responses", |
| 2804 | "https://api.openai.com/v1?route=plan", |
| 2805 | "https://api.openai.com/v1#plan", |
| 2806 | "https://user:secret@api.openai.com/v1", |
| 2807 | "https://chatgpt.com/backend-api", |
| 2808 | "", |
| 2809 | ] { |
| 2810 | assert!(!is_official_chatgpt_api(endpoint), "{endpoint}"); |
| 2811 | } |
| 2812 | } |
| 2813 | |
| 2814 | #[test] |
| 2815 | fn public_chatgpt_endpoint_does_not_prove_a_plan_without_grant_provenance() { |
| 2816 | assert_eq!( |
| 2817 | billing_surface_for_route(ProviderKind::OpenaiCodex, Some("https://api.openai.com/v1")), |
| 2818 | Some(UNCLASSIFIED_BILLING_SURFACE) |
| 2819 | ); |
| 2820 | assert_eq!( |
| 2821 | billing_surface_for_route(ProviderKind::Openai, Some("https://api.openai.com/v1")), |
| 2822 | Some(FIRST_PARTY_PAYG_BILLING_SURFACE) |
| 2823 | ); |
| 2824 | assert_eq!( |
| 2825 | billing_surface_for_route( |
| 2826 | ProviderKind::OpenaiCodex, |
| 2827 | Some("https://chatgpt.com/backend-api/codex") |
| 2828 | ), |
| 2829 | Some(OAUTH_SUBSCRIPTION_BILLING_SURFACE) |
| 2830 | ); |
| 2831 | } |
| 2832 | |
| 2833 | #[test] |
| 2834 | fn endpoint_classification_covers_every_exact_billing_surface() { |
| 2835 | for (provider, base_url, expected_surface, expected_metering) in [ |
| 2836 | ( |
| 2837 | ProviderKind::Zai, |
| 2838 | "https://api.z.ai/api/coding/paas/v4", |
| 2839 | ZAI_CODING_PLAN_BILLING_SURFACE, |
| 2840 | EndpointMetering::ExactSubscription, |
| 2841 | ), |
| 2842 | ( |
| 2843 | ProviderKind::Zai, |
| 2844 | "https://api.z.ai/api/paas/v4", |
| 2845 | ZAI_PAYG_BILLING_SURFACE, |
| 2846 | EndpointMetering::Money, |
| 2847 | ), |
| 2848 | ( |
| 2849 | ProviderKind::Moonshot, |
| 2850 | crate::config::DEFAULT_KIMI_CODE_BASE_URL, |
| 2851 | MOONSHOT_KIMI_CODE_BILLING_SURFACE, |
| 2852 | EndpointMetering::ExactSubscription, |
| 2853 | ), |
| 2854 | ( |
| 2855 | ProviderKind::Moonshot, |
| 2856 | "https://api.moonshot.ai/v1", |
| 2857 | MOONSHOT_PAYG_BILLING_SURFACE, |
| 2858 | EndpointMetering::Money, |
| 2859 | ), |
| 2860 | ( |
| 2861 | ProviderKind::XiaomiMimo, |
| 2862 | crate::config::XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, |
| 2863 | XIAOMI_PAYG_BILLING_SURFACE, |
| 2864 | EndpointMetering::Money, |
| 2865 | ), |
| 2866 | ( |
| 2867 | ProviderKind::XiaomiMimo, |
| 2868 | crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL, |
| 2869 | XIAOMI_TOKEN_PLAN_BILLING_SURFACE, |
| 2870 | EndpointMetering::ExactSubscription, |
| 2871 | ), |
| 2872 | ( |
| 2873 | ProviderKind::Stepfun, |
| 2874 | "https://api.stepfun.ai/step_plan/v1", |
| 2875 | STEPFUN_PLAN_BILLING_SURFACE, |
| 2876 | EndpointMetering::ExactSubscription, |
| 2877 | ), |
| 2878 | ( |
| 2879 | ProviderKind::Stepfun, |
| 2880 | "https://api.stepfun.ai/v1", |
| 2881 | STEPFUN_PAYG_BILLING_SURFACE, |
| 2882 | EndpointMetering::Money, |
| 2883 | ), |
| 2884 | ( |
| 2885 | ProviderKind::Anthropic, |
| 2886 | "https://api.anthropic.com/v1", |
| 2887 | FIRST_PARTY_PAYG_BILLING_SURFACE, |
| 2888 | EndpointMetering::Money, |
| 2889 | ), |
| 2890 | ( |
| 2891 | ProviderKind::Openrouter, |
| 2892 | "https://openrouter.ai/api/v1", |
| 2893 | AGGREGATOR_BILLING_SURFACE, |
| 2894 | EndpointMetering::Money, |
| 2895 | ), |
| 2896 | ( |
| 2897 | ProviderKind::Orcarouter, |
| 2898 | "https://api.orcarouter.ai/v1", |
| 2899 | AGGREGATOR_BILLING_SURFACE, |
| 2900 | EndpointMetering::Money, |
| 2901 | ), |
| 2902 | ] { |
| 2903 | let surface = billing_surface_for_route(provider, Some(base_url)); |
| 2904 | assert_eq!(surface, Some(expected_surface), "{provider:?} {base_url}"); |
| 2905 | assert_eq!( |
| 2906 | endpoint_metering_for_billing_surface(surface), |
| 2907 | expected_metering, |
| 2908 | "{provider:?} {base_url}" |
| 2909 | ); |
| 2910 | } |
| 2911 | |
| 2912 | // Provider-intrinsic surfaces need no URL at all. |
| 2913 | for (provider, expected_surface, expected_metering) in [ |
| 2914 | ( |
| 2915 | ProviderKind::OpenaiCodex, |
| 2916 | UNCLASSIFIED_BILLING_SURFACE, |
| 2917 | EndpointMetering::Unknown, |
| 2918 | ), |
| 2919 | ( |
| 2920 | ProviderKind::OpencodeGo, |
| 2921 | OAUTH_SUBSCRIPTION_BILLING_SURFACE, |
| 2922 | EndpointMetering::ExactSubscription, |
| 2923 | ), |
| 2924 | ( |
| 2925 | ProviderKind::Ollama, |
| 2926 | LOCAL_BILLING_SURFACE, |
| 2927 | EndpointMetering::LocalNoBill, |
| 2928 | ), |
| 2929 | ( |
| 2930 | ProviderKind::OllamaCloud, |
| 2931 | UNCLASSIFIED_BILLING_SURFACE, |
| 2932 | EndpointMetering::Unknown, |
| 2933 | ), |
| 2934 | ( |
| 2935 | ProviderKind::Vllm, |
| 2936 | LOCAL_BILLING_SURFACE, |
| 2937 | EndpointMetering::LocalNoBill, |
| 2938 | ), |
| 2939 | // A named custom endpoint's pay mode is config, not URL shape. |
| 2940 | ( |
| 2941 | ProviderKind::Custom, |
| 2942 | UNCLASSIFIED_BILLING_SURFACE, |
| 2943 | EndpointMetering::Unknown, |
| 2944 | ), |
| 2945 | ] { |
| 2946 | let surface = billing_surface_for_route(provider, None); |
| 2947 | assert_eq!(surface, Some(expected_surface), "{provider:?}"); |
| 2948 | assert_eq!( |
| 2949 | endpoint_metering_for_billing_surface(surface), |
| 2950 | expected_metering, |
| 2951 | "{provider:?}" |
| 2952 | ); |
| 2953 | } |
| 2954 | |
| 2955 | // An unrecognized surface id — including one a newer build might write — |
| 2956 | // is never guessed into a known bucket. |
| 2957 | for unknown in [ |
| 2958 | Some("some-future-surface"), |
| 2959 | Some(""), |
| 2960 | Some(" "), |
| 2961 | Some(UNCLASSIFIED_BILLING_SURFACE), |
| 2962 | None, |
| 2963 | ] { |
| 2964 | assert_eq!( |
| 2965 | endpoint_metering_for_billing_surface(unknown), |
| 2966 | EndpointMetering::Unknown, |
| 2967 | "{unknown:?}" |
| 2968 | ); |
| 2969 | } |
| 2970 | } |
| 2971 | |
| 2972 | /// An endpoint that was never established is not the official endpoint. |
| 2973 | /// |
| 2974 | /// The route audit used to fall through to the provider/model catalog when |
| 2975 | /// no billing surface was supplied, which meant a persisted or recorded row |
| 2976 | /// carrying nothing but `provider: "openai"` and a familiar model id got |
| 2977 | /// billed at OpenAI's published first-party rates — even though the turn |
| 2978 | /// could equally have been served by a proxy, a gateway, or a self-hosted |
| 2979 | /// clone speaking the same protocol. Absence of endpoint evidence is not |
| 2980 | /// evidence of the official endpoint. |
| 2981 | #[test] |
| 2982 | fn an_unestablished_endpoint_is_never_priced_as_the_official_one() { |
| 2983 | let usage = Usage { |
| 2984 | input_tokens: 10_000, |
| 2985 | output_tokens: 1_000, |
| 2986 | ..Usage::default() |
| 2987 | }; |
| 2988 | let now = Utc::now(); |
| 2989 | for (provider, model) in [ |
| 2990 | (ProviderKind::Openai, "gpt-5.5"), |
| 2991 | (ProviderKind::Anthropic, "claude-haiku-4-5"), |
| 2992 | (ProviderKind::Deepseek, "deepseek-v4-flash"), |
| 2993 | (ProviderKind::Openrouter, "openai/gpt-5.5"), |
| 2994 | (ProviderKind::Moonshot, "kimi-k2.7-code"), |
| 2995 | ] { |
| 2996 | let audit = audit_turn_cost_for_route_at(provider, model, None, &usage, now); |
| 2997 | assert_eq!( |
| 2998 | audit.unpriced_reason, |
| 2999 | Some(UnpricedReason::UnestablishedEndpoint), |
| 3000 | "{provider:?}/{model}: {audit:?}" |
| 3001 | ); |
| 3002 | assert!(!audit.is_priced(), "{provider:?}/{model}: {audit:?}"); |
| 3003 | assert_eq!(audit.estimate, None, "{provider:?}/{model}"); |
| 3004 | // An unknown route is still possibly-spent money, so it stays in |
| 3005 | // the coverage denominator rather than being excused like an OAuth |
| 3006 | // or local route. |
| 3007 | assert!( |
| 3008 | audit.counts_toward_money_coverage(), |
| 3009 | "{provider:?}/{model}: an unknown route must not leave money coverage" |
| 3010 | ); |
| 3011 | |
| 3012 | // The same route with its endpoint actually classified prices |
| 3013 | // normally: this is a fail-closed rule, not a refusal to price. |
| 3014 | // (OpenRouter is excluded here only because its aggregator surface |
| 3015 | // carries no bundled rate at all, which is a different gap.) |
| 3016 | if provider == ProviderKind::Openrouter { |
| 3017 | continue; |
| 3018 | } |
| 3019 | let classified = audit_turn_cost_for_route_at( |
| 3020 | provider, |
| 3021 | model, |
| 3022 | billing_surface_for_route(provider, Some(provider.provider().default_base_url())), |
| 3023 | &usage, |
| 3024 | now, |
| 3025 | ); |
| 3026 | assert!( |
| 3027 | classified.is_priced(), |
| 3028 | "{provider:?}/{model} must price on its own official endpoint: {classified:?}" |
| 3029 | ); |
| 3030 | } |
| 3031 | |
| 3032 | // The distinction is preserved end to end: "no endpoint offered" and |
| 3033 | // "endpoint offered but unplaceable" are different findings, and |
| 3034 | // neither is a price. |
| 3035 | let unplaceable = audit_turn_cost_for_route_at( |
| 3036 | ProviderKind::Openai, |
| 3037 | "gpt-5.5", |
| 3038 | billing_surface_for_route(ProviderKind::Openai, Some("https://proxy.example/v1")), |
| 3039 | &usage, |
| 3040 | now, |
| 3041 | ); |
| 3042 | assert_eq!( |
| 3043 | unplaceable.unpriced_reason, |
| 3044 | Some(UnpricedReason::UnknownBillingBasis) |
| 3045 | ); |
| 3046 | } |
| 3047 | |
| 3048 | #[test] |
| 3049 | fn builtin_provider_names_do_not_price_unofficial_proxy_endpoints() { |
| 3050 | let usage = Usage { |
| 3051 | input_tokens: 10_000, |
| 3052 | output_tokens: 1_000, |
| 3053 | ..Usage::default() |
| 3054 | }; |
| 3055 | for (provider, model) in [ |
| 3056 | (ProviderKind::Deepseek, "deepseek-v4-flash"), |
| 3057 | (ProviderKind::Openai, "gpt-5.5"), |
| 3058 | (ProviderKind::Anthropic, "claude-haiku-4-5"), |
| 3059 | (ProviderKind::Openrouter, "openai/gpt-5.5"), |
| 3060 | ] { |
| 3061 | let surface = billing_surface_for_route(provider, Some("https://proxy.example/v1")); |
| 3062 | assert_eq!(surface, Some(UNCLASSIFIED_BILLING_SURFACE), "{provider:?}"); |
| 3063 | let audit = audit_turn_cost_for_route_at(provider, model, surface, &usage, Utc::now()); |
| 3064 | assert_eq!( |
| 3065 | audit.unpriced_reason, |
| 3066 | Some(UnpricedReason::UnknownBillingBasis), |
| 3067 | "{provider:?}: {audit:?}" |
| 3068 | ); |
| 3069 | assert!(!audit.is_priced(), "{provider:?}: {audit:?}"); |
| 3070 | } |
| 3071 | |
| 3072 | assert_eq!( |
| 3073 | billing_surface_for_route( |
| 3074 | ProviderKind::Moonshot, |
| 3075 | Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL) |
| 3076 | ), |
| 3077 | Some(MOONSHOT_KIMI_CODE_BILLING_SURFACE) |
| 3078 | ); |
| 3079 | for (provider, endpoint) in [ |
| 3080 | (ProviderKind::Minimax, "https://api.minimax.io/v1"), |
| 3081 | ( |
| 3082 | ProviderKind::MinimaxAnthropic, |
| 3083 | "https://api.minimax.io/anthropic", |
| 3084 | ), |
| 3085 | ( |
| 3086 | ProviderKind::Minimax, |
| 3087 | "https://api.minimax.io/v1/token-plan", |
| 3088 | ), |
| 3089 | ( |
| 3090 | ProviderKind::XiaomiMimo, |
| 3091 | "https://token-plan-proxy.example/v1", |
| 3092 | ), |
| 3093 | ( |
| 3094 | ProviderKind::Zai, |
| 3095 | "https://api.z.ai/api/coding/something-else", |
| 3096 | ), |
| 3097 | ] { |
| 3098 | assert_eq!( |
| 3099 | billing_surface_for_route(provider, Some(endpoint)), |
| 3100 | Some(UNCLASSIFIED_BILLING_SURFACE), |
| 3101 | "{provider:?} {endpoint}" |
| 3102 | ); |
| 3103 | } |
| 3104 | } |
| 3105 | |
| 3106 | /// A route classified as an exact subscription surface is not money-metered |
| 3107 | /// even when the provider-level presentation guessed "metered", and it must |
| 3108 | /// never reach a per-token rate. |
| 3109 | #[test] |
| 3110 | fn exact_plan_surface_overrides_a_metered_presentation() { |
| 3111 | let usage = Usage { |
| 3112 | input_tokens: 100_000, |
| 3113 | output_tokens: 10_000, |
| 3114 | ..Usage::default() |
| 3115 | }; |
| 3116 | let audit = audit_turn_cost_for_route( |
| 3117 | ProviderKind::Zai, |
| 3118 | "glm-5.2", |
| 3119 | Some(ZAI_CODING_PLAN_BILLING_SURFACE), |
| 3120 | &usage, |
| 3121 | Utc::now(), |
| 3122 | crate::route_billing::BillingPresentation::Metered, |
| 3123 | ); |
| 3124 | assert!(!audit.is_priced(), "{audit:?}"); |
| 3125 | assert_eq!(audit.unpriced_reason, Some(UnpricedReason::NotMoneyMetered)); |
| 3126 | assert!(!audit.counts_toward_money_coverage()); |
| 3127 | |
| 3128 | // The same model on the per-token surface is money-metered, so it stays |
| 3129 | // in the coverage denominator whether or not a price is found. |
| 3130 | let payg = audit_turn_cost_for_route( |
| 3131 | ProviderKind::Zai, |
| 3132 | "glm-5.2", |
| 3133 | Some(ZAI_PAYG_BILLING_SURFACE), |
| 3134 | &usage, |
| 3135 | Utc::now(), |
| 3136 | crate::route_billing::BillingPresentation::Metered, |
| 3137 | ); |
| 3138 | assert!(payg.counts_toward_money_coverage(), "{payg:?}"); |
| 3139 | } |
| 3140 | |
| 3141 | /// An unknown billing basis is *not* a subscription. It stays unpriced and |
| 3142 | /// stays inside the money-coverage denominator, so its spend is reported as |
| 3143 | /// missing rather than excused (#4318). |
| 3144 | #[test] |
| 3145 | fn unknown_billing_basis_is_not_excused_as_not_money_metered() { |
| 3146 | let usage = Usage { |
| 3147 | input_tokens: 10_000, |
| 3148 | output_tokens: 1_000, |
| 3149 | ..Usage::default() |
| 3150 | }; |
| 3151 | let unknown = audit_turn_cost_for_route( |
| 3152 | ProviderKind::Anthropic, |
| 3153 | "claude-haiku-4-5", |
| 3154 | None, |
| 3155 | &usage, |
| 3156 | Utc::now(), |
| 3157 | crate::route_billing::BillingPresentation::Unknown, |
| 3158 | ); |
| 3159 | assert!(!unknown.is_priced()); |
| 3160 | assert_eq!( |
| 3161 | unknown.unpriced_reason, |
| 3162 | Some(UnpricedReason::UnknownBillingBasis) |
| 3163 | ); |
| 3164 | assert!(unknown.counts_toward_money_coverage()); |
| 3165 | |
| 3166 | // Local and subscription presentations are exact, so they *are* excused. |
| 3167 | for billing in [ |
| 3168 | crate::route_billing::BillingPresentation::Local, |
| 3169 | crate::route_billing::BillingPresentation::Subscription("plan"), |
| 3170 | ] { |
| 3171 | let audit = audit_turn_cost_for_route( |
| 3172 | ProviderKind::Anthropic, |
| 3173 | "claude-haiku-4-5", |
| 3174 | None, |
| 3175 | &usage, |
| 3176 | Utc::now(), |
| 3177 | billing, |
| 3178 | ); |
| 3179 | assert_eq!(audit.unpriced_reason, Some(UnpricedReason::NotMoneyMetered)); |
| 3180 | assert!(!audit.counts_toward_money_coverage()); |
| 3181 | } |
| 3182 | } |
| 3183 | |
| 3184 | #[test] |
| 3185 | fn audit_names_why_a_turn_is_missing_from_a_total() { |
| 3186 | let write_heavy = Usage { |
| 3187 | input_tokens: 1_000_000, |
| 3188 | output_tokens: 100_000, |
| 3189 | prompt_cache_hit_tokens: Some(200_000), |
| 3190 | prompt_cache_write_tokens: Some(100_000), |
| 3191 | ..Usage::default() |
| 3192 | }; |
| 3193 | |
| 3194 | // Anthropic publishes a cache-write rate: fully priced, provenance kept. |
| 3195 | let priced = audit_turn_cost_for_provider_at( |
| 3196 | ProviderKind::Anthropic, |
| 3197 | "claude-haiku-4-5", |
| 3198 | &write_heavy, |
| 3199 | Utc::now(), |
| 3200 | ); |
| 3201 | assert!(priced.is_priced()); |
| 3202 | assert_eq!(priced.unpriced_reason, None); |
| 3203 | assert!(priced.unpriced_classes.is_empty()); |
| 3204 | assert!(priced.provenance.is_some()); |
| 3205 | |
| 3206 | // Moonshot does not: the turn fails closed and names the class. |
| 3207 | let missing = audit_turn_cost_for_provider_at( |
| 3208 | ProviderKind::Moonshot, |
| 3209 | "kimi-k2.7-code", |
| 3210 | &write_heavy, |
| 3211 | Utc::now(), |
| 3212 | ); |
| 3213 | assert!(!missing.is_priced()); |
| 3214 | assert_eq!( |
| 3215 | missing.unpriced_reason, |
| 3216 | Some(UnpricedReason::MissingClassPrice) |
| 3217 | ); |
| 3218 | assert_eq!(missing.unpriced_classes, vec![TokenClass::CacheWrite]); |
| 3219 | // Dropping the write tokens makes the very same route priceable, which |
| 3220 | // proves the gap is class-scoped rather than route-scoped. |
| 3221 | let no_write = Usage { |
| 3222 | prompt_cache_write_tokens: None, |
| 3223 | ..write_heavy.clone() |
| 3224 | }; |
| 3225 | assert!( |
| 3226 | audit_turn_cost_for_provider_at( |
| 3227 | ProviderKind::Moonshot, |
| 3228 | "kimi-k2.7-code", |
| 3229 | &no_write, |
| 3230 | Utc::now(), |
| 3231 | ) |
| 3232 | .is_priced() |
| 3233 | ); |
| 3234 | |
| 3235 | // Subscription/OAuth and ambiguous-surface routes report their own |
| 3236 | // reasons rather than an absent price. |
| 3237 | assert_eq!( |
| 3238 | audit_turn_cost_for_provider_at( |
| 3239 | ProviderKind::OpenaiCodex, |
| 3240 | "gpt-5.5", |
| 3241 | &write_heavy, |
| 3242 | Utc::now(), |
| 3243 | ) |
| 3244 | .unpriced_reason, |
| 3245 | Some(UnpricedReason::AmbiguousBillingSurface) |
| 3246 | ); |
| 3247 | assert_eq!( |
| 3248 | audit_turn_cost_for_route_at( |
| 3249 | ProviderKind::Stepfun, |
| 3250 | DEFAULT_STEPFUN_MODEL, |
| 3251 | None, |
| 3252 | &write_heavy, |
| 3253 | Utc::now(), |
| 3254 | ) |
| 3255 | .unpriced_reason, |
| 3256 | Some(UnpricedReason::AmbiguousBillingSurface) |
| 3257 | ); |
| 3258 | assert_eq!( |
| 3259 | audit_turn_cost_for_provider_at( |
| 3260 | ProviderKind::Openai, |
| 3261 | "gpt-5.5", |
| 3262 | &Usage { |
| 3263 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 3264 | ..Usage::default() |
| 3265 | }, |
| 3266 | Utc::now(), |
| 3267 | ) |
| 3268 | .unpriced_reason, |
| 3269 | Some(UnpricedReason::UnrepresentedTier) |
| 3270 | ); |
| 3271 | } |
| 3272 | |
| 3273 | /// The audit and the estimator are the same computation, so every route |
| 3274 | /// must agree on whether it produced a number. |
| 3275 | #[test] |
| 3276 | fn audit_and_estimate_never_disagree() { |
| 3277 | let usage = Usage { |
| 3278 | input_tokens: 10_000, |
| 3279 | output_tokens: 1_000, |
| 3280 | prompt_cache_hit_tokens: Some(2_000), |
| 3281 | prompt_cache_write_tokens: Some(1_000), |
| 3282 | ..Usage::default() |
| 3283 | }; |
| 3284 | let now = Utc::now(); |
| 3285 | for (provider, model) in [ |
| 3286 | (ProviderKind::Anthropic, "claude-haiku-4-5"), |
| 3287 | (ProviderKind::Anthropic, "claude-sonnet-5"), |
| 3288 | (ProviderKind::Moonshot, "kimi-k2.7-code"), |
| 3289 | (ProviderKind::Openai, "gpt-5.5"), |
| 3290 | (ProviderKind::OpenaiCodex, "gpt-5.5"), |
| 3291 | (ProviderKind::Deepseek, "deepseek-v4-pro"), |
| 3292 | (ProviderKind::Ollama, "gpt-5.5"), |
| 3293 | (ProviderKind::Stepfun, DEFAULT_STEPFUN_MODEL), |
| 3294 | ] { |
| 3295 | let audit = audit_turn_cost_for_route_at(provider, model, None, &usage, now); |
| 3296 | let estimate = |
| 3297 | calculate_turn_cost_estimate_for_route_at(provider, model, None, &usage, now); |
| 3298 | assert_eq!(audit.estimate, estimate, "{provider:?}/{model}"); |
| 3299 | assert_eq!( |
| 3300 | audit.is_priced(), |
| 3301 | audit.unpriced_reason.is_none(), |
| 3302 | "{provider:?}/{model}" |
| 3303 | ); |
| 3304 | } |
| 3305 | } |
| 3306 | |
| 3307 | #[test] |
| 3308 | fn nvidia_nim_deepseek_model_does_not_use_deepseek_platform_pricing() { |
| 3309 | assert!(!has_pricing_for_model("deepseek-ai/deepseek-v4-pro")); |
| 3310 | } |
| 3311 | |
| 3312 | #[test] |
| 3313 | fn stepfun_current_model_rates_require_payg_provenance() { |
| 3314 | for (model, cache, input, output) in [ |
| 3315 | ("step-5-preview", 0.05, 1.00, 2.70), |
| 3316 | ("step-3.7-flash", 0.04, 0.20, 1.15), |
| 3317 | ("step-3.5-flash", 0.02, 0.10, 0.30), |
| 3318 | ("step-3.5-flash-2603", 0.02, 0.10, 0.30), |
| 3319 | ] { |
| 3320 | let price = pricing_for_billing_surface( |
| 3321 | ProviderKind::Stepfun, |
| 3322 | model, |
| 3323 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 3324 | ) |
| 3325 | .unwrap(); |
| 3326 | assert_eq!(price.usd.input_cache_hit_per_million, cache); |
| 3327 | assert_eq!(price.usd.input_cache_miss_per_million, input); |
| 3328 | assert_eq!(price.usd.output_per_million, output); |
| 3329 | assert!( |
| 3330 | pricing_for_billing_surface( |
| 3331 | ProviderKind::Stepfun, |
| 3332 | model, |
| 3333 | Some(STEPFUN_PLAN_BILLING_SURFACE) |
| 3334 | ) |
| 3335 | .is_none() |
| 3336 | ); |
| 3337 | assert!(route_requires_billing_surface(ProviderKind::Custom, model)); |
| 3338 | assert!(!has_pricing_for_provider(ProviderKind::Stepfun, model)); |
| 3339 | } |
| 3340 | } |
| 3341 | |
| 3342 | #[test] |
| 3343 | fn stepfun_billing_surface_keeps_payg_separate_from_step_plan() { |
| 3344 | for base_url in [ |
| 3345 | "https://api.stepfun.ai", |
| 3346 | "https://api.stepfun.ai/", |
| 3347 | "https://api.stepfun.ai/v1", |
| 3348 | "https://API.STEPFUN.AI/v1/", |
| 3349 | ] { |
| 3350 | assert_eq!( |
| 3351 | billing_surface_for_route(ProviderKind::Stepfun, Some(base_url)), |
| 3352 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 3353 | "{base_url}" |
| 3354 | ); |
| 3355 | } |
| 3356 | for base_url in [ |
| 3357 | "https://api.stepfun.ai/step_plan", |
| 3358 | "https://api.stepfun.ai/step_plan/v1/", |
| 3359 | "https://api.stepfun.com/step_plan/v1", |
| 3360 | ] { |
| 3361 | assert_eq!( |
| 3362 | billing_surface_for_route(ProviderKind::Stepfun, Some(base_url)), |
| 3363 | Some(STEPFUN_PLAN_BILLING_SURFACE), |
| 3364 | "{base_url}" |
| 3365 | ); |
| 3366 | } |
| 3367 | // Endpoints CodeWhale cannot place now classify *positively* as |
| 3368 | // unclassified rather than returning `None`. Both fail closed |
| 3369 | // identically, but "we looked and could not place this" is a different |
| 3370 | // fact from "no endpoint was supplied", and the audit reports it as |
| 3371 | // such (#4318). |
| 3372 | for base_url in [ |
| 3373 | "http://api.stepfun.ai/v1", |
| 3374 | "https://token@api.stepfun.ai/v1", |
| 3375 | "https://api.stepfun.ai/v1?account=other", |
| 3376 | "https://api.stepfun.ai/STEP_PLAN/v1", |
| 3377 | "https://stepfun.example/v1", |
| 3378 | ] { |
| 3379 | assert_eq!( |
| 3380 | billing_surface_for_route(ProviderKind::Stepfun, Some(base_url)), |
| 3381 | Some(UNCLASSIFIED_BILLING_SURFACE), |
| 3382 | "{base_url}" |
| 3383 | ); |
| 3384 | assert_eq!( |
| 3385 | endpoint_metering_for_billing_surface(Some(UNCLASSIFIED_BILLING_SURFACE)), |
| 3386 | EndpointMetering::Unknown |
| 3387 | ); |
| 3388 | } |
| 3389 | // A StepFun URL paired with the OpenRouter protocol is a foreign custom |
| 3390 | // endpoint, not proof of either provider's billing surface. |
| 3391 | assert_eq!( |
| 3392 | billing_surface_for_route(ProviderKind::Openrouter, Some(DEFAULT_STEPFUN_BASE_URL)), |
| 3393 | Some(UNCLASSIFIED_BILLING_SURFACE) |
| 3394 | ); |
| 3395 | // No endpoint at all stays `None`. |
| 3396 | assert_eq!( |
| 3397 | billing_surface_for_route(ProviderKind::Stepfun, None), |
| 3398 | None, |
| 3399 | "an absent endpoint is not a classification" |
| 3400 | ); |
| 3401 | |
| 3402 | let usage = Usage { |
| 3403 | input_tokens: 1_000_000, |
| 3404 | output_tokens: 500_000, |
| 3405 | prompt_cache_hit_tokens: Some(250_000), |
| 3406 | ..Default::default() |
| 3407 | }; |
| 3408 | let payg = calculate_turn_cost_estimate_for_billing_surface( |
| 3409 | ProviderKind::Stepfun, |
| 3410 | DEFAULT_STEPFUN_MODEL, |
| 3411 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 3412 | &usage, |
| 3413 | ) |
| 3414 | .expect("standard StepFun API has an authoritative token price"); |
| 3415 | assert!((payg.usd - 0.735).abs() < 1e-12); |
| 3416 | assert_eq!(payg.cny, 0.0); |
| 3417 | |
| 3418 | // Provider/model-only legacy callers cannot distinguish PAYG from Step |
| 3419 | // Plan and must not add either route to spend or savings totals. |
| 3420 | assert!( |
| 3421 | calculate_turn_cost_estimate_for_provider( |
| 3422 | ProviderKind::Stepfun, |
| 3423 | DEFAULT_STEPFUN_MODEL, |
| 3424 | &usage, |
| 3425 | ) |
| 3426 | .is_none() |
| 3427 | ); |
| 3428 | assert!( |
| 3429 | calculate_turn_cost_estimate_for_provider_at( |
| 3430 | ProviderKind::Stepfun, |
| 3431 | DEFAULT_STEPFUN_MODEL, |
| 3432 | &usage, |
| 3433 | Utc::now(), |
| 3434 | ) |
| 3435 | .is_none() |
| 3436 | ); |
| 3437 | assert!(!has_pricing_for_provider( |
| 3438 | ProviderKind::Stepfun, |
| 3439 | DEFAULT_STEPFUN_MODEL |
| 3440 | )); |
| 3441 | |
| 3442 | for surface in [None, Some(STEPFUN_PLAN_BILLING_SURFACE)] { |
| 3443 | assert!( |
| 3444 | calculate_turn_cost_estimate_for_billing_surface( |
| 3445 | ProviderKind::Stepfun, |
| 3446 | DEFAULT_STEPFUN_MODEL, |
| 3447 | surface, |
| 3448 | &usage, |
| 3449 | ) |
| 3450 | .is_none() |
| 3451 | ); |
| 3452 | } |
| 3453 | assert!( |
| 3454 | calculate_turn_cost_estimate_for_billing_surface( |
| 3455 | ProviderKind::Stepfun, |
| 3456 | "step-unknown", |
| 3457 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 3458 | &usage, |
| 3459 | ) |
| 3460 | .is_none() |
| 3461 | ); |
| 3462 | for provider in [ |
| 3463 | ProviderKind::Openrouter, |
| 3464 | ProviderKind::Ollama, |
| 3465 | ProviderKind::Custom, |
| 3466 | ] { |
| 3467 | assert!( |
| 3468 | calculate_turn_cost_estimate_for_billing_surface( |
| 3469 | provider, |
| 3470 | DEFAULT_STEPFUN_MODEL, |
| 3471 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 3472 | &usage, |
| 3473 | ) |
| 3474 | .is_none(), |
| 3475 | "{provider:?}" |
| 3476 | ); |
| 3477 | assert!( |
| 3478 | calculate_turn_cost_estimate_for_provider(provider, DEFAULT_STEPFUN_MODEL, &usage,) |
| 3479 | .is_none(), |
| 3480 | "{provider:?}" |
| 3481 | ); |
| 3482 | assert!( |
| 3483 | calculate_turn_cost_estimate_for_provider_at( |
| 3484 | provider, |
| 3485 | DEFAULT_STEPFUN_MODEL, |
| 3486 | &usage, |
| 3487 | Utc::now(), |
| 3488 | ) |
| 3489 | .is_none(), |
| 3490 | "{provider:?}" |
| 3491 | ); |
| 3492 | assert!( |
| 3493 | !has_pricing_for_provider(provider, DEFAULT_STEPFUN_MODEL), |
| 3494 | "{provider:?}" |
| 3495 | ); |
| 3496 | } |
| 3497 | |
| 3498 | let recorded = calculate_turn_cost_estimate_for_route_at( |
| 3499 | ProviderKind::Stepfun, |
| 3500 | DEFAULT_STEPFUN_MODEL, |
| 3501 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 3502 | &usage, |
| 3503 | Utc::now(), |
| 3504 | ) |
| 3505 | .expect("recorded PAYG route retains provider-scoped pricing"); |
| 3506 | assert_eq!(recorded, payg); |
| 3507 | } |
| 3508 | |
| 3509 | #[test] |
| 3510 | fn catalog_sourced_models_have_usd_pricing() { |
| 3511 | for (model, input, output) in [ |
| 3512 | ("minimax-m2.7", 0.3, 1.2), |
| 3513 | ("minimax/minimax-m2.7", 0.3, 1.2), |
| 3514 | ("step-3.7-flash", 0.2, 1.15), |
| 3515 | ("fugu-ultra-20260615", 5.0, 30.0), |
| 3516 | ("fugu-ultra", 5.0, 30.0), |
| 3517 | ] { |
| 3518 | let pricing = pricing_for_model_at(model, Utc::now()).expect(model); |
| 3519 | assert_eq!(pricing.usd.input_cache_miss_per_million, input, "{model}"); |
| 3520 | assert_eq!(pricing.usd.output_per_million, output, "{model}"); |
| 3521 | assert!(has_pricing_for_model(model)); |
| 3522 | } |
| 3523 | } |
| 3524 | |
| 3525 | #[test] |
| 3526 | fn trinity_mini_stays_unpriced_without_verified_provider_rates() { |
| 3527 | let usage = Usage { |
| 3528 | input_tokens: 1_000, |
| 3529 | output_tokens: 100, |
| 3530 | ..Usage::default() |
| 3531 | }; |
| 3532 | |
| 3533 | assert!(pricing_for_model_at("trinity-mini", Utc::now()).is_none()); |
| 3534 | assert!(!has_pricing_for_model("trinity-mini")); |
| 3535 | assert!(!has_pricing_for_provider( |
| 3536 | ProviderKind::Arcee, |
| 3537 | "trinity-mini" |
| 3538 | )); |
| 3539 | assert!( |
| 3540 | calculate_turn_cost_estimate_for_provider(ProviderKind::Arcee, "trinity-mini", &usage,) |
| 3541 | .is_none() |
| 3542 | ); |
| 3543 | } |
| 3544 | |
| 3545 | #[test] |
| 3546 | fn minimax_m3_standard_pricing_tracks_the_512k_input_boundary() { |
| 3547 | for model in ["MiniMax-M3", "minimax/minimax-m3"] { |
| 3548 | for (input_tokens, cache_read, input, output) in |
| 3549 | [(512_000, 0.06, 0.30, 1.20), (512_001, 0.12, 0.60, 2.40)] |
| 3550 | { |
| 3551 | let usage = Usage { |
| 3552 | input_tokens, |
| 3553 | ..Usage::default() |
| 3554 | }; |
| 3555 | let pricing = pricing_for_model_and_usage(model, &usage).expect("M3 pricing"); |
| 3556 | assert_eq!(pricing.usd.input_cache_hit_per_million, cache_read); |
| 3557 | assert_eq!(pricing.usd.input_cache_miss_per_million, input); |
| 3558 | assert_eq!(pricing.usd.output_per_million, output); |
| 3559 | } |
| 3560 | assert!(calculate_cache_savings(model, 1).is_none()); |
| 3561 | } |
| 3562 | } |
| 3563 | |
| 3564 | #[test] |
| 3565 | fn grok_46_pricing_tracks_the_200k_prompt_boundary() { |
| 3566 | for (input_tokens, cache_read, input, output) in |
| 3567 | [(199_999, 0.50, 2.00, 6.00), (200_000, 1.00, 4.00, 12.00)] |
| 3568 | { |
| 3569 | let usage = Usage { |
| 3570 | input_tokens, |
| 3571 | ..Usage::default() |
| 3572 | }; |
| 3573 | let pricing = |
| 3574 | pricing_for_model_and_usage("grok-4.6", &usage).expect("Grok 4.6 pricing"); |
| 3575 | assert_eq!(pricing.usd.input_cache_hit_per_million, cache_read); |
| 3576 | assert_eq!(pricing.usd.input_cache_miss_per_million, input); |
| 3577 | assert_eq!(pricing.usd.output_per_million, output); |
| 3578 | } |
| 3579 | } |
| 3580 | |
| 3581 | /// Published xAI rates per 1M tokens (cache-read, input, output) at the |
| 3582 | /// standard tier, verified 2026-08-17 on docs.x.ai/docs/models/grok-4.5 |
| 3583 | /// and /grok-4.3; the pages' embedded price tables carry a `LongContext` |
| 3584 | /// column at exactly 2x for prompts past 200K. |
| 3585 | const GROK_4_5_USD_STANDARD: (f64, f64, f64) = (0.30, 2.00, 6.00); |
| 3586 | const GROK_4_5_USD_LONG_CONTEXT: (f64, f64, f64) = (0.60, 4.00, 12.00); |
| 3587 | const GROK_4_3_USD_STANDARD: (f64, f64, f64) = (0.20, 1.25, 2.50); |
| 3588 | const GROK_4_3_USD_LONG_CONTEXT: (f64, f64, f64) = (0.40, 2.50, 5.00); |
| 3589 | |
| 3590 | #[test] |
| 3591 | fn grok_45_and_43_pricing_track_the_200k_prompt_boundary() { |
| 3592 | for (model, standard, long_context) in [ |
| 3593 | ("grok-4.5", GROK_4_5_USD_STANDARD, GROK_4_5_USD_LONG_CONTEXT), |
| 3594 | ("grok-4.3", GROK_4_3_USD_STANDARD, GROK_4_3_USD_LONG_CONTEXT), |
| 3595 | ] { |
| 3596 | for (input_tokens, expected) in [(199_999, standard), (200_000, long_context)] { |
| 3597 | let usage = Usage { |
| 3598 | input_tokens, |
| 3599 | ..Usage::default() |
| 3600 | }; |
| 3601 | let pricing = pricing_for_model_and_usage(model, &usage) |
| 3602 | .unwrap_or_else(|| panic!("{model} pricing")); |
| 3603 | assert_eq!( |
| 3604 | pricing.usd.input_cache_hit_per_million, expected.0, |
| 3605 | "{model} @ {input_tokens} cache-read" |
| 3606 | ); |
| 3607 | assert_eq!( |
| 3608 | pricing.usd.input_cache_miss_per_million, expected.1, |
| 3609 | "{model} @ {input_tokens} input" |
| 3610 | ); |
| 3611 | assert_eq!( |
| 3612 | pricing.usd.output_per_million, expected.2, |
| 3613 | "{model} @ {input_tokens} output" |
| 3614 | ); |
| 3615 | assert!(pricing.cny.is_none()); |
| 3616 | } |
| 3617 | // Metadata-only lookups report the standard tier. |
| 3618 | let metadata = pricing_for_model_at(model, Utc::now()).unwrap(); |
| 3619 | assert_eq!(metadata.usd.input_cache_miss_per_million, standard.1); |
| 3620 | } |
| 3621 | } |
| 3622 | |
| 3623 | #[test] |
| 3624 | fn direct_xai_grok_45_and_43_own_usage_tier_without_leaking_to_other_providers() { |
| 3625 | for (model, standard_input, long_input) in |
| 3626 | [("grok-4.5", 2.00, 4.00), ("grok-4.3", 1.25, 2.50)] |
| 3627 | { |
| 3628 | for (input_tokens, input_rate) in [(199_999, standard_input), (200_000, long_input)] { |
| 3629 | let usage = Usage { |
| 3630 | input_tokens, |
| 3631 | ..Usage::default() |
| 3632 | }; |
| 3633 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 3634 | ProviderKind::Xai, |
| 3635 | model, |
| 3636 | &usage, |
| 3637 | Utc::now(), |
| 3638 | ) |
| 3639 | .unwrap_or_else(|| panic!("direct xAI {model} has tiered pricing")); |
| 3640 | let expected = f64::from(input_tokens) / 1_000_000.0 * input_rate; |
| 3641 | assert!( |
| 3642 | (estimate.usd - expected).abs() < 1e-12, |
| 3643 | "{model} @ {input_tokens}: {} != {expected}", |
| 3644 | estimate.usd |
| 3645 | ); |
| 3646 | } |
| 3647 | assert!( |
| 3648 | provider_owned_hand_pricing_at(ProviderKind::Openrouter, model, Utc::now()) |
| 3649 | .is_none(), |
| 3650 | "{model}: OpenRouter must not inherit xAI billing" |
| 3651 | ); |
| 3652 | } |
| 3653 | } |
| 3654 | |
| 3655 | #[test] |
| 3656 | fn direct_xai_grok_46_owns_usage_tier_without_leaking_to_other_providers() { |
| 3657 | for (input_tokens, input_rate) in [(199_999, 2.00), (200_000, 4.00)] { |
| 3658 | let usage = Usage { |
| 3659 | input_tokens, |
| 3660 | ..Usage::default() |
| 3661 | }; |
| 3662 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 3663 | ProviderKind::Xai, |
| 3664 | "grok-4.6", |
| 3665 | &usage, |
| 3666 | Utc::now(), |
| 3667 | ) |
| 3668 | .expect("direct xAI route has authoritative tiered pricing"); |
| 3669 | let expected = f64::from(input_tokens) / 1_000_000.0 * input_rate; |
| 3670 | assert!((estimate.usd - expected).abs() < 1e-12); |
| 3671 | } |
| 3672 | |
| 3673 | assert!( |
| 3674 | provider_owned_hand_pricing_at(ProviderKind::Openrouter, "grok-4.6", Utc::now(),) |
| 3675 | .is_none() |
| 3676 | ); |
| 3677 | } |
| 3678 | |
| 3679 | #[test] |
| 3680 | fn provider_scoped_minimax_m3_keeps_usage_tiers_for_both_wire_protocols() { |
| 3681 | for provider in [ProviderKind::Minimax, ProviderKind::MinimaxAnthropic] { |
| 3682 | for (input_tokens, input_rate) in [(512_000, 0.30), (512_001, 0.60)] { |
| 3683 | let usage = Usage { |
| 3684 | input_tokens, |
| 3685 | ..Usage::default() |
| 3686 | }; |
| 3687 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 3688 | provider, |
| 3689 | "MiniMax-M3", |
| 3690 | &usage, |
| 3691 | Utc::now(), |
| 3692 | ) |
| 3693 | .expect("direct MiniMax route has authoritative pricing"); |
| 3694 | let expected = f64::from(input_tokens) / 1_000_000.0 * input_rate; |
| 3695 | assert!((estimate.usd - expected).abs() < 1e-12, "{provider:?}"); |
| 3696 | } |
| 3697 | } |
| 3698 | } |
| 3699 | |
| 3700 | #[test] |
| 3701 | fn direct_openai_long_context_estimates_fail_closed_above_272k() { |
| 3702 | for model in [ |
| 3703 | "gpt-5.5", |
| 3704 | "gpt-5.6", |
| 3705 | "gpt-5.6-sol", |
| 3706 | "gpt-5.6-terra", |
| 3707 | "gpt-5.6-luna", |
| 3708 | ] { |
| 3709 | let at_boundary = Usage { |
| 3710 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 3711 | ..Usage::default() |
| 3712 | }; |
| 3713 | let above_boundary = Usage { |
| 3714 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 3715 | ..Usage::default() |
| 3716 | }; |
| 3717 | |
| 3718 | assert!( |
| 3719 | calculate_turn_cost_estimate_for_provider( |
| 3720 | ProviderKind::Openai, |
| 3721 | model, |
| 3722 | &at_boundary, |
| 3723 | ) |
| 3724 | .is_some(), |
| 3725 | "{model} should retain its standard price at 272K" |
| 3726 | ); |
| 3727 | assert!( |
| 3728 | calculate_turn_cost_estimate_for_provider( |
| 3729 | ProviderKind::Openai, |
| 3730 | model, |
| 3731 | &above_boundary, |
| 3732 | ) |
| 3733 | .is_none(), |
| 3734 | "{model} must not report the lower static price above 272K" |
| 3735 | ); |
| 3736 | } |
| 3737 | } |
| 3738 | |
| 3739 | #[test] |
| 3740 | fn direct_openai_gpt54_family_is_guarded_even_without_a_bundled_catalog_row() { |
| 3741 | for model in ["gpt-5.4", "gpt-5.4-pro"] { |
| 3742 | assert!(!direct_openai_long_context_tier_is_unpriced( |
| 3743 | ProviderKind::Openai, |
| 3744 | model, |
| 3745 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 3746 | )); |
| 3747 | assert!(direct_openai_long_context_tier_is_unpriced( |
| 3748 | ProviderKind::Openai, |
| 3749 | model, |
| 3750 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 3751 | )); |
| 3752 | |
| 3753 | let above_boundary = Usage { |
| 3754 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 3755 | ..Usage::default() |
| 3756 | }; |
| 3757 | assert!( |
| 3758 | calculate_turn_cost_estimate_for_provider( |
| 3759 | ProviderKind::Openai, |
| 3760 | model, |
| 3761 | &above_boundary, |
| 3762 | ) |
| 3763 | .is_none(), |
| 3764 | "{model} must remain unpriced if a live catalog row is available" |
| 3765 | ); |
| 3766 | } |
| 3767 | } |
| 3768 | |
| 3769 | #[test] |
| 3770 | fn openai_long_context_guard_is_exact_and_provider_scoped() { |
| 3771 | let input_tokens = OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1; |
| 3772 | |
| 3773 | for provider in [ |
| 3774 | ProviderKind::Openrouter, |
| 3775 | ProviderKind::OpenaiCodex, |
| 3776 | ProviderKind::Ollama, |
| 3777 | ProviderKind::Custom, |
| 3778 | ] { |
| 3779 | assert!( |
| 3780 | !direct_openai_long_context_tier_is_unpriced(provider, "gpt-5.5", input_tokens,), |
| 3781 | "{provider:?} must not inherit direct OpenAI tier handling" |
| 3782 | ); |
| 3783 | } |
| 3784 | for model in [ |
| 3785 | "gpt-5.4-mini", |
| 3786 | "gpt-5.4-nano", |
| 3787 | "gpt-5.5-pro", |
| 3788 | "gpt-5.5-pro-2026-04-23", |
| 3789 | "gpt-5.5-2026-04-23-extra", |
| 3790 | "openai/gpt-5.5", |
| 3791 | "gpt-5.6-sol-preview", |
| 3792 | ] { |
| 3793 | assert!( |
| 3794 | !direct_openai_long_context_tier_is_unpriced( |
| 3795 | ProviderKind::Openai, |
| 3796 | model, |
| 3797 | input_tokens, |
| 3798 | ), |
| 3799 | "non-documented id {model} must not be treated as an alias" |
| 3800 | ); |
| 3801 | } |
| 3802 | |
| 3803 | let usage = Usage { |
| 3804 | input_tokens, |
| 3805 | output_tokens: 1, |
| 3806 | ..Usage::default() |
| 3807 | }; |
| 3808 | assert!(calculate_turn_cost_estimate_from_usage("gpt-5.5", &usage).is_some()); |
| 3809 | assert!( |
| 3810 | calculate_turn_cost_estimate_for_provider(ProviderKind::OpenaiCodex, "gpt-5.5", &usage,) |
| 3811 | .is_none() |
| 3812 | ); |
| 3813 | } |
| 3814 | |
| 3815 | #[test] |
| 3816 | fn direct_openai_snapshots_use_the_same_strict_272k_boundary() { |
| 3817 | for snapshot in [ |
| 3818 | "gpt-5.4-2026-03-05", |
| 3819 | "gpt-5.4-pro-2026-03-05", |
| 3820 | "gpt-5.5-2026-04-23", |
| 3821 | ] { |
| 3822 | assert!(!direct_openai_long_context_tier_is_unpriced( |
| 3823 | ProviderKind::Openai, |
| 3824 | snapshot, |
| 3825 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 3826 | )); |
| 3827 | assert!(direct_openai_long_context_tier_is_unpriced( |
| 3828 | ProviderKind::Openai, |
| 3829 | snapshot, |
| 3830 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 3831 | )); |
| 3832 | |
| 3833 | let above_boundary = Usage { |
| 3834 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 3835 | ..Usage::default() |
| 3836 | }; |
| 3837 | assert!( |
| 3838 | calculate_turn_cost_estimate_for_provider( |
| 3839 | ProviderKind::Openai, |
| 3840 | snapshot, |
| 3841 | &above_boundary, |
| 3842 | ) |
| 3843 | .is_none(), |
| 3844 | "{snapshot} must not report the lower static price above 272K" |
| 3845 | ); |
| 3846 | } |
| 3847 | } |
| 3848 | |
| 3849 | #[test] |
| 3850 | fn direct_openai_long_context_guard_uses_total_input_with_mixed_cache_classes() { |
| 3851 | let at_boundary = Usage { |
| 3852 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 3853 | output_tokens: 1_000, |
| 3854 | prompt_cache_hit_tokens: Some(100_000), |
| 3855 | prompt_cache_miss_tokens: Some(100_000), |
| 3856 | prompt_cache_write_tokens: Some(72_000), |
| 3857 | ..Usage::default() |
| 3858 | }; |
| 3859 | let above_boundary = Usage { |
| 3860 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 3861 | prompt_cache_write_tokens: Some(72_001), |
| 3862 | ..at_boundary.clone() |
| 3863 | }; |
| 3864 | |
| 3865 | assert!( |
| 3866 | calculate_turn_cost_estimate_for_provider( |
| 3867 | ProviderKind::Openai, |
| 3868 | "gpt-5.6-sol", |
| 3869 | &at_boundary, |
| 3870 | ) |
| 3871 | .is_some() |
| 3872 | ); |
| 3873 | assert!( |
| 3874 | calculate_turn_cost_estimate_for_provider( |
| 3875 | ProviderKind::Openai, |
| 3876 | "gpt-5.6-sol", |
| 3877 | &above_boundary, |
| 3878 | ) |
| 3879 | .is_none() |
| 3880 | ); |
| 3881 | } |
| 3882 | |
| 3883 | #[test] |
| 3884 | fn minimax_m2_7_preserves_cache_read_and_write_rates() { |
| 3885 | let pricing = pricing_for_model_at("MiniMax-M2.7", Utc::now()).expect("M2.7 pricing"); |
| 3886 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.06); |
| 3887 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.30); |
| 3888 | assert_eq!(pricing.usd.output_per_million, 1.20); |
| 3889 | assert_eq!(pricing.usd.cache_write, CacheWritePolicy::Rate(0.375)); |
| 3890 | } |
| 3891 | |
| 3892 | /// The offline seed's price and the reviewed provider-owned table must |
| 3893 | /// agree wherever both price a row: the route audit reads a catalog rate |
| 3894 | /// before the hand table, so a disagreement silently changes the offline |
| 3895 | /// estimate (#6396). A deliberate difference belongs in |
| 3896 | /// `catalog_corrections.json`, which this reads through. |
| 3897 | #[test] |
| 3898 | fn bundled_seed_prices_agree_with_provider_owned_table() { |
| 3899 | let at = Utc.with_ymd_and_hms(2026, 9, 26, 12, 0, 0).unwrap(); |
| 3900 | let close = |a: f64, b: f64| (a - b).abs() < 1e-9; |
| 3901 | let mut checked = 0; |
| 3902 | let mut mismatches = Vec::new(); |
| 3903 | for row in codewhale_config::catalog::bundled_catalog_offerings() { |
| 3904 | let Some(cost) = row.cost.as_ref() else { |
| 3905 | continue; |
| 3906 | }; |
| 3907 | let Some(provider) = ProviderKind::parse(&row.provider) else { |
| 3908 | continue; |
| 3909 | }; |
| 3910 | let Some(hand) = provider_owned_hand_pricing_at(provider, &row.wire_model_id, at) |
| 3911 | else { |
| 3912 | continue; |
| 3913 | }; |
| 3914 | checked += 1; |
| 3915 | let usd = &hand.usd; |
| 3916 | let pairs = [ |
| 3917 | ("input", cost.input, usd.input_cache_miss_per_million), |
| 3918 | ("output", cost.output, usd.output_per_million), |
| 3919 | ( |
| 3920 | "cache_read", |
| 3921 | cost.cache_read, |
| 3922 | usd.input_cache_hit_per_million, |
| 3923 | ), |
| 3924 | ]; |
| 3925 | for (field, seed, table) in pairs { |
| 3926 | if let Some(seed) = seed |
| 3927 | && !close(seed, table) |
| 3928 | { |
| 3929 | mismatches.push(format!( |
| 3930 | "{}/{} {field}: seed {seed} vs table {table}", |
| 3931 | row.provider, row.wire_model_id |
| 3932 | )); |
| 3933 | } |
| 3934 | } |
| 3935 | } |
| 3936 | assert!( |
| 3937 | checked > 0, |
| 3938 | "no bundled row has a provider-owned table price" |
| 3939 | ); |
| 3940 | assert!(mismatches.is_empty(), "{mismatches:#?}"); |
| 3941 | } |
| 3942 | |
| 3943 | #[test] |
| 3944 | fn curated_usd_only_models_have_pricing_and_accrue_cost() { |
| 3945 | let usage = Usage { |
| 3946 | input_tokens: 1_000_000, |
| 3947 | output_tokens: 500_000, |
| 3948 | prompt_cache_hit_tokens: Some(250_000), |
| 3949 | prompt_cache_miss_tokens: Some(750_000), |
| 3950 | ..Default::default() |
| 3951 | }; |
| 3952 | for (model, hit, miss, output) in [ |
| 3953 | ("kimi-k2.6", 0.16, 0.95, 4.00), |
| 3954 | ("kimi-k2.7-code", 0.19, 0.95, 4.00), |
| 3955 | ("moonshotai/kimi-k2.7-code", 0.19, 0.95, 4.00), |
| 3956 | ("kimi-k2.7-code-highspeed", 0.38, 1.90, 8.00), |
| 3957 | ("moonshotai/kimi-k2.7-code-highspeed", 0.38, 1.90, 8.00), |
| 3958 | ("kimi-k3", 0.30, 3.00, 15.00), |
| 3959 | ("moonshotai/kimi-k3", 0.30, 3.00, 15.00), |
| 3960 | ("z-ai/glm-5.1", 0.26, 1.40, 4.40), |
| 3961 | ("glm-5.2", 0.26, 1.40, 4.40), |
| 3962 | ("z-ai/glm-5.2", 0.26, 1.40, 4.40), |
| 3963 | ("glm-5.3-flash", 0.03, 0.15, 0.50), |
| 3964 | ("z-ai/glm-5.3-flash", 0.03, 0.15, 0.50), |
| 3965 | ("glm-5-turbo", 0.24, 1.20, 4.00), |
| 3966 | ("z-ai/glm-5-turbo", 0.24, 1.20, 4.00), |
| 3967 | ("qwen/qwen3.6-plus", 0.325, 0.325, 1.95), |
| 3968 | ("qwen/qwen3.6-35b-a3b", 0.05, 0.14, 1.00), |
| 3969 | ("qwen/qwen3.6-27b", 0.15, 0.285, 2.40), |
| 3970 | // No published cache rate: cache-hit billed at the input rate. |
| 3971 | ("trinity-large-thinking", 0.25, 0.25, 0.80), |
| 3972 | ("nvidia/nemotron-3-ultra-550b-a55b", 0.10, 0.50, 2.20), |
| 3973 | ("claude-opus-4-8", 0.50, 5.00, 25.00), |
| 3974 | ("claude-opus-5", 0.50, 5.00, 25.00), |
| 3975 | ("claude-sonnet-4-6", 0.30, 3.00, 15.00), |
| 3976 | ("claude-haiku-4-5", 0.10, 1.00, 5.00), |
| 3977 | ("claude-fable-5", 1.00, 10.00, 50.00), |
| 3978 | ("gpt-5.5", 0.50, 5.00, 30.00), |
| 3979 | // GPT-5.5 Pro has no cached-input discount: cache-hit == input. |
| 3980 | ("gpt-5.5-pro", 30.00, 30.00, 180.00), |
| 3981 | ("gpt-5.6", 0.40, 4.00, 20.00), |
| 3982 | ("gpt-5.6-sol", 0.40, 4.00, 20.00), |
| 3983 | ("gpt-5.6-terra", 0.20, 2.00, 12.00), |
| 3984 | ("gpt-5.6-luna", 0.02, 0.20, 1.20), |
| 3985 | ("gpt-5-codex", 0.125, 1.25, 10.00), |
| 3986 | ("gpt-5.3-codex", 0.175, 1.75, 14.00), |
| 3987 | ("mistral-medium-latest", 0.15, 1.50, 7.50), |
| 3988 | ("mistral-medium-3-5", 0.15, 1.50, 7.50), |
| 3989 | ("mistral-large-latest", 0.05, 0.50, 1.50), |
| 3990 | ("mistral-large-2512", 0.05, 0.50, 1.50), |
| 3991 | ("mistral-small-latest", 0.015, 0.15, 0.60), |
| 3992 | ("mistral-small-2603", 0.015, 0.15, 0.60), |
| 3993 | ("mistral-code-latest", 0.03, 0.30, 0.90), |
| 3994 | ("codestral-latest", 0.03, 0.30, 0.90), |
| 3995 | ("qwen/qwen3.7-plus", 0.064, 0.32, 1.28), |
| 3996 | ("muse-spark-1.1", 0.15, 1.25, 4.25), |
| 3997 | ("muse-spark-1.2", 0.15, 1.25, 4.25), |
| 3998 | ("muse-spark-1.2-contributor", 0.002, 0.10, 0.20), |
| 3999 | ] { |
| 4000 | let pricing = pricing_for_model_at(model, Utc::now()).expect(model); |
| 4001 | assert_eq!(pricing.usd.input_cache_hit_per_million, hit); |
| 4002 | assert_eq!(pricing.usd.input_cache_miss_per_million, miss); |
| 4003 | assert_eq!(pricing.usd.output_per_million, output); |
| 4004 | assert!(pricing.cny.is_none()); |
| 4005 | assert!(has_pricing_for_model(model)); |
| 4006 | |
| 4007 | let estimate = calculate_turn_cost_estimate_from_usage(model, &usage).expect(model); |
| 4008 | assert!(estimate.usd > 0.0, "expected positive USD for {model}"); |
| 4009 | assert_eq!(estimate.cny, 0.0); |
| 4010 | } |
| 4011 | |
| 4012 | // Anthropic / Qwen rows that publish a cache-write premium, and one row |
| 4013 | // (`gpt-5.5`) that publishes none — which is `Unpublished`, not a |
| 4014 | // licence to bill writes at the input rate (#4318). |
| 4015 | for (model, write) in [ |
| 4016 | ("claude-opus-4-8", CacheWritePolicy::Rate(6.25)), |
| 4017 | ("claude-sonnet-4-6", CacheWritePolicy::Rate(3.75)), |
| 4018 | ("claude-haiku-4-5", CacheWritePolicy::Rate(1.25)), |
| 4019 | ("claude-fable-5", CacheWritePolicy::Rate(12.50)), |
| 4020 | ("qwen/qwen3.7-plus", CacheWritePolicy::Rate(0.40)), |
| 4021 | ("gpt-5.5", CacheWritePolicy::Unpublished), |
| 4022 | ] { |
| 4023 | let pricing = pricing_for_model_at(model, Utc::now()).expect(model); |
| 4024 | assert_eq!( |
| 4025 | pricing.usd.cache_write, write, |
| 4026 | "cache-write policy for {model}" |
| 4027 | ); |
| 4028 | } |
| 4029 | } |
| 4030 | |
| 4031 | #[test] |
| 4032 | fn glm_5_3_has_no_hardcoded_price() { |
| 4033 | // GLM-5.3's catalog metadata is inherited from GLM-5.2, but Z.ai has |
| 4034 | // published no GLM-5.3 rate. Inheriting the 5.2 price would invent one, |
| 4035 | // so every price surface must report *unknown*, never a number and |
| 4036 | // never $0. If Z.ai publishes rates, delete this test and add the real |
| 4037 | // row — do not "fix" it by copying 5.2's. |
| 4038 | for model in ["glm-5.3", "z-ai/glm-5.3"] { |
| 4039 | assert!( |
| 4040 | pricing_for_model_at(model, Utc::now()).is_none(), |
| 4041 | "{model} must have no price row until Z.ai publishes one" |
| 4042 | ); |
| 4043 | assert!(!has_pricing_for_model(model), "{model} must be unpriced"); |
| 4044 | assert!( |
| 4045 | calculate_turn_cost_estimate_from_usage( |
| 4046 | model, |
| 4047 | &Usage { |
| 4048 | input_tokens: 1_000_000, |
| 4049 | output_tokens: 500_000, |
| 4050 | ..Default::default() |
| 4051 | }, |
| 4052 | ) |
| 4053 | .is_none(), |
| 4054 | "{model} must not accrue an invented cost estimate" |
| 4055 | ); |
| 4056 | } |
| 4057 | // The priced sibling it inherits capabilities from is unaffected. |
| 4058 | assert!(has_pricing_for_model("glm-5.2")); |
| 4059 | } |
| 4060 | |
| 4061 | #[test] |
| 4062 | fn cache_write_tokens_increase_anthropic_cost_estimate() { |
| 4063 | let with_write = Usage { |
| 4064 | input_tokens: 12_048, |
| 4065 | output_tokens: 1, |
| 4066 | prompt_cache_hit_tokens: Some(10_000), |
| 4067 | prompt_cache_miss_tokens: Some(3), |
| 4068 | prompt_cache_write_tokens: Some(2_045), |
| 4069 | ..Default::default() |
| 4070 | }; |
| 4071 | let write_as_miss = Usage { |
| 4072 | input_tokens: 12_048, |
| 4073 | output_tokens: 1, |
| 4074 | prompt_cache_hit_tokens: Some(10_000), |
| 4075 | prompt_cache_miss_tokens: Some(2_048), |
| 4076 | prompt_cache_write_tokens: None, |
| 4077 | ..Default::default() |
| 4078 | }; |
| 4079 | |
| 4080 | let priced = |
| 4081 | calculate_turn_cost_estimate_from_usage("claude-fable-5", &with_write).expect("priced"); |
| 4082 | let undercounted = |
| 4083 | calculate_turn_cost_estimate_from_usage("claude-fable-5", &write_as_miss) |
| 4084 | .expect("priced"); |
| 4085 | // 2045 write @ 12.50 vs same tokens @ miss 10.00 → ~0.005 USD premium. |
| 4086 | assert!( |
| 4087 | priced.usd > undercounted.usd, |
| 4088 | "write premium should raise cost: priced={} undercounted={}", |
| 4089 | priced.usd, |
| 4090 | undercounted.usd |
| 4091 | ); |
| 4092 | let expected_premium = (2_045.0 / 1_000_000.0) * (12.50 - 10.00); |
| 4093 | assert!( |
| 4094 | (priced.usd - undercounted.usd - expected_premium).abs() < 1e-9, |
| 4095 | "premium delta mismatch: {}", |
| 4096 | priced.usd - undercounted.usd |
| 4097 | ); |
| 4098 | } |
| 4099 | |
| 4100 | #[test] |
| 4101 | fn catalog_pricing_uses_its_cache_write_rate() { |
| 4102 | let offering = codewhale_config::catalog::CatalogOffering { |
| 4103 | provider: "anthropic".to_string(), |
| 4104 | wire_model_id: "catalog-priced-model".to_string(), |
| 4105 | endpoint_key: "chat".to_string(), |
| 4106 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 4107 | input: Some(10.0), |
| 4108 | output: Some(50.0), |
| 4109 | cache_read: Some(1.0), |
| 4110 | cache_write: Some(12.5), |
| 4111 | }), |
| 4112 | ..Default::default() |
| 4113 | }; |
| 4114 | let usage = Usage { |
| 4115 | input_tokens: 13, |
| 4116 | output_tokens: 5, |
| 4117 | prompt_cache_hit_tokens: Some(2), |
| 4118 | prompt_cache_miss_tokens: Some(3), |
| 4119 | prompt_cache_write_tokens: Some(8), |
| 4120 | ..Default::default() |
| 4121 | }; |
| 4122 | |
| 4123 | let estimate = catalog_cost_estimate_for_route( |
| 4124 | ProviderKind::Anthropic, |
| 4125 | "catalog-priced-model", |
| 4126 | &offering, |
| 4127 | &usage, |
| 4128 | ) |
| 4129 | .expect("catalog cost estimate"); |
| 4130 | assert!((estimate.usd - 0.000_382).abs() < 1e-15); |
| 4131 | assert_eq!(estimate.cny, 0.0); |
| 4132 | } |
| 4133 | |
| 4134 | #[test] |
| 4135 | fn recorded_time_provider_cost_keeps_catalog_cache_write_tier() { |
| 4136 | let usage = Usage { |
| 4137 | input_tokens: 1_000_000, |
| 4138 | output_tokens: 0, |
| 4139 | prompt_cache_hit_tokens: Some(0), |
| 4140 | prompt_cache_miss_tokens: Some(0), |
| 4141 | prompt_cache_write_tokens: Some(1_000_000), |
| 4142 | ..Default::default() |
| 4143 | }; |
| 4144 | |
| 4145 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 4146 | ProviderKind::Openrouter, |
| 4147 | "qwen/qwen3.7-plus", |
| 4148 | &usage, |
| 4149 | Utc::now(), |
| 4150 | ) |
| 4151 | .expect("provider catalog write price"); |
| 4152 | |
| 4153 | assert!((estimate.usd - 0.40).abs() < f64::EPSILON); |
| 4154 | assert_eq!(estimate.cny, 0.0); |
| 4155 | } |
| 4156 | |
| 4157 | #[test] |
| 4158 | fn recorded_time_provider_cost_rejects_foreign_model_ids() { |
| 4159 | let usage = Usage { |
| 4160 | input_tokens: 1_000, |
| 4161 | output_tokens: 100, |
| 4162 | ..Default::default() |
| 4163 | }; |
| 4164 | |
| 4165 | assert!( |
| 4166 | calculate_turn_cost_estimate_for_provider_at( |
| 4167 | ProviderKind::Ollama, |
| 4168 | "gpt-5.5", |
| 4169 | &usage, |
| 4170 | Utc::now(), |
| 4171 | ) |
| 4172 | .is_none() |
| 4173 | ); |
| 4174 | } |
| 4175 | |
| 4176 | #[test] |
| 4177 | fn provider_cost_keeps_owned_hand_price_without_catalog_offering() { |
| 4178 | let usage = Usage { |
| 4179 | input_tokens: 1_000_000, |
| 4180 | output_tokens: 0, |
| 4181 | ..Default::default() |
| 4182 | }; |
| 4183 | assert!( |
| 4184 | crate::provider_lake::catalog_offering_for_model(ProviderKind::Openai, "gpt-5-codex") |
| 4185 | .is_none(), |
| 4186 | "regression fixture must exercise the hand-price fallback" |
| 4187 | ); |
| 4188 | |
| 4189 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 4190 | ProviderKind::Openai, |
| 4191 | "gpt-5-codex", |
| 4192 | &usage, |
| 4193 | Utc::now(), |
| 4194 | ) |
| 4195 | .expect("OpenAI API owns the hand-priced model"); |
| 4196 | |
| 4197 | assert!((estimate.usd - 1.25).abs() < f64::EPSILON); |
| 4198 | assert_eq!(estimate.cny, 0.0); |
| 4199 | assert!(has_pricing_for_provider( |
| 4200 | ProviderKind::Openai, |
| 4201 | "gpt-5-codex" |
| 4202 | )); |
| 4203 | } |
| 4204 | |
| 4205 | #[test] |
| 4206 | fn provider_price_does_not_invent_catalog_missing_cache_write_class() { |
| 4207 | let offering = |
| 4208 | crate::provider_lake::catalog_offering_for_model(ProviderKind::Openai, "gpt-5.5") |
| 4209 | .expect("bundled OpenAI route"); |
| 4210 | let catalog_pricing = |
| 4211 | OfferingPricing::from_catalog_offering(&offering).expect("catalog pricing"); |
| 4212 | assert!(catalog_pricing.cache_write_per_million.is_none()); |
| 4213 | let usage = Usage { |
| 4214 | input_tokens: 250_000, |
| 4215 | output_tokens: 0, |
| 4216 | prompt_cache_miss_tokens: Some(0), |
| 4217 | prompt_cache_write_tokens: Some(250_000), |
| 4218 | ..Default::default() |
| 4219 | }; |
| 4220 | |
| 4221 | let audit = |
| 4222 | audit_turn_cost_for_provider_at(ProviderKind::Openai, "gpt-5.5", &usage, Utc::now()); |
| 4223 | |
| 4224 | assert!(audit.estimate.is_none()); |
| 4225 | assert_eq!( |
| 4226 | audit.unpriced_reason, |
| 4227 | Some(UnpricedReason::MissingClassPrice) |
| 4228 | ); |
| 4229 | assert_eq!(audit.unpriced_classes, vec![TokenClass::CacheWrite]); |
| 4230 | } |
| 4231 | |
| 4232 | #[test] |
| 4233 | fn provider_cost_does_not_fabricate_price_for_costless_catalog_route() { |
| 4234 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 4235 | crate::provider_lake::clear_live_snapshot(); |
| 4236 | let usage = Usage { |
| 4237 | input_tokens: 1_000_000, |
| 4238 | output_tokens: 0, |
| 4239 | ..Default::default() |
| 4240 | }; |
| 4241 | let recorded_at = Utc::now(); |
| 4242 | |
| 4243 | for (provider, model) in [ |
| 4244 | (ProviderKind::Zai, "GLM-5.3"), |
| 4245 | (ProviderKind::XiaomiMimo, "mimo-v2.5-pro"), |
| 4246 | (ProviderKind::ModelstudioTokenPlan, "qwen3.8-max"), |
| 4247 | ] { |
| 4248 | let offering = |
| 4249 | crate::provider_lake::bundled_catalog_offering_for_model(provider, model) |
| 4250 | .unwrap_or_else(|| panic!("missing bundled route: {provider:?}/{model}")); |
| 4251 | assert!( |
| 4252 | OfferingPricing::from_catalog_offering(&offering).is_none(), |
| 4253 | "{provider:?}/{model}" |
| 4254 | ); |
| 4255 | assert!( |
| 4256 | calculate_turn_cost_estimate_for_provider_at(provider, model, &usage, recorded_at,) |
| 4257 | .is_none(), |
| 4258 | "{provider:?}/{model}" |
| 4259 | ); |
| 4260 | assert!( |
| 4261 | calculate_turn_cost_estimate_for_provider(provider, model, &usage).is_none(), |
| 4262 | "{provider:?}/{model}" |
| 4263 | ); |
| 4264 | assert!( |
| 4265 | !has_pricing_for_provider(provider, model), |
| 4266 | "{provider:?}/{model}" |
| 4267 | ); |
| 4268 | } |
| 4269 | |
| 4270 | crate::provider_lake::clear_live_snapshot(); |
| 4271 | } |
| 4272 | |
| 4273 | #[test] |
| 4274 | fn recorded_time_provider_cost_bounds_deepseek_compatibility_aliases() { |
| 4275 | let usage = Usage { |
| 4276 | input_tokens: 1_000, |
| 4277 | output_tokens: 100, |
| 4278 | ..Default::default() |
| 4279 | }; |
| 4280 | let before_retirement: DateTime<Utc> = |
| 4281 | "2026-07-24T15:58:59Z".parse().expect("pre-retirement time"); |
| 4282 | let at_retirement: DateTime<Utc> = DEEPSEEK_ALIAS_RETIREMENT_UTC |
| 4283 | .parse() |
| 4284 | .expect("retirement time"); |
| 4285 | |
| 4286 | assert!( |
| 4287 | calculate_turn_cost_estimate_for_provider_at( |
| 4288 | ProviderKind::Deepseek, |
| 4289 | "deepseek-chat", |
| 4290 | &usage, |
| 4291 | before_retirement, |
| 4292 | ) |
| 4293 | .is_some() |
| 4294 | ); |
| 4295 | assert!( |
| 4296 | calculate_turn_cost_estimate_for_provider_at( |
| 4297 | ProviderKind::Deepseek, |
| 4298 | "deepseek-reasoner", |
| 4299 | &usage, |
| 4300 | at_retirement, |
| 4301 | ) |
| 4302 | .is_none() |
| 4303 | ); |
| 4304 | } |
| 4305 | |
| 4306 | #[test] |
| 4307 | fn deepseek_time_tier_names_the_window_for_tiered_models_only() { |
| 4308 | // Wednesday 2026-09-16: 02:00Z sits inside the 01:00-04:00 peak |
| 4309 | // window, 12:00Z outside every window. |
| 4310 | let peak = Utc.with_ymd_and_hms(2026, 9, 16, 2, 0, 0).unwrap(); |
| 4311 | let off = Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap(); |
| 4312 | // Saturday 02:00 Beijing time (Friday 18:00Z) bills off-peak even |
| 4313 | // inside a weekday peak hour. |
| 4314 | let weekend = Utc.with_ymd_and_hms(2026, 9, 18, 18, 0, 0).unwrap(); |
| 4315 | for model in ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"] { |
| 4316 | assert_eq!(deepseek_time_tier(model, peak), Some(true), "{model}"); |
| 4317 | assert_eq!(deepseek_time_tier(model, off), Some(false), "{model}"); |
| 4318 | assert_eq!(deepseek_time_tier(model, weekend), Some(false), "{model}"); |
| 4319 | } |
| 4320 | assert_eq!(deepseek_time_tier(" DeepSeek-V4-Flash ", peak), Some(true)); |
| 4321 | for flat in [ |
| 4322 | "deepseek-chat", |
| 4323 | "deepseek-reasoner", |
| 4324 | "deepseek-ai/deepseek-v4-flash", |
| 4325 | "", |
| 4326 | ] { |
| 4327 | assert_eq!(deepseek_time_tier(flat, peak), None, "{flat}"); |
| 4328 | } |
| 4329 | } |
| 4330 | |
| 4331 | #[test] |
| 4332 | fn deepseek_pricing_requires_exact_ids_or_explicit_route_aliases() { |
| 4333 | let at = utc_hm(2, 0); |
| 4334 | let usage = Usage { |
| 4335 | input_tokens: 1_000, |
| 4336 | output_tokens: 100, |
| 4337 | ..Default::default() |
| 4338 | }; |
| 4339 | let providers = [ |
| 4340 | ProviderKind::Deepseek, |
| 4341 | ProviderKind::Deepseek, |
| 4342 | ProviderKind::DeepseekAnthropic, |
| 4343 | ]; |
| 4344 | |
| 4345 | for model in [ |
| 4346 | "deepseek-v4.1-flash-expires-on-0910", |
| 4347 | "deepseek-v4.1-flash", |
| 4348 | "deepseek-v4.1-pro", |
| 4349 | "deepseek-v4-flash-vendor-preview", |
| 4350 | "vendor/deepseek-v4-pro-unverified", |
| 4351 | ] { |
| 4352 | assert!(pricing_for_model_at(model, at).is_none(), "{model}"); |
| 4353 | for provider in providers { |
| 4354 | assert!( |
| 4355 | calculate_turn_cost_estimate_for_provider_at(provider, model, &usage, at) |
| 4356 | .is_none(), |
| 4357 | "{provider:?}/{model} must not inherit V4 rates" |
| 4358 | ); |
| 4359 | } |
| 4360 | } |
| 4361 | |
| 4362 | for (canonical, aliases) in [ |
| 4363 | ( |
| 4364 | "deepseek-v4-flash", |
| 4365 | ["flash", "deepseek-v4flash", "deepseek-ai/deepseek-v4flash"], |
| 4366 | ), |
| 4367 | ( |
| 4368 | "deepseek-v4-pro", |
| 4369 | ["pro", "deepseek-v4pro", "deepseek/deepseek-v4-pro"], |
| 4370 | ), |
| 4371 | ] { |
| 4372 | let expected = cost_estimate_with_pricing( |
| 4373 | pricing_for_model_at(canonical, at).expect("canonical V4 pricing"), |
| 4374 | &usage, |
| 4375 | ); |
| 4376 | for provider in providers { |
| 4377 | for model in std::iter::once(canonical).chain(aliases) { |
| 4378 | assert_eq!( |
| 4379 | calculate_turn_cost_estimate_for_provider_at(provider, model, &usage, at), |
| 4380 | Some(expected), |
| 4381 | "{provider:?}/{model} must preserve the canonical rate" |
| 4382 | ); |
| 4383 | } |
| 4384 | } |
| 4385 | } |
| 4386 | } |
| 4387 | |
| 4388 | #[test] |
| 4389 | fn token_usage_for_pricing_maps_cache_classes_without_double_billing_reasoning() { |
| 4390 | let usage = Usage { |
| 4391 | input_tokens: 1_000, |
| 4392 | output_tokens: 100, |
| 4393 | prompt_cache_hit_tokens: Some(250), |
| 4394 | prompt_cache_miss_tokens: Some(700), |
| 4395 | prompt_cache_write_tokens: Some(50), |
| 4396 | // Reasoning is a subset of the 100 reported output tokens, not an |
| 4397 | // extra 50 tokens of billable output. |
| 4398 | reasoning_tokens: Some(50), |
| 4399 | ..Default::default() |
| 4400 | }; |
| 4401 | |
| 4402 | assert_eq!( |
| 4403 | token_usage_for_pricing(&usage), |
| 4404 | TokenUsage { |
| 4405 | input: 700, |
| 4406 | output: 100, |
| 4407 | cache_read: 250, |
| 4408 | cache_write: 50, |
| 4409 | } |
| 4410 | ); |
| 4411 | |
| 4412 | // Informational reasoning telemetry must not move the billed output at |
| 4413 | // all: the same completion count costs the same with or without it. |
| 4414 | let without_reasoning = Usage { |
| 4415 | reasoning_tokens: None, |
| 4416 | ..usage.clone() |
| 4417 | }; |
| 4418 | assert_eq!( |
| 4419 | token_usage_for_pricing(&usage).output, |
| 4420 | token_usage_for_pricing(&without_reasoning).output |
| 4421 | ); |
| 4422 | assert_eq!( |
| 4423 | calculate_turn_cost_estimate_for_provider( |
| 4424 | ProviderKind::Anthropic, |
| 4425 | "claude-haiku-4-5", |
| 4426 | &usage, |
| 4427 | ), |
| 4428 | calculate_turn_cost_estimate_for_provider( |
| 4429 | ProviderKind::Anthropic, |
| 4430 | "claude-haiku-4-5", |
| 4431 | &without_reasoning, |
| 4432 | ) |
| 4433 | ); |
| 4434 | } |
| 4435 | |
| 4436 | #[test] |
| 4437 | fn contradictory_cache_partition_is_bounded_and_fails_closed() { |
| 4438 | let usage = Usage { |
| 4439 | input_tokens: 100, |
| 4440 | output_tokens: 10, |
| 4441 | prompt_cache_hit_tokens: Some(80), |
| 4442 | prompt_cache_miss_tokens: Some(40), |
| 4443 | prompt_cache_write_tokens: Some(30), |
| 4444 | ..Usage::default() |
| 4445 | }; |
| 4446 | |
| 4447 | let classes = token_usage_for_pricing(&usage); |
| 4448 | assert_eq!( |
| 4449 | classes.input + classes.cache_read + classes.cache_write, |
| 4450 | u64::from(usage.input_tokens), |
| 4451 | "token projection may never exceed the provider's input total" |
| 4452 | ); |
| 4453 | let audit = audit_turn_cost_for_provider_on_endpoint_at( |
| 4454 | ProviderKind::Deepseek, |
| 4455 | "deepseek-v4-flash", |
| 4456 | None, |
| 4457 | &usage, |
| 4458 | Utc::now(), |
| 4459 | ); |
| 4460 | assert!(audit.estimate.is_none()); |
| 4461 | assert_eq!( |
| 4462 | audit.unpriced_reason, |
| 4463 | Some(UnpricedReason::InconsistentUsage) |
| 4464 | ); |
| 4465 | |
| 4466 | let overflow_shape = Usage { |
| 4467 | input_tokens: u32::MAX, |
| 4468 | prompt_cache_hit_tokens: Some(u32::MAX), |
| 4469 | prompt_cache_miss_tokens: Some(1), |
| 4470 | ..Usage::default() |
| 4471 | }; |
| 4472 | assert!( |
| 4473 | !usage_cache_partition_is_consistent(&overflow_shape), |
| 4474 | "consistency validation must not hide overflow via saturation" |
| 4475 | ); |
| 4476 | } |
| 4477 | |
| 4478 | #[test] |
| 4479 | fn openai_codex_gpt55_cost_is_unavailable_even_with_usage() { |
| 4480 | let usage = Usage { |
| 4481 | input_tokens: 1_000, |
| 4482 | output_tokens: 100, |
| 4483 | prompt_cache_hit_tokens: Some(250), |
| 4484 | prompt_cache_miss_tokens: Some(750), |
| 4485 | ..Default::default() |
| 4486 | }; |
| 4487 | |
| 4488 | assert!(calculate_turn_cost_estimate_from_usage("gpt-5.5", &usage).is_some()); |
| 4489 | assert!(has_pricing_for_provider(ProviderKind::Openai, "gpt-5.5")); |
| 4490 | assert!(!has_pricing_for_provider( |
| 4491 | ProviderKind::OpenaiCodex, |
| 4492 | "gpt-5.5" |
| 4493 | )); |
| 4494 | assert!( |
| 4495 | calculate_turn_cost_estimate_for_provider(ProviderKind::OpenaiCodex, "gpt-5.5", &usage) |
| 4496 | .is_none() |
| 4497 | ); |
| 4498 | } |
| 4499 | |
| 4500 | #[test] |
| 4501 | fn subscription_route_does_not_inherit_same_models_api_price() { |
| 4502 | let usage = Usage { |
| 4503 | input_tokens: 1_000, |
| 4504 | output_tokens: 100, |
| 4505 | ..Default::default() |
| 4506 | }; |
| 4507 | assert!( |
| 4508 | calculate_turn_cost_estimate_for_billing_surface( |
| 4509 | ProviderKind::Anthropic, |
| 4510 | "claude-sonnet-5", |
| 4511 | Some(FIRST_PARTY_PAYG_BILLING_SURFACE), |
| 4512 | &usage, |
| 4513 | ) |
| 4514 | .is_some() |
| 4515 | ); |
| 4516 | assert!( |
| 4517 | calculate_turn_cost_estimate_for_route( |
| 4518 | ProviderKind::Anthropic, |
| 4519 | "claude-sonnet-5", |
| 4520 | &usage, |
| 4521 | crate::route_billing::BillingPresentation::Subscription("Claude OAuth quota"), |
| 4522 | ) |
| 4523 | .is_none() |
| 4524 | ); |
| 4525 | } |
| 4526 | |
| 4527 | #[test] |
| 4528 | fn token_usage_for_pricing_infers_missing_cache_miss_from_hit_source() { |
| 4529 | let usage = Usage { |
| 4530 | input_tokens: 1_000, |
| 4531 | output_tokens: 100, |
| 4532 | prompt_cache_hit_tokens: Some(250), |
| 4533 | prompt_cache_miss_tokens: None, |
| 4534 | ..Default::default() |
| 4535 | }; |
| 4536 | |
| 4537 | assert_eq!( |
| 4538 | token_usage_for_pricing(&usage), |
| 4539 | TokenUsage { |
| 4540 | input: 750, |
| 4541 | output: 100, |
| 4542 | cache_read: 250, |
| 4543 | cache_write: 0, |
| 4544 | } |
| 4545 | ); |
| 4546 | } |
| 4547 | |
| 4548 | #[test] |
| 4549 | fn catalog_pricing_overrides_known_row_when_present() { |
| 4550 | for model in [ |
| 4551 | "catalog-priced-model", |
| 4552 | "deepseek-v4.1-flash-expires-on-0910", |
| 4553 | ] { |
| 4554 | assert!( |
| 4555 | pricing_for_model_at(model, Utc::now()).is_none(), |
| 4556 | "private unknown rows never gain global prices" |
| 4557 | ); |
| 4558 | } |
| 4559 | // Exact configured prices are tested through provider_lake + endpoint |
| 4560 | // identity above; the primitive helper cannot see those scoped declarations. |
| 4561 | } |
| 4562 | |
| 4563 | /// Published Claude Sonnet 5 rates per 1M tokens (cache-hit, cache-miss, |
| 4564 | /// output, 5m cache-write), verified live on |
| 4565 | /// platform.claude.com/docs/en/about-claude/pricing on 2026-08-17: the |
| 4566 | /// $2/$10 launch rate is now standard and the 2026-09-01 increase to |
| 4567 | /// $3/$15 "will not occur". |
| 4568 | const CLAUDE_SONNET_5_USD: (f64, f64, f64, f64) = (0.20, 2.00, 10.00, 2.50); |
| 4569 | |
| 4570 | fn assert_sonnet_5_standard_rate(at: DateTime<Utc>) { |
| 4571 | let pricing = pricing_for_model_at("claude-sonnet-5", at).unwrap(); |
| 4572 | let (hit, miss, out, write) = CLAUDE_SONNET_5_USD; |
| 4573 | assert_eq!(pricing.usd.input_cache_hit_per_million, hit, "{at} hit"); |
| 4574 | assert_eq!(pricing.usd.input_cache_miss_per_million, miss, "{at} miss"); |
| 4575 | assert_eq!(pricing.usd.output_per_million, out, "{at} output"); |
| 4576 | assert_eq!( |
| 4577 | pricing.usd.cache_write, |
| 4578 | CacheWritePolicy::Rate(write), |
| 4579 | "{at} write" |
| 4580 | ); |
| 4581 | assert!(pricing.cny.is_none()); |
| 4582 | } |
| 4583 | |
| 4584 | #[test] |
| 4585 | fn sonnet_5_keeps_the_2_10_rate_before_the_former_2026_08_31_boundary() { |
| 4586 | assert_sonnet_5_standard_rate( |
| 4587 | Utc.with_ymd_and_hms(2026, 8, 31, 23, 59, 59) |
| 4588 | .single() |
| 4589 | .unwrap(), |
| 4590 | ); |
| 4591 | assert!(has_pricing_for_model("claude-sonnet-5")); |
| 4592 | } |
| 4593 | |
| 4594 | #[test] |
| 4595 | fn sonnet_5_does_not_flip_to_3_15_on_2026_09_01() { |
| 4596 | // Regression for the retired intro window: the scheduled increase was |
| 4597 | // cancelled upstream, so neither boundary minute nor any later time |
| 4598 | // may resurface 0.30 / 3.00 / 15.00 / 3.75. |
| 4599 | for at in [ |
| 4600 | Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).single().unwrap(), |
| 4601 | Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).single().unwrap(), |
| 4602 | ] { |
| 4603 | assert_sonnet_5_standard_rate(at); |
| 4604 | let pricing = pricing_for_model_at("claude-sonnet-5", at).unwrap(); |
| 4605 | assert_ne!(pricing.usd.input_cache_hit_per_million, 0.30); |
| 4606 | assert_ne!(pricing.usd.input_cache_miss_per_million, 3.00); |
| 4607 | assert_ne!(pricing.usd.output_per_million, 15.00); |
| 4608 | assert_ne!(pricing.usd.cache_write, CacheWritePolicy::Rate(3.75)); |
| 4609 | } |
| 4610 | } |
| 4611 | |
| 4612 | #[test] |
| 4613 | fn claude_opus_5_matches_published_first_party_card() { |
| 4614 | // https://platform.claude.com/docs/en/about-claude/pricing (2026-08-17): |
| 4615 | // $5 in / $25 out, cache read 0.50, 5m cache write 6.25. |
| 4616 | let pricing = pricing_for_model_at("claude-opus-5", Utc::now()).expect("Opus 5 pricing"); |
| 4617 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.50); |
| 4618 | assert_eq!(pricing.usd.input_cache_miss_per_million, 5.00); |
| 4619 | assert_eq!(pricing.usd.output_per_million, 25.00); |
| 4620 | assert_eq!(pricing.usd.cache_write, CacheWritePolicy::Rate(6.25)); |
| 4621 | assert!(pricing.cny.is_none()); |
| 4622 | assert!( |
| 4623 | provider_owned_hand_pricing_at(ProviderKind::Anthropic, "claude-opus-5", Utc::now()) |
| 4624 | .is_some(), |
| 4625 | "direct Anthropic owns the Opus 5 row" |
| 4626 | ); |
| 4627 | assert!( |
| 4628 | provider_owned_hand_pricing_at(ProviderKind::Openrouter, "claude-opus-5", Utc::now()) |
| 4629 | .is_none(), |
| 4630 | "an aggregator must not inherit the first-party Opus 5 row" |
| 4631 | ); |
| 4632 | } |
| 4633 | |
| 4634 | #[test] |
| 4635 | fn gpt_5_6_terra_and_luna_use_current_short_context_rates() { |
| 4636 | // https://developers.openai.com/api/docs/models/gpt-5.6-terra and |
| 4637 | // /gpt-5.6-luna (2026-08-17): Terra $2.00 / $0.20 / $12.00, Luna |
| 4638 | // $0.20 / $0.02 / $1.20 per 1M. The retired launch cards must not |
| 4639 | // resurface. |
| 4640 | for (model, hit, miss, out, stale) in [ |
| 4641 | ("gpt-5.6-terra", 0.20, 2.00, 12.00, (0.25, 2.50, 15.00)), |
| 4642 | ("gpt-5.6-luna", 0.02, 0.20, 1.20, (0.10, 1.00, 6.00)), |
| 4643 | ] { |
| 4644 | let pricing = pricing_for_model_at(model, Utc::now()).expect(model); |
| 4645 | assert_eq!(pricing.usd.input_cache_hit_per_million, hit, "{model}"); |
| 4646 | assert_eq!(pricing.usd.input_cache_miss_per_million, miss, "{model}"); |
| 4647 | assert_eq!(pricing.usd.output_per_million, out, "{model}"); |
| 4648 | assert_ne!(pricing.usd.input_cache_hit_per_million, stale.0); |
| 4649 | assert_ne!(pricing.usd.input_cache_miss_per_million, stale.1); |
| 4650 | assert_ne!(pricing.usd.output_per_million, stale.2); |
| 4651 | } |
| 4652 | } |
| 4653 | |
| 4654 | #[test] |
| 4655 | fn moonshot_direct_kimi_k3_is_priced_but_membership_k3_is_not() { |
| 4656 | // https://platform.kimi.ai/docs/pricing/chat-k3 (2026-08-17): |
| 4657 | // cache-hit 0.30 / cache-miss 3.00 / output 15.00 per 1M. |
| 4658 | let now = Utc::now(); |
| 4659 | let pricing = provider_owned_hand_pricing_at(ProviderKind::Moonshot, "kimi-k3", now) |
| 4660 | .expect("direct Moonshot owns the kimi-k3 row"); |
| 4661 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.30); |
| 4662 | assert_eq!(pricing.usd.input_cache_miss_per_million, 3.00); |
| 4663 | assert_eq!(pricing.usd.output_per_million, 15.00); |
| 4664 | assert!( |
| 4665 | provider_owned_hand_pricing_at(ProviderKind::Moonshot, "k3", now).is_none(), |
| 4666 | "Kimi Code membership `k3` is quota billed" |
| 4667 | ); |
| 4668 | assert!(pricing_for_model_at("k3", now).is_none()); |
| 4669 | // Fireworks-hosted K3 keeps its own (still unpublished) rate card. |
| 4670 | assert!( |
| 4671 | provider_owned_hand_pricing_at( |
| 4672 | ProviderKind::Fireworks, |
| 4673 | "accounts/fireworks/models/kimi-k3", |
| 4674 | now |
| 4675 | ) |
| 4676 | .is_none() |
| 4677 | ); |
| 4678 | } |
| 4679 | |
| 4680 | #[test] |
| 4681 | fn kimi_k2_7_code_highspeed_matches_published_rates() { |
| 4682 | // https://platform.kimi.ai/docs/pricing/chat-k27-code (2026-08-17). |
| 4683 | let now = Utc::now(); |
| 4684 | let pricing = |
| 4685 | provider_owned_hand_pricing_at(ProviderKind::Moonshot, "kimi-k2.7-code-highspeed", now) |
| 4686 | .expect("direct Moonshot owns the K2.7 Code high-speed row"); |
| 4687 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.38); |
| 4688 | assert_eq!(pricing.usd.input_cache_miss_per_million, 1.90); |
| 4689 | assert_eq!(pricing.usd.output_per_million, 8.00); |
| 4690 | // Exactly 2x the standard K2.7 Code card. |
| 4691 | let standard = pricing_for_model_at("kimi-k2.7-code", now).unwrap(); |
| 4692 | assert!((standard.usd.input_cache_hit_per_million * 2.0 - 0.38).abs() < 1e-12); |
| 4693 | assert!((standard.usd.input_cache_miss_per_million * 2.0 - 1.90).abs() < 1e-12); |
| 4694 | assert!((standard.usd.output_per_million * 2.0 - 8.00).abs() < 1e-12); |
| 4695 | } |
| 4696 | |
| 4697 | #[test] |
| 4698 | fn minimax_m2_7_highspeed_preserves_cache_read_and_write_rates() { |
| 4699 | // https://platform.minimax.io/docs/guides/pricing-paygo (2026-08-17): |
| 4700 | // $0.6 in / $2.4 out / $0.06 cache read / $0.375 cache write. |
| 4701 | for provider in [ProviderKind::Minimax, ProviderKind::MinimaxAnthropic] { |
| 4702 | let pricing = |
| 4703 | provider_owned_hand_pricing_at(provider, "MiniMax-M2.7-highspeed", Utc::now()) |
| 4704 | .expect("direct MiniMax owns the M2.7 high-speed row"); |
| 4705 | assert_eq!( |
| 4706 | pricing.usd.input_cache_hit_per_million, 0.06, |
| 4707 | "{provider:?}" |
| 4708 | ); |
| 4709 | assert_eq!( |
| 4710 | pricing.usd.input_cache_miss_per_million, 0.60, |
| 4711 | "{provider:?}" |
| 4712 | ); |
| 4713 | assert_eq!(pricing.usd.output_per_million, 2.40, "{provider:?}"); |
| 4714 | assert_eq!( |
| 4715 | pricing.usd.cache_write, |
| 4716 | CacheWritePolicy::Rate(0.375), |
| 4717 | "{provider:?}" |
| 4718 | ); |
| 4719 | } |
| 4720 | } |
| 4721 | |
| 4722 | #[test] |
| 4723 | fn mistral_first_party_rows_match_published_table_and_stay_provider_owned() { |
| 4724 | // https://docs.mistral.ai/inference/pricing (2026-08-17): Medium 3.5 |
| 4725 | // 1.5 / 0.15 / 7.5, Large 3 0.5 / 0.05 / 1.5, Small 4 0.15 / 0.015 / |
| 4726 | // 0.6, Codestral 0.3 / 0.03 / 0.9 (input / cached input / output). |
| 4727 | let now = Utc::now(); |
| 4728 | for (model, hit, miss, out) in [ |
| 4729 | ("mistral-medium-latest", 0.15, 1.50, 7.50), |
| 4730 | ("mistral-large-latest", 0.05, 0.50, 1.50), |
| 4731 | ("mistral-small-latest", 0.015, 0.15, 0.60), |
| 4732 | ("mistral-code-latest", 0.03, 0.30, 0.90), |
| 4733 | ] { |
| 4734 | let pricing = provider_owned_hand_pricing_at(ProviderKind::Mistral, model, now) |
| 4735 | .unwrap_or_else(|| panic!("direct Mistral owns {model}")); |
| 4736 | assert_eq!(pricing.usd.input_cache_hit_per_million, hit, "{model}"); |
| 4737 | assert_eq!(pricing.usd.input_cache_miss_per_million, miss, "{model}"); |
| 4738 | assert_eq!(pricing.usd.output_per_million, out, "{model}"); |
| 4739 | // No published cache-write rate: unpriced, never assumed. |
| 4740 | assert_eq!( |
| 4741 | pricing.usd.cache_write, |
| 4742 | CacheWritePolicy::Unpublished, |
| 4743 | "{model}" |
| 4744 | ); |
| 4745 | assert!( |
| 4746 | provider_owned_hand_pricing_at(ProviderKind::Openrouter, model, now).is_none(), |
| 4747 | "{model}: aggregators must not inherit first-party Mistral rates" |
| 4748 | ); |
| 4749 | } |
| 4750 | } |
| 4751 | |
| 4752 | /// Published DeepSeek V4 rates per 1M tokens (cache-hit, cache-miss, |
| 4753 | /// output), verified live on api-docs.deepseek.com/quick_start/pricing |
| 4754 | /// (and /zh-cn) on 2026-08-17. Off-peak is exactly half of peak. |
| 4755 | const DEEPSEEK_V4_FLASH_USD_OFF_PEAK: (f64, f64, f64) = (0.007, 0.22, 0.66); |
| 4756 | const DEEPSEEK_V4_FLASH_USD_PEAK: (f64, f64, f64) = (0.014, 0.44, 1.32); |
| 4757 | const DEEPSEEK_V4_FLASH_CNY_OFF_PEAK: (f64, f64, f64) = (0.05, 1.5, 4.5); |
| 4758 | const DEEPSEEK_V4_FLASH_CNY_PEAK: (f64, f64, f64) = (0.10, 3.0, 9.0); |
| 4759 | const DEEPSEEK_V4_PRO_USD_OFF_PEAK: (f64, f64, f64) = (0.022, 0.66, 1.98); |
| 4760 | const DEEPSEEK_V4_PRO_USD_PEAK: (f64, f64, f64) = (0.044, 1.32, 3.96); |
| 4761 | const DEEPSEEK_V4_PRO_CNY_OFF_PEAK: (f64, f64, f64) = (0.15, 4.5, 13.5); |
| 4762 | const DEEPSEEK_V4_PRO_CNY_PEAK: (f64, f64, f64) = (0.30, 9.0, 27.0); |
| 4763 | |
| 4764 | fn assert_currency_rates(actual: &CurrencyPricing, expected: (f64, f64, f64), ctx: &str) { |
| 4765 | assert_eq!( |
| 4766 | actual.input_cache_hit_per_million, expected.0, |
| 4767 | "{ctx} cache-hit" |
| 4768 | ); |
| 4769 | assert_eq!( |
| 4770 | actual.input_cache_miss_per_million, expected.1, |
| 4771 | "{ctx} cache-miss" |
| 4772 | ); |
| 4773 | assert_eq!(actual.output_per_million, expected.2, "{ctx} output"); |
| 4774 | assert_eq!( |
| 4775 | actual.cache_write, |
| 4776 | CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 4777 | "{ctx} cache-write" |
| 4778 | ); |
| 4779 | } |
| 4780 | |
| 4781 | fn assert_deepseek_tier(model: &str, at: DateTime<Utc>, peak: bool) { |
| 4782 | let pricing = pricing_for_model_at(model, at).expect("DeepSeek V4 pricing"); |
| 4783 | let (usd, cny) = match (model.contains("pro"), peak) { |
| 4784 | (true, true) => (DEEPSEEK_V4_PRO_USD_PEAK, DEEPSEEK_V4_PRO_CNY_PEAK), |
| 4785 | (true, false) => (DEEPSEEK_V4_PRO_USD_OFF_PEAK, DEEPSEEK_V4_PRO_CNY_OFF_PEAK), |
| 4786 | (false, true) => (DEEPSEEK_V4_FLASH_USD_PEAK, DEEPSEEK_V4_FLASH_CNY_PEAK), |
| 4787 | (false, false) => ( |
| 4788 | DEEPSEEK_V4_FLASH_USD_OFF_PEAK, |
| 4789 | DEEPSEEK_V4_FLASH_CNY_OFF_PEAK, |
| 4790 | ), |
| 4791 | }; |
| 4792 | let tier = if peak { "peak" } else { "off-peak" }; |
| 4793 | assert_currency_rates(&pricing.usd, usd, &format!("{model} @ {at} USD {tier}")); |
| 4794 | let cny_pricing = pricing.cny.expect("DeepSeek pricing has CNY"); |
| 4795 | assert_currency_rates(&cny_pricing, cny, &format!("{model} @ {at} CNY {tier}")); |
| 4796 | } |
| 4797 | |
| 4798 | fn utc_hm(hour: u32, minute: u32) -> DateTime<Utc> { |
| 4799 | Utc.with_ymd_and_hms(2026, 8, 17, hour, minute, 59) |
| 4800 | .single() |
| 4801 | .unwrap() |
| 4802 | } |
| 4803 | |
| 4804 | #[test] |
| 4805 | fn deepseek_peak_window_is_half_open_on_utc_hours() { |
| 4806 | for hour in 0..24 { |
| 4807 | let expected = matches!(hour, 1..=3 | 6..=9); |
| 4808 | assert_eq!(deepseek_peak_hour(hour), expected, "hour {hour}"); |
| 4809 | } |
| 4810 | } |
| 4811 | |
| 4812 | #[test] |
| 4813 | fn deepseek_v4_tiers_flip_at_each_published_utc_boundary() { |
| 4814 | // Peak windows are 01:00-04:00 and 06:00-10:00 UTC, half-open: the |
| 4815 | // start minute is peak, the end minute is off-peak. |
| 4816 | let boundaries = [ |
| 4817 | (utc_hm(0, 59), false), |
| 4818 | (utc_hm(1, 0), true), |
| 4819 | (utc_hm(3, 59), true), |
| 4820 | (utc_hm(4, 0), false), |
| 4821 | (utc_hm(5, 59), false), |
| 4822 | (utc_hm(6, 0), true), |
| 4823 | (utc_hm(9, 59), true), |
| 4824 | (utc_hm(10, 0), false), |
| 4825 | ]; |
| 4826 | for model in ["deepseek-v4-pro", "deepseek-v4-flash"] { |
| 4827 | for (at, peak) in boundaries { |
| 4828 | assert_deepseek_tier(model, at, peak); |
| 4829 | } |
| 4830 | } |
| 4831 | } |
| 4832 | |
| 4833 | fn utc_ymd_h(year: i32, month: u32, day: u32, hour: u32) -> DateTime<Utc> { |
| 4834 | Utc.with_ymd_and_hms(year, month, day, hour, 0, 0) |
| 4835 | .single() |
| 4836 | .unwrap() |
| 4837 | } |
| 4838 | |
| 4839 | #[test] |
| 4840 | fn deepseek_v4_bills_beijing_weekends_off_peak_from_the_published_date() { |
| 4841 | // 2026-08-22T16:00Z is 00:00 Beijing on Sunday 2026-08-23, when the rule |
| 4842 | // starts. Times are UTC; the Beijing day is in the comment. |
| 4843 | let cases = [ |
| 4844 | (utc_ymd_h(2026, 8, 22, 6), true), // Sat 14:00 Beijing, rule not yet live |
| 4845 | (utc_ymd_h(2026, 8, 23, 1), false), // Sun 09:00 Beijing, first window it changes |
| 4846 | (utc_ymd_h(2026, 8, 23, 9), false), // Sun 17:00 Beijing |
| 4847 | (utc_ymd_h(2026, 8, 24, 1), true), // Mon 09:00 Beijing |
| 4848 | (utc_ymd_h(2026, 8, 28, 6), true), // Fri 14:00 Beijing |
| 4849 | (utc_ymd_h(2026, 8, 29, 6), false), // Sat 14:00 Beijing |
| 4850 | ]; |
| 4851 | for (at, peak) in cases { |
| 4852 | assert_eq!(deepseek_is_peak(at), peak, "peak tier at {at}"); |
| 4853 | for model in ["deepseek-v4-pro", "deepseek-v4-flash"] { |
| 4854 | assert_deepseek_tier(model, at, peak); |
| 4855 | } |
| 4856 | } |
| 4857 | } |
| 4858 | |
| 4859 | #[test] |
| 4860 | fn deepseek_weekend_edges_are_bounded_in_beijing_time_not_utc() { |
| 4861 | // All four instants are off-peak by the hour, so `deepseek_is_peak` |
| 4862 | // cannot tell them apart today. Pinning the predicate keeps the 16:00Z |
| 4863 | // edges right if the peak windows ever move. |
| 4864 | let cases = [ |
| 4865 | (utc_ymd_h(2026, 8, 28, 15), false), // Fri 23:00 Beijing |
| 4866 | (utc_ymd_h(2026, 8, 28, 16), true), // Sat 00:00 Beijing |
| 4867 | (utc_ymd_h(2026, 8, 30, 15), true), // Sun 23:00 Beijing |
| 4868 | (utc_ymd_h(2026, 8, 30, 16), false), // Mon 00:00 Beijing |
| 4869 | ]; |
| 4870 | for (at, weekend) in cases { |
| 4871 | assert_eq!(deepseek_weekend_off_peak(at), weekend, "weekend at {at}"); |
| 4872 | } |
| 4873 | } |
| 4874 | |
| 4875 | #[test] |
| 4876 | fn deepseek_v4_pro_keeps_pro_rates_after_cancelled_retirement() { |
| 4877 | for day in [13, 14, 15, 21] { |
| 4878 | let at = utc_ymd_h(2026, 9, day, 12); |
| 4879 | let pro = pricing_for_model_at("deepseek-v4-pro", at).unwrap(); |
| 4880 | let flash = pricing_for_model_at("deepseek-flash", at).unwrap(); |
| 4881 | assert_eq!(pro.usd.output_per_million, 1.98); |
| 4882 | assert_ne!(pro.usd.output_per_million, flash.usd.output_per_million); |
| 4883 | } |
| 4884 | } |
| 4885 | |
| 4886 | #[test] |
| 4887 | fn deepseek_v4_pro_off_peak_and_peak_rates_match_published_table() { |
| 4888 | assert_deepseek_tier("deepseek-v4-pro", utc_hm(12, 0), false); |
| 4889 | assert_deepseek_tier("deepseek-v4-pro", utc_hm(2, 0), true); |
| 4890 | // Regression for #267 / #2489: the retired flat promo rates must not |
| 4891 | // resurface in either tier. |
| 4892 | for at in [utc_hm(12, 0), utc_hm(2, 0)] { |
| 4893 | let pricing = pricing_for_model_at("deepseek-v4-pro", at).unwrap(); |
| 4894 | assert_ne!(pricing.usd.input_cache_hit_per_million, 0.003625); |
| 4895 | assert_ne!(pricing.usd.input_cache_miss_per_million, 0.435); |
| 4896 | assert_ne!(pricing.usd.output_per_million, 0.87); |
| 4897 | } |
| 4898 | } |
| 4899 | |
| 4900 | #[test] |
| 4901 | fn deepseek_v4_flash_off_peak_and_peak_rates_match_published_table() { |
| 4902 | assert_deepseek_tier("deepseek-v4-flash", utc_hm(12, 0), false); |
| 4903 | assert_deepseek_tier("deepseek-v4-flash", utc_hm(7, 0), true); |
| 4904 | for at in [utc_hm(12, 0), utc_hm(7, 0)] { |
| 4905 | let pricing = pricing_for_model_at("deepseek-v4-flash", at).unwrap(); |
| 4906 | assert_ne!(pricing.usd.input_cache_hit_per_million, 0.0028); |
| 4907 | assert_ne!(pricing.usd.input_cache_miss_per_million, 0.14); |
| 4908 | assert_ne!(pricing.usd.output_per_million, 0.28); |
| 4909 | } |
| 4910 | } |
| 4911 | |
| 4912 | #[test] |
| 4913 | fn deepseek_v4_off_peak_is_exactly_half_of_peak() { |
| 4914 | for model in ["deepseek-v4-pro", "deepseek-v4-flash"] { |
| 4915 | let off = pricing_for_model_at(model, utc_hm(12, 0)).unwrap(); |
| 4916 | let peak = pricing_for_model_at(model, utc_hm(2, 0)).unwrap(); |
| 4917 | for (o, p) in [ |
| 4918 | ( |
| 4919 | off.usd.input_cache_hit_per_million, |
| 4920 | peak.usd.input_cache_hit_per_million, |
| 4921 | ), |
| 4922 | ( |
| 4923 | off.usd.input_cache_miss_per_million, |
| 4924 | peak.usd.input_cache_miss_per_million, |
| 4925 | ), |
| 4926 | (off.usd.output_per_million, peak.usd.output_per_million), |
| 4927 | ] { |
| 4928 | assert!((o * 2.0 - p).abs() < 1e-12, "{model}: {o} * 2 != {p}"); |
| 4929 | } |
| 4930 | let (off_cny, peak_cny) = (off.cny.unwrap(), peak.cny.unwrap()); |
| 4931 | for (o, p) in [ |
| 4932 | ( |
| 4933 | off_cny.input_cache_hit_per_million, |
| 4934 | peak_cny.input_cache_hit_per_million, |
| 4935 | ), |
| 4936 | ( |
| 4937 | off_cny.input_cache_miss_per_million, |
| 4938 | peak_cny.input_cache_miss_per_million, |
| 4939 | ), |
| 4940 | (off_cny.output_per_million, peak_cny.output_per_million), |
| 4941 | ] { |
| 4942 | assert!((o * 2.0 - p).abs() < 1e-12, "{model}: CNY {o} * 2 != {p}"); |
| 4943 | } |
| 4944 | } |
| 4945 | } |
| 4946 | |
| 4947 | /// The route audit prices a DeepSeek turn at the tier of its RECORDED |
| 4948 | /// time, not the wall clock at audit time (same contract as Sonnet 5's |
| 4949 | /// recorded-time introductory window). |
| 4950 | #[test] |
| 4951 | fn deepseek_audit_uses_recorded_time_tier_not_now() { |
| 4952 | let usage = million_input_usage(); |
| 4953 | for (provider, model) in [ |
| 4954 | (ProviderKind::Deepseek, "deepseek-v4-flash"), |
| 4955 | (ProviderKind::Deepseek, "deepseek-v4-pro"), |
| 4956 | (ProviderKind::Deepseek, "deepseek-v4-flash"), |
| 4957 | (ProviderKind::DeepseekAnthropic, "deepseek-v4-pro"), |
| 4958 | ] { |
| 4959 | let off_peak_usd = if model.contains("pro") { 0.66 } else { 0.22 }; |
| 4960 | let off_peak_cny = if model.contains("pro") { 4.5 } else { 1.5 }; |
| 4961 | let off = audit_turn_cost_for_provider_at(provider, model, &usage, utc_hm(12, 0)); |
| 4962 | assert!(off.is_priced(), "{provider:?}/{model}: {off:?}"); |
| 4963 | let off_estimate = off.estimate.expect("priced"); |
| 4964 | assert!( |
| 4965 | (off_estimate.usd - off_peak_usd).abs() < 1e-12, |
| 4966 | "{provider:?}/{model} off-peak: {}", |
| 4967 | off_estimate.usd |
| 4968 | ); |
| 4969 | assert!( |
| 4970 | (off_estimate.cny - off_peak_cny).abs() < 1e-12, |
| 4971 | "{provider:?}/{model} off-peak CNY: {}", |
| 4972 | off_estimate.cny |
| 4973 | ); |
| 4974 | |
| 4975 | let peak = audit_turn_cost_for_provider_at(provider, model, &usage, utc_hm(2, 0)); |
| 4976 | assert!(peak.is_priced(), "{provider:?}/{model}: {peak:?}"); |
| 4977 | let peak_estimate = peak.estimate.expect("priced"); |
| 4978 | assert!( |
| 4979 | (peak_estimate.usd - 2.0 * off_peak_usd).abs() < 1e-12, |
| 4980 | "{provider:?}/{model} peak: {}", |
| 4981 | peak_estimate.usd |
| 4982 | ); |
| 4983 | assert!( |
| 4984 | (peak_estimate.cny - 2.0 * off_peak_cny).abs() < 1e-12, |
| 4985 | "{provider:?}/{model} peak CNY: {}", |
| 4986 | peak_estimate.cny |
| 4987 | ); |
| 4988 | } |
| 4989 | } |
| 4990 | |
| 4991 | #[test] |
| 4992 | fn fireworks_and_zen_flash_use_bundled_family_rates() { |
| 4993 | let now = Utc.with_ymd_and_hms(2026, 8, 14, 0, 0, 0).single().unwrap(); |
| 4994 | let fireworks = provider_owned_hand_pricing_at( |
| 4995 | ProviderKind::Fireworks, |
| 4996 | "accounts/fireworks/models/deepseek-v4-flash", |
| 4997 | now, |
| 4998 | ) |
| 4999 | .expect("Fireworks Flash should inherit the bundled DeepSeek family row"); |
| 5000 | let zen = |
| 5001 | provider_owned_hand_pricing_at(ProviderKind::OpencodeZen, "deepseek-v4-flash", now) |
| 5002 | .expect("OpenCode Zen Flash should inherit the bundled DeepSeek family row"); |
| 5003 | assert_eq!(fireworks.usd.output_per_million, zen.usd.output_per_million); |
| 5004 | assert!( |
| 5005 | provider_owned_hand_pricing_at( |
| 5006 | ProviderKind::Fireworks, |
| 5007 | "accounts/fireworks/models/kimi-k3", |
| 5008 | now, |
| 5009 | ) |
| 5010 | .is_none(), |
| 5011 | "kimi-k3 has no published bundled rate; do not invent one" |
| 5012 | ); |
| 5013 | } |
| 5014 | |
| 5015 | #[test] |
| 5016 | fn xiaomi_mimo_token_plan_models_leave_cost_unknown() { |
| 5017 | let now = Utc.with_ymd_and_hms(2026, 6, 4, 0, 0, 0).single().unwrap(); |
| 5018 | |
| 5019 | for model in [ |
| 5020 | "mimo-v2.5-pro", |
| 5021 | "mimo-v2.5-pro-ultraspeed", |
| 5022 | "mimo-v2.5", |
| 5023 | "xiaomi/mimo-v2.5", |
| 5024 | ] { |
| 5025 | assert!(pricing_for_model_at(model, now).is_none()); |
| 5026 | assert!(!has_pricing_for_model(model)); |
| 5027 | } |
| 5028 | } |
| 5029 | |
| 5030 | #[test] |
| 5031 | fn cost_estimate_calculates_usd_and_cny() { |
| 5032 | let usage = Usage { |
| 5033 | input_tokens: 1_000_000, |
| 5034 | output_tokens: 500_000, |
| 5035 | ..Default::default() |
| 5036 | }; |
| 5037 | // Off-peak (12:00 UTC): 1M input at 0.22 + 0.5M output at 0.66 USD; |
| 5038 | // 1.5 + 0.5 * 4.5 CNY. |
| 5039 | let off_peak = Utc |
| 5040 | .with_ymd_and_hms(2026, 8, 17, 12, 0, 0) |
| 5041 | .single() |
| 5042 | .unwrap(); |
| 5043 | let pricing = pricing_for_model_at("deepseek-v4-flash", off_peak).expect("pricing"); |
| 5044 | let estimate = cost_estimate_with_pricing(pricing, &usage); |
| 5045 | assert!((estimate.usd - 0.55).abs() < 1e-12, "{}", estimate.usd); |
| 5046 | assert!((estimate.cny - 3.75).abs() < 1e-12, "{}", estimate.cny); |
| 5047 | |
| 5048 | // Peak (02:00 UTC) doubles both currencies. |
| 5049 | let peak = Utc.with_ymd_and_hms(2026, 8, 17, 2, 0, 0).single().unwrap(); |
| 5050 | let pricing = pricing_for_model_at("deepseek-v4-flash", peak).expect("pricing"); |
| 5051 | let estimate = cost_estimate_with_pricing(pricing, &usage); |
| 5052 | assert!((estimate.usd - 1.10).abs() < 1e-12, "{}", estimate.usd); |
| 5053 | assert!((estimate.cny - 7.5).abs() < 1e-12, "{}", estimate.cny); |
| 5054 | } |
| 5055 | |
| 5056 | #[test] |
| 5057 | fn cost_currency_accepts_yuan_aliases() { |
| 5058 | assert_eq!(CostCurrency::from_setting("usd"), Some(CostCurrency::Usd)); |
| 5059 | assert_eq!(CostCurrency::from_setting("yuan"), Some(CostCurrency::Cny)); |
| 5060 | assert_eq!(CostCurrency::from_setting("rmb"), Some(CostCurrency::Cny)); |
| 5061 | assert_eq!(CostCurrency::from_setting("cny"), Some(CostCurrency::Cny)); |
| 5062 | assert_eq!(CostCurrency::from_setting("eur"), None); |
| 5063 | } |
| 5064 | |
| 5065 | #[test] |
| 5066 | fn format_cost_amount_uses_selected_symbol() { |
| 5067 | assert_eq!(format_cost_amount(0.42, CostCurrency::Usd), "$0.42"); |
| 5068 | assert_eq!(format_cost_amount(2.0, CostCurrency::Cny), "¥2.00"); |
| 5069 | assert_eq!(format_cost_amount(0.0, CostCurrency::Usd), "$0.00"); |
| 5070 | assert_eq!(format_cost_amount(0.00001, CostCurrency::Usd), "<$0.0001"); |
| 5071 | } |
| 5072 | |
| 5073 | #[test] |
| 5074 | fn format_cost_amount_precise_keeps_report_precision() { |
| 5075 | assert_eq!( |
| 5076 | format_cost_amount_precise(0.1234, CostCurrency::Usd), |
| 5077 | "$0.1234" |
| 5078 | ); |
| 5079 | assert_eq!( |
| 5080 | format_cost_amount_precise(0.1234, CostCurrency::Cny), |
| 5081 | "¥0.1234" |
| 5082 | ); |
| 5083 | assert_eq!( |
| 5084 | format_cost_amount_precise(0.0, CostCurrency::Usd), |
| 5085 | "$0.0000" |
| 5086 | ); |
| 5087 | assert_eq!( |
| 5088 | format_cost_amount_precise(0.00001, CostCurrency::Usd), |
| 5089 | "<$0.0001" |
| 5090 | ); |
| 5091 | } |
| 5092 | |
| 5093 | #[test] |
| 5094 | fn accumulated_cost_stays_finite_and_nonnegative() { |
| 5095 | let saturated = CostEstimate { |
| 5096 | usd: f64::MAX, |
| 5097 | cny: 1.0, |
| 5098 | } |
| 5099 | .saturating_add(CostEstimate { |
| 5100 | usd: f64::MAX, |
| 5101 | cny: -1.0, |
| 5102 | }); |
| 5103 | assert_eq!(saturated.usd, f64::MAX); |
| 5104 | assert_eq!(saturated.cny, 1.0); |
| 5105 | assert!(saturated.is_finite_nonnegative()); |
| 5106 | |
| 5107 | assert_eq!( |
| 5108 | CostEstimate { |
| 5109 | usd: f64::NAN, |
| 5110 | cny: f64::INFINITY, |
| 5111 | } |
| 5112 | .sanitized(), |
| 5113 | CostEstimate::default() |
| 5114 | ); |
| 5115 | } |
| 5116 | |
| 5117 | fn official_route_audit(provider: ProviderKind, model: &str, usage: &Usage) -> TurnCostAudit { |
| 5118 | audit_turn_cost_for_route_at( |
| 5119 | provider, |
| 5120 | model, |
| 5121 | billing_surface_for_route(provider, Some(provider.provider().default_base_url())), |
| 5122 | usage, |
| 5123 | Utc::now(), |
| 5124 | ) |
| 5125 | } |
| 5126 | |
| 5127 | fn million_input_usage() -> Usage { |
| 5128 | Usage { |
| 5129 | input_tokens: 1_000_000, |
| 5130 | output_tokens: 0, |
| 5131 | ..Usage::default() |
| 5132 | } |
| 5133 | } |
| 5134 | |
| 5135 | /// #5241: Fireworks flash / pro and OpenCode Zen flash must leave |
| 5136 | /// `unverified_live_pricing` via provider-docs bundled rates when live |
| 5137 | /// control-plane / Models.dev pricing is not a usable rate source. |
| 5138 | #[test] |
| 5139 | fn hosted_flash_and_pro_routes_price_from_bundled_docs_rates() { |
| 5140 | let usage = million_input_usage(); |
| 5141 | let now = Utc::now(); |
| 5142 | let cases = [ |
| 5143 | ( |
| 5144 | ProviderKind::Fireworks, |
| 5145 | "accounts/fireworks/models/deepseek-v4-flash-0731", |
| 5146 | 0.14, |
| 5147 | ), |
| 5148 | ( |
| 5149 | ProviderKind::Fireworks, |
| 5150 | "accounts/fireworks/models/deepseek-v4-flash", |
| 5151 | 0.14, |
| 5152 | ), |
| 5153 | (ProviderKind::Fireworks, "deepseek-v4-flash", 0.14), |
| 5154 | ( |
| 5155 | ProviderKind::Fireworks, |
| 5156 | "accounts/fireworks/models/deepseek-v4-pro", |
| 5157 | 1.74, |
| 5158 | ), |
| 5159 | (ProviderKind::OpencodeZen, "deepseek-v4-flash", 0.14), |
| 5160 | ]; |
| 5161 | for (provider, model, expected_usd) in cases { |
| 5162 | let audit = official_route_audit(provider, model, &usage); |
| 5163 | assert!( |
| 5164 | audit.is_priced(), |
| 5165 | "{provider:?}/{model} must price on its official endpoint: {audit:?}" |
| 5166 | ); |
| 5167 | assert_ne!( |
| 5168 | audit.unpriced_reason, |
| 5169 | Some(UnpricedReason::UnverifiedLivePricing), |
| 5170 | "{provider:?}/{model}" |
| 5171 | ); |
| 5172 | assert_eq!( |
| 5173 | audit.provenance, |
| 5174 | Some(PricingProvenance::ProviderDocs), |
| 5175 | "{provider:?}/{model}" |
| 5176 | ); |
| 5177 | let estimate = audit.estimate.expect("priced"); |
| 5178 | assert!( |
| 5179 | (estimate.usd - expected_usd).abs() < 1e-12, |
| 5180 | "{provider:?}/{model}: {} != {expected_usd}", |
| 5181 | estimate.usd |
| 5182 | ); |
| 5183 | assert_eq!(estimate.cny, 0.0, "{provider:?}/{model}"); |
| 5184 | |
| 5185 | let hand = |
| 5186 | provider_owned_hand_pricing_at(provider, model, now).expect("bundled fallback row"); |
| 5187 | if model.contains("flash") { |
| 5188 | assert_eq!(hand.usd.input_cache_hit_per_million, 0.028); |
| 5189 | for first_party in [0.007, 0.014] { |
| 5190 | assert_ne!( |
| 5191 | hand.usd.input_cache_hit_per_million, first_party, |
| 5192 | "must not inherit first-party DeepSeek cache-hit" |
| 5193 | ); |
| 5194 | } |
| 5195 | } |
| 5196 | } |
| 5197 | |
| 5198 | let off_peak = Utc |
| 5199 | .with_ymd_and_hms(2026, 8, 17, 12, 0, 0) |
| 5200 | .single() |
| 5201 | .unwrap(); |
| 5202 | let peak = Utc.with_ymd_and_hms(2026, 8, 17, 2, 0, 0).single().unwrap(); |
| 5203 | assert_eq!( |
| 5204 | deepseek_v4_flash_pricing(off_peak) |
| 5205 | .usd |
| 5206 | .input_cache_hit_per_million, |
| 5207 | 0.007 |
| 5208 | ); |
| 5209 | assert_eq!( |
| 5210 | deepseek_v4_flash_pricing(peak) |
| 5211 | .usd |
| 5212 | .input_cache_hit_per_million, |
| 5213 | 0.014 |
| 5214 | ); |
| 5215 | } |
| 5216 | |
| 5217 | #[test] |
| 5218 | fn models_dev_live_cost_is_capabilities_only_and_falls_back_to_bundled_rates() { |
| 5219 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5220 | crate::provider_lake::clear_live_snapshot(); |
| 5221 | let now = Utc::now(); |
| 5222 | let fetched_at = u64::try_from(now.timestamp()).expect("timestamp"); |
| 5223 | crate::provider_lake::set_live_snapshot( |
| 5224 | codewhale_config::catalog::CatalogSnapshot { |
| 5225 | offerings: vec![codewhale_config::catalog::CatalogOffering { |
| 5226 | provider: "fireworks".to_string(), |
| 5227 | wire_model_id: "accounts/fireworks/models/deepseek-v4-flash-0731".to_string(), |
| 5228 | endpoint_key: "chat".to_string(), |
| 5229 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 5230 | input: Some(99.0), |
| 5231 | output: Some(199.0), |
| 5232 | cache_read: Some(9.0), |
| 5233 | cache_write: None, |
| 5234 | }), |
| 5235 | source: codewhale_config::catalog::CatalogSource::Live { |
| 5236 | base_url_fingerprint: "models-dev-capabilities".to_string(), |
| 5237 | fetched_at, |
| 5238 | }, |
| 5239 | ..Default::default() |
| 5240 | }], |
| 5241 | }, |
| 5242 | crate::provider_lake::LiveSource::ModelsDev, |
| 5243 | ); |
| 5244 | |
| 5245 | let usage = million_input_usage(); |
| 5246 | let audit = official_route_audit( |
| 5247 | ProviderKind::Fireworks, |
| 5248 | "accounts/fireworks/models/deepseek-v4-flash-0731", |
| 5249 | &usage, |
| 5250 | ); |
| 5251 | crate::provider_lake::clear_live_snapshot(); |
| 5252 | |
| 5253 | assert!(audit.is_priced(), "{audit:?}"); |
| 5254 | assert_ne!( |
| 5255 | audit.unpriced_reason, |
| 5256 | Some(UnpricedReason::UnverifiedLivePricing) |
| 5257 | ); |
| 5258 | assert_eq!(audit.provenance, Some(PricingProvenance::ProviderDocs)); |
| 5259 | assert_eq!(audit.live_pricing_defect, None); |
| 5260 | let estimate = audit.estimate.expect("priced"); |
| 5261 | assert!( |
| 5262 | (estimate.usd - 0.14).abs() < 1e-12, |
| 5263 | "models.dev leftover cost must not be billed: {}", |
| 5264 | estimate.usd |
| 5265 | ); |
| 5266 | } |
| 5267 | |
| 5268 | #[test] |
| 5269 | fn unverifiable_provider_live_rates_degrade_to_bundled_docs_with_defect() { |
| 5270 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5271 | crate::provider_lake::clear_live_snapshot(); |
| 5272 | let now = Utc::now(); |
| 5273 | let fetched_at = u64::try_from(now.timestamp()).expect("timestamp"); |
| 5274 | crate::provider_lake::set_live_snapshot( |
| 5275 | codewhale_config::catalog::CatalogSnapshot { |
| 5276 | offerings: vec![codewhale_config::catalog::CatalogOffering { |
| 5277 | provider: "opencode-zen".to_string(), |
| 5278 | wire_model_id: "deepseek-v4-flash".to_string(), |
| 5279 | endpoint_key: "chat".to_string(), |
| 5280 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 5281 | input: Some(99.0), |
| 5282 | output: Some(199.0), |
| 5283 | cache_read: Some(9.0), |
| 5284 | cache_write: None, |
| 5285 | }), |
| 5286 | source: codewhale_config::catalog::CatalogSource::Live { |
| 5287 | base_url_fingerprint: "other-endpoint".to_string(), |
| 5288 | fetched_at, |
| 5289 | }, |
| 5290 | ..Default::default() |
| 5291 | }], |
| 5292 | }, |
| 5293 | crate::provider_lake::LiveSource::PerProvider, |
| 5294 | ); |
| 5295 | |
| 5296 | let usage = million_input_usage(); |
| 5297 | let audit = official_route_audit(ProviderKind::OpencodeZen, "deepseek-v4-flash", &usage); |
| 5298 | crate::provider_lake::clear_live_snapshot(); |
| 5299 | |
| 5300 | assert!(audit.is_priced(), "{audit:?}"); |
| 5301 | assert_eq!(audit.provenance, Some(PricingProvenance::ProviderDocs)); |
| 5302 | assert!( |
| 5303 | audit.live_pricing_defect.is_some(), |
| 5304 | "unverified provider-live must receipt a defect: {audit:?}" |
| 5305 | ); |
| 5306 | let estimate = audit.estimate.expect("priced"); |
| 5307 | assert!((estimate.usd - 0.14).abs() < 1e-12, "{}", estimate.usd); |
| 5308 | } |
| 5309 | |
| 5310 | #[test] |
| 5311 | fn verified_provider_live_rates_win_over_bundled_docs() { |
| 5312 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5313 | crate::provider_lake::clear_live_snapshot(); |
| 5314 | let now = Utc::now(); |
| 5315 | let fetched_at = u64::try_from(now.timestamp()).expect("timestamp"); |
| 5316 | let fingerprint = codewhale_config::catalog::base_url_fingerprint( |
| 5317 | crate::config::DEFAULT_FIREWORKS_BASE_URL, |
| 5318 | ); |
| 5319 | crate::provider_lake::set_live_snapshot( |
| 5320 | codewhale_config::catalog::CatalogSnapshot { |
| 5321 | offerings: vec![codewhale_config::catalog::CatalogOffering { |
| 5322 | provider: "fireworks".to_string(), |
| 5323 | wire_model_id: "accounts/fireworks/models/kimi-k3".to_string(), |
| 5324 | endpoint_key: "chat".to_string(), |
| 5325 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 5326 | input: Some(9.0), |
| 5327 | output: Some(18.0), |
| 5328 | cache_read: Some(1.0), |
| 5329 | cache_write: None, |
| 5330 | }), |
| 5331 | source: codewhale_config::catalog::CatalogSource::Live { |
| 5332 | base_url_fingerprint: fingerprint.clone(), |
| 5333 | fetched_at, |
| 5334 | }, |
| 5335 | ..Default::default() |
| 5336 | }], |
| 5337 | }, |
| 5338 | crate::provider_lake::LiveSource::PerProvider, |
| 5339 | ); |
| 5340 | |
| 5341 | let usage = million_input_usage(); |
| 5342 | let audit = audit_turn_cost_for_route_on_endpoint_at( |
| 5343 | ProviderKind::Fireworks, |
| 5344 | "accounts/fireworks/models/kimi-k3", |
| 5345 | billing_surface_for_route( |
| 5346 | ProviderKind::Fireworks, |
| 5347 | Some(crate::config::DEFAULT_FIREWORKS_BASE_URL), |
| 5348 | ), |
| 5349 | Some(&fingerprint), |
| 5350 | &usage, |
| 5351 | now, |
| 5352 | ); |
| 5353 | crate::provider_lake::clear_live_snapshot(); |
| 5354 | |
| 5355 | assert!(audit.is_priced(), "{audit:?}"); |
| 5356 | assert_eq!(audit.provenance, Some(PricingProvenance::ProviderLive)); |
| 5357 | assert_eq!(audit.live_pricing_defect, None); |
| 5358 | let estimate = audit.estimate.expect("priced"); |
| 5359 | assert!( |
| 5360 | (estimate.usd - 9.0).abs() < 1e-12, |
| 5361 | "verified live must win: {}", |
| 5362 | estimate.usd |
| 5363 | ); |
| 5364 | } |
| 5365 | |
| 5366 | #[test] |
| 5367 | fn future_effective_provider_live_rate_is_not_treated_as_age_zero() { |
| 5368 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5369 | crate::provider_lake::clear_live_snapshot(); |
| 5370 | let dispatched_at = Utc::now(); |
| 5371 | let future_fetched_at = u64::try_from(dispatched_at.timestamp()) |
| 5372 | .expect("timestamp") |
| 5373 | .saturating_add(1); |
| 5374 | let fingerprint = codewhale_config::catalog::base_url_fingerprint( |
| 5375 | crate::config::DEFAULT_FIREWORKS_BASE_URL, |
| 5376 | ); |
| 5377 | crate::provider_lake::set_live_snapshot( |
| 5378 | codewhale_config::catalog::CatalogSnapshot { |
| 5379 | offerings: vec![codewhale_config::catalog::CatalogOffering { |
| 5380 | provider: "fireworks".to_string(), |
| 5381 | wire_model_id: "accounts/fireworks/models/future-price-only".to_string(), |
| 5382 | endpoint_key: "chat".to_string(), |
| 5383 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 5384 | input: Some(9.0), |
| 5385 | output: Some(18.0), |
| 5386 | cache_read: Some(1.0), |
| 5387 | cache_write: None, |
| 5388 | }), |
| 5389 | source: codewhale_config::catalog::CatalogSource::Live { |
| 5390 | base_url_fingerprint: fingerprint.clone(), |
| 5391 | fetched_at: future_fetched_at, |
| 5392 | }, |
| 5393 | ..Default::default() |
| 5394 | }], |
| 5395 | }, |
| 5396 | crate::provider_lake::LiveSource::PerProvider, |
| 5397 | ); |
| 5398 | |
| 5399 | let audit = audit_turn_cost_for_route_on_endpoint_at( |
| 5400 | ProviderKind::Fireworks, |
| 5401 | "accounts/fireworks/models/future-price-only", |
| 5402 | billing_surface_for_route( |
| 5403 | ProviderKind::Fireworks, |
| 5404 | Some(crate::config::DEFAULT_FIREWORKS_BASE_URL), |
| 5405 | ), |
| 5406 | Some(&fingerprint), |
| 5407 | &million_input_usage(), |
| 5408 | dispatched_at, |
| 5409 | ); |
| 5410 | crate::provider_lake::clear_live_snapshot(); |
| 5411 | |
| 5412 | assert!( |
| 5413 | !audit.is_priced(), |
| 5414 | "future price must fail closed: {audit:?}" |
| 5415 | ); |
| 5416 | assert_eq!( |
| 5417 | audit.unpriced_reason, |
| 5418 | Some(UnpricedReason::UnverifiedLivePricing) |
| 5419 | ); |
| 5420 | } |
| 5421 | |
| 5422 | /// The fixture deliberately stamps `Live` rather than the `ModelsDevLive` |
| 5423 | /// the refresh now emits: this pins the *second*, independent check — the |
| 5424 | /// live partition the row sits in — which is what still catches a row |
| 5425 | /// mislabelled by an older publisher or a stale on-disk cache. Do not |
| 5426 | /// "correct" the source here; that would delete this belt's only coverage. |
| 5427 | #[test] |
| 5428 | fn models_dev_live_overlay_does_not_replace_bundled_catalog_rates() { |
| 5429 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5430 | crate::provider_lake::clear_live_snapshot(); |
| 5431 | let now = Utc::now(); |
| 5432 | let fetched_at = u64::try_from(now.timestamp()).expect("timestamp"); |
| 5433 | crate::provider_lake::set_live_snapshot( |
| 5434 | codewhale_config::catalog::CatalogSnapshot { |
| 5435 | offerings: vec![codewhale_config::catalog::CatalogOffering { |
| 5436 | provider: "openai".to_string(), |
| 5437 | wire_model_id: "gpt-5.5".to_string(), |
| 5438 | endpoint_key: "chat".to_string(), |
| 5439 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 5440 | input: Some(99.0), |
| 5441 | output: Some(199.0), |
| 5442 | cache_read: Some(9.0), |
| 5443 | cache_write: None, |
| 5444 | }), |
| 5445 | source: codewhale_config::catalog::CatalogSource::Live { |
| 5446 | base_url_fingerprint: "models-dev-capabilities".to_string(), |
| 5447 | fetched_at, |
| 5448 | }, |
| 5449 | ..Default::default() |
| 5450 | }], |
| 5451 | }, |
| 5452 | crate::provider_lake::LiveSource::ModelsDev, |
| 5453 | ); |
| 5454 | |
| 5455 | // Stay under the 272K long-context surcharge so this asserts the |
| 5456 | // catalog source, not the unrepresented-tier guard. |
| 5457 | let usage = Usage { |
| 5458 | input_tokens: 10_000, |
| 5459 | output_tokens: 0, |
| 5460 | ..Usage::default() |
| 5461 | }; |
| 5462 | let audit = official_route_audit(ProviderKind::Openai, "gpt-5.5", &usage); |
| 5463 | crate::provider_lake::clear_live_snapshot(); |
| 5464 | |
| 5465 | assert!(audit.is_priced(), "{audit:?}"); |
| 5466 | assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevBundled)); |
| 5467 | assert_eq!(audit.live_pricing_defect, None); |
| 5468 | let estimate = audit.estimate.expect("priced"); |
| 5469 | assert!( |
| 5470 | (estimate.usd - 0.05).abs() < 1e-12, |
| 5471 | "bundled OpenAI rate must win over models.dev leftover cost: {}", |
| 5472 | estimate.usd |
| 5473 | ); |
| 5474 | } |
| 5475 | |
| 5476 | /// Concentrate publishes different upstream rates and can fail over even |
| 5477 | /// when a provider/model prefix is requested, so a requested model's own |
| 5478 | /// published rate is never inherited: without a verified scoped offering |
| 5479 | /// the route reports the explicit routing-dependent reason instead of |
| 5480 | /// dollars (#5976). |
| 5481 | #[test] |
| 5482 | fn concentrate_without_verified_scoped_pricing_reports_routing_dependent() { |
| 5483 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5484 | crate::provider_lake::clear_live_snapshot(); |
| 5485 | let usage = million_input_usage(); |
| 5486 | |
| 5487 | // deepseek-v4-pro is the model owner's own hand-priced row; a |
| 5488 | // Concentrate request for it must not inherit that rate. |
| 5489 | let audit = official_route_audit(ProviderKind::Concentrate, "deepseek-v4-pro", &usage); |
| 5490 | assert!(!audit.is_priced(), "{audit:?}"); |
| 5491 | assert_eq!( |
| 5492 | audit.unpriced_reason, |
| 5493 | Some(UnpricedReason::RoutingDependentPrice), |
| 5494 | "{audit:?}" |
| 5495 | ); |
| 5496 | assert!(!has_pricing_for_provider( |
| 5497 | ProviderKind::Concentrate, |
| 5498 | "deepseek-v4-pro" |
| 5499 | )); |
| 5500 | } |
| 5501 | |
| 5502 | /// A fresh per-provider `/models` row fetched from the exact Concentrate |
| 5503 | /// endpoint is the scoped offering that *is* authoritative: with the |
| 5504 | /// endpoint fingerprint it prices at the scoped rate; without it the same |
| 5505 | /// row degrades to an unverified-live receipt rather than billing. |
| 5506 | #[test] |
| 5507 | fn concentrate_scoped_offering_prices_only_with_endpoint_provenance() { |
| 5508 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5509 | crate::provider_lake::clear_live_snapshot(); |
| 5510 | let now = Utc::now(); |
| 5511 | let fetched_at = u64::try_from(now.timestamp()).expect("timestamp"); |
| 5512 | let fingerprint = codewhale_config::catalog::base_url_fingerprint( |
| 5513 | crate::config::DEFAULT_CONCENTRATE_BASE_URL, |
| 5514 | ); |
| 5515 | crate::provider_lake::set_live_snapshot( |
| 5516 | codewhale_config::catalog::CatalogSnapshot { |
| 5517 | offerings: vec![codewhale_config::catalog::CatalogOffering { |
| 5518 | provider: "concentrate".to_string(), |
| 5519 | wire_model_id: "deepseek-v4-pro".to_string(), |
| 5520 | endpoint_key: "chat".to_string(), |
| 5521 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 5522 | input: Some(0.5), |
| 5523 | output: Some(1.5), |
| 5524 | cache_read: None, |
| 5525 | cache_write: None, |
| 5526 | }), |
| 5527 | source: codewhale_config::catalog::CatalogSource::Live { |
| 5528 | base_url_fingerprint: fingerprint.clone(), |
| 5529 | fetched_at, |
| 5530 | }, |
| 5531 | ..Default::default() |
| 5532 | }], |
| 5533 | }, |
| 5534 | crate::provider_lake::LiveSource::PerProvider, |
| 5535 | ); |
| 5536 | |
| 5537 | let usage = million_input_usage(); |
| 5538 | let surface = billing_surface_for_route( |
| 5539 | ProviderKind::Concentrate, |
| 5540 | Some(crate::config::DEFAULT_CONCENTRATE_BASE_URL), |
| 5541 | ); |
| 5542 | let scoped = audit_turn_cost_for_route_on_endpoint_at( |
| 5543 | ProviderKind::Concentrate, |
| 5544 | "deepseek-v4-pro", |
| 5545 | surface, |
| 5546 | Some(&fingerprint), |
| 5547 | &usage, |
| 5548 | now, |
| 5549 | ); |
| 5550 | let unproven = audit_turn_cost_for_route_on_endpoint_at( |
| 5551 | ProviderKind::Concentrate, |
| 5552 | "deepseek-v4-pro", |
| 5553 | surface, |
| 5554 | None, |
| 5555 | &usage, |
| 5556 | now, |
| 5557 | ); |
| 5558 | crate::provider_lake::clear_live_snapshot(); |
| 5559 | |
| 5560 | assert!(scoped.is_priced(), "{scoped:?}"); |
| 5561 | assert_eq!(scoped.provenance, Some(PricingProvenance::ProviderLive)); |
| 5562 | let estimate = scoped.estimate.expect("priced"); |
| 5563 | assert!( |
| 5564 | (estimate.usd - 0.5).abs() < 1e-12, |
| 5565 | "scoped Concentrate rate must govern: {}", |
| 5566 | estimate.usd |
| 5567 | ); |
| 5568 | |
| 5569 | assert!(!unproven.is_priced(), "{unproven:?}"); |
| 5570 | assert_eq!( |
| 5571 | unproven.unpriced_reason, |
| 5572 | Some(UnpricedReason::UnverifiedLivePricing), |
| 5573 | "{unproven:?}" |
| 5574 | ); |
| 5575 | } |
| 5576 | |
| 5577 | // ── BalanceResponse / BalanceInfo ────────────────────────────── |
| 5578 | |
| 5579 | #[test] |
| 5580 | fn balance_response_deserializes_from_json() { |
| 5581 | let json = r#"{ |
| 5582 | "is_available": true, |
| 5583 | "balance_infos": [ |
| 5584 | { |
| 5585 | "currency": "CNY", |
| 5586 | "total_balance": "123.45", |
| 5587 | "topped_up_balance": "100.00", |
| 5588 | "granted_balance": "23.45" |
| 5589 | } |
| 5590 | ] |
| 5591 | }"#; |
| 5592 | let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); |
| 5593 | assert!(resp.is_available); |
| 5594 | assert_eq!(resp.balance_infos.len(), 1); |
| 5595 | let info = &resp.balance_infos[0]; |
| 5596 | assert_eq!(info.currency, "CNY"); |
| 5597 | assert_eq!(info.total_balance, "123.45"); |
| 5598 | assert_eq!(info.topped_up_balance, "100.00"); |
| 5599 | assert_eq!(info.granted_balance, "23.45"); |
| 5600 | } |
| 5601 | |
| 5602 | #[test] |
| 5603 | fn balance_response_defaults_empty_balance_infos_when_unavailable() { |
| 5604 | let json = r#"{"is_available": false, "balance_infos": []}"#; |
| 5605 | let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); |
| 5606 | assert!(!resp.is_available); |
| 5607 | assert!(resp.balance_infos.is_empty()); |
| 5608 | } |
| 5609 | |
| 5610 | #[test] |
| 5611 | fn balance_response_empty_list_is_valid() { |
| 5612 | let json = r#"{"is_available": true, "balance_infos": []}"#; |
| 5613 | let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); |
| 5614 | assert!(resp.is_available); |
| 5615 | assert!(resp.balance_infos.is_empty()); |
| 5616 | } |
| 5617 | |
| 5618 | #[test] |
| 5619 | fn balance_info_chip_label_uses_currency_prefix() { |
| 5620 | let cny = BalanceInfo { |
| 5621 | currency: "CNY".to_string(), |
| 5622 | total_balance: "123.45".to_string(), |
| 5623 | ..BalanceInfo::default() |
| 5624 | }; |
| 5625 | assert_eq!(cny.chip_label().as_deref(), Some("¥123.45")); |
| 5626 | let usd = BalanceInfo { |
| 5627 | currency: "USD".to_string(), |
| 5628 | total_balance: "12.50".to_string(), |
| 5629 | ..BalanceInfo::default() |
| 5630 | }; |
| 5631 | assert_eq!(usd.chip_label().as_deref(), Some("$12.50")); |
| 5632 | assert_eq!( |
| 5633 | usd.report("OpenRouter"), |
| 5634 | "OpenRouter account balance: $12.50" |
| 5635 | ); |
| 5636 | let deepseek = BalanceInfo { |
| 5637 | currency: "CNY".to_string(), |
| 5638 | total_balance: "123.45".to_string(), |
| 5639 | topped_up_balance: "100.00".to_string(), |
| 5640 | granted_balance: "23.45".to_string(), |
| 5641 | }; |
| 5642 | assert_eq!( |
| 5643 | deepseek.report("DeepSeek"), |
| 5644 | "DeepSeek account balance: ¥123.45 (topped up 100.00, granted 23.45)" |
| 5645 | ); |
| 5646 | } |
| 5647 | |
| 5648 | struct CloudAuditReset; |
| 5649 | impl Drop for CloudAuditReset { |
| 5650 | fn drop(&mut self) { |
| 5651 | codewhale_config::cloud_facts::overlay::clear(); |
| 5652 | crate::provider_lake::clear_live_snapshot(); |
| 5653 | crate::provider_catalog_live::reset_cache_for_test(); |
| 5654 | } |
| 5655 | } |
| 5656 | |
| 5657 | fn publish_audit_facts( |
| 5658 | channel: &str, |
| 5659 | version: u64, |
| 5660 | models: Vec<codewhale_config::cloud_facts::ModelFact>, |
| 5661 | now: u64, |
| 5662 | ) { |
| 5663 | use codewhale_config::cloud_facts::{ |
| 5664 | CloudFactsState, CloudFactsStatus, FactsOrigin, ScopedFacts, overlay, |
| 5665 | }; |
| 5666 | let ticket = overlay::configure(true, channel).unwrap(); |
| 5667 | assert!(overlay::publish( |
| 5668 | &ticket, |
| 5669 | Some(ScopedFacts { |
| 5670 | channel: channel.into(), |
| 5671 | facts_version: version, |
| 5672 | key_id: "cwf-test-only".into(), |
| 5673 | valid_until: Some(now + 600), |
| 5674 | models, |
| 5675 | ..Default::default() |
| 5676 | }), |
| 5677 | CloudFactsStatus { |
| 5678 | state: CloudFactsState::Verified { |
| 5679 | channel: channel.into(), |
| 5680 | facts_version: version, |
| 5681 | key_id: "cwf-test-only".into(), |
| 5682 | fetched_at: now, |
| 5683 | origin: FactsOrigin::LocalFile, |
| 5684 | stale: false, |
| 5685 | patches: 1, |
| 5686 | defaults: 0, |
| 5687 | announcements: 0 |
| 5688 | }, |
| 5689 | ..Default::default() |
| 5690 | } |
| 5691 | )); |
| 5692 | } |
| 5693 | |
| 5694 | fn audit_price_patch( |
| 5695 | provider: ProviderKind, |
| 5696 | model: &str, |
| 5697 | input: f64, |
| 5698 | ) -> codewhale_config::cloud_facts::ModelFact { |
| 5699 | use codewhale_config::cloud_facts::{ModelFact, PricingFact}; |
| 5700 | ModelFact { |
| 5701 | provider: provider.as_str().into(), |
| 5702 | id: model.into(), |
| 5703 | context_window: Some(1_000_000), |
| 5704 | pricing: Some(PricingFact { |
| 5705 | input_per_m: Some(input), |
| 5706 | output_per_m: Some(2.0), |
| 5707 | cache_read_per_m: Some(0.1), |
| 5708 | }), |
| 5709 | ..Default::default() |
| 5710 | } |
| 5711 | } |
| 5712 | |
| 5713 | #[test] |
| 5714 | fn cloud_prices_require_dispatch_quotes_and_never_reprice_old_turns() { |
| 5715 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5716 | let home = tempfile::tempdir().unwrap(); |
| 5717 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 5718 | let _enabled = crate::test_support::EnvVarGuard::remove("CODEWHALE_DISABLE_CLOUD_FACTS"); |
| 5719 | let _reset = CloudAuditReset; |
| 5720 | codewhale_config::cloud_facts::overlay::clear(); |
| 5721 | crate::provider_lake::clear_live_snapshot(); |
| 5722 | crate::provider_catalog_live::reset_cache_for_test(); |
| 5723 | let provider = ProviderKind::Openai; |
| 5724 | let model = "cloud-audit-fixture"; |
| 5725 | let base = provider.provider().default_base_url(); |
| 5726 | let fingerprint = codewhale_config::catalog::base_url_fingerprint(base); |
| 5727 | let now = Utc::now(); |
| 5728 | let at = now.timestamp() as u64; |
| 5729 | let usage = Usage { |
| 5730 | input_tokens: 1_000_000, |
| 5731 | output_tokens: 0, |
| 5732 | ..Default::default() |
| 5733 | }; |
| 5734 | let audit = |quote| { |
| 5735 | audit_turn_cost_for_route_on_endpoint_for_identity_at( |
| 5736 | provider, |
| 5737 | Some("openai"), |
| 5738 | model, |
| 5739 | billing_surface_for_route(provider, Some(base)), |
| 5740 | Some(&fingerprint), |
| 5741 | quote, |
| 5742 | &usage, |
| 5743 | now, |
| 5744 | ) |
| 5745 | }; |
| 5746 | let before = audit(None); |
| 5747 | publish_audit_facts( |
| 5748 | "pricing-retro-test", |
| 5749 | 1, |
| 5750 | vec![audit_price_patch(provider, model, 1.0)], |
| 5751 | at, |
| 5752 | ); |
| 5753 | assert_eq!( |
| 5754 | audit(None), |
| 5755 | before, |
| 5756 | "a newly installed catalog cannot price an old dispatch without a quote" |
| 5757 | ); |
| 5758 | let estimate = audit_turn_cost_for_provider_on_endpoint_at( |
| 5759 | provider, |
| 5760 | model, |
| 5761 | Some(&fingerprint), |
| 5762 | &usage, |
| 5763 | now, |
| 5764 | ); |
| 5765 | assert_eq!(estimate.provenance, Some(PricingProvenance::CloudFacts)); |
| 5766 | assert_eq!(estimate.estimate.unwrap().usd, 1.0); |
| 5767 | let before_fetch = audit_turn_cost_for_provider_on_endpoint_at( |
| 5768 | provider, |
| 5769 | model, |
| 5770 | Some(&fingerprint), |
| 5771 | &usage, |
| 5772 | now - chrono::Duration::seconds(1), |
| 5773 | ); |
| 5774 | assert_eq!( |
| 5775 | before_fetch.unpriced_reason, |
| 5776 | Some(UnpricedReason::UnverifiedLivePricing) |
| 5777 | ); |
| 5778 | let quote = crate::provider_catalog_live::fresh_dispatch_pricing_quote_at( |
| 5779 | provider, "openai", model, base, at, |
| 5780 | ) |
| 5781 | .unwrap(); |
| 5782 | assert_eq!(audit(Some("e)).estimate.unwrap().usd, 1.0); |
| 5783 | publish_audit_facts( |
| 5784 | "pricing-retro-test", |
| 5785 | 2, |
| 5786 | vec![audit_price_patch(provider, model, 9.0)], |
| 5787 | at, |
| 5788 | ); |
| 5789 | assert_eq!(audit(Some("e)).estimate.unwrap().usd, 1.0); |
| 5790 | assert_eq!(audit(None), before); |
| 5791 | codewhale_config::cloud_facts::overlay::clear(); |
| 5792 | assert_eq!(audit(Some("e)).estimate.unwrap().usd, 1.0); |
| 5793 | } |
| 5794 | |
| 5795 | #[test] |
| 5796 | fn cloud_quote_keeps_provider_hand_tiers_and_recorded_time_authority() { |
| 5797 | let _live = crate::provider_lake::lock_live_snapshot(); |
| 5798 | let home = tempfile::tempdir().unwrap(); |
| 5799 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 5800 | let _enabled = crate::test_support::EnvVarGuard::remove("CODEWHALE_DISABLE_CLOUD_FACTS"); |
| 5801 | let _reset = CloudAuditReset; |
| 5802 | codewhale_config::cloud_facts::overlay::clear(); |
| 5803 | crate::provider_lake::clear_live_snapshot(); |
| 5804 | crate::provider_catalog_live::reset_cache_for_test(); |
| 5805 | let now = Utc::now(); |
| 5806 | let at = now.timestamp() as u64; |
| 5807 | let usage = Usage { |
| 5808 | input_tokens: 600_000, |
| 5809 | output_tokens: 1000, |
| 5810 | ..Default::default() |
| 5811 | }; |
| 5812 | let routes = [ |
| 5813 | (ProviderKind::Deepseek, "deepseek-v4-flash"), |
| 5814 | (ProviderKind::Anthropic, "claude-sonnet-5"), |
| 5815 | (ProviderKind::Minimax, "MiniMax-M3"), |
| 5816 | (ProviderKind::MinimaxAnthropic, "MiniMax-M3"), |
| 5817 | (ProviderKind::Xai, "grok-4.6"), |
| 5818 | ]; |
| 5819 | let before: Vec<_> = routes |
| 5820 | .iter() |
| 5821 | .map(|(provider, model)| audit_turn_cost_for_provider_at(*provider, model, &usage, now)) |
| 5822 | .collect(); |
| 5823 | assert!(before.iter().all(TurnCostAudit::is_priced)); |
| 5824 | publish_audit_facts( |
| 5825 | "pricing-hand-test", |
| 5826 | 1, |
| 5827 | routes |
| 5828 | .iter() |
| 5829 | .map(|(provider, model)| audit_price_patch(*provider, model, 999.0)) |
| 5830 | .collect(), |
| 5831 | at, |
| 5832 | ); |
| 5833 | for ((provider, model), expected) in routes.into_iter().zip(before) { |
| 5834 | let base = provider.provider().default_base_url(); |
| 5835 | let quote = crate::provider_catalog_live::fresh_dispatch_pricing_quote_at( |
| 5836 | provider, |
| 5837 | provider.as_str(), |
| 5838 | model, |
| 5839 | base, |
| 5840 | at, |
| 5841 | ) |
| 5842 | .unwrap_or_else(|| { |
| 5843 | panic!( |
| 5844 | "cloud quote missing for {provider:?} identity={} model={model}", |
| 5845 | provider.as_str() |
| 5846 | ) |
| 5847 | }); |
| 5848 | // MiniMax shares these endpoints with subscription keys. Only a |
| 5849 | // saved PAYG mode supplies this receipt (as the client does); a |
| 5850 | // signed price cannot itself establish the account billing mode. |
| 5851 | let surface = if matches!( |
| 5852 | provider, |
| 5853 | ProviderKind::Minimax | ProviderKind::MinimaxAnthropic |
| 5854 | ) { |
| 5855 | let unknown = audit_turn_cost_for_route_on_endpoint_for_identity_at( |
| 5856 | provider, |
| 5857 | Some(provider.as_str()), |
| 5858 | model, |
| 5859 | billing_surface_for_route(provider, Some(base)), |
| 5860 | Some(&codewhale_config::catalog::base_url_fingerprint(base)), |
| 5861 | Some("e), |
| 5862 | &usage, |
| 5863 | now, |
| 5864 | ); |
| 5865 | assert_eq!( |
| 5866 | unknown.unpriced_reason, |
| 5867 | Some(UnpricedReason::UnknownBillingBasis) |
| 5868 | ); |
| 5869 | Some(MINIMAX_PAYG_BILLING_SURFACE) |
| 5870 | } else { |
| 5871 | billing_surface_for_route(provider, Some(base)) |
| 5872 | }; |
| 5873 | let actual = audit_turn_cost_for_route_on_endpoint_for_identity_at( |
| 5874 | provider, |
| 5875 | Some(provider.as_str()), |
| 5876 | model, |
| 5877 | surface, |
| 5878 | Some(&codewhale_config::catalog::base_url_fingerprint(base)), |
| 5879 | Some("e), |
| 5880 | &usage, |
| 5881 | now, |
| 5882 | ); |
| 5883 | assert_eq!(actual, expected, "{provider:?}/{model}"); |
| 5884 | } |
| 5885 | } |
| 5886 | } |
| 5887 |