1use 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 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}