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