1use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::OnceLock;
34
35use anyhow::{anyhow, Result};
36use serde::{Deserialize, Serialize};
37
38pub(crate) const MODELS_YAML: &str = include_str!("../templates/models.yaml");
40
41pub const MODELS_SCHEMA_VERSION: &str = "1";
47
48pub const OMNI_DEV_MODELS_YAML_ENV: &str = "OMNI_DEV_MODELS_YAML";
51
52const FALLBACK_MAX_OUTPUT_TOKENS: usize = 4096;
54
55const FALLBACK_INPUT_CONTEXT: usize = 100_000;
57
58#[derive(
60 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
61)]
62#[serde(rename_all = "lowercase")]
63pub enum ModelSource {
64 #[default]
66 Embedded,
67 User,
69 Project,
71 Override,
73}
74
75impl std::fmt::Display for ModelSource {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.write_str(match self {
78 Self::Embedded => "embedded",
79 Self::User => "user",
80 Self::Project => "project",
81 Self::Override => "override",
82 })
83 }
84}
85
86#[derive(Debug, Deserialize, Serialize, Clone)]
100pub struct BetaHeader {
101 pub key: String,
103 pub value: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub max_output_tokens: Option<usize>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub input_context: Option<usize>,
111}
112
113#[derive(Debug, Deserialize, Serialize, Clone)]
140pub struct ModelSpec {
141 pub provider: String,
143 pub model: String,
145 pub api_identifier: String,
147 pub max_output_tokens: usize,
149 pub input_context: usize,
151 pub generation: f32,
153 pub tier: String,
155 #[serde(default)]
157 pub legacy: bool,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub retires: Option<String>,
174 #[serde(default)]
185 pub supports_structured_output: bool,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub input_token_price: Option<f64>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub output_token_price: Option<f64>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub beta_headers: Vec<BetaHeader>,
201 #[serde(default, skip_deserializing)]
204 pub source: ModelSource,
205}
206
207#[derive(Debug, Deserialize, Serialize, Clone)]
217pub struct TierInfo {
218 pub description: String,
220 pub use_cases: Vec<String>,
222}
223
224#[derive(Debug, Deserialize, Serialize, Clone)]
235pub struct DefaultConfig {
236 pub max_output_tokens: usize,
238 pub input_context: usize,
240}
241
242#[derive(Debug, Deserialize, Serialize, Clone)]
254pub struct ProviderConfig {
255 pub name: String,
257 pub api_base: String,
259 pub default_model: String,
261 pub tiers: HashMap<String, TierInfo>,
263 pub defaults: DefaultConfig,
265 #[serde(default, skip_deserializing)]
267 pub source: ModelSource,
268}
269
270#[derive(Debug, Deserialize, Serialize, Clone)]
283pub struct ModelConfiguration {
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub version: Option<String>,
287 pub models: Vec<ModelSpec>,
289 pub providers: HashMap<String, ProviderConfig>,
291}
292
293pub struct ModelRegistry {
303 config: ModelConfiguration,
304 by_identifier: HashMap<String, ModelSpec>,
305 by_provider: HashMap<String, Vec<ModelSpec>>,
306}
307
308impl ModelRegistry {
309 pub fn load() -> Result<Self> {
322 let override_path = std::env::var(OMNI_DEV_MODELS_YAML_ENV)
323 .ok()
324 .filter(|s| !s.is_empty())
325 .map(PathBuf::from);
326 let project_path = default_project_path();
327 let user_path = default_user_path();
328 Self::load_layered_from_paths(
329 project_path.as_deref(),
330 user_path.as_deref(),
331 override_path.as_deref(),
332 )
333 }
334
335 pub fn load_layered_from_paths(
339 project_path: Option<&Path>,
340 user_path: Option<&Path>,
341 override_path: Option<&Path>,
342 ) -> Result<Self> {
343 let mut layers: Vec<(ModelSource, String)> = Vec::new();
344 layers.push((ModelSource::Embedded, MODELS_YAML.to_string()));
345
346 if let Some(path) = override_path {
347 match read_optional_yaml(path) {
348 Some(yaml) => layers.push((ModelSource::Override, yaml)),
349 None => {
350 tracing::warn!(
351 "{OMNI_DEV_MODELS_YAML_ENV} points at {} but the file is missing or unreadable; falling back to embedded catalog",
352 path.display()
353 );
354 }
355 }
356 } else {
357 if let Some(path) = user_path {
358 if let Some(yaml) = read_optional_yaml(path) {
359 layers.push((ModelSource::User, yaml));
360 }
361 }
362 if let Some(path) = project_path {
363 if let Some(yaml) = read_optional_yaml(path) {
364 layers.push((ModelSource::Project, yaml));
365 }
366 }
367 }
368
369 Self::from_layers(&layers)
370 }
371
372 pub(crate) fn from_layers(layers: &[(ModelSource, String)]) -> Result<Self> {
378 let mut merged: serde_yaml::Value =
379 serde_yaml::Value::Mapping(serde_yaml::Mapping::default());
380 let mut model_sources: HashMap<String, ModelSource> = HashMap::new();
381 let mut provider_sources: HashMap<String, ModelSource> = HashMap::new();
382 let mut declared_versions: Vec<(ModelSource, Option<String>)> = Vec::new();
383
384 for (source, yaml) in layers {
385 let value: serde_yaml::Value = match serde_yaml::from_str(yaml) {
386 Ok(v) => v,
387 Err(e) => {
388 if matches!(source, ModelSource::Embedded) {
389 return Err(anyhow!(
390 "Embedded models.yaml is malformed at compile time: {e}"
391 ));
392 }
393 tracing::error!(
394 "Malformed {source} models.yaml: {e}. Falling through to lower-precedence layers."
395 );
396 continue;
397 }
398 };
399
400 let version = value
402 .get("version")
403 .and_then(|v| v.as_str())
404 .map(String::from);
405 declared_versions.push((*source, version));
406
407 merge_layer_into(
408 &mut merged,
409 value,
410 *source,
411 &mut model_sources,
412 &mut provider_sources,
413 );
414 }
415
416 warn_on_version_mismatch(&declared_versions);
417
418 let mut config: ModelConfiguration = serde_yaml::from_value(merged)
419 .map_err(|e| anyhow!("Failed to deserialize merged model configuration: {e}"))?;
420
421 for spec in &mut config.models {
422 spec.source = model_sources
423 .get(&spec.api_identifier)
424 .copied()
425 .unwrap_or_default();
426 }
427 for (name, prov) in &mut config.providers {
428 prov.source = provider_sources.get(name).copied().unwrap_or_default();
429 }
430
431 let mut by_identifier = HashMap::new();
432 let mut by_provider: HashMap<String, Vec<ModelSpec>> = HashMap::new();
433 for model in &config.models {
434 by_identifier.insert(model.api_identifier.clone(), model.clone());
435 by_provider
436 .entry(model.provider.clone())
437 .or_default()
438 .push(model.clone());
439 }
440
441 Ok(Self {
442 config,
443 by_identifier,
444 by_provider,
445 })
446 }
447
448 #[must_use]
450 pub fn config(&self) -> &ModelConfiguration {
451 &self.config
452 }
453
454 #[must_use]
456 pub fn get_model_spec(&self, api_identifier: &str) -> Option<&ModelSpec> {
457 if let Some(spec) = self.by_identifier.get(api_identifier) {
459 return Some(spec);
460 }
461
462 self.find_model_by_normalized_id(api_identifier)
464 }
465
466 #[must_use]
468 pub fn get_max_output_tokens(&self, api_identifier: &str) -> usize {
469 if let Some(spec) = self.get_model_spec(api_identifier) {
470 return spec.max_output_tokens;
471 }
472
473 if let Some(provider) = self.infer_provider(api_identifier) {
475 if let Some(provider_config) = self.config.providers.get(&provider) {
476 return provider_config.defaults.max_output_tokens;
477 }
478 }
479
480 FALLBACK_MAX_OUTPUT_TOKENS
482 }
483
484 #[must_use]
486 pub fn get_input_context(&self, api_identifier: &str) -> usize {
487 if let Some(spec) = self.get_model_spec(api_identifier) {
488 return spec.input_context;
489 }
490
491 if let Some(provider) = self.infer_provider(api_identifier) {
493 if let Some(provider_config) = self.config.providers.get(&provider) {
494 return provider_config.defaults.input_context;
495 }
496 }
497
498 FALLBACK_INPUT_CONTEXT
500 }
501
502 #[must_use]
512 pub fn supports_structured_output(&self, api_identifier: &str) -> bool {
513 self.get_model_spec(api_identifier)
514 .is_some_and(|spec| spec.supports_structured_output)
515 }
516
517 fn infer_provider(&self, api_identifier: &str) -> Option<String> {
533 let id = api_identifier;
534 if id.starts_with("claude") || id.contains("anthropic") {
535 Some("claude".to_string())
536 } else if id.starts_with("gpt") || id.starts_with("chatgpt") || is_openai_reasoning_id(id) {
537 Some("openai".to_string())
538 } else if id.starts_with("gemini") {
539 Some("gemini".to_string())
540 } else {
541 None
542 }
543 }
544
545 fn find_model_by_normalized_id(&self, api_identifier: &str) -> Option<&ModelSpec> {
550 let core_identifier = self.extract_core_model_identifier(api_identifier);
551 self.by_identifier.get(&core_identifier)
552 }
553
554 fn extract_core_model_identifier(&self, api_identifier: &str) -> String {
556 let mut identifier = api_identifier.to_string();
557
558 if let Some(dot_pos) = identifier.find('.') {
560 if identifier[..dot_pos].len() <= 3 {
561 identifier = identifier[dot_pos + 1..].to_string();
563 }
564 }
565
566 if identifier.starts_with("anthropic.") {
568 identifier = identifier["anthropic.".len()..].to_string();
569 }
570
571 if let Some(version_pos) = identifier.rfind("-v") {
573 if identifier[version_pos..].contains(':') {
574 identifier = identifier[..version_pos].to_string();
575 }
576 }
577
578 identifier
579 }
580
581 #[must_use]
583 pub fn is_legacy_model(&self, api_identifier: &str) -> bool {
584 self.get_model_spec(api_identifier)
585 .is_some_and(|spec| spec.legacy)
586 }
587
588 #[must_use]
590 pub fn get_all_models(&self) -> &[ModelSpec] {
591 &self.config.models
592 }
593
594 #[must_use]
596 pub fn get_models_by_provider(&self, provider: &str) -> Vec<&ModelSpec> {
597 self.by_provider
598 .get(provider)
599 .map(|models| models.iter().collect())
600 .unwrap_or_default()
601 }
602
603 #[must_use]
609 pub fn is_known_model(&self, identifier: &str) -> bool {
610 self.get_model_spec(identifier).is_some()
611 }
612
613 #[must_use]
619 pub fn known_identifiers(&self, provider: &str) -> Vec<&str> {
620 let mut identifiers: Vec<&str> = self
621 .get_models_by_provider(provider)
622 .into_iter()
623 .filter(|model| !model.legacy)
624 .map(|model| model.api_identifier.as_str())
625 .collect();
626 identifiers.sort_unstable();
627 identifiers
628 }
629
630 #[must_use]
632 pub fn get_models_by_provider_and_tier(&self, provider: &str, tier: &str) -> Vec<&ModelSpec> {
633 self.get_models_by_provider(provider)
634 .into_iter()
635 .filter(|model| model.tier == tier)
636 .collect()
637 }
638
639 #[must_use]
641 pub fn get_default_model(&self, provider: &str) -> Option<&str> {
642 self.config
643 .providers
644 .get(provider)
645 .map(|p| p.default_model.as_str())
646 }
647
648 #[must_use]
650 pub fn get_provider_config(&self, provider: &str) -> Option<&ProviderConfig> {
651 self.config.providers.get(provider)
652 }
653
654 #[must_use]
656 pub fn get_tier_info(&self, provider: &str, tier: &str) -> Option<&TierInfo> {
657 self.config.providers.get(provider)?.tiers.get(tier)
658 }
659
660 #[must_use]
662 pub fn get_beta_headers(&self, api_identifier: &str) -> &[BetaHeader] {
663 self.get_model_spec(api_identifier)
664 .map(|spec| spec.beta_headers.as_slice())
665 .unwrap_or_default()
666 }
667
668 #[must_use]
670 pub fn get_max_output_tokens_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
671 if let Some(spec) = self.get_model_spec(api_identifier) {
672 if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
673 if let Some(max) = bh.max_output_tokens {
674 return max;
675 }
676 }
677 return spec.max_output_tokens;
678 }
679 self.get_max_output_tokens(api_identifier)
680 }
681
682 #[must_use]
684 pub fn get_input_context_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
685 if let Some(spec) = self.get_model_spec(api_identifier) {
686 if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
687 if let Some(ctx) = bh.input_context {
688 return ctx;
689 }
690 }
691 return spec.input_context;
692 }
693 self.get_input_context(api_identifier)
694 }
695}
696
697fn is_openai_reasoning_id(id: &str) -> bool {
703 let mut chars = id.chars();
704 chars.next() == Some('o') && chars.next().is_some_and(|c| c.is_ascii_digit())
705}
706
707fn default_project_path() -> Option<PathBuf> {
709 std::env::current_dir()
710 .ok()
711 .map(|cwd| cwd.join(".omni-dev").join("models.yaml"))
712}
713
714fn default_user_path() -> Option<PathBuf> {
716 dirs::home_dir().map(|h| h.join(".omni-dev").join("models.yaml"))
717}
718
719fn read_optional_yaml(path: &Path) -> Option<String> {
722 if !path.exists() {
723 return None;
724 }
725 match std::fs::read_to_string(path) {
726 Ok(s) => Some(s),
727 Err(e) => {
728 tracing::error!(
729 "Failed to read {}: {e}. Falling through to lower-precedence layers.",
730 path.display()
731 );
732 None
733 }
734 }
735}
736
737fn merge_layer_into(
748 dest: &mut serde_yaml::Value,
749 src: serde_yaml::Value,
750 source: ModelSource,
751 model_sources: &mut HashMap<String, ModelSource>,
752 provider_sources: &mut HashMap<String, ModelSource>,
753) {
754 use serde_yaml::Value;
755
756 let Value::Mapping(src_map) = src else {
757 *dest = src;
761 return;
762 };
763
764 if !matches!(dest, Value::Mapping(_)) {
765 *dest = Value::Mapping(serde_yaml::Mapping::new());
766 }
767 let Value::Mapping(dest_map) = dest else {
768 unreachable!("dest is a mapping after the check above");
769 };
770
771 for (k, v) in src_map {
772 match k.as_str() {
773 Some("models") => merge_models_into(dest_map, k, v, source, model_sources),
774 Some("providers") => merge_providers_into(dest_map, k, v, source, provider_sources),
775 _ => {
776 dest_map.insert(k, v);
777 }
778 }
779 }
780}
781
782fn merge_models_into(
783 dest_map: &mut serde_yaml::Mapping,
784 key: serde_yaml::Value,
785 incoming: serde_yaml::Value,
786 source: ModelSource,
787 model_sources: &mut HashMap<String, ModelSource>,
788) {
789 use serde_yaml::Value;
790
791 let Value::Sequence(incoming_seq) = incoming else {
792 dest_map.insert(key, incoming);
794 return;
795 };
796
797 let dest_value = dest_map
798 .entry(key)
799 .or_insert_with(|| Value::Sequence(Vec::new()));
800 if !matches!(dest_value, Value::Sequence(_)) {
801 *dest_value = Value::Sequence(Vec::new());
802 }
803 let Value::Sequence(dest_seq) = dest_value else {
804 unreachable!("dest is a sequence after the check above");
805 };
806
807 for entry in incoming_seq {
808 let api_id = entry
809 .get("api_identifier")
810 .and_then(|v| v.as_str())
811 .map(String::from);
812
813 let Some(api_id) = api_id else {
814 tracing::warn!(
815 "Skipping model entry without `api_identifier` from {source} models.yaml"
816 );
817 continue;
818 };
819
820 if let Some(existing) = dest_seq
821 .iter_mut()
822 .find(|e| e.get("api_identifier").and_then(serde_yaml::Value::as_str) == Some(&api_id))
823 {
824 deep_merge(existing, entry);
825 } else {
826 dest_seq.push(entry);
827 }
828
829 model_sources.insert(api_id, source);
830 }
831}
832
833fn merge_providers_into(
834 dest_map: &mut serde_yaml::Mapping,
835 key: serde_yaml::Value,
836 incoming: serde_yaml::Value,
837 source: ModelSource,
838 provider_sources: &mut HashMap<String, ModelSource>,
839) {
840 use serde_yaml::Value;
841
842 let Value::Mapping(incoming_providers) = incoming else {
843 dest_map.insert(key, incoming);
844 return;
845 };
846
847 let dest_value = dest_map
848 .entry(key)
849 .or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
850 if !matches!(dest_value, Value::Mapping(_)) {
851 *dest_value = Value::Mapping(serde_yaml::Mapping::new());
852 }
853 let Value::Mapping(dest_providers) = dest_value else {
854 unreachable!("dest is a mapping after the check above");
855 };
856
857 for (pname, pvalue) in incoming_providers {
858 let pname_str = pname.as_str().map(String::from);
859
860 if let Some(existing) = dest_providers.get_mut(&pname) {
861 deep_merge(existing, pvalue);
862 } else {
863 dest_providers.insert(pname.clone(), pvalue);
864 }
865
866 if let Some(name) = pname_str {
867 provider_sources.insert(name, source);
868 }
869 }
870}
871
872fn deep_merge(dest: &mut serde_yaml::Value, src: serde_yaml::Value) {
875 use serde_yaml::Value;
876 match (dest, src) {
877 (Value::Mapping(d), Value::Mapping(s)) => {
878 for (k, v) in s {
879 if let Some(existing) = d.get_mut(&k) {
880 deep_merge(existing, v);
881 } else {
882 d.insert(k, v);
883 }
884 }
885 }
886 (d, s) => *d = s,
887 }
888}
889
890fn warn_on_version_mismatch(declared: &[(ModelSource, Option<String>)]) {
893 for (source, version) in declared {
894 if matches!(source, ModelSource::Embedded) {
895 continue;
896 }
897 match version {
898 None => {
899 tracing::warn!(
900 "{source} models.yaml has no `version:` field; assuming compatibility with schema version {MODELS_SCHEMA_VERSION}. Add `version: \"{MODELS_SCHEMA_VERSION}\"` to silence this warning."
901 );
902 }
903 Some(v) if v == MODELS_SCHEMA_VERSION => {}
904 Some(v) => {
905 tracing::warn!(
906 "{source} models.yaml declares schema version {v}; this build understands {MODELS_SCHEMA_VERSION}. Continuing — unrecognised fields may be ignored."
907 );
908 }
909 }
910 }
911}
912
913static MODEL_REGISTRY: OnceLock<ModelRegistry> = OnceLock::new();
915
916#[must_use]
918pub fn get_model_registry() -> &'static ModelRegistry {
919 #[allow(clippy::expect_used)] MODEL_REGISTRY.get_or_init(|| ModelRegistry::load().expect("Failed to load model registry"))
921}
922
923#[cfg(test)]
924#[allow(clippy::unwrap_used, clippy::expect_used)]
925mod tests {
926 use super::*;
927 use std::io::Write;
928
929 fn embedded_only() -> ModelRegistry {
930 ModelRegistry::load_layered_from_paths(None, None, None).unwrap()
931 }
932
933 #[test]
936 fn is_known_model_rejects_unknown_claude_identifier() {
937 let registry = embedded_only();
938 assert!(!registry.is_known_model("claude-sonnet-4-8"));
939 assert!(registry.is_known_model("claude-sonnet-4-6"));
940 }
941
942 #[test]
943 fn is_known_model_normalizes_bedrock_identifiers() {
944 let registry = embedded_only();
945 let bedrock_id = registry
948 .get_all_models()
949 .iter()
950 .find(|m| m.provider == "claude")
951 .map(|m| format!("us.anthropic.{}", m.api_identifier))
952 .expect("embedded catalog should list claude models");
953 assert!(
954 registry.is_known_model(&bedrock_id),
955 "{bedrock_id} should normalise to a known model"
956 );
957 }
958
959 #[test]
960 fn known_identifiers_lists_non_legacy_claude_models() {
961 let registry = embedded_only();
962 let identifiers = registry.known_identifiers("claude");
963
964 assert!(!identifiers.is_empty());
965 assert!(identifiers.contains(&"claude-sonnet-4-6"));
966 assert!(
967 identifiers.windows(2).all(|w| w[0] <= w[1]),
968 "identifiers should be sorted: {identifiers:?}"
969 );
970 for id in &identifiers {
971 let spec = registry
972 .get_model_spec(id)
973 .expect("listed model must exist");
974 assert!(!spec.legacy, "legacy model {id} should not be suggested");
975 assert_eq!(spec.provider, "claude");
976 }
977 }
978
979 #[test]
980 fn known_identifiers_is_empty_for_provider_without_models() {
981 let registry = embedded_only();
982 assert!(registry.known_identifiers("ollama").is_empty());
983 }
984
985 #[test]
988 fn legacy_models_are_known_but_not_suggested() {
989 let registry = embedded_only();
990 let legacy = registry
991 .get_all_models()
992 .iter()
993 .find(|m| m.provider == "claude" && m.legacy)
994 .map(|m| m.api_identifier.clone())
995 .expect("embedded catalog should list a legacy claude model");
996
997 assert!(
998 registry.is_known_model(&legacy),
999 "{legacy} must still resolve"
1000 );
1001 assert!(
1002 !registry
1003 .known_identifiers("claude")
1004 .contains(&legacy.as_str()),
1005 "{legacy} should not be suggested"
1006 );
1007 }
1008
1009 fn write_yaml(dir: &Path, name: &str, contents: &str) -> PathBuf {
1010 let path = dir.join(name);
1011 let mut f = std::fs::File::create(&path).unwrap();
1012 f.write_all(contents.as_bytes()).unwrap();
1013 path
1014 }
1015
1016 #[test]
1017 fn load_model_registry() {
1018 let registry = embedded_only();
1019 assert!(!registry.config.models.is_empty());
1020 assert!(registry.config.providers.contains_key("claude"));
1021 assert_eq!(
1022 registry.config.version.as_deref(),
1023 Some(MODELS_SCHEMA_VERSION)
1024 );
1025 }
1026
1027 #[test]
1028 fn claude_model_lookup() {
1029 let registry = embedded_only();
1030
1031 let opus_spec = registry.get_model_spec("claude-3-opus-20240229");
1033 assert!(opus_spec.is_some());
1034 assert_eq!(opus_spec.unwrap().max_output_tokens, 4096);
1035 assert_eq!(opus_spec.unwrap().provider, "claude");
1036 assert!(registry.is_legacy_model("claude-3-opus-20240229"));
1037
1038 let sonnet45_tokens = registry.get_max_output_tokens("claude-sonnet-4-5-20250929");
1040 assert_eq!(sonnet45_tokens, 64000);
1041
1042 let sonnet4_tokens = registry.get_max_output_tokens("claude-sonnet-4-20250514");
1044 assert_eq!(sonnet4_tokens, 64000);
1045 assert!(registry.is_legacy_model("claude-sonnet-4-20250514"));
1046
1047 let unknown_tokens = registry.get_max_output_tokens("claude-unknown-model");
1049 assert_eq!(unknown_tokens, 4096); }
1051
1052 #[test]
1053 fn unknown_provider_uses_ultimate_fallback() {
1054 let registry = embedded_only();
1055
1056 assert_eq!(
1058 registry.get_max_output_tokens("totally-unknown-vendor-x"),
1059 FALLBACK_MAX_OUTPUT_TOKENS
1060 );
1061 assert_eq!(
1062 registry.get_input_context("totally-unknown-vendor-x"),
1063 FALLBACK_INPUT_CONTEXT
1064 );
1065 }
1066
1067 #[test]
1068 fn unknown_openai_model_uses_openai_provider_defaults() {
1069 let registry = embedded_only();
1070
1071 for id in ["gpt-6-ultra", "chatgpt-6-latest", "o5-preview"] {
1076 assert_eq!(
1077 registry.get_max_output_tokens(id),
1078 16384,
1079 "max_output_tokens for {id}"
1080 );
1081 assert_eq!(
1082 registry.get_input_context(id),
1083 128_000,
1084 "input_context for {id}"
1085 );
1086 }
1087 }
1088
1089 #[test]
1090 fn unknown_gemini_model_uses_gemini_provider_defaults() {
1091 let registry = embedded_only();
1092
1093 let id = "gemini-9-pro";
1096 assert_eq!(registry.get_max_output_tokens(id), 8192);
1097 assert_eq!(registry.get_input_context(id), 1_048_576);
1098 }
1099
1100 #[test]
1101 fn provider_filtering() {
1102 let registry = embedded_only();
1103
1104 let claude_models = registry.get_models_by_provider("claude");
1105 assert!(!claude_models.is_empty());
1106
1107 let fast_claude_models = registry.get_models_by_provider_and_tier("claude", "fast");
1108 assert!(!fast_claude_models.is_empty());
1109
1110 let tier_info = registry.get_tier_info("claude", "fast");
1111 assert!(tier_info.is_some());
1112 }
1113
1114 #[test]
1115 fn provider_config() {
1116 let registry = embedded_only();
1117
1118 let claude_config = registry.get_provider_config("claude");
1119 assert!(claude_config.is_some());
1120 assert_eq!(claude_config.unwrap().name, "Anthropic Claude");
1121 }
1122
1123 #[test]
1124 fn default_model_per_provider() {
1125 let registry = embedded_only();
1126
1127 assert_eq!(
1128 registry.get_default_model("claude"),
1129 Some("claude-sonnet-5")
1130 );
1131 assert_eq!(registry.get_default_model("openai"), Some("gpt-5-mini"));
1132 assert_eq!(
1133 registry.get_default_model("gemini"),
1134 Some("gemini-2.5-flash")
1135 );
1136 assert_eq!(registry.get_default_model("nonexistent"), None);
1137 }
1138
1139 #[test]
1140 fn normalized_id_matching() {
1141 let registry = embedded_only();
1142
1143 let bedrock_3_7_sonnet = "us.anthropic.claude-3-7-sonnet-20250219-v1:0";
1145 let spec = registry.get_model_spec(bedrock_3_7_sonnet);
1146 assert!(spec.is_some());
1147 assert_eq!(spec.unwrap().api_identifier, "claude-3-7-sonnet-20250219");
1148 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1149
1150 let aws_haiku = "anthropic.claude-3-haiku-20240307-v1:0";
1152 let spec = registry.get_model_spec(aws_haiku);
1153 assert!(spec.is_some());
1154 assert_eq!(spec.unwrap().api_identifier, "claude-3-haiku-20240307");
1155 assert_eq!(spec.unwrap().max_output_tokens, 4096);
1156
1157 let eu_opus = "eu.anthropic.claude-3-opus-20240229-v2:1";
1159 let spec = registry.get_model_spec(eu_opus);
1160 assert!(spec.is_some());
1161 assert_eq!(spec.unwrap().api_identifier, "claude-3-opus-20240229");
1162 assert_eq!(spec.unwrap().max_output_tokens, 4096);
1163
1164 let exact_sonnet45 = "claude-sonnet-4-5-20250929";
1166 let spec = registry.get_model_spec(exact_sonnet45);
1167 assert!(spec.is_some());
1168 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1169
1170 let exact_sonnet4 = "claude-sonnet-4-20250514";
1172 let spec = registry.get_model_spec(exact_sonnet4);
1173 assert!(spec.is_some());
1174 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1175 }
1176
1177 #[test]
1182 fn supports_structured_output_gates_by_model() {
1183 let registry = embedded_only();
1184
1185 assert!(registry.supports_structured_output("claude-fable-5"));
1187 assert!(registry.supports_structured_output("claude-opus-4-8"));
1188 assert!(registry.supports_structured_output("claude-opus-4-7"));
1189 assert!(registry.supports_structured_output("claude-sonnet-5"));
1190 assert!(registry.supports_structured_output("claude-sonnet-4-6"));
1191 assert!(registry.supports_structured_output("claude-opus-4-6"));
1192 assert!(registry.supports_structured_output("claude-haiku-4-5-20251001"));
1193 assert!(registry.supports_structured_output("claude-opus-4-5-20251101"));
1194 assert!(
1195 registry.supports_structured_output("us.anthropic.claude-sonnet-4-5-20250929-v1:0"),
1196 "Bedrock/region-prefixed forms must normalize to the flagged spec"
1197 );
1198
1199 assert!(!registry.supports_structured_output("claude-opus-4-1-20250805"));
1201 assert!(!registry.supports_structured_output("claude-3-opus-20240229"));
1202 assert!(!registry.supports_structured_output("claude-sonnet-4-20250514"));
1203
1204 assert!(!registry.supports_structured_output("totally-unknown-model"));
1206 }
1207
1208 #[test]
1212 fn current_generation_models_are_registered() {
1213 let registry = embedded_only();
1214
1215 for id in [
1216 "claude-fable-5",
1217 "claude-opus-4-8",
1218 "claude-opus-4-7",
1219 "claude-sonnet-5",
1220 ] {
1221 let spec = registry
1222 .get_model_spec(id)
1223 .unwrap_or_else(|| panic!("{id} must be registered"));
1224 assert_eq!(spec.input_context, 1_000_000, "{id} context window");
1225 assert_eq!(spec.max_output_tokens, 128_000, "{id} max output");
1226 assert!(
1227 spec.beta_headers.is_empty(),
1228 "{id} exposes 1M natively and must not rely on a context beta header"
1229 );
1230 assert!(spec.input_token_price.is_some(), "{id} input price");
1231 assert!(spec.output_token_price.is_some(), "{id} output price");
1232 assert!(!spec.legacy, "{id} is current, not legacy");
1233 }
1234
1235 let sonnet5 = registry.get_model_spec("claude-sonnet-5").unwrap();
1238 assert_eq!(sonnet5.input_token_price, Some(3.0));
1239 assert_eq!(sonnet5.output_token_price, Some(15.0));
1240 }
1241
1242 #[test]
1246 fn claude_4_6_limits_are_native_not_beta_gated() {
1247 let registry = embedded_only();
1248
1249 assert_eq!(registry.get_input_context("claude-opus-4-6"), 1_000_000);
1250 assert_eq!(registry.get_input_context("claude-sonnet-4-6"), 1_000_000);
1251 assert_eq!(registry.get_max_output_tokens("claude-sonnet-4-6"), 128_000);
1252 assert_eq!(registry.get_max_output_tokens("claude-opus-4-6"), 128_000);
1253 }
1254
1255 #[test]
1262 fn undated_aliases_resolve_to_real_limits() {
1263 let registry = embedded_only();
1264
1265 for (alias, dated) in [
1266 ("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
1267 ("claude-sonnet-4-5", "claude-sonnet-4-5-20250929"),
1268 ("claude-opus-4-5", "claude-opus-4-5-20251101"),
1269 ] {
1270 assert_eq!(
1271 registry.get_max_output_tokens(alias),
1272 registry.get_max_output_tokens(dated),
1273 "{alias} must resolve to the same output cap as {dated}, \
1274 not the provider fallback"
1275 );
1276 assert_eq!(
1277 registry.get_input_context(alias),
1278 registry.get_input_context(dated),
1279 "{alias} context window must match {dated}"
1280 );
1281 }
1282
1283 assert_eq!(registry.get_max_output_tokens("claude-haiku-4-5"), 64_000);
1285 }
1286
1287 #[test]
1291 fn retires_records_announced_dates_only() {
1292 let registry = embedded_only();
1293
1294 let opus_41 = registry.get_model_spec("claude-opus-4-1-20250805").unwrap();
1295 assert_eq!(opus_41.retires.as_deref(), Some("2026-08-05"));
1296 assert!(opus_41.legacy);
1297
1298 assert_eq!(
1301 registry
1302 .get_model_spec("claude-3-opus-20240229")
1303 .unwrap()
1304 .retires
1305 .as_deref(),
1306 Some("2026-01-05")
1307 );
1308
1309 for id in ["claude-opus-4-20250514", "claude-sonnet-4-20250514"] {
1311 let spec = registry.get_model_spec(id).unwrap();
1312 assert!(spec.legacy, "{id} is deprecated");
1313 assert!(
1314 spec.retires.is_none(),
1315 "{id} has no announced retirement date; absence must not be \
1316 filled in with a guess"
1317 );
1318 }
1319
1320 assert!(registry
1322 .get_model_spec("claude-sonnet-5")
1323 .unwrap()
1324 .retires
1325 .is_none());
1326 }
1327
1328 #[test]
1329 fn extract_core_model_identifier() {
1330 let registry = embedded_only();
1331
1332 assert_eq!(
1334 registry.extract_core_model_identifier("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
1335 "claude-3-7-sonnet-20250219"
1336 );
1337
1338 assert_eq!(
1339 registry.extract_core_model_identifier("anthropic.claude-3-haiku-20240307-v1:0"),
1340 "claude-3-haiku-20240307"
1341 );
1342
1343 assert_eq!(
1344 registry.extract_core_model_identifier("claude-3-opus-20240229"),
1345 "claude-3-opus-20240229"
1346 );
1347
1348 assert_eq!(
1349 registry.extract_core_model_identifier("eu.anthropic.claude-sonnet-4-20250514-v2:1"),
1350 "claude-sonnet-4-20250514"
1351 );
1352 }
1353
1354 #[test]
1355 fn beta_header_lookups() {
1356 let registry = embedded_only();
1357
1358 assert_eq!(registry.get_max_output_tokens("claude-opus-4-6"), 128_000);
1361 assert_eq!(registry.get_input_context("claude-opus-4-6"), 1_000_000);
1362
1363 assert_eq!(
1368 registry.get_input_context_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1369 1_000_000
1370 );
1371 assert_eq!(
1373 registry.get_max_output_tokens_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1374 128_000
1375 );
1376
1377 assert_eq!(
1379 registry.get_max_output_tokens_with_beta(
1380 "claude-3-7-sonnet-20250219",
1381 "output-128k-2025-02-19"
1382 ),
1383 128_000
1384 );
1385
1386 assert_eq!(
1388 registry.get_max_output_tokens("claude-3-7-sonnet-20250219"),
1389 64000
1390 );
1391
1392 let headers = registry.get_beta_headers("claude-opus-4-6");
1394 assert_eq!(headers.len(), 1);
1395 assert_eq!(headers[0].key, "anthropic-beta");
1396 assert_eq!(headers[0].value, "context-1m-2025-08-07");
1397
1398 let headers = registry.get_beta_headers("claude-3-7-sonnet-20250219");
1400 assert_eq!(headers.len(), 2);
1401
1402 let headers = registry.get_beta_headers("claude-3-haiku-20240307");
1404 assert!(headers.is_empty());
1405
1406 let headers = registry.get_beta_headers("unknown-model");
1408 assert!(headers.is_empty());
1409 }
1410
1411 #[test]
1412 fn beta_lookups_for_unknown_model_fall_through_to_provider_defaults() {
1413 let registry = embedded_only();
1414
1415 assert_eq!(
1419 registry
1420 .get_max_output_tokens_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1421 4096
1422 );
1423 assert_eq!(
1424 registry.get_input_context_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1425 200_000
1426 );
1427 }
1428
1429 #[test]
1430 fn embedded_models_default_to_embedded_source() {
1431 let registry = embedded_only();
1432 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1433 assert_eq!(spec.source, ModelSource::Embedded);
1434
1435 let provider = registry.get_provider_config("claude").unwrap();
1436 assert_eq!(provider.source, ModelSource::Embedded);
1437 }
1438
1439 #[test]
1440 fn missing_user_and_project_files_fall_through_silently() {
1441 let dir = tempfile::tempdir().unwrap();
1442 let project_path = dir.path().join("missing-project.yaml");
1443 let user_path = dir.path().join("missing-user.yaml");
1444 let registry =
1445 ModelRegistry::load_layered_from_paths(Some(&project_path), Some(&user_path), None)
1446 .unwrap();
1447
1448 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1450 assert_eq!(spec.source, ModelSource::Embedded);
1451 assert_eq!(spec.max_output_tokens, 128_000);
1452 }
1453
1454 #[test]
1455 fn user_layer_overrides_embedded_entry() {
1456 let dir = tempfile::tempdir().unwrap();
1457 let user = write_yaml(
1458 dir.path(),
1459 "user.yaml",
1460 r#"
1461version: "1"
1462models:
1463 - provider: "claude"
1464 model: "Claude Opus 4.6 (custom)"
1465 api_identifier: "claude-opus-4-6"
1466 max_output_tokens: 999999
1467 input_context: 200000
1468 generation: 4.6
1469 tier: "flagship"
1470"#,
1471 );
1472
1473 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1474 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1475 assert_eq!(spec.max_output_tokens, 999_999);
1476 assert_eq!(spec.model, "Claude Opus 4.6 (custom)");
1477 assert_eq!(spec.source, ModelSource::User);
1478 }
1479
1480 #[test]
1481 fn project_layer_takes_precedence_over_user_layer() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let user = write_yaml(
1484 dir.path(),
1485 "user.yaml",
1486 r#"
1487version: "1"
1488models:
1489 - provider: "claude"
1490 model: "From User"
1491 api_identifier: "claude-opus-4-6"
1492 max_output_tokens: 1
1493 input_context: 1
1494 generation: 4.6
1495 tier: "flagship"
1496"#,
1497 );
1498 let project = write_yaml(
1499 dir.path(),
1500 "project.yaml",
1501 r#"
1502version: "1"
1503models:
1504 - provider: "claude"
1505 model: "From Project"
1506 api_identifier: "claude-opus-4-6"
1507 max_output_tokens: 2
1508 input_context: 2
1509 generation: 4.6
1510 tier: "flagship"
1511"#,
1512 );
1513
1514 let registry =
1515 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1516 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1517 assert_eq!(spec.model, "From Project");
1518 assert_eq!(spec.max_output_tokens, 2);
1519 assert_eq!(spec.source, ModelSource::Project);
1520 }
1521
1522 #[test]
1523 fn additive_user_entry_is_appended() {
1524 let dir = tempfile::tempdir().unwrap();
1525 let user = write_yaml(
1526 dir.path(),
1527 "user.yaml",
1528 r#"
1529version: "1"
1530models:
1531 - provider: "claude"
1532 model: "Claude Custom Future"
1533 api_identifier: "claude-future-9000"
1534 max_output_tokens: 250000
1535 input_context: 5000000
1536 generation: 9.0
1537 tier: "flagship"
1538"#,
1539 );
1540
1541 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1542 let spec = registry.get_model_spec("claude-future-9000").unwrap();
1543 assert_eq!(spec.max_output_tokens, 250_000);
1544 assert_eq!(spec.input_context, 5_000_000);
1545 assert_eq!(spec.source, ModelSource::User);
1546
1547 let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1549 assert_eq!(opus.source, ModelSource::Embedded);
1550 }
1551
1552 #[test]
1553 fn provider_fields_can_be_partially_overridden() {
1554 let dir = tempfile::tempdir().unwrap();
1555 let user = write_yaml(
1558 dir.path(),
1559 "user.yaml",
1560 r#"
1561version: "1"
1562providers:
1563 claude:
1564 default_model: "claude-opus-4-6"
1565"#,
1566 );
1567
1568 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1569 let claude = registry.get_provider_config("claude").unwrap();
1570 assert_eq!(claude.default_model, "claude-opus-4-6");
1571 assert_eq!(claude.name, "Anthropic Claude");
1573 assert_eq!(claude.api_base, "https://api.anthropic.com/v1");
1574 assert!(claude.tiers.contains_key("flagship"));
1575 assert_eq!(claude.source, ModelSource::User);
1577 }
1578
1579 #[test]
1580 fn malformed_user_yaml_logs_and_falls_through() {
1581 let dir = tempfile::tempdir().unwrap();
1582 let user = write_yaml(
1583 dir.path(),
1584 "user.yaml",
1585 "this: is: definitely: not: valid: yaml: [unbalanced",
1586 );
1587
1588 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1589 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1591 assert_eq!(spec.source, ModelSource::Embedded);
1592 assert_eq!(spec.max_output_tokens, 128_000);
1593 }
1594
1595 #[test]
1596 fn override_path_short_circuits_user_and_project() {
1597 let dir = tempfile::tempdir().unwrap();
1598 let user = write_yaml(
1599 dir.path(),
1600 "user.yaml",
1601 r#"
1602version: "1"
1603models:
1604 - provider: "claude"
1605 model: "From User"
1606 api_identifier: "claude-opus-4-6"
1607 max_output_tokens: 1
1608 input_context: 1
1609 generation: 4.6
1610 tier: "flagship"
1611"#,
1612 );
1613 let project = write_yaml(
1614 dir.path(),
1615 "project.yaml",
1616 r#"
1617version: "1"
1618models:
1619 - provider: "claude"
1620 model: "From Project"
1621 api_identifier: "claude-opus-4-6"
1622 max_output_tokens: 2
1623 input_context: 2
1624 generation: 4.6
1625 tier: "flagship"
1626"#,
1627 );
1628 let override_file = write_yaml(
1629 dir.path(),
1630 "override.yaml",
1631 r#"
1632version: "1"
1633models:
1634 - provider: "claude"
1635 model: "From Override"
1636 api_identifier: "claude-opus-4-6"
1637 max_output_tokens: 3
1638 input_context: 3
1639 generation: 4.6
1640 tier: "flagship"
1641"#,
1642 );
1643
1644 let registry = ModelRegistry::load_layered_from_paths(
1645 Some(&project),
1646 Some(&user),
1647 Some(&override_file),
1648 )
1649 .unwrap();
1650 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1651 assert_eq!(spec.model, "From Override");
1652 assert_eq!(spec.max_output_tokens, 3);
1653 assert_eq!(spec.source, ModelSource::Override);
1654 }
1655
1656 #[test]
1657 fn missing_override_path_falls_back_to_embedded() {
1658 let dir = tempfile::tempdir().unwrap();
1659 let missing = dir.path().join("does-not-exist.yaml");
1660 let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&missing)).unwrap();
1661 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1662 assert_eq!(spec.source, ModelSource::Embedded);
1663 }
1664
1665 #[test]
1666 fn version_mismatch_is_warned_not_fatal() {
1667 let dir = tempfile::tempdir().unwrap();
1668 let user = write_yaml(
1669 dir.path(),
1670 "user.yaml",
1671 r#"
1672version: "9999"
1673models:
1674 - provider: "claude"
1675 model: "From Future"
1676 api_identifier: "claude-future-9000"
1677 max_output_tokens: 1
1678 input_context: 1
1679 generation: 9.0
1680 tier: "flagship"
1681"#,
1682 );
1683 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1684 assert!(registry.get_model_spec("claude-future-9000").is_some());
1686 }
1687
1688 #[test]
1689 fn missing_version_is_accepted() {
1690 let dir = tempfile::tempdir().unwrap();
1691 let user = write_yaml(
1692 dir.path(),
1693 "user.yaml",
1694 r#"
1695models:
1696 - provider: "claude"
1697 model: "Versionless"
1698 api_identifier: "claude-versionless"
1699 max_output_tokens: 1
1700 input_context: 1
1701 generation: 1.0
1702 tier: "flagship"
1703"#,
1704 );
1705 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1706 assert!(registry.get_model_spec("claude-versionless").is_some());
1707 }
1708
1709 #[test]
1710 fn model_entry_without_api_identifier_is_skipped() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let user = write_yaml(
1713 dir.path(),
1714 "user.yaml",
1715 r#"
1716version: "1"
1717models:
1718 - provider: "claude"
1719 model: "No Id"
1720 max_output_tokens: 1
1721 input_context: 1
1722 generation: 1.0
1723 tier: "flagship"
1724"#,
1725 );
1726 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1727 let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1729 assert_eq!(opus.source, ModelSource::Embedded);
1730 }
1731
1732 #[test]
1733 fn model_source_display() {
1734 assert_eq!(ModelSource::Embedded.to_string(), "embedded");
1735 assert_eq!(ModelSource::User.to_string(), "user");
1736 assert_eq!(ModelSource::Project.to_string(), "project");
1737 assert_eq!(ModelSource::Override.to_string(), "override");
1738 }
1739
1740 #[test]
1741 fn embedded_yaml_must_not_be_malformed() {
1742 let layers = [(ModelSource::Embedded, "::: not yaml :::".to_string())];
1744 let result = ModelRegistry::from_layers(&layers);
1745 assert!(result.is_err());
1746 }
1747
1748 #[test]
1749 fn user_layer_with_scalar_top_level_returns_error() {
1750 let dir = tempfile::tempdir().unwrap();
1754 let user = write_yaml(dir.path(), "user.yaml", "\"just a string\"\n");
1755 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1756 assert!(result.is_err());
1757 }
1758
1759 #[test]
1760 fn user_layer_with_non_sequence_models_returns_error() {
1761 let dir = tempfile::tempdir().unwrap();
1765 let user = write_yaml(
1766 dir.path(),
1767 "user.yaml",
1768 r#"
1769version: "1"
1770models: 42
1771"#,
1772 );
1773 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1774 assert!(result.is_err());
1775 }
1776
1777 #[test]
1778 fn user_layer_with_non_mapping_providers_returns_error() {
1779 let dir = tempfile::tempdir().unwrap();
1782 let user = write_yaml(
1783 dir.path(),
1784 "user.yaml",
1785 r#"
1786version: "1"
1787providers: 42
1788"#,
1789 );
1790 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1791 assert!(result.is_err());
1792 }
1793
1794 #[test]
1795 fn deep_merge_inserts_new_keys_into_existing_mapping() {
1796 let dir = tempfile::tempdir().unwrap();
1800 let user = write_yaml(
1801 dir.path(),
1802 "user.yaml",
1803 r#"
1804version: "1"
1805providers:
1806 claude:
1807 tiers:
1808 experimental:
1809 description: "Experimental tier"
1810 use_cases: ["bleeding edge"]
1811"#,
1812 );
1813 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1814 let claude = registry.get_provider_config("claude").unwrap();
1815 assert!(claude.tiers.contains_key("flagship"));
1817 assert!(claude.tiers.contains_key("balanced"));
1818 assert!(claude.tiers.contains_key("fast"));
1819 let experimental = claude.tiers.get("experimental").unwrap();
1821 assert_eq!(experimental.description, "Experimental tier");
1822 assert_eq!(experimental.use_cases, vec!["bleeding edge".to_string()]);
1823 }
1824
1825 #[test]
1826 #[cfg(unix)]
1827 fn user_path_pointing_at_a_directory_logs_and_falls_through() {
1828 let dir = tempfile::tempdir().unwrap();
1831 let bogus = dir.path().join("models.yaml");
1832 std::fs::create_dir(&bogus).unwrap();
1833 let registry = ModelRegistry::load_layered_from_paths(None, Some(&bogus), None).unwrap();
1834 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1835 assert_eq!(spec.source, ModelSource::Embedded);
1836 }
1837
1838 #[test]
1839 #[cfg(unix)]
1840 fn override_path_pointing_at_a_directory_warns_and_falls_through() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let bogus = dir.path().join("override.yaml");
1843 std::fs::create_dir(&bogus).unwrap();
1844 let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&bogus)).unwrap();
1845 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1846 assert_eq!(spec.source, ModelSource::Embedded);
1847 }
1848
1849 #[test]
1850 fn project_layer_recovers_after_user_replaces_top_level_with_scalar() {
1851 let dir = tempfile::tempdir().unwrap();
1857 let user = write_yaml(dir.path(), "user.yaml", "\"junk\"\n");
1858 let project = write_yaml(
1859 dir.path(),
1860 "project.yaml",
1861 r#"
1862version: "1"
1863models:
1864 - provider: "claude"
1865 model: "Project Rescue"
1866 api_identifier: "claude-rescue"
1867 max_output_tokens: 1
1868 input_context: 1
1869 generation: 1.0
1870 tier: "flagship"
1871providers:
1872 custom-provider:
1873 name: "Custom"
1874 api_base: "https://example.invalid"
1875 default_model: "custom-default"
1876 tiers: {}
1877 defaults:
1878 max_output_tokens: 100
1879 input_context: 1000
1880"#,
1881 );
1882 let registry =
1883 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1884 let spec = registry.get_model_spec("claude-rescue").unwrap();
1886 assert_eq!(spec.source, ModelSource::Project);
1887 }
1888
1889 #[test]
1890 fn project_layer_recovers_after_user_replaces_models_with_scalar() {
1891 let dir = tempfile::tempdir().unwrap();
1895 let user = write_yaml(
1896 dir.path(),
1897 "user.yaml",
1898 r#"
1899version: "1"
1900models: 42
1901"#,
1902 );
1903 let project = write_yaml(
1904 dir.path(),
1905 "project.yaml",
1906 r#"
1907version: "1"
1908models:
1909 - provider: "claude"
1910 model: "Project Rescue"
1911 api_identifier: "claude-rescue"
1912 max_output_tokens: 1
1913 input_context: 1
1914 generation: 1.0
1915 tier: "flagship"
1916"#,
1917 );
1918 let registry =
1919 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1920 let spec = registry.get_model_spec("claude-rescue").unwrap();
1921 assert_eq!(spec.source, ModelSource::Project);
1922 }
1923
1924 #[test]
1925 fn project_layer_recovers_after_user_replaces_providers_with_scalar() {
1926 let dir = tempfile::tempdir().unwrap();
1929 let user = write_yaml(
1930 dir.path(),
1931 "user.yaml",
1932 r#"
1933version: "1"
1934providers: 42
1935"#,
1936 );
1937 let project = write_yaml(
1938 dir.path(),
1939 "project.yaml",
1940 r#"
1941version: "1"
1942providers:
1943 custom-provider:
1944 name: "Custom"
1945 api_base: "https://example.invalid"
1946 default_model: "custom-default"
1947 tiers: {}
1948 defaults:
1949 max_output_tokens: 100
1950 input_context: 1000
1951"#,
1952 );
1953 let registry =
1954 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1955 let provider = registry.get_provider_config("custom-provider").unwrap();
1956 assert_eq!(provider.name, "Custom");
1957 assert_eq!(provider.source, ModelSource::Project);
1958 }
1959
1960 #[test]
1961 fn empty_omni_dev_models_yaml_env_var_is_ignored() {
1962 let resolved: Option<PathBuf> = Some(String::new())
1966 .filter(|s| !s.is_empty())
1967 .map(PathBuf::from);
1968 assert!(resolved.is_none());
1969 let resolved: Option<PathBuf> = Some("/some/path".to_string())
1970 .filter(|s| !s.is_empty())
1971 .map(PathBuf::from);
1972 assert_eq!(resolved.as_deref(), Some(Path::new("/some/path")));
1973 }
1974}