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        _ => None,
396    }
397}
398
399#[cfg(test)]
400pub mod tests {
401    use super::*;
402
403    /// Registry built from a small hand-written `LiteLLM` dataset, so lookup
404    /// and tier behaviour is tested independently of the bundled snapshot,
405    /// which upstream refreshes can change at any time.
406    pub fn fixture() -> ModelRegistry {
407        ModelRegistry::from_litellm_json(include_bytes!("../testdata/litellm_fixture.json"))
408    }
409
410    fn litellm_id_of(registry: &ModelRegistry, query: &str) -> Option<String> {
411        registry
412            .lookup(query)
413            .map(|entry| entry.litellm_id().to_owned())
414    }
415
416    #[test]
417    fn fixture_skips_sample_spec_and_unknown_modes() {
418        let registry = fixture();
419        assert_eq!(registry.len(), 13);
420        assert!(registry.lookup("sample_spec").is_none());
421        assert!(registry.lookup("fixture-unknown-mode").is_none());
422    }
423
424    #[test]
425    fn exact_lookup_returns_converted_entry() {
426        let registry = fixture();
427        let entry = registry.lookup("fixture-gpt").expect("fixture-gpt exists");
428        assert_eq!(entry.id(), "fixture-gpt");
429        assert_eq!(*entry.provider(), Provider::OpenAI);
430        assert_eq!(entry.mode(), ModelMode::Chat);
431        assert_eq!(entry.max_input_tokens(), Some(128_000));
432        assert!(entry.has_ability(Ability::Vision));
433        assert!(entry.has_ability(Ability::ToolUse));
434        assert!(entry.pricing().input_per_token() > 0.0);
435        assert!(entry.pricing().output_per_token() > 0.0);
436    }
437
438    #[test]
439    fn lookup_is_case_insensitive() {
440        let registry = fixture();
441        assert_eq!(
442            litellm_id_of(&registry, "FIXTURE-GPT").as_deref(),
443            Some("fixture-gpt")
444        );
445        assert_eq!(
446            litellm_id_of(&registry, "GITHUB_COPILOT/FIXTURE-HOSTED").as_deref(),
447            Some("github_copilot/fixture-hosted")
448        );
449    }
450
451    #[test]
452    fn dated_snapshot_with_own_record_matches_exactly() {
453        let registry = fixture();
454        assert_eq!(
455            litellm_id_of(&registry, "fixture-gpt-2024-05-13").as_deref(),
456            Some("fixture-gpt-2024-05-13")
457        );
458    }
459
460    #[test]
461    fn version_suffixes_normalize_to_base_model() {
462        let registry = fixture();
463        for alias in [
464            "fixture-gpt-21000101",
465            "fixture-gpt-2099-01-01",
466            "fixture-gpt@20240513",
467            "fixture-gpt-v2",
468            "fixture-gpt-v2:1",
469            "fixture-gpt-1017",
470        ] {
471            assert_eq!(
472                litellm_id_of(&registry, alias).as_deref(),
473                Some("fixture-gpt"),
474                "{alias} should normalize to fixture-gpt"
475            );
476        }
477        // Normalization stays within the queried family.
478        assert_eq!(
479            litellm_id_of(&registry, "fixture-gpt-mini-20250101").as_deref(),
480            Some("fixture-gpt-mini")
481        );
482    }
483
484    #[test]
485    fn hyphenated_v_minor_and_compact_date_normalize_to_base() {
486        let registry = fixture();
487        for alias in [
488            "fixture-v4-flash-1017",
489            "fixture-v4-1-flash",
490            "fixture-v4.1-flash",
491            "fixture-v4-1-flash-1017",
492        ] {
493            assert_eq!(
494                litellm_id_of(&registry, alias).as_deref(),
495                Some("fixture-v4-flash"),
496                "{alias} should normalize to fixture-v4-flash"
497            );
498            assert_eq!(
499                registry
500                    .lookup(alias)
501                    .and_then(ModelEntry::max_input_tokens),
502                Some(1_000_000),
503                "{alias} should inherit the 1M window"
504            );
505        }
506    }
507
508    #[test]
509    fn hyphenated_v_minor_prefers_dotted_record_when_present() {
510        let registry = ModelRegistry::from_litellm_json(
511            br#"{
512                "fixture-v4-flash": {
513                    "litellm_provider": "deepseek",
514                    "mode": "chat",
515                    "max_input_tokens": 1000000,
516                    "input_cost_per_token": 3e-07,
517                    "output_cost_per_token": 1.2e-06
518                },
519                "fixture-v4.1-flash": {
520                    "litellm_provider": "deepseek",
521                    "mode": "chat",
522                    "max_input_tokens": 1048576,
523                    "input_cost_per_token": 3e-07,
524                    "output_cost_per_token": 1.2e-06
525                }
526            }"#,
527        );
528        assert_eq!(
529            litellm_id_of(&registry, "fixture-v4-1-flash").as_deref(),
530            Some("fixture-v4.1-flash")
531        );
532        assert_eq!(
533            registry
534                .lookup("fixture-v4-1-flash")
535                .and_then(ModelEntry::max_input_tokens),
536            Some(1_048_576)
537        );
538    }
539
540    #[test]
541    fn compact_month_day_must_be_a_valid_calendar_date() {
542        let registry = fixture();
543        assert!(registry.lookup("fixture-gpt-1301").is_none());
544        assert!(registry.lookup("fixture-gpt-0001").is_none());
545        assert!(registry.lookup("fixture-gpt-4096").is_none());
546    }
547
548    #[test]
549    fn numeric_minor_without_v_prefix_does_not_collapse() {
550        let registry = fixture();
551        assert!(registry.lookup("fixture-gpt-4-1").is_none());
552        assert!(registry.lookup("fixture-gpt-4.1").is_none());
553    }
554
555    #[test]
556    fn normalization_never_changes_model_family() {
557        let registry = fixture();
558        assert!(registry.lookup("fixture-gpt-turbo").is_none());
559        assert!(registry.lookup("fixture-gpt-mini-turbo").is_none());
560    }
561
562    #[test]
563    fn unknown_id_returns_none() {
564        let registry = fixture();
565        assert!(registry.lookup("no-such-model-xyz").is_none());
566        assert!(registry.lookup("no-such-model-xyz-20250101").is_none());
567    }
568
569    #[test]
570    fn bare_id_does_not_bind_provider_scoped_record() {
571        let registry = fixture();
572        // Only a hosted (`github_copilot/`, `azure/`) record exists, so the
573        // bare ID — dated or not — is a miss rather than a fallback.
574        assert!(registry.lookup("fixture-hosted").is_none());
575        assert!(registry.lookup("fixture-hosted-20250514").is_none());
576        assert!(registry.lookup("fixture-azure-only").is_none());
577        assert!(registry.lookup("fixture-bedrock-v1:0").is_none());
578    }
579
580    #[test]
581    fn provider_scoped_query_normalizes_within_its_namespace() {
582        let registry = fixture();
583        assert_eq!(
584            litellm_id_of(&registry, "github_copilot/fixture-hosted-20250514").as_deref(),
585            Some("github_copilot/fixture-hosted")
586        );
587        assert_eq!(
588            litellm_id_of(&registry, "azure/fixture-azure-only").as_deref(),
589            Some("azure/fixture-azure-only")
590        );
591        assert_eq!(
592            litellm_id_of(&registry, "bedrock/fixture-bedrock-v1:0").as_deref(),
593            Some("bedrock/fixture-bedrock-v1:0")
594        );
595        // Another provider's namespace does not answer for this one.
596        assert!(registry.lookup("azure/fixture-hosted").is_none());
597    }
598
599    #[test]
600    fn first_party_prefix_claims_bare_id() {
601        let registry = fixture();
602        assert_eq!(
603            litellm_id_of(&registry, "fixture-gemini").as_deref(),
604            Some("gemini/fixture-gemini")
605        );
606        assert_eq!(
607            litellm_id_of(&registry, "fixture-gemini-2099-01-01").as_deref(),
608            Some("gemini/fixture-gemini")
609        );
610    }
611
612    #[test]
613    fn unprefixed_record_wins_bare_id_over_first_party_prefix() {
614        let registry = fixture();
615        // `anthropic/fixture-claude-mid` precedes the unprefixed record in
616        // iteration order; the unprefixed key still owns the bare ID.
617        assert_eq!(
618            litellm_id_of(&registry, "fixture-claude-mid").as_deref(),
619            Some("fixture-claude-mid")
620        );
621        assert_eq!(
622            litellm_id_of(&registry, "anthropic/fixture-claude-mid").as_deref(),
623            Some("anthropic/fixture-claude-mid")
624        );
625    }
626
627    #[test]
628    fn query_helpers_filter_by_mode_ability_and_provider() {
629        let registry = fixture();
630        let image: Vec<_> = registry
631            .models_by_mode(ModelMode::ImageGeneration)
632            .map(ModelEntry::litellm_id)
633            .collect();
634        assert_eq!(image, ["fixture-image"]);
635        let reasoning: Vec<_> = registry
636            .models_with_ability(Ability::Reasoning)
637            .map(ModelEntry::litellm_id)
638            .collect();
639        assert_eq!(reasoning, ["fixture-flagship"]);
640        let copilot: Vec<_> = registry
641            .models_for_provider(&Provider::Copilot)
642            .map(ModelEntry::litellm_id)
643            .collect();
644        assert_eq!(copilot, ["github_copilot/fixture-hosted"]);
645    }
646}