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