| 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, TimeZone, Utc}; |
| 9 | use codewhale_config::pricing::{ |
| 10 | Currency, LIVE_PRICING_MAX_AGE_SECS, LivePricingDefect, OfferingPricing, PricingProvenance, |
| 11 | TokenClass, TokenUsage, |
| 12 | }; |
| 13 | |
| 14 | use crate::config::{ |
| 15 | ApiProvider, DEEPSEEK_ALIAS_REPLACEMENT, DEEPSEEK_ALIAS_RETIREMENT_UTC, |
| 16 | DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_MODEL, DEFAULT_STEPFUN_PLAN_BASE_URL, |
| 17 | canonical_model_id_for_provider, |
| 18 | }; |
| 19 | use crate::models::{Usage, has_date_snapshot_suffix}; |
| 20 | |
| 21 | /// Cost display currency. |
| 22 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 23 | pub enum CostCurrency { |
| 24 | Usd, |
| 25 | Cny, |
| 26 | } |
| 27 | |
| 28 | impl CostCurrency { |
| 29 | pub fn from_setting(value: &str) -> Option<Self> { |
| 30 | match value.trim().to_ascii_lowercase().as_str() { |
| 31 | "usd" | "dollar" | "dollars" | "$" => Some(Self::Usd), |
| 32 | "cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny), |
| 33 | _ => None, |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | fn symbol(self) -> &'static str { |
| 38 | match self { |
| 39 | Self::Usd => "$", |
| 40 | Self::Cny => "¥", |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// Cost estimate in displayable currencies. |
| 46 | #[derive(Debug, Clone, Copy, Default, PartialEq)] |
| 47 | pub struct CostEstimate { |
| 48 | pub usd: f64, |
| 49 | pub cny: f64, |
| 50 | } |
| 51 | |
| 52 | impl CostEstimate { |
| 53 | #[allow(dead_code)] |
| 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 | // === DeepSeek Account Balance === |
| 111 | |
| 112 | /// Response from `GET https://api.deepseek.com/user/balance`. |
| 113 | #[derive(Debug, Clone, Default, serde::Deserialize)] |
| 114 | pub struct BalanceResponse { |
| 115 | #[allow(dead_code)] |
| 116 | pub is_available: bool, |
| 117 | pub balance_infos: Vec<BalanceInfo>, |
| 118 | } |
| 119 | |
| 120 | /// Per-currency balance entry from the balance API. |
| 121 | #[derive(Debug, Clone, Default, serde::Deserialize)] |
| 122 | pub struct BalanceInfo { |
| 123 | // Wire fields of the live `GET /user/balance` response deserialized in |
| 124 | // `tui::ui::fetch_deepseek_balance` and parked in `App::balance_cell`. |
| 125 | // Nothing renders them today — the footer balance chip went with the |
| 126 | // legacy FooterWidget — so `dead_code` cannot see the producer. Kept |
| 127 | // because they are the API contract, matching the sibling fields below. |
| 128 | #[allow(dead_code)] |
| 129 | pub currency: String, |
| 130 | #[serde(default)] |
| 131 | #[allow(dead_code)] |
| 132 | pub total_balance: String, |
| 133 | #[serde(default)] |
| 134 | #[allow(dead_code)] |
| 135 | pub topped_up_balance: String, |
| 136 | #[serde(default)] |
| 137 | #[allow(dead_code)] |
| 138 | pub granted_balance: String, |
| 139 | } |
| 140 | |
| 141 | impl BalanceInfo {} |
| 142 | |
| 143 | /// How a hand-sourced row bills cache-creation (cache-write) tokens. |
| 144 | /// |
| 145 | /// The distinction matters because "no separate write rate published" and |
| 146 | /// "documented to cost the same as ordinary input" are different facts that used |
| 147 | /// to collapse onto the same `None`. Folding the unknown case into the input |
| 148 | /// rate invents a price; this enum keeps the invention impossible (#4318). |
| 149 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 150 | enum CacheWritePolicy { |
| 151 | /// The provider publishes a distinct cache-creation rate (per million). |
| 152 | Rate(f64), |
| 153 | /// Provider documentation states that cache creation carries **no separate |
| 154 | /// charge** beyond the ordinary cache-miss input rate, so the miss rate is |
| 155 | /// the published write rate rather than a substitute for a missing one. |
| 156 | /// |
| 157 | /// The `&'static str` is the documentation receipt this claim rests on, so |
| 158 | /// the policy is auditable instead of asserted. |
| 159 | DocumentedAsInputRate(&'static str), |
| 160 | /// No published cache-write rate was found for this row. A turn that |
| 161 | /// actually wrote to cache fails closed rather than being billed at a rate |
| 162 | /// CodeWhale made up. |
| 163 | Unpublished, |
| 164 | } |
| 165 | |
| 166 | /// DeepSeek's context-caching docs: tokens that miss the cache are billed once |
| 167 | /// at the cache-miss rate and writing them into the cache costs nothing extra. |
| 168 | /// <https://api-docs.deepseek.com/guides/kv_cache> |
| 169 | const DEEPSEEK_CACHE_WRITE_IS_FREE: &str = "deepseek-kv-cache-no-write-charge"; |
| 170 | |
| 171 | impl CacheWritePolicy { |
| 172 | /// The rate to bill cache-write tokens at, given the row's input rate. |
| 173 | /// |
| 174 | /// `None` means the row cannot price cache-write tokens at all. |
| 175 | fn rate(self, input_cache_miss_per_million: f64) -> Option<f64> { |
| 176 | match self { |
| 177 | Self::Rate(rate) => Some(rate), |
| 178 | Self::DocumentedAsInputRate(_) => Some(input_cache_miss_per_million), |
| 179 | Self::Unpublished => None, |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | /// Per-million-token pricing for a model. |
| 185 | #[derive(Debug, Clone, Copy)] |
| 186 | struct CurrencyPricing { |
| 187 | input_cache_hit_per_million: f64, |
| 188 | input_cache_miss_per_million: f64, |
| 189 | output_per_million: f64, |
| 190 | /// How cache-creation tokens are billed on this row. |
| 191 | cache_write: CacheWritePolicy, |
| 192 | } |
| 193 | |
| 194 | /// Per-million-token pricing for a model. |
| 195 | #[derive(Debug, Clone, Copy)] |
| 196 | struct ModelPricing { |
| 197 | usd: CurrencyPricing, |
| 198 | cny: Option<CurrencyPricing>, |
| 199 | } |
| 200 | |
| 201 | pub(crate) const STEPFUN_PAYG_BILLING_SURFACE: &str = "stepfun-payg"; |
| 202 | pub(crate) const STEPFUN_PLAN_BILLING_SURFACE: &str = "stepfun-plan"; |
| 203 | const LEGACY_STEPFUN_PLAN_BASE_URL: &str = "https://api.stepfun.com/step_plan/v1"; |
| 204 | |
| 205 | /// Z.ai's dedicated Coding endpoint — the GLM Coding Plan subscription route. |
| 206 | pub(crate) const ZAI_CODING_PLAN_BILLING_SURFACE: &str = "zai-coding-plan"; |
| 207 | /// Z.ai's ordinary public per-token API. |
| 208 | pub(crate) const ZAI_PAYG_BILLING_SURFACE: &str = "zai-payg"; |
| 209 | /// Moonshot's Kimi Code subscription endpoint. |
| 210 | pub(crate) const MOONSHOT_KIMI_CODE_BILLING_SURFACE: &str = "moonshot-kimi-code"; |
| 211 | /// Moonshot's ordinary public per-token API. |
| 212 | pub(crate) const MOONSHOT_PAYG_BILLING_SURFACE: &str = "moonshot-payg"; |
| 213 | /// MiniMax's prepaid Token Plan endpoint. |
| 214 | pub(crate) const MINIMAX_TOKEN_PLAN_BILLING_SURFACE: &str = "minimax-token-plan"; |
| 215 | /// MiniMax's ordinary public per-token API. |
| 216 | pub(crate) const MINIMAX_PAYG_BILLING_SURFACE: &str = "minimax-payg"; |
| 217 | /// Xiaomi MiMo's prepaid token-plan endpoint. |
| 218 | pub(crate) const XIAOMI_TOKEN_PLAN_BILLING_SURFACE: &str = "xiaomi-mimo-token-plan"; |
| 219 | /// Xiaomi MiMo's ordinary public per-token API. |
| 220 | pub(crate) const XIAOMI_PAYG_BILLING_SURFACE: &str = "xiaomi-mimo-payg"; |
| 221 | /// An OAuth/subscription-brokered endpoint (Codex, Claude OAuth, Grok OAuth, |
| 222 | /// OpenCode Go). Never per-token metered from CodeWhale's side. |
| 223 | pub(crate) const OAUTH_SUBSCRIPTION_BILLING_SURFACE: &str = "oauth-subscription"; |
| 224 | /// A loopback / self-hosted endpoint with no provider bill at all. |
| 225 | pub(crate) const LOCAL_BILLING_SURFACE: &str = "local-no-bill"; |
| 226 | /// A provider's own first-party public per-token API, on its documented host. |
| 227 | pub(crate) const FIRST_PARTY_PAYG_BILLING_SURFACE: &str = "first-party-payg"; |
| 228 | /// An aggregator/reseller endpoint: metered, but priced by the aggregator's own |
| 229 | /// catalog rather than by the upstream model owner's published rates. |
| 230 | pub(crate) const AGGREGATOR_BILLING_SURFACE: &str = "aggregator-payg"; |
| 231 | /// A reachable endpoint CodeWhale could not match to any known billing surface. |
| 232 | /// Distinct from "not classified yet": this is a positive statement that the |
| 233 | /// surface is unknown, and it fails closed everywhere it is consumed. |
| 234 | pub(crate) const UNCLASSIFIED_BILLING_SURFACE: &str = "unclassified"; |
| 235 | |
| 236 | /// How a classified billing surface meters money. |
| 237 | /// |
| 238 | /// This is the fact every cost surface actually needs: whether a dollar figure |
| 239 | /// is even the right unit for the route. `Unknown` is a real answer and is |
| 240 | /// treated as *possibly* metered — it is counted as missing spend rather than |
| 241 | /// excused as a subscription (#4318). |
| 242 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 243 | pub enum EndpointMetering { |
| 244 | /// Per-token money, priced against published rates. |
| 245 | Money, |
| 246 | /// An exactly-identified subscription or prepaid-quota endpoint. Money is |
| 247 | /// the wrong unit here, so these turns are excluded from money coverage. |
| 248 | ExactSubscription, |
| 249 | /// Local/self-hosted: there is no provider bill. |
| 250 | LocalNoBill, |
| 251 | /// Could not be established. Fails closed as possibly-money. |
| 252 | Unknown, |
| 253 | } |
| 254 | |
| 255 | /// Classify a billing-surface id into its metering shape. |
| 256 | /// |
| 257 | /// Unrecognized ids — including ones written by a newer build — resolve to |
| 258 | /// [`EndpointMetering::Unknown`] rather than being guessed into a bucket. |
| 259 | #[must_use] |
| 260 | pub fn endpoint_metering_for_billing_surface(billing_surface: Option<&str>) -> EndpointMetering { |
| 261 | let Some(surface) = billing_surface.map(str::trim).filter(|s| !s.is_empty()) else { |
| 262 | return EndpointMetering::Unknown; |
| 263 | }; |
| 264 | // Exact, case-insensitive matches only. A prefix/substring rule here would |
| 265 | // let an unrecognized future surface impersonate a known one. |
| 266 | for (known, metering) in [ |
| 267 | (STEPFUN_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 268 | (ZAI_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 269 | (MOONSHOT_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 270 | (MINIMAX_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 271 | (XIAOMI_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 272 | (FIRST_PARTY_PAYG_BILLING_SURFACE, EndpointMetering::Money), |
| 273 | (AGGREGATOR_BILLING_SURFACE, EndpointMetering::Money), |
| 274 | ( |
| 275 | STEPFUN_PLAN_BILLING_SURFACE, |
| 276 | EndpointMetering::ExactSubscription, |
| 277 | ), |
| 278 | ( |
| 279 | ZAI_CODING_PLAN_BILLING_SURFACE, |
| 280 | EndpointMetering::ExactSubscription, |
| 281 | ), |
| 282 | ( |
| 283 | MOONSHOT_KIMI_CODE_BILLING_SURFACE, |
| 284 | EndpointMetering::ExactSubscription, |
| 285 | ), |
| 286 | ( |
| 287 | MINIMAX_TOKEN_PLAN_BILLING_SURFACE, |
| 288 | EndpointMetering::ExactSubscription, |
| 289 | ), |
| 290 | ( |
| 291 | XIAOMI_TOKEN_PLAN_BILLING_SURFACE, |
| 292 | EndpointMetering::ExactSubscription, |
| 293 | ), |
| 294 | ( |
| 295 | OAUTH_SUBSCRIPTION_BILLING_SURFACE, |
| 296 | EndpointMetering::ExactSubscription, |
| 297 | ), |
| 298 | (LOCAL_BILLING_SURFACE, EndpointMetering::LocalNoBill), |
| 299 | (UNCLASSIFIED_BILLING_SURFACE, EndpointMetering::Unknown), |
| 300 | ] { |
| 301 | if surface.eq_ignore_ascii_case(known) { |
| 302 | return metering; |
| 303 | } |
| 304 | } |
| 305 | EndpointMetering::Unknown |
| 306 | } |
| 307 | |
| 308 | /// A base URL reduced to the non-secret parts a billing classification may |
| 309 | /// depend on: scheme, host, normalized path. `None` when the URL carries |
| 310 | /// embedded credentials, a query, a fragment, a non-default port, or is not |
| 311 | /// HTTPS — any of which means CodeWhale cannot vouch for which surface it is. |
| 312 | struct EndpointShape { |
| 313 | host: String, |
| 314 | path: String, |
| 315 | } |
| 316 | |
| 317 | fn endpoint_shape(base_url: &str) -> Option<EndpointShape> { |
| 318 | let parsed = reqwest::Url::parse(base_url.trim()).ok()?; |
| 319 | if parsed.scheme() != "https" |
| 320 | || !parsed.username().is_empty() |
| 321 | || parsed.password().is_some() |
| 322 | || parsed.query().is_some() |
| 323 | || parsed.fragment().is_some() |
| 324 | || parsed.port_or_known_default() != Some(443) |
| 325 | { |
| 326 | return None; |
| 327 | } |
| 328 | Some(EndpointShape { |
| 329 | host: parsed.host_str()?.to_ascii_lowercase(), |
| 330 | path: parsed.path().trim_end_matches('/').to_string(), |
| 331 | }) |
| 332 | } |
| 333 | |
| 334 | fn host_of(url: &str) -> Option<String> { |
| 335 | reqwest::Url::parse(url) |
| 336 | .ok()? |
| 337 | .host_str() |
| 338 | .map(str::to_ascii_lowercase) |
| 339 | } |
| 340 | |
| 341 | /// Reduce a concrete request endpoint to non-secret billing provenance. |
| 342 | /// |
| 343 | /// Every reachable endpoint now gets a positive classification, including |
| 344 | /// [`UNCLASSIFIED_BILLING_SURFACE`] for one CodeWhale cannot place. `None` is |
| 345 | /// reserved for "no endpoint was supplied", which is a different failure and is |
| 346 | /// also treated as unknown downstream. Nothing here consults credentials or |
| 347 | /// echoes a URL, so the result is safe to persist and log. |
| 348 | pub(crate) fn billing_surface_for_route( |
| 349 | provider: ApiProvider, |
| 350 | base_url: Option<&str>, |
| 351 | ) -> Option<&'static str> { |
| 352 | // Routes whose billing shape is a property of the provider itself, not of |
| 353 | // the endpoint spelling. |
| 354 | match provider { |
| 355 | ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm => { |
| 356 | return Some(LOCAL_BILLING_SURFACE); |
| 357 | } |
| 358 | ApiProvider::OpenaiCodex | ApiProvider::OpencodeGo => { |
| 359 | return Some(OAUTH_SUBSCRIPTION_BILLING_SURFACE); |
| 360 | } |
| 361 | // A named custom endpoint is never assumed to be metered; the billing |
| 362 | // presentation layer decides that from explicit config. |
| 363 | ApiProvider::Custom => return Some(UNCLASSIFIED_BILLING_SURFACE), |
| 364 | _ => {} |
| 365 | } |
| 366 | |
| 367 | let base_url = base_url.map(str::trim).filter(|url| !url.is_empty())?; |
| 368 | let Some(shape) = endpoint_shape(base_url) else { |
| 369 | return Some(UNCLASSIFIED_BILLING_SURFACE); |
| 370 | }; |
| 371 | |
| 372 | let surface = match provider { |
| 373 | ApiProvider::Stepfun => stepfun_surface(&shape), |
| 374 | ApiProvider::Zai => zai_surface(&shape), |
| 375 | ApiProvider::Moonshot => moonshot_surface(&shape), |
| 376 | ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => minimax_surface(&shape), |
| 377 | ApiProvider::XiaomiMimo => xiaomi_surface(&shape), |
| 378 | ApiProvider::Openrouter | ApiProvider::NvidiaNim | ApiProvider::OpencodeZen => { |
| 379 | is_official_default_endpoint(provider, &shape).then_some(AGGREGATOR_BILLING_SURFACE) |
| 380 | } |
| 381 | _ => is_official_default_endpoint(provider, &shape) |
| 382 | .then_some(FIRST_PARTY_PAYG_BILLING_SURFACE), |
| 383 | }; |
| 384 | Some(surface.unwrap_or(UNCLASSIFIED_BILLING_SURFACE)) |
| 385 | } |
| 386 | |
| 387 | fn stepfun_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 388 | if host_of(DEFAULT_STEPFUN_BASE_URL).is_some_and(|official| shape.host == official) |
| 389 | && matches!(shape.path.as_str(), "" | "/v1") |
| 390 | { |
| 391 | return Some(STEPFUN_PAYG_BILLING_SURFACE); |
| 392 | } |
| 393 | let plan_host = [DEFAULT_STEPFUN_PLAN_BASE_URL, LEGACY_STEPFUN_PLAN_BASE_URL] |
| 394 | .iter() |
| 395 | .filter_map(|url| host_of(url)) |
| 396 | .any(|plan| plan == shape.host); |
| 397 | if plan_host && matches!(shape.path.as_str(), "/step_plan" | "/step_plan/v1") { |
| 398 | return Some(STEPFUN_PLAN_BILLING_SURFACE); |
| 399 | } |
| 400 | None |
| 401 | } |
| 402 | |
| 403 | fn zai_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 404 | // The Coding Plan contract is the exact shipped Z.ai endpoint. Do not let |
| 405 | // arbitrary future `/api/coding/*` paths, or the separate BigModel host, |
| 406 | // inherit a subscription classification. |
| 407 | if shape.host == "api.z.ai" && shape.path == "/api/coding/paas/v4" { |
| 408 | Some(ZAI_CODING_PLAN_BILLING_SURFACE) |
| 409 | } else if matches!(shape.host.as_str(), "api.z.ai" | "open.bigmodel.cn") |
| 410 | && matches!( |
| 411 | shape.path.as_str(), |
| 412 | "/api/paas/v4" | "/api/anthropic" | "/v1" | "" |
| 413 | ) |
| 414 | { |
| 415 | Some(ZAI_PAYG_BILLING_SURFACE) |
| 416 | } else { |
| 417 | None |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | fn moonshot_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 422 | // Kimi Code is a distinct membership product on api.kimi.com. Accept the |
| 423 | // exact shipped endpoint as well as its slash-normalized parent; do not |
| 424 | // infer a plan from a model id or from an arbitrary host carrying a |
| 425 | // `/coding` path. |
| 426 | if shape.host == "api.kimi.com" && matches!(shape.path.as_str(), "/coding" | "/coding/v1") { |
| 427 | Some(MOONSHOT_KIMI_CODE_BILLING_SURFACE) |
| 428 | } else if matches!(shape.host.as_str(), "api.moonshot.ai" | "api.moonshot.cn") |
| 429 | && matches!(shape.path.as_str(), "" | "/v1" | "/anthropic") |
| 430 | { |
| 431 | Some(MOONSHOT_PAYG_BILLING_SURFACE) |
| 432 | } else { |
| 433 | None |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | fn minimax_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 438 | // MiniMax API keys and subscription-plan keys use the same normal |
| 439 | // endpoints. The URL therefore proves neither PAYG nor plan billing; only |
| 440 | // an explicit saved mode may produce a concrete MiniMax surface. |
| 441 | let _is_supported_endpoint = matches!( |
| 442 | shape.host.as_str(), |
| 443 | "api.minimax.io" | "api.minimaxi.com" | "api.minimax.chat" |
| 444 | ) && matches!(shape.path.as_str(), "" | "/v1" | "/anthropic"); |
| 445 | None |
| 446 | } |
| 447 | |
| 448 | fn xiaomi_surface(shape: &EndpointShape) -> Option<&'static str> { |
| 449 | if matches!( |
| 450 | shape.host.as_str(), |
| 451 | "token-plan-cn.xiaomimimo.com" |
| 452 | | "token-plan-sgp.xiaomimimo.com" |
| 453 | | "token-plan-ams.xiaomimimo.com" |
| 454 | ) && shape.path == "/v1" |
| 455 | { |
| 456 | return Some(XIAOMI_TOKEN_PLAN_BILLING_SURFACE); |
| 457 | } |
| 458 | if shape.host == "api.xiaomimimo.com" && shape.path == "/v1" { |
| 459 | return Some(XIAOMI_PAYG_BILLING_SURFACE); |
| 460 | } |
| 461 | None |
| 462 | } |
| 463 | |
| 464 | /// Exact default endpoint match for built-in providers whose billing surface |
| 465 | /// has no provider-specific split above. |
| 466 | /// |
| 467 | /// A provider enum is not proof that a configured URL is that provider's own |
| 468 | /// billing surface. This allowlist keeps `https://proxy.example/v1` from |
| 469 | /// inheriting OpenAI/Anthropic/DeepSeek/OpenRouter prices merely because the |
| 470 | /// selected protocol/provider name is familiar. |
| 471 | fn is_official_default_endpoint(provider: ApiProvider, shape: &EndpointShape) -> bool { |
| 472 | let Some(default) = endpoint_shape(provider.default_base_url()) else { |
| 473 | return false; |
| 474 | }; |
| 475 | if shape.host != default.host { |
| 476 | return false; |
| 477 | } |
| 478 | if shape.path == default.path { |
| 479 | return true; |
| 480 | } |
| 481 | match provider { |
| 482 | ApiProvider::Deepseek | ApiProvider::DeepseekCN => { |
| 483 | matches!(shape.path.as_str(), "" | "/v1" | "/beta") |
| 484 | } |
| 485 | ApiProvider::DeepseekAnthropic => shape.path == "/anthropic", |
| 486 | ApiProvider::Openai => matches!(shape.path.as_str(), "" | "/v1"), |
| 487 | ApiProvider::Anthropic => matches!(shape.path.as_str(), "" | "/v1"), |
| 488 | _ => false, |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | fn pricing_for_billing_surface( |
| 493 | provider: ApiProvider, |
| 494 | model: &str, |
| 495 | billing_surface: Option<&str>, |
| 496 | ) -> Option<ModelPricing> { |
| 497 | if provider == ApiProvider::Stepfun |
| 498 | && model.trim().eq_ignore_ascii_case(DEFAULT_STEPFUN_MODEL) |
| 499 | && billing_surface |
| 500 | .is_some_and(|surface| surface.eq_ignore_ascii_case(STEPFUN_PAYG_BILLING_SURFACE)) |
| 501 | { |
| 502 | // StepFun standard API pricing (2026-07-13 audit). Step Plan uses a |
| 503 | // separate subscription quota and must never reach this token rate. |
| 504 | // https://platform.stepfun.ai/docs/en/guides/pricing/details |
| 505 | Some(usd_only_pricing(0.04, 0.20, 1.15)) |
| 506 | } else { |
| 507 | None |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | fn route_requires_billing_surface(provider: ApiProvider, model: &str) -> bool { |
| 512 | provider == ApiProvider::Stepfun || model.trim().eq_ignore_ascii_case(DEFAULT_STEPFUN_MODEL) |
| 513 | } |
| 514 | |
| 515 | /// Look up pricing for a model name. |
| 516 | fn pricing_for_model(model: &str) -> Option<ModelPricing> { |
| 517 | pricing_for_model_at(model, Utc::now()) |
| 518 | } |
| 519 | |
| 520 | /// Return whether a model has a row in the pricing table. |
| 521 | #[must_use] |
| 522 | pub fn has_pricing_for_model(model: &str) -> bool { |
| 523 | pricing_for_model(model).is_some() |
| 524 | } |
| 525 | |
| 526 | /// Return whether the selected provider route exposes authoritative dollar |
| 527 | /// pricing for this model without endpoint provenance. ChatGPT/Codex OAuth is |
| 528 | /// subscription/account scoped, while StepFun needs PAYG-vs-Plan provenance. |
| 529 | #[must_use] |
| 530 | pub fn has_pricing_for_provider(provider: ApiProvider, model: &str) -> bool { |
| 531 | calculate_turn_cost_estimate_for_provider(provider, model, &Usage::default()).is_some() |
| 532 | } |
| 533 | |
| 534 | /// Return whether a provider/model route has authoritative pricing for an |
| 535 | /// already-classified billing surface. |
| 536 | #[must_use] |
| 537 | pub(crate) fn has_pricing_for_billing_surface( |
| 538 | provider: ApiProvider, |
| 539 | model: &str, |
| 540 | billing_surface: Option<&str>, |
| 541 | ) -> bool { |
| 542 | pricing_for_billing_surface(provider, model, billing_surface).is_some() |
| 543 | } |
| 544 | |
| 545 | fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing> { |
| 546 | let lower = model.to_lowercase(); |
| 547 | if lower.starts_with("deepseek-ai/") { |
| 548 | // NVIDIA NIM-hosted DeepSeek uses NVIDIA's catalog/account terms, not |
| 549 | // DeepSeek Platform pricing. Avoid showing misleading DeepSeek costs. |
| 550 | return None; |
| 551 | } |
| 552 | if lower == "claude-sonnet-5" { |
| 553 | // Time-aware introductory pricing; resolved ahead of the catalog so |
| 554 | // the intro rate is honored while it lasts (same pattern as |
| 555 | // deepseek_v4_pro_pricing() / #2489). |
| 556 | return Some(claude_sonnet_5_pricing(now)); |
| 557 | } |
| 558 | if let Some(pricing) = known_pricing_for_model(&lower) { |
| 559 | return Some(pricing); |
| 560 | } |
| 561 | if lower.contains("deepseek") { |
| 562 | if lower.contains("v4-pro") || lower.contains("v4pro") { |
| 563 | // DeepSeek's pricing page says the V4-Pro promotional 75% discount |
| 564 | // becomes the official one-quarter base price after 2026-05-31 15:59 |
| 565 | // UTC. Keep using the adjusted rate after that cutoff (#2489). |
| 566 | Some(deepseek_v4_pro_pricing()) |
| 567 | } else { |
| 568 | Some(deepseek_v4_flash_pricing()) |
| 569 | } |
| 570 | } else { |
| 571 | None |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | fn known_pricing_for_model(model_lower: &str) -> Option<ModelPricing> { |
| 576 | let explicit = match model_lower { |
| 577 | "openai/gpt-5.6" | "openai/gpt-5.6-sol" | "gpt-5.6" | "gpt-5.6-sol" => { |
| 578 | Some(usd_only_pricing(0.50, 5.00, 30.00)) |
| 579 | } |
| 580 | "openai/gpt-5.6-terra" | "gpt-5.6-terra" => Some(usd_only_pricing(0.25, 2.50, 15.00)), |
| 581 | "openai/gpt-5.6-luna" | "gpt-5.6-luna" => Some(usd_only_pricing(0.10, 1.00, 6.00)), |
| 582 | "meta/muse-spark-1.1" | "muse-spark-1.1" => Some(usd_only_pricing(0.15, 1.25, 4.25)), |
| 583 | "meta/muse-spark-1.2" | "muse-spark-1.2" => Some(usd_only_pricing(0.15, 1.25, 4.25)), |
| 584 | "meta/muse-spark-1.2-contributor" | "muse-spark-1.2-contributor" => { |
| 585 | Some(usd_only_pricing(0.002, 0.10, 0.20)) |
| 586 | } |
| 587 | // Anthropic first-party rates including the published cache-read |
| 588 | // discounts and 5-minute cache-write rates (2026-07-09 audit, |
| 589 | // https://platform.claude.com/docs/en/about-claude/pricing). These sit |
| 590 | // above the catalog lookup because the bundled catalog cannot carry |
| 591 | // cache-read/write rates yet. 1h write is 2x input; we price the |
| 592 | // common 5m tier (1.25x input) here (#4318). |
| 593 | "claude-opus-4-8" => Some(usd_pricing_with_write(0.50, 5.00, 25.00, 6.25)), |
| 594 | "claude-sonnet-4-6" => Some(usd_pricing_with_write(0.30, 3.00, 15.00, 3.75)), |
| 595 | "claude-haiku-4-5" => Some(usd_pricing_with_write(0.10, 1.00, 5.00, 1.25)), |
| 596 | // Claude Fable 5 (GA 2026-06-09). Its newer tokenizer produces ~30% |
| 597 | // more tokens for the same text than prior Claude models, so raw |
| 598 | // per-token rate comparisons against other Claude rows undercount its |
| 599 | // effective cost. Cache-write is 12.50 (5m) / 20.00 (1h) upstream. |
| 600 | "claude-fable-5" => Some(usd_pricing_with_write(1.00, 10.00, 50.00, 12.50)), |
| 601 | // Z.ai GLM-5.2 cache-read rate per https://docs.z.ai/guides/overview/pricing |
| 602 | // (cache storage limited-time free). |
| 603 | "z-ai/glm-5.2" | "glm-5.2" => Some(usd_only_pricing(0.26, 1.40, 4.40)), |
| 604 | // Moonshot K2.7 Code cache-read rate per |
| 605 | // https://platform.kimi.ai/docs/pricing/chat-k27-code |
| 606 | "moonshotai/kimi-k2.7-code" | "kimi-k2.7-code" => Some(usd_only_pricing(0.19, 0.95, 4.00)), |
| 607 | // MiniMax-M3 uses the lower standard tier for metadata-only lookups; |
| 608 | // cost estimation selects the correct tier from total input usage. |
| 609 | "minimax-m3" => Some(minimax_m3_standard_pricing(false)), |
| 610 | "minimax-m2.7" => Some(usd_pricing_with_write(0.06, 0.30, 1.20, 0.375)), |
| 611 | // gpt-5-codex is deprecated upstream on the ChatGPT-OAuth path |
| 612 | // (successor: gpt-5.3-codex); API usage is still billed at these rates. |
| 613 | // https://developers.openai.com/api/docs/models/gpt-5.3-codex |
| 614 | "openai/gpt-5-codex" | "gpt-5-codex" => Some(usd_only_pricing(0.125, 1.25, 10.00)), |
| 615 | "openai/gpt-5.3-codex" | "gpt-5.3-codex" => Some(usd_only_pricing(0.175, 1.75, 14.00)), |
| 616 | _ => None, |
| 617 | }; |
| 618 | if explicit.is_some() { |
| 619 | return explicit; |
| 620 | } |
| 621 | if let Some((input_usd_per_million, output_usd_per_million)) = |
| 622 | crate::model_catalog::resolved_usd_pricing(model_lower) |
| 623 | { |
| 624 | return Some(usd_only_pricing( |
| 625 | input_usd_per_million, |
| 626 | input_usd_per_million, |
| 627 | output_usd_per_million, |
| 628 | )); |
| 629 | } |
| 630 | match model_lower { |
| 631 | "moonshotai/kimi-k2.6" | "kimi-k2.6" => Some(usd_only_pricing(0.16, 0.95, 4.00)), |
| 632 | "z-ai/glm-5.1" | "glm-5.1" => Some(usd_only_pricing(0.26, 1.40, 4.40)), |
| 633 | // GLM-5 Turbo pricing per https://docs.z.ai/guides/overview/pricing |
| 634 | "z-ai/glm-5-turbo" | "glm-5-turbo" => Some(usd_only_pricing(0.24, 1.20, 4.00)), |
| 635 | // Arcee publishes no cache rate for Trinity Large Thinking, so the |
| 636 | // cache-hit rate equals the input rate (no-discount representation). |
| 637 | // https://docs.arcee.ai/get-started/pricing |
| 638 | "arcee-ai/trinity-large-thinking" | "trinity-large-thinking" => { |
| 639 | Some(usd_only_pricing(0.25, 0.25, 0.80)) |
| 640 | } |
| 641 | "openai/gpt-5.5" | "gpt-5.5" => Some(usd_only_pricing(0.50, 5.00, 30.00)), |
| 642 | // GPT-5.5 Pro does not offer a cached input discount, so the cache-hit |
| 643 | // rate equals the input rate. |
| 644 | // https://developers.openai.com/api/docs/models/gpt-5.5-pro |
| 645 | "openai/gpt-5.5-pro" | "gpt-5.5-pro" => Some(usd_only_pricing(30.00, 30.00, 180.00)), |
| 646 | "qwen/qwen3.6-flash" => Some(usd_only_pricing(0.1875, 0.1875, 1.125)), |
| 647 | "qwen/qwen3.6-35b-a3b" => Some(usd_only_pricing(0.05, 0.14, 1.00)), |
| 648 | "qwen/qwen3.6-max-preview" => Some(usd_only_pricing(1.04, 1.04, 6.24)), |
| 649 | "qwen/qwen3.6-27b" => Some(usd_only_pricing(0.15, 0.285, 2.40)), |
| 650 | "qwen/qwen3.6-plus" => Some(usd_only_pricing(0.325, 0.325, 1.95)), |
| 651 | // Cache-write is 0.40 upstream (#4318). |
| 652 | "qwen/qwen3.7-plus" => Some(usd_pricing_with_write(0.064, 0.32, 1.28, 0.40)), |
| 653 | "qwen/qwen3.7-max" => Some(usd_only_pricing(0.25, 1.25, 3.75)), |
| 654 | |
| 655 | "google/gemma-4-31b-it" => Some(usd_only_pricing(0.09, 0.12, 0.35)), |
| 656 | "google/gemma-4-26b-a4b-it" => Some(usd_only_pricing(0.06, 0.06, 0.33)), |
| 657 | "tencent/hy3-preview" => Some(usd_only_pricing(0.021, 0.063, 0.21)), |
| 658 | "nvidia/nemotron-3-ultra-550b-a55b" | "nvidia/nemotron-3-ultra" => { |
| 659 | Some(usd_only_pricing(0.10, 0.50, 2.20)) |
| 660 | } |
| 661 | _ => None, |
| 662 | } |
| 663 | } |
| 664 | |
| 665 | /// A USD row whose provider publishes input/cache-read/output rates but **no** |
| 666 | /// cache-creation rate. Cache-write tokens on such a row are unpriced, not free |
| 667 | /// and not silently charged at the input rate (#4318). |
| 668 | fn usd_only_pricing( |
| 669 | input_cache_hit_per_million: f64, |
| 670 | input_cache_miss_per_million: f64, |
| 671 | output_per_million: f64, |
| 672 | ) -> ModelPricing { |
| 673 | usd_pricing( |
| 674 | input_cache_hit_per_million, |
| 675 | input_cache_miss_per_million, |
| 676 | output_per_million, |
| 677 | CacheWritePolicy::Unpublished, |
| 678 | ) |
| 679 | } |
| 680 | |
| 681 | fn usd_pricing_with_write( |
| 682 | input_cache_hit_per_million: f64, |
| 683 | input_cache_miss_per_million: f64, |
| 684 | output_per_million: f64, |
| 685 | cache_write_per_million: f64, |
| 686 | ) -> ModelPricing { |
| 687 | usd_pricing( |
| 688 | input_cache_hit_per_million, |
| 689 | input_cache_miss_per_million, |
| 690 | output_per_million, |
| 691 | CacheWritePolicy::Rate(cache_write_per_million), |
| 692 | ) |
| 693 | } |
| 694 | |
| 695 | fn usd_pricing( |
| 696 | input_cache_hit_per_million: f64, |
| 697 | input_cache_miss_per_million: f64, |
| 698 | output_per_million: f64, |
| 699 | cache_write: CacheWritePolicy, |
| 700 | ) -> ModelPricing { |
| 701 | ModelPricing { |
| 702 | usd: CurrencyPricing { |
| 703 | input_cache_hit_per_million, |
| 704 | input_cache_miss_per_million, |
| 705 | output_per_million, |
| 706 | cache_write, |
| 707 | }, |
| 708 | cny: None, |
| 709 | } |
| 710 | } |
| 711 | |
| 712 | const MINIMAX_M3_LONG_CONTEXT_THRESHOLD: u32 = 512_000; |
| 713 | const OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD: u32 = 272_000; |
| 714 | |
| 715 | /// OpenAI applies a higher price to the full request once these models exceed |
| 716 | /// 272K input tokens. Until the pricing layer can represent request-wide tiers, |
| 717 | /// refuse to report the lower static catalog price (#4317). |
| 718 | /// <https://developers.openai.com/api/docs/models/gpt-5.4> |
| 719 | /// <https://developers.openai.com/api/docs/models/gpt-5.5> |
| 720 | /// <https://developers.openai.com/api/docs/models/gpt-5.6-sol> |
| 721 | fn direct_openai_long_context_tier_is_unpriced( |
| 722 | provider: ApiProvider, |
| 723 | model: &str, |
| 724 | input_tokens: u32, |
| 725 | ) -> bool { |
| 726 | let model_lower = model.trim().to_ascii_lowercase(); |
| 727 | let affected_model = matches!( |
| 728 | model_lower.as_str(), |
| 729 | "gpt-5.4" |
| 730 | | "gpt-5.4-pro" |
| 731 | | "gpt-5.5" |
| 732 | | "gpt-5.6" |
| 733 | | "gpt-5.6-sol" |
| 734 | | "gpt-5.6-terra" |
| 735 | | "gpt-5.6-luna" |
| 736 | ) || has_date_snapshot_suffix(&model_lower, "gpt-5.4-") |
| 737 | || has_date_snapshot_suffix(&model_lower, "gpt-5.4-pro-") |
| 738 | || has_date_snapshot_suffix(&model_lower, "gpt-5.5-"); |
| 739 | provider == ApiProvider::Openai |
| 740 | && input_tokens > OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD |
| 741 | && affected_model |
| 742 | } |
| 743 | |
| 744 | fn minimax_m3_standard_pricing(long_context: bool) -> ModelPricing { |
| 745 | if long_context { |
| 746 | usd_only_pricing(0.12, 0.60, 2.40) |
| 747 | } else { |
| 748 | usd_only_pricing(0.06, 0.30, 1.20) |
| 749 | } |
| 750 | } |
| 751 | |
| 752 | fn is_minimax_m3(model: &str) -> bool { |
| 753 | matches!( |
| 754 | model.trim().to_ascii_lowercase().as_str(), |
| 755 | "minimax-m3" | "minimax/minimax-m3" |
| 756 | ) |
| 757 | } |
| 758 | |
| 759 | fn pricing_for_model_and_usage(model: &str, usage: &Usage) -> Option<ModelPricing> { |
| 760 | if is_minimax_m3(model) { |
| 761 | return Some(minimax_m3_standard_pricing( |
| 762 | usage.input_tokens > MINIMAX_M3_LONG_CONTEXT_THRESHOLD, |
| 763 | )); |
| 764 | } |
| 765 | pricing_for_model(model) |
| 766 | } |
| 767 | |
| 768 | /// Claude Sonnet 5 pricing (<https://platform.claude.com/docs/en/about-claude/pricing>): |
| 769 | /// introductory 2.00 / 10.00 (cache-read 0.20, cache-write 2.50) through |
| 770 | /// 2026-08-31 UTC, then the standard 3.00 / 15.00 (cache-read 0.30, |
| 771 | /// cache-write 3.75). Write rates are the published 5-minute tier (#4318). |
| 772 | fn claude_sonnet_5_pricing(now: DateTime<Utc>) -> ModelPricing { |
| 773 | let intro_ends = Utc |
| 774 | .with_ymd_and_hms(2026, 9, 1, 0, 0, 0) |
| 775 | .single() |
| 776 | .expect("valid intro-pricing cutoff"); |
| 777 | if now < intro_ends { |
| 778 | usd_pricing_with_write(0.20, 2.00, 10.00, 2.50) |
| 779 | } else { |
| 780 | usd_pricing_with_write(0.30, 3.00, 15.00, 3.75) |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | /// DeepSeek publishes only cache-hit and cache-miss input rates *because* its |
| 785 | /// context cache charges nothing extra to write: a token that misses the cache |
| 786 | /// is billed once at the miss rate and is cached as a side effect. That makes |
| 787 | /// the miss rate the documented write rate, not a stand-in for a missing one. |
| 788 | fn deepseek_v4_pro_pricing() -> ModelPricing { |
| 789 | ModelPricing { |
| 790 | usd: CurrencyPricing { |
| 791 | input_cache_hit_per_million: 0.003625, |
| 792 | input_cache_miss_per_million: 0.435, |
| 793 | output_per_million: 0.87, |
| 794 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 795 | }, |
| 796 | cny: Some(CurrencyPricing { |
| 797 | input_cache_hit_per_million: 0.025, |
| 798 | input_cache_miss_per_million: 3.0, |
| 799 | output_per_million: 6.0, |
| 800 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 801 | }), |
| 802 | } |
| 803 | } |
| 804 | |
| 805 | fn deepseek_v4_flash_pricing() -> ModelPricing { |
| 806 | ModelPricing { |
| 807 | usd: CurrencyPricing { |
| 808 | input_cache_hit_per_million: 0.0028, |
| 809 | input_cache_miss_per_million: 0.14, |
| 810 | output_per_million: 0.28, |
| 811 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 812 | }, |
| 813 | cny: Some(CurrencyPricing { |
| 814 | input_cache_hit_per_million: 0.02, |
| 815 | input_cache_miss_per_million: 1.0, |
| 816 | output_per_million: 2.0, |
| 817 | cache_write: CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE), |
| 818 | }), |
| 819 | } |
| 820 | } |
| 821 | |
| 822 | /// Calculate cost from provider usage, honoring DeepSeek context-cache fields. |
| 823 | #[must_use] |
| 824 | #[cfg(test)] |
| 825 | pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64> { |
| 826 | calculate_turn_cost_estimate_from_usage(model, usage).map(|estimate| estimate.usd) |
| 827 | } |
| 828 | |
| 829 | /// Calculate cost from provider usage in both official currencies. |
| 830 | #[must_use] |
| 831 | #[cfg(test)] |
| 832 | pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Option<CostEstimate> { |
| 833 | let pricing = pricing_for_model_and_usage(model, usage)?; |
| 834 | Some(cost_estimate_with_pricing(pricing, usage)) |
| 835 | } |
| 836 | |
| 837 | /// Cost from a hand-sourced row, or `None` when the row cannot price a class |
| 838 | /// this turn actually used. |
| 839 | /// |
| 840 | /// Only cache-write can fail here: input, cache-read, and output rates are |
| 841 | /// mandatory on every hand row, while a cache-creation rate exists only where a |
| 842 | /// provider publishes one or documents that writes cost nothing extra. |
| 843 | fn cost_estimate_with_pricing_checked( |
| 844 | pricing: ModelPricing, |
| 845 | usage: &Usage, |
| 846 | ) -> Result<CostEstimate, Vec<TokenClass>> { |
| 847 | let classes = token_usage_for_pricing(usage); |
| 848 | if classes.cache_write > 0 |
| 849 | && pricing |
| 850 | .usd |
| 851 | .cache_write |
| 852 | .rate(pricing.usd.input_cache_miss_per_million) |
| 853 | .is_none() |
| 854 | { |
| 855 | return Err(vec![TokenClass::CacheWrite]); |
| 856 | } |
| 857 | Ok(CostEstimate { |
| 858 | usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), |
| 859 | cny: pricing |
| 860 | .cny |
| 861 | .map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage)) |
| 862 | .unwrap_or(0.0), |
| 863 | }) |
| 864 | } |
| 865 | |
| 866 | /// Unchecked projection for the legacy model-only test helpers, which construct |
| 867 | /// usage they have already established the row can price. |
| 868 | /// |
| 869 | /// Production paths must use [`cost_estimate_with_pricing_checked`] so an |
| 870 | /// unpublished cache-write rate fails closed instead of billing writes at the |
| 871 | /// input rate. |
| 872 | #[cfg(test)] |
| 873 | fn cost_estimate_with_pricing(pricing: ModelPricing, usage: &Usage) -> CostEstimate { |
| 874 | CostEstimate { |
| 875 | usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), |
| 876 | cny: pricing |
| 877 | .cny |
| 878 | .map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage)) |
| 879 | .unwrap_or(0.0), |
| 880 | } |
| 881 | } |
| 882 | |
| 883 | /// Calculate cost from provider/model usage when that pair identifies a single |
| 884 | /// billing surface. ChatGPT/Codex OAuth has no authoritative API dollar price, |
| 885 | /// while StepFun needs endpoint-derived PAYG-vs-Plan provenance; both stay |
| 886 | /// unpriced here rather than fabricating spend. |
| 887 | #[must_use] |
| 888 | pub fn calculate_turn_cost_estimate_for_provider( |
| 889 | provider: ApiProvider, |
| 890 | model: &str, |
| 891 | usage: &Usage, |
| 892 | ) -> Option<CostEstimate> { |
| 893 | calculate_turn_cost_estimate_for_provider_at(provider, model, usage, Utc::now()) |
| 894 | } |
| 895 | |
| 896 | /// Calculate cost only for routes that are actually money-metered. OAuth and |
| 897 | /// token-plan routes deliberately return `None` even when the underlying model |
| 898 | /// also exists behind a separately-priced public API. |
| 899 | /// |
| 900 | /// Production callers use [`audit_turn_cost_for_route`] instead: a caller that |
| 901 | /// adds to a total must also record why a turn was left out of it. |
| 902 | #[must_use] |
| 903 | #[cfg(test)] |
| 904 | pub fn calculate_turn_cost_estimate_for_route( |
| 905 | provider: ApiProvider, |
| 906 | model: &str, |
| 907 | usage: &Usage, |
| 908 | billing: crate::route_billing::BillingPresentation, |
| 909 | ) -> Option<CostEstimate> { |
| 910 | audit_turn_cost_for_route(provider, model, None, usage, Utc::now(), billing).estimate |
| 911 | } |
| 912 | |
| 913 | /// Estimate a turn when endpoint-derived billing provenance is available. |
| 914 | /// StepFun's standard API and Step Plan share provider/model text but not a |
| 915 | /// billing system, so that route fails closed unless the PAYG surface is known. |
| 916 | #[must_use] |
| 917 | #[cfg(test)] |
| 918 | pub(crate) fn calculate_turn_cost_estimate_for_billing_surface( |
| 919 | provider: ApiProvider, |
| 920 | model: &str, |
| 921 | billing_surface: Option<&str>, |
| 922 | usage: &Usage, |
| 923 | ) -> Option<CostEstimate> { |
| 924 | calculate_turn_cost_estimate_for_route_at(provider, model, billing_surface, usage, Utc::now()) |
| 925 | } |
| 926 | |
| 927 | /// Deterministic provider-aware estimate at the turn's recorded time. |
| 928 | #[must_use] |
| 929 | pub(crate) fn calculate_turn_cost_estimate_for_provider_at( |
| 930 | provider: ApiProvider, |
| 931 | model: &str, |
| 932 | usage: &Usage, |
| 933 | recorded_at: DateTime<Utc>, |
| 934 | ) -> Option<CostEstimate> { |
| 935 | audit_turn_cost_for_provider_at(provider, model, usage, recorded_at).estimate |
| 936 | } |
| 937 | |
| 938 | /// Why a route produced no cost estimate. |
| 939 | /// |
| 940 | /// Every `None` from the estimator carries one of these so `/cost`, `/cache`, |
| 941 | /// and the scorecard can say *why* a turn is missing from a total instead of |
| 942 | /// letting the total read as complete. |
| 943 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 944 | pub enum UnpricedReason { |
| 945 | /// The route is **exactly identified** as one where money is not the unit: |
| 946 | /// a named OAuth subscription, a named prepaid token plan, or a local |
| 947 | /// endpoint with no provider bill. Only this reason excuses a turn from |
| 948 | /// money coverage, and only exact evidence may produce it (#4318). |
| 949 | NotMoneyMetered, |
| 950 | /// The route may or may not meter money and CodeWhale could not establish |
| 951 | /// which. Distinct from [`Self::NotMoneyMetered`] on purpose: an unknown |
| 952 | /// basis is counted as *possibly missing spend*, never waved through as a |
| 953 | /// subscription. A cross-provider child route with no dispatch config is |
| 954 | /// the common case. |
| 955 | UnknownBillingBasis, |
| 956 | /// One provider/model pair spans several billing systems and the non-secret |
| 957 | /// endpoint provenance needed to pick one is missing. |
| 958 | AmbiguousBillingSurface, |
| 959 | /// No endpoint classification was supplied for the route at all. |
| 960 | /// |
| 961 | /// Distinct from [`Self::UnknownBillingBasis`], which means an endpoint was |
| 962 | /// classified and could not be placed. This means none was offered, so |
| 963 | /// there is no evidence the turn was served by the provider's own official |
| 964 | /// surface rather than a proxy, a gateway, or a self-hosted clone that |
| 965 | /// happens to speak the same protocol. A provider enum plus a familiar |
| 966 | /// model id is not that evidence (#4318). |
| 967 | UnestablishedEndpoint, |
| 968 | /// The turn's endpoint classified as a per-token surface, but the pricing |
| 969 | /// layer holds no rates for that specific surface (as opposed to no rates |
| 970 | /// for the model at all). |
| 971 | UnpricedBillingSurface, |
| 972 | /// The only pricing row found claims live provider provenance but is stale |
| 973 | /// or was fetched from a different endpoint, so it is not authoritative for |
| 974 | /// this turn. Never silently downgraded to "authoritative anyway". |
| 975 | UnverifiedLivePricing, |
| 976 | /// A compatibility alias whose published rate has been retired. |
| 977 | RetiredAlias, |
| 978 | /// The turn crossed a request-wide pricing tier the pricing layer cannot |
| 979 | /// represent yet (for example OpenAI's >272K long-context surcharge). |
| 980 | UnrepresentedTier, |
| 981 | /// No pricing row exists for this provider/model route. |
| 982 | NoPricingRow, |
| 983 | /// A row exists, but a token class this turn actually used has no published |
| 984 | /// price, so the estimate fails closed rather than under-reporting. |
| 985 | MissingClassPrice, |
| 986 | /// A catalog row contains a NaN, infinite, or negative rate. The whole row |
| 987 | /// is rejected at the trust boundary rather than partially billed. |
| 988 | InvalidPricingRow, |
| 989 | /// The row is denominated in a currency CodeWhale does not carry. No |
| 990 | /// conversion is invented. |
| 991 | UnsupportedCurrency, |
| 992 | /// Provider telemetry assigns more cache-hit/miss/write tokens than the |
| 993 | /// reported input total. Pricing that contradictory partition would |
| 994 | /// over-count input, so the call is retained but fails closed. |
| 995 | InconsistentUsage, |
| 996 | } |
| 997 | |
| 998 | impl UnpricedReason { |
| 999 | /// Stable, non-localized identifier for logs, JSON, and scorecards. |
| 1000 | #[must_use] |
| 1001 | pub fn label(self) -> &'static str { |
| 1002 | match self { |
| 1003 | Self::NotMoneyMetered => "not_money_metered", |
| 1004 | Self::UnknownBillingBasis => "unknown_billing_basis", |
| 1005 | Self::AmbiguousBillingSurface => "ambiguous_billing_surface", |
| 1006 | Self::UnestablishedEndpoint => "unestablished_endpoint", |
| 1007 | Self::UnpricedBillingSurface => "unpriced_billing_surface", |
| 1008 | Self::UnverifiedLivePricing => "unverified_live_pricing", |
| 1009 | Self::RetiredAlias => "retired_alias", |
| 1010 | Self::UnrepresentedTier => "unrepresented_pricing_tier", |
| 1011 | Self::NoPricingRow => "no_pricing_row", |
| 1012 | Self::MissingClassPrice => "missing_class_price", |
| 1013 | Self::InvalidPricingRow => "invalid_pricing_row", |
| 1014 | Self::UnsupportedCurrency => "unsupported_currency", |
| 1015 | Self::InconsistentUsage => "inconsistent_usage", |
| 1016 | } |
| 1017 | } |
| 1018 | |
| 1019 | /// Whether a turn with this reason belongs in the money-metered coverage |
| 1020 | /// denominator `/cost` reports against its dollar total. |
| 1021 | /// |
| 1022 | /// Only [`Self::NotMoneyMetered`] — an *exactly* identified subscription, |
| 1023 | /// token plan, or local route — is excluded. Everything else, including an |
| 1024 | /// unknown billing basis, counts as spend the total is missing, because |
| 1025 | /// treating "don't know" as "not billed" is what let unpriced turns |
| 1026 | /// disappear from a total that then read as complete (#4318). |
| 1027 | #[must_use] |
| 1028 | pub fn counts_toward_money_coverage(self) -> bool { |
| 1029 | self != Self::NotMoneyMetered |
| 1030 | } |
| 1031 | } |
| 1032 | |
| 1033 | /// A turn cost plus the provenance and completeness needed to audit it. |
| 1034 | /// |
| 1035 | /// `estimate.is_some()` and `unpriced_reason.is_none()` always agree: this type |
| 1036 | /// is produced by the same code path that computes the estimate, so an audit |
| 1037 | /// can never disagree with the number a total was built from. |
| 1038 | #[derive(Debug, Clone, PartialEq)] |
| 1039 | pub struct TurnCostAudit { |
| 1040 | /// The cost, when the route is priced for every class this turn used. |
| 1041 | pub estimate: Option<CostEstimate>, |
| 1042 | /// Where the applied (or attempted) pricing row came from. |
| 1043 | pub provenance: Option<PricingProvenance>, |
| 1044 | /// Classes this turn used that carry no published price. |
| 1045 | pub unpriced_classes: Vec<TokenClass>, |
| 1046 | /// Why the estimate is absent, when it is. |
| 1047 | pub unpriced_reason: Option<UnpricedReason>, |
| 1048 | /// Set when a live catalog row could not be verified as authoritative for |
| 1049 | /// this route. Present both when the row was *degraded* to the bundled |
| 1050 | /// snapshot (the estimate is still priced, from the bundled row) and when |
| 1051 | /// there was no fallback at all. It is the receipt for the downgrade, so a |
| 1052 | /// `provider_live` label is never claimed for an unproven row. |
| 1053 | pub live_pricing_defect: Option<LivePricingDefect>, |
| 1054 | /// Whether the estimate is authoritative in each carried currency. A zero |
| 1055 | /// amount is still priced when usage is zero; these flags therefore cannot |
| 1056 | /// be inferred from `estimate > 0`. |
| 1057 | pub usd_priced: bool, |
| 1058 | pub cny_priced: bool, |
| 1059 | } |
| 1060 | |
| 1061 | impl TurnCostAudit { |
| 1062 | fn priced( |
| 1063 | estimate: CostEstimate, |
| 1064 | provenance: PricingProvenance, |
| 1065 | usd_priced: bool, |
| 1066 | cny_priced: bool, |
| 1067 | ) -> Self { |
| 1068 | Self { |
| 1069 | estimate: Some(estimate), |
| 1070 | provenance: Some(provenance), |
| 1071 | unpriced_classes: Vec::new(), |
| 1072 | unpriced_reason: None, |
| 1073 | live_pricing_defect: None, |
| 1074 | usd_priced, |
| 1075 | cny_priced, |
| 1076 | } |
| 1077 | } |
| 1078 | |
| 1079 | pub(crate) fn unpriced(reason: UnpricedReason) -> Self { |
| 1080 | Self { |
| 1081 | estimate: None, |
| 1082 | provenance: None, |
| 1083 | unpriced_classes: Vec::new(), |
| 1084 | unpriced_reason: Some(reason), |
| 1085 | live_pricing_defect: None, |
| 1086 | usd_priced: false, |
| 1087 | cny_priced: false, |
| 1088 | } |
| 1089 | } |
| 1090 | |
| 1091 | fn missing_classes(provenance: PricingProvenance, classes: Vec<TokenClass>) -> Self { |
| 1092 | Self { |
| 1093 | estimate: None, |
| 1094 | provenance: Some(provenance), |
| 1095 | unpriced_classes: classes, |
| 1096 | unpriced_reason: Some(UnpricedReason::MissingClassPrice), |
| 1097 | live_pricing_defect: None, |
| 1098 | usd_priced: false, |
| 1099 | cny_priced: false, |
| 1100 | } |
| 1101 | } |
| 1102 | |
| 1103 | fn unverified_live(defect: LivePricingDefect) -> Self { |
| 1104 | Self { |
| 1105 | estimate: None, |
| 1106 | // Deliberately not `ProviderLive`: an unverified row must never be |
| 1107 | // labelled with authoritative live provenance. |
| 1108 | provenance: Some(PricingProvenance::Unknown), |
| 1109 | unpriced_classes: Vec::new(), |
| 1110 | unpriced_reason: Some(UnpricedReason::UnverifiedLivePricing), |
| 1111 | live_pricing_defect: Some(defect), |
| 1112 | usd_priced: false, |
| 1113 | cny_priced: false, |
| 1114 | } |
| 1115 | } |
| 1116 | |
| 1117 | /// Attach a live-pricing downgrade receipt to an otherwise complete audit. |
| 1118 | fn with_live_defect(mut self, defect: Option<LivePricingDefect>) -> Self { |
| 1119 | if let Some(defect) = defect { |
| 1120 | self.live_pricing_defect = Some(defect); |
| 1121 | } |
| 1122 | self |
| 1123 | } |
| 1124 | |
| 1125 | /// Whether this turn contributed an authoritative number to a total. |
| 1126 | #[must_use] |
| 1127 | #[cfg(test)] |
| 1128 | pub fn is_priced(&self) -> bool { |
| 1129 | self.estimate.is_some() |
| 1130 | } |
| 1131 | |
| 1132 | /// Whether the estimate is authoritative in the requested display |
| 1133 | /// currency. Exact zero remains priced; the boolean provenance flags are |
| 1134 | /// intentionally not inferred from the numeric amount. |
| 1135 | #[must_use] |
| 1136 | pub fn is_priced_in(&self, currency: CostCurrency) -> bool { |
| 1137 | self.estimate.is_some() |
| 1138 | && match currency { |
| 1139 | CostCurrency::Usd => self.usd_priced, |
| 1140 | CostCurrency::Cny => self.cny_priced, |
| 1141 | } |
| 1142 | } |
| 1143 | |
| 1144 | /// Whether this turn belongs in the money-metered coverage denominator. |
| 1145 | /// |
| 1146 | /// Priced turns always do. Unpriced ones do unless the route was *exactly* |
| 1147 | /// identified as non-metered. |
| 1148 | #[must_use] |
| 1149 | pub fn counts_toward_money_coverage(&self) -> bool { |
| 1150 | self.unpriced_reason |
| 1151 | .is_none_or(UnpricedReason::counts_toward_money_coverage) |
| 1152 | } |
| 1153 | } |
| 1154 | |
| 1155 | /// Audit a turn on a provider/model route, without knowing which endpoint served |
| 1156 | /// it. A live catalog row cannot be *confirmed* for an unknown endpoint, so this |
| 1157 | /// path degrades to the bundled published snapshot; use |
| 1158 | /// [`audit_turn_cost_for_provider_on_endpoint_at`] when the base URL is known. |
| 1159 | #[must_use] |
| 1160 | pub(crate) fn audit_turn_cost_for_provider_at( |
| 1161 | provider: ApiProvider, |
| 1162 | model: &str, |
| 1163 | usage: &Usage, |
| 1164 | recorded_at: DateTime<Utc>, |
| 1165 | ) -> TurnCostAudit { |
| 1166 | audit_turn_cost_for_provider_on_endpoint_at(provider, model, None, usage, recorded_at) |
| 1167 | } |
| 1168 | |
| 1169 | /// Audit a turn's cost on a provider/model route at its recorded time. |
| 1170 | /// |
| 1171 | /// This is the single implementation; `calculate_turn_cost_estimate_*` are thin |
| 1172 | /// projections of it, so no caller can build a total from one rule set while |
| 1173 | /// reporting completeness from another. |
| 1174 | #[must_use] |
| 1175 | pub(crate) fn audit_turn_cost_for_provider_on_endpoint_at( |
| 1176 | provider: ApiProvider, |
| 1177 | model: &str, |
| 1178 | endpoint_fingerprint: Option<&str>, |
| 1179 | usage: &Usage, |
| 1180 | recorded_at: DateTime<Utc>, |
| 1181 | ) -> TurnCostAudit { |
| 1182 | if !usage_cache_partition_is_consistent(usage) { |
| 1183 | return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage); |
| 1184 | } |
| 1185 | if provider == ApiProvider::OpenaiCodex { |
| 1186 | return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); |
| 1187 | } |
| 1188 | if route_requires_billing_surface(provider, model) { |
| 1189 | return TurnCostAudit::unpriced(UnpricedReason::AmbiguousBillingSurface); |
| 1190 | } |
| 1191 | let normalized_model = model.trim(); |
| 1192 | let model_lower = normalized_model.to_ascii_lowercase(); |
| 1193 | let direct_deepseek = matches!( |
| 1194 | provider, |
| 1195 | ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic |
| 1196 | ); |
| 1197 | let Some(canonical_model) = canonical_model_id_for_provider(provider, normalized_model) else { |
| 1198 | return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); |
| 1199 | }; |
| 1200 | let catalog_model = if direct_deepseek |
| 1201 | && matches!(model_lower.as_str(), "deepseek-chat" | "deepseek-reasoner") |
| 1202 | { |
| 1203 | let Ok(retirement) = DateTime::parse_from_rfc3339(DEEPSEEK_ALIAS_RETIREMENT_UTC) else { |
| 1204 | return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); |
| 1205 | }; |
| 1206 | if recorded_at >= retirement.with_timezone(&Utc) { |
| 1207 | return TurnCostAudit::unpriced(UnpricedReason::RetiredAlias); |
| 1208 | } |
| 1209 | DEEPSEEK_ALIAS_REPLACEMENT.to_string() |
| 1210 | } else { |
| 1211 | canonical_model |
| 1212 | }; |
| 1213 | |
| 1214 | if direct_openai_long_context_tier_is_unpriced(provider, &catalog_model, usage.input_tokens) { |
| 1215 | return TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier); |
| 1216 | } |
| 1217 | |
| 1218 | // MiniMax-M3 doubles its published rates above 512K total input. The |
| 1219 | // catalog row is necessarily static, so retain the usage-aware first-party |
| 1220 | // table for both direct wire protocols after provider/model provenance has |
| 1221 | // been canonicalized. |
| 1222 | if matches!( |
| 1223 | provider, |
| 1224 | ApiProvider::Minimax | ApiProvider::MinimaxAnthropic |
| 1225 | ) && catalog_model.eq_ignore_ascii_case("minimax-m3") |
| 1226 | { |
| 1227 | return hand_priced_audit(pricing_for_model_and_usage(&catalog_model, usage), usage); |
| 1228 | } |
| 1229 | |
| 1230 | // Direct DeepSeek pricing carries an authoritative CNY row, and Sonnet 5 |
| 1231 | // has a recorded-time introductory window that a static catalog row cannot |
| 1232 | // represent. These exact first-party routes intentionally override the |
| 1233 | // catalog; no other provider/model text match is allowed to do so. |
| 1234 | if direct_deepseek |
| 1235 | || (provider == ApiProvider::Anthropic |
| 1236 | && catalog_model.eq_ignore_ascii_case("claude-sonnet-5")) |
| 1237 | { |
| 1238 | return hand_priced_audit( |
| 1239 | provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at), |
| 1240 | usage, |
| 1241 | ); |
| 1242 | } |
| 1243 | |
| 1244 | let classes = token_usage_for_pricing(usage); |
| 1245 | // A live catalog row is only authoritative when it is fresh *and* was |
| 1246 | // fetched from the endpoint this turn was served on. When it is not, degrade |
| 1247 | // to the bundled published snapshot and receipt the defect; only if there is |
| 1248 | // no bundled row at all does the turn fail closed (#4318). |
| 1249 | let mut live_defect = None; |
| 1250 | let offering = match verified_catalog_offering( |
| 1251 | provider, |
| 1252 | &catalog_model, |
| 1253 | endpoint_fingerprint, |
| 1254 | recorded_at, |
| 1255 | ) { |
| 1256 | VerifiedOffering::Usable(offering) => Some(offering), |
| 1257 | VerifiedOffering::DegradedToBundled { offering, defect } => { |
| 1258 | live_defect = Some(defect); |
| 1259 | Some(offering) |
| 1260 | } |
| 1261 | VerifiedOffering::Unusable(defect) => { |
| 1262 | live_defect = Some(defect); |
| 1263 | None |
| 1264 | } |
| 1265 | VerifiedOffering::Absent => None, |
| 1266 | }; |
| 1267 | |
| 1268 | if let Some(audit) = offering.as_ref().and_then(invalid_catalog_pricing_audit) { |
| 1269 | return audit.with_live_defect(live_defect); |
| 1270 | } |
| 1271 | |
| 1272 | if let Some(offering) = offering.as_ref() |
| 1273 | && let Some(pricing) = |
| 1274 | effective_offering_pricing(provider, &catalog_model, offering, &classes) |
| 1275 | { |
| 1276 | if let Some(estimate) = |
| 1277 | catalog_cost_estimate_for_route(provider, &catalog_model, offering, usage) |
| 1278 | { |
| 1279 | let (usd_priced, cny_priced) = match pricing.currency { |
| 1280 | Currency::Usd => (true, false), |
| 1281 | Currency::Cny => (false, true), |
| 1282 | Currency::Other(_) => (false, false), |
| 1283 | }; |
| 1284 | return TurnCostAudit::priced( |
| 1285 | estimate, |
| 1286 | pricing.provenance.clone(), |
| 1287 | usd_priced, |
| 1288 | cny_priced, |
| 1289 | ) |
| 1290 | .with_live_defect(live_defect); |
| 1291 | } |
| 1292 | let classes = pricing.unpriced_used_classes(&classes); |
| 1293 | if classes.is_empty() { |
| 1294 | // Every used class is priced, so the only way the estimate failed |
| 1295 | // is a currency CodeWhale does not carry. Never convert. |
| 1296 | return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency) |
| 1297 | .with_live_defect(live_defect); |
| 1298 | } |
| 1299 | return TurnCostAudit::missing_classes(pricing.provenance, classes) |
| 1300 | .with_live_defect(live_defect); |
| 1301 | } |
| 1302 | |
| 1303 | // A few first-party rows predate or intentionally omit a Models.dev entry |
| 1304 | // (for example OpenAI API `gpt-5-codex` and MiniMax `minimax-m2.7`). |
| 1305 | // Preserve only an explicit provider-owned allowlist here; |
| 1306 | // a costless foreign/catalog route must remain unpriced. |
| 1307 | let hand_row = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at); |
| 1308 | |
| 1309 | // An unverifiable live row with no bundled fallback and no hand row is a |
| 1310 | // route CodeWhale cannot price truthfully. Say which, rather than reporting |
| 1311 | // the unverified rate or a bare "no pricing row". |
| 1312 | match (live_defect, hand_row) { |
| 1313 | (Some(defect), None) => TurnCostAudit::unverified_live(defect), |
| 1314 | (defect, hand_row) => hand_priced_audit(hand_row, usage).with_live_defect(defect), |
| 1315 | } |
| 1316 | } |
| 1317 | |
| 1318 | /// Convert malformed catalog numerics into an explicit runtime audit reason. |
| 1319 | /// Keeping this distinct from the ordinary `None` projection prevents a bad |
| 1320 | /// published row from becoming indistinguishable from an absent price. |
| 1321 | fn invalid_catalog_pricing_audit( |
| 1322 | offering: &codewhale_config::catalog::CatalogOffering, |
| 1323 | ) -> Option<TurnCostAudit> { |
| 1324 | offering |
| 1325 | .cost |
| 1326 | .as_ref() |
| 1327 | .is_some_and(|cost| !codewhale_config::pricing::catalog_cost_is_valid(cost)) |
| 1328 | .then(|| TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow)) |
| 1329 | } |
| 1330 | |
| 1331 | /// Outcome of checking a catalog row's pricing provenance against the route. |
| 1332 | enum VerifiedOffering { |
| 1333 | /// The row is authoritative as-is (bundled, user override, or a live row |
| 1334 | /// proven fresh and endpoint-matched). |
| 1335 | Usable(codewhale_config::catalog::CatalogOffering), |
| 1336 | /// The live row could not be verified, so the bundled published row is used |
| 1337 | /// instead. The defect is retained as the receipt for why. |
| 1338 | DegradedToBundled { |
| 1339 | offering: codewhale_config::catalog::CatalogOffering, |
| 1340 | defect: LivePricingDefect, |
| 1341 | }, |
| 1342 | /// The live row could not be verified and no bundled row exists. |
| 1343 | Unusable(LivePricingDefect), |
| 1344 | /// No catalog row for this provider/model at all. |
| 1345 | Absent, |
| 1346 | } |
| 1347 | |
| 1348 | /// Resolve the catalog row to price against, refusing to treat an unverifiable |
| 1349 | /// live row as authoritative. |
| 1350 | /// |
| 1351 | /// `endpoint_fingerprint` is the non-secret SHA-256 digest of the base URL the turn |
| 1352 | /// was actually served on (see [`codewhale_config::catalog::base_url_fingerprint`]). |
| 1353 | /// Callers that do not know the endpoint pass `None`, which cannot *confirm* a |
| 1354 | /// live row — so those callers degrade to the bundled snapshot rather than |
| 1355 | /// billing against a rate whose endpoint scope is unproven. |
| 1356 | fn verified_catalog_offering( |
| 1357 | provider: ApiProvider, |
| 1358 | catalog_model: &str, |
| 1359 | endpoint_fingerprint: Option<&str>, |
| 1360 | recorded_at: DateTime<Utc>, |
| 1361 | ) -> VerifiedOffering { |
| 1362 | let Some(offering) = crate::provider_lake::catalog_offering_for_model(provider, catalog_model) |
| 1363 | else { |
| 1364 | return VerifiedOffering::Absent; |
| 1365 | }; |
| 1366 | let Some(pricing) = OfferingPricing::from_catalog_offering(&offering) else { |
| 1367 | // No priced row to verify; downstream treats this as unpriced. |
| 1368 | return VerifiedOffering::Usable(offering); |
| 1369 | }; |
| 1370 | // `recorded_at` is the turn's own clock, which is the right reference for |
| 1371 | // "was this price current when the turn happened". |
| 1372 | let now_unix = u64::try_from(recorded_at.timestamp()).ok(); |
| 1373 | let Some(defect) = |
| 1374 | pricing.live_pricing_defect(endpoint_fingerprint, now_unix, LIVE_PRICING_MAX_AGE_SECS) |
| 1375 | else { |
| 1376 | return VerifiedOffering::Usable(offering); |
| 1377 | }; |
| 1378 | match crate::provider_lake::bundled_catalog_offering_for_model(provider, catalog_model) { |
| 1379 | Some(bundled) => VerifiedOffering::DegradedToBundled { |
| 1380 | offering: bundled, |
| 1381 | defect, |
| 1382 | }, |
| 1383 | None => VerifiedOffering::Unusable(defect), |
| 1384 | } |
| 1385 | } |
| 1386 | |
| 1387 | /// Project a hand-sourced provider row into an audit. |
| 1388 | /// |
| 1389 | /// A hand row always publishes input, cache-read, and output rates. Cache-write |
| 1390 | /// is the one class that can be genuinely absent: only providers that publish a |
| 1391 | /// write premium, or document that cache creation carries no separate charge, |
| 1392 | /// can price it. A turn that wrote to cache on a row with neither fact fails |
| 1393 | /// closed and names the class, rather than being billed at the input rate on the |
| 1394 | /// strength of an assumption (#4318). |
| 1395 | fn hand_priced_audit(pricing: Option<ModelPricing>, usage: &Usage) -> TurnCostAudit { |
| 1396 | let Some(pricing) = pricing else { |
| 1397 | return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); |
| 1398 | }; |
| 1399 | let has_cny = pricing.cny.is_some(); |
| 1400 | match cost_estimate_with_pricing_checked(pricing, usage) { |
| 1401 | Ok(estimate) => { |
| 1402 | TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, has_cny) |
| 1403 | } |
| 1404 | Err(classes) => TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes), |
| 1405 | } |
| 1406 | } |
| 1407 | |
| 1408 | /// Recorded-time variant with explicit billing-surface provenance. |
| 1409 | #[must_use] |
| 1410 | #[cfg(test)] |
| 1411 | pub(crate) fn calculate_turn_cost_estimate_for_route_at( |
| 1412 | provider: ApiProvider, |
| 1413 | model: &str, |
| 1414 | billing_surface: Option<&str>, |
| 1415 | usage: &Usage, |
| 1416 | recorded_at: DateTime<Utc>, |
| 1417 | ) -> Option<CostEstimate> { |
| 1418 | audit_turn_cost_for_route_at(provider, model, billing_surface, usage, recorded_at).estimate |
| 1419 | } |
| 1420 | |
| 1421 | /// Audit a turn's cost with endpoint-derived billing provenance. |
| 1422 | #[must_use] |
| 1423 | pub(crate) fn audit_turn_cost_for_route_at( |
| 1424 | provider: ApiProvider, |
| 1425 | model: &str, |
| 1426 | billing_surface: Option<&str>, |
| 1427 | usage: &Usage, |
| 1428 | recorded_at: DateTime<Utc>, |
| 1429 | ) -> TurnCostAudit { |
| 1430 | audit_turn_cost_for_route_on_endpoint_at( |
| 1431 | provider, |
| 1432 | model, |
| 1433 | billing_surface, |
| 1434 | None, |
| 1435 | usage, |
| 1436 | recorded_at, |
| 1437 | ) |
| 1438 | } |
| 1439 | |
| 1440 | /// Audit a turn's cost with both endpoint-derived billing provenance and the |
| 1441 | /// endpoint fingerprint needed to verify live catalog pricing. |
| 1442 | #[must_use] |
| 1443 | pub(crate) fn audit_turn_cost_for_route_on_endpoint_at( |
| 1444 | provider: ApiProvider, |
| 1445 | model: &str, |
| 1446 | billing_surface: Option<&str>, |
| 1447 | endpoint_fingerprint: Option<&str>, |
| 1448 | usage: &Usage, |
| 1449 | recorded_at: DateTime<Utc>, |
| 1450 | ) -> TurnCostAudit { |
| 1451 | // An explicitly recorded surface is evidence. Exact non-metered surfaces |
| 1452 | // override provider guesses; an explicit unknown/unrecognized surface must |
| 1453 | // fail closed and may never fall through to a familiar model's hand row. |
| 1454 | match endpoint_metering_for_billing_surface(billing_surface) { |
| 1455 | EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill => { |
| 1456 | return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); |
| 1457 | } |
| 1458 | EndpointMetering::Unknown if billing_surface.is_some() => { |
| 1459 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 1460 | } |
| 1461 | EndpointMetering::Unknown | EndpointMetering::Money => {} |
| 1462 | } |
| 1463 | if !usage_cache_partition_is_consistent(usage) { |
| 1464 | return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage); |
| 1465 | } |
| 1466 | if provider == ApiProvider::Stepfun { |
| 1467 | return match pricing_for_billing_surface(provider, model, billing_surface) { |
| 1468 | // StepFun's hand row publishes no cache-write rate, so a turn that |
| 1469 | // wrote to cache fails closed here as well. |
| 1470 | Some(pricing) => match cost_estimate_with_pricing_checked(pricing, usage) { |
| 1471 | Ok(estimate) => { |
| 1472 | TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, false) |
| 1473 | } |
| 1474 | Err(classes) => { |
| 1475 | TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes) |
| 1476 | } |
| 1477 | }, |
| 1478 | // The surface classified as per-token but no rates exist for it, or |
| 1479 | // no surface was established at all. |
| 1480 | None => TurnCostAudit::unpriced(match billing_surface { |
| 1481 | Some(_) => UnpricedReason::UnpricedBillingSurface, |
| 1482 | None => UnpricedReason::AmbiguousBillingSurface, |
| 1483 | }), |
| 1484 | }; |
| 1485 | } |
| 1486 | if model.trim().eq_ignore_ascii_case(DEFAULT_STEPFUN_MODEL) { |
| 1487 | return TurnCostAudit::unpriced(UnpricedReason::AmbiguousBillingSurface); |
| 1488 | } |
| 1489 | // This is the *route* audit: the caller is asserting it knows which |
| 1490 | // endpoint served the turn. With no classification at all, nothing |
| 1491 | // distinguishes the provider's own official surface from a proxy, a |
| 1492 | // gateway, or a self-hosted clone speaking the same protocol — a provider |
| 1493 | // enum plus a familiar model id is not evidence of an official endpoint. |
| 1494 | // So the turn prices as unknown rather than at official rates. |
| 1495 | // |
| 1496 | // Callers that genuinely hold only a provider and a model use |
| 1497 | // `audit_turn_cost_for_provider_*`, which says so in its name and carries |
| 1498 | // its own weaker claim. |
| 1499 | if billing_surface.is_none() { |
| 1500 | return TurnCostAudit::unpriced(UnpricedReason::UnestablishedEndpoint); |
| 1501 | } |
| 1502 | audit_turn_cost_for_provider_on_endpoint_at( |
| 1503 | provider, |
| 1504 | model, |
| 1505 | endpoint_fingerprint, |
| 1506 | usage, |
| 1507 | recorded_at, |
| 1508 | ) |
| 1509 | } |
| 1510 | |
| 1511 | /// Audit a turn against the route's billing presentation. |
| 1512 | /// |
| 1513 | /// The three non-metered presentations are **not** interchangeable, and |
| 1514 | /// collapsing them was the bug (#4318): |
| 1515 | /// |
| 1516 | /// - [`BillingPresentation::Subscription`] and [`BillingPresentation::Local`] |
| 1517 | /// are exact evidence that money is the wrong unit, so those turns are |
| 1518 | /// `NotMoneyMetered` and drop out of the coverage denominator. |
| 1519 | /// - [`BillingPresentation::Unknown`] is *not* such evidence. It means CodeWhale |
| 1520 | /// could not establish the basis, so the turn is `UnknownBillingBasis`: still |
| 1521 | /// unpriced, but counted as spend the total may be missing. |
| 1522 | /// |
| 1523 | /// [`BillingPresentation::Subscription`]: crate::route_billing::BillingPresentation::Subscription |
| 1524 | /// [`BillingPresentation::Local`]: crate::route_billing::BillingPresentation::Local |
| 1525 | /// [`BillingPresentation::Unknown`]: crate::route_billing::BillingPresentation::Unknown |
| 1526 | #[must_use] |
| 1527 | #[cfg(test)] |
| 1528 | pub fn audit_turn_cost_for_route( |
| 1529 | provider: ApiProvider, |
| 1530 | model: &str, |
| 1531 | billing_surface: Option<&str>, |
| 1532 | usage: &Usage, |
| 1533 | recorded_at: DateTime<Utc>, |
| 1534 | billing: crate::route_billing::BillingPresentation, |
| 1535 | ) -> TurnCostAudit { |
| 1536 | audit_turn_cost_for_route_on_endpoint( |
| 1537 | provider, |
| 1538 | model, |
| 1539 | billing_surface, |
| 1540 | None, |
| 1541 | usage, |
| 1542 | recorded_at, |
| 1543 | billing, |
| 1544 | ) |
| 1545 | } |
| 1546 | |
| 1547 | /// [`audit_turn_cost_for_route`] plus the endpoint fingerprint that lets live |
| 1548 | /// catalog pricing be verified for this exact route. |
| 1549 | #[must_use] |
| 1550 | #[cfg(test)] |
| 1551 | pub fn audit_turn_cost_for_route_on_endpoint( |
| 1552 | provider: ApiProvider, |
| 1553 | model: &str, |
| 1554 | billing_surface: Option<&str>, |
| 1555 | endpoint_fingerprint: Option<&str>, |
| 1556 | usage: &Usage, |
| 1557 | recorded_at: DateTime<Utc>, |
| 1558 | billing: crate::route_billing::BillingPresentation, |
| 1559 | ) -> TurnCostAudit { |
| 1560 | use crate::route_billing::BillingPresentation; |
| 1561 | match billing { |
| 1562 | BillingPresentation::Subscription(_) | BillingPresentation::Local => { |
| 1563 | return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); |
| 1564 | } |
| 1565 | BillingPresentation::Unknown => { |
| 1566 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 1567 | } |
| 1568 | BillingPresentation::Metered => {} |
| 1569 | } |
| 1570 | // A metered presentation still has to survive the endpoint classification: |
| 1571 | // an endpoint that classifies as an exact subscription surface overrides a |
| 1572 | // metered guess, and an unclassifiable one fails closed. |
| 1573 | match endpoint_metering_for_billing_surface(billing_surface) { |
| 1574 | EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill => { |
| 1575 | return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); |
| 1576 | } |
| 1577 | // `Unknown` here is the common, benign case of a caller that has no |
| 1578 | // endpoint to classify; the provider/model path below still decides. |
| 1579 | EndpointMetering::Unknown if billing_surface.is_some() => { |
| 1580 | return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); |
| 1581 | } |
| 1582 | EndpointMetering::Unknown | EndpointMetering::Money => {} |
| 1583 | } |
| 1584 | audit_turn_cost_for_route_on_endpoint_at( |
| 1585 | provider, |
| 1586 | model, |
| 1587 | billing_surface, |
| 1588 | endpoint_fingerprint, |
| 1589 | usage, |
| 1590 | recorded_at, |
| 1591 | ) |
| 1592 | } |
| 1593 | |
| 1594 | fn provider_owned_hand_pricing_at( |
| 1595 | provider: ApiProvider, |
| 1596 | model: &str, |
| 1597 | recorded_at: DateTime<Utc>, |
| 1598 | ) -> Option<ModelPricing> { |
| 1599 | let model_lower = model.trim().to_ascii_lowercase(); |
| 1600 | let provider_owns_row = match provider { |
| 1601 | ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => { |
| 1602 | matches!( |
| 1603 | model_lower.as_str(), |
| 1604 | "deepseek-v4-pro" | "deepseek-v4-flash" |
| 1605 | ) |
| 1606 | } |
| 1607 | ApiProvider::Openai => matches!( |
| 1608 | model_lower.as_str(), |
| 1609 | "gpt-5-codex" |
| 1610 | | "gpt-5.3-codex" |
| 1611 | | "gpt-5.5" |
| 1612 | | "gpt-5.5-pro" |
| 1613 | | "gpt-5.6" |
| 1614 | | "gpt-5.6-sol" |
| 1615 | | "gpt-5.6-terra" |
| 1616 | | "gpt-5.6-luna" |
| 1617 | ), |
| 1618 | ApiProvider::Anthropic => matches!( |
| 1619 | model_lower.as_str(), |
| 1620 | "claude-opus-4-8" |
| 1621 | | "claude-sonnet-4-6" |
| 1622 | | "claude-haiku-4-5" |
| 1623 | | "claude-fable-5" |
| 1624 | | "claude-sonnet-5" |
| 1625 | ), |
| 1626 | // GLM-5.3 is deliberately absent: this allowlist declares that Z.ai |
| 1627 | // owns a *hand-written price row* for the model, and no GLM-5.3 rate |
| 1628 | // has been published. An absent price is honest; an owned-but-empty |
| 1629 | // row is not. See `glm_5_3_has_no_hardcoded_price` below. |
| 1630 | ApiProvider::Zai => matches!(model_lower.as_str(), "glm-5.1" | "glm-5.2" | "glm-5-turbo"), |
| 1631 | ApiProvider::Moonshot => { |
| 1632 | matches!(model_lower.as_str(), "kimi-k2.6" | "kimi-k2.7-code") |
| 1633 | } |
| 1634 | ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => { |
| 1635 | matches!(model_lower.as_str(), "minimax-m3" | "minimax-m2.7") |
| 1636 | } |
| 1637 | ApiProvider::Arcee => model_lower == "trinity-large-thinking", |
| 1638 | // 1.2 and its contributor tier own hand-written rows the same way 1.1 |
| 1639 | // does (see `pricing_for_model_at`). 1.2 is now `DEFAULT_META_MODEL`, |
| 1640 | // so omitting them here left the default Meta route without a |
| 1641 | // provider-owned fallback row. |
| 1642 | ApiProvider::Meta => matches!( |
| 1643 | model_lower.as_str(), |
| 1644 | "muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" |
| 1645 | ), |
| 1646 | _ => false, |
| 1647 | }; |
| 1648 | provider_owns_row |
| 1649 | .then(|| pricing_for_model_at(&model_lower, recorded_at)) |
| 1650 | .flatten() |
| 1651 | } |
| 1652 | |
| 1653 | /// The offering's pricing row as it actually applies to this route. |
| 1654 | /// |
| 1655 | /// Two documented first-party routes publish no separate cache rate *because* |
| 1656 | /// cache tokens are billed at the plain input rate; that substitution happens |
| 1657 | /// here so cost estimation and the unpriced-class audit read the same row. |
| 1658 | fn effective_offering_pricing( |
| 1659 | provider: ApiProvider, |
| 1660 | model: &str, |
| 1661 | offering: &codewhale_config::catalog::CatalogOffering, |
| 1662 | classes: &TokenUsage, |
| 1663 | ) -> Option<OfferingPricing> { |
| 1664 | let mut pricing = OfferingPricing::from_catalog_offering(offering)?; |
| 1665 | let model_lower = model.trim().to_ascii_lowercase(); |
| 1666 | let cache_uses_input_rate = matches!( |
| 1667 | (provider, model_lower.as_str()), |
| 1668 | (ApiProvider::Openai, "gpt-5.5-pro") | (ApiProvider::Arcee, "trinity-large-thinking") |
| 1669 | ); |
| 1670 | if cache_uses_input_rate { |
| 1671 | if classes.cache_read > 0 && pricing.cache_read_per_million.is_none() { |
| 1672 | pricing.cache_read_per_million = pricing.input_per_million; |
| 1673 | } |
| 1674 | if classes.cache_write > 0 && pricing.cache_write_per_million.is_none() { |
| 1675 | pricing.cache_write_per_million = pricing.input_per_million; |
| 1676 | } |
| 1677 | } |
| 1678 | Some(pricing) |
| 1679 | } |
| 1680 | |
| 1681 | /// Estimate usage only from the exact provider offering. Missing prices for a |
| 1682 | /// used token class fail closed, except on the two documented first-party |
| 1683 | /// routes where cache tokens are explicitly billed at the input rate. |
| 1684 | fn catalog_cost_estimate_for_route( |
| 1685 | provider: ApiProvider, |
| 1686 | model: &str, |
| 1687 | offering: &codewhale_config::catalog::CatalogOffering, |
| 1688 | usage: &Usage, |
| 1689 | ) -> Option<CostEstimate> { |
| 1690 | let classes = token_usage_for_pricing(usage); |
| 1691 | let pricing = effective_offering_pricing(provider, model, offering, &classes)?; |
| 1692 | |
| 1693 | let amount = pricing.estimate_cost(&classes)?; |
| 1694 | match pricing.currency { |
| 1695 | Currency::Usd => Some(CostEstimate::usd_only(amount)), |
| 1696 | Currency::Cny => Some(CostEstimate { |
| 1697 | usd: 0.0, |
| 1698 | cny: amount, |
| 1699 | }), |
| 1700 | Currency::Other(_) => None, |
| 1701 | } |
| 1702 | } |
| 1703 | |
| 1704 | /// Project provider-normalized turn usage into canonical billable token |
| 1705 | /// classes for the shared config pricing layer (#2961 / #4318). |
| 1706 | /// |
| 1707 | /// `Usage::prompt_cache_miss_tokens` is billed as ordinary non-cached input. |
| 1708 | /// `Usage::prompt_cache_write_tokens` maps to `TokenUsage::cache_write` so |
| 1709 | /// providers that publish a write premium (Anthropic 1.25x–2x) are not |
| 1710 | /// undercounted. |
| 1711 | /// |
| 1712 | /// `Usage::reasoning_tokens` is deliberately **not** added to the billable |
| 1713 | /// output. Every provider CodeWhale normalizes reports reasoning as a *subset* |
| 1714 | /// of the completion count it already bills — OpenAI Responses nests |
| 1715 | /// `reasoning_tokens` under `output_tokens_details` while `output_tokens` is |
| 1716 | /// the total, and Chat Completions nests it under `completion_tokens_details` |
| 1717 | /// while `completion_tokens` is the total. Adding it charged reasoning turns |
| 1718 | /// twice for the same tokens (up to 2x on reasoning-heavy turns). It stays on |
| 1719 | /// `Usage` as informational telemetry (`/usage`, hooks, sub-agent metadata). |
| 1720 | #[must_use] |
| 1721 | pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage { |
| 1722 | // `input_tokens` is the authoritative total. Even malformed provider |
| 1723 | // telemetry must never produce token classes whose sum exceeds it. The |
| 1724 | // audit path rejects contradictory partitions; this bounded projection |
| 1725 | // keeps token-only displays truthful while retaining deterministic class |
| 1726 | // priority (read, write, then miss/unclassified input). |
| 1727 | let total_input = usage.input_tokens; |
| 1728 | let cache_read = usage.prompt_cache_hit_tokens.unwrap_or(0).min(total_input); |
| 1729 | let after_read = total_input.saturating_sub(cache_read); |
| 1730 | let cache_write = usage.prompt_cache_write_tokens.unwrap_or(0).min(after_read); |
| 1731 | let after_write = after_read.saturating_sub(cache_write); |
| 1732 | let non_cached_reported = usage |
| 1733 | .prompt_cache_miss_tokens |
| 1734 | .unwrap_or(after_write) |
| 1735 | .min(after_write); |
| 1736 | let uncategorized_input = after_write.saturating_sub(non_cached_reported); |
| 1737 | let input = non_cached_reported.saturating_add(uncategorized_input); |
| 1738 | // Reasoning tokens are already inside `output_tokens`; see the doc comment. |
| 1739 | let output = usage.output_tokens; |
| 1740 | |
| 1741 | TokenUsage { |
| 1742 | input: u64::from(input), |
| 1743 | output: u64::from(output), |
| 1744 | cache_read: u64::from(cache_read), |
| 1745 | cache_write: u64::from(cache_write), |
| 1746 | } |
| 1747 | } |
| 1748 | |
| 1749 | fn usage_cache_partition_is_consistent(usage: &Usage) -> bool { |
| 1750 | let reported = u64::from(usage.prompt_cache_hit_tokens.unwrap_or(0)) |
| 1751 | + u64::from(usage.prompt_cache_miss_tokens.unwrap_or(0)) |
| 1752 | + u64::from(usage.prompt_cache_write_tokens.unwrap_or(0)); |
| 1753 | reported <= u64::from(usage.input_tokens) |
| 1754 | } |
| 1755 | |
| 1756 | fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: &Usage) -> f64 { |
| 1757 | let usage = token_usage_for_pricing(usage); |
| 1758 | let hit_cost = (usage.cache_read as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million; |
| 1759 | let miss_cost = (usage.input as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million; |
| 1760 | // An unpublished write policy is only reachable here for usage with zero |
| 1761 | // cache-write tokens; `cost_estimate_with_pricing_checked` rejects the rest |
| 1762 | // before any money is computed. |
| 1763 | let write_rate = pricing |
| 1764 | .cache_write |
| 1765 | .rate(pricing.input_cache_miss_per_million) |
| 1766 | .unwrap_or(0.0); |
| 1767 | let write_cost = (usage.cache_write as f64 / 1_000_000.0) * write_rate; |
| 1768 | let output_cost = (usage.output as f64 / 1_000_000.0) * pricing.output_per_million; |
| 1769 | hit_cost + miss_cost + write_cost + output_cost |
| 1770 | } |
| 1771 | |
| 1772 | /// Estimate how much money was saved by serving `cache_hit_tokens` from the |
| 1773 | /// prefix cache instead of billing them at the cache-miss rate. Returns `None` |
| 1774 | /// when the model's pricing is unknown or the number of cache-hit tokens is |
| 1775 | /// zero (nothing to save). |
| 1776 | #[must_use] |
| 1777 | #[cfg(test)] |
| 1778 | pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option<CostEstimate> { |
| 1779 | if cache_hit_tokens == 0 { |
| 1780 | return None; |
| 1781 | } |
| 1782 | // M3's cache-read savings depend on whether total input crosses 512k; |
| 1783 | // this helper receives only cache-hit tokens, so an estimate would guess |
| 1784 | // the tier. The full turn-cost path has total input and remains precise. |
| 1785 | if is_minimax_m3(model) { |
| 1786 | return None; |
| 1787 | } |
| 1788 | let pricing = pricing_for_model(model)?; |
| 1789 | let tokens = cache_hit_tokens as f64 / 1_000_000.0; |
| 1790 | Some(CostEstimate { |
| 1791 | usd: tokens |
| 1792 | * (pricing.usd.input_cache_miss_per_million - pricing.usd.input_cache_hit_per_million), |
| 1793 | cny: pricing |
| 1794 | .cny |
| 1795 | .map(|pricing| { |
| 1796 | tokens |
| 1797 | * (pricing.input_cache_miss_per_million - pricing.input_cache_hit_per_million) |
| 1798 | }) |
| 1799 | .unwrap_or(0.0), |
| 1800 | }) |
| 1801 | } |
| 1802 | |
| 1803 | /// Format a cost amount for compact display in the chosen currency. |
| 1804 | #[must_use] |
| 1805 | pub fn format_cost_amount(cost: f64, currency: CostCurrency) -> String { |
| 1806 | let symbol = currency.symbol(); |
| 1807 | if cost == 0.0 { |
| 1808 | format!("{symbol}0.00") |
| 1809 | } else if cost > 0.0 && cost < 0.0001 { |
| 1810 | format!("<{symbol}0.0001") |
| 1811 | } else if cost < 0.01 { |
| 1812 | format!("{symbol}{cost:.4}") |
| 1813 | } else { |
| 1814 | format!("{symbol}{cost:.2}") |
| 1815 | } |
| 1816 | } |
| 1817 | |
| 1818 | /// Format a cost amount for detailed reports in the chosen currency. |
| 1819 | #[must_use] |
| 1820 | pub fn format_cost_amount_precise(cost: f64, currency: CostCurrency) -> String { |
| 1821 | let symbol = currency.symbol(); |
| 1822 | if cost == 0.0 { |
| 1823 | format!("{symbol}0.0000") |
| 1824 | } else if cost > 0.0 && cost < 0.0001 { |
| 1825 | format!("<{symbol}0.0001") |
| 1826 | } else { |
| 1827 | format!("{symbol}{cost:.4}") |
| 1828 | } |
| 1829 | } |
| 1830 | |
| 1831 | /// Format a dual-currency estimate using the selected display currency. |
| 1832 | #[must_use] |
| 1833 | pub fn format_cost_estimate(estimate: CostEstimate, currency: CostCurrency) -> String { |
| 1834 | format_cost_amount(estimate.amount(currency), currency) |
| 1835 | } |
| 1836 | |
| 1837 | #[cfg(test)] |
| 1838 | mod tests { |
| 1839 | use super::*; |
| 1840 | use std::collections::BTreeMap; |
| 1841 | |
| 1842 | #[test] |
| 1843 | fn malformed_catalog_row_has_an_explicit_runtime_reason() { |
| 1844 | let offering = codewhale_config::catalog::CatalogOffering { |
| 1845 | provider: "openrouter".to_string(), |
| 1846 | wire_model_id: "openai/gpt-5.5".to_string(), |
| 1847 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 1848 | input: Some(f64::NAN), |
| 1849 | output: Some(30.0), |
| 1850 | cache_read: Some(0.05), |
| 1851 | cache_write: None, |
| 1852 | }), |
| 1853 | ..Default::default() |
| 1854 | }; |
| 1855 | |
| 1856 | let audit = invalid_catalog_pricing_audit(&offering) |
| 1857 | .expect("malformed row must become an explicit failed-closed audit"); |
| 1858 | assert!(!audit.is_priced()); |
| 1859 | assert_eq!( |
| 1860 | audit.unpriced_reason, |
| 1861 | Some(UnpricedReason::InvalidPricingRow) |
| 1862 | ); |
| 1863 | assert_eq!( |
| 1864 | audit.unpriced_reason.unwrap().label(), |
| 1865 | "invalid_pricing_row" |
| 1866 | ); |
| 1867 | } |
| 1868 | |
| 1869 | /// A hand-sourced row with **no published** cache-write rate must fail closed |
| 1870 | /// for a turn that wrote to cache, while a row whose provider *documents* |
| 1871 | /// that writes carry no separate charge prices it at the input rate. |
| 1872 | /// |
| 1873 | /// Both used to be `None` and both silently billed writes at the input rate, |
| 1874 | /// which invented a price for the first case (#4318). |
| 1875 | #[test] |
| 1876 | fn unpublished_cache_write_fails_closed_but_documented_same_rate_prices() { |
| 1877 | let write_heavy = Usage { |
| 1878 | input_tokens: 1_000_000, |
| 1879 | output_tokens: 0, |
| 1880 | prompt_cache_hit_tokens: Some(0), |
| 1881 | prompt_cache_miss_tokens: Some(900_000), |
| 1882 | prompt_cache_write_tokens: Some(100_000), |
| 1883 | ..Usage::default() |
| 1884 | }; |
| 1885 | let now = Utc::now(); |
| 1886 | |
| 1887 | // DeepSeek documents that a cache miss is billed once and cached for |
| 1888 | // free, so the miss rate *is* the published write rate. The policy |
| 1889 | // carries the documentation receipt rather than being an assumption. |
| 1890 | let deepseek = deepseek_v4_flash_pricing(); |
| 1891 | assert_eq!( |
| 1892 | deepseek.usd.cache_write, |
| 1893 | CacheWritePolicy::DocumentedAsInputRate(DEEPSEEK_CACHE_WRITE_IS_FREE) |
| 1894 | ); |
| 1895 | let priced = audit_turn_cost_for_provider_at( |
| 1896 | ApiProvider::Deepseek, |
| 1897 | "deepseek-v4-flash", |
| 1898 | &write_heavy, |
| 1899 | now, |
| 1900 | ); |
| 1901 | assert!(priced.is_priced(), "{priced:?}"); |
| 1902 | // 900k miss + 100k write, both at the 0.14/M miss rate. |
| 1903 | let expected = (0.9 + 0.1) * 0.14; |
| 1904 | assert!( |
| 1905 | (priced.estimate.expect("priced").usd - expected).abs() < 1e-12, |
| 1906 | "{priced:?}" |
| 1907 | ); |
| 1908 | |
| 1909 | // StepFun's hand row publishes input/cache-read/output only. A write |
| 1910 | // turn is unpriced and names the class instead of borrowing the input |
| 1911 | // rate. |
| 1912 | let stepfun = pricing_for_billing_surface( |
| 1913 | ApiProvider::Stepfun, |
| 1914 | DEFAULT_STEPFUN_MODEL, |
| 1915 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 1916 | ) |
| 1917 | .expect("StepFun PAYG row"); |
| 1918 | assert_eq!(stepfun.usd.cache_write, CacheWritePolicy::Unpublished); |
| 1919 | let failed = audit_turn_cost_for_route_at( |
| 1920 | ApiProvider::Stepfun, |
| 1921 | DEFAULT_STEPFUN_MODEL, |
| 1922 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 1923 | &write_heavy, |
| 1924 | now, |
| 1925 | ); |
| 1926 | assert!(!failed.is_priced(), "{failed:?}"); |
| 1927 | assert_eq!( |
| 1928 | failed.unpriced_reason, |
| 1929 | Some(UnpricedReason::MissingClassPrice) |
| 1930 | ); |
| 1931 | assert_eq!(failed.unpriced_classes, vec![TokenClass::CacheWrite]); |
| 1932 | |
| 1933 | // The same route with no cache-write tokens prices normally, proving the |
| 1934 | // gap is class-scoped rather than route-scoped. |
| 1935 | let no_write = Usage { |
| 1936 | prompt_cache_write_tokens: None, |
| 1937 | ..write_heavy.clone() |
| 1938 | }; |
| 1939 | assert!( |
| 1940 | audit_turn_cost_for_route_at( |
| 1941 | ApiProvider::Stepfun, |
| 1942 | DEFAULT_STEPFUN_MODEL, |
| 1943 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 1944 | &no_write, |
| 1945 | now, |
| 1946 | ) |
| 1947 | .is_priced() |
| 1948 | ); |
| 1949 | } |
| 1950 | |
| 1951 | /// Every exact billing surface a route can carry must be understood, and |
| 1952 | /// anything unrecognized must fail closed as unknown rather than defaulting |
| 1953 | /// into per-token dollars (#4318). |
| 1954 | #[test] |
| 1955 | fn endpoint_classification_covers_every_exact_billing_surface() { |
| 1956 | for (provider, base_url, expected_surface, expected_metering) in [ |
| 1957 | ( |
| 1958 | ApiProvider::Zai, |
| 1959 | "https://api.z.ai/api/coding/paas/v4", |
| 1960 | ZAI_CODING_PLAN_BILLING_SURFACE, |
| 1961 | EndpointMetering::ExactSubscription, |
| 1962 | ), |
| 1963 | ( |
| 1964 | ApiProvider::Zai, |
| 1965 | "https://api.z.ai/api/paas/v4", |
| 1966 | ZAI_PAYG_BILLING_SURFACE, |
| 1967 | EndpointMetering::Money, |
| 1968 | ), |
| 1969 | ( |
| 1970 | ApiProvider::Moonshot, |
| 1971 | crate::config::DEFAULT_KIMI_CODE_BASE_URL, |
| 1972 | MOONSHOT_KIMI_CODE_BILLING_SURFACE, |
| 1973 | EndpointMetering::ExactSubscription, |
| 1974 | ), |
| 1975 | ( |
| 1976 | ApiProvider::Moonshot, |
| 1977 | "https://api.moonshot.ai/v1", |
| 1978 | MOONSHOT_PAYG_BILLING_SURFACE, |
| 1979 | EndpointMetering::Money, |
| 1980 | ), |
| 1981 | ( |
| 1982 | ApiProvider::XiaomiMimo, |
| 1983 | crate::config::XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, |
| 1984 | XIAOMI_PAYG_BILLING_SURFACE, |
| 1985 | EndpointMetering::Money, |
| 1986 | ), |
| 1987 | ( |
| 1988 | ApiProvider::XiaomiMimo, |
| 1989 | crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL, |
| 1990 | XIAOMI_TOKEN_PLAN_BILLING_SURFACE, |
| 1991 | EndpointMetering::ExactSubscription, |
| 1992 | ), |
| 1993 | ( |
| 1994 | ApiProvider::Stepfun, |
| 1995 | "https://api.stepfun.ai/step_plan/v1", |
| 1996 | STEPFUN_PLAN_BILLING_SURFACE, |
| 1997 | EndpointMetering::ExactSubscription, |
| 1998 | ), |
| 1999 | ( |
| 2000 | ApiProvider::Stepfun, |
| 2001 | "https://api.stepfun.ai/v1", |
| 2002 | STEPFUN_PAYG_BILLING_SURFACE, |
| 2003 | EndpointMetering::Money, |
| 2004 | ), |
| 2005 | ( |
| 2006 | ApiProvider::Anthropic, |
| 2007 | "https://api.anthropic.com/v1", |
| 2008 | FIRST_PARTY_PAYG_BILLING_SURFACE, |
| 2009 | EndpointMetering::Money, |
| 2010 | ), |
| 2011 | ( |
| 2012 | ApiProvider::Openrouter, |
| 2013 | "https://openrouter.ai/api/v1", |
| 2014 | AGGREGATOR_BILLING_SURFACE, |
| 2015 | EndpointMetering::Money, |
| 2016 | ), |
| 2017 | ] { |
| 2018 | let surface = billing_surface_for_route(provider, Some(base_url)); |
| 2019 | assert_eq!(surface, Some(expected_surface), "{provider:?} {base_url}"); |
| 2020 | assert_eq!( |
| 2021 | endpoint_metering_for_billing_surface(surface), |
| 2022 | expected_metering, |
| 2023 | "{provider:?} {base_url}" |
| 2024 | ); |
| 2025 | } |
| 2026 | |
| 2027 | // Provider-intrinsic surfaces need no URL at all. |
| 2028 | for (provider, expected_surface, expected_metering) in [ |
| 2029 | ( |
| 2030 | ApiProvider::OpenaiCodex, |
| 2031 | OAUTH_SUBSCRIPTION_BILLING_SURFACE, |
| 2032 | EndpointMetering::ExactSubscription, |
| 2033 | ), |
| 2034 | ( |
| 2035 | ApiProvider::OpencodeGo, |
| 2036 | OAUTH_SUBSCRIPTION_BILLING_SURFACE, |
| 2037 | EndpointMetering::ExactSubscription, |
| 2038 | ), |
| 2039 | ( |
| 2040 | ApiProvider::Ollama, |
| 2041 | LOCAL_BILLING_SURFACE, |
| 2042 | EndpointMetering::LocalNoBill, |
| 2043 | ), |
| 2044 | ( |
| 2045 | ApiProvider::Vllm, |
| 2046 | LOCAL_BILLING_SURFACE, |
| 2047 | EndpointMetering::LocalNoBill, |
| 2048 | ), |
| 2049 | // A named custom endpoint's pay mode is config, not URL shape. |
| 2050 | ( |
| 2051 | ApiProvider::Custom, |
| 2052 | UNCLASSIFIED_BILLING_SURFACE, |
| 2053 | EndpointMetering::Unknown, |
| 2054 | ), |
| 2055 | ] { |
| 2056 | let surface = billing_surface_for_route(provider, None); |
| 2057 | assert_eq!(surface, Some(expected_surface), "{provider:?}"); |
| 2058 | assert_eq!( |
| 2059 | endpoint_metering_for_billing_surface(surface), |
| 2060 | expected_metering, |
| 2061 | "{provider:?}" |
| 2062 | ); |
| 2063 | } |
| 2064 | |
| 2065 | // An unrecognized surface id — including one a newer build might write — |
| 2066 | // is never guessed into a known bucket. |
| 2067 | for unknown in [ |
| 2068 | Some("some-future-surface"), |
| 2069 | Some(""), |
| 2070 | Some(" "), |
| 2071 | Some(UNCLASSIFIED_BILLING_SURFACE), |
| 2072 | None, |
| 2073 | ] { |
| 2074 | assert_eq!( |
| 2075 | endpoint_metering_for_billing_surface(unknown), |
| 2076 | EndpointMetering::Unknown, |
| 2077 | "{unknown:?}" |
| 2078 | ); |
| 2079 | } |
| 2080 | } |
| 2081 | |
| 2082 | /// An endpoint that was never established is not the official endpoint. |
| 2083 | /// |
| 2084 | /// The route audit used to fall through to the provider/model catalog when |
| 2085 | /// no billing surface was supplied, which meant a persisted or recorded row |
| 2086 | /// carrying nothing but `provider: "openai"` and a familiar model id got |
| 2087 | /// billed at OpenAI's published first-party rates — even though the turn |
| 2088 | /// could equally have been served by a proxy, a gateway, or a self-hosted |
| 2089 | /// clone speaking the same protocol. Absence of endpoint evidence is not |
| 2090 | /// evidence of the official endpoint. |
| 2091 | #[test] |
| 2092 | fn an_unestablished_endpoint_is_never_priced_as_the_official_one() { |
| 2093 | let usage = Usage { |
| 2094 | input_tokens: 10_000, |
| 2095 | output_tokens: 1_000, |
| 2096 | ..Usage::default() |
| 2097 | }; |
| 2098 | let now = Utc::now(); |
| 2099 | for (provider, model) in [ |
| 2100 | (ApiProvider::Openai, "gpt-5.5"), |
| 2101 | (ApiProvider::Anthropic, "claude-haiku-4-5"), |
| 2102 | (ApiProvider::Deepseek, "deepseek-v4-flash"), |
| 2103 | (ApiProvider::Openrouter, "openai/gpt-5.5"), |
| 2104 | (ApiProvider::Moonshot, "kimi-k2.7-code"), |
| 2105 | ] { |
| 2106 | let audit = audit_turn_cost_for_route_at(provider, model, None, &usage, now); |
| 2107 | assert_eq!( |
| 2108 | audit.unpriced_reason, |
| 2109 | Some(UnpricedReason::UnestablishedEndpoint), |
| 2110 | "{provider:?}/{model}: {audit:?}" |
| 2111 | ); |
| 2112 | assert!(!audit.is_priced(), "{provider:?}/{model}: {audit:?}"); |
| 2113 | assert_eq!(audit.estimate, None, "{provider:?}/{model}"); |
| 2114 | // An unknown route is still possibly-spent money, so it stays in |
| 2115 | // the coverage denominator rather than being excused like an OAuth |
| 2116 | // or local route. |
| 2117 | assert!( |
| 2118 | audit.counts_toward_money_coverage(), |
| 2119 | "{provider:?}/{model}: an unknown route must not leave money coverage" |
| 2120 | ); |
| 2121 | |
| 2122 | // The same route with its endpoint actually classified prices |
| 2123 | // normally: this is a fail-closed rule, not a refusal to price. |
| 2124 | // (OpenRouter is excluded here only because its aggregator surface |
| 2125 | // carries no bundled rate at all, which is a different gap.) |
| 2126 | if provider == ApiProvider::Openrouter { |
| 2127 | continue; |
| 2128 | } |
| 2129 | let classified = audit_turn_cost_for_route_at( |
| 2130 | provider, |
| 2131 | model, |
| 2132 | billing_surface_for_route(provider, Some(provider.default_base_url())), |
| 2133 | &usage, |
| 2134 | now, |
| 2135 | ); |
| 2136 | assert!( |
| 2137 | classified.is_priced(), |
| 2138 | "{provider:?}/{model} must price on its own official endpoint: {classified:?}" |
| 2139 | ); |
| 2140 | } |
| 2141 | |
| 2142 | // The distinction is preserved end to end: "no endpoint offered" and |
| 2143 | // "endpoint offered but unplaceable" are different findings, and |
| 2144 | // neither is a price. |
| 2145 | let unplaceable = audit_turn_cost_for_route_at( |
| 2146 | ApiProvider::Openai, |
| 2147 | "gpt-5.5", |
| 2148 | billing_surface_for_route(ApiProvider::Openai, Some("https://proxy.example/v1")), |
| 2149 | &usage, |
| 2150 | now, |
| 2151 | ); |
| 2152 | assert_eq!( |
| 2153 | unplaceable.unpriced_reason, |
| 2154 | Some(UnpricedReason::UnknownBillingBasis) |
| 2155 | ); |
| 2156 | } |
| 2157 | |
| 2158 | #[test] |
| 2159 | fn builtin_provider_names_do_not_price_unofficial_proxy_endpoints() { |
| 2160 | let usage = Usage { |
| 2161 | input_tokens: 10_000, |
| 2162 | output_tokens: 1_000, |
| 2163 | ..Usage::default() |
| 2164 | }; |
| 2165 | for (provider, model) in [ |
| 2166 | (ApiProvider::Deepseek, "deepseek-v4-flash"), |
| 2167 | (ApiProvider::Openai, "gpt-5.5"), |
| 2168 | (ApiProvider::Anthropic, "claude-haiku-4-5"), |
| 2169 | (ApiProvider::Openrouter, "openai/gpt-5.5"), |
| 2170 | ] { |
| 2171 | let surface = billing_surface_for_route(provider, Some("https://proxy.example/v1")); |
| 2172 | assert_eq!(surface, Some(UNCLASSIFIED_BILLING_SURFACE), "{provider:?}"); |
| 2173 | let audit = audit_turn_cost_for_route_at(provider, model, surface, &usage, Utc::now()); |
| 2174 | assert_eq!( |
| 2175 | audit.unpriced_reason, |
| 2176 | Some(UnpricedReason::UnknownBillingBasis), |
| 2177 | "{provider:?}: {audit:?}" |
| 2178 | ); |
| 2179 | assert!(!audit.is_priced(), "{provider:?}: {audit:?}"); |
| 2180 | } |
| 2181 | |
| 2182 | assert_eq!( |
| 2183 | billing_surface_for_route( |
| 2184 | ApiProvider::Moonshot, |
| 2185 | Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL) |
| 2186 | ), |
| 2187 | Some(MOONSHOT_KIMI_CODE_BILLING_SURFACE) |
| 2188 | ); |
| 2189 | for (provider, endpoint) in [ |
| 2190 | (ApiProvider::Minimax, "https://api.minimax.io/v1"), |
| 2191 | ( |
| 2192 | ApiProvider::MinimaxAnthropic, |
| 2193 | "https://api.minimax.io/anthropic", |
| 2194 | ), |
| 2195 | (ApiProvider::Minimax, "https://api.minimax.io/v1/token-plan"), |
| 2196 | ( |
| 2197 | ApiProvider::XiaomiMimo, |
| 2198 | "https://token-plan-proxy.example/v1", |
| 2199 | ), |
| 2200 | ( |
| 2201 | ApiProvider::Zai, |
| 2202 | "https://api.z.ai/api/coding/something-else", |
| 2203 | ), |
| 2204 | ] { |
| 2205 | assert_eq!( |
| 2206 | billing_surface_for_route(provider, Some(endpoint)), |
| 2207 | Some(UNCLASSIFIED_BILLING_SURFACE), |
| 2208 | "{provider:?} {endpoint}" |
| 2209 | ); |
| 2210 | } |
| 2211 | } |
| 2212 | |
| 2213 | /// A route classified as an exact subscription surface is not money-metered |
| 2214 | /// even when the provider-level presentation guessed "metered", and it must |
| 2215 | /// never reach a per-token rate. |
| 2216 | #[test] |
| 2217 | fn exact_plan_surface_overrides_a_metered_presentation() { |
| 2218 | let usage = Usage { |
| 2219 | input_tokens: 100_000, |
| 2220 | output_tokens: 10_000, |
| 2221 | ..Usage::default() |
| 2222 | }; |
| 2223 | let audit = audit_turn_cost_for_route( |
| 2224 | ApiProvider::Zai, |
| 2225 | "glm-5.2", |
| 2226 | Some(ZAI_CODING_PLAN_BILLING_SURFACE), |
| 2227 | &usage, |
| 2228 | Utc::now(), |
| 2229 | crate::route_billing::BillingPresentation::Metered, |
| 2230 | ); |
| 2231 | assert!(!audit.is_priced(), "{audit:?}"); |
| 2232 | assert_eq!(audit.unpriced_reason, Some(UnpricedReason::NotMoneyMetered)); |
| 2233 | assert!(!audit.counts_toward_money_coverage()); |
| 2234 | |
| 2235 | // The same model on the per-token surface is money-metered, so it stays |
| 2236 | // in the coverage denominator whether or not a price is found. |
| 2237 | let payg = audit_turn_cost_for_route( |
| 2238 | ApiProvider::Zai, |
| 2239 | "glm-5.2", |
| 2240 | Some(ZAI_PAYG_BILLING_SURFACE), |
| 2241 | &usage, |
| 2242 | Utc::now(), |
| 2243 | crate::route_billing::BillingPresentation::Metered, |
| 2244 | ); |
| 2245 | assert!(payg.counts_toward_money_coverage(), "{payg:?}"); |
| 2246 | } |
| 2247 | |
| 2248 | /// An unknown billing basis is *not* a subscription. It stays unpriced and |
| 2249 | /// stays inside the money-coverage denominator, so its spend is reported as |
| 2250 | /// missing rather than excused (#4318). |
| 2251 | #[test] |
| 2252 | fn unknown_billing_basis_is_not_excused_as_not_money_metered() { |
| 2253 | let usage = Usage { |
| 2254 | input_tokens: 10_000, |
| 2255 | output_tokens: 1_000, |
| 2256 | ..Usage::default() |
| 2257 | }; |
| 2258 | let unknown = audit_turn_cost_for_route( |
| 2259 | ApiProvider::Anthropic, |
| 2260 | "claude-haiku-4-5", |
| 2261 | None, |
| 2262 | &usage, |
| 2263 | Utc::now(), |
| 2264 | crate::route_billing::BillingPresentation::Unknown, |
| 2265 | ); |
| 2266 | assert!(!unknown.is_priced()); |
| 2267 | assert_eq!( |
| 2268 | unknown.unpriced_reason, |
| 2269 | Some(UnpricedReason::UnknownBillingBasis) |
| 2270 | ); |
| 2271 | assert!(unknown.counts_toward_money_coverage()); |
| 2272 | |
| 2273 | // Local and subscription presentations are exact, so they *are* excused. |
| 2274 | for billing in [ |
| 2275 | crate::route_billing::BillingPresentation::Local, |
| 2276 | crate::route_billing::BillingPresentation::Subscription("plan"), |
| 2277 | ] { |
| 2278 | let audit = audit_turn_cost_for_route( |
| 2279 | ApiProvider::Anthropic, |
| 2280 | "claude-haiku-4-5", |
| 2281 | None, |
| 2282 | &usage, |
| 2283 | Utc::now(), |
| 2284 | billing, |
| 2285 | ); |
| 2286 | assert_eq!(audit.unpriced_reason, Some(UnpricedReason::NotMoneyMetered)); |
| 2287 | assert!(!audit.counts_toward_money_coverage()); |
| 2288 | } |
| 2289 | } |
| 2290 | |
| 2291 | #[test] |
| 2292 | fn audit_names_why_a_turn_is_missing_from_a_total() { |
| 2293 | let write_heavy = Usage { |
| 2294 | input_tokens: 1_000_000, |
| 2295 | output_tokens: 100_000, |
| 2296 | prompt_cache_hit_tokens: Some(200_000), |
| 2297 | prompt_cache_write_tokens: Some(100_000), |
| 2298 | ..Usage::default() |
| 2299 | }; |
| 2300 | |
| 2301 | // Anthropic publishes a cache-write rate: fully priced, provenance kept. |
| 2302 | let priced = audit_turn_cost_for_provider_at( |
| 2303 | ApiProvider::Anthropic, |
| 2304 | "claude-haiku-4-5", |
| 2305 | &write_heavy, |
| 2306 | Utc::now(), |
| 2307 | ); |
| 2308 | assert!(priced.is_priced()); |
| 2309 | assert_eq!(priced.unpriced_reason, None); |
| 2310 | assert!(priced.unpriced_classes.is_empty()); |
| 2311 | assert!(priced.provenance.is_some()); |
| 2312 | |
| 2313 | // Moonshot does not: the turn fails closed and names the class. |
| 2314 | let missing = audit_turn_cost_for_provider_at( |
| 2315 | ApiProvider::Moonshot, |
| 2316 | "kimi-k2.7-code", |
| 2317 | &write_heavy, |
| 2318 | Utc::now(), |
| 2319 | ); |
| 2320 | assert!(!missing.is_priced()); |
| 2321 | assert_eq!( |
| 2322 | missing.unpriced_reason, |
| 2323 | Some(UnpricedReason::MissingClassPrice) |
| 2324 | ); |
| 2325 | assert_eq!(missing.unpriced_classes, vec![TokenClass::CacheWrite]); |
| 2326 | // Dropping the write tokens makes the very same route priceable, which |
| 2327 | // proves the gap is class-scoped rather than route-scoped. |
| 2328 | let no_write = Usage { |
| 2329 | prompt_cache_write_tokens: None, |
| 2330 | ..write_heavy.clone() |
| 2331 | }; |
| 2332 | assert!( |
| 2333 | audit_turn_cost_for_provider_at( |
| 2334 | ApiProvider::Moonshot, |
| 2335 | "kimi-k2.7-code", |
| 2336 | &no_write, |
| 2337 | Utc::now(), |
| 2338 | ) |
| 2339 | .is_priced() |
| 2340 | ); |
| 2341 | |
| 2342 | // Subscription/OAuth and ambiguous-surface routes report their own |
| 2343 | // reasons rather than an absent price. |
| 2344 | assert_eq!( |
| 2345 | audit_turn_cost_for_provider_at( |
| 2346 | ApiProvider::OpenaiCodex, |
| 2347 | "gpt-5.5", |
| 2348 | &write_heavy, |
| 2349 | Utc::now(), |
| 2350 | ) |
| 2351 | .unpriced_reason, |
| 2352 | Some(UnpricedReason::NotMoneyMetered) |
| 2353 | ); |
| 2354 | assert_eq!( |
| 2355 | audit_turn_cost_for_route_at( |
| 2356 | ApiProvider::Stepfun, |
| 2357 | DEFAULT_STEPFUN_MODEL, |
| 2358 | None, |
| 2359 | &write_heavy, |
| 2360 | Utc::now(), |
| 2361 | ) |
| 2362 | .unpriced_reason, |
| 2363 | Some(UnpricedReason::AmbiguousBillingSurface) |
| 2364 | ); |
| 2365 | assert_eq!( |
| 2366 | audit_turn_cost_for_provider_at( |
| 2367 | ApiProvider::Openai, |
| 2368 | "gpt-5.5", |
| 2369 | &Usage { |
| 2370 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 2371 | ..Usage::default() |
| 2372 | }, |
| 2373 | Utc::now(), |
| 2374 | ) |
| 2375 | .unpriced_reason, |
| 2376 | Some(UnpricedReason::UnrepresentedTier) |
| 2377 | ); |
| 2378 | } |
| 2379 | |
| 2380 | /// The audit and the estimator are the same computation, so every route |
| 2381 | /// must agree on whether it produced a number. |
| 2382 | #[test] |
| 2383 | fn audit_and_estimate_never_disagree() { |
| 2384 | let usage = Usage { |
| 2385 | input_tokens: 10_000, |
| 2386 | output_tokens: 1_000, |
| 2387 | prompt_cache_hit_tokens: Some(2_000), |
| 2388 | prompt_cache_write_tokens: Some(1_000), |
| 2389 | ..Usage::default() |
| 2390 | }; |
| 2391 | let now = Utc::now(); |
| 2392 | for (provider, model) in [ |
| 2393 | (ApiProvider::Anthropic, "claude-haiku-4-5"), |
| 2394 | (ApiProvider::Anthropic, "claude-sonnet-5"), |
| 2395 | (ApiProvider::Moonshot, "kimi-k2.7-code"), |
| 2396 | (ApiProvider::Openai, "gpt-5.5"), |
| 2397 | (ApiProvider::OpenaiCodex, "gpt-5.5"), |
| 2398 | (ApiProvider::Deepseek, "deepseek-v4-pro"), |
| 2399 | (ApiProvider::Ollama, "gpt-5.5"), |
| 2400 | (ApiProvider::Stepfun, DEFAULT_STEPFUN_MODEL), |
| 2401 | ] { |
| 2402 | let audit = audit_turn_cost_for_route_at(provider, model, None, &usage, now); |
| 2403 | let estimate = |
| 2404 | calculate_turn_cost_estimate_for_route_at(provider, model, None, &usage, now); |
| 2405 | assert_eq!(audit.estimate, estimate, "{provider:?}/{model}"); |
| 2406 | assert_eq!( |
| 2407 | audit.is_priced(), |
| 2408 | audit.unpriced_reason.is_none(), |
| 2409 | "{provider:?}/{model}" |
| 2410 | ); |
| 2411 | } |
| 2412 | } |
| 2413 | |
| 2414 | #[test] |
| 2415 | fn nvidia_nim_deepseek_model_does_not_use_deepseek_platform_pricing() { |
| 2416 | assert!(!has_pricing_for_model("deepseek-ai/deepseek-v4-pro")); |
| 2417 | } |
| 2418 | |
| 2419 | #[test] |
| 2420 | fn stepfun_billing_surface_keeps_payg_separate_from_step_plan() { |
| 2421 | for base_url in [ |
| 2422 | "https://api.stepfun.ai", |
| 2423 | "https://api.stepfun.ai/", |
| 2424 | "https://api.stepfun.ai/v1", |
| 2425 | "https://API.STEPFUN.AI/v1/", |
| 2426 | ] { |
| 2427 | assert_eq!( |
| 2428 | billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)), |
| 2429 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2430 | "{base_url}" |
| 2431 | ); |
| 2432 | } |
| 2433 | for base_url in [ |
| 2434 | "https://api.stepfun.ai/step_plan", |
| 2435 | "https://api.stepfun.ai/step_plan/v1/", |
| 2436 | "https://api.stepfun.com/step_plan/v1", |
| 2437 | ] { |
| 2438 | assert_eq!( |
| 2439 | billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)), |
| 2440 | Some(STEPFUN_PLAN_BILLING_SURFACE), |
| 2441 | "{base_url}" |
| 2442 | ); |
| 2443 | } |
| 2444 | // Endpoints CodeWhale cannot place now classify *positively* as |
| 2445 | // unclassified rather than returning `None`. Both fail closed |
| 2446 | // identically, but "we looked and could not place this" is a different |
| 2447 | // fact from "no endpoint was supplied", and the audit reports it as |
| 2448 | // such (#4318). |
| 2449 | for base_url in [ |
| 2450 | "http://api.stepfun.ai/v1", |
| 2451 | "https://token@api.stepfun.ai/v1", |
| 2452 | "https://api.stepfun.ai/v1?account=other", |
| 2453 | "https://api.stepfun.ai/STEP_PLAN/v1", |
| 2454 | "https://stepfun.example/v1", |
| 2455 | ] { |
| 2456 | assert_eq!( |
| 2457 | billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)), |
| 2458 | Some(UNCLASSIFIED_BILLING_SURFACE), |
| 2459 | "{base_url}" |
| 2460 | ); |
| 2461 | assert_eq!( |
| 2462 | endpoint_metering_for_billing_surface(Some(UNCLASSIFIED_BILLING_SURFACE)), |
| 2463 | EndpointMetering::Unknown |
| 2464 | ); |
| 2465 | } |
| 2466 | // A StepFun URL paired with the OpenRouter protocol is a foreign custom |
| 2467 | // endpoint, not proof of either provider's billing surface. |
| 2468 | assert_eq!( |
| 2469 | billing_surface_for_route(ApiProvider::Openrouter, Some(DEFAULT_STEPFUN_BASE_URL)), |
| 2470 | Some(UNCLASSIFIED_BILLING_SURFACE) |
| 2471 | ); |
| 2472 | // No endpoint at all stays `None`. |
| 2473 | assert_eq!( |
| 2474 | billing_surface_for_route(ApiProvider::Stepfun, None), |
| 2475 | None, |
| 2476 | "an absent endpoint is not a classification" |
| 2477 | ); |
| 2478 | |
| 2479 | let usage = Usage { |
| 2480 | input_tokens: 1_000_000, |
| 2481 | output_tokens: 500_000, |
| 2482 | prompt_cache_hit_tokens: Some(250_000), |
| 2483 | ..Default::default() |
| 2484 | }; |
| 2485 | let payg = calculate_turn_cost_estimate_for_billing_surface( |
| 2486 | ApiProvider::Stepfun, |
| 2487 | DEFAULT_STEPFUN_MODEL, |
| 2488 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2489 | &usage, |
| 2490 | ) |
| 2491 | .expect("standard StepFun API has an authoritative token price"); |
| 2492 | assert!((payg.usd - 0.735).abs() < 1e-12); |
| 2493 | assert_eq!(payg.cny, 0.0); |
| 2494 | |
| 2495 | // Provider/model-only legacy callers cannot distinguish PAYG from Step |
| 2496 | // Plan and must not add either route to spend or savings totals. |
| 2497 | assert!( |
| 2498 | calculate_turn_cost_estimate_for_provider( |
| 2499 | ApiProvider::Stepfun, |
| 2500 | DEFAULT_STEPFUN_MODEL, |
| 2501 | &usage, |
| 2502 | ) |
| 2503 | .is_none() |
| 2504 | ); |
| 2505 | assert!( |
| 2506 | calculate_turn_cost_estimate_for_provider_at( |
| 2507 | ApiProvider::Stepfun, |
| 2508 | DEFAULT_STEPFUN_MODEL, |
| 2509 | &usage, |
| 2510 | Utc::now(), |
| 2511 | ) |
| 2512 | .is_none() |
| 2513 | ); |
| 2514 | assert!(!has_pricing_for_provider( |
| 2515 | ApiProvider::Stepfun, |
| 2516 | DEFAULT_STEPFUN_MODEL |
| 2517 | )); |
| 2518 | |
| 2519 | for surface in [None, Some(STEPFUN_PLAN_BILLING_SURFACE)] { |
| 2520 | assert!( |
| 2521 | calculate_turn_cost_estimate_for_billing_surface( |
| 2522 | ApiProvider::Stepfun, |
| 2523 | DEFAULT_STEPFUN_MODEL, |
| 2524 | surface, |
| 2525 | &usage, |
| 2526 | ) |
| 2527 | .is_none() |
| 2528 | ); |
| 2529 | } |
| 2530 | assert!( |
| 2531 | calculate_turn_cost_estimate_for_billing_surface( |
| 2532 | ApiProvider::Stepfun, |
| 2533 | "step-3.5-flash", |
| 2534 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2535 | &usage, |
| 2536 | ) |
| 2537 | .is_none() |
| 2538 | ); |
| 2539 | for provider in [ |
| 2540 | ApiProvider::Openrouter, |
| 2541 | ApiProvider::Ollama, |
| 2542 | ApiProvider::Custom, |
| 2543 | ] { |
| 2544 | assert!( |
| 2545 | calculate_turn_cost_estimate_for_billing_surface( |
| 2546 | provider, |
| 2547 | DEFAULT_STEPFUN_MODEL, |
| 2548 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2549 | &usage, |
| 2550 | ) |
| 2551 | .is_none(), |
| 2552 | "{provider:?}" |
| 2553 | ); |
| 2554 | assert!( |
| 2555 | calculate_turn_cost_estimate_for_provider(provider, DEFAULT_STEPFUN_MODEL, &usage,) |
| 2556 | .is_none(), |
| 2557 | "{provider:?}" |
| 2558 | ); |
| 2559 | assert!( |
| 2560 | calculate_turn_cost_estimate_for_provider_at( |
| 2561 | provider, |
| 2562 | DEFAULT_STEPFUN_MODEL, |
| 2563 | &usage, |
| 2564 | Utc::now(), |
| 2565 | ) |
| 2566 | .is_none(), |
| 2567 | "{provider:?}" |
| 2568 | ); |
| 2569 | assert!( |
| 2570 | !has_pricing_for_provider(provider, DEFAULT_STEPFUN_MODEL), |
| 2571 | "{provider:?}" |
| 2572 | ); |
| 2573 | } |
| 2574 | |
| 2575 | let recorded = calculate_turn_cost_estimate_for_route_at( |
| 2576 | ApiProvider::Stepfun, |
| 2577 | DEFAULT_STEPFUN_MODEL, |
| 2578 | Some(STEPFUN_PAYG_BILLING_SURFACE), |
| 2579 | &usage, |
| 2580 | Utc::now(), |
| 2581 | ) |
| 2582 | .expect("recorded PAYG route retains provider-scoped pricing"); |
| 2583 | assert_eq!(recorded, payg); |
| 2584 | } |
| 2585 | |
| 2586 | #[test] |
| 2587 | fn catalog_sourced_models_have_usd_pricing() { |
| 2588 | for (model, input, output) in [ |
| 2589 | ("minimax-m2.7", 0.3, 1.2), |
| 2590 | ("minimax/minimax-m2.7", 0.3, 1.2), |
| 2591 | ("step-3.7-flash", 0.2, 1.15), |
| 2592 | ("fugu-ultra-20260615", 5.0, 30.0), |
| 2593 | ("fugu-ultra", 5.0, 30.0), |
| 2594 | ] { |
| 2595 | let pricing = pricing_for_model_at(model, Utc::now()).expect(model); |
| 2596 | assert_eq!(pricing.usd.input_cache_miss_per_million, input, "{model}"); |
| 2597 | assert_eq!(pricing.usd.output_per_million, output, "{model}"); |
| 2598 | assert!(has_pricing_for_model(model)); |
| 2599 | } |
| 2600 | } |
| 2601 | |
| 2602 | #[test] |
| 2603 | fn trinity_mini_stays_unpriced_without_verified_provider_rates() { |
| 2604 | let usage = Usage { |
| 2605 | input_tokens: 1_000, |
| 2606 | output_tokens: 100, |
| 2607 | ..Usage::default() |
| 2608 | }; |
| 2609 | |
| 2610 | assert!(pricing_for_model_at("trinity-mini", Utc::now()).is_none()); |
| 2611 | assert!(!has_pricing_for_model("trinity-mini")); |
| 2612 | assert!(!has_pricing_for_provider( |
| 2613 | ApiProvider::Arcee, |
| 2614 | "trinity-mini" |
| 2615 | )); |
| 2616 | assert!( |
| 2617 | calculate_turn_cost_estimate_for_provider(ApiProvider::Arcee, "trinity-mini", &usage,) |
| 2618 | .is_none() |
| 2619 | ); |
| 2620 | } |
| 2621 | |
| 2622 | #[test] |
| 2623 | fn minimax_m3_standard_pricing_tracks_the_512k_input_boundary() { |
| 2624 | for model in ["MiniMax-M3", "minimax/minimax-m3"] { |
| 2625 | for (input_tokens, cache_read, input, output) in |
| 2626 | [(512_000, 0.06, 0.30, 1.20), (512_001, 0.12, 0.60, 2.40)] |
| 2627 | { |
| 2628 | let usage = Usage { |
| 2629 | input_tokens, |
| 2630 | ..Usage::default() |
| 2631 | }; |
| 2632 | let pricing = pricing_for_model_and_usage(model, &usage).expect("M3 pricing"); |
| 2633 | assert_eq!(pricing.usd.input_cache_hit_per_million, cache_read); |
| 2634 | assert_eq!(pricing.usd.input_cache_miss_per_million, input); |
| 2635 | assert_eq!(pricing.usd.output_per_million, output); |
| 2636 | } |
| 2637 | assert!(calculate_cache_savings(model, 1).is_none()); |
| 2638 | } |
| 2639 | } |
| 2640 | |
| 2641 | #[test] |
| 2642 | fn provider_scoped_minimax_m3_keeps_usage_tiers_for_both_wire_protocols() { |
| 2643 | for provider in [ApiProvider::Minimax, ApiProvider::MinimaxAnthropic] { |
| 2644 | for (input_tokens, input_rate) in [(512_000, 0.30), (512_001, 0.60)] { |
| 2645 | let usage = Usage { |
| 2646 | input_tokens, |
| 2647 | ..Usage::default() |
| 2648 | }; |
| 2649 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 2650 | provider, |
| 2651 | "MiniMax-M3", |
| 2652 | &usage, |
| 2653 | Utc::now(), |
| 2654 | ) |
| 2655 | .expect("direct MiniMax route has authoritative pricing"); |
| 2656 | let expected = f64::from(input_tokens) / 1_000_000.0 * input_rate; |
| 2657 | assert!((estimate.usd - expected).abs() < 1e-12, "{provider:?}"); |
| 2658 | } |
| 2659 | } |
| 2660 | } |
| 2661 | |
| 2662 | #[test] |
| 2663 | fn direct_openai_long_context_estimates_fail_closed_above_272k() { |
| 2664 | for model in [ |
| 2665 | "gpt-5.5", |
| 2666 | "gpt-5.6", |
| 2667 | "gpt-5.6-sol", |
| 2668 | "gpt-5.6-terra", |
| 2669 | "gpt-5.6-luna", |
| 2670 | ] { |
| 2671 | let at_boundary = Usage { |
| 2672 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 2673 | ..Usage::default() |
| 2674 | }; |
| 2675 | let above_boundary = Usage { |
| 2676 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 2677 | ..Usage::default() |
| 2678 | }; |
| 2679 | |
| 2680 | assert!( |
| 2681 | calculate_turn_cost_estimate_for_provider( |
| 2682 | ApiProvider::Openai, |
| 2683 | model, |
| 2684 | &at_boundary, |
| 2685 | ) |
| 2686 | .is_some(), |
| 2687 | "{model} should retain its standard price at 272K" |
| 2688 | ); |
| 2689 | assert!( |
| 2690 | calculate_turn_cost_estimate_for_provider( |
| 2691 | ApiProvider::Openai, |
| 2692 | model, |
| 2693 | &above_boundary, |
| 2694 | ) |
| 2695 | .is_none(), |
| 2696 | "{model} must not report the lower static price above 272K" |
| 2697 | ); |
| 2698 | } |
| 2699 | } |
| 2700 | |
| 2701 | #[test] |
| 2702 | fn direct_openai_gpt54_family_is_guarded_even_without_a_bundled_catalog_row() { |
| 2703 | for model in ["gpt-5.4", "gpt-5.4-pro"] { |
| 2704 | assert!(!direct_openai_long_context_tier_is_unpriced( |
| 2705 | ApiProvider::Openai, |
| 2706 | model, |
| 2707 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 2708 | )); |
| 2709 | assert!(direct_openai_long_context_tier_is_unpriced( |
| 2710 | ApiProvider::Openai, |
| 2711 | model, |
| 2712 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 2713 | )); |
| 2714 | |
| 2715 | let above_boundary = Usage { |
| 2716 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 2717 | ..Usage::default() |
| 2718 | }; |
| 2719 | assert!( |
| 2720 | calculate_turn_cost_estimate_for_provider( |
| 2721 | ApiProvider::Openai, |
| 2722 | model, |
| 2723 | &above_boundary, |
| 2724 | ) |
| 2725 | .is_none(), |
| 2726 | "{model} must remain unpriced if a live catalog row is available" |
| 2727 | ); |
| 2728 | } |
| 2729 | } |
| 2730 | |
| 2731 | #[test] |
| 2732 | fn openai_long_context_guard_is_exact_and_provider_scoped() { |
| 2733 | let input_tokens = OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1; |
| 2734 | |
| 2735 | for provider in [ |
| 2736 | ApiProvider::Openrouter, |
| 2737 | ApiProvider::OpenaiCodex, |
| 2738 | ApiProvider::Ollama, |
| 2739 | ApiProvider::Custom, |
| 2740 | ] { |
| 2741 | assert!( |
| 2742 | !direct_openai_long_context_tier_is_unpriced(provider, "gpt-5.5", input_tokens,), |
| 2743 | "{provider:?} must not inherit direct OpenAI tier handling" |
| 2744 | ); |
| 2745 | } |
| 2746 | for model in [ |
| 2747 | "gpt-5.4-mini", |
| 2748 | "gpt-5.4-nano", |
| 2749 | "gpt-5.5-pro", |
| 2750 | "gpt-5.5-pro-2026-04-23", |
| 2751 | "gpt-5.5-2026-04-23-extra", |
| 2752 | "openai/gpt-5.5", |
| 2753 | "gpt-5.6-sol-preview", |
| 2754 | ] { |
| 2755 | assert!( |
| 2756 | !direct_openai_long_context_tier_is_unpriced( |
| 2757 | ApiProvider::Openai, |
| 2758 | model, |
| 2759 | input_tokens, |
| 2760 | ), |
| 2761 | "non-documented id {model} must not be treated as an alias" |
| 2762 | ); |
| 2763 | } |
| 2764 | |
| 2765 | let usage = Usage { |
| 2766 | input_tokens, |
| 2767 | output_tokens: 1, |
| 2768 | ..Usage::default() |
| 2769 | }; |
| 2770 | assert!(calculate_turn_cost_estimate_from_usage("gpt-5.5", &usage).is_some()); |
| 2771 | assert!( |
| 2772 | calculate_turn_cost_estimate_for_provider(ApiProvider::OpenaiCodex, "gpt-5.5", &usage,) |
| 2773 | .is_none() |
| 2774 | ); |
| 2775 | } |
| 2776 | |
| 2777 | #[test] |
| 2778 | fn direct_openai_snapshots_use_the_same_strict_272k_boundary() { |
| 2779 | for snapshot in [ |
| 2780 | "gpt-5.4-2026-03-05", |
| 2781 | "gpt-5.4-pro-2026-03-05", |
| 2782 | "gpt-5.5-2026-04-23", |
| 2783 | ] { |
| 2784 | assert!(!direct_openai_long_context_tier_is_unpriced( |
| 2785 | ApiProvider::Openai, |
| 2786 | snapshot, |
| 2787 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 2788 | )); |
| 2789 | assert!(direct_openai_long_context_tier_is_unpriced( |
| 2790 | ApiProvider::Openai, |
| 2791 | snapshot, |
| 2792 | OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 2793 | )); |
| 2794 | |
| 2795 | let above_boundary = Usage { |
| 2796 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 2797 | ..Usage::default() |
| 2798 | }; |
| 2799 | assert!( |
| 2800 | calculate_turn_cost_estimate_for_provider( |
| 2801 | ApiProvider::Openai, |
| 2802 | snapshot, |
| 2803 | &above_boundary, |
| 2804 | ) |
| 2805 | .is_none(), |
| 2806 | "{snapshot} must not report the lower static price above 272K" |
| 2807 | ); |
| 2808 | } |
| 2809 | } |
| 2810 | |
| 2811 | #[test] |
| 2812 | fn direct_openai_long_context_guard_uses_total_input_with_mixed_cache_classes() { |
| 2813 | let at_boundary = Usage { |
| 2814 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD, |
| 2815 | output_tokens: 1_000, |
| 2816 | prompt_cache_hit_tokens: Some(100_000), |
| 2817 | prompt_cache_miss_tokens: Some(100_000), |
| 2818 | prompt_cache_write_tokens: Some(72_000), |
| 2819 | ..Usage::default() |
| 2820 | }; |
| 2821 | let above_boundary = Usage { |
| 2822 | input_tokens: OPENAI_LONG_CONTEXT_SURCHARGE_THRESHOLD + 1, |
| 2823 | prompt_cache_write_tokens: Some(72_001), |
| 2824 | ..at_boundary.clone() |
| 2825 | }; |
| 2826 | |
| 2827 | assert!( |
| 2828 | calculate_turn_cost_estimate_for_provider( |
| 2829 | ApiProvider::Openai, |
| 2830 | "gpt-5.6-sol", |
| 2831 | &at_boundary, |
| 2832 | ) |
| 2833 | .is_some() |
| 2834 | ); |
| 2835 | assert!( |
| 2836 | calculate_turn_cost_estimate_for_provider( |
| 2837 | ApiProvider::Openai, |
| 2838 | "gpt-5.6-sol", |
| 2839 | &above_boundary, |
| 2840 | ) |
| 2841 | .is_none() |
| 2842 | ); |
| 2843 | } |
| 2844 | |
| 2845 | #[test] |
| 2846 | fn minimax_m2_7_preserves_cache_read_and_write_rates() { |
| 2847 | let pricing = pricing_for_model_at("MiniMax-M2.7", Utc::now()).expect("M2.7 pricing"); |
| 2848 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.06); |
| 2849 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.30); |
| 2850 | assert_eq!(pricing.usd.output_per_million, 1.20); |
| 2851 | assert_eq!(pricing.usd.cache_write, CacheWritePolicy::Rate(0.375)); |
| 2852 | } |
| 2853 | |
| 2854 | #[test] |
| 2855 | fn curated_usd_only_models_have_pricing_and_accrue_cost() { |
| 2856 | let usage = Usage { |
| 2857 | input_tokens: 1_000_000, |
| 2858 | output_tokens: 500_000, |
| 2859 | prompt_cache_hit_tokens: Some(250_000), |
| 2860 | prompt_cache_miss_tokens: Some(750_000), |
| 2861 | ..Default::default() |
| 2862 | }; |
| 2863 | for (model, hit, miss, output) in [ |
| 2864 | ("kimi-k2.6", 0.16, 0.95, 4.00), |
| 2865 | ("kimi-k2.7-code", 0.19, 0.95, 4.00), |
| 2866 | ("moonshotai/kimi-k2.7-code", 0.19, 0.95, 4.00), |
| 2867 | ("z-ai/glm-5.1", 0.26, 1.40, 4.40), |
| 2868 | ("glm-5.2", 0.26, 1.40, 4.40), |
| 2869 | ("z-ai/glm-5.2", 0.26, 1.40, 4.40), |
| 2870 | ("glm-5-turbo", 0.24, 1.20, 4.00), |
| 2871 | ("z-ai/glm-5-turbo", 0.24, 1.20, 4.00), |
| 2872 | ("qwen/qwen3.6-plus", 0.325, 0.325, 1.95), |
| 2873 | ("qwen/qwen3.6-35b-a3b", 0.05, 0.14, 1.00), |
| 2874 | ("qwen/qwen3.6-27b", 0.15, 0.285, 2.40), |
| 2875 | // No published cache rate: cache-hit billed at the input rate. |
| 2876 | ("trinity-large-thinking", 0.25, 0.25, 0.80), |
| 2877 | ("nvidia/nemotron-3-ultra-550b-a55b", 0.10, 0.50, 2.20), |
| 2878 | ("claude-opus-4-8", 0.50, 5.00, 25.00), |
| 2879 | ("claude-sonnet-4-6", 0.30, 3.00, 15.00), |
| 2880 | ("claude-haiku-4-5", 0.10, 1.00, 5.00), |
| 2881 | ("claude-fable-5", 1.00, 10.00, 50.00), |
| 2882 | ("gpt-5.5", 0.50, 5.00, 30.00), |
| 2883 | // GPT-5.5 Pro has no cached-input discount: cache-hit == input. |
| 2884 | ("gpt-5.5-pro", 30.00, 30.00, 180.00), |
| 2885 | ("gpt-5.6-sol", 0.50, 5.00, 30.00), |
| 2886 | ("gpt-5.6-terra", 0.25, 2.50, 15.00), |
| 2887 | ("gpt-5.6-luna", 0.10, 1.00, 6.00), |
| 2888 | ("gpt-5-codex", 0.125, 1.25, 10.00), |
| 2889 | ("gpt-5.3-codex", 0.175, 1.75, 14.00), |
| 2890 | ("qwen/qwen3.7-plus", 0.064, 0.32, 1.28), |
| 2891 | ("muse-spark-1.1", 0.15, 1.25, 4.25), |
| 2892 | ("muse-spark-1.2", 0.15, 1.25, 4.25), |
| 2893 | ("muse-spark-1.2-contributor", 0.002, 0.10, 0.20), |
| 2894 | ] { |
| 2895 | let pricing = pricing_for_model_at(model, Utc::now()).expect(model); |
| 2896 | assert_eq!(pricing.usd.input_cache_hit_per_million, hit); |
| 2897 | assert_eq!(pricing.usd.input_cache_miss_per_million, miss); |
| 2898 | assert_eq!(pricing.usd.output_per_million, output); |
| 2899 | assert!(pricing.cny.is_none()); |
| 2900 | assert!(has_pricing_for_model(model)); |
| 2901 | |
| 2902 | let estimate = calculate_turn_cost_estimate_from_usage(model, &usage).expect(model); |
| 2903 | assert!(estimate.usd > 0.0, "expected positive USD for {model}"); |
| 2904 | assert_eq!(estimate.cny, 0.0); |
| 2905 | } |
| 2906 | |
| 2907 | // Anthropic / Qwen rows that publish a cache-write premium, and one row |
| 2908 | // (`gpt-5.5`) that publishes none — which is `Unpublished`, not a |
| 2909 | // licence to bill writes at the input rate (#4318). |
| 2910 | for (model, write) in [ |
| 2911 | ("claude-opus-4-8", CacheWritePolicy::Rate(6.25)), |
| 2912 | ("claude-sonnet-4-6", CacheWritePolicy::Rate(3.75)), |
| 2913 | ("claude-haiku-4-5", CacheWritePolicy::Rate(1.25)), |
| 2914 | ("claude-fable-5", CacheWritePolicy::Rate(12.50)), |
| 2915 | ("qwen/qwen3.7-plus", CacheWritePolicy::Rate(0.40)), |
| 2916 | ("gpt-5.5", CacheWritePolicy::Unpublished), |
| 2917 | ] { |
| 2918 | let pricing = pricing_for_model_at(model, Utc::now()).expect(model); |
| 2919 | assert_eq!( |
| 2920 | pricing.usd.cache_write, write, |
| 2921 | "cache-write policy for {model}" |
| 2922 | ); |
| 2923 | } |
| 2924 | } |
| 2925 | |
| 2926 | #[test] |
| 2927 | fn glm_5_3_has_no_hardcoded_price() { |
| 2928 | // GLM-5.3's catalog metadata is inherited from GLM-5.2, but Z.ai has |
| 2929 | // published no GLM-5.3 rate. Inheriting the 5.2 price would invent one, |
| 2930 | // so every price surface must report *unknown*, never a number and |
| 2931 | // never $0. If Z.ai publishes rates, delete this test and add the real |
| 2932 | // row — do not "fix" it by copying 5.2's. |
| 2933 | for model in ["glm-5.3", "z-ai/glm-5.3"] { |
| 2934 | assert!( |
| 2935 | pricing_for_model_at(model, Utc::now()).is_none(), |
| 2936 | "{model} must have no price row until Z.ai publishes one" |
| 2937 | ); |
| 2938 | assert!(!has_pricing_for_model(model), "{model} must be unpriced"); |
| 2939 | assert!( |
| 2940 | calculate_turn_cost_estimate_from_usage( |
| 2941 | model, |
| 2942 | &Usage { |
| 2943 | input_tokens: 1_000_000, |
| 2944 | output_tokens: 500_000, |
| 2945 | ..Default::default() |
| 2946 | }, |
| 2947 | ) |
| 2948 | .is_none(), |
| 2949 | "{model} must not accrue an invented cost estimate" |
| 2950 | ); |
| 2951 | } |
| 2952 | // The priced sibling it inherits capabilities from is unaffected. |
| 2953 | assert!(has_pricing_for_model("glm-5.2")); |
| 2954 | } |
| 2955 | |
| 2956 | #[test] |
| 2957 | fn cache_write_tokens_increase_anthropic_cost_estimate() { |
| 2958 | let with_write = Usage { |
| 2959 | input_tokens: 12_048, |
| 2960 | output_tokens: 1, |
| 2961 | prompt_cache_hit_tokens: Some(10_000), |
| 2962 | prompt_cache_miss_tokens: Some(3), |
| 2963 | prompt_cache_write_tokens: Some(2_045), |
| 2964 | ..Default::default() |
| 2965 | }; |
| 2966 | let write_as_miss = Usage { |
| 2967 | input_tokens: 12_048, |
| 2968 | output_tokens: 1, |
| 2969 | prompt_cache_hit_tokens: Some(10_000), |
| 2970 | prompt_cache_miss_tokens: Some(2_048), |
| 2971 | prompt_cache_write_tokens: None, |
| 2972 | ..Default::default() |
| 2973 | }; |
| 2974 | |
| 2975 | let priced = |
| 2976 | calculate_turn_cost_estimate_from_usage("claude-fable-5", &with_write).expect("priced"); |
| 2977 | let undercounted = |
| 2978 | calculate_turn_cost_estimate_from_usage("claude-fable-5", &write_as_miss) |
| 2979 | .expect("priced"); |
| 2980 | // 2045 write @ 12.50 vs same tokens @ miss 10.00 → ~0.005 USD premium. |
| 2981 | assert!( |
| 2982 | priced.usd > undercounted.usd, |
| 2983 | "write premium should raise cost: priced={} undercounted={}", |
| 2984 | priced.usd, |
| 2985 | undercounted.usd |
| 2986 | ); |
| 2987 | let expected_premium = (2_045.0 / 1_000_000.0) * (12.50 - 10.00); |
| 2988 | assert!( |
| 2989 | (priced.usd - undercounted.usd - expected_premium).abs() < 1e-9, |
| 2990 | "premium delta mismatch: {}", |
| 2991 | priced.usd - undercounted.usd |
| 2992 | ); |
| 2993 | } |
| 2994 | |
| 2995 | #[test] |
| 2996 | fn catalog_pricing_uses_its_cache_write_rate() { |
| 2997 | let offering = codewhale_config::catalog::CatalogOffering { |
| 2998 | provider: "anthropic".to_string(), |
| 2999 | wire_model_id: "catalog-priced-model".to_string(), |
| 3000 | endpoint_key: "chat".to_string(), |
| 3001 | cost: Some(codewhale_config::models_dev::ModelsDevCost { |
| 3002 | input: Some(10.0), |
| 3003 | output: Some(50.0), |
| 3004 | cache_read: Some(1.0), |
| 3005 | cache_write: Some(12.5), |
| 3006 | }), |
| 3007 | ..Default::default() |
| 3008 | }; |
| 3009 | let usage = Usage { |
| 3010 | input_tokens: 13, |
| 3011 | output_tokens: 5, |
| 3012 | prompt_cache_hit_tokens: Some(2), |
| 3013 | prompt_cache_miss_tokens: Some(3), |
| 3014 | prompt_cache_write_tokens: Some(8), |
| 3015 | ..Default::default() |
| 3016 | }; |
| 3017 | |
| 3018 | let estimate = catalog_cost_estimate_for_route( |
| 3019 | ApiProvider::Anthropic, |
| 3020 | "catalog-priced-model", |
| 3021 | &offering, |
| 3022 | &usage, |
| 3023 | ) |
| 3024 | .expect("catalog cost estimate"); |
| 3025 | assert!((estimate.usd - 0.000_382).abs() < 1e-15); |
| 3026 | assert_eq!(estimate.cny, 0.0); |
| 3027 | } |
| 3028 | |
| 3029 | #[test] |
| 3030 | fn recorded_time_provider_cost_keeps_catalog_cache_write_tier() { |
| 3031 | let usage = Usage { |
| 3032 | input_tokens: 1_000_000, |
| 3033 | output_tokens: 0, |
| 3034 | prompt_cache_hit_tokens: Some(0), |
| 3035 | prompt_cache_miss_tokens: Some(0), |
| 3036 | prompt_cache_write_tokens: Some(1_000_000), |
| 3037 | ..Default::default() |
| 3038 | }; |
| 3039 | |
| 3040 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 3041 | ApiProvider::Openrouter, |
| 3042 | "qwen/qwen3.7-plus", |
| 3043 | &usage, |
| 3044 | Utc::now(), |
| 3045 | ) |
| 3046 | .expect("provider catalog write price"); |
| 3047 | |
| 3048 | assert!((estimate.usd - 0.40).abs() < f64::EPSILON); |
| 3049 | assert_eq!(estimate.cny, 0.0); |
| 3050 | } |
| 3051 | |
| 3052 | #[test] |
| 3053 | fn recorded_time_provider_cost_rejects_foreign_model_ids() { |
| 3054 | let usage = Usage { |
| 3055 | input_tokens: 1_000, |
| 3056 | output_tokens: 100, |
| 3057 | ..Default::default() |
| 3058 | }; |
| 3059 | |
| 3060 | assert!( |
| 3061 | calculate_turn_cost_estimate_for_provider_at( |
| 3062 | ApiProvider::Ollama, |
| 3063 | "gpt-5.5", |
| 3064 | &usage, |
| 3065 | Utc::now(), |
| 3066 | ) |
| 3067 | .is_none() |
| 3068 | ); |
| 3069 | } |
| 3070 | |
| 3071 | #[test] |
| 3072 | fn provider_cost_keeps_owned_hand_price_without_catalog_offering() { |
| 3073 | let usage = Usage { |
| 3074 | input_tokens: 1_000_000, |
| 3075 | output_tokens: 0, |
| 3076 | ..Default::default() |
| 3077 | }; |
| 3078 | assert!( |
| 3079 | crate::provider_lake::catalog_offering_for_model(ApiProvider::Openai, "gpt-5-codex") |
| 3080 | .is_none(), |
| 3081 | "regression fixture must exercise the hand-price fallback" |
| 3082 | ); |
| 3083 | |
| 3084 | let estimate = calculate_turn_cost_estimate_for_provider_at( |
| 3085 | ApiProvider::Openai, |
| 3086 | "gpt-5-codex", |
| 3087 | &usage, |
| 3088 | Utc::now(), |
| 3089 | ) |
| 3090 | .expect("OpenAI API owns the hand-priced model"); |
| 3091 | |
| 3092 | assert!((estimate.usd - 1.25).abs() < f64::EPSILON); |
| 3093 | assert_eq!(estimate.cny, 0.0); |
| 3094 | assert!(has_pricing_for_provider(ApiProvider::Openai, "gpt-5-codex")); |
| 3095 | } |
| 3096 | |
| 3097 | #[test] |
| 3098 | fn provider_price_does_not_invent_catalog_missing_cache_write_class() { |
| 3099 | let offering = |
| 3100 | crate::provider_lake::catalog_offering_for_model(ApiProvider::Openai, "gpt-5.5") |
| 3101 | .expect("bundled OpenAI route"); |
| 3102 | let catalog_pricing = |
| 3103 | OfferingPricing::from_catalog_offering(&offering).expect("catalog pricing"); |
| 3104 | assert!(catalog_pricing.cache_write_per_million.is_none()); |
| 3105 | let usage = Usage { |
| 3106 | input_tokens: 250_000, |
| 3107 | output_tokens: 0, |
| 3108 | prompt_cache_miss_tokens: Some(0), |
| 3109 | prompt_cache_write_tokens: Some(250_000), |
| 3110 | ..Default::default() |
| 3111 | }; |
| 3112 | |
| 3113 | let audit = |
| 3114 | audit_turn_cost_for_provider_at(ApiProvider::Openai, "gpt-5.5", &usage, Utc::now()); |
| 3115 | |
| 3116 | assert!(audit.estimate.is_none()); |
| 3117 | assert_eq!( |
| 3118 | audit.unpriced_reason, |
| 3119 | Some(UnpricedReason::MissingClassPrice) |
| 3120 | ); |
| 3121 | assert_eq!(audit.unpriced_classes, vec![TokenClass::CacheWrite]); |
| 3122 | } |
| 3123 | |
| 3124 | #[test] |
| 3125 | fn provider_cost_does_not_fabricate_price_for_costless_catalog_route() { |
| 3126 | let offering = crate::provider_lake::catalog_offering_for_model( |
| 3127 | ApiProvider::Openai, |
| 3128 | "deepseek-v4-pro", |
| 3129 | ) |
| 3130 | .expect("bundled OpenAI-compatible route"); |
| 3131 | assert!(OfferingPricing::from_catalog_offering(&offering).is_none()); |
| 3132 | let usage = Usage { |
| 3133 | input_tokens: 1_000_000, |
| 3134 | output_tokens: 0, |
| 3135 | ..Default::default() |
| 3136 | }; |
| 3137 | |
| 3138 | assert!( |
| 3139 | calculate_turn_cost_estimate_for_provider_at( |
| 3140 | ApiProvider::Openai, |
| 3141 | "deepseek-v4-pro", |
| 3142 | &usage, |
| 3143 | Utc::now(), |
| 3144 | ) |
| 3145 | .is_none() |
| 3146 | ); |
| 3147 | assert!( |
| 3148 | calculate_turn_cost_estimate_for_provider( |
| 3149 | ApiProvider::Openai, |
| 3150 | "deepseek-v4-pro", |
| 3151 | &usage, |
| 3152 | ) |
| 3153 | .is_none() |
| 3154 | ); |
| 3155 | assert!(!has_pricing_for_provider( |
| 3156 | ApiProvider::Openai, |
| 3157 | "deepseek-v4-pro" |
| 3158 | )); |
| 3159 | } |
| 3160 | |
| 3161 | #[test] |
| 3162 | fn recorded_time_provider_cost_bounds_deepseek_compatibility_aliases() { |
| 3163 | let usage = Usage { |
| 3164 | input_tokens: 1_000, |
| 3165 | output_tokens: 100, |
| 3166 | ..Default::default() |
| 3167 | }; |
| 3168 | let before_retirement: DateTime<Utc> = |
| 3169 | "2026-07-24T15:58:59Z".parse().expect("pre-retirement time"); |
| 3170 | let at_retirement: DateTime<Utc> = DEEPSEEK_ALIAS_RETIREMENT_UTC |
| 3171 | .parse() |
| 3172 | .expect("retirement time"); |
| 3173 | |
| 3174 | assert!( |
| 3175 | calculate_turn_cost_estimate_for_provider_at( |
| 3176 | ApiProvider::Deepseek, |
| 3177 | "deepseek-chat", |
| 3178 | &usage, |
| 3179 | before_retirement, |
| 3180 | ) |
| 3181 | .is_some() |
| 3182 | ); |
| 3183 | assert!( |
| 3184 | calculate_turn_cost_estimate_for_provider_at( |
| 3185 | ApiProvider::Deepseek, |
| 3186 | "deepseek-reasoner", |
| 3187 | &usage, |
| 3188 | at_retirement, |
| 3189 | ) |
| 3190 | .is_none() |
| 3191 | ); |
| 3192 | } |
| 3193 | |
| 3194 | #[test] |
| 3195 | fn token_usage_for_pricing_maps_cache_classes_without_double_billing_reasoning() { |
| 3196 | let usage = Usage { |
| 3197 | input_tokens: 1_000, |
| 3198 | output_tokens: 100, |
| 3199 | prompt_cache_hit_tokens: Some(250), |
| 3200 | prompt_cache_miss_tokens: Some(700), |
| 3201 | prompt_cache_write_tokens: Some(50), |
| 3202 | // Reasoning is a subset of the 100 reported output tokens, not an |
| 3203 | // extra 50 tokens of billable output. |
| 3204 | reasoning_tokens: Some(50), |
| 3205 | ..Default::default() |
| 3206 | }; |
| 3207 | |
| 3208 | assert_eq!( |
| 3209 | token_usage_for_pricing(&usage), |
| 3210 | TokenUsage { |
| 3211 | input: 700, |
| 3212 | output: 100, |
| 3213 | cache_read: 250, |
| 3214 | cache_write: 50, |
| 3215 | } |
| 3216 | ); |
| 3217 | |
| 3218 | // Informational reasoning telemetry must not move the billed output at |
| 3219 | // all: the same completion count costs the same with or without it. |
| 3220 | let without_reasoning = Usage { |
| 3221 | reasoning_tokens: None, |
| 3222 | ..usage.clone() |
| 3223 | }; |
| 3224 | assert_eq!( |
| 3225 | token_usage_for_pricing(&usage).output, |
| 3226 | token_usage_for_pricing(&without_reasoning).output |
| 3227 | ); |
| 3228 | assert_eq!( |
| 3229 | calculate_turn_cost_estimate_for_provider( |
| 3230 | ApiProvider::Anthropic, |
| 3231 | "claude-haiku-4-5", |
| 3232 | &usage, |
| 3233 | ), |
| 3234 | calculate_turn_cost_estimate_for_provider( |
| 3235 | ApiProvider::Anthropic, |
| 3236 | "claude-haiku-4-5", |
| 3237 | &without_reasoning, |
| 3238 | ) |
| 3239 | ); |
| 3240 | } |
| 3241 | |
| 3242 | #[test] |
| 3243 | fn contradictory_cache_partition_is_bounded_and_fails_closed() { |
| 3244 | let usage = Usage { |
| 3245 | input_tokens: 100, |
| 3246 | output_tokens: 10, |
| 3247 | prompt_cache_hit_tokens: Some(80), |
| 3248 | prompt_cache_miss_tokens: Some(40), |
| 3249 | prompt_cache_write_tokens: Some(30), |
| 3250 | ..Usage::default() |
| 3251 | }; |
| 3252 | |
| 3253 | let classes = token_usage_for_pricing(&usage); |
| 3254 | assert_eq!( |
| 3255 | classes.input + classes.cache_read + classes.cache_write, |
| 3256 | u64::from(usage.input_tokens), |
| 3257 | "token projection may never exceed the provider's input total" |
| 3258 | ); |
| 3259 | let audit = audit_turn_cost_for_provider_on_endpoint_at( |
| 3260 | ApiProvider::Deepseek, |
| 3261 | "deepseek-v4-flash", |
| 3262 | None, |
| 3263 | &usage, |
| 3264 | Utc::now(), |
| 3265 | ); |
| 3266 | assert!(audit.estimate.is_none()); |
| 3267 | assert_eq!( |
| 3268 | audit.unpriced_reason, |
| 3269 | Some(UnpricedReason::InconsistentUsage) |
| 3270 | ); |
| 3271 | |
| 3272 | let overflow_shape = Usage { |
| 3273 | input_tokens: u32::MAX, |
| 3274 | prompt_cache_hit_tokens: Some(u32::MAX), |
| 3275 | prompt_cache_miss_tokens: Some(1), |
| 3276 | ..Usage::default() |
| 3277 | }; |
| 3278 | assert!( |
| 3279 | !usage_cache_partition_is_consistent(&overflow_shape), |
| 3280 | "consistency validation must not hide overflow via saturation" |
| 3281 | ); |
| 3282 | } |
| 3283 | |
| 3284 | #[test] |
| 3285 | fn openai_codex_gpt55_cost_is_unavailable_even_with_usage() { |
| 3286 | let usage = Usage { |
| 3287 | input_tokens: 1_000, |
| 3288 | output_tokens: 100, |
| 3289 | prompt_cache_hit_tokens: Some(250), |
| 3290 | prompt_cache_miss_tokens: Some(750), |
| 3291 | ..Default::default() |
| 3292 | }; |
| 3293 | |
| 3294 | assert!(calculate_turn_cost_estimate_from_usage("gpt-5.5", &usage).is_some()); |
| 3295 | assert!(has_pricing_for_provider(ApiProvider::Openai, "gpt-5.5")); |
| 3296 | assert!(!has_pricing_for_provider( |
| 3297 | ApiProvider::OpenaiCodex, |
| 3298 | "gpt-5.5" |
| 3299 | )); |
| 3300 | assert!( |
| 3301 | calculate_turn_cost_estimate_for_provider(ApiProvider::OpenaiCodex, "gpt-5.5", &usage) |
| 3302 | .is_none() |
| 3303 | ); |
| 3304 | } |
| 3305 | |
| 3306 | #[test] |
| 3307 | fn subscription_route_does_not_inherit_same_models_api_price() { |
| 3308 | let usage = Usage { |
| 3309 | input_tokens: 1_000, |
| 3310 | output_tokens: 100, |
| 3311 | ..Default::default() |
| 3312 | }; |
| 3313 | assert!( |
| 3314 | calculate_turn_cost_estimate_for_billing_surface( |
| 3315 | ApiProvider::Anthropic, |
| 3316 | "claude-sonnet-5", |
| 3317 | Some(FIRST_PARTY_PAYG_BILLING_SURFACE), |
| 3318 | &usage, |
| 3319 | ) |
| 3320 | .is_some() |
| 3321 | ); |
| 3322 | assert!( |
| 3323 | calculate_turn_cost_estimate_for_route( |
| 3324 | ApiProvider::Anthropic, |
| 3325 | "claude-sonnet-5", |
| 3326 | &usage, |
| 3327 | crate::route_billing::BillingPresentation::Subscription("Claude OAuth quota"), |
| 3328 | ) |
| 3329 | .is_none() |
| 3330 | ); |
| 3331 | } |
| 3332 | |
| 3333 | #[test] |
| 3334 | fn token_usage_for_pricing_infers_missing_cache_miss_from_hit_source() { |
| 3335 | let usage = Usage { |
| 3336 | input_tokens: 1_000, |
| 3337 | output_tokens: 100, |
| 3338 | prompt_cache_hit_tokens: Some(250), |
| 3339 | prompt_cache_miss_tokens: None, |
| 3340 | ..Default::default() |
| 3341 | }; |
| 3342 | |
| 3343 | assert_eq!( |
| 3344 | token_usage_for_pricing(&usage), |
| 3345 | TokenUsage { |
| 3346 | input: 750, |
| 3347 | output: 100, |
| 3348 | cache_read: 250, |
| 3349 | cache_write: 0, |
| 3350 | } |
| 3351 | ); |
| 3352 | } |
| 3353 | |
| 3354 | #[test] |
| 3355 | fn catalog_pricing_overrides_known_row_when_present() { |
| 3356 | let _lock = crate::model_catalog::test_catalog_lock(); |
| 3357 | let mut overrides = BTreeMap::new(); |
| 3358 | overrides.insert( |
| 3359 | "catalog-priced-model".to_string(), |
| 3360 | crate::model_catalog::CatalogEntry { |
| 3361 | id: "catalog-priced-model".to_string(), |
| 3362 | context_window: None, |
| 3363 | max_output: None, |
| 3364 | supports_reasoning: None, |
| 3365 | input_usd_per_million: Some(0.25), |
| 3366 | output_usd_per_million: Some(1.25), |
| 3367 | modalities: Vec::new(), |
| 3368 | supported_parameters: Vec::new(), |
| 3369 | provider_model_id: None, |
| 3370 | provenance: crate::model_catalog::MetadataProvenance::UserOverride, |
| 3371 | }, |
| 3372 | ); |
| 3373 | let catalog = crate::model_catalog::MergedCatalog::from_sources( |
| 3374 | overrides, |
| 3375 | None, |
| 3376 | crate::model_catalog::bundled_catalog(), |
| 3377 | Utc::now(), |
| 3378 | ); |
| 3379 | let _guard = crate::model_catalog::replace_active_catalog_for_test(catalog); |
| 3380 | |
| 3381 | let pricing = pricing_for_model_at("catalog-priced-model", Utc::now()).expect("pricing"); |
| 3382 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.25); |
| 3383 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.25); |
| 3384 | assert_eq!(pricing.usd.output_per_million, 1.25); |
| 3385 | assert!(pricing.cny.is_none()); |
| 3386 | } |
| 3387 | |
| 3388 | #[test] |
| 3389 | fn sonnet_5_uses_intro_pricing_before_2026_08_31_expiry() { |
| 3390 | let before_expiry = Utc |
| 3391 | .with_ymd_and_hms(2026, 8, 31, 23, 59, 59) |
| 3392 | .single() |
| 3393 | .unwrap(); |
| 3394 | let pricing = pricing_for_model_at("claude-sonnet-5", before_expiry).unwrap(); |
| 3395 | |
| 3396 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.20); |
| 3397 | assert_eq!(pricing.usd.input_cache_miss_per_million, 2.00); |
| 3398 | assert_eq!(pricing.usd.output_per_million, 10.00); |
| 3399 | assert_eq!(pricing.usd.cache_write, CacheWritePolicy::Rate(2.50)); |
| 3400 | assert!(pricing.cny.is_none()); |
| 3401 | } |
| 3402 | |
| 3403 | #[test] |
| 3404 | fn sonnet_5_uses_standard_pricing_after_intro_window() { |
| 3405 | let after_expiry = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).single().unwrap(); |
| 3406 | let pricing = pricing_for_model_at("claude-sonnet-5", after_expiry).unwrap(); |
| 3407 | |
| 3408 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.30); |
| 3409 | assert_eq!(pricing.usd.input_cache_miss_per_million, 3.00); |
| 3410 | assert_eq!(pricing.usd.output_per_million, 15.00); |
| 3411 | assert_eq!(pricing.usd.cache_write, CacheWritePolicy::Rate(3.75)); |
| 3412 | assert!(pricing.cny.is_none()); |
| 3413 | assert!(has_pricing_for_model("claude-sonnet-5")); |
| 3414 | } |
| 3415 | |
| 3416 | #[test] |
| 3417 | fn v4_pro_uses_limited_time_discount_before_expiry() { |
| 3418 | let before_expiry = Utc |
| 3419 | .with_ymd_and_hms(2026, 5, 31, 15, 58, 59) |
| 3420 | .single() |
| 3421 | .unwrap(); |
| 3422 | let pricing = pricing_for_model_at("deepseek-v4-pro", before_expiry).unwrap(); |
| 3423 | |
| 3424 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625); |
| 3425 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435); |
| 3426 | assert_eq!(pricing.usd.output_per_million, 0.87); |
| 3427 | let cny = pricing.cny.expect("DeepSeek pricing has CNY"); |
| 3428 | assert_eq!(cny.input_cache_hit_per_million, 0.025); |
| 3429 | assert_eq!(cny.input_cache_miss_per_million, 3.0); |
| 3430 | assert_eq!(cny.output_per_million, 6.0); |
| 3431 | } |
| 3432 | |
| 3433 | #[test] |
| 3434 | fn v4_pro_keeps_adjusted_rates_after_discount_window() { |
| 3435 | let after_expiry = Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).single().unwrap(); |
| 3436 | let pricing = pricing_for_model_at("deepseek-v4-pro", after_expiry).unwrap(); |
| 3437 | |
| 3438 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625); |
| 3439 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435); |
| 3440 | assert_eq!(pricing.usd.output_per_million, 0.87); |
| 3441 | let cny = pricing.cny.expect("DeepSeek pricing has CNY"); |
| 3442 | assert_eq!(cny.input_cache_hit_per_million, 0.025); |
| 3443 | assert_eq!(cny.input_cache_miss_per_million, 3.0); |
| 3444 | assert_eq!(cny.output_per_million, 6.0); |
| 3445 | } |
| 3446 | |
| 3447 | #[test] |
| 3448 | fn v4_pro_discount_still_applies_just_before_old_may5_expiry() { |
| 3449 | // Regression for #267 and #2489: the adjusted V4-Pro pricing should |
| 3450 | // not drift back to the original higher launch rates. |
| 3451 | let after_old_expiry = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).single().unwrap(); |
| 3452 | let pricing = pricing_for_model_at("deepseek-v4-pro", after_old_expiry).unwrap(); |
| 3453 | |
| 3454 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625); |
| 3455 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435); |
| 3456 | assert_eq!(pricing.usd.output_per_million, 0.87); |
| 3457 | } |
| 3458 | |
| 3459 | #[test] |
| 3460 | fn v4_flash_keeps_current_published_rates() { |
| 3461 | let now = Utc.with_ymd_and_hms(2026, 4, 25, 0, 0, 0).single().unwrap(); |
| 3462 | let pricing = pricing_for_model_at("deepseek-v4-flash", now).unwrap(); |
| 3463 | |
| 3464 | assert_eq!(pricing.usd.input_cache_hit_per_million, 0.0028); |
| 3465 | assert_eq!(pricing.usd.input_cache_miss_per_million, 0.14); |
| 3466 | assert_eq!(pricing.usd.output_per_million, 0.28); |
| 3467 | let cny = pricing.cny.expect("DeepSeek pricing has CNY"); |
| 3468 | assert_eq!(cny.input_cache_hit_per_million, 0.02); |
| 3469 | assert_eq!(cny.input_cache_miss_per_million, 1.0); |
| 3470 | assert_eq!(cny.output_per_million, 2.0); |
| 3471 | } |
| 3472 | |
| 3473 | #[test] |
| 3474 | fn xiaomi_mimo_token_plan_models_leave_cost_unknown() { |
| 3475 | let now = Utc.with_ymd_and_hms(2026, 6, 4, 0, 0, 0).single().unwrap(); |
| 3476 | |
| 3477 | for model in [ |
| 3478 | "mimo-v2.5-pro", |
| 3479 | "mimo-v2.5-pro-ultraspeed", |
| 3480 | "mimo-v2.5", |
| 3481 | "xiaomi/mimo-v2.5", |
| 3482 | ] { |
| 3483 | assert!(pricing_for_model_at(model, now).is_none()); |
| 3484 | assert!(!has_pricing_for_model(model)); |
| 3485 | } |
| 3486 | } |
| 3487 | |
| 3488 | #[test] |
| 3489 | fn cost_estimate_calculates_usd_and_cny() { |
| 3490 | let usage = Usage { |
| 3491 | input_tokens: 1_000_000, |
| 3492 | output_tokens: 500_000, |
| 3493 | ..Default::default() |
| 3494 | }; |
| 3495 | let estimate = |
| 3496 | calculate_turn_cost_estimate_from_usage("deepseek-v4-flash", &usage).expect("estimate"); |
| 3497 | |
| 3498 | assert_eq!(estimate.usd, 0.28); |
| 3499 | assert_eq!(estimate.cny, 2.0); |
| 3500 | } |
| 3501 | |
| 3502 | #[test] |
| 3503 | fn cost_currency_accepts_yuan_aliases() { |
| 3504 | assert_eq!(CostCurrency::from_setting("usd"), Some(CostCurrency::Usd)); |
| 3505 | assert_eq!(CostCurrency::from_setting("yuan"), Some(CostCurrency::Cny)); |
| 3506 | assert_eq!(CostCurrency::from_setting("rmb"), Some(CostCurrency::Cny)); |
| 3507 | assert_eq!(CostCurrency::from_setting("cny"), Some(CostCurrency::Cny)); |
| 3508 | assert_eq!(CostCurrency::from_setting("eur"), None); |
| 3509 | } |
| 3510 | |
| 3511 | #[test] |
| 3512 | fn format_cost_amount_uses_selected_symbol() { |
| 3513 | assert_eq!(format_cost_amount(0.42, CostCurrency::Usd), "$0.42"); |
| 3514 | assert_eq!(format_cost_amount(2.0, CostCurrency::Cny), "¥2.00"); |
| 3515 | assert_eq!(format_cost_amount(0.0, CostCurrency::Usd), "$0.00"); |
| 3516 | assert_eq!(format_cost_amount(0.00001, CostCurrency::Usd), "<$0.0001"); |
| 3517 | } |
| 3518 | |
| 3519 | #[test] |
| 3520 | fn format_cost_amount_precise_keeps_report_precision() { |
| 3521 | assert_eq!( |
| 3522 | format_cost_amount_precise(0.1234, CostCurrency::Usd), |
| 3523 | "$0.1234" |
| 3524 | ); |
| 3525 | assert_eq!( |
| 3526 | format_cost_amount_precise(0.1234, CostCurrency::Cny), |
| 3527 | "¥0.1234" |
| 3528 | ); |
| 3529 | assert_eq!( |
| 3530 | format_cost_amount_precise(0.0, CostCurrency::Usd), |
| 3531 | "$0.0000" |
| 3532 | ); |
| 3533 | assert_eq!( |
| 3534 | format_cost_amount_precise(0.00001, CostCurrency::Usd), |
| 3535 | "<$0.0001" |
| 3536 | ); |
| 3537 | } |
| 3538 | |
| 3539 | #[test] |
| 3540 | fn accumulated_cost_stays_finite_and_nonnegative() { |
| 3541 | let saturated = CostEstimate { |
| 3542 | usd: f64::MAX, |
| 3543 | cny: 1.0, |
| 3544 | } |
| 3545 | .saturating_add(CostEstimate { |
| 3546 | usd: f64::MAX, |
| 3547 | cny: -1.0, |
| 3548 | }); |
| 3549 | assert_eq!(saturated.usd, f64::MAX); |
| 3550 | assert_eq!(saturated.cny, 1.0); |
| 3551 | assert!(saturated.is_finite_nonnegative()); |
| 3552 | |
| 3553 | assert_eq!( |
| 3554 | CostEstimate { |
| 3555 | usd: f64::NAN, |
| 3556 | cny: f64::INFINITY, |
| 3557 | } |
| 3558 | .sanitized(), |
| 3559 | CostEstimate::default() |
| 3560 | ); |
| 3561 | } |
| 3562 | |
| 3563 | // ── BalanceResponse / BalanceInfo ────────────────────────────── |
| 3564 | |
| 3565 | #[test] |
| 3566 | fn balance_response_deserializes_from_json() { |
| 3567 | let json = r#"{ |
| 3568 | "is_available": true, |
| 3569 | "balance_infos": [ |
| 3570 | { |
| 3571 | "currency": "CNY", |
| 3572 | "total_balance": "123.45", |
| 3573 | "topped_up_balance": "100.00", |
| 3574 | "granted_balance": "23.45" |
| 3575 | } |
| 3576 | ] |
| 3577 | }"#; |
| 3578 | let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); |
| 3579 | assert!(resp.is_available); |
| 3580 | assert_eq!(resp.balance_infos.len(), 1); |
| 3581 | let info = &resp.balance_infos[0]; |
| 3582 | assert_eq!(info.currency, "CNY"); |
| 3583 | assert_eq!(info.total_balance, "123.45"); |
| 3584 | assert_eq!(info.topped_up_balance, "100.00"); |
| 3585 | assert_eq!(info.granted_balance, "23.45"); |
| 3586 | } |
| 3587 | |
| 3588 | #[test] |
| 3589 | fn balance_response_defaults_empty_balance_infos_when_unavailable() { |
| 3590 | let json = r#"{"is_available": false, "balance_infos": []}"#; |
| 3591 | let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); |
| 3592 | assert!(!resp.is_available); |
| 3593 | assert!(resp.balance_infos.is_empty()); |
| 3594 | } |
| 3595 | |
| 3596 | #[test] |
| 3597 | fn balance_response_empty_list_is_valid() { |
| 3598 | let json = r#"{"is_available": true, "balance_infos": []}"#; |
| 3599 | let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); |
| 3600 | assert!(resp.is_available); |
| 3601 | assert!(resp.balance_infos.is_empty()); |
| 3602 | } |
| 3603 | |
| 3604 | // ── BalanceInfo::total_balance_f64 ───────────────────────────── |
| 3605 | } |
| 3606 |