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