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, Clone, Copy, PartialEq, Eq)]
125pub enum ModelFamily {
126 Fable,
128 Opus,
130 Sonnet,
132 Haiku,
134 Unknown,
136}
137
138impl ModelFamily {
139 #[must_use]
142 pub fn glyph(self) -> &'static str {
143 match self {
144 Self::Fable => "π ",
145 Self::Opus => "π‘",
146 Self::Sonnet => "π’",
147 Self::Haiku => "π΅",
148 Self::Unknown => "βͺ",
149 }
150 }
151
152 #[must_use]
154 pub fn label(self) -> &'static str {
155 match self {
156 Self::Fable => "Fable",
157 Self::Opus => "Opus",
158 Self::Sonnet => "Sonnet",
159 Self::Haiku => "Haiku",
160 Self::Unknown => "Claude",
161 }
162 }
163}
164
165#[derive(Debug, Deserialize, Serialize, Clone)]
192pub struct ModelSpec {
193 pub provider: String,
195 pub model: String,
197 pub api_identifier: String,
199 pub max_output_tokens: usize,
201 pub input_context: usize,
203 pub generation: f32,
205 pub tier: String,
207 #[serde(default)]
209 pub legacy: bool,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub retires: Option<String>,
226 #[serde(default)]
237 pub supports_structured_output: bool,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub input_token_price: Option<f64>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub output_token_price: Option<f64>,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub beta_headers: Vec<BetaHeader>,
253 #[serde(default, skip_deserializing)]
256 pub source: ModelSource,
257}
258
259#[derive(Debug, Deserialize, Serialize, Clone)]
269pub struct TierInfo {
270 pub description: String,
272 pub use_cases: Vec<String>,
274}
275
276#[derive(Debug, Deserialize, Serialize, Clone)]
287pub struct DefaultConfig {
288 pub max_output_tokens: usize,
290 pub input_context: usize,
292}
293
294#[derive(Debug, Deserialize, Serialize, Clone)]
306pub struct ProviderConfig {
307 pub name: String,
309 pub api_base: String,
311 pub default_model: String,
313 pub tiers: HashMap<String, TierInfo>,
315 pub defaults: DefaultConfig,
317 #[serde(default, skip_deserializing)]
319 pub source: ModelSource,
320}
321
322#[derive(Debug, Deserialize, Serialize, Clone)]
335pub struct ModelConfiguration {
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub version: Option<String>,
339 pub models: Vec<ModelSpec>,
341 pub providers: HashMap<String, ProviderConfig>,
343}
344
345pub struct ModelRegistry {
355 config: ModelConfiguration,
356 by_identifier: HashMap<String, ModelSpec>,
357 by_provider: HashMap<String, Vec<ModelSpec>>,
358}
359
360impl ModelRegistry {
361 pub fn load() -> Result<Self> {
374 let override_path = std::env::var(OMNI_DEV_MODELS_YAML_ENV)
375 .ok()
376 .filter(|s| !s.is_empty())
377 .map(PathBuf::from);
378 let project_path = default_project_path();
379 let user_path = default_user_path();
380 Self::load_layered_from_paths(
381 project_path.as_deref(),
382 user_path.as_deref(),
383 override_path.as_deref(),
384 )
385 }
386
387 pub fn load_layered_from_paths(
391 project_path: Option<&Path>,
392 user_path: Option<&Path>,
393 override_path: Option<&Path>,
394 ) -> Result<Self> {
395 let mut layers: Vec<(ModelSource, String)> = Vec::new();
396 layers.push((ModelSource::Embedded, MODELS_YAML.to_string()));
397
398 if let Some(path) = override_path {
399 match read_optional_yaml(path) {
400 Some(yaml) => layers.push((ModelSource::Override, yaml)),
401 None => {
402 tracing::warn!(
403 "{OMNI_DEV_MODELS_YAML_ENV} points at {} but the file is missing or unreadable; falling back to embedded catalog",
404 path.display()
405 );
406 }
407 }
408 } else {
409 if let Some(path) = user_path {
410 if let Some(yaml) = read_optional_yaml(path) {
411 layers.push((ModelSource::User, yaml));
412 }
413 }
414 if let Some(path) = project_path {
415 if let Some(yaml) = read_optional_yaml(path) {
416 layers.push((ModelSource::Project, yaml));
417 }
418 }
419 }
420
421 Self::from_layers(&layers)
422 }
423
424 pub(crate) fn from_layers(layers: &[(ModelSource, String)]) -> Result<Self> {
430 let mut merged: serde_yaml::Value =
431 serde_yaml::Value::Mapping(serde_yaml::Mapping::default());
432 let mut model_sources: HashMap<String, ModelSource> = HashMap::new();
433 let mut provider_sources: HashMap<String, ModelSource> = HashMap::new();
434 let mut declared_versions: Vec<(ModelSource, Option<String>)> = Vec::new();
435
436 for (source, yaml) in layers {
437 let value: serde_yaml::Value = match serde_yaml::from_str(yaml) {
438 Ok(v) => v,
439 Err(e) => {
440 if matches!(source, ModelSource::Embedded) {
441 return Err(anyhow!(
442 "Embedded models.yaml is malformed at compile time: {e}"
443 ));
444 }
445 tracing::error!(
446 "Malformed {source} models.yaml: {e}. Falling through to lower-precedence layers."
447 );
448 continue;
449 }
450 };
451
452 let version = value
454 .get("version")
455 .and_then(|v| v.as_str())
456 .map(String::from);
457 declared_versions.push((*source, version));
458
459 merge_layer_into(
460 &mut merged,
461 value,
462 *source,
463 &mut model_sources,
464 &mut provider_sources,
465 );
466 }
467
468 warn_on_version_mismatch(&declared_versions);
469
470 let mut config: ModelConfiguration = serde_yaml::from_value(merged)
471 .map_err(|e| anyhow!("Failed to deserialize merged model configuration: {e}"))?;
472
473 for spec in &mut config.models {
474 spec.source = model_sources
475 .get(&spec.api_identifier)
476 .copied()
477 .unwrap_or_default();
478 }
479 for (name, prov) in &mut config.providers {
480 prov.source = provider_sources.get(name).copied().unwrap_or_default();
481 }
482
483 let mut by_identifier = HashMap::new();
484 let mut by_provider: HashMap<String, Vec<ModelSpec>> = HashMap::new();
485 for model in &config.models {
486 by_identifier.insert(model.api_identifier.clone(), model.clone());
487 by_provider
488 .entry(model.provider.clone())
489 .or_default()
490 .push(model.clone());
491 }
492
493 Ok(Self {
494 config,
495 by_identifier,
496 by_provider,
497 })
498 }
499
500 #[must_use]
502 pub fn config(&self) -> &ModelConfiguration {
503 &self.config
504 }
505
506 #[must_use]
508 pub fn get_model_spec(&self, api_identifier: &str) -> Option<&ModelSpec> {
509 if let Some(spec) = self.by_identifier.get(api_identifier) {
511 return Some(spec);
512 }
513
514 self.find_model_by_normalized_id(api_identifier)
516 }
517
518 #[must_use]
520 pub fn get_max_output_tokens(&self, api_identifier: &str) -> usize {
521 if let Some(spec) = self.get_model_spec(api_identifier) {
522 return spec.max_output_tokens;
523 }
524
525 if let Some(provider) = self.infer_provider(api_identifier) {
527 if let Some(provider_config) = self.config.providers.get(&provider) {
528 return provider_config.defaults.max_output_tokens;
529 }
530 }
531
532 FALLBACK_MAX_OUTPUT_TOKENS
534 }
535
536 #[must_use]
538 pub fn get_input_context(&self, api_identifier: &str) -> usize {
539 if let Some(spec) = self.get_model_spec(api_identifier) {
540 return spec.input_context;
541 }
542
543 if let Some(provider) = self.infer_provider(api_identifier) {
545 if let Some(provider_config) = self.config.providers.get(&provider) {
546 return provider_config.defaults.input_context;
547 }
548 }
549
550 FALLBACK_INPUT_CONTEXT
552 }
553
554 #[must_use]
564 pub fn supports_structured_output(&self, api_identifier: &str) -> bool {
565 self.get_model_spec(api_identifier)
566 .is_some_and(|spec| spec.supports_structured_output)
567 }
568
569 fn infer_provider(&self, api_identifier: &str) -> Option<String> {
585 let id = api_identifier;
586 if id.starts_with("claude") || id.contains("anthropic") {
587 Some("claude".to_string())
588 } else if id.starts_with("gpt") || id.starts_with("chatgpt") || is_openai_reasoning_id(id) {
589 Some("openai".to_string())
590 } else if id.starts_with("gemini") {
591 Some("gemini".to_string())
592 } else {
593 None
594 }
595 }
596
597 fn find_model_by_normalized_id(&self, api_identifier: &str) -> Option<&ModelSpec> {
602 let core_identifier = self.extract_core_model_identifier(api_identifier);
603 self.by_identifier.get(&core_identifier)
604 }
605
606 fn extract_core_model_identifier(&self, api_identifier: &str) -> String {
608 let mut identifier = api_identifier.to_string();
609
610 if let Some(dot_pos) = identifier.find('.') {
612 if identifier[..dot_pos].len() <= 3 {
613 identifier = identifier[dot_pos + 1..].to_string();
615 }
616 }
617
618 if identifier.starts_with("anthropic.") {
620 identifier = identifier["anthropic.".len()..].to_string();
621 }
622
623 if let Some(version_pos) = identifier.rfind("-v") {
625 if identifier[version_pos..].contains(':') {
626 identifier = identifier[..version_pos].to_string();
627 }
628 }
629
630 identifier
631 }
632
633 #[must_use]
635 pub fn is_legacy_model(&self, api_identifier: &str) -> bool {
636 self.get_model_spec(api_identifier)
637 .is_some_and(|spec| spec.legacy)
638 }
639
640 #[must_use]
642 pub fn get_all_models(&self) -> &[ModelSpec] {
643 &self.config.models
644 }
645
646 #[must_use]
648 pub fn get_models_by_provider(&self, provider: &str) -> Vec<&ModelSpec> {
649 self.by_provider
650 .get(provider)
651 .map(|models| models.iter().collect())
652 .unwrap_or_default()
653 }
654
655 #[must_use]
661 pub fn is_known_model(&self, identifier: &str) -> bool {
662 self.get_model_spec(identifier).is_some()
663 }
664
665 #[must_use]
671 pub fn known_identifiers(&self, provider: &str) -> Vec<&str> {
672 let mut identifiers: Vec<&str> = self
673 .get_models_by_provider(provider)
674 .into_iter()
675 .filter(|model| !model.legacy)
676 .map(|model| model.api_identifier.as_str())
677 .collect();
678 identifiers.sort_unstable();
679 identifiers
680 }
681
682 #[must_use]
684 pub fn get_models_by_provider_and_tier(&self, provider: &str, tier: &str) -> Vec<&ModelSpec> {
685 self.get_models_by_provider(provider)
686 .into_iter()
687 .filter(|model| model.tier == tier)
688 .collect()
689 }
690
691 #[must_use]
693 pub fn get_default_model(&self, provider: &str) -> Option<&str> {
694 self.config
695 .providers
696 .get(provider)
697 .map(|p| p.default_model.as_str())
698 }
699
700 #[must_use]
702 pub fn get_provider_config(&self, provider: &str) -> Option<&ProviderConfig> {
703 self.config.providers.get(provider)
704 }
705
706 #[must_use]
708 pub fn get_tier_info(&self, provider: &str, tier: &str) -> Option<&TierInfo> {
709 self.config.providers.get(provider)?.tiers.get(tier)
710 }
711
712 #[must_use]
714 pub fn get_beta_headers(&self, api_identifier: &str) -> &[BetaHeader] {
715 self.get_model_spec(api_identifier)
716 .map(|spec| spec.beta_headers.as_slice())
717 .unwrap_or_default()
718 }
719
720 #[must_use]
722 pub fn get_max_output_tokens_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
723 if let Some(spec) = self.get_model_spec(api_identifier) {
724 if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
725 if let Some(max) = bh.max_output_tokens {
726 return max;
727 }
728 }
729 return spec.max_output_tokens;
730 }
731 self.get_max_output_tokens(api_identifier)
732 }
733
734 #[must_use]
736 pub fn get_input_context_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
737 if let Some(spec) = self.get_model_spec(api_identifier) {
738 if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
739 if let Some(ctx) = bh.input_context {
740 return ctx;
741 }
742 }
743 return spec.input_context;
744 }
745 self.get_input_context(api_identifier)
746 }
747
748 #[must_use]
760 pub fn get_model_family(&self, api_identifier: &str) -> ModelFamily {
761 let haystack = self
762 .get_model_spec(api_identifier)
763 .map_or(api_identifier, |spec| spec.model.as_str())
764 .to_ascii_lowercase();
765 if haystack.contains("fable") {
766 ModelFamily::Fable
767 } else if haystack.contains("opus") {
768 ModelFamily::Opus
769 } else if haystack.contains("sonnet") {
770 ModelFamily::Sonnet
771 } else if haystack.contains("haiku") {
772 ModelFamily::Haiku
773 } else {
774 ModelFamily::Unknown
775 }
776 }
777}
778
779fn is_openai_reasoning_id(id: &str) -> bool {
785 let mut chars = id.chars();
786 chars.next() == Some('o') && chars.next().is_some_and(|c| c.is_ascii_digit())
787}
788
789fn default_project_path() -> Option<PathBuf> {
791 std::env::current_dir()
792 .ok()
793 .map(|cwd| cwd.join(".omni-dev").join("models.yaml"))
794}
795
796fn default_user_path() -> Option<PathBuf> {
798 dirs::home_dir().map(|h| h.join(".omni-dev").join("models.yaml"))
799}
800
801fn read_optional_yaml(path: &Path) -> Option<String> {
804 if !path.exists() {
805 return None;
806 }
807 match std::fs::read_to_string(path) {
808 Ok(s) => Some(s),
809 Err(e) => {
810 tracing::error!(
811 "Failed to read {}: {e}. Falling through to lower-precedence layers.",
812 path.display()
813 );
814 None
815 }
816 }
817}
818
819fn merge_layer_into(
830 dest: &mut serde_yaml::Value,
831 src: serde_yaml::Value,
832 source: ModelSource,
833 model_sources: &mut HashMap<String, ModelSource>,
834 provider_sources: &mut HashMap<String, ModelSource>,
835) {
836 use serde_yaml::Value;
837
838 let Value::Mapping(src_map) = src else {
839 *dest = src;
843 return;
844 };
845
846 if !matches!(dest, Value::Mapping(_)) {
847 *dest = Value::Mapping(serde_yaml::Mapping::new());
848 }
849 let Value::Mapping(dest_map) = dest else {
850 unreachable!("dest is a mapping after the check above");
851 };
852
853 for (k, v) in src_map {
854 match k.as_str() {
855 Some("models") => merge_models_into(dest_map, k, v, source, model_sources),
856 Some("providers") => merge_providers_into(dest_map, k, v, source, provider_sources),
857 _ => {
858 dest_map.insert(k, v);
859 }
860 }
861 }
862}
863
864fn merge_models_into(
865 dest_map: &mut serde_yaml::Mapping,
866 key: serde_yaml::Value,
867 incoming: serde_yaml::Value,
868 source: ModelSource,
869 model_sources: &mut HashMap<String, ModelSource>,
870) {
871 use serde_yaml::Value;
872
873 let Value::Sequence(incoming_seq) = incoming else {
874 dest_map.insert(key, incoming);
876 return;
877 };
878
879 let dest_value = dest_map
880 .entry(key)
881 .or_insert_with(|| Value::Sequence(Vec::new()));
882 if !matches!(dest_value, Value::Sequence(_)) {
883 *dest_value = Value::Sequence(Vec::new());
884 }
885 let Value::Sequence(dest_seq) = dest_value else {
886 unreachable!("dest is a sequence after the check above");
887 };
888
889 for entry in incoming_seq {
890 let api_id = entry
891 .get("api_identifier")
892 .and_then(|v| v.as_str())
893 .map(String::from);
894
895 let Some(api_id) = api_id else {
896 tracing::warn!(
897 "Skipping model entry without `api_identifier` from {source} models.yaml"
898 );
899 continue;
900 };
901
902 if let Some(existing) = dest_seq
903 .iter_mut()
904 .find(|e| e.get("api_identifier").and_then(serde_yaml::Value::as_str) == Some(&api_id))
905 {
906 deep_merge(existing, entry);
907 } else {
908 dest_seq.push(entry);
909 }
910
911 model_sources.insert(api_id, source);
912 }
913}
914
915fn merge_providers_into(
916 dest_map: &mut serde_yaml::Mapping,
917 key: serde_yaml::Value,
918 incoming: serde_yaml::Value,
919 source: ModelSource,
920 provider_sources: &mut HashMap<String, ModelSource>,
921) {
922 use serde_yaml::Value;
923
924 let Value::Mapping(incoming_providers) = incoming else {
925 dest_map.insert(key, incoming);
926 return;
927 };
928
929 let dest_value = dest_map
930 .entry(key)
931 .or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
932 if !matches!(dest_value, Value::Mapping(_)) {
933 *dest_value = Value::Mapping(serde_yaml::Mapping::new());
934 }
935 let Value::Mapping(dest_providers) = dest_value else {
936 unreachable!("dest is a mapping after the check above");
937 };
938
939 for (pname, pvalue) in incoming_providers {
940 let pname_str = pname.as_str().map(String::from);
941
942 if let Some(existing) = dest_providers.get_mut(&pname) {
943 deep_merge(existing, pvalue);
944 } else {
945 dest_providers.insert(pname.clone(), pvalue);
946 }
947
948 if let Some(name) = pname_str {
949 provider_sources.insert(name, source);
950 }
951 }
952}
953
954fn deep_merge(dest: &mut serde_yaml::Value, src: serde_yaml::Value) {
957 use serde_yaml::Value;
958 match (dest, src) {
959 (Value::Mapping(d), Value::Mapping(s)) => {
960 for (k, v) in s {
961 if let Some(existing) = d.get_mut(&k) {
962 deep_merge(existing, v);
963 } else {
964 d.insert(k, v);
965 }
966 }
967 }
968 (d, s) => *d = s,
969 }
970}
971
972fn warn_on_version_mismatch(declared: &[(ModelSource, Option<String>)]) {
975 for (source, version) in declared {
976 if matches!(source, ModelSource::Embedded) {
977 continue;
978 }
979 match version {
980 None => {
981 tracing::warn!(
982 "{source} models.yaml has no `version:` field; assuming compatibility with schema version {MODELS_SCHEMA_VERSION}. Add `version: \"{MODELS_SCHEMA_VERSION}\"` to silence this warning."
983 );
984 }
985 Some(v) if v == MODELS_SCHEMA_VERSION => {}
986 Some(v) => {
987 tracing::warn!(
988 "{source} models.yaml declares schema version {v}; this build understands {MODELS_SCHEMA_VERSION}. Continuing β unrecognised fields may be ignored."
989 );
990 }
991 }
992 }
993}
994
995static MODEL_REGISTRY: OnceLock<ModelRegistry> = OnceLock::new();
997
998#[must_use]
1000pub fn get_model_registry() -> &'static ModelRegistry {
1001 #[allow(clippy::expect_used)] MODEL_REGISTRY.get_or_init(|| ModelRegistry::load().expect("Failed to load model registry"))
1003}
1004
1005#[cfg(test)]
1006#[allow(clippy::unwrap_used, clippy::expect_used)]
1007mod tests {
1008 use super::*;
1009 use std::io::Write;
1010
1011 fn embedded_only() -> ModelRegistry {
1012 ModelRegistry::load_layered_from_paths(None, None, None).unwrap()
1013 }
1014
1015 #[test]
1018 fn is_known_model_rejects_unknown_claude_identifier() {
1019 let registry = embedded_only();
1020 assert!(!registry.is_known_model("claude-sonnet-4-8"));
1021 assert!(registry.is_known_model("claude-sonnet-4-6"));
1022 }
1023
1024 #[test]
1025 fn is_known_model_normalizes_bedrock_identifiers() {
1026 let registry = embedded_only();
1027 let bedrock_id = registry
1030 .get_all_models()
1031 .iter()
1032 .find(|m| m.provider == "claude")
1033 .map(|m| format!("us.anthropic.{}", m.api_identifier))
1034 .expect("embedded catalog should list claude models");
1035 assert!(
1036 registry.is_known_model(&bedrock_id),
1037 "{bedrock_id} should normalise to a known model"
1038 );
1039 }
1040
1041 #[test]
1042 fn known_identifiers_lists_non_legacy_claude_models() {
1043 let registry = embedded_only();
1044 let identifiers = registry.known_identifiers("claude");
1045
1046 assert!(!identifiers.is_empty());
1047 assert!(identifiers.contains(&"claude-sonnet-4-6"));
1048 assert!(
1049 identifiers.windows(2).all(|w| w[0] <= w[1]),
1050 "identifiers should be sorted: {identifiers:?}"
1051 );
1052 for id in &identifiers {
1053 let spec = registry
1054 .get_model_spec(id)
1055 .expect("listed model must exist");
1056 assert!(!spec.legacy, "legacy model {id} should not be suggested");
1057 assert_eq!(spec.provider, "claude");
1058 }
1059 }
1060
1061 #[test]
1062 fn known_identifiers_is_empty_for_provider_without_models() {
1063 let registry = embedded_only();
1064 assert!(registry.known_identifiers("ollama").is_empty());
1065 }
1066
1067 #[test]
1070 fn legacy_models_are_known_but_not_suggested() {
1071 let registry = embedded_only();
1072 let legacy = registry
1073 .get_all_models()
1074 .iter()
1075 .find(|m| m.provider == "claude" && m.legacy)
1076 .map(|m| m.api_identifier.clone())
1077 .expect("embedded catalog should list a legacy claude model");
1078
1079 assert!(
1080 registry.is_known_model(&legacy),
1081 "{legacy} must still resolve"
1082 );
1083 assert!(
1084 !registry
1085 .known_identifiers("claude")
1086 .contains(&legacy.as_str()),
1087 "{legacy} should not be suggested"
1088 );
1089 }
1090
1091 fn write_yaml(dir: &Path, name: &str, contents: &str) -> PathBuf {
1092 let path = dir.join(name);
1093 let mut f = std::fs::File::create(&path).unwrap();
1094 f.write_all(contents.as_bytes()).unwrap();
1095 path
1096 }
1097
1098 #[test]
1099 fn load_model_registry() {
1100 let registry = embedded_only();
1101 assert!(!registry.config.models.is_empty());
1102 assert!(registry.config.providers.contains_key("claude"));
1103 assert_eq!(
1104 registry.config.version.as_deref(),
1105 Some(MODELS_SCHEMA_VERSION)
1106 );
1107 }
1108
1109 #[test]
1110 fn claude_model_lookup() {
1111 let registry = embedded_only();
1112
1113 let opus_spec = registry.get_model_spec("claude-3-opus-20240229");
1115 assert!(opus_spec.is_some());
1116 assert_eq!(opus_spec.unwrap().max_output_tokens, 4096);
1117 assert_eq!(opus_spec.unwrap().provider, "claude");
1118 assert!(registry.is_legacy_model("claude-3-opus-20240229"));
1119
1120 let sonnet45_tokens = registry.get_max_output_tokens("claude-sonnet-4-5-20250929");
1122 assert_eq!(sonnet45_tokens, 64000);
1123
1124 let sonnet4_tokens = registry.get_max_output_tokens("claude-sonnet-4-20250514");
1126 assert_eq!(sonnet4_tokens, 64000);
1127 assert!(registry.is_legacy_model("claude-sonnet-4-20250514"));
1128
1129 let unknown_tokens = registry.get_max_output_tokens("claude-unknown-model");
1131 assert_eq!(unknown_tokens, 4096); }
1133
1134 #[test]
1135 fn unknown_provider_uses_ultimate_fallback() {
1136 let registry = embedded_only();
1137
1138 assert_eq!(
1140 registry.get_max_output_tokens("totally-unknown-vendor-x"),
1141 FALLBACK_MAX_OUTPUT_TOKENS
1142 );
1143 assert_eq!(
1144 registry.get_input_context("totally-unknown-vendor-x"),
1145 FALLBACK_INPUT_CONTEXT
1146 );
1147 }
1148
1149 #[test]
1150 fn unknown_openai_model_uses_openai_provider_defaults() {
1151 let registry = embedded_only();
1152
1153 for id in ["gpt-6-ultra", "chatgpt-6-latest", "o5-preview"] {
1158 assert_eq!(
1159 registry.get_max_output_tokens(id),
1160 16384,
1161 "max_output_tokens for {id}"
1162 );
1163 assert_eq!(
1164 registry.get_input_context(id),
1165 128_000,
1166 "input_context for {id}"
1167 );
1168 }
1169 }
1170
1171 #[test]
1172 fn unknown_gemini_model_uses_gemini_provider_defaults() {
1173 let registry = embedded_only();
1174
1175 let id = "gemini-9-pro";
1178 assert_eq!(registry.get_max_output_tokens(id), 8192);
1179 assert_eq!(registry.get_input_context(id), 1_048_576);
1180 }
1181
1182 #[test]
1183 fn provider_filtering() {
1184 let registry = embedded_only();
1185
1186 let claude_models = registry.get_models_by_provider("claude");
1187 assert!(!claude_models.is_empty());
1188
1189 let fast_claude_models = registry.get_models_by_provider_and_tier("claude", "fast");
1190 assert!(!fast_claude_models.is_empty());
1191
1192 let tier_info = registry.get_tier_info("claude", "fast");
1193 assert!(tier_info.is_some());
1194 }
1195
1196 #[test]
1197 fn provider_config() {
1198 let registry = embedded_only();
1199
1200 let claude_config = registry.get_provider_config("claude");
1201 assert!(claude_config.is_some());
1202 assert_eq!(claude_config.unwrap().name, "Anthropic Claude");
1203 }
1204
1205 #[test]
1206 fn default_model_per_provider() {
1207 let registry = embedded_only();
1208
1209 assert_eq!(
1210 registry.get_default_model("claude"),
1211 Some("claude-sonnet-5")
1212 );
1213 assert_eq!(registry.get_default_model("openai"), Some("gpt-5-mini"));
1214 assert_eq!(
1215 registry.get_default_model("gemini"),
1216 Some("gemini-2.5-flash")
1217 );
1218 assert_eq!(registry.get_default_model("nonexistent"), None);
1219 }
1220
1221 #[test]
1222 fn normalized_id_matching() {
1223 let registry = embedded_only();
1224
1225 let bedrock_3_7_sonnet = "us.anthropic.claude-3-7-sonnet-20250219-v1:0";
1227 let spec = registry.get_model_spec(bedrock_3_7_sonnet);
1228 assert!(spec.is_some());
1229 assert_eq!(spec.unwrap().api_identifier, "claude-3-7-sonnet-20250219");
1230 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1231
1232 let aws_haiku = "anthropic.claude-3-haiku-20240307-v1:0";
1234 let spec = registry.get_model_spec(aws_haiku);
1235 assert!(spec.is_some());
1236 assert_eq!(spec.unwrap().api_identifier, "claude-3-haiku-20240307");
1237 assert_eq!(spec.unwrap().max_output_tokens, 4096);
1238
1239 let eu_opus = "eu.anthropic.claude-3-opus-20240229-v2:1";
1241 let spec = registry.get_model_spec(eu_opus);
1242 assert!(spec.is_some());
1243 assert_eq!(spec.unwrap().api_identifier, "claude-3-opus-20240229");
1244 assert_eq!(spec.unwrap().max_output_tokens, 4096);
1245
1246 let exact_sonnet45 = "claude-sonnet-4-5-20250929";
1248 let spec = registry.get_model_spec(exact_sonnet45);
1249 assert!(spec.is_some());
1250 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1251
1252 let exact_sonnet4 = "claude-sonnet-4-20250514";
1254 let spec = registry.get_model_spec(exact_sonnet4);
1255 assert!(spec.is_some());
1256 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1257 }
1258
1259 #[test]
1264 fn supports_structured_output_gates_by_model() {
1265 let registry = embedded_only();
1266
1267 assert!(registry.supports_structured_output("claude-fable-5"));
1269 assert!(registry.supports_structured_output("claude-opus-4-8"));
1270 assert!(registry.supports_structured_output("claude-opus-4-7"));
1271 assert!(registry.supports_structured_output("claude-sonnet-5"));
1272 assert!(registry.supports_structured_output("claude-sonnet-4-6"));
1273 assert!(registry.supports_structured_output("claude-opus-4-6"));
1274 assert!(registry.supports_structured_output("claude-haiku-4-5-20251001"));
1275 assert!(registry.supports_structured_output("claude-opus-4-5-20251101"));
1276 assert!(
1277 registry.supports_structured_output("us.anthropic.claude-sonnet-4-5-20250929-v1:0"),
1278 "Bedrock/region-prefixed forms must normalize to the flagged spec"
1279 );
1280
1281 assert!(!registry.supports_structured_output("claude-opus-4-1-20250805"));
1283 assert!(!registry.supports_structured_output("claude-3-opus-20240229"));
1284 assert!(!registry.supports_structured_output("claude-sonnet-4-20250514"));
1285
1286 assert!(!registry.supports_structured_output("totally-unknown-model"));
1288 }
1289
1290 #[test]
1294 fn current_generation_models_are_registered() {
1295 let registry = embedded_only();
1296
1297 for id in [
1298 "claude-fable-5",
1299 "claude-opus-4-8",
1300 "claude-opus-4-7",
1301 "claude-sonnet-5",
1302 ] {
1303 let spec = registry
1304 .get_model_spec(id)
1305 .unwrap_or_else(|| panic!("{id} must be registered"));
1306 assert_eq!(spec.input_context, 1_000_000, "{id} context window");
1307 assert_eq!(spec.max_output_tokens, 128_000, "{id} max output");
1308 assert!(
1309 spec.beta_headers.is_empty(),
1310 "{id} exposes 1M natively and must not rely on a context beta header"
1311 );
1312 assert!(spec.input_token_price.is_some(), "{id} input price");
1313 assert!(spec.output_token_price.is_some(), "{id} output price");
1314 assert!(!spec.legacy, "{id} is current, not legacy");
1315 }
1316
1317 let sonnet5 = registry.get_model_spec("claude-sonnet-5").unwrap();
1320 assert_eq!(sonnet5.input_token_price, Some(3.0));
1321 assert_eq!(sonnet5.output_token_price, Some(15.0));
1322 }
1323
1324 #[test]
1328 fn claude_4_6_limits_are_native_not_beta_gated() {
1329 let registry = embedded_only();
1330
1331 assert_eq!(registry.get_input_context("claude-opus-4-6"), 1_000_000);
1332 assert_eq!(registry.get_input_context("claude-sonnet-4-6"), 1_000_000);
1333 assert_eq!(registry.get_max_output_tokens("claude-sonnet-4-6"), 128_000);
1334 assert_eq!(registry.get_max_output_tokens("claude-opus-4-6"), 128_000);
1335 }
1336
1337 #[test]
1344 fn undated_aliases_resolve_to_real_limits() {
1345 let registry = embedded_only();
1346
1347 for (alias, dated) in [
1348 ("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
1349 ("claude-sonnet-4-5", "claude-sonnet-4-5-20250929"),
1350 ("claude-opus-4-5", "claude-opus-4-5-20251101"),
1351 ] {
1352 assert_eq!(
1353 registry.get_max_output_tokens(alias),
1354 registry.get_max_output_tokens(dated),
1355 "{alias} must resolve to the same output cap as {dated}, \
1356 not the provider fallback"
1357 );
1358 assert_eq!(
1359 registry.get_input_context(alias),
1360 registry.get_input_context(dated),
1361 "{alias} context window must match {dated}"
1362 );
1363 }
1364
1365 assert_eq!(registry.get_max_output_tokens("claude-haiku-4-5"), 64_000);
1367 }
1368
1369 #[test]
1373 fn retires_records_announced_dates_only() {
1374 let registry = embedded_only();
1375
1376 let opus_41 = registry.get_model_spec("claude-opus-4-1-20250805").unwrap();
1377 assert_eq!(opus_41.retires.as_deref(), Some("2026-08-05"));
1378 assert!(opus_41.legacy);
1379
1380 assert_eq!(
1383 registry
1384 .get_model_spec("claude-3-opus-20240229")
1385 .unwrap()
1386 .retires
1387 .as_deref(),
1388 Some("2026-01-05")
1389 );
1390
1391 for id in ["claude-opus-4-20250514", "claude-sonnet-4-20250514"] {
1393 let spec = registry.get_model_spec(id).unwrap();
1394 assert!(spec.legacy, "{id} is deprecated");
1395 assert!(
1396 spec.retires.is_none(),
1397 "{id} has no announced retirement date; absence must not be \
1398 filled in with a guess"
1399 );
1400 }
1401
1402 assert!(registry
1404 .get_model_spec("claude-sonnet-5")
1405 .unwrap()
1406 .retires
1407 .is_none());
1408 }
1409
1410 #[test]
1411 fn extract_core_model_identifier() {
1412 let registry = embedded_only();
1413
1414 assert_eq!(
1416 registry.extract_core_model_identifier("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
1417 "claude-3-7-sonnet-20250219"
1418 );
1419
1420 assert_eq!(
1421 registry.extract_core_model_identifier("anthropic.claude-3-haiku-20240307-v1:0"),
1422 "claude-3-haiku-20240307"
1423 );
1424
1425 assert_eq!(
1426 registry.extract_core_model_identifier("claude-3-opus-20240229"),
1427 "claude-3-opus-20240229"
1428 );
1429
1430 assert_eq!(
1431 registry.extract_core_model_identifier("eu.anthropic.claude-sonnet-4-20250514-v2:1"),
1432 "claude-sonnet-4-20250514"
1433 );
1434 }
1435
1436 #[test]
1437 fn beta_header_lookups() {
1438 let registry = embedded_only();
1439
1440 assert_eq!(registry.get_max_output_tokens("claude-opus-4-6"), 128_000);
1443 assert_eq!(registry.get_input_context("claude-opus-4-6"), 1_000_000);
1444
1445 assert_eq!(
1450 registry.get_input_context_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1451 1_000_000
1452 );
1453 assert_eq!(
1455 registry.get_max_output_tokens_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1456 128_000
1457 );
1458
1459 assert_eq!(
1461 registry.get_max_output_tokens_with_beta(
1462 "claude-3-7-sonnet-20250219",
1463 "output-128k-2025-02-19"
1464 ),
1465 128_000
1466 );
1467
1468 assert_eq!(
1470 registry.get_max_output_tokens("claude-3-7-sonnet-20250219"),
1471 64000
1472 );
1473
1474 let headers = registry.get_beta_headers("claude-opus-4-6");
1476 assert_eq!(headers.len(), 1);
1477 assert_eq!(headers[0].key, "anthropic-beta");
1478 assert_eq!(headers[0].value, "context-1m-2025-08-07");
1479
1480 let headers = registry.get_beta_headers("claude-3-7-sonnet-20250219");
1482 assert_eq!(headers.len(), 2);
1483
1484 let headers = registry.get_beta_headers("claude-3-haiku-20240307");
1486 assert!(headers.is_empty());
1487
1488 let headers = registry.get_beta_headers("unknown-model");
1490 assert!(headers.is_empty());
1491 }
1492
1493 #[test]
1494 fn beta_lookups_for_unknown_model_fall_through_to_provider_defaults() {
1495 let registry = embedded_only();
1496
1497 assert_eq!(
1501 registry
1502 .get_max_output_tokens_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1503 4096
1504 );
1505 assert_eq!(
1506 registry.get_input_context_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1507 200_000
1508 );
1509 }
1510
1511 #[test]
1512 fn embedded_models_default_to_embedded_source() {
1513 let registry = embedded_only();
1514 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1515 assert_eq!(spec.source, ModelSource::Embedded);
1516
1517 let provider = registry.get_provider_config("claude").unwrap();
1518 assert_eq!(provider.source, ModelSource::Embedded);
1519 }
1520
1521 #[test]
1522 fn missing_user_and_project_files_fall_through_silently() {
1523 let dir = tempfile::tempdir().unwrap();
1524 let project_path = dir.path().join("missing-project.yaml");
1525 let user_path = dir.path().join("missing-user.yaml");
1526 let registry =
1527 ModelRegistry::load_layered_from_paths(Some(&project_path), Some(&user_path), None)
1528 .unwrap();
1529
1530 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1532 assert_eq!(spec.source, ModelSource::Embedded);
1533 assert_eq!(spec.max_output_tokens, 128_000);
1534 }
1535
1536 #[test]
1537 fn user_layer_overrides_embedded_entry() {
1538 let dir = tempfile::tempdir().unwrap();
1539 let user = write_yaml(
1540 dir.path(),
1541 "user.yaml",
1542 r#"
1543version: "1"
1544models:
1545 - provider: "claude"
1546 model: "Claude Opus 4.6 (custom)"
1547 api_identifier: "claude-opus-4-6"
1548 max_output_tokens: 999999
1549 input_context: 200000
1550 generation: 4.6
1551 tier: "flagship"
1552"#,
1553 );
1554
1555 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1556 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1557 assert_eq!(spec.max_output_tokens, 999_999);
1558 assert_eq!(spec.model, "Claude Opus 4.6 (custom)");
1559 assert_eq!(spec.source, ModelSource::User);
1560 }
1561
1562 #[test]
1563 fn project_layer_takes_precedence_over_user_layer() {
1564 let dir = tempfile::tempdir().unwrap();
1565 let user = write_yaml(
1566 dir.path(),
1567 "user.yaml",
1568 r#"
1569version: "1"
1570models:
1571 - provider: "claude"
1572 model: "From User"
1573 api_identifier: "claude-opus-4-6"
1574 max_output_tokens: 1
1575 input_context: 1
1576 generation: 4.6
1577 tier: "flagship"
1578"#,
1579 );
1580 let project = write_yaml(
1581 dir.path(),
1582 "project.yaml",
1583 r#"
1584version: "1"
1585models:
1586 - provider: "claude"
1587 model: "From Project"
1588 api_identifier: "claude-opus-4-6"
1589 max_output_tokens: 2
1590 input_context: 2
1591 generation: 4.6
1592 tier: "flagship"
1593"#,
1594 );
1595
1596 let registry =
1597 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1598 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1599 assert_eq!(spec.model, "From Project");
1600 assert_eq!(spec.max_output_tokens, 2);
1601 assert_eq!(spec.source, ModelSource::Project);
1602 }
1603
1604 #[test]
1605 fn additive_user_entry_is_appended() {
1606 let dir = tempfile::tempdir().unwrap();
1607 let user = write_yaml(
1608 dir.path(),
1609 "user.yaml",
1610 r#"
1611version: "1"
1612models:
1613 - provider: "claude"
1614 model: "Claude Custom Future"
1615 api_identifier: "claude-future-9000"
1616 max_output_tokens: 250000
1617 input_context: 5000000
1618 generation: 9.0
1619 tier: "flagship"
1620"#,
1621 );
1622
1623 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1624 let spec = registry.get_model_spec("claude-future-9000").unwrap();
1625 assert_eq!(spec.max_output_tokens, 250_000);
1626 assert_eq!(spec.input_context, 5_000_000);
1627 assert_eq!(spec.source, ModelSource::User);
1628
1629 let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1631 assert_eq!(opus.source, ModelSource::Embedded);
1632 }
1633
1634 #[test]
1635 fn provider_fields_can_be_partially_overridden() {
1636 let dir = tempfile::tempdir().unwrap();
1637 let user = write_yaml(
1640 dir.path(),
1641 "user.yaml",
1642 r#"
1643version: "1"
1644providers:
1645 claude:
1646 default_model: "claude-opus-4-6"
1647"#,
1648 );
1649
1650 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1651 let claude = registry.get_provider_config("claude").unwrap();
1652 assert_eq!(claude.default_model, "claude-opus-4-6");
1653 assert_eq!(claude.name, "Anthropic Claude");
1655 assert_eq!(claude.api_base, "https://api.anthropic.com/v1");
1656 assert!(claude.tiers.contains_key("flagship"));
1657 assert_eq!(claude.source, ModelSource::User);
1659 }
1660
1661 #[test]
1662 fn malformed_user_yaml_logs_and_falls_through() {
1663 let dir = tempfile::tempdir().unwrap();
1664 let user = write_yaml(
1665 dir.path(),
1666 "user.yaml",
1667 "this: is: definitely: not: valid: yaml: [unbalanced",
1668 );
1669
1670 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1671 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1673 assert_eq!(spec.source, ModelSource::Embedded);
1674 assert_eq!(spec.max_output_tokens, 128_000);
1675 }
1676
1677 #[test]
1678 fn override_path_short_circuits_user_and_project() {
1679 let dir = tempfile::tempdir().unwrap();
1680 let user = write_yaml(
1681 dir.path(),
1682 "user.yaml",
1683 r#"
1684version: "1"
1685models:
1686 - provider: "claude"
1687 model: "From User"
1688 api_identifier: "claude-opus-4-6"
1689 max_output_tokens: 1
1690 input_context: 1
1691 generation: 4.6
1692 tier: "flagship"
1693"#,
1694 );
1695 let project = write_yaml(
1696 dir.path(),
1697 "project.yaml",
1698 r#"
1699version: "1"
1700models:
1701 - provider: "claude"
1702 model: "From Project"
1703 api_identifier: "claude-opus-4-6"
1704 max_output_tokens: 2
1705 input_context: 2
1706 generation: 4.6
1707 tier: "flagship"
1708"#,
1709 );
1710 let override_file = write_yaml(
1711 dir.path(),
1712 "override.yaml",
1713 r#"
1714version: "1"
1715models:
1716 - provider: "claude"
1717 model: "From Override"
1718 api_identifier: "claude-opus-4-6"
1719 max_output_tokens: 3
1720 input_context: 3
1721 generation: 4.6
1722 tier: "flagship"
1723"#,
1724 );
1725
1726 let registry = ModelRegistry::load_layered_from_paths(
1727 Some(&project),
1728 Some(&user),
1729 Some(&override_file),
1730 )
1731 .unwrap();
1732 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1733 assert_eq!(spec.model, "From Override");
1734 assert_eq!(spec.max_output_tokens, 3);
1735 assert_eq!(spec.source, ModelSource::Override);
1736 }
1737
1738 #[test]
1739 fn missing_override_path_falls_back_to_embedded() {
1740 let dir = tempfile::tempdir().unwrap();
1741 let missing = dir.path().join("does-not-exist.yaml");
1742 let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&missing)).unwrap();
1743 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1744 assert_eq!(spec.source, ModelSource::Embedded);
1745 }
1746
1747 #[test]
1748 fn version_mismatch_is_warned_not_fatal() {
1749 let dir = tempfile::tempdir().unwrap();
1750 let user = write_yaml(
1751 dir.path(),
1752 "user.yaml",
1753 r#"
1754version: "9999"
1755models:
1756 - provider: "claude"
1757 model: "From Future"
1758 api_identifier: "claude-future-9000"
1759 max_output_tokens: 1
1760 input_context: 1
1761 generation: 9.0
1762 tier: "flagship"
1763"#,
1764 );
1765 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1766 assert!(registry.get_model_spec("claude-future-9000").is_some());
1768 }
1769
1770 #[test]
1771 fn missing_version_is_accepted() {
1772 let dir = tempfile::tempdir().unwrap();
1773 let user = write_yaml(
1774 dir.path(),
1775 "user.yaml",
1776 r#"
1777models:
1778 - provider: "claude"
1779 model: "Versionless"
1780 api_identifier: "claude-versionless"
1781 max_output_tokens: 1
1782 input_context: 1
1783 generation: 1.0
1784 tier: "flagship"
1785"#,
1786 );
1787 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1788 assert!(registry.get_model_spec("claude-versionless").is_some());
1789 }
1790
1791 #[test]
1792 fn model_entry_without_api_identifier_is_skipped() {
1793 let dir = tempfile::tempdir().unwrap();
1794 let user = write_yaml(
1795 dir.path(),
1796 "user.yaml",
1797 r#"
1798version: "1"
1799models:
1800 - provider: "claude"
1801 model: "No Id"
1802 max_output_tokens: 1
1803 input_context: 1
1804 generation: 1.0
1805 tier: "flagship"
1806"#,
1807 );
1808 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1809 let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1811 assert_eq!(opus.source, ModelSource::Embedded);
1812 }
1813
1814 #[test]
1815 fn model_source_display() {
1816 assert_eq!(ModelSource::Embedded.to_string(), "embedded");
1817 assert_eq!(ModelSource::User.to_string(), "user");
1818 assert_eq!(ModelSource::Project.to_string(), "project");
1819 assert_eq!(ModelSource::Override.to_string(), "override");
1820 }
1821
1822 #[test]
1823 fn embedded_yaml_must_not_be_malformed() {
1824 let layers = [(ModelSource::Embedded, "::: not yaml :::".to_string())];
1826 let result = ModelRegistry::from_layers(&layers);
1827 assert!(result.is_err());
1828 }
1829
1830 #[test]
1831 fn user_layer_with_scalar_top_level_returns_error() {
1832 let dir = tempfile::tempdir().unwrap();
1836 let user = write_yaml(dir.path(), "user.yaml", "\"just a string\"\n");
1837 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1838 assert!(result.is_err());
1839 }
1840
1841 #[test]
1842 fn user_layer_with_non_sequence_models_returns_error() {
1843 let dir = tempfile::tempdir().unwrap();
1847 let user = write_yaml(
1848 dir.path(),
1849 "user.yaml",
1850 r#"
1851version: "1"
1852models: 42
1853"#,
1854 );
1855 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1856 assert!(result.is_err());
1857 }
1858
1859 #[test]
1860 fn user_layer_with_non_mapping_providers_returns_error() {
1861 let dir = tempfile::tempdir().unwrap();
1864 let user = write_yaml(
1865 dir.path(),
1866 "user.yaml",
1867 r#"
1868version: "1"
1869providers: 42
1870"#,
1871 );
1872 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1873 assert!(result.is_err());
1874 }
1875
1876 #[test]
1877 fn deep_merge_inserts_new_keys_into_existing_mapping() {
1878 let dir = tempfile::tempdir().unwrap();
1882 let user = write_yaml(
1883 dir.path(),
1884 "user.yaml",
1885 r#"
1886version: "1"
1887providers:
1888 claude:
1889 tiers:
1890 experimental:
1891 description: "Experimental tier"
1892 use_cases: ["bleeding edge"]
1893"#,
1894 );
1895 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1896 let claude = registry.get_provider_config("claude").unwrap();
1897 assert!(claude.tiers.contains_key("flagship"));
1899 assert!(claude.tiers.contains_key("balanced"));
1900 assert!(claude.tiers.contains_key("fast"));
1901 let experimental = claude.tiers.get("experimental").unwrap();
1903 assert_eq!(experimental.description, "Experimental tier");
1904 assert_eq!(experimental.use_cases, vec!["bleeding edge".to_string()]);
1905 }
1906
1907 #[test]
1908 #[cfg(unix)]
1909 fn user_path_pointing_at_a_directory_logs_and_falls_through() {
1910 let dir = tempfile::tempdir().unwrap();
1913 let bogus = dir.path().join("models.yaml");
1914 std::fs::create_dir(&bogus).unwrap();
1915 let registry = ModelRegistry::load_layered_from_paths(None, Some(&bogus), None).unwrap();
1916 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1917 assert_eq!(spec.source, ModelSource::Embedded);
1918 }
1919
1920 #[test]
1921 #[cfg(unix)]
1922 fn override_path_pointing_at_a_directory_warns_and_falls_through() {
1923 let dir = tempfile::tempdir().unwrap();
1924 let bogus = dir.path().join("override.yaml");
1925 std::fs::create_dir(&bogus).unwrap();
1926 let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&bogus)).unwrap();
1927 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1928 assert_eq!(spec.source, ModelSource::Embedded);
1929 }
1930
1931 #[test]
1932 fn project_layer_recovers_after_user_replaces_top_level_with_scalar() {
1933 let dir = tempfile::tempdir().unwrap();
1939 let user = write_yaml(dir.path(), "user.yaml", "\"junk\"\n");
1940 let project = write_yaml(
1941 dir.path(),
1942 "project.yaml",
1943 r#"
1944version: "1"
1945models:
1946 - provider: "claude"
1947 model: "Project Rescue"
1948 api_identifier: "claude-rescue"
1949 max_output_tokens: 1
1950 input_context: 1
1951 generation: 1.0
1952 tier: "flagship"
1953providers:
1954 custom-provider:
1955 name: "Custom"
1956 api_base: "https://example.invalid"
1957 default_model: "custom-default"
1958 tiers: {}
1959 defaults:
1960 max_output_tokens: 100
1961 input_context: 1000
1962"#,
1963 );
1964 let registry =
1965 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1966 let spec = registry.get_model_spec("claude-rescue").unwrap();
1968 assert_eq!(spec.source, ModelSource::Project);
1969 }
1970
1971 #[test]
1972 fn project_layer_recovers_after_user_replaces_models_with_scalar() {
1973 let dir = tempfile::tempdir().unwrap();
1977 let user = write_yaml(
1978 dir.path(),
1979 "user.yaml",
1980 r#"
1981version: "1"
1982models: 42
1983"#,
1984 );
1985 let project = write_yaml(
1986 dir.path(),
1987 "project.yaml",
1988 r#"
1989version: "1"
1990models:
1991 - provider: "claude"
1992 model: "Project Rescue"
1993 api_identifier: "claude-rescue"
1994 max_output_tokens: 1
1995 input_context: 1
1996 generation: 1.0
1997 tier: "flagship"
1998"#,
1999 );
2000 let registry =
2001 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
2002 let spec = registry.get_model_spec("claude-rescue").unwrap();
2003 assert_eq!(spec.source, ModelSource::Project);
2004 }
2005
2006 #[test]
2007 fn project_layer_recovers_after_user_replaces_providers_with_scalar() {
2008 let dir = tempfile::tempdir().unwrap();
2011 let user = write_yaml(
2012 dir.path(),
2013 "user.yaml",
2014 r#"
2015version: "1"
2016providers: 42
2017"#,
2018 );
2019 let project = write_yaml(
2020 dir.path(),
2021 "project.yaml",
2022 r#"
2023version: "1"
2024providers:
2025 custom-provider:
2026 name: "Custom"
2027 api_base: "https://example.invalid"
2028 default_model: "custom-default"
2029 tiers: {}
2030 defaults:
2031 max_output_tokens: 100
2032 input_context: 1000
2033"#,
2034 );
2035 let registry =
2036 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
2037 let provider = registry.get_provider_config("custom-provider").unwrap();
2038 assert_eq!(provider.name, "Custom");
2039 assert_eq!(provider.source, ModelSource::Project);
2040 }
2041
2042 #[test]
2043 fn empty_omni_dev_models_yaml_env_var_is_ignored() {
2044 let resolved: Option<PathBuf> = Some(String::new())
2048 .filter(|s| !s.is_empty())
2049 .map(PathBuf::from);
2050 assert!(resolved.is_none());
2051 let resolved: Option<PathBuf> = Some("/some/path".to_string())
2052 .filter(|s| !s.is_empty())
2053 .map(PathBuf::from);
2054 assert_eq!(resolved.as_deref(), Some(Path::new("/some/path")));
2055 }
2056
2057 #[test]
2058 fn model_family_classifies_registered_claude_identifiers() {
2059 let registry = embedded_only();
2060 assert_eq!(
2061 registry.get_model_family("claude-fable-5"),
2062 ModelFamily::Fable
2063 );
2064 assert_eq!(
2065 registry.get_model_family("claude-opus-4-8"),
2066 ModelFamily::Opus
2067 );
2068 assert_eq!(
2069 registry.get_model_family("claude-sonnet-5"),
2070 ModelFamily::Sonnet
2071 );
2072 assert_eq!(
2073 registry.get_model_family("claude-haiku-4-5-20251001"),
2074 ModelFamily::Haiku
2075 );
2076 }
2077
2078 #[test]
2079 fn model_family_normalizes_bedrock_identifiers() {
2080 let registry = embedded_only();
2081 assert_eq!(
2082 registry.get_model_family("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
2083 ModelFamily::Sonnet
2084 );
2085 }
2086
2087 #[test]
2088 fn model_family_falls_back_to_the_raw_identifier_when_unregistered() {
2089 let registry = embedded_only();
2090 assert!(registry.get_model_spec("claude-opus-5").is_none());
2094 assert_eq!(
2095 registry.get_model_family("claude-opus-5"),
2096 ModelFamily::Opus
2097 );
2098 }
2099
2100 #[test]
2101 fn model_family_is_unknown_for_unrecognized_identifiers() {
2102 let registry = embedded_only();
2103 assert_eq!(registry.get_model_family("gpt-5.2"), ModelFamily::Unknown);
2104 assert_eq!(
2105 registry.get_model_family("some-future-model"),
2106 ModelFamily::Unknown
2107 );
2108 }
2109
2110 #[test]
2111 fn model_family_glyph_and_label_cover_every_variant() {
2112 assert_eq!(ModelFamily::Fable.glyph(), "π ");
2113 assert_eq!(ModelFamily::Opus.glyph(), "π‘");
2114 assert_eq!(ModelFamily::Sonnet.glyph(), "π’");
2115 assert_eq!(ModelFamily::Haiku.glyph(), "π΅");
2116 assert_eq!(ModelFamily::Unknown.glyph(), "βͺ");
2117 assert_eq!(ModelFamily::Fable.label(), "Fable");
2118 assert_eq!(ModelFamily::Opus.label(), "Opus");
2119 assert_eq!(ModelFamily::Sonnet.label(), "Sonnet");
2120 assert_eq!(ModelFamily::Haiku.label(), "Haiku");
2121 assert_eq!(ModelFamily::Unknown.label(), "Claude");
2122 }
2123}