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