Skip to main content

aither_models/
registry.rs

1//! Model registry with indexed lookup.
2
3use std::collections::HashMap;
4use std::sync::OnceLock;
5
6use aither_core::llm::model::Ability;
7
8use crate::convert;
9use crate::tier::{ChatPriceBreakpoints, ModelTier, classify_entry};
10use crate::types::{ModelEntry, ModelMode, Pricing, Provider};
11
12// The registry table below is emitted by `build.rs` from the LiteLLM snapshot.
13// It is machine-written and never edited by hand; the generator applies its own
14// `#[allow]` attributes because style lints on generated text can only be
15// "fixed" by changing the generator's formatting.
16include!(concat!(env!("OUT_DIR"), "/generated.rs"));
17
18/// A searchable collection of model entries.
19#[derive(Debug)]
20pub struct ModelRegistry {
21    entries: Vec<ModelEntry>,
22    index: HashMap<String, usize>,
23    chat_price_breakpoints: Option<ChatPriceBreakpoints>,
24}
25
26impl ModelRegistry {
27    /// Returns the compile-time bundled registry.
28    #[must_use]
29    pub fn bundled() -> &'static Self {
30        static INSTANCE: OnceLock<ModelRegistry> = OnceLock::new();
31        INSTANCE.get_or_init(|| {
32            let entries: Vec<ModelEntry> = ENTRIES.to_vec();
33            Self::from_entries(entries)
34        })
35    }
36
37    /// Build a registry from parsed `LiteLLM` JSON bytes.
38    #[must_use]
39    pub fn from_litellm_json(json: &[u8]) -> Self {
40        let converted = convert::parse_litellm_json(json);
41        let entries = converted.into_iter().filter_map(convert_to_entry).collect();
42        Self::from_entries(entries)
43    }
44
45    fn from_entries(entries: Vec<ModelEntry>) -> Self {
46        let mut index = HashMap::with_capacity(entries.len() * 2);
47        // Canonical IDs index first-party records only. Unprefixed keys
48        // (`claude-sonnet-4-5`) come first so a bare query resolves the
49        // vendor's own record rather than whichever hosted variant
50        // (`github_copilot/claude-sonnet-4`, `bedrock/…`) appears first.
51        for (i, entry) in entries.iter().enumerate() {
52            if entry.litellm_id() == entry.id() {
53                index.entry(entry.id().to_lowercase()).or_insert(i);
54            }
55        }
56        for (i, entry) in entries.iter().enumerate() {
57            if is_first_party(entry) {
58                index.entry(entry.id().to_lowercase()).or_insert(i);
59            }
60        }
61        // Every entry stays reachable by its full `LiteLLM` key.
62        for (i, entry) in entries.iter().enumerate() {
63            index.entry(entry.litellm_id().to_lowercase()).or_insert(i);
64        }
65        let chat_price_breakpoints = ChatPriceBreakpoints::compute(entries.iter());
66        Self {
67            entries,
68            index,
69            chat_price_breakpoints,
70        }
71    }
72
73    /// Capability/cost tier of a model, classified against this registry's
74    /// chat-model price distribution. Unknown models classify as
75    /// [`ModelTier::Balanced`].
76    #[must_use]
77    pub fn tier(&self, model_id: &str) -> ModelTier {
78        self.lookup(model_id)
79            .map_or(ModelTier::Balanced, |entry| self.tier_of(entry))
80    }
81
82    /// Capability/cost tier of a specific registry entry.
83    #[must_use]
84    pub fn tier_of(&self, entry: &ModelEntry) -> ModelTier {
85        classify_entry(entry, self.chat_price_breakpoints)
86    }
87
88    /// Exact match on the `LiteLLM` key, then version-suffix normalization.
89    ///
90    /// Normalization strips trailing `-vN[:M]`, `-YYYYMMDD`, `-YYYY-MM-DD`,
91    /// `@YYYYMMDD`, and compact `-MMDD` checkpoint dates, rewrites a
92    /// `vMAJOR-MINOR` token to `vMAJOR.MINOR`, and collapses `vMAJOR.MINOR`
93    /// to `vMAJOR` when the dotted form is also a miss, so dated or versioned
94    /// aliases resolve to the base model's record. It never binds a bare
95    /// query to a provider-scoped key (`github_copilot/claude-sonnet-4` only
96    /// answers `github_copilot/` lookups) and never changes the model family:
97    /// an unknown or retired ID returns `None`.
98    #[must_use]
99    pub fn lookup(&self, model_id: &str) -> Option<&ModelEntry> {
100        let mut id = model_id.to_lowercase();
101        loop {
102            if let Some(&idx) = self.index.get(id.as_str()) {
103                return Some(&self.entries[idx]);
104            }
105            id = next_lookup_form(&id)?;
106        }
107    }
108
109    /// All models from a given provider.
110    pub fn models_for_provider(&self, provider: &Provider) -> impl Iterator<Item = &ModelEntry> {
111        let provider = provider.clone();
112        self.entries
113            .iter()
114            .filter(move |e| *e.provider() == provider)
115    }
116
117    /// All models matching a given mode.
118    pub fn models_by_mode(&self, mode: ModelMode) -> impl Iterator<Item = &ModelEntry> {
119        self.entries.iter().filter(move |e| e.mode() == mode)
120    }
121
122    /// All models with a specific ability.
123    pub fn models_with_ability(&self, ability: Ability) -> impl Iterator<Item = &ModelEntry> {
124        self.entries.iter().filter(move |e| e.has_ability(ability))
125    }
126
127    /// Iterate over all entries.
128    pub fn all(&self) -> impl Iterator<Item = &ModelEntry> {
129        self.entries.iter()
130    }
131
132    /// Total number of entries.
133    #[must_use]
134    pub const fn len(&self) -> usize {
135        self.entries.len()
136    }
137
138    /// Whether the registry is empty.
139    #[must_use]
140    pub const fn is_empty(&self) -> bool {
141        self.entries.is_empty()
142    }
143}
144
145/// `LiteLLM` prefixes under which a vendor publishes its own models
146/// (`gemini/gemini-2.5-flash` is Google's record for its own model).
147/// Hosted variants — `azure/`, `bedrock/`, `github_copilot/`, … — are
148/// reachable by their full key but do not claim the bare canonical ID.
149const FIRST_PARTY_PREFIXES: &[&str] = &[
150    "anthropic/",
151    "cohere/",
152    "cohere_chat/",
153    "dashscope/",
154    "deepseek/",
155    "gemini/",
156    "meta-llama/",
157    "mistral/",
158    "openai/",
159    "xai/",
160];
161
162/// Whether an entry is a first-party record allowed to claim the bare
163/// canonical ID: either its key carries no provider prefix at all, or the
164/// prefix is the vendor's own namespace.
165fn is_first_party(entry: &ModelEntry) -> bool {
166    entry.litellm_id() == entry.id()
167        || FIRST_PARTY_PREFIXES
168            .iter()
169            .any(|prefix| entry.litellm_id().starts_with(prefix))
170}
171
172/// Next more-general form of a lookup id, or `None` when none apply.
173///
174/// Order: trailing version/date suffix, then `vMAJOR-MINOR` → `vMAJOR.MINOR`,
175/// then `vMAJOR.MINOR` → `vMAJOR`. Each step is tried only after the previous
176/// form missed in the index.
177fn next_lookup_form(id: &str) -> Option<String> {
178    if let Some(stripped) = strip_version_suffix(id) {
179        return Some(stripped.to_owned());
180    }
181    let minor = find_v_minor(id)?;
182    if minor.sep_byte == b'-' {
183        let mut dotted = id.to_owned();
184        dotted.replace_range(minor.sep..=minor.sep, ".");
185        return Some(dotted);
186    }
187    let mut collapsed = String::with_capacity(id.len() - (minor.minor_end - minor.sep));
188    collapsed.push_str(&id[..minor.sep]);
189    collapsed.push_str(&id[minor.minor_end..]);
190    Some(collapsed)
191}
192
193/// Strip one trailing version suffix from a model ID: `-vN` / `-vN:M`
194/// (Bedrock), `-YYYYMMDD` / `-YYYY-MM-DD` (dated snapshots), `@YYYYMMDD`
195/// (Vertex), or compact `-MMDD` (month 01–12, day 01–31). Returns the base
196/// ID, or `None` when no suffix is recognized.
197fn strip_version_suffix(id: &str) -> Option<&str> {
198    let bytes = id.as_bytes();
199    let n = bytes.len();
200    if let Some((base, suffix)) = id.rsplit_once('-')
201        && let Some(rest) = suffix.strip_prefix('v')
202        && rest
203            .split(':')
204            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
205    {
206        return Some(base);
207    }
208    if n > 9 && matches!(bytes[n - 9], b'-' | b'@') && bytes[n - 8..].iter().all(u8::is_ascii_digit)
209    {
210        return Some(&id[..n - 9]);
211    }
212    if n > 11
213        && bytes[n - 11] == b'-'
214        && bytes[n - 6] == b'-'
215        && bytes[n - 3] == b'-'
216        && bytes[n - 10..n - 6].iter().all(u8::is_ascii_digit)
217        && bytes[n - 5..n - 3].iter().all(u8::is_ascii_digit)
218        && bytes[n - 2..].iter().all(u8::is_ascii_digit)
219    {
220        return Some(&id[..n - 11]);
221    }
222    if n > 5 && bytes[n - 5] == b'-' && is_compact_month_day(&bytes[n - 4..]) {
223        return Some(&id[..n - 5]);
224    }
225    None
226}
227
228/// Four ASCII digits that are a calendar month and day (`01`–`12`, `01`–`31`).
229fn is_compact_month_day(digits: &[u8]) -> bool {
230    if digits.len() != 4 || !digits.iter().all(u8::is_ascii_digit) {
231        return false;
232    }
233    let month = (digits[0] - b'0') * 10 + (digits[1] - b'0');
234    let day = (digits[2] - b'0') * 10 + (digits[3] - b'0');
235    (1..=12).contains(&month) && (1..=31).contains(&day)
236}
237
238/// Location of a `vMAJOR-MINOR` or `vMAJOR.MINOR` token inside a model id.
239struct VMinor {
240    /// Byte index of the `-` or `.` between major and minor.
241    sep: usize,
242    /// Byte index after the minor digits.
243    minor_end: usize,
244    /// The separator between major and minor.
245    sep_byte: u8,
246}
247
248/// First `vMAJOR-MINOR` / `vMAJOR.MINOR` token whose minor is one or two digits.
249fn find_v_minor(id: &str) -> Option<VMinor> {
250    let bytes = id.as_bytes();
251    let mut i = 0;
252    while i < bytes.len() {
253        let at_token_start = i == 0 || bytes[i - 1] == b'-' || bytes[i - 1] == b'/';
254        if bytes[i] != b'v' || !at_token_start {
255            i += 1;
256            continue;
257        }
258        let mut j = i + 1;
259        if j >= bytes.len() || !bytes[j].is_ascii_digit() {
260            i += 1;
261            continue;
262        }
263        while j < bytes.len() && bytes[j].is_ascii_digit() {
264            j += 1;
265        }
266        let Some(&sep_byte) = bytes.get(j) else {
267            i += 1;
268            continue;
269        };
270        if sep_byte != b'-' && sep_byte != b'.' {
271            i += 1;
272            continue;
273        }
274        let mut k = j + 1;
275        let minor_start = k;
276        while k < bytes.len() && bytes[k].is_ascii_digit() {
277            k += 1;
278        }
279        let minor_len = k - minor_start;
280        if !(1..=2).contains(&minor_len) {
281            i += 1;
282            continue;
283        }
284        if k != bytes.len() && bytes[k] != b'-' {
285            i += 1;
286            continue;
287        }
288        return Some(VMinor {
289            sep: j,
290            minor_end: k,
291            sep_byte,
292        });
293    }
294    None
295}
296
297/// Convert a `ConvertedEntry` from the shared convert module into a `ModelEntry`.
298fn convert_to_entry(c: convert::ConvertedEntry) -> Option<ModelEntry> {
299    let provider = parse_provider_enum(&c.provider);
300    let mode = parse_mode_enum(&c.mode)?;
301
302    let mut pricing = crate::types::Pricing::new(c.input_cost_per_token, c.output_cost_per_token);
303    if let Some(v) = c.cache_read_per_token {
304        pricing = pricing.with_cache_read(v);
305    }
306    if let Some(v) = c.cache_write_per_token {
307        pricing = pricing.with_cache_write(v);
308    }
309    if let Some(v) = c.reasoning_per_token {
310        pricing = pricing.with_reasoning(v);
311    }
312    if let Some(v) = c.image_per_token {
313        pricing = pricing.with_image(v);
314    }
315
316    let abilities: Vec<Ability> = c
317        .abilities
318        .iter()
319        .filter_map(|a| parse_ability(a))
320        .collect();
321
322    let mut entry = ModelEntry::new(c.litellm_id, c.canonical_id, provider, mode)
323        .with_pricing(pricing)
324        .with_abilities(abilities);
325
326    if let Some(max_in) = c.max_input_tokens {
327        entry = entry.with_max_input_tokens(max_in);
328    }
329    if let Some(max_out) = c.max_output_tokens {
330        entry = entry.with_max_output_tokens(max_out);
331    }
332    if let Some(date) = c.deprecation_date {
333        entry = entry.with_deprecation_date(date);
334    }
335
336    Some(entry)
337}
338
339fn parse_provider_enum(s: &str) -> Provider {
340    match s {
341        "openai" => Provider::OpenAI,
342        "anthropic" => Provider::Anthropic,
343        "gemini" => Provider::Google,
344        "deepseek" => Provider::DeepSeek,
345        "xai" => Provider::XAI,
346        "mistral" => Provider::Mistral,
347        "meta" => Provider::Meta,
348        "alibaba" => Provider::Alibaba,
349        "bedrock" => Provider::Bedrock,
350        "azure" => Provider::Azure,
351        "vertex_ai" => Provider::VertexAI,
352        "groq" => Provider::Groq,
353        "together_ai" => Provider::Together,
354        "fireworks_ai" => Provider::Fireworks,
355        "replicate" => Provider::Replicate,
356        "cohere" => Provider::Cohere,
357        "perplexity" => Provider::Perplexity,
358        "github_copilot" => Provider::Copilot,
359        other => Provider::Other(std::borrow::Cow::Owned(other.to_string())),
360    }
361}
362
363fn parse_mode_enum(s: &str) -> Option<ModelMode> {
364    match s {
365        "chat" => Some(ModelMode::Chat),
366        "embedding" => Some(ModelMode::Embedding),
367        "image_generation" => Some(ModelMode::ImageGeneration),
368        "audio_transcription" => Some(ModelMode::AudioTranscription),
369        "audio_speech" => Some(ModelMode::AudioSpeech),
370        "rerank" => Some(ModelMode::Rerank),
371        "moderation" => Some(ModelMode::Moderation),
372        "completion" => Some(ModelMode::Completion),
373        "video_generation" => Some(ModelMode::VideoGeneration),
374        "search" => Some(ModelMode::Search),
375        "ocr" => Some(ModelMode::Ocr),
376        _ => None,
377    }
378}
379
380fn parse_ability(s: &str) -> Option<Ability> {
381    match s {
382        "ToolUse" => Some(Ability::ToolUse),
383        "Vision" => Some(Ability::Vision),
384        "Audio" => Some(Ability::Audio),
385        "AudioOutput" => Some(Ability::AudioOutput),
386        "Video" => Some(Ability::Video),
387        "WebSearch" => Some(Ability::WebSearch),
388        "Pdf" => Some(Ability::Pdf),
389        "CodeExecution" => Some(Ability::CodeExecution),
390        "Reasoning" => Some(Ability::Reasoning),
391        "ImageGeneration" => Some(Ability::ImageGeneration),
392        "ComputerUse" => Some(Ability::ComputerUse),
393        "PromptCaching" => Some(Ability::PromptCaching),
394        "AssistantPrefill" => Some(Ability::AssistantPrefill),
395        "PerMessageEffort" => Some(Ability::PerMessageEffort),
396        _ => None,
397    }
398}
399
400#[cfg(test)]
401pub mod tests {
402    use super::*;
403
404    /// Registry built from a small hand-written `LiteLLM` dataset, so lookup
405    /// and tier behaviour is tested independently of the bundled snapshot,
406    /// which upstream refreshes can change at any time.
407    pub fn fixture() -> ModelRegistry {
408        ModelRegistry::from_litellm_json(include_bytes!("../testdata/litellm_fixture.json"))
409    }
410
411    fn litellm_id_of(registry: &ModelRegistry, query: &str) -> Option<String> {
412        registry
413            .lookup(query)
414            .map(|entry| entry.litellm_id().to_owned())
415    }
416
417    /// Per-message effort follows the vendors' documented model lists, on
418    /// the platforms they document it for.
419    #[test]
420    fn per_message_effort_follows_the_documented_models_and_platforms() {
421        use crate::convert::takes_per_message_effort;
422        for (id, expected) in [
423            ("claude-opus-5-5", true),
424            ("claude-sonnet-5-5", true),
425            ("claude-fable-5-1", true),
426            ("claude-sonnet-5", false),
427            ("claude-fable-5", false),
428            ("gpt-6-sol", true),
429            ("gpt-6.1-sol", true),
430            ("gpt-5", false),
431        ] {
432            let entry = crate::lookup(id).unwrap_or_else(|| panic!("{id} is in the snapshot"));
433            assert_eq!(
434                entry.has_ability(Ability::PerMessageEffort),
435                expected,
436                "{id}"
437            );
438        }
439        assert!(takes_per_message_effort(
440            "vertex_ai",
441            "claude-opus-5-5@default"
442        ));
443        assert!(!takes_per_message_effort(
444            "bedrock",
445            "anthropic.claude-opus-5-5"
446        ));
447        assert!(!takes_per_message_effort("azure", "gpt-6-sol"));
448    }
449
450    #[test]
451    fn fixture_skips_sample_spec_and_unknown_modes() {
452        let registry = fixture();
453        assert_eq!(registry.len(), 13);
454        assert!(registry.lookup("sample_spec").is_none());
455        assert!(registry.lookup("fixture-unknown-mode").is_none());
456    }
457
458    #[test]
459    fn exact_lookup_returns_converted_entry() {
460        let registry = fixture();
461        let entry = registry.lookup("fixture-gpt").expect("fixture-gpt exists");
462        assert_eq!(entry.id(), "fixture-gpt");
463        assert_eq!(*entry.provider(), Provider::OpenAI);
464        assert_eq!(entry.mode(), ModelMode::Chat);
465        assert_eq!(entry.max_input_tokens(), Some(128_000));
466        assert!(entry.has_ability(Ability::Vision));
467        assert!(entry.has_ability(Ability::ToolUse));
468        assert!(entry.pricing().input_per_token() > 0.0);
469        assert!(entry.pricing().output_per_token() > 0.0);
470    }
471
472    #[test]
473    fn lookup_is_case_insensitive() {
474        let registry = fixture();
475        assert_eq!(
476            litellm_id_of(&registry, "FIXTURE-GPT").as_deref(),
477            Some("fixture-gpt")
478        );
479        assert_eq!(
480            litellm_id_of(&registry, "GITHUB_COPILOT/FIXTURE-HOSTED").as_deref(),
481            Some("github_copilot/fixture-hosted")
482        );
483    }
484
485    #[test]
486    fn dated_snapshot_with_own_record_matches_exactly() {
487        let registry = fixture();
488        assert_eq!(
489            litellm_id_of(&registry, "fixture-gpt-2024-05-13").as_deref(),
490            Some("fixture-gpt-2024-05-13")
491        );
492    }
493
494    #[test]
495    fn version_suffixes_normalize_to_base_model() {
496        let registry = fixture();
497        for alias in [
498            "fixture-gpt-21000101",
499            "fixture-gpt-2099-01-01",
500            "fixture-gpt@20240513",
501            "fixture-gpt-v2",
502            "fixture-gpt-v2:1",
503            "fixture-gpt-1017",
504        ] {
505            assert_eq!(
506                litellm_id_of(&registry, alias).as_deref(),
507                Some("fixture-gpt"),
508                "{alias} should normalize to fixture-gpt"
509            );
510        }
511        // Normalization stays within the queried family.
512        assert_eq!(
513            litellm_id_of(&registry, "fixture-gpt-mini-20250101").as_deref(),
514            Some("fixture-gpt-mini")
515        );
516    }
517
518    #[test]
519    fn hyphenated_v_minor_and_compact_date_normalize_to_base() {
520        let registry = fixture();
521        for alias in [
522            "fixture-v4-flash-1017",
523            "fixture-v4-1-flash",
524            "fixture-v4.1-flash",
525            "fixture-v4-1-flash-1017",
526        ] {
527            assert_eq!(
528                litellm_id_of(&registry, alias).as_deref(),
529                Some("fixture-v4-flash"),
530                "{alias} should normalize to fixture-v4-flash"
531            );
532            assert_eq!(
533                registry
534                    .lookup(alias)
535                    .and_then(ModelEntry::max_input_tokens),
536                Some(1_000_000),
537                "{alias} should inherit the 1M window"
538            );
539        }
540    }
541
542    #[test]
543    fn hyphenated_v_minor_prefers_dotted_record_when_present() {
544        let registry = ModelRegistry::from_litellm_json(
545            br#"{
546                "fixture-v4-flash": {
547                    "litellm_provider": "deepseek",
548                    "mode": "chat",
549                    "max_input_tokens": 1000000,
550                    "input_cost_per_token": 3e-07,
551                    "output_cost_per_token": 1.2e-06
552                },
553                "fixture-v4.1-flash": {
554                    "litellm_provider": "deepseek",
555                    "mode": "chat",
556                    "max_input_tokens": 1048576,
557                    "input_cost_per_token": 3e-07,
558                    "output_cost_per_token": 1.2e-06
559                }
560            }"#,
561        );
562        assert_eq!(
563            litellm_id_of(&registry, "fixture-v4-1-flash").as_deref(),
564            Some("fixture-v4.1-flash")
565        );
566        assert_eq!(
567            registry
568                .lookup("fixture-v4-1-flash")
569                .and_then(ModelEntry::max_input_tokens),
570            Some(1_048_576)
571        );
572    }
573
574    #[test]
575    fn compact_month_day_must_be_a_valid_calendar_date() {
576        let registry = fixture();
577        assert!(registry.lookup("fixture-gpt-1301").is_none());
578        assert!(registry.lookup("fixture-gpt-0001").is_none());
579        assert!(registry.lookup("fixture-gpt-4096").is_none());
580    }
581
582    #[test]
583    fn numeric_minor_without_v_prefix_does_not_collapse() {
584        let registry = fixture();
585        assert!(registry.lookup("fixture-gpt-4-1").is_none());
586        assert!(registry.lookup("fixture-gpt-4.1").is_none());
587    }
588
589    #[test]
590    fn normalization_never_changes_model_family() {
591        let registry = fixture();
592        assert!(registry.lookup("fixture-gpt-turbo").is_none());
593        assert!(registry.lookup("fixture-gpt-mini-turbo").is_none());
594    }
595
596    #[test]
597    fn unknown_id_returns_none() {
598        let registry = fixture();
599        assert!(registry.lookup("no-such-model-xyz").is_none());
600        assert!(registry.lookup("no-such-model-xyz-20250101").is_none());
601    }
602
603    #[test]
604    fn bare_id_does_not_bind_provider_scoped_record() {
605        let registry = fixture();
606        // Only a hosted (`github_copilot/`, `azure/`) record exists, so the
607        // bare ID — dated or not — is a miss rather than a fallback.
608        assert!(registry.lookup("fixture-hosted").is_none());
609        assert!(registry.lookup("fixture-hosted-20250514").is_none());
610        assert!(registry.lookup("fixture-azure-only").is_none());
611        assert!(registry.lookup("fixture-bedrock-v1:0").is_none());
612    }
613
614    #[test]
615    fn provider_scoped_query_normalizes_within_its_namespace() {
616        let registry = fixture();
617        assert_eq!(
618            litellm_id_of(&registry, "github_copilot/fixture-hosted-20250514").as_deref(),
619            Some("github_copilot/fixture-hosted")
620        );
621        assert_eq!(
622            litellm_id_of(&registry, "azure/fixture-azure-only").as_deref(),
623            Some("azure/fixture-azure-only")
624        );
625        assert_eq!(
626            litellm_id_of(&registry, "bedrock/fixture-bedrock-v1:0").as_deref(),
627            Some("bedrock/fixture-bedrock-v1:0")
628        );
629        // Another provider's namespace does not answer for this one.
630        assert!(registry.lookup("azure/fixture-hosted").is_none());
631    }
632
633    #[test]
634    fn first_party_prefix_claims_bare_id() {
635        let registry = fixture();
636        assert_eq!(
637            litellm_id_of(&registry, "fixture-gemini").as_deref(),
638            Some("gemini/fixture-gemini")
639        );
640        assert_eq!(
641            litellm_id_of(&registry, "fixture-gemini-2099-01-01").as_deref(),
642            Some("gemini/fixture-gemini")
643        );
644    }
645
646    #[test]
647    fn unprefixed_record_wins_bare_id_over_first_party_prefix() {
648        let registry = fixture();
649        // `anthropic/fixture-claude-mid` precedes the unprefixed record in
650        // iteration order; the unprefixed key still owns the bare ID.
651        assert_eq!(
652            litellm_id_of(&registry, "fixture-claude-mid").as_deref(),
653            Some("fixture-claude-mid")
654        );
655        assert_eq!(
656            litellm_id_of(&registry, "anthropic/fixture-claude-mid").as_deref(),
657            Some("anthropic/fixture-claude-mid")
658        );
659    }
660
661    #[test]
662    fn query_helpers_filter_by_mode_ability_and_provider() {
663        let registry = fixture();
664        let image: Vec<_> = registry
665            .models_by_mode(ModelMode::ImageGeneration)
666            .map(ModelEntry::litellm_id)
667            .collect();
668        assert_eq!(image, ["fixture-image"]);
669        let reasoning: Vec<_> = registry
670            .models_with_ability(Ability::Reasoning)
671            .map(ModelEntry::litellm_id)
672            .collect();
673        assert_eq!(reasoning, ["fixture-flagship"]);
674        let copilot: Vec<_> = registry
675            .models_for_provider(&Provider::Copilot)
676            .map(ModelEntry::litellm_id)
677            .collect();
678        assert_eq!(copilot, ["github_copilot/fixture-hosted"]);
679    }
680}