1use crate::route::{RoutePolicy, RouteTier};
16use crate::secret::SecretRef;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum BillingMode {
30 Subscription,
32 UsageBilled,
34 Local,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
39pub struct ModelEntry {
40 #[serde(default)]
41 pub provider: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub vendor: Option<String>,
55 #[serde(default)]
56 pub model: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub base_url: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub secret: Option<SecretRef>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub capabilities: Vec<String>,
63 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
64 pub params: serde_json::Value,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub tier: Option<RouteTier>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub cost_per_1k_tokens: Option<f64>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub input_cost_per_1k: Option<f64>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub output_cost_per_1k: Option<f64>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub context_window: Option<u64>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub max_tokens: Option<u32>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub priced_at: Option<chrono::DateTime<chrono::Utc>>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub billing: Option<BillingMode>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub catalog_verified: Option<bool>,
112}
113
114fn vendor_label_of_url(base_url: Option<&str>) -> Option<String> {
119 let host = base_url?
120 .split("//")
121 .nth(1)
122 .unwrap_or(base_url?)
123 .split(['/', ':'])
124 .next()
125 .unwrap_or("");
126 let label = host.strip_prefix("api.").unwrap_or(host);
127 let first = label.split('.').next().unwrap_or("");
128 (!first.is_empty()).then(|| first.to_string())
129}
130
131const LOCAL_MODEL_PROVIDERS: &[&str] = &[
132 "ollama",
133 "mlx",
134 "llamacpp",
135 "llama_cpp",
136 "localai",
137 "lmstudio",
138 "local",
139];
140
141pub fn provider_is_local(provider: &str) -> bool {
143 LOCAL_MODEL_PROVIDERS.contains(&provider.to_ascii_lowercase().as_str())
144}
145
146pub fn inferred_billing_for_provider(provider: &str) -> BillingMode {
148 if provider_is_local(provider) {
149 BillingMode::Local
150 } else if matches!(provider.to_ascii_lowercase().as_str(), "codex" | "claude") {
151 BillingMode::Subscription
152 } else {
153 BillingMode::UsageBilled
154 }
155}
156
157impl ModelEntry {
158 pub fn billing_or_inferred(&self) -> BillingMode {
167 self.billing
168 .unwrap_or_else(|| inferred_billing_for_provider(&self.provider))
169 }
170
171 pub fn effective_route_tier(&self) -> RouteTier {
173 self.tier.unwrap_or_else(|| {
174 if provider_is_local(&self.provider) {
175 RouteTier::Local
176 } else {
177 RouteTier::Frontier
178 }
179 })
180 }
181 pub fn effective_costs(&self) -> (Option<f64>, Option<f64>) {
186 let output = self.output_cost_per_1k.or(self.cost_per_1k_tokens);
187 let input = self.input_cost_per_1k.or(self.cost_per_1k_tokens);
188 (input, output)
189 }
190
191 pub fn projected_cost(&self, input_tokens: u64, output_tokens: u64) -> Option<f64> {
194 let (input, output) = self.effective_costs();
195 let fallback = input.or(output)?;
196 let input = input.unwrap_or(fallback);
197 let output = output.unwrap_or(fallback);
198 Some(input_tokens as f64 / 1_000.0 * input + output_tokens as f64 / 1_000.0 * output)
199 }
200
201 pub fn vendor_candidates(&self) -> Vec<String> {
212 let mut out: Vec<String> = Vec::with_capacity(3);
213 let mut push = |v: &str| {
214 if !v.is_empty() && !out.iter().any(|e| e == v) {
215 out.push(v.to_string());
216 }
217 };
218 if let Some(v) = self.vendor.as_deref() {
219 push(v);
220 }
221 if let Some(label) = vendor_label_of_url(self.base_url.as_deref()) {
222 push(&label);
223 }
224 push(&self.provider);
225 out
226 }
227
228 pub fn is_priced(&self) -> bool {
230 let (input, output) = self.effective_costs();
231 input.is_some() || output.is_some()
232 }
233
234 pub fn stamp_priced_at(&mut self, now: chrono::DateTime<chrono::Utc>) {
238 if self.is_priced() && self.priced_at.is_none_or(|prev| prev < now) {
239 self.priced_at = Some(now);
240 }
241 }
242
243 pub fn price_age(&self, now: chrono::DateTime<chrono::Utc>) -> Option<chrono::TimeDelta> {
245 self.priced_at.map(|at| now - at)
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
250pub struct RoleEntry {
251 pub primary: String,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub fallback: Option<String>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub cost_budget_per_day_usd: Option<f64>,
259 #[serde(default)]
261 pub privacy_local_only: bool,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub route_policy: Option<RoutePolicy>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269pub struct ModelRegistry {
270 pub schema_version: u32,
271 #[serde(default)]
272 pub models: BTreeMap<String, ModelEntry>,
273 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
274 pub roles: BTreeMap<String, RoleEntry>,
275}
276
277impl Default for ModelRegistry {
278 fn default() -> Self {
279 Self {
280 schema_version: 1,
281 models: BTreeMap::new(),
282 roles: BTreeMap::new(),
283 }
284 }
285}
286
287impl ModelRegistry {
288 pub fn load_from(path: &Path) -> anyhow::Result<Self> {
289 if !path.exists() {
290 return Ok(Self::default());
291 }
292 let body = std::fs::read_to_string(path)?;
293 if body.trim().is_empty() {
294 return Ok(Self::default());
295 }
296 Ok(serde_yaml_ng::from_str(&body)?)
297 }
298
299 pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
300 if let Some(parent) = path.parent() {
301 std::fs::create_dir_all(parent)?;
302 }
303 let body = serde_yaml_ng::to_string(self)?;
304 let tmp = path.with_extension("yaml.tmp");
305 std::fs::write(&tmp, body)?;
306 std::fs::rename(&tmp, path)?;
307 Ok(())
308 }
309
310 pub fn default_path() -> anyhow::Result<PathBuf> {
311 if let Ok(p) = std::env::var("MUR_HOME")
314 && !p.is_empty()
315 {
316 return Ok(PathBuf::from(p).join("models.yaml"));
317 }
318 let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
319 Ok(home.join(".mur/models.yaml"))
320 }
321
322 pub fn resolve_role(&self, role: &str) -> Option<&str> {
325 let entry = self.roles.get(role)?;
326 if self.models.contains_key(&entry.primary) {
327 return Some(&entry.primary);
328 }
329 if let Some(fb) = &entry.fallback
331 && self.models.contains_key(fb)
332 {
333 return Some(fb);
334 }
335 None
337 }
338}
339
340use crate::agent::AgentProfile;
341use crate::config::{DEFAULT_ROUTING_THRESHOLD, ModelSwitchConfig, RoutingConfig};
342
343pub fn resolve_model_refs(
348 profile: &AgentProfile,
349 cfg: &ModelSwitchConfig,
350 routed_primary: Option<String>,
351) -> Vec<String> {
352 let primary = routed_primary
353 .or_else(|| profile.model_ref.clone())
354 .or_else(|| cfg.default.clone());
355 let chain = if !profile.fallback_chain.is_empty() {
356 profile.fallback_chain.clone()
357 } else {
358 cfg.fallback_chain.clone()
359 };
360 let mut out: Vec<String> = Vec::new();
361 if let Some(p) = primary {
362 out.push(p);
363 }
364 for r in chain {
365 if !out.contains(&r) {
366 out.push(r);
367 }
368 }
369 out
370}
371
372pub fn choose_by_difficulty(est_input_tokens: u32, r: &RoutingConfig) -> Option<String> {
376 let threshold = r
377 .threshold_input_tokens
378 .unwrap_or(DEFAULT_ROUTING_THRESHOLD);
379 match (r.cheap.as_ref(), r.frontier.as_ref()) {
380 (Some(cheap), Some(frontier)) => Some(if est_input_tokens > threshold {
381 frontier.clone()
382 } else {
383 cheap.clone()
384 }),
385 _ => None,
386 }
387}
388
389pub const CAP_CHAT: &str = "chat";
393pub const CAP_TOOLS: &str = "tools";
394pub const CAP_VISION: &str = "vision";
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub enum Requirement {
401 Vision,
403 Tools,
405}
406
407impl Requirement {
408 pub fn capability(self) -> &'static str {
410 match self {
411 Requirement::Vision => CAP_VISION,
412 Requirement::Tools => CAP_TOOLS,
413 }
414 }
415
416 fn permitted_when_undeclared(self) -> bool {
436 match self {
437 Requirement::Vision => false,
438 Requirement::Tools => true,
439 }
440 }
441}
442
443pub fn satisfies(e: &ModelEntry, reqs: &[Requirement]) -> bool {
451 let chat_capable = e.capabilities.is_empty() || e.capabilities.iter().any(|c| c == CAP_CHAT);
452 if !chat_capable {
453 return false;
454 }
455 reqs.iter().all(|r| {
456 if e.capabilities.is_empty() {
457 r.permitted_when_undeclared()
458 } else {
459 e.capabilities.iter().any(|c| c == r.capability())
460 }
461 })
462}
463
464pub fn pick_cheap_model(
468 reg: &ModelRegistry,
469 exclude: Option<&str>,
470 reqs: &[Requirement],
471) -> Option<String> {
472 reg.models
473 .iter()
474 .filter(|(k, _)| exclude != Some(k.as_str()))
475 .filter(|(_, e)| satisfies(e, reqs))
476 .filter_map(|(k, e)| e.projected_cost(4_000, 1_000).map(|c| (c, k.clone())))
477 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
478 .map(|(_, k)| k)
479}
480
481#[cfg(test)]
482mod tests {
483
484 #[test]
485 fn vendor_candidates_prefer_the_recorded_vendor_then_the_host_then_provider() {
486 let e = ModelEntry {
488 provider: "openai".into(),
489 vendor: Some("deepseek".into()),
490 base_url: Some("https://api.deepseek.com/v1".into()),
491 ..Default::default()
492 };
493 assert_eq!(e.vendor_candidates(), vec!["deepseek", "openai"]);
494
495 let legacy = ModelEntry {
498 provider: "openai".into(),
499 base_url: Some("https://api.deepseek.com/v1".into()),
500 ..Default::default()
501 };
502 assert_eq!(legacy.vendor_candidates(), vec!["deepseek", "openai"]);
503
504 let bare = ModelEntry {
506 provider: "anthropic".into(),
507 ..Default::default()
508 };
509 assert_eq!(bare.vendor_candidates(), vec!["anthropic"]);
510
511 let same = ModelEntry {
513 provider: "openai".into(),
514 base_url: Some("https://api.openai.com/v1".into()),
515 ..Default::default()
516 };
517 assert_eq!(same.vendor_candidates(), vec!["openai"]);
518 }
519
520 #[test]
521 fn vendor_is_omitted_from_yaml_when_absent_and_round_trips_when_set() {
522 let bare = ModelEntry {
523 provider: "anthropic".into(),
524 model: "claude-opus-5".into(),
525 ..Default::default()
526 };
527 let y = serde_yaml_ng::to_string(&bare).unwrap();
528 assert!(!y.contains("vendor"), "{y}");
529
530 let tagged = ModelEntry {
531 provider: "openai".into(),
532 vendor: Some("groq".into()),
533 model: "llama-3.3".into(),
534 ..Default::default()
535 };
536 let y = serde_yaml_ng::to_string(&tagged).unwrap();
537 let back: ModelEntry = serde_yaml_ng::from_str(&y).unwrap();
538 assert_eq!(back.vendor.as_deref(), Some("groq"));
539 }
540 use super::*;
541
542 #[test]
543 fn parses_full_registry() {
544 let yaml = r#"
545schema_version: 1
546models:
547 anthropic_opus_4_7:
548 provider: anthropic
549 model: claude-opus-4-7
550 secret: env:ANTHROPIC_API_KEY
551 capabilities: [chat, tools]
552 ollama_llama3:
553 provider: ollama
554 model: llama3.2:3b
555 base_url: http://127.0.0.1:11434
556"#;
557 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
558 assert_eq!(r.schema_version, 1);
559 assert_eq!(r.models.len(), 2);
560 let opus = r.models.get("anthropic_opus_4_7").unwrap();
561 assert_eq!(opus.provider, "anthropic");
562 assert_eq!(
563 opus.secret,
564 Some(SecretRef::Env("ANTHROPIC_API_KEY".into()))
565 );
566 assert!(r.models["ollama_llama3"].secret.is_none());
567 }
568
569 #[test]
570 fn round_trip_preserves_shape() {
571 let mut r = ModelRegistry::default();
572 r.models.insert(
573 "foo".into(),
574 ModelEntry {
575 provider: "anthropic".into(),
576 model: "claude-opus-4-7".into(),
577 base_url: None,
578 secret: Some(SecretRef::Keychain {
579 service: "mur".into(),
580 account: "anthropic".into(),
581 }),
582 capabilities: vec!["chat".into()],
583 params: serde_json::Value::Null,
584 tier: None,
585 cost_per_1k_tokens: None,
586 input_cost_per_1k: None,
587 output_cost_per_1k: None,
588 context_window: None,
589 priced_at: None,
590 ..Default::default()
591 },
592 );
593 let s = serde_yaml_ng::to_string(&r).unwrap();
594 let parsed: ModelRegistry = serde_yaml_ng::from_str(&s).unwrap();
595 assert_eq!(r, parsed);
596 }
597
598 #[test]
599 fn rejects_unknown_secret_scheme() {
600 let yaml = r#"
601schema_version: 1
602models:
603 bad:
604 provider: x
605 model: y
606 secret: bogus:value
607"#;
608 let r: Result<ModelRegistry, _> = serde_yaml_ng::from_str(yaml);
609 assert!(r.is_err(), "should reject unknown scheme");
610 }
611
612 #[test]
613 fn test_registry_roundtrip_with_roles() {
614 let yaml = r#"
615schema_version: 1
616models:
617 haiku:
618 provider: anthropic
619 model: claude-haiku-4-5
620roles:
621 reflector:
622 primary: haiku
623 fallback: null
624 cost_budget_per_day_usd: 0.5
625"#;
626 let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
627 assert_eq!(reg.roles["reflector"].primary, "haiku");
628 let back = serde_yaml_ng::to_string(®).unwrap();
629 let reg2: ModelRegistry = serde_yaml_ng::from_str(&back).unwrap();
630 assert_eq!(reg, reg2);
631 }
632
633 #[test]
634 fn test_resolve_role_primary() {
635 let mut reg = ModelRegistry::default();
636 reg.models.insert(
637 "haiku".into(),
638 ModelEntry {
639 provider: "anthropic".into(),
640 model: "claude-haiku-4-5".into(),
641 base_url: None,
642 secret: None,
643 capabilities: vec![],
644 params: serde_json::Value::Null,
645 tier: None,
646 cost_per_1k_tokens: None,
647 input_cost_per_1k: None,
648 output_cost_per_1k: None,
649 context_window: None,
650 priced_at: None,
651 ..Default::default()
652 },
653 );
654 reg.roles.insert(
655 "reflector".into(),
656 RoleEntry {
657 primary: "haiku".into(),
658 fallback: None,
659 ..Default::default()
660 },
661 );
662 assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
663 }
664
665 #[test]
666 fn test_resolve_role_fallback() {
667 let mut reg = ModelRegistry::default();
668 reg.models.insert(
669 "haiku".into(),
670 ModelEntry {
671 provider: "anthropic".into(),
672 model: "claude-haiku-4-5".into(),
673 base_url: None,
674 secret: None,
675 capabilities: vec![],
676 params: serde_json::Value::Null,
677 tier: None,
678 cost_per_1k_tokens: None,
679 input_cost_per_1k: None,
680 output_cost_per_1k: None,
681 context_window: None,
682 priced_at: None,
683 ..Default::default()
684 },
685 );
686 reg.roles.insert(
687 "reflector".into(),
688 RoleEntry {
689 primary: "nonexistent".into(),
690 fallback: Some("haiku".into()),
691 ..Default::default()
692 },
693 );
694 assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
695 }
696
697 #[test]
698 fn test_resolve_role_none() {
699 let reg = ModelRegistry::default();
700 assert_eq!(reg.resolve_role("reflector"), None);
701 }
702
703 #[test]
704 fn model_entry_parses_tier_field() {
705 let yaml = r#"
706schema_version: 1
707models:
708 haiku:
709 provider: anthropic
710 model: claude-haiku-4-5
711 tier: local
712 opus:
713 provider: anthropic
714 model: claude-opus-4-7
715 tier: frontier
716 cost_per_1k_tokens: 0.015
717"#;
718 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
719 assert_eq!(r.models["haiku"].tier, Some(RouteTier::Local));
720 assert_eq!(r.models["opus"].tier, Some(RouteTier::Frontier));
721 assert_eq!(r.models["opus"].cost_per_1k_tokens, Some(0.015));
722 let mut r2 = ModelRegistry::default();
724 r2.models.insert(
725 "x".into(),
726 ModelEntry {
727 provider: "ollama".into(),
728 model: "llama3".into(),
729 base_url: None,
730 secret: None,
731 capabilities: vec![],
732 params: serde_json::Value::Null,
733 tier: None,
734 cost_per_1k_tokens: None,
735 input_cost_per_1k: None,
736 output_cost_per_1k: None,
737 context_window: None,
738 priced_at: None,
739 ..Default::default()
740 },
741 );
742 let yaml = serde_yaml_ng::to_string(&r2).unwrap();
743 assert!(
744 !yaml.contains("tier:"),
745 "absent tier should not be serialized: {yaml}"
746 );
747 }
748
749 #[test]
750 fn role_entry_parses_route_policy() {
751 let yaml = r#"
752schema_version: 1
753models:
754 haiku:
755 provider: anthropic
756 model: claude-haiku-4-5
757 opus:
758 provider: anthropic
759 model: claude-opus-4-7
760roles:
761 dev:
762 primary: opus
763 route_policy: !force_frontier
764 model_id: opus
765 reflector:
766 primary: haiku
767 route_policy: prefer_local
768 curator:
769 primary: haiku
770 route_policy: force_local
771 chat:
772 primary: haiku
773"#;
774 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
775 assert_eq!(
776 r.roles["dev"].route_policy,
777 Some(RoutePolicy::ForceFrontier {
778 model_id: "opus".into()
779 })
780 );
781 assert_eq!(
782 r.roles["reflector"].route_policy,
783 Some(RoutePolicy::PreferLocal)
784 );
785 assert_eq!(
786 r.roles["curator"].route_policy,
787 Some(RoutePolicy::ForceLocal)
788 );
789 assert_eq!(r.roles["chat"].route_policy, None);
790 }
791
792 #[test]
793 fn parses_split_cost_fields() {
794 let yaml = r#"
795schema_version: 1
796models:
797 opus:
798 provider: anthropic
799 model: claude-opus-4-8
800 input_cost_per_1k: 0.005
801 output_cost_per_1k: 0.025
802 context_window: 200000
803"#;
804 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
805 let e = r.models.get("opus").unwrap();
806 assert_eq!(e.input_cost_per_1k, Some(0.005));
807 assert_eq!(e.output_cost_per_1k, Some(0.025));
808 assert_eq!(e.context_window, Some(200_000));
809 }
810
811 #[test]
812 fn max_tokens_parses_and_is_omitted_when_unset() {
813 let yaml = r#"
814schema_version: 1
815models:
816 opus:
817 provider: claude
818 model: claude-opus-5-5
819 max_tokens: 128000
820 plain:
821 provider: anthropic
822 model: claude-opus-5-5
823"#;
824 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
825 assert_eq!(r.models["opus"].max_tokens, Some(128_000));
826 assert_eq!(r.models["plain"].max_tokens, None);
827 let out = serde_yaml_ng::to_string(&r.models["plain"]).unwrap();
830 assert!(!out.contains("max_tokens"), "{out}");
831 }
832
833 #[test]
834 fn default_model_entry_is_empty() {
835 let e = ModelEntry::default();
836 assert!(e.provider.is_empty());
837 assert_eq!(e.input_cost_per_1k, None);
838 assert_eq!(e.output_cost_per_1k, None);
839 assert_eq!(e.context_window, None);
840 }
841
842 #[test]
843 fn effective_costs_fallback_matrix() {
844 let mut e = ModelEntry {
846 cost_per_1k_tokens: Some(0.01),
847 ..Default::default()
848 };
849 assert_eq!(e.effective_costs(), (Some(0.01), Some(0.01)));
850
851 e = ModelEntry {
853 input_cost_per_1k: Some(0.005),
854 output_cost_per_1k: Some(0.025),
855 ..Default::default()
856 };
857 assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
858
859 e = ModelEntry {
861 cost_per_1k_tokens: Some(0.01),
862 input_cost_per_1k: Some(0.005),
863 output_cost_per_1k: Some(0.025),
864 ..Default::default()
865 };
866 assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
867
868 e = ModelEntry::default();
870 assert_eq!(e.effective_costs(), (None, None));
871 }
872}
873
874#[cfg(test)]
875mod io_tests {
876 use super::*;
877 use tempfile::tempdir;
878
879 #[test]
880 fn load_returns_empty_when_file_missing() {
881 let dir = tempdir().unwrap();
882 let r = ModelRegistry::load_from(&dir.path().join("nope.yaml")).unwrap();
883 assert_eq!(r.models.len(), 0);
884 assert_eq!(r.schema_version, 1);
885 }
886
887 #[test]
888 fn save_then_load_round_trips() {
889 let dir = tempdir().unwrap();
890 let p = dir.path().join("models.yaml");
891 let mut r = ModelRegistry::default();
892 r.models.insert(
893 "x".into(),
894 ModelEntry {
895 provider: "ollama".into(),
896 model: "llama3.2:3b".into(),
897 base_url: None,
898 secret: None,
899 capabilities: vec![],
900 params: serde_json::Value::Null,
901 tier: None,
902 cost_per_1k_tokens: None,
903 input_cost_per_1k: None,
904 output_cost_per_1k: None,
905 context_window: None,
906 priced_at: None,
907 ..Default::default()
908 },
909 );
910 r.save_to(&p).unwrap();
911 let r2 = ModelRegistry::load_from(&p).unwrap();
912 assert_eq!(r, r2);
913 }
914
915 #[test]
916 fn save_uses_atomic_rename() {
917 let dir = tempdir().unwrap();
918 let p = dir.path().join("models.yaml");
919 ModelRegistry::default().save_to(&p).unwrap();
920 let temp = dir.path().join("models.yaml.tmp");
921 assert!(!temp.exists(), "atomic temp left behind");
922 }
923}
924
925#[cfg(test)]
926mod switch_tests {
927 use super::*;
928 use crate::agent::AgentProfile;
929 use crate::config::{ModelSwitchConfig, RoutingConfig};
930
931 fn profile(model_ref: Option<&str>, chain: &[&str]) -> AgentProfile {
932 let mut p = AgentProfile::default_for_tests();
933 p.model_ref = model_ref.map(|s| s.to_string());
934 p.fallback_chain = chain.iter().map(|s| s.to_string()).collect();
935 p
936 }
937
938 #[test]
939 fn per_agent_primary_and_chain_win_over_global() {
940 let cfg = ModelSwitchConfig {
941 default: Some("global_default".into()),
942 fallback_chain: vec!["g1".into(), "g2".into()],
943 ..Default::default()
944 };
945 let p = profile(Some("agent_primary"), &["agent_primary", "agent_fb"]);
946 assert_eq!(
948 resolve_model_refs(&p, &cfg, None),
949 vec!["agent_primary", "agent_fb"]
950 );
951 }
952
953 #[test]
954 fn falls_back_to_global_default_and_chain() {
955 let cfg = ModelSwitchConfig {
956 default: Some("global_default".into()),
957 fallback_chain: vec!["g1".into(), "global_default".into()],
958 ..Default::default()
959 };
960 let p = profile(None, &[]); assert_eq!(
963 resolve_model_refs(&p, &cfg, None),
964 vec!["global_default", "g1"]
965 );
966 }
967
968 #[test]
969 fn routed_primary_overrides_model_ref() {
970 let cfg = ModelSwitchConfig {
971 fallback_chain: vec!["g1".into()],
972 ..Default::default()
973 };
974 let p = profile(Some("agent_primary"), &[]);
975 assert_eq!(
976 resolve_model_refs(&p, &cfg, Some("frontier".into())),
977 vec!["frontier", "g1"]
978 );
979 }
980
981 #[test]
982 fn no_config_no_agent_yields_empty() {
983 let cfg = ModelSwitchConfig::default();
985 assert!(resolve_model_refs(&profile(None, &[]), &cfg, None).is_empty());
986 }
987
988 #[test]
989 fn difficulty_picks_frontier_over_threshold() {
990 let r = RoutingConfig {
991 enabled: true,
992 cheap: Some("cheap".into()),
993 frontier: Some("frontier".into()),
994 threshold_input_tokens: Some(1000),
995 };
996 assert_eq!(choose_by_difficulty(1500, &r), Some("frontier".into()));
997 assert_eq!(choose_by_difficulty(500, &r), Some("cheap".into()));
998 let bad = RoutingConfig {
1000 enabled: true,
1001 cheap: Some("c".into()),
1002 frontier: None,
1003 threshold_input_tokens: None,
1004 };
1005 assert_eq!(choose_by_difficulty(9999, &bad), None);
1006 }
1007
1008 #[test]
1009 fn pick_cheap_model_lowest_cost_chat_excluding_primary() {
1010 let mut reg = ModelRegistry::default();
1011 let mk = |cost: f64, caps: &[&str]| ModelEntry {
1012 provider: "x".into(),
1013 model: "m".into(),
1014 capabilities: caps.iter().map(|s| s.to_string()).collect(),
1015 cost_per_1k_tokens: Some(cost),
1016 ..Default::default()
1017 };
1018 reg.models.insert("frontier".into(), mk(0.01, &["chat"]));
1019 reg.models.insert("cheap".into(), mk(0.0001, &["chat"]));
1020 reg.models
1021 .insert("embed".into(), mk(0.00001, &["embedding"])); assert_eq!(
1024 pick_cheap_model(®, Some("cheap"), &[]),
1025 Some("frontier".into())
1026 ); assert_eq!(pick_cheap_model(®, None, &[]), Some("cheap".into()));
1028 let mut empty = ModelRegistry::default();
1030 empty.models.insert("e".into(), mk(0.0, &["embedding"]));
1031 assert_eq!(pick_cheap_model(&empty, None, &[]), None);
1032 }
1033
1034 #[test]
1035 fn satisfies_is_permissive_at_baseline_and_fail_closed_above_it() {
1036 let mk = |caps: &[&str]| ModelEntry {
1037 provider: "x".into(),
1038 model: "m".into(),
1039 capabilities: caps.iter().map(|s| s.to_string()).collect(),
1040 ..Default::default()
1041 };
1042 assert!(satisfies(&mk(&[]), &[]));
1044 assert!(satisfies(&mk(&["chat"]), &[]));
1045 assert!(!satisfies(&mk(&["embedding"]), &[]));
1046 assert!(!satisfies(&mk(&[]), &[Requirement::Vision]));
1048 assert!(!satisfies(&mk(&["chat"]), &[Requirement::Vision]));
1049 assert!(satisfies(&mk(&["chat", "vision"]), &[Requirement::Vision]));
1050 assert!(!satisfies(&mk(&["chat", "vision"]), &[Requirement::Tools]));
1051 assert!(satisfies(
1052 &mk(&["chat", "vision", "tools"]),
1053 &[Requirement::Vision, Requirement::Tools]
1054 ));
1055 }
1056
1057 #[test]
1064 fn undeclared_capabilities_pass_tools_but_never_vision() {
1065 let mk = |caps: &[&str]| ModelEntry {
1066 provider: "x".into(),
1067 model: "m".into(),
1068 capabilities: caps.iter().map(|s| s.to_string()).collect(),
1069 ..Default::default()
1070 };
1071 assert!(satisfies(&mk(&[]), &[Requirement::Tools]));
1073 assert!(!satisfies(&mk(&[]), &[Requirement::Vision]));
1074 assert!(!satisfies(
1075 &mk(&[]),
1076 &[Requirement::Vision, Requirement::Tools]
1077 ));
1078 assert!(!satisfies(&mk(&["chat"]), &[Requirement::Tools]));
1080 assert!(satisfies(&mk(&["chat", "tools"]), &[Requirement::Tools]));
1081 }
1082
1083 #[test]
1086 fn pick_cheap_model_declines_when_no_entry_declares_the_requirement() {
1087 let mk = |cost: f64, caps: &[&str]| ModelEntry {
1088 provider: "x".into(),
1089 model: "m".into(),
1090 capabilities: caps.iter().map(|s| s.to_string()).collect(),
1091 cost_per_1k_tokens: Some(cost),
1092 ..Default::default()
1093 };
1094 let mut reg = ModelRegistry::default();
1095 reg.models
1096 .insert("cheap_text".into(), mk(0.0001, &["chat"]));
1097 reg.models.insert("legacy".into(), mk(0.0002, &[]));
1098 reg.models
1099 .insert("frontier".into(), mk(0.01, &["chat", "vision"]));
1100 assert_eq!(pick_cheap_model(®, None, &[]), Some("cheap_text".into()));
1102 assert_eq!(
1104 pick_cheap_model(®, None, &[Requirement::Vision]),
1105 Some("frontier".into())
1106 );
1107 let mut blind = ModelRegistry::default();
1109 blind
1110 .models
1111 .insert("cheap_text".into(), mk(0.0001, &["chat"]));
1112 blind.models.insert("legacy".into(), mk(0.0002, &[]));
1113 assert_eq!(pick_cheap_model(&blind, None, &[Requirement::Vision]), None);
1114 }
1115
1116 #[test]
1120 fn priced_at_stamps_only_priced_entries() {
1121 let now = chrono::Utc::now();
1122
1123 let mut unpriced = ModelEntry {
1124 provider: "openai".into(),
1125 model: "local-thing".into(),
1126 ..Default::default()
1127 };
1128 unpriced.stamp_priced_at(now);
1129 assert_eq!(unpriced.priced_at, None);
1130 assert_eq!(unpriced.price_age(now), None);
1131
1132 let mut priced = ModelEntry {
1133 output_cost_per_1k: Some(0.025),
1134 ..unpriced.clone()
1135 };
1136 priced.stamp_priced_at(now);
1137 assert_eq!(priced.priced_at, Some(now));
1138
1139 let mut legacy = ModelEntry {
1141 cost_per_1k_tokens: Some(0.01),
1142 ..unpriced.clone()
1143 };
1144 legacy.stamp_priced_at(now);
1145 assert!(legacy.priced_at.is_some());
1146
1147 let earlier = now - chrono::TimeDelta::days(30);
1149 priced.stamp_priced_at(earlier);
1150 assert_eq!(priced.priced_at, Some(now));
1151 }
1152
1153 #[test]
1156 fn registry_without_priced_at_still_loads_and_reports_unknown_age() {
1157 let yaml = r#"
1158schema_version: 1
1159models:
1160 opus:
1161 provider: anthropic
1162 model: claude-opus-5
1163 input_cost_per_1k: 0.005
1164 output_cost_per_1k: 0.025
1165"#;
1166 let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
1167 let e = ®.models["opus"];
1168 assert_eq!(e.priced_at, None);
1169 assert_eq!(e.price_age(chrono::Utc::now()), None);
1170 let out = serde_yaml_ng::to_string(®).unwrap();
1172 assert!(!out.contains("priced_at"), "{out}");
1173 }
1174
1175 #[test]
1178 fn pick_cheap_model_sees_split_cost_entries() {
1179 let mut reg = ModelRegistry::default();
1180 let split = |input: f64, output: f64| ModelEntry {
1181 provider: "x".into(),
1182 model: "m".into(),
1183 capabilities: vec!["chat".into()],
1184 input_cost_per_1k: Some(input),
1185 output_cost_per_1k: Some(output),
1186 ..Default::default()
1187 };
1188 reg.models.insert("dear".into(), split(0.005, 0.025));
1189 reg.models.insert("cheap".into(), split(0.0001, 0.0004));
1190 assert_eq!(pick_cheap_model(®, None, &[]), Some("cheap".into()));
1191
1192 let mut input_only = ModelRegistry::default();
1194 input_only.models.insert(
1195 "in".into(),
1196 ModelEntry {
1197 provider: "x".into(),
1198 model: "m".into(),
1199 capabilities: vec!["chat".into()],
1200 input_cost_per_1k: Some(0.002),
1201 ..Default::default()
1202 },
1203 );
1204 assert_eq!(pick_cheap_model(&input_only, None, &[]), Some("in".into()));
1205 }
1206
1207 #[test]
1208 fn subscription_metadata_round_trips_without_a_secret() {
1209 let yaml = r#"schema_version: 1
1210models:
1211 chatgpt_sol:
1212 provider: codex
1213 model: gpt-5.6-sol
1214 base_url: http://127.0.0.1:8088/codex/v1
1215 tier: frontier
1216 billing: subscription
1217 catalog_verified: true
1218"#;
1219 let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
1220 let entry = ®.models["chatgpt_sol"];
1221 assert_eq!(entry.billing, Some(BillingMode::Subscription));
1222 assert_eq!(entry.catalog_verified, Some(true));
1223 assert!(entry.secret.is_none());
1224 let out = serde_yaml_ng::to_string(®).unwrap();
1225 assert!(out.contains("billing: subscription"), "{out}");
1226 assert!(out.contains("catalog_verified: true"), "{out}");
1227 }
1228
1229 #[test]
1236 fn provider_inference_and_route_tier_cover_existing_aliases() {
1237 for provider in [
1238 "ollama",
1239 "mlx",
1240 "llamacpp",
1241 "llama_cpp",
1242 "localai",
1243 "lmstudio",
1244 "local",
1245 ] {
1246 assert_eq!(
1247 inferred_billing_for_provider(provider),
1248 BillingMode::Local,
1249 "{provider}"
1250 );
1251 let entry = ModelEntry {
1252 provider: provider.into(),
1253 ..Default::default()
1254 };
1255 assert_eq!(entry.effective_route_tier(), RouteTier::Local, "{provider}");
1256 }
1257 for provider in ["claude", "codex"] {
1258 assert_eq!(
1259 inferred_billing_for_provider(provider),
1260 BillingMode::Subscription
1261 );
1262 }
1263 assert_eq!(
1264 inferred_billing_for_provider("unknown"),
1265 BillingMode::UsageBilled
1266 );
1267 }
1268
1269 #[test]
1270 fn projected_cost_uses_four_to_one_workload_and_legacy_rates() {
1271 let split = ModelEntry {
1272 input_cost_per_1k: Some(1.0),
1273 output_cost_per_1k: Some(10.0),
1274 ..Default::default()
1275 };
1276 assert_eq!(split.projected_cost(4_000, 1_000), Some(14.0));
1277 let legacy = ModelEntry {
1278 cost_per_1k_tokens: Some(2.0),
1279 ..Default::default()
1280 };
1281 assert_eq!(legacy.projected_cost(4_000, 1_000), Some(10.0));
1282 assert_eq!(ModelEntry::default().projected_cost(4_000, 1_000), None);
1283 }
1284
1285 #[test]
1286 fn pick_cheap_model_uses_projected_four_to_one_cost() {
1287 let mut reg = ModelRegistry::default();
1288 reg.models.insert(
1289 "cheap_input".into(),
1290 ModelEntry {
1291 provider: "openai".into(),
1292 model: "a".into(),
1293 input_cost_per_1k: Some(0.1),
1294 output_cost_per_1k: Some(2.0),
1295 ..Default::default()
1296 },
1297 );
1298 reg.models.insert(
1299 "cheap_output".into(),
1300 ModelEntry {
1301 provider: "openai".into(),
1302 model: "b".into(),
1303 input_cost_per_1k: Some(1.0),
1304 output_cost_per_1k: Some(0.1),
1305 ..Default::default()
1306 },
1307 );
1308 assert_eq!(
1309 pick_cheap_model(®, None, &[]),
1310 Some("cheap_input".into())
1311 );
1312 }
1313
1314 #[test]
1315 fn billing_is_inferred_from_the_provider_when_not_declared() {
1316 let mut e = ModelEntry {
1317 provider: "ollama".into(),
1318 model: "llama3.2:3b".into(),
1319 ..Default::default()
1320 };
1321 assert_eq!(e.billing_or_inferred(), BillingMode::Local);
1322 e.provider = "codex".into();
1323 assert_eq!(e.billing_or_inferred(), BillingMode::Subscription);
1324 e.provider = "claude".into();
1325 assert_eq!(e.billing_or_inferred(), BillingMode::Subscription);
1326 e.provider = "openai".into();
1327 assert_eq!(
1328 e.billing_or_inferred(),
1329 BillingMode::UsageBilled,
1330 "unknown is metered"
1331 );
1332 e.provider = "anthropic".into();
1333 assert_eq!(e.billing_or_inferred(), BillingMode::UsageBilled);
1334 e.billing = Some(BillingMode::Local);
1337 assert_eq!(e.billing_or_inferred(), BillingMode::Local);
1338 }
1339
1340 #[test]
1341 fn entry_without_billing_metadata_stays_unknown() {
1342 let yaml = r#"schema_version: 1
1343models:
1344 gpt:
1345 provider: openai
1346 model: gpt-4o
1347 secret: env:OPENAI_API_KEY
1348"#;
1349 let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
1350 let entry = ®.models["gpt"];
1351 assert_eq!(entry.billing, None);
1352 assert_eq!(entry.catalog_verified, None);
1353 let out = serde_yaml_ng::to_string(®).unwrap();
1354 assert!(!out.contains("billing"), "{out}");
1355 assert!(!out.contains("catalog_verified"), "{out}");
1356 for (raw, mode) in [
1357 ("subscription", BillingMode::Subscription),
1358 ("usage_billed", BillingMode::UsageBilled),
1359 ("local", BillingMode::Local),
1360 ] {
1361 let m: BillingMode = serde_yaml_ng::from_str(raw).unwrap();
1362 assert_eq!(m, mode);
1363 }
1364 }
1365}