Skip to main content

meerkat_core/
model_registry.rs

1//! Effective model registry merged from built-in catalog and configured self-hosted aliases.
2
3use crate::Provider;
4use crate::config::{
5    Config, ConfigError, SelfHostedApiStyle, SelfHostedConfig, SelfHostedTransport,
6};
7use crate::model_profile::{ModelProfile, catalog::ModelTier};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::fmt;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SelfHostedServerRef {
14    pub server_id: String,
15    pub remote_model: String,
16    pub transport: SelfHostedTransport,
17    pub api_style: SelfHostedApiStyle,
18    pub base_url: String,
19}
20
21#[derive(Debug, Clone)]
22pub struct ModelRegistryEntry {
23    pub id: String,
24    pub display_name: String,
25    pub provider: Provider,
26    pub tier: ModelTier,
27    pub context_window: Option<u32>,
28    pub max_output_tokens: Option<u32>,
29    pub self_hosted: Option<SelfHostedServerRef>,
30}
31
32#[derive(Debug, Clone)]
33pub struct ModelRegistry {
34    entries: BTreeMap<String, ModelRegistryEntry>,
35    profiles: BTreeMap<(Provider, String), ModelProfile>,
36    defaults: BTreeMap<Provider, String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum ModelCapability {
42    InlineVideo,
43}
44
45impl ModelCapability {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::InlineVideo => "inline_video",
49        }
50    }
51
52    fn display_name(self) -> &'static str {
53        match self {
54            Self::InlineVideo => "inline video",
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum UnsupportedModelCapabilityReason {
62    CapabilityDisabled,
63    ProviderModelProfileMissing,
64    CapabilityRegistryUnavailable,
65}
66
67impl UnsupportedModelCapabilityReason {
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::CapabilityDisabled => "capability_disabled",
71            Self::ProviderModelProfileMissing => "provider_model_profile_missing",
72            Self::CapabilityRegistryUnavailable => "capability_registry_unavailable",
73        }
74    }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct UnsupportedModelCapabilityEvidence {
79    pub capability: ModelCapability,
80    pub provider: Provider,
81    pub model: String,
82    pub reason: UnsupportedModelCapabilityReason,
83}
84
85impl UnsupportedModelCapabilityEvidence {
86    pub fn inline_video(
87        provider: Provider,
88        model: impl Into<String>,
89        reason: UnsupportedModelCapabilityReason,
90    ) -> Self {
91        Self {
92            capability: ModelCapability::InlineVideo,
93            provider,
94            model: model.into(),
95            reason,
96        }
97    }
98
99    pub fn details(&self) -> serde_json::Value {
100        serde_json::json!({
101            "unsupported_capability": {
102                "capability": self.capability.as_str(),
103                "provider": self.provider.as_str(),
104                "model": self.model.as_str(),
105                "reason": self.reason.as_str(),
106            },
107        })
108    }
109}
110
111impl fmt::Display for UnsupportedModelCapabilityEvidence {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write!(
114            f,
115            "{} input is not supported by model '{}' on provider '{}' (capability: {}, reason: {})",
116            self.capability.display_name(),
117            self.model,
118            self.provider.as_str(),
119            self.capability.as_str(),
120            self.reason.as_str()
121        )
122    }
123}
124
125impl ModelRegistry {
126    pub fn from_config(config: &Config) -> Result<Self, ConfigError> {
127        let mut entries = BTreeMap::new();
128        let mut profiles = BTreeMap::new();
129        let mut defaults = BTreeMap::new();
130
131        for provider_name in crate::model_profile::catalog::provider_names() {
132            let provider = Provider::parse_strict(provider_name).ok_or_else(|| {
133                ConfigError::InternalError(format!("unknown built-in provider '{provider_name}'"))
134            })?;
135            let default_model = crate::model_profile::catalog::default_model(provider_name)
136                .ok_or_else(|| {
137                    ConfigError::InternalError(format!(
138                        "missing built-in default for '{provider_name}'"
139                    ))
140                })?;
141            defaults.insert(provider, default_model.to_string());
142
143            for entry in crate::model_profile::catalog::catalog()
144                .iter()
145                .filter(|entry| entry.provider == *provider_name)
146            {
147                let profile =
148                    crate::model_profile::profile_for(provider, entry.id).ok_or_else(|| {
149                        ConfigError::InternalError(format!(
150                            "missing built-in profile for {}:{}",
151                            entry.provider, entry.id
152                        ))
153                    })?;
154                insert_unique(
155                    &mut entries,
156                    &mut profiles,
157                    ModelRegistryEntry {
158                        id: entry.id.to_string(),
159                        display_name: entry.display_name.to_string(),
160                        provider,
161                        tier: entry.tier,
162                        context_window: entry.context_window,
163                        max_output_tokens: entry.max_output_tokens,
164                        self_hosted: None,
165                    },
166                    profile,
167                )?;
168            }
169        }
170
171        append_self_hosted(
172            &mut entries,
173            &mut profiles,
174            &mut defaults,
175            &config.self_hosted,
176        )?;
177
178        Ok(Self {
179            entries,
180            profiles,
181            defaults,
182        })
183    }
184
185    /// Returns model projection metadata by id.
186    ///
187    /// This model-only lookup intentionally does not expose `ModelProfile` or
188    /// capability fields. Capability decisions must use typed provider-aware
189    /// lookup through [`ModelRegistry::profile_for_provider`].
190    ///
191    /// ```compile_fail
192    /// let registry = meerkat_core::Config::default().model_registry().unwrap();
193    /// let entry = registry.entry("gemini-3.5-flash").unwrap();
194    /// let _inline_video = entry.profile.inline_video;
195    /// ```
196    ///
197    /// ```
198    /// let registry = meerkat_core::Config::default().model_registry().unwrap();
199    /// let profile = registry
200    ///     .profile_for_provider(meerkat_core::Provider::Gemini, "gemini-3.5-flash")
201    ///     .unwrap();
202    /// assert!(profile.inline_video);
203    /// ```
204    pub fn entry(&self, model_id: &str) -> Option<&ModelRegistryEntry> {
205        self.entries.get(model_id)
206    }
207
208    pub fn entry_for_provider(
209        &self,
210        provider: Provider,
211        model_id: &str,
212    ) -> Option<&ModelRegistryEntry> {
213        self.entry(model_id)
214            .filter(|entry| entry.provider == provider)
215    }
216
217    pub fn provider_override_mismatch_reason(
218        &self,
219        provider: Provider,
220        model_id: &str,
221    ) -> Option<String> {
222        let registered_provider = self.entry(model_id)?.provider;
223        if registered_provider == provider {
224            return None;
225        }
226
227        Some(format!(
228            "model '{model_id}' is registered for provider '{}', not provider '{}'; explicit provider overrides must match catalog ownership",
229            registered_provider.as_str(),
230            provider.as_str()
231        ))
232    }
233
234    pub fn profile_for_provider(&self, provider: Provider, model_id: &str) -> Option<ModelProfile> {
235        self.entry_for_provider(provider, model_id)?;
236        self.profiles
237            .get(&(provider, model_id.to_string()))
238            .cloned()
239    }
240
241    pub fn require_inline_video_for_provider(
242        &self,
243        provider: Provider,
244        model_id: &str,
245    ) -> Result<(), UnsupportedModelCapabilityEvidence> {
246        let Some(profile) = self.profile_for_provider(provider, model_id) else {
247            return Err(UnsupportedModelCapabilityEvidence::inline_video(
248                provider,
249                model_id,
250                UnsupportedModelCapabilityReason::ProviderModelProfileMissing,
251            ));
252        };
253
254        if profile.inline_video {
255            Ok(())
256        } else {
257            Err(UnsupportedModelCapabilityEvidence::inline_video(
258                provider,
259                model_id,
260                UnsupportedModelCapabilityReason::CapabilityDisabled,
261            ))
262        }
263    }
264
265    pub fn default_model(&self, provider: Provider) -> Option<&str> {
266        self.defaults.get(&provider).map(String::as_str)
267    }
268
269    pub fn entries_for_provider(
270        &self,
271        provider: Provider,
272    ) -> impl Iterator<Item = &ModelRegistryEntry> {
273        self.entries
274            .values()
275            .filter(move |entry| entry.provider == provider)
276    }
277
278    pub fn provider_defaults(&self) -> impl Iterator<Item = (Provider, &str)> {
279        self.defaults
280            .iter()
281            .map(|(provider, default_model)| (*provider, default_model.as_str()))
282    }
283}
284
285fn append_self_hosted(
286    entries: &mut BTreeMap<String, ModelRegistryEntry>,
287    profiles: &mut BTreeMap<(Provider, String), ModelProfile>,
288    defaults: &mut BTreeMap<Provider, String>,
289    config: &SelfHostedConfig,
290) -> Result<(), ConfigError> {
291    if config.models.is_empty() {
292        return Ok(());
293    }
294
295    let default_model = config.models.keys().min().cloned().ok_or_else(|| {
296        ConfigError::InternalError("self-hosted models unexpectedly empty".to_string())
297    })?;
298    defaults.insert(Provider::SelfHosted, default_model);
299
300    for (model_id, model) in &config.models {
301        let server = config.servers.get(&model.server).ok_or_else(|| {
302            ConfigError::Validation(format!(
303                "self_hosted.models.{model_id} references unknown server '{}'",
304                model.server
305            ))
306        })?;
307        if server.bearer_token.is_some() {
308            tracing::warn!(
309                server_id = %model.server,
310                "self-hosted server uses a literal bearer_token; bearer_token_env is recommended to avoid storing secrets in config files"
311            );
312        }
313
314        let self_hosted = SelfHostedServerRef {
315            server_id: model.server.clone(),
316            remote_model: model.remote_model.clone(),
317            transport: server.transport,
318            api_style: server.api_style,
319            base_url: normalize_base_url(&server.base_url),
320        };
321        let profile = ModelProfile {
322            provider: Provider::SelfHosted.as_str().to_string(),
323            model_family: model.family.clone(),
324            supports_temperature: model.supports_temperature,
325            supports_thinking: model.supports_thinking,
326            supports_reasoning: model.supports_reasoning,
327            supports_web_search: model.supports_web_search,
328            inline_video: model.inline_video,
329            vision: model.vision,
330            image_input: model.vision,
331            image_tool_results: model.image_tool_results,
332            realtime: false,
333            image_generation: false,
334            params_schema: serde_json::json!({}),
335            beta_headers: Vec::new(),
336            call_timeout_secs: model.call_timeout_secs,
337        };
338
339        insert_unique(
340            entries,
341            profiles,
342            ModelRegistryEntry {
343                id: model_id.clone(),
344                display_name: model.display_name.clone(),
345                provider: Provider::SelfHosted,
346                tier: model.tier,
347                context_window: model.context_window,
348                max_output_tokens: model.max_output_tokens,
349                self_hosted: Some(self_hosted),
350            },
351            profile,
352        )?;
353    }
354
355    Ok(())
356}
357
358fn insert_unique(
359    entries: &mut BTreeMap<String, ModelRegistryEntry>,
360    profiles: &mut BTreeMap<(Provider, String), ModelProfile>,
361    entry: ModelRegistryEntry,
362    profile: ModelProfile,
363) -> Result<(), ConfigError> {
364    let model_id = entry.id.clone();
365    let provider = entry.provider;
366    if entries.insert(model_id.clone(), entry).is_some() {
367        return Err(ConfigError::Validation(
368            "model id must be unique across built-in and self-hosted entries".to_string(),
369        ));
370    }
371    profiles.insert((provider, model_id), profile);
372    Ok(())
373}
374
375pub fn normalize_base_url(base_url: &str) -> String {
376    let trimmed = base_url.trim_end_matches('/');
377    if trimmed.ends_with("/v1") {
378        trimmed.to_string()
379    } else {
380        format!("{trimmed}/v1")
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    #![allow(clippy::panic)]
387
388    use super::*;
389    use crate::config::{
390        SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
391    };
392
393    fn config_with_self_hosted() -> Config {
394        let mut config = Config::default();
395        config.self_hosted.servers.insert(
396            "local".to_string(),
397            SelfHostedServerConfig {
398                transport: SelfHostedTransport::OpenAiCompatible,
399                base_url: "http://127.0.0.1:11434".to_string(),
400                api_style: SelfHostedApiStyle::Responses,
401                bearer_token: None,
402                bearer_token_env: Some("LOCAL_TOKEN".to_string()),
403            },
404        );
405        config.self_hosted.models.insert(
406            "gemma-4-31b".to_string(),
407            SelfHostedModelConfig {
408                server: "local".to_string(),
409                remote_model: "gemma4:31b".to_string(),
410                display_name: "Gemma 4 31B".to_string(),
411                family: "gemma-4".to_string(),
412                tier: ModelTier::Supported,
413                context_window: Some(256_000),
414                max_output_tokens: Some(8_192),
415                vision: true,
416                image_tool_results: true,
417                inline_video: false,
418                supports_temperature: true,
419                supports_thinking: false,
420                supports_reasoning: false,
421                supports_web_search: false,
422                call_timeout_secs: Some(600),
423            },
424        );
425        config
426    }
427
428    #[test]
429    fn merges_self_hosted_models_into_registry() {
430        let config = config_with_self_hosted();
431        let registry = match ModelRegistry::from_config(&config) {
432            Ok(registry) => registry,
433            Err(err) => panic!("registry construction failed: {err}"),
434        };
435        let entry = match registry.entry("gemma-4-31b") {
436            Some(entry) => entry,
437            None => panic!("missing self-hosted entry for gemma-4-31b"),
438        };
439        assert_eq!(entry.provider, Provider::SelfHosted);
440        assert_eq!(entry.display_name, "Gemma 4 31B");
441        assert_eq!(
442            entry
443                .self_hosted
444                .as_ref()
445                .map(|server| server.server_id.as_str()),
446            Some("local")
447        );
448        assert_eq!(
449            entry
450                .self_hosted
451                .as_ref()
452                .map(|server| server.remote_model.as_str()),
453            Some("gemma4:31b")
454        );
455        assert_eq!(
456            entry
457                .self_hosted
458                .as_ref()
459                .map(|server| server.base_url.as_str()),
460            Some("http://127.0.0.1:11434/v1")
461        );
462        assert_eq!(
463            registry.default_model(Provider::SelfHosted),
464            Some("gemma-4-31b")
465        );
466    }
467
468    #[test]
469    fn rejects_unknown_server_reference() {
470        let mut config = Config::default();
471        config.self_hosted.models.insert(
472            "gemma-4-31b".to_string(),
473            SelfHostedModelConfig {
474                server: "missing".to_string(),
475                remote_model: "gemma4:31b".to_string(),
476                display_name: "Gemma 4 31B".to_string(),
477                family: "gemma-4".to_string(),
478                tier: ModelTier::Supported,
479                context_window: None,
480                max_output_tokens: None,
481                vision: true,
482                image_tool_results: true,
483                inline_video: false,
484                supports_temperature: true,
485                supports_thinking: false,
486                supports_reasoning: false,
487                supports_web_search: false,
488                call_timeout_secs: None,
489            },
490        );
491        let err = match ModelRegistry::from_config(&config) {
492            Ok(_) => panic!("unknown server should fail"),
493            Err(err) => err,
494        };
495        assert!(err.to_string().contains("references unknown server"));
496    }
497
498    #[test]
499    fn rejects_duplicate_model_ids() {
500        let mut config = Config::default();
501        config.self_hosted.servers.insert(
502            "local".to_string(),
503            SelfHostedServerConfig {
504                transport: SelfHostedTransport::OpenAiCompatible,
505                base_url: "http://127.0.0.1:11434".to_string(),
506                api_style: SelfHostedApiStyle::Responses,
507                bearer_token: None,
508                bearer_token_env: None,
509            },
510        );
511        config.self_hosted.models.insert(
512            "gpt-5.4".to_string(),
513            SelfHostedModelConfig {
514                server: "local".to_string(),
515                remote_model: "override".to_string(),
516                display_name: "Override".to_string(),
517                family: "override".to_string(),
518                tier: ModelTier::Supported,
519                context_window: None,
520                max_output_tokens: None,
521                vision: false,
522                image_tool_results: false,
523                inline_video: false,
524                supports_temperature: true,
525                supports_thinking: false,
526                supports_reasoning: false,
527                supports_web_search: false,
528                call_timeout_secs: None,
529            },
530        );
531        let err = match ModelRegistry::from_config(&config) {
532            Ok(_) => panic!("duplicate model id should fail"),
533            Err(err) => err,
534        };
535        assert!(err.to_string().contains("model id must be unique"));
536    }
537
538    #[test]
539    fn uncatalogued_models_do_not_use_provider_prefix_inference() {
540        let registry = match ModelRegistry::from_config(&Config::default()) {
541            Ok(registry) => registry,
542            Err(err) => panic!("registry construction failed: {err}"),
543        };
544        assert!(
545            registry
546                .profile_for_provider(Provider::OpenAI, "gpt-unknown-preview")
547                .is_none()
548        );
549        assert!(
550            registry
551                .profile_for_provider(Provider::Anthropic, "claude-unknown-preview")
552                .is_none()
553        );
554        assert!(
555            registry
556                .profile_for_provider(Provider::Gemini, "gemini-unknown-preview")
557                .is_none()
558        );
559    }
560
561    #[test]
562    fn provider_aware_profile_lookup_requires_matching_provider() {
563        let registry = match ModelRegistry::from_config(&Config::default()) {
564            Ok(registry) => registry,
565            Err(err) => panic!("registry construction failed: {err}"),
566        };
567
568        let profile = registry.profile_for_provider(Provider::OpenAI, "gpt-5.4");
569        assert_eq!(
570            profile.and_then(|profile| profile.call_timeout_secs),
571            Some(600)
572        );
573        assert!(
574            registry
575                .profile_for_provider(Provider::Anthropic, "gpt-5.4")
576                .is_none(),
577            "provider-aware lookup must not share OpenAI defaults with Anthropic"
578        );
579        assert!(
580            registry
581                .profile_for_provider(Provider::OpenAI, "gemini-3.5-flash")
582                .is_none(),
583            "provider-aware lookup must not let provider strings select another provider's capabilities"
584        );
585    }
586
587    #[test]
588    fn model_only_entries_are_projection_metadata_not_capability_authority() {
589        let registry = match ModelRegistry::from_config(&Config::default()) {
590            Ok(registry) => registry,
591            Err(err) => panic!("registry construction failed: {err}"),
592        };
593
594        let entry = match registry.entry("gemini-3.5-flash") {
595            Some(entry) => entry,
596            None => panic!("catalog entry must exist"),
597        };
598        assert_eq!(entry.provider, Provider::Gemini);
599        assert_eq!(entry.id, "gemini-3.5-flash");
600        let rendered = format!("{entry:?}");
601        assert!(
602            !rendered.contains("inline_video") && !rendered.contains("supports_temperature"),
603            "model-only projection entry must not expose capability fields: {rendered}"
604        );
605
606        let profile = match registry.profile_for_provider(Provider::Gemini, "gemini-3.5-flash") {
607            Some(profile) => profile,
608            None => panic!("typed provider-aware capability lookup should resolve"),
609        };
610        assert!(profile.inline_video);
611        assert!(
612            registry
613                .profile_for_provider(Provider::OpenAI, "gemini-3.5-flash")
614                .is_none(),
615            "display/catalog lookup must not let another typed provider read capability truth"
616        );
617    }
618
619    #[test]
620    fn provider_aware_profile_lookup_fails_closed_for_unknown_pairs() {
621        let registry = match ModelRegistry::from_config(&Config::default()) {
622            Ok(registry) => registry,
623            Err(err) => panic!("registry construction failed: {err}"),
624        };
625
626        assert!(
627            registry
628                .profile_for_provider(Provider::Other, "gpt-5.4")
629                .is_none(),
630            "unknown typed provider must not receive known model defaults"
631        );
632        assert!(
633            registry
634                .profile_for_provider(Provider::Other, "uncatalogued-gpt-compatible")
635                .is_none(),
636            "unknown provider/model pairs must fail closed"
637        );
638        assert!(
639            registry
640                .profile_for_provider(Provider::OpenAI, "uncatalogued-gpt-compatible")
641                .is_none(),
642            "known provider plus uncatalogued model must fail closed"
643        );
644    }
645
646    #[test]
647    fn inline_video_capability_requires_typed_provider_owner() {
648        let registry = match ModelRegistry::from_config(&Config::default()) {
649            Ok(registry) => registry,
650            Err(err) => panic!("registry construction failed: {err}"),
651        };
652
653        if let Err(err) =
654            registry.require_inline_video_for_provider(Provider::Gemini, "gemini-3.5-flash")
655        {
656            panic!("Gemini catalog owner should authorize inline video: {err}");
657        }
658
659        let err = match registry
660            .require_inline_video_for_provider(Provider::OpenAI, "gemini-3.5-flash")
661        {
662            Ok(()) => panic!("same model name under another provider must fail closed"),
663            Err(err) => err,
664        };
665        assert_eq!(err.capability, ModelCapability::InlineVideo);
666        assert_eq!(err.provider, Provider::OpenAI);
667        assert_eq!(err.model, "gemini-3.5-flash");
668        assert_eq!(
669            err.reason,
670            UnsupportedModelCapabilityReason::ProviderModelProfileMissing
671        );
672    }
673
674    #[test]
675    fn inline_video_capability_evidence_distinguishes_disabled_and_unknown() {
676        let registry = match ModelRegistry::from_config(&Config::default()) {
677            Ok(registry) => registry,
678            Err(err) => panic!("registry construction failed: {err}"),
679        };
680
681        let disabled = match registry.require_inline_video_for_provider(Provider::OpenAI, "gpt-5.4")
682        {
683            Ok(()) => panic!("known OpenAI model has catalog-owned inline video disabled"),
684            Err(err) => err,
685        };
686        assert_eq!(
687            disabled.reason,
688            UnsupportedModelCapabilityReason::CapabilityDisabled
689        );
690
691        let unknown = match registry
692            .require_inline_video_for_provider(Provider::Other, "uncatalogued-video-model")
693        {
694            Ok(()) => panic!("unknown provider/model pair must fail closed"),
695            Err(err) => err,
696        };
697        assert_eq!(
698            unknown.reason,
699            UnsupportedModelCapabilityReason::ProviderModelProfileMissing
700        );
701        let details = unknown.details();
702        assert_eq!(
703            details["unsupported_capability"]["capability"],
704            serde_json::json!("inline_video")
705        );
706        assert_eq!(
707            details["unsupported_capability"]["reason"],
708            serde_json::json!("provider_model_profile_missing")
709        );
710    }
711
712    #[test]
713    fn provider_override_mismatch_reason_reports_catalog_owner_contradictions() {
714        let registry = match ModelRegistry::from_config(&Config::default()) {
715            Ok(registry) => registry,
716            Err(err) => panic!("registry construction failed: {err}"),
717        };
718
719        let reason =
720            match registry.provider_override_mismatch_reason(Provider::Anthropic, "gpt-5.4") {
721                Some(reason) => reason,
722                None => panic!("wrong-provider override for a catalog model should be rejected"),
723            };
724        assert!(reason.contains("model 'gpt-5.4'"));
725        assert!(reason.contains("registered for provider 'openai'"));
726        assert!(reason.contains("not provider 'anthropic'"));
727        assert!(reason.contains("explicit provider overrides"));
728
729        assert!(
730            registry
731                .provider_override_mismatch_reason(Provider::OpenAI, "gpt-5.4")
732                .is_none(),
733            "matching provider override should remain valid"
734        );
735        assert!(
736            registry
737                .provider_override_mismatch_reason(Provider::OpenAI, "uncatalogued-gpt-compatible")
738                .is_none(),
739            "uncatalogued models have no catalog owner to contradict"
740        );
741    }
742}