1use super::cache::ModelPricing;
2use lru::LruCache;
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::num::NonZeroUsize;
6use std::rc::Rc;
7use std::sync::atomic::{AtomicU64, Ordering};
8use strsim::jaro_winkler;
9
10const SIMILARITY_THRESHOLD: f64 = 0.7;
12
13const PRICING_MATCH_CACHE_SIZE: usize = 64;
15
16static MATCH_CACHE_GENERATION: AtomicU64 = AtomicU64::new(0);
20
21#[derive(Debug, Clone)]
23pub struct ModelPricingResult {
24 pub pricing: ModelPricing,
26 pub matched_model: Option<String>,
28}
29
30#[derive(Debug, Clone)]
32pub struct ModelPricingMap {
33 raw: HashMap<Rc<str>, ModelPricing>,
35 normalized_index: HashMap<String, Vec<Rc<str>>>,
37 lowercase_keys: Vec<(String, Rc<str>)>, match_cache: RefCell<MatchCache>,
42}
43
44#[derive(Debug, Clone)]
45struct MatchCache {
46 generation: u64,
47 entries: LruCache<String, ModelPricingResult>,
48}
49
50impl MatchCache {
51 fn new() -> Self {
52 let capacity = NonZeroUsize::new(PRICING_MATCH_CACHE_SIZE)
53 .expect("pricing match cache capacity must be non-zero");
54 Self {
55 generation: MATCH_CACHE_GENERATION.load(Ordering::Acquire),
56 entries: LruCache::new(capacity),
57 }
58 }
59}
60
61impl ModelPricingMap {
62 pub fn new(raw: HashMap<String, ModelPricing>) -> Self {
80 let capacity = raw.len();
82 let mut normalized_index = HashMap::with_capacity(capacity);
83 let mut lowercase_keys = Vec::with_capacity(capacity);
84 let mut rc_raw = HashMap::with_capacity(capacity);
85
86 for (key, pricing) in raw {
88 let rc_key: Rc<str> = key.as_str().into();
89
90 let normalized = normalize_model_name(&key);
92 normalized_index
93 .entry(normalized)
94 .or_insert_with(Vec::new)
95 .push(rc_key.clone());
96
97 lowercase_keys.push((key.to_lowercase(), rc_key.clone()));
99
100 rc_raw.insert(rc_key, pricing);
101 }
102
103 lowercase_keys.sort_by(|a, b| a.0.cmp(&b.0));
105 for candidates in normalized_index.values_mut() {
106 candidates.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
107 }
108
109 Self {
110 raw: rc_raw,
111 normalized_index,
112 lowercase_keys,
113 match_cache: RefCell::new(MatchCache::new()),
114 }
115 }
116
117 pub fn get(&self, model_name: &str) -> ModelPricingResult {
153 if let Some(cached_result) = self.cached_result(model_name) {
154 return cached_result;
155 }
156
157 if let Some(pricing) = self.raw.get(model_name) {
159 let result = ModelPricingResult {
160 pricing: pricing.clone(),
161 matched_model: None,
162 };
163 self.cache_result(model_name, &result);
164 return result;
165 }
166
167 let normalized_name = normalize_model_name(model_name);
169 if let Some(original_key) = self.normalized_match(model_name, &normalized_name)
170 && let Some(pricing) = self.raw.get(original_key.as_ref())
171 {
172 let result = ModelPricingResult {
173 pricing: pricing.clone(),
174 matched_model: Some(original_key.to_string()),
175 };
176 self.cache_result(model_name, &result);
177 return result;
178 }
179
180 let model_lower = model_name.to_lowercase();
186 if !eligible_for_loose_match(model_without_provider(&model_lower)) {
187 let result = ModelPricingResult {
188 pricing: ModelPricing::default(),
189 matched_model: None,
190 };
191 self.cache_result(model_name, &result);
192 return result;
193 }
194
195 if let Some(matched_key) = self.substring_match(&model_lower)
199 && let Some(pricing) = self.raw.get(matched_key.as_ref())
200 {
201 let result = ModelPricingResult {
202 pricing: pricing.clone(),
203 matched_model: Some(matched_key.to_string()),
204 };
205 self.cache_result(model_name, &result);
206 return result;
207 }
208
209 if let Some(matched_key) = self.fuzzy_match(&model_lower)
212 && let Some(pricing) = self.raw.get(matched_key.as_ref())
213 {
214 let result = ModelPricingResult {
215 pricing: pricing.clone(),
216 matched_model: Some(matched_key.to_string()),
217 };
218 self.cache_result(model_name, &result);
219 return result;
220 }
221
222 let result = ModelPricingResult {
223 pricing: ModelPricing::default(),
224 matched_model: None,
225 };
226 self.cache_result(model_name, &result);
227 result
228 }
229
230 fn cached_result(&self, model_name: &str) -> Option<ModelPricingResult> {
231 let mut cache = self.match_cache.borrow_mut();
232 refresh_cache_generation(&mut cache);
233 cache.entries.get(model_name).cloned()
234 }
235
236 fn cache_result(&self, model_name: &str, result: &ModelPricingResult) {
237 let mut cache = self.match_cache.borrow_mut();
238 refresh_cache_generation(&mut cache);
239 cache.entries.put(model_name.to_string(), result.clone());
240 }
241
242 fn normalized_match(&self, model_name: &str, normalized_name: &str) -> Option<Rc<str>> {
243 let candidates = self.normalized_index.get(normalized_name)?;
244 let query_provider = provider_prefix(model_name);
245
246 candidates
247 .iter()
248 .min_by(|a, b| {
249 normalized_candidate_rank(a, model_name, normalized_name, query_provider).cmp(
250 &normalized_candidate_rank(b, model_name, normalized_name, query_provider),
251 )
252 })
253 .cloned()
254 }
255
256 fn substring_match(&self, model_lower: &str) -> Option<Rc<str>> {
257 let model_segment = model_without_provider(model_lower);
258 if model_segment.is_empty() {
259 return None;
260 }
261 let query_provider = provider_prefix(model_lower);
262
263 self.lowercase_keys
264 .iter()
265 .filter_map(|(key_lower, original_key)| {
266 let key_segment = model_without_provider(key_lower);
267 if key_segment.is_empty()
268 || !(model_segment.contains(key_segment) || key_segment.contains(model_segment))
269 {
270 return None;
271 }
272 let overlap = model_segment.len().min(key_segment.len());
273 let length_difference = model_segment.len().abs_diff(key_segment.len());
274 let provider_rank = substring_provider_rank(query_provider, key_lower);
275 Some((overlap, length_difference, provider_rank, original_key))
276 })
277 .min_by(|a, b| {
278 b.0.cmp(&a.0)
279 .then_with(|| a.1.cmp(&b.1))
280 .then_with(|| a.2.cmp(&b.2))
281 .then_with(|| a.3.as_ref().cmp(b.3.as_ref()))
282 })
283 .map(|(_, _, _, key)| key.clone())
284 }
285
286 fn fuzzy_match(&self, model_lower: &str) -> Option<Rc<str>> {
287 if model_lower.is_empty() {
288 return None;
289 }
290
291 self.lowercase_keys
292 .iter()
293 .filter_map(|(key_lower, original_key)| {
294 let similarity = jaro_winkler(model_lower, key_lower);
295 (similarity >= SIMILARITY_THRESHOLD).then_some((
296 similarity,
297 model_lower.len().abs_diff(key_lower.len()),
298 original_key,
299 ))
300 })
301 .min_by(|a, b| {
302 b.0.total_cmp(&a.0)
303 .then_with(|| a.1.cmp(&b.1))
304 .then_with(|| a.2.as_ref().cmp(b.2.as_ref()))
305 })
306 .map(|(_, _, key)| key.clone())
307 }
308
309 pub fn get_exact(&self, model_name: &str) -> Option<ModelPricing> {
318 self.raw.get(model_name).cloned()
319 }
320
321 pub fn is_empty(&self) -> bool {
323 self.raw.is_empty()
324 }
325
326 pub fn raw(&self) -> &HashMap<Rc<str>, ModelPricing> {
328 &self.raw
329 }
330
331 pub fn tier_thresholds(&self) -> crate::pricing::TierThresholds {
335 crate::pricing::TierThresholds::from_entries(self.raw.iter().filter_map(
336 |(key, pricing)| {
337 pricing
338 .tiers
339 .first()
340 .map(|tier| (key.as_ref(), tier.threshold_tokens))
341 },
342 ))
343 }
344}
345
346pub fn clear_pricing_cache() {
351 MATCH_CACHE_GENERATION.fetch_add(1, Ordering::AcqRel);
352}
353
354fn refresh_cache_generation(cache: &mut MatchCache) {
355 let generation = MATCH_CACHE_GENERATION.load(Ordering::Acquire);
356 if cache.generation != generation {
357 cache.entries.clear();
358 cache.generation = generation;
359 }
360}
361
362fn provider_prefix(model_name: &str) -> Option<&str> {
363 model_name
364 .split_once('/')
365 .and_then(|(prefix, _)| (!prefix.is_empty()).then_some(prefix))
366}
367
368fn normalized_candidate_rank<'a>(
369 candidate: &'a str,
370 model_name: &str,
371 normalized_name: &str,
372 query_provider: Option<&str>,
373) -> (u8, usize, &'a str) {
374 let same_provider_base = query_provider.is_some()
375 && provider_prefix(candidate) == query_provider
376 && model_without_provider(candidate) == normalized_name;
377 let unprefixed_base = provider_prefix(candidate).is_none() && candidate == normalized_name;
378 let priority = if same_provider_base {
379 0
380 } else if unprefixed_base {
381 1
382 } else {
383 2
384 };
385 (
386 priority,
387 model_name.len().abs_diff(candidate.len()),
388 candidate,
389 )
390}
391
392fn model_without_provider(model_name: &str) -> &str {
393 model_name
394 .split_once('/')
395 .map_or(model_name, |(_, model)| model)
396}
397
398const LOOSE_MATCH_STOPLIST: [&str; 5] = ["default", "auto", "custom", "unknown", "none"];
401
402const LOOSE_MATCH_MIN_SEGMENT_LEN: usize = 4;
406
407fn eligible_for_loose_match(model_segment: &str) -> bool {
408 model_segment.len() >= LOOSE_MATCH_MIN_SEGMENT_LEN
409 && !LOOSE_MATCH_STOPLIST.contains(&model_segment)
410}
411
412fn substring_provider_rank(query_provider: Option<&str>, candidate: &str) -> u8 {
413 match (query_provider, provider_prefix(candidate)) {
414 (Some(query), Some(candidate)) if query == candidate => 0,
415 (_, None) => 1,
416 _ => 2,
417 }
418}
419
420pub fn normalize_model_name(name: &str) -> String {
439 let mut start = 0;
440 let mut end = name.len();
441
442 if let Some(idx) = name.find('/') {
444 start = idx + 1;
445 }
446
447 loop {
448 let working_slice = &name[start..end];
449
450 if let Some((base, suffix)) = working_slice.rsplit_once('-')
452 && suffix.len() == 8
453 && suffix.bytes().all(|byte| byte.is_ascii_digit())
454 {
455 end = start + base.len();
456 continue;
457 }
458
459 if let Some((base, suffix)) = working_slice.rsplit_once("-v")
461 && is_numeric_version_suffix(suffix)
462 {
463 end = start + base.len();
464 continue;
465 }
466
467 break;
468 }
469
470 name[start..end].to_string()
472}
473
474fn is_numeric_version_suffix(suffix: &str) -> bool {
475 !suffix.is_empty()
476 && suffix
477 .split('.')
478 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
479}
480
481#[cfg(test)]
482mod tests {
483 use super::super::cache::ThresholdTier;
484 use super::*;
485 use std::collections::HashMap;
486
487 #[test]
488 fn generic_placeholder_names_never_loose_match() {
489 clear_pricing_cache();
490 let mut raw = HashMap::new();
491 raw.insert(
492 "fireworks-ai-default".to_string(),
493 ModelPricing {
494 input_cost_per_token: 0.5,
495 ..Default::default()
496 },
497 );
498 raw.insert(
499 "aut".to_string(),
500 ModelPricing {
501 input_cost_per_token: 0.5,
502 ..Default::default()
503 },
504 );
505 let map = ModelPricingMap::new(raw);
506
507 let result = map.get("default");
510 assert_eq!(result.pricing.input_cost_per_token, 0.0);
511 assert_eq!(result.matched_model, None);
512
513 let result = map.get("xyz");
515 assert_eq!(result.pricing.input_cost_per_token, 0.0);
516
517 let result = map.get("aut");
519 assert_eq!(result.pricing.input_cost_per_token, 0.5);
520 assert_eq!(result.matched_model, None);
521 }
522
523 #[test]
524 fn test_normalize_model_name() {
525 assert_eq!(
526 normalize_model_name("claude-3-sonnet-20240229"),
527 "claude-3-sonnet"
528 );
529 assert_eq!(normalize_model_name("gpt-4-v1.0"), "gpt-4");
530 assert_eq!(
531 normalize_model_name("bedrock/claude-3-opus"),
532 "claude-3-opus"
533 );
534 }
535
536 fn create_test_pricing() -> ModelPricing {
537 ModelPricing {
538 input_cost_per_token: 0.000001,
539 output_cost_per_token: 0.000002,
540 cache_read_input_token_cost: 0.0000001,
541 cache_creation_input_token_cost: 0.0000005,
542 tiers: vec![ThresholdTier {
543 threshold_tokens: 200_000,
544 input_cost_per_token: 0.000002,
545 output_cost_per_token: 0.000004,
546 cache_read_input_token_cost: 0.0000002,
547 cache_creation_input_token_cost: 0.000001,
548 ..Default::default()
549 }],
550 ranges: None,
551 ..Default::default()
552 }
553 }
554
555 #[test]
556 fn test_exact_match() {
557 clear_pricing_cache();
559
560 let mut raw = HashMap::new();
561 raw.insert("gpt-4".to_string(), create_test_pricing());
562 raw.insert("claude-3-opus".to_string(), create_test_pricing());
563
564 let map = ModelPricingMap::new(raw);
565
566 let result = map.get("gpt-4");
567 assert!(result.pricing.input_cost_per_token > 0.0); let result2 = map.get("claude-3-opus");
570 assert!(result2.pricing.input_cost_per_token > 0.0); }
572
573 #[test]
574 fn test_normalized_match() {
575 clear_pricing_cache();
577
578 let mut raw = HashMap::new();
579 raw.insert("gpt-4-0613".to_string(), create_test_pricing());
580
581 let map = ModelPricingMap::new(raw);
582
583 let result = map.get("gpt-4");
585 assert!(result.pricing.input_cost_per_token > 0.0);
586 }
587
588 #[test]
589 fn test_substring_match() {
590 clear_pricing_cache();
592
593 let mut raw = HashMap::new();
594 raw.insert("claude-3-opus-20240229".to_string(), create_test_pricing());
595
596 let map = ModelPricingMap::new(raw);
597
598 let result = map.get("claude-3-opus");
600 assert!(result.pricing.input_cost_per_token > 0.0);
601 }
602
603 #[test]
604 fn test_case_insensitive_match() {
605 let mut raw = HashMap::new();
607 raw.insert("GPT-4".to_string(), create_test_pricing());
608
609 let map = ModelPricingMap::new(raw);
610
611 let result = map.get("gpt-4");
612 assert!(result.pricing.input_cost_per_token > 0.0);
613 }
614
615 #[test]
616 fn test_fuzzy_match() {
617 let mut raw = HashMap::new();
619 raw.insert("claude-3-sonnet".to_string(), create_test_pricing());
620
621 let map = ModelPricingMap::new(raw);
622
623 let result = map.get("claude-3-sonet");
625 assert!(result.pricing.input_cost_per_token >= 0.0);
628 }
629
630 #[test]
631 fn test_get_exact_only_matches_verbatim() {
632 let mut raw = HashMap::new();
634 raw.insert("gpt-4".to_string(), create_test_pricing());
635 let map = ModelPricingMap::new(raw);
636
637 assert!(map.get_exact("gpt-4").is_some());
638 assert!(map.get_exact("gpt-4-turbo").is_none());
640 assert!(map.get_exact("deepseek-v4-pro").is_none());
641 assert!(map.get_exact("GPT-4").is_none());
642 }
643
644 #[test]
645 fn test_no_match_returns_default() {
646 let raw = HashMap::new();
648 let map = ModelPricingMap::new(raw);
649
650 let result = map.get("unknown-model");
651 assert_eq!(result.pricing.input_cost_per_token, 0.0);
652 assert_eq!(result.pricing.output_cost_per_token, 0.0);
653 assert!(result.matched_model.is_none());
654 }
655
656 #[test]
657 fn test_multiple_models() {
658 let mut raw = HashMap::new();
660 let pricing1 = ModelPricing {
661 input_cost_per_token: 0.000001,
662 output_cost_per_token: 0.000002,
663 ..Default::default()
664 };
665 let pricing2 = ModelPricing {
666 input_cost_per_token: 0.000003,
667 output_cost_per_token: 0.000006,
668 ..Default::default()
669 };
670
671 raw.insert("model-a".to_string(), pricing1);
672 raw.insert("model-b".to_string(), pricing2);
673
674 let map = ModelPricingMap::new(raw);
675
676 let result_a = map.get("model-a");
677 assert_eq!(result_a.pricing.input_cost_per_token, 0.000001);
678
679 let result_b = map.get("model-b");
680 assert_eq!(result_b.pricing.input_cost_per_token, 0.000003);
681 }
682
683 #[test]
684 fn test_empty_model_name() {
685 clear_pricing_cache();
686
687 let mut raw = HashMap::new();
688 raw.insert("gpt-4".to_string(), create_test_pricing());
689
690 let map = ModelPricingMap::new(raw);
691
692 let result = map.get("");
693 assert_eq!(result.pricing.input_cost_per_token, 0.0);
694 }
695
696 #[test]
697 fn test_pricing_map_debug() {
698 let mut raw = HashMap::new();
700 raw.insert("test-model".to_string(), create_test_pricing());
701
702 let map = ModelPricingMap::new(raw);
703 let debug_str = format!("{:?}", map);
704
705 assert!(!debug_str.is_empty());
706 }
707
708 #[test]
709 fn test_pricing_map_clone() {
710 let mut raw = HashMap::new();
712 raw.insert("test-model".to_string(), create_test_pricing());
713
714 let map1 = ModelPricingMap::new(raw);
715 let map2 = map1.clone();
716
717 let result1 = map1.get("test-model");
718 let result2 = map2.get("test-model");
719
720 assert_eq!(
721 result1.pricing.input_cost_per_token,
722 result2.pricing.input_cost_per_token
723 );
724 }
725
726 #[test]
727 fn clear_invalidates_existing_map_cache() {
728 let map = ModelPricingMap::new(HashMap::new());
729 assert_eq!(map.match_cache.borrow().entries.cap().get(), 64);
730 map.get("before-clear");
731 let old_generation = map.match_cache.borrow().generation;
732
733 clear_pricing_cache();
734 map.get("after-clear");
735
736 let cache = map.match_cache.borrow();
737 assert_ne!(cache.generation, old_generation);
738 assert_eq!(cache.entries.len(), 1);
739 assert!(cache.entries.peek("after-clear").is_some());
740 }
741
742 #[test]
743 fn test_match_priority() {
744 clear_pricing_cache();
746
747 let mut raw = HashMap::new();
748 let exact_pricing = ModelPricing {
749 input_cost_per_token: 0.000001,
750 ..Default::default()
751 };
752 let other_pricing = ModelPricing {
753 input_cost_per_token: 0.000099,
754 ..Default::default()
755 };
756
757 raw.insert("gpt-4".to_string(), exact_pricing);
758 raw.insert("gpt-4-turbo".to_string(), other_pricing);
759
760 let map = ModelPricingMap::new(raw);
761
762 let result = map.get("gpt-4");
764 assert_eq!(result.pricing.input_cost_per_token, 0.000001);
765 }
766
767 #[test]
768 fn test_version_stripping() {
769 let mut raw = HashMap::new();
771 raw.insert("claude-3-opus".to_string(), create_test_pricing());
772
773 let map = ModelPricingMap::new(raw);
774
775 let result = map.get("claude-3-opus-20240229");
777 assert!(result.pricing.input_cost_per_token > 0.0);
778 }
779
780 #[test]
781 fn test_result_clone() {
782 let mut raw = HashMap::new();
784 raw.insert("test".to_string(), create_test_pricing());
785
786 let map = ModelPricingMap::new(raw);
787 let result1 = map.get("test");
788 let result2 = result1.clone();
789
790 assert_eq!(
791 result1.pricing.input_cost_per_token,
792 result2.pricing.input_cost_per_token
793 );
794 }
795
796 #[test]
797 fn test_result_debug() {
798 let mut raw = HashMap::new();
800 raw.insert("test".to_string(), create_test_pricing());
801
802 let map = ModelPricingMap::new(raw);
803 let result = map.get("test");
804 let debug_str = format!("{:?}", result);
805
806 assert!(!debug_str.is_empty());
807 assert!(debug_str.contains("pricing"));
808 }
809
810 #[test]
811 fn test_special_characters() {
812 let mut raw = HashMap::new();
814 raw.insert("model/v1.0".to_string(), create_test_pricing());
815 raw.insert("model:latest".to_string(), create_test_pricing());
816
817 let map = ModelPricingMap::new(raw);
818
819 let result1 = map.get("model/v1.0");
820 assert!(result1.pricing.input_cost_per_token > 0.0);
821
822 let result2 = map.get("model:latest");
823 assert!(result2.pricing.input_cost_per_token > 0.0);
824 }
825
826 #[test]
827 fn test_very_long_model_name() {
828 let mut raw = HashMap::new();
830 let long_name = "a".repeat(1000);
831 raw.insert(long_name.clone(), create_test_pricing());
832
833 let map = ModelPricingMap::new(raw);
834
835 let result = map.get(&long_name);
836 assert!(result.pricing.input_cost_per_token > 0.0);
837 }
838
839 #[test]
840 fn test_unicode_model_names() {
841 let mut raw = HashMap::new();
843 raw.insert("模型-1".to_string(), create_test_pricing());
844 raw.insert("モデル-2".to_string(), create_test_pricing());
845
846 let map = ModelPricingMap::new(raw);
847
848 let result1 = map.get("模型-1");
849 assert!(result1.pricing.input_cost_per_token > 0.0);
850
851 let result2 = map.get("モデル-2");
852 assert!(result2.pricing.input_cost_per_token > 0.0);
853 }
854}