66//! reliable balance endpoint exists.
77
88use chrono:: { DateTime , TimeZone , Utc } ;
9- use codewhale_config:: pricing:: { OfferingPricing , TokenUsage } ;
9+ use codewhale_config:: pricing:: { Currency , OfferingPricing , TokenUsage } ;
1010
1111use crate :: config:: {
1212 ApiProvider , DEEPSEEK_ALIAS_REPLACEMENT , DEEPSEEK_ALIAS_RETIREMENT_UTC ,
13- DEFAULT_STEPFUN_BASE_URL , DEFAULT_STEPFUN_MODEL ,
13+ DEFAULT_STEPFUN_BASE_URL , DEFAULT_STEPFUN_MODEL , canonical_model_id_for_provider ,
1414} ;
1515use crate :: models:: Usage ;
1616
@@ -200,9 +200,7 @@ pub fn has_pricing_for_model(model: &str) -> bool {
200200/// subscription/account scoped, while StepFun needs PAYG-vs-Plan provenance.
201201#[ must_use]
202202pub fn has_pricing_for_provider ( provider : ApiProvider , model : & str ) -> bool {
203- provider != ApiProvider :: OpenaiCodex
204- && !route_requires_billing_surface ( provider, model)
205- && has_pricing_for_model ( model)
203+ calculate_turn_cost_estimate_for_provider ( provider, model, & Usage :: default ( ) ) . is_some ( )
206204}
207205
208206fn pricing_for_model_at ( model : & str , now : DateTime < Utc > ) -> Option < ModelPricing > {
@@ -421,6 +419,7 @@ pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64>
421419
422420/// Calculate cost from provider usage in both official currencies.
423421#[ must_use]
422+ #[ cfg( test) ]
424423pub fn calculate_turn_cost_estimate_from_usage ( model : & str , usage : & Usage ) -> Option < CostEstimate > {
425424 let pricing = pricing_for_model ( model) ?;
426425 Some ( cost_estimate_with_pricing ( pricing, usage) )
@@ -446,19 +445,14 @@ pub fn calculate_turn_cost_estimate_for_provider(
446445 model : & str ,
447446 usage : & Usage ,
448447) -> Option < CostEstimate > {
449- if provider == ApiProvider :: OpenaiCodex || route_requires_billing_surface ( provider, model) {
450- return None ;
451- }
452- if let Some ( estimate) = provider_catalog_cache_write_estimate ( provider, model, usage) {
453- return Some ( estimate) ;
454- }
455- calculate_turn_cost_estimate_from_usage ( model, usage)
448+ calculate_turn_cost_estimate_for_provider_at ( provider, model, usage, Utc :: now ( ) )
456449}
457450
458451/// Estimate a turn when endpoint-derived billing provenance is available.
459452/// StepFun's standard API and Step Plan share provider/model text but not a
460453/// billing system, so that route fails closed unless the PAYG surface is known.
461454#[ must_use]
455+ #[ cfg( test) ]
462456pub ( crate ) fn calculate_turn_cost_estimate_for_route (
463457 provider : ApiProvider ,
464458 model : & str ,
@@ -492,6 +486,7 @@ pub(crate) fn calculate_turn_cost_estimate_for_provider_at(
492486 provider,
493487 ApiProvider :: Deepseek | ApiProvider :: DeepseekCN | ApiProvider :: DeepseekAnthropic
494488 ) ;
489+ let canonical_model = canonical_model_id_for_provider ( provider, normalized_model) ?;
495490 let catalog_model = if direct_deepseek
496491 && matches ! ( model_lower. as_str( ) , "deepseek-chat" | "deepseek-reasoner" )
497492 {
@@ -501,23 +496,35 @@ pub(crate) fn calculate_turn_cost_estimate_for_provider_at(
501496 if recorded_at >= retirement {
502497 return None ;
503498 }
504- DEEPSEEK_ALIAS_REPLACEMENT
499+ DEEPSEEK_ALIAS_REPLACEMENT . to_string ( )
505500 } else {
506- normalized_model
501+ canonical_model
507502 } ;
508- let offering = crate :: provider_lake:: catalog_offering_for_model ( provider, catalog_model) ?;
509- // Sonnet 5 has an explicit recorded-time window, including its cache-write
510- // tier. Other models should retain the provider catalog override added in
511- // #4318 when their recorded usage contains cache creation tokens.
512- let time_scoped_sonnet =
513- provider == ApiProvider :: Anthropic && catalog_model. eq_ignore_ascii_case ( "claude-sonnet-5" ) ;
514- if !time_scoped_sonnet
515- && usage. prompt_cache_write_tokens . unwrap_or ( 0 ) > 0
516- && let Some ( estimate) = catalog_cost_estimate_from_offering ( & offering, usage)
503+
504+ // Direct DeepSeek pricing carries an authoritative CNY row, and Sonnet 5
505+ // has a recorded-time introductory window that a static catalog row cannot
506+ // represent. These exact first-party routes intentionally override the
507+ // catalog; no other provider/model text match is allowed to do so.
508+ if direct_deepseek
509+ || ( provider == ApiProvider :: Anthropic
510+ && catalog_model. eq_ignore_ascii_case ( "claude-sonnet-5" ) )
517511 {
518- return Some ( estimate) ;
512+ let pricing = provider_owned_hand_pricing_at ( provider, & catalog_model, recorded_at) ?;
513+ return Some ( cost_estimate_with_pricing ( pricing, usage) ) ;
514+ }
515+
516+ if let Some ( offering) =
517+ crate :: provider_lake:: catalog_offering_for_model ( provider, & catalog_model)
518+ && OfferingPricing :: from_catalog_offering ( & offering) . is_some ( )
519+ {
520+ return catalog_cost_estimate_for_route ( provider, & catalog_model, & offering, usage) ;
519521 }
520- let pricing = pricing_for_model_at ( catalog_model, recorded_at) ?;
522+
523+ // A few first-party rows predate or intentionally omit a Models.dev entry
524+ // (for example OpenAI API `gpt-5-codex`, Arcee `trinity-mini`, and MiniMax
525+ // `minimax-m2.7`). Preserve only an explicit provider-owned allowlist here;
526+ // a costless foreign/catalog route must remain unpriced.
527+ let pricing = provider_owned_hand_pricing_at ( provider, & catalog_model, recorded_at) ?;
521528 Some ( cost_estimate_with_pricing ( pricing, usage) )
522529}
523530
@@ -540,33 +547,93 @@ pub(crate) fn calculate_turn_cost_estimate_for_route_at(
540547 calculate_turn_cost_estimate_for_provider_at ( provider, model, usage, recorded_at)
541548}
542549
543- fn provider_catalog_cache_write_estimate (
550+ fn provider_owned_hand_pricing_at (
544551 provider : ApiProvider ,
545552 model : & str ,
546- usage : & Usage ,
547- ) -> Option < CostEstimate > {
548- if usage. prompt_cache_write_tokens . unwrap_or ( 0 ) == 0 {
549- return None ;
550- }
551- crate :: provider_lake:: catalog_offering_for_model ( provider, model)
552- . as_ref ( )
553- . and_then ( |offering| catalog_cost_estimate_from_offering ( offering, usage) )
553+ recorded_at : DateTime < Utc > ,
554+ ) -> Option < ModelPricing > {
555+ let model_lower = model. trim ( ) . to_ascii_lowercase ( ) ;
556+ let provider_owns_row = match provider {
557+ ApiProvider :: Deepseek | ApiProvider :: DeepseekCN | ApiProvider :: DeepseekAnthropic => {
558+ matches ! (
559+ model_lower. as_str( ) ,
560+ "deepseek-v4-pro" | "deepseek-v4-flash"
561+ )
562+ }
563+ ApiProvider :: Openai => matches ! (
564+ model_lower. as_str( ) ,
565+ "gpt-5-codex"
566+ | "gpt-5.3-codex"
567+ | "gpt-5.5"
568+ | "gpt-5.5-pro"
569+ | "gpt-5.6"
570+ | "gpt-5.6-sol"
571+ | "gpt-5.6-terra"
572+ | "gpt-5.6-luna"
573+ ) ,
574+ ApiProvider :: Anthropic => matches ! (
575+ model_lower. as_str( ) ,
576+ "claude-opus-4-8"
577+ | "claude-sonnet-4-6"
578+ | "claude-haiku-4-5"
579+ | "claude-fable-5"
580+ | "claude-sonnet-5"
581+ ) ,
582+ ApiProvider :: Zai => matches ! ( model_lower. as_str( ) , "glm-5.1" | "glm-5.2" | "glm-5-turbo" ) ,
583+ ApiProvider :: Moonshot => {
584+ matches ! ( model_lower. as_str( ) , "kimi-k2.6" | "kimi-k2.7-code" )
585+ }
586+ ApiProvider :: Minimax => {
587+ matches ! ( model_lower. as_str( ) , "minimax-m3" | "minimax-m2.7" )
588+ }
589+ ApiProvider :: Arcee => {
590+ matches ! (
591+ model_lower. as_str( ) ,
592+ "trinity-mini" | "trinity-large-thinking"
593+ )
594+ }
595+ ApiProvider :: Meta => model_lower == "muse-spark-1.1" ,
596+ _ => false ,
597+ } ;
598+ provider_owns_row
599+ . then ( || pricing_for_model_at ( & model_lower, recorded_at) )
600+ . flatten ( )
554601}
555602
556- /// Estimate cache-write usage from a sourced catalog row when it publishes the
557- /// separate write tier. Other usage continues through the legacy table, which
558- /// retains CNY estimates and compatibility fallbacks.
559- fn catalog_cost_estimate_from_offering (
603+ /// Estimate usage only from the exact provider offering. Missing prices for a
604+ /// used token class fail closed, except on the two documented first-party
605+ /// routes where cache tokens are explicitly billed at the input rate.
606+ fn catalog_cost_estimate_for_route (
607+ provider : ApiProvider ,
608+ model : & str ,
560609 offering : & codewhale_config:: catalog:: CatalogOffering ,
561610 usage : & Usage ,
562611) -> Option < CostEstimate > {
563612 let usage = token_usage_for_pricing ( usage) ;
564- let pricing = OfferingPricing :: from_catalog_offering ( offering) ?;
565- if usage. cache_write == 0 || pricing. cache_write_per_million . is_none ( ) {
566- return None ;
613+ let mut pricing = OfferingPricing :: from_catalog_offering ( offering) ?;
614+ let model_lower = model. trim ( ) . to_ascii_lowercase ( ) ;
615+ let cache_uses_input_rate = matches ! (
616+ ( provider, model_lower. as_str( ) ) ,
617+ ( ApiProvider :: Openai , "gpt-5.5-pro" ) | ( ApiProvider :: Arcee , "trinity-large-thinking" )
618+ ) ;
619+ if cache_uses_input_rate {
620+ if usage. cache_read > 0 && pricing. cache_read_per_million . is_none ( ) {
621+ pricing. cache_read_per_million = pricing. input_per_million ;
622+ }
623+ if usage. cache_write > 0 && pricing. cache_write_per_million . is_none ( ) {
624+ pricing. cache_write_per_million = pricing. input_per_million ;
625+ }
567626 }
568627
569- pricing. estimate_cost ( & usage) . map ( CostEstimate :: usd_only)
628+ let amount = pricing. estimate_cost ( & usage) ?;
629+ match pricing. currency {
630+ Currency :: Usd => Some ( CostEstimate :: usd_only ( amount) ) ,
631+ Currency :: Cny => Some ( CostEstimate {
632+ usd : 0.0 ,
633+ cny : amount,
634+ } ) ,
635+ Currency :: Other ( _) => None ,
636+ }
570637}
571638
572639/// Project provider-normalized turn usage into canonical billable token
@@ -615,40 +682,36 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage:
615682 hit_cost + miss_cost + write_cost + output_cost
616683}
617684
618- /// Estimate how much money was saved by serving `cache_hit_tokens` from the
619- /// prefix cache instead of billing them at the cache-miss rate. Returns `None`
620- /// when the model's pricing is unknown or the number of cache-hit tokens is
621- /// zero (nothing to save).
622- #[ must_use]
623- pub fn calculate_cache_savings ( model : & str , cache_hit_tokens : u32 ) -> Option < CostEstimate > {
624- if cache_hit_tokens == 0 {
625- return None ;
626- }
627- let pricing = pricing_for_model ( model) ?;
628- let tokens = cache_hit_tokens as f64 / 1_000_000.0 ;
629- Some ( CostEstimate {
630- usd : tokens
631- * ( pricing. usd . input_cache_miss_per_million - pricing. usd . input_cache_hit_per_million ) ,
632- cny : pricing
633- . cny
634- . map ( |pricing| {
635- tokens
636- * ( pricing. input_cache_miss_per_million - pricing. input_cache_hit_per_million )
637- } )
638- . unwrap_or ( 0.0 ) ,
639- } )
640- }
641-
685+ /// Estimate cache savings from the exact provider route by comparing the same
686+ /// tokens as cache hits and ordinary input. Unknown or costless routes remain
687+ /// unavailable instead of inheriting a model-only rate.
642688#[ must_use]
643689pub fn calculate_cache_savings_for_provider (
644690 provider : ApiProvider ,
645691 model : & str ,
646692 cache_hit_tokens : u32 ,
647693) -> Option < CostEstimate > {
648- if provider == ApiProvider :: OpenaiCodex || route_requires_billing_surface ( provider , model ) {
694+ if cache_hit_tokens == 0 {
649695 return None ;
650696 }
651- calculate_cache_savings ( model, cache_hit_tokens)
697+ let cached = Usage {
698+ input_tokens : cache_hit_tokens,
699+ prompt_cache_hit_tokens : Some ( cache_hit_tokens) ,
700+ prompt_cache_miss_tokens : Some ( 0 ) ,
701+ ..Usage :: default ( )
702+ } ;
703+ let uncached = Usage {
704+ input_tokens : cache_hit_tokens,
705+ prompt_cache_hit_tokens : Some ( 0 ) ,
706+ prompt_cache_miss_tokens : Some ( cache_hit_tokens) ,
707+ ..Usage :: default ( )
708+ } ;
709+ let cached = calculate_turn_cost_estimate_for_provider ( provider, model, & cached) ?;
710+ let uncached = calculate_turn_cost_estimate_for_provider ( provider, model, & uncached) ?;
711+ Some ( CostEstimate {
712+ usd : uncached. usd - cached. usd ,
713+ cny : uncached. cny - cached. cny ,
714+ } )
652715}
653716
654717/// Format a cost amount for compact display in the chosen currency.
@@ -1003,8 +1066,13 @@ mod tests {
10031066 ..Default :: default ( )
10041067 } ;
10051068
1006- let estimate =
1007- catalog_cost_estimate_from_offering ( & offering, & usage) . expect ( "catalog cost estimate" ) ;
1069+ let estimate = catalog_cost_estimate_for_route (
1070+ ApiProvider :: Anthropic ,
1071+ "catalog-priced-model" ,
1072+ & offering,
1073+ & usage,
1074+ )
1075+ . expect ( "catalog cost estimate" ) ;
10081076 assert ! ( ( estimate. usd - 0.000_382 ) . abs( ) < 1e-15 ) ;
10091077 assert_eq ! ( estimate. cny, 0.0 ) ;
10101078 }
@@ -1051,6 +1119,77 @@ mod tests {
10511119 ) ;
10521120 }
10531121
1122+ #[ test]
1123+ fn provider_cost_keeps_owned_hand_price_without_catalog_offering ( ) {
1124+ let usage = Usage {
1125+ input_tokens : 1_000_000 ,
1126+ output_tokens : 0 ,
1127+ ..Default :: default ( )
1128+ } ;
1129+ assert ! (
1130+ crate :: provider_lake:: catalog_offering_for_model( ApiProvider :: Openai , "gpt-5-codex" )
1131+ . is_none( ) ,
1132+ "regression fixture must exercise the hand-price fallback"
1133+ ) ;
1134+
1135+ let estimate = calculate_turn_cost_estimate_for_provider_at (
1136+ ApiProvider :: Openai ,
1137+ "gpt-5-codex" ,
1138+ & usage,
1139+ Utc :: now ( ) ,
1140+ )
1141+ . expect ( "OpenAI API owns the hand-priced model" ) ;
1142+
1143+ assert ! ( ( estimate. usd - 1.25 ) . abs( ) < f64 :: EPSILON ) ;
1144+ assert_eq ! ( estimate. cny, 0.0 ) ;
1145+ assert ! ( has_pricing_for_provider( ApiProvider :: Openai , "gpt-5-codex" ) ) ;
1146+ }
1147+
1148+ #[ test]
1149+ fn provider_cost_does_not_fabricate_price_for_costless_catalog_route ( ) {
1150+ let offering = crate :: provider_lake:: catalog_offering_for_model (
1151+ ApiProvider :: Openai ,
1152+ "deepseek-v4-pro" ,
1153+ )
1154+ . expect ( "bundled OpenAI-compatible route" ) ;
1155+ assert ! ( OfferingPricing :: from_catalog_offering( & offering) . is_none( ) ) ;
1156+ let usage = Usage {
1157+ input_tokens : 1_000_000 ,
1158+ output_tokens : 0 ,
1159+ ..Default :: default ( )
1160+ } ;
1161+
1162+ assert ! (
1163+ calculate_turn_cost_estimate_for_provider_at(
1164+ ApiProvider :: Openai ,
1165+ "deepseek-v4-pro" ,
1166+ & usage,
1167+ Utc :: now( ) ,
1168+ )
1169+ . is_none( )
1170+ ) ;
1171+ assert ! (
1172+ calculate_turn_cost_estimate_for_provider(
1173+ ApiProvider :: Openai ,
1174+ "deepseek-v4-pro" ,
1175+ & usage,
1176+ )
1177+ . is_none( )
1178+ ) ;
1179+ assert ! ( !has_pricing_for_provider(
1180+ ApiProvider :: Openai ,
1181+ "deepseek-v4-pro"
1182+ ) ) ;
1183+ assert ! (
1184+ calculate_cache_savings_for_provider(
1185+ ApiProvider :: Openai ,
1186+ "deepseek-v4-pro" ,
1187+ 1_000_000 ,
1188+ )
1189+ . is_none( )
1190+ ) ;
1191+ }
1192+
10541193 #[ test]
10551194 fn recorded_time_provider_cost_bounds_deepseek_compatibility_aliases ( ) {
10561195 let usage = Usage {
0 commit comments