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")]
165 pub input_token_price: Option<f64>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub output_token_price: Option<f64>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub beta_headers: Vec<BetaHeader>,
173 #[serde(default, skip_deserializing)]
176 pub source: ModelSource,
177}
178
179#[derive(Debug, Deserialize, Serialize, Clone)]
189pub struct TierInfo {
190 pub description: String,
192 pub use_cases: Vec<String>,
194}
195
196#[derive(Debug, Deserialize, Serialize, Clone)]
207pub struct DefaultConfig {
208 pub max_output_tokens: usize,
210 pub input_context: usize,
212}
213
214#[derive(Debug, Deserialize, Serialize, Clone)]
226pub struct ProviderConfig {
227 pub name: String,
229 pub api_base: String,
231 pub default_model: String,
233 pub tiers: HashMap<String, TierInfo>,
235 pub defaults: DefaultConfig,
237 #[serde(default, skip_deserializing)]
239 pub source: ModelSource,
240}
241
242#[derive(Debug, Deserialize, Serialize, Clone)]
255pub struct ModelConfiguration {
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub version: Option<String>,
259 pub models: Vec<ModelSpec>,
261 pub providers: HashMap<String, ProviderConfig>,
263}
264
265pub struct ModelRegistry {
275 config: ModelConfiguration,
276 by_identifier: HashMap<String, ModelSpec>,
277 by_provider: HashMap<String, Vec<ModelSpec>>,
278}
279
280impl ModelRegistry {
281 pub fn load() -> Result<Self> {
294 let override_path = std::env::var(OMNI_DEV_MODELS_YAML_ENV)
295 .ok()
296 .filter(|s| !s.is_empty())
297 .map(PathBuf::from);
298 let project_path = default_project_path();
299 let user_path = default_user_path();
300 Self::load_layered_from_paths(
301 project_path.as_deref(),
302 user_path.as_deref(),
303 override_path.as_deref(),
304 )
305 }
306
307 pub fn load_layered_from_paths(
311 project_path: Option<&Path>,
312 user_path: Option<&Path>,
313 override_path: Option<&Path>,
314 ) -> Result<Self> {
315 let mut layers: Vec<(ModelSource, String)> = Vec::new();
316 layers.push((ModelSource::Embedded, MODELS_YAML.to_string()));
317
318 if let Some(path) = override_path {
319 match read_optional_yaml(path) {
320 Some(yaml) => layers.push((ModelSource::Override, yaml)),
321 None => {
322 tracing::warn!(
323 "{OMNI_DEV_MODELS_YAML_ENV} points at {} but the file is missing or unreadable; falling back to embedded catalog",
324 path.display()
325 );
326 }
327 }
328 } else {
329 if let Some(path) = user_path {
330 if let Some(yaml) = read_optional_yaml(path) {
331 layers.push((ModelSource::User, yaml));
332 }
333 }
334 if let Some(path) = project_path {
335 if let Some(yaml) = read_optional_yaml(path) {
336 layers.push((ModelSource::Project, yaml));
337 }
338 }
339 }
340
341 Self::from_layers(&layers)
342 }
343
344 pub(crate) fn from_layers(layers: &[(ModelSource, String)]) -> Result<Self> {
350 let mut merged: serde_yaml::Value =
351 serde_yaml::Value::Mapping(serde_yaml::Mapping::default());
352 let mut model_sources: HashMap<String, ModelSource> = HashMap::new();
353 let mut provider_sources: HashMap<String, ModelSource> = HashMap::new();
354 let mut declared_versions: Vec<(ModelSource, Option<String>)> = Vec::new();
355
356 for (source, yaml) in layers {
357 let value: serde_yaml::Value = match serde_yaml::from_str(yaml) {
358 Ok(v) => v,
359 Err(e) => {
360 if matches!(source, ModelSource::Embedded) {
361 return Err(anyhow!(
362 "Embedded models.yaml is malformed at compile time: {e}"
363 ));
364 }
365 tracing::error!(
366 "Malformed {source} models.yaml: {e}. Falling through to lower-precedence layers."
367 );
368 continue;
369 }
370 };
371
372 let version = value
374 .get("version")
375 .and_then(|v| v.as_str())
376 .map(String::from);
377 declared_versions.push((*source, version));
378
379 merge_layer_into(
380 &mut merged,
381 value,
382 *source,
383 &mut model_sources,
384 &mut provider_sources,
385 );
386 }
387
388 warn_on_version_mismatch(&declared_versions);
389
390 let mut config: ModelConfiguration = serde_yaml::from_value(merged)
391 .map_err(|e| anyhow!("Failed to deserialize merged model configuration: {e}"))?;
392
393 for spec in &mut config.models {
394 spec.source = model_sources
395 .get(&spec.api_identifier)
396 .copied()
397 .unwrap_or_default();
398 }
399 for (name, prov) in &mut config.providers {
400 prov.source = provider_sources.get(name).copied().unwrap_or_default();
401 }
402
403 let mut by_identifier = HashMap::new();
404 let mut by_provider: HashMap<String, Vec<ModelSpec>> = HashMap::new();
405 for model in &config.models {
406 by_identifier.insert(model.api_identifier.clone(), model.clone());
407 by_provider
408 .entry(model.provider.clone())
409 .or_default()
410 .push(model.clone());
411 }
412
413 Ok(Self {
414 config,
415 by_identifier,
416 by_provider,
417 })
418 }
419
420 #[must_use]
422 pub fn config(&self) -> &ModelConfiguration {
423 &self.config
424 }
425
426 #[must_use]
428 pub fn get_model_spec(&self, api_identifier: &str) -> Option<&ModelSpec> {
429 if let Some(spec) = self.by_identifier.get(api_identifier) {
431 return Some(spec);
432 }
433
434 self.find_model_by_normalized_id(api_identifier)
436 }
437
438 #[must_use]
440 pub fn get_max_output_tokens(&self, api_identifier: &str) -> usize {
441 if let Some(spec) = self.get_model_spec(api_identifier) {
442 return spec.max_output_tokens;
443 }
444
445 if let Some(provider) = self.infer_provider(api_identifier) {
447 if let Some(provider_config) = self.config.providers.get(&provider) {
448 return provider_config.defaults.max_output_tokens;
449 }
450 }
451
452 FALLBACK_MAX_OUTPUT_TOKENS
454 }
455
456 #[must_use]
458 pub fn get_input_context(&self, api_identifier: &str) -> usize {
459 if let Some(spec) = self.get_model_spec(api_identifier) {
460 return spec.input_context;
461 }
462
463 if let Some(provider) = self.infer_provider(api_identifier) {
465 if let Some(provider_config) = self.config.providers.get(&provider) {
466 return provider_config.defaults.input_context;
467 }
468 }
469
470 FALLBACK_INPUT_CONTEXT
472 }
473
474 fn infer_provider(&self, api_identifier: &str) -> Option<String> {
490 let id = api_identifier;
491 if id.starts_with("claude") || id.contains("anthropic") {
492 Some("claude".to_string())
493 } else if id.starts_with("gpt") || id.starts_with("chatgpt") || is_openai_reasoning_id(id) {
494 Some("openai".to_string())
495 } else if id.starts_with("gemini") {
496 Some("gemini".to_string())
497 } else {
498 None
499 }
500 }
501
502 fn find_model_by_normalized_id(&self, api_identifier: &str) -> Option<&ModelSpec> {
507 let core_identifier = self.extract_core_model_identifier(api_identifier);
508 self.by_identifier.get(&core_identifier)
509 }
510
511 fn extract_core_model_identifier(&self, api_identifier: &str) -> String {
513 let mut identifier = api_identifier.to_string();
514
515 if let Some(dot_pos) = identifier.find('.') {
517 if identifier[..dot_pos].len() <= 3 {
518 identifier = identifier[dot_pos + 1..].to_string();
520 }
521 }
522
523 if identifier.starts_with("anthropic.") {
525 identifier = identifier["anthropic.".len()..].to_string();
526 }
527
528 if let Some(version_pos) = identifier.rfind("-v") {
530 if identifier[version_pos..].contains(':') {
531 identifier = identifier[..version_pos].to_string();
532 }
533 }
534
535 identifier
536 }
537
538 #[must_use]
540 pub fn is_legacy_model(&self, api_identifier: &str) -> bool {
541 self.get_model_spec(api_identifier)
542 .is_some_and(|spec| spec.legacy)
543 }
544
545 #[must_use]
547 pub fn get_all_models(&self) -> &[ModelSpec] {
548 &self.config.models
549 }
550
551 #[must_use]
553 pub fn get_models_by_provider(&self, provider: &str) -> Vec<&ModelSpec> {
554 self.by_provider
555 .get(provider)
556 .map(|models| models.iter().collect())
557 .unwrap_or_default()
558 }
559
560 #[must_use]
562 pub fn get_models_by_provider_and_tier(&self, provider: &str, tier: &str) -> Vec<&ModelSpec> {
563 self.get_models_by_provider(provider)
564 .into_iter()
565 .filter(|model| model.tier == tier)
566 .collect()
567 }
568
569 #[must_use]
571 pub fn get_default_model(&self, provider: &str) -> Option<&str> {
572 self.config
573 .providers
574 .get(provider)
575 .map(|p| p.default_model.as_str())
576 }
577
578 #[must_use]
580 pub fn get_provider_config(&self, provider: &str) -> Option<&ProviderConfig> {
581 self.config.providers.get(provider)
582 }
583
584 #[must_use]
586 pub fn get_tier_info(&self, provider: &str, tier: &str) -> Option<&TierInfo> {
587 self.config.providers.get(provider)?.tiers.get(tier)
588 }
589
590 #[must_use]
592 pub fn get_beta_headers(&self, api_identifier: &str) -> &[BetaHeader] {
593 self.get_model_spec(api_identifier)
594 .map(|spec| spec.beta_headers.as_slice())
595 .unwrap_or_default()
596 }
597
598 #[must_use]
600 pub fn get_max_output_tokens_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
601 if let Some(spec) = self.get_model_spec(api_identifier) {
602 if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
603 if let Some(max) = bh.max_output_tokens {
604 return max;
605 }
606 }
607 return spec.max_output_tokens;
608 }
609 self.get_max_output_tokens(api_identifier)
610 }
611
612 #[must_use]
614 pub fn get_input_context_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
615 if let Some(spec) = self.get_model_spec(api_identifier) {
616 if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
617 if let Some(ctx) = bh.input_context {
618 return ctx;
619 }
620 }
621 return spec.input_context;
622 }
623 self.get_input_context(api_identifier)
624 }
625}
626
627fn is_openai_reasoning_id(id: &str) -> bool {
633 let mut chars = id.chars();
634 chars.next() == Some('o') && chars.next().is_some_and(|c| c.is_ascii_digit())
635}
636
637fn default_project_path() -> Option<PathBuf> {
639 std::env::current_dir()
640 .ok()
641 .map(|cwd| cwd.join(".omni-dev").join("models.yaml"))
642}
643
644fn default_user_path() -> Option<PathBuf> {
646 dirs::home_dir().map(|h| h.join(".omni-dev").join("models.yaml"))
647}
648
649fn read_optional_yaml(path: &Path) -> Option<String> {
652 if !path.exists() {
653 return None;
654 }
655 match std::fs::read_to_string(path) {
656 Ok(s) => Some(s),
657 Err(e) => {
658 tracing::error!(
659 "Failed to read {}: {e}. Falling through to lower-precedence layers.",
660 path.display()
661 );
662 None
663 }
664 }
665}
666
667fn merge_layer_into(
678 dest: &mut serde_yaml::Value,
679 src: serde_yaml::Value,
680 source: ModelSource,
681 model_sources: &mut HashMap<String, ModelSource>,
682 provider_sources: &mut HashMap<String, ModelSource>,
683) {
684 use serde_yaml::Value;
685
686 let Value::Mapping(src_map) = src else {
687 *dest = src;
691 return;
692 };
693
694 if !matches!(dest, Value::Mapping(_)) {
695 *dest = Value::Mapping(serde_yaml::Mapping::new());
696 }
697 let Value::Mapping(dest_map) = dest else {
698 unreachable!("dest is a mapping after the check above");
699 };
700
701 for (k, v) in src_map {
702 match k.as_str() {
703 Some("models") => merge_models_into(dest_map, k, v, source, model_sources),
704 Some("providers") => merge_providers_into(dest_map, k, v, source, provider_sources),
705 _ => {
706 dest_map.insert(k, v);
707 }
708 }
709 }
710}
711
712fn merge_models_into(
713 dest_map: &mut serde_yaml::Mapping,
714 key: serde_yaml::Value,
715 incoming: serde_yaml::Value,
716 source: ModelSource,
717 model_sources: &mut HashMap<String, ModelSource>,
718) {
719 use serde_yaml::Value;
720
721 let Value::Sequence(incoming_seq) = incoming else {
722 dest_map.insert(key, incoming);
724 return;
725 };
726
727 let dest_value = dest_map
728 .entry(key)
729 .or_insert_with(|| Value::Sequence(Vec::new()));
730 if !matches!(dest_value, Value::Sequence(_)) {
731 *dest_value = Value::Sequence(Vec::new());
732 }
733 let Value::Sequence(dest_seq) = dest_value else {
734 unreachable!("dest is a sequence after the check above");
735 };
736
737 for entry in incoming_seq {
738 let api_id = entry
739 .get("api_identifier")
740 .and_then(|v| v.as_str())
741 .map(String::from);
742
743 let Some(api_id) = api_id else {
744 tracing::warn!(
745 "Skipping model entry without `api_identifier` from {source} models.yaml"
746 );
747 continue;
748 };
749
750 if let Some(existing) = dest_seq
751 .iter_mut()
752 .find(|e| e.get("api_identifier").and_then(serde_yaml::Value::as_str) == Some(&api_id))
753 {
754 deep_merge(existing, entry);
755 } else {
756 dest_seq.push(entry);
757 }
758
759 model_sources.insert(api_id, source);
760 }
761}
762
763fn merge_providers_into(
764 dest_map: &mut serde_yaml::Mapping,
765 key: serde_yaml::Value,
766 incoming: serde_yaml::Value,
767 source: ModelSource,
768 provider_sources: &mut HashMap<String, ModelSource>,
769) {
770 use serde_yaml::Value;
771
772 let Value::Mapping(incoming_providers) = incoming else {
773 dest_map.insert(key, incoming);
774 return;
775 };
776
777 let dest_value = dest_map
778 .entry(key)
779 .or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
780 if !matches!(dest_value, Value::Mapping(_)) {
781 *dest_value = Value::Mapping(serde_yaml::Mapping::new());
782 }
783 let Value::Mapping(dest_providers) = dest_value else {
784 unreachable!("dest is a mapping after the check above");
785 };
786
787 for (pname, pvalue) in incoming_providers {
788 let pname_str = pname.as_str().map(String::from);
789
790 if let Some(existing) = dest_providers.get_mut(&pname) {
791 deep_merge(existing, pvalue);
792 } else {
793 dest_providers.insert(pname.clone(), pvalue);
794 }
795
796 if let Some(name) = pname_str {
797 provider_sources.insert(name, source);
798 }
799 }
800}
801
802fn deep_merge(dest: &mut serde_yaml::Value, src: serde_yaml::Value) {
805 use serde_yaml::Value;
806 match (dest, src) {
807 (Value::Mapping(d), Value::Mapping(s)) => {
808 for (k, v) in s {
809 if let Some(existing) = d.get_mut(&k) {
810 deep_merge(existing, v);
811 } else {
812 d.insert(k, v);
813 }
814 }
815 }
816 (d, s) => *d = s,
817 }
818}
819
820fn warn_on_version_mismatch(declared: &[(ModelSource, Option<String>)]) {
823 for (source, version) in declared {
824 if matches!(source, ModelSource::Embedded) {
825 continue;
826 }
827 match version {
828 None => {
829 tracing::warn!(
830 "{source} models.yaml has no `version:` field; assuming compatibility with schema version {MODELS_SCHEMA_VERSION}. Add `version: \"{MODELS_SCHEMA_VERSION}\"` to silence this warning."
831 );
832 }
833 Some(v) if v == MODELS_SCHEMA_VERSION => {}
834 Some(v) => {
835 tracing::warn!(
836 "{source} models.yaml declares schema version {v}; this build understands {MODELS_SCHEMA_VERSION}. Continuing — unrecognised fields may be ignored."
837 );
838 }
839 }
840 }
841}
842
843static MODEL_REGISTRY: OnceLock<ModelRegistry> = OnceLock::new();
845
846#[must_use]
848pub fn get_model_registry() -> &'static ModelRegistry {
849 #[allow(clippy::expect_used)] MODEL_REGISTRY.get_or_init(|| ModelRegistry::load().expect("Failed to load model registry"))
851}
852
853#[cfg(test)]
854#[allow(clippy::unwrap_used, clippy::expect_used)]
855mod tests {
856 use super::*;
857 use std::io::Write;
858
859 fn embedded_only() -> ModelRegistry {
860 ModelRegistry::load_layered_from_paths(None, None, None).unwrap()
861 }
862
863 fn write_yaml(dir: &Path, name: &str, contents: &str) -> PathBuf {
864 let path = dir.join(name);
865 let mut f = std::fs::File::create(&path).unwrap();
866 f.write_all(contents.as_bytes()).unwrap();
867 path
868 }
869
870 #[test]
871 fn load_model_registry() {
872 let registry = embedded_only();
873 assert!(!registry.config.models.is_empty());
874 assert!(registry.config.providers.contains_key("claude"));
875 assert_eq!(
876 registry.config.version.as_deref(),
877 Some(MODELS_SCHEMA_VERSION)
878 );
879 }
880
881 #[test]
882 fn claude_model_lookup() {
883 let registry = embedded_only();
884
885 let opus_spec = registry.get_model_spec("claude-3-opus-20240229");
887 assert!(opus_spec.is_some());
888 assert_eq!(opus_spec.unwrap().max_output_tokens, 4096);
889 assert_eq!(opus_spec.unwrap().provider, "claude");
890 assert!(registry.is_legacy_model("claude-3-opus-20240229"));
891
892 let sonnet45_tokens = registry.get_max_output_tokens("claude-sonnet-4-5-20250929");
894 assert_eq!(sonnet45_tokens, 64000);
895
896 let sonnet4_tokens = registry.get_max_output_tokens("claude-sonnet-4-20250514");
898 assert_eq!(sonnet4_tokens, 64000);
899 assert!(registry.is_legacy_model("claude-sonnet-4-20250514"));
900
901 let unknown_tokens = registry.get_max_output_tokens("claude-unknown-model");
903 assert_eq!(unknown_tokens, 4096); }
905
906 #[test]
907 fn unknown_provider_uses_ultimate_fallback() {
908 let registry = embedded_only();
909
910 assert_eq!(
912 registry.get_max_output_tokens("totally-unknown-vendor-x"),
913 FALLBACK_MAX_OUTPUT_TOKENS
914 );
915 assert_eq!(
916 registry.get_input_context("totally-unknown-vendor-x"),
917 FALLBACK_INPUT_CONTEXT
918 );
919 }
920
921 #[test]
922 fn unknown_openai_model_uses_openai_provider_defaults() {
923 let registry = embedded_only();
924
925 for id in ["gpt-6-ultra", "chatgpt-6-latest", "o5-preview"] {
930 assert_eq!(
931 registry.get_max_output_tokens(id),
932 16384,
933 "max_output_tokens for {id}"
934 );
935 assert_eq!(
936 registry.get_input_context(id),
937 128_000,
938 "input_context for {id}"
939 );
940 }
941 }
942
943 #[test]
944 fn unknown_gemini_model_uses_gemini_provider_defaults() {
945 let registry = embedded_only();
946
947 let id = "gemini-9-pro";
950 assert_eq!(registry.get_max_output_tokens(id), 8192);
951 assert_eq!(registry.get_input_context(id), 1_048_576);
952 }
953
954 #[test]
955 fn provider_filtering() {
956 let registry = embedded_only();
957
958 let claude_models = registry.get_models_by_provider("claude");
959 assert!(!claude_models.is_empty());
960
961 let fast_claude_models = registry.get_models_by_provider_and_tier("claude", "fast");
962 assert!(!fast_claude_models.is_empty());
963
964 let tier_info = registry.get_tier_info("claude", "fast");
965 assert!(tier_info.is_some());
966 }
967
968 #[test]
969 fn provider_config() {
970 let registry = embedded_only();
971
972 let claude_config = registry.get_provider_config("claude");
973 assert!(claude_config.is_some());
974 assert_eq!(claude_config.unwrap().name, "Anthropic Claude");
975 }
976
977 #[test]
978 fn default_model_per_provider() {
979 let registry = embedded_only();
980
981 assert_eq!(
982 registry.get_default_model("claude"),
983 Some("claude-sonnet-4-6")
984 );
985 assert_eq!(registry.get_default_model("openai"), Some("gpt-5-mini"));
986 assert_eq!(
987 registry.get_default_model("gemini"),
988 Some("gemini-2.5-flash")
989 );
990 assert_eq!(registry.get_default_model("nonexistent"), None);
991 }
992
993 #[test]
994 fn normalized_id_matching() {
995 let registry = embedded_only();
996
997 let bedrock_3_7_sonnet = "us.anthropic.claude-3-7-sonnet-20250219-v1:0";
999 let spec = registry.get_model_spec(bedrock_3_7_sonnet);
1000 assert!(spec.is_some());
1001 assert_eq!(spec.unwrap().api_identifier, "claude-3-7-sonnet-20250219");
1002 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1003
1004 let aws_haiku = "anthropic.claude-3-haiku-20240307-v1:0";
1006 let spec = registry.get_model_spec(aws_haiku);
1007 assert!(spec.is_some());
1008 assert_eq!(spec.unwrap().api_identifier, "claude-3-haiku-20240307");
1009 assert_eq!(spec.unwrap().max_output_tokens, 4096);
1010
1011 let eu_opus = "eu.anthropic.claude-3-opus-20240229-v2:1";
1013 let spec = registry.get_model_spec(eu_opus);
1014 assert!(spec.is_some());
1015 assert_eq!(spec.unwrap().api_identifier, "claude-3-opus-20240229");
1016 assert_eq!(spec.unwrap().max_output_tokens, 4096);
1017
1018 let exact_sonnet45 = "claude-sonnet-4-5-20250929";
1020 let spec = registry.get_model_spec(exact_sonnet45);
1021 assert!(spec.is_some());
1022 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1023
1024 let exact_sonnet4 = "claude-sonnet-4-20250514";
1026 let spec = registry.get_model_spec(exact_sonnet4);
1027 assert!(spec.is_some());
1028 assert_eq!(spec.unwrap().max_output_tokens, 64000);
1029 }
1030
1031 #[test]
1032 fn extract_core_model_identifier() {
1033 let registry = embedded_only();
1034
1035 assert_eq!(
1037 registry.extract_core_model_identifier("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
1038 "claude-3-7-sonnet-20250219"
1039 );
1040
1041 assert_eq!(
1042 registry.extract_core_model_identifier("anthropic.claude-3-haiku-20240307-v1:0"),
1043 "claude-3-haiku-20240307"
1044 );
1045
1046 assert_eq!(
1047 registry.extract_core_model_identifier("claude-3-opus-20240229"),
1048 "claude-3-opus-20240229"
1049 );
1050
1051 assert_eq!(
1052 registry.extract_core_model_identifier("eu.anthropic.claude-sonnet-4-20250514-v2:1"),
1053 "claude-sonnet-4-20250514"
1054 );
1055 }
1056
1057 #[test]
1058 fn beta_header_lookups() {
1059 let registry = embedded_only();
1060
1061 assert_eq!(registry.get_max_output_tokens("claude-opus-4-6"), 128_000);
1063 assert_eq!(registry.get_input_context("claude-opus-4-6"), 200_000);
1064
1065 assert_eq!(
1067 registry.get_input_context_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1068 1_000_000
1069 );
1070 assert_eq!(
1072 registry.get_max_output_tokens_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1073 128_000
1074 );
1075
1076 assert_eq!(
1078 registry.get_max_output_tokens_with_beta(
1079 "claude-3-7-sonnet-20250219",
1080 "output-128k-2025-02-19"
1081 ),
1082 128_000
1083 );
1084
1085 assert_eq!(
1087 registry.get_max_output_tokens("claude-3-7-sonnet-20250219"),
1088 64000
1089 );
1090
1091 let headers = registry.get_beta_headers("claude-opus-4-6");
1093 assert_eq!(headers.len(), 1);
1094 assert_eq!(headers[0].key, "anthropic-beta");
1095 assert_eq!(headers[0].value, "context-1m-2025-08-07");
1096
1097 let headers = registry.get_beta_headers("claude-3-7-sonnet-20250219");
1099 assert_eq!(headers.len(), 2);
1100
1101 let headers = registry.get_beta_headers("claude-3-haiku-20240307");
1103 assert!(headers.is_empty());
1104
1105 let headers = registry.get_beta_headers("unknown-model");
1107 assert!(headers.is_empty());
1108 }
1109
1110 #[test]
1111 fn beta_lookups_for_unknown_model_fall_through_to_provider_defaults() {
1112 let registry = embedded_only();
1113
1114 assert_eq!(
1118 registry
1119 .get_max_output_tokens_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1120 4096
1121 );
1122 assert_eq!(
1123 registry.get_input_context_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1124 200_000
1125 );
1126 }
1127
1128 #[test]
1129 fn embedded_models_default_to_embedded_source() {
1130 let registry = embedded_only();
1131 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1132 assert_eq!(spec.source, ModelSource::Embedded);
1133
1134 let provider = registry.get_provider_config("claude").unwrap();
1135 assert_eq!(provider.source, ModelSource::Embedded);
1136 }
1137
1138 #[test]
1139 fn missing_user_and_project_files_fall_through_silently() {
1140 let dir = tempfile::tempdir().unwrap();
1141 let project_path = dir.path().join("missing-project.yaml");
1142 let user_path = dir.path().join("missing-user.yaml");
1143 let registry =
1144 ModelRegistry::load_layered_from_paths(Some(&project_path), Some(&user_path), None)
1145 .unwrap();
1146
1147 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1149 assert_eq!(spec.source, ModelSource::Embedded);
1150 assert_eq!(spec.max_output_tokens, 128_000);
1151 }
1152
1153 #[test]
1154 fn user_layer_overrides_embedded_entry() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let user = write_yaml(
1157 dir.path(),
1158 "user.yaml",
1159 r#"
1160version: "1"
1161models:
1162 - provider: "claude"
1163 model: "Claude Opus 4.6 (custom)"
1164 api_identifier: "claude-opus-4-6"
1165 max_output_tokens: 999999
1166 input_context: 200000
1167 generation: 4.6
1168 tier: "flagship"
1169"#,
1170 );
1171
1172 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1173 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1174 assert_eq!(spec.max_output_tokens, 999_999);
1175 assert_eq!(spec.model, "Claude Opus 4.6 (custom)");
1176 assert_eq!(spec.source, ModelSource::User);
1177 }
1178
1179 #[test]
1180 fn project_layer_takes_precedence_over_user_layer() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let user = write_yaml(
1183 dir.path(),
1184 "user.yaml",
1185 r#"
1186version: "1"
1187models:
1188 - provider: "claude"
1189 model: "From User"
1190 api_identifier: "claude-opus-4-6"
1191 max_output_tokens: 1
1192 input_context: 1
1193 generation: 4.6
1194 tier: "flagship"
1195"#,
1196 );
1197 let project = write_yaml(
1198 dir.path(),
1199 "project.yaml",
1200 r#"
1201version: "1"
1202models:
1203 - provider: "claude"
1204 model: "From Project"
1205 api_identifier: "claude-opus-4-6"
1206 max_output_tokens: 2
1207 input_context: 2
1208 generation: 4.6
1209 tier: "flagship"
1210"#,
1211 );
1212
1213 let registry =
1214 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1215 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1216 assert_eq!(spec.model, "From Project");
1217 assert_eq!(spec.max_output_tokens, 2);
1218 assert_eq!(spec.source, ModelSource::Project);
1219 }
1220
1221 #[test]
1222 fn additive_user_entry_is_appended() {
1223 let dir = tempfile::tempdir().unwrap();
1224 let user = write_yaml(
1225 dir.path(),
1226 "user.yaml",
1227 r#"
1228version: "1"
1229models:
1230 - provider: "claude"
1231 model: "Claude Custom Future"
1232 api_identifier: "claude-future-9000"
1233 max_output_tokens: 250000
1234 input_context: 5000000
1235 generation: 9.0
1236 tier: "flagship"
1237"#,
1238 );
1239
1240 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1241 let spec = registry.get_model_spec("claude-future-9000").unwrap();
1242 assert_eq!(spec.max_output_tokens, 250_000);
1243 assert_eq!(spec.input_context, 5_000_000);
1244 assert_eq!(spec.source, ModelSource::User);
1245
1246 let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1248 assert_eq!(opus.source, ModelSource::Embedded);
1249 }
1250
1251 #[test]
1252 fn provider_fields_can_be_partially_overridden() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let user = write_yaml(
1257 dir.path(),
1258 "user.yaml",
1259 r#"
1260version: "1"
1261providers:
1262 claude:
1263 default_model: "claude-opus-4-6"
1264"#,
1265 );
1266
1267 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1268 let claude = registry.get_provider_config("claude").unwrap();
1269 assert_eq!(claude.default_model, "claude-opus-4-6");
1270 assert_eq!(claude.name, "Anthropic Claude");
1272 assert_eq!(claude.api_base, "https://api.anthropic.com/v1");
1273 assert!(claude.tiers.contains_key("flagship"));
1274 assert_eq!(claude.source, ModelSource::User);
1276 }
1277
1278 #[test]
1279 fn malformed_user_yaml_logs_and_falls_through() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let user = write_yaml(
1282 dir.path(),
1283 "user.yaml",
1284 "this: is: definitely: not: valid: yaml: [unbalanced",
1285 );
1286
1287 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1288 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1290 assert_eq!(spec.source, ModelSource::Embedded);
1291 assert_eq!(spec.max_output_tokens, 128_000);
1292 }
1293
1294 #[test]
1295 fn override_path_short_circuits_user_and_project() {
1296 let dir = tempfile::tempdir().unwrap();
1297 let user = write_yaml(
1298 dir.path(),
1299 "user.yaml",
1300 r#"
1301version: "1"
1302models:
1303 - provider: "claude"
1304 model: "From User"
1305 api_identifier: "claude-opus-4-6"
1306 max_output_tokens: 1
1307 input_context: 1
1308 generation: 4.6
1309 tier: "flagship"
1310"#,
1311 );
1312 let project = write_yaml(
1313 dir.path(),
1314 "project.yaml",
1315 r#"
1316version: "1"
1317models:
1318 - provider: "claude"
1319 model: "From Project"
1320 api_identifier: "claude-opus-4-6"
1321 max_output_tokens: 2
1322 input_context: 2
1323 generation: 4.6
1324 tier: "flagship"
1325"#,
1326 );
1327 let override_file = write_yaml(
1328 dir.path(),
1329 "override.yaml",
1330 r#"
1331version: "1"
1332models:
1333 - provider: "claude"
1334 model: "From Override"
1335 api_identifier: "claude-opus-4-6"
1336 max_output_tokens: 3
1337 input_context: 3
1338 generation: 4.6
1339 tier: "flagship"
1340"#,
1341 );
1342
1343 let registry = ModelRegistry::load_layered_from_paths(
1344 Some(&project),
1345 Some(&user),
1346 Some(&override_file),
1347 )
1348 .unwrap();
1349 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1350 assert_eq!(spec.model, "From Override");
1351 assert_eq!(spec.max_output_tokens, 3);
1352 assert_eq!(spec.source, ModelSource::Override);
1353 }
1354
1355 #[test]
1356 fn missing_override_path_falls_back_to_embedded() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let missing = dir.path().join("does-not-exist.yaml");
1359 let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&missing)).unwrap();
1360 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1361 assert_eq!(spec.source, ModelSource::Embedded);
1362 }
1363
1364 #[test]
1365 fn version_mismatch_is_warned_not_fatal() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let user = write_yaml(
1368 dir.path(),
1369 "user.yaml",
1370 r#"
1371version: "9999"
1372models:
1373 - provider: "claude"
1374 model: "From Future"
1375 api_identifier: "claude-future-9000"
1376 max_output_tokens: 1
1377 input_context: 1
1378 generation: 9.0
1379 tier: "flagship"
1380"#,
1381 );
1382 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1383 assert!(registry.get_model_spec("claude-future-9000").is_some());
1385 }
1386
1387 #[test]
1388 fn missing_version_is_accepted() {
1389 let dir = tempfile::tempdir().unwrap();
1390 let user = write_yaml(
1391 dir.path(),
1392 "user.yaml",
1393 r#"
1394models:
1395 - provider: "claude"
1396 model: "Versionless"
1397 api_identifier: "claude-versionless"
1398 max_output_tokens: 1
1399 input_context: 1
1400 generation: 1.0
1401 tier: "flagship"
1402"#,
1403 );
1404 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1405 assert!(registry.get_model_spec("claude-versionless").is_some());
1406 }
1407
1408 #[test]
1409 fn model_entry_without_api_identifier_is_skipped() {
1410 let dir = tempfile::tempdir().unwrap();
1411 let user = write_yaml(
1412 dir.path(),
1413 "user.yaml",
1414 r#"
1415version: "1"
1416models:
1417 - provider: "claude"
1418 model: "No Id"
1419 max_output_tokens: 1
1420 input_context: 1
1421 generation: 1.0
1422 tier: "flagship"
1423"#,
1424 );
1425 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1426 let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1428 assert_eq!(opus.source, ModelSource::Embedded);
1429 }
1430
1431 #[test]
1432 fn model_source_display() {
1433 assert_eq!(ModelSource::Embedded.to_string(), "embedded");
1434 assert_eq!(ModelSource::User.to_string(), "user");
1435 assert_eq!(ModelSource::Project.to_string(), "project");
1436 assert_eq!(ModelSource::Override.to_string(), "override");
1437 }
1438
1439 #[test]
1440 fn embedded_yaml_must_not_be_malformed() {
1441 let layers = [(ModelSource::Embedded, "::: not yaml :::".to_string())];
1443 let result = ModelRegistry::from_layers(&layers);
1444 assert!(result.is_err());
1445 }
1446
1447 #[test]
1448 fn user_layer_with_scalar_top_level_returns_error() {
1449 let dir = tempfile::tempdir().unwrap();
1453 let user = write_yaml(dir.path(), "user.yaml", "\"just a string\"\n");
1454 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1455 assert!(result.is_err());
1456 }
1457
1458 #[test]
1459 fn user_layer_with_non_sequence_models_returns_error() {
1460 let dir = tempfile::tempdir().unwrap();
1464 let user = write_yaml(
1465 dir.path(),
1466 "user.yaml",
1467 r#"
1468version: "1"
1469models: 42
1470"#,
1471 );
1472 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1473 assert!(result.is_err());
1474 }
1475
1476 #[test]
1477 fn user_layer_with_non_mapping_providers_returns_error() {
1478 let dir = tempfile::tempdir().unwrap();
1481 let user = write_yaml(
1482 dir.path(),
1483 "user.yaml",
1484 r#"
1485version: "1"
1486providers: 42
1487"#,
1488 );
1489 let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1490 assert!(result.is_err());
1491 }
1492
1493 #[test]
1494 fn deep_merge_inserts_new_keys_into_existing_mapping() {
1495 let dir = tempfile::tempdir().unwrap();
1499 let user = write_yaml(
1500 dir.path(),
1501 "user.yaml",
1502 r#"
1503version: "1"
1504providers:
1505 claude:
1506 tiers:
1507 experimental:
1508 description: "Experimental tier"
1509 use_cases: ["bleeding edge"]
1510"#,
1511 );
1512 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1513 let claude = registry.get_provider_config("claude").unwrap();
1514 assert!(claude.tiers.contains_key("flagship"));
1516 assert!(claude.tiers.contains_key("balanced"));
1517 assert!(claude.tiers.contains_key("fast"));
1518 let experimental = claude.tiers.get("experimental").unwrap();
1520 assert_eq!(experimental.description, "Experimental tier");
1521 assert_eq!(experimental.use_cases, vec!["bleeding edge".to_string()]);
1522 }
1523
1524 #[test]
1525 #[cfg(unix)]
1526 fn user_path_pointing_at_a_directory_logs_and_falls_through() {
1527 let dir = tempfile::tempdir().unwrap();
1530 let bogus = dir.path().join("models.yaml");
1531 std::fs::create_dir(&bogus).unwrap();
1532 let registry = ModelRegistry::load_layered_from_paths(None, Some(&bogus), None).unwrap();
1533 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1534 assert_eq!(spec.source, ModelSource::Embedded);
1535 }
1536
1537 #[test]
1538 #[cfg(unix)]
1539 fn override_path_pointing_at_a_directory_warns_and_falls_through() {
1540 let dir = tempfile::tempdir().unwrap();
1541 let bogus = dir.path().join("override.yaml");
1542 std::fs::create_dir(&bogus).unwrap();
1543 let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&bogus)).unwrap();
1544 let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1545 assert_eq!(spec.source, ModelSource::Embedded);
1546 }
1547
1548 #[test]
1549 fn project_layer_recovers_after_user_replaces_top_level_with_scalar() {
1550 let dir = tempfile::tempdir().unwrap();
1556 let user = write_yaml(dir.path(), "user.yaml", "\"junk\"\n");
1557 let project = write_yaml(
1558 dir.path(),
1559 "project.yaml",
1560 r#"
1561version: "1"
1562models:
1563 - provider: "claude"
1564 model: "Project Rescue"
1565 api_identifier: "claude-rescue"
1566 max_output_tokens: 1
1567 input_context: 1
1568 generation: 1.0
1569 tier: "flagship"
1570providers:
1571 custom-provider:
1572 name: "Custom"
1573 api_base: "https://example.invalid"
1574 default_model: "custom-default"
1575 tiers: {}
1576 defaults:
1577 max_output_tokens: 100
1578 input_context: 1000
1579"#,
1580 );
1581 let registry =
1582 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1583 let spec = registry.get_model_spec("claude-rescue").unwrap();
1585 assert_eq!(spec.source, ModelSource::Project);
1586 }
1587
1588 #[test]
1589 fn project_layer_recovers_after_user_replaces_models_with_scalar() {
1590 let dir = tempfile::tempdir().unwrap();
1594 let user = write_yaml(
1595 dir.path(),
1596 "user.yaml",
1597 r#"
1598version: "1"
1599models: 42
1600"#,
1601 );
1602 let project = write_yaml(
1603 dir.path(),
1604 "project.yaml",
1605 r#"
1606version: "1"
1607models:
1608 - provider: "claude"
1609 model: "Project Rescue"
1610 api_identifier: "claude-rescue"
1611 max_output_tokens: 1
1612 input_context: 1
1613 generation: 1.0
1614 tier: "flagship"
1615"#,
1616 );
1617 let registry =
1618 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1619 let spec = registry.get_model_spec("claude-rescue").unwrap();
1620 assert_eq!(spec.source, ModelSource::Project);
1621 }
1622
1623 #[test]
1624 fn project_layer_recovers_after_user_replaces_providers_with_scalar() {
1625 let dir = tempfile::tempdir().unwrap();
1628 let user = write_yaml(
1629 dir.path(),
1630 "user.yaml",
1631 r#"
1632version: "1"
1633providers: 42
1634"#,
1635 );
1636 let project = write_yaml(
1637 dir.path(),
1638 "project.yaml",
1639 r#"
1640version: "1"
1641providers:
1642 custom-provider:
1643 name: "Custom"
1644 api_base: "https://example.invalid"
1645 default_model: "custom-default"
1646 tiers: {}
1647 defaults:
1648 max_output_tokens: 100
1649 input_context: 1000
1650"#,
1651 );
1652 let registry =
1653 ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1654 let provider = registry.get_provider_config("custom-provider").unwrap();
1655 assert_eq!(provider.name, "Custom");
1656 assert_eq!(provider.source, ModelSource::Project);
1657 }
1658
1659 #[test]
1660 fn empty_omni_dev_models_yaml_env_var_is_ignored() {
1661 let resolved: Option<PathBuf> = Some(String::new())
1665 .filter(|s| !s.is_empty())
1666 .map(PathBuf::from);
1667 assert!(resolved.is_none());
1668 let resolved: Option<PathBuf> = Some("/some/path".to_string())
1669 .filter(|s| !s.is_empty())
1670 .map(PathBuf::from);
1671 assert_eq!(resolved.as_deref(), Some(Path::new("/some/path")));
1672 }
1673}