Skip to main content

vct_core/pricing/
matching.rs

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
10// Similarity threshold for fuzzy matching (0.0 to 1.0)
11const SIMILARITY_THRESHOLD: f64 = 0.7;
12
13// Maximum number of cached pricing lookups per pricing map.
14const PRICING_MATCH_CACHE_SIZE: usize = 64;
15
16// Incrementing this invalidates the per-instance caches lazily. This keeps the
17// public clear_pricing_cache() API useful without reintroducing a global result
18// cache that can leak matches between unrelated pricing maps.
19static MATCH_CACHE_GENERATION: AtomicU64 = AtomicU64::new(0);
20
21/// Result of a model pricing lookup, including the matched model name for transparency.
22#[derive(Debug, Clone)]
23pub struct ModelPricingResult {
24    /// Pricing for the matched model, or `ModelPricing::default()` (all zero) on no match.
25    pub pricing: ModelPricing,
26    /// The actual model key that matched, or `None` for an exact match or no match.
27    pub matched_model: Option<String>,
28}
29
30/// Optimized pricing map with precomputed indices for O(1) exact matches and fast fuzzy matching.
31#[derive(Debug, Clone)]
32pub struct ModelPricingMap {
33    // Original pricing data (use Rc<str> to avoid cloning keys)
34    raw: HashMap<Rc<str>, ModelPricing>,
35    // Precomputed normalized keys for fast matching
36    normalized_index: HashMap<String, Vec<Rc<str>>>,
37    // Precomputed lowercase keys for substring/fuzzy matching
38    lowercase_keys: Vec<(String, Rc<str>)>, // (lowercase_key, original_key as Rc)
39    // Lookup results belong to this map. A process-global result cache is
40    // incorrect because model names can map to different prices in each map.
41    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    /// Creates a new pricing map with precomputed indices for optimized lookups.
63    ///
64    /// This constructor processes the raw pricing data to build:
65    /// - Normalized key index for version-agnostic matching.
66    /// - Lowercase key list for substring and fuzzy matching.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use std::collections::HashMap;
72    /// use vct_core::pricing::{ModelPricing, ModelPricingMap};
73    ///
74    /// let mut raw = HashMap::new();
75    /// raw.insert("gpt-4".to_string(), ModelPricing::default());
76    /// let map = ModelPricingMap::new(raw);
77    /// assert!(!map.is_empty());
78    /// ```
79    pub fn new(raw: HashMap<String, ModelPricing>) -> Self {
80        // Pre-allocate with exact capacity
81        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        // Convert keys to Rc<str> to avoid cloning
87        for (key, pricing) in raw {
88            let rc_key: Rc<str> = key.as_str().into();
89
90            // Precompute normalized key
91            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            // Precompute lowercase key for substring/fuzzy matching
98            lowercase_keys.push((key.to_lowercase(), rc_key.clone()));
99
100            rc_raw.insert(rc_key, pricing);
101        }
102
103        // Sort lowercase_keys for potential binary search optimization
104        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    /// Retrieves pricing for a model using a multi-tier matching strategy.
118    ///
119    /// Matching strategy (in order of priority):
120    /// 1. Exact match (O(1) hash lookup).
121    /// 2. Normalized match (removes version suffixes).
122    /// 3. Substring match (bidirectional contains check).
123    /// 4. Fuzzy match (Jaro-Winkler ≥ 0.7 threshold).
124    /// 5. Default (zero cost) if no match found.
125    ///
126    /// Results are cached per map. [`clear_pricing_cache`] invalidates every
127    /// existing map lazily, so even the "no match" outcome can be memoized
128    /// without leaking a result from one pricing table into another.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use std::collections::HashMap;
134    /// use vct_core::pricing::{ModelPricing, ModelPricingMap};
135    ///
136    /// let mut raw = HashMap::new();
137    /// raw.insert(
138    ///     "gpt-4".to_string(),
139    ///     ModelPricing { input_cost_per_token: 3e-5, ..Default::default() },
140    /// );
141    /// let map = ModelPricingMap::new(raw);
142    ///
143    /// // Exact match: `matched_model` stays `None`.
144    /// let hit = map.get("gpt-4");
145    /// assert_eq!(hit.pricing.input_cost_per_token, 3e-5);
146    /// assert!(hit.matched_model.is_none());
147    ///
148    /// // No match: zero-cost default.
149    /// let miss = map.get("does-not-exist-xyzzy");
150    /// assert_eq!(miss.pricing.input_cost_per_token, 0.0);
151    /// ```
152    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        // Fast path 1: Exact match
158        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        // Fast path 2: Normalized match
168        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        // Loose (substring / fuzzy) matching needs a distinctive query: a
181        // placeholder like `default` (what cursor-agent stores for auto-mode
182        // conversations) or a very short fragment would otherwise inherit a
183        // coincidentally-similar model's prices. Unpriced ($0) is the safer
184        // answer for those.
185        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        // Slow path 1: inspect every substring candidate and choose the most
196        // specific overlap. Returning the first HashMap-derived candidate can
197        // otherwise price gpt-4o as gpt-4.
198        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        // Slow path 2: fuzzy matching runs only when normalization and
210        // substring matching found nothing.
211        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    /// Returns the pricing for an **exact** model-name match only.
310    ///
311    /// Unlike [`get`](Self::get), this performs no normalization, substring, or
312    /// fuzzy matching: it returns `Some` only when `model_name` is a verbatim
313    /// key in the pricing table, and `None` otherwise. This is the lookup used
314    /// for providers (OpenCode) that carry their own authoritative cost and
315    /// should fall back to that stored cost rather than guess a price from a
316    /// loosely-similar model name.
317    pub fn get_exact(&self, model_name: &str) -> Option<ModelPricing> {
318        self.raw.get(model_name).cloned()
319    }
320
321    /// Returns whether the pricing map contains no models.
322    pub fn is_empty(&self) -> bool {
323        self.raw.is_empty()
324    }
325
326    /// Returns the raw pricing data with reference-counted keys.
327    pub fn raw(&self) -> &HashMap<Rc<str>, ModelPricing> {
328        &self.raw
329    }
330
331    /// Builds the `Send + Sync` "model → lowest context-tier threshold"
332    /// snapshot the usage scan hands to session parsers for per-request tier
333    /// classification. Models without threshold tiers are absent.
334    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
346/// Invalidates the lookup cache in every pricing map.
347///
348/// Existing maps observe the generation change on their next lookup and clear
349/// their own bounded LRU. No result data is stored globally.
350pub 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
398/// Placeholder names that must never take a loose price from a
399/// coincidentally-similar key (e.g. cursor-agent's literal `default`).
400const LOOSE_MATCH_STOPLIST: [&str; 5] = ["default", "auto", "custom", "unknown", "none"];
401
402/// Minimum model-segment length for substring / fuzzy matching; anything
403/// shorter is too ambiguous to loosely price (exact and normalized matches
404/// still apply to short names like `o3`).
405const 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
420/// Normalizes model names by removing provider prefixes and version suffixes.
421///
422/// Removes patterns like:
423/// - Provider prefixes: `bedrock/`, `openai/`.
424/// - Date suffixes: `-20231201`, `-12345678` (exactly 8 ASCII digits).
425/// - Version suffixes: `-v1.0`, `-v2`.
426///
427/// Optimized to minimize string allocations (a single allocation at the end).
428///
429/// # Examples
430///
431/// ```
432/// use vct_core::pricing::normalize_model_name;
433///
434/// assert_eq!(normalize_model_name("claude-3-sonnet-20240229"), "claude-3-sonnet");
435/// assert_eq!(normalize_model_name("gpt-4-v1.0"), "gpt-4");
436/// assert_eq!(normalize_model_name("bedrock/claude-3-opus"), "claude-3-opus");
437/// ```
438pub fn normalize_model_name(name: &str) -> String {
439    let mut start = 0;
440    let mut end = name.len();
441
442    // Remove provider prefixes (e.g., "bedrock/", "openai/") - do this first
443    if let Some(idx) = name.find('/') {
444        start = idx + 1;
445    }
446
447    loop {
448        let working_slice = &name[start..end];
449
450        // Remove date suffixes only when all eight bytes are ASCII digits.
451        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        // Remove version patterns (e.g., "-v1.0", "-v2").
460        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    // Only allocate once at the end
471    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        // `default` (cursor-agent's auto-mode placeholder) would substring
508        // match fireworks-ai-default; it must stay unpriced instead.
509        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        // Short fragments skip loose matching too...
514        let result = map.get("xyz");
515        assert_eq!(result.pricing.input_cost_per_token, 0.0);
516
517        // ...but exact matches still work at any length.
518        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        // Test exact model name match
558        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); // Should match
568
569        let result2 = map.get("claude-3-opus");
570        assert!(result2.pricing.input_cost_per_token > 0.0); // Should match
571    }
572
573    #[test]
574    fn test_normalized_match() {
575        // Test normalized matching (removes version suffixes)
576        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        // Should match via substring or fuzzy matching
584        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        // Test substring matching
591        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        // Should match via substring or normalization
599        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        // Test case-insensitive matching
606        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        // Test fuzzy matching with similar names
618        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        // Slightly misspelled should still match (if similarity >= 0.7)
624        let result = map.get("claude-3-sonet");
625        // This might or might not match depending on Jaro-Winkler score
626        // Just verify it returns a result
627        assert!(result.pricing.input_cost_per_token >= 0.0);
628    }
629
630    #[test]
631    fn test_get_exact_only_matches_verbatim() {
632        // get_exact must NOT normalize, substring, or fuzzy match.
633        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        // These all resolve via get() (substring/fuzzy) but must miss get_exact.
639        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        // Test that unmatched models return default (zero cost)
647        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        // Test with multiple models
659        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        // Test that ModelPricingMap can be debug formatted
699        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        // Test that ModelPricingMap can be cloned
711        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        // Test that exact match takes priority over fuzzy match
745        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        // Exact match should be used
763        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        // Test that version numbers are handled correctly
770        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        // Should match without version
776        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        // Test that ModelPricingResult can be cloned
783        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        // Test that ModelPricingResult can be debug formatted
799        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        // Test model names with special characters
813        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        // Test with very long model name
829        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        // Test model names with unicode characters
842        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}