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, Serialize, Deserialize, PartialEq, Default)]
22pub struct ModelEntry {
23 #[serde(default)]
24 pub provider: String,
25 #[serde(default)]
26 pub model: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub base_url: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub secret: Option<SecretRef>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub capabilities: Vec<String>,
33 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
34 pub params: serde_json::Value,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub tier: Option<RouteTier>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub cost_per_1k_tokens: Option<f64>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub input_cost_per_1k: Option<f64>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub output_cost_per_1k: Option<f64>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub context_window: Option<u64>,
54}
55
56impl ModelEntry {
57 pub fn effective_costs(&self) -> (Option<f64>, Option<f64>) {
62 let output = self.output_cost_per_1k.or(self.cost_per_1k_tokens);
63 let input = self.input_cost_per_1k.or(self.cost_per_1k_tokens);
64 (input, output)
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
69pub struct RoleEntry {
70 pub primary: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub fallback: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub cost_budget_per_day_usd: Option<f64>,
78 #[serde(default)]
80 pub privacy_local_only: bool,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub route_policy: Option<RoutePolicy>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct ModelRegistry {
89 pub schema_version: u32,
90 #[serde(default)]
91 pub models: BTreeMap<String, ModelEntry>,
92 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
93 pub roles: BTreeMap<String, RoleEntry>,
94}
95
96impl Default for ModelRegistry {
97 fn default() -> Self {
98 Self {
99 schema_version: 1,
100 models: BTreeMap::new(),
101 roles: BTreeMap::new(),
102 }
103 }
104}
105
106impl ModelRegistry {
107 pub fn load_from(path: &Path) -> anyhow::Result<Self> {
108 if !path.exists() {
109 return Ok(Self::default());
110 }
111 let body = std::fs::read_to_string(path)?;
112 if body.trim().is_empty() {
113 return Ok(Self::default());
114 }
115 Ok(serde_yaml_ng::from_str(&body)?)
116 }
117
118 pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
119 if let Some(parent) = path.parent() {
120 std::fs::create_dir_all(parent)?;
121 }
122 let body = serde_yaml_ng::to_string(self)?;
123 let tmp = path.with_extension("yaml.tmp");
124 std::fs::write(&tmp, body)?;
125 std::fs::rename(&tmp, path)?;
126 Ok(())
127 }
128
129 pub fn default_path() -> anyhow::Result<PathBuf> {
130 if let Ok(p) = std::env::var("MUR_HOME")
133 && !p.is_empty()
134 {
135 return Ok(PathBuf::from(p).join("models.yaml"));
136 }
137 let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
138 Ok(home.join(".mur/models.yaml"))
139 }
140
141 pub fn resolve_role(&self, role: &str) -> Option<&str> {
144 let entry = self.roles.get(role)?;
145 if self.models.contains_key(&entry.primary) {
146 return Some(&entry.primary);
147 }
148 if let Some(fb) = &entry.fallback
150 && self.models.contains_key(fb)
151 {
152 return Some(fb);
153 }
154 None
156 }
157}
158
159use crate::agent::AgentProfile;
160use crate::config::{DEFAULT_ROUTING_THRESHOLD, ModelSwitchConfig, RoutingConfig};
161
162pub fn resolve_model_refs(
167 profile: &AgentProfile,
168 cfg: &ModelSwitchConfig,
169 routed_primary: Option<String>,
170) -> Vec<String> {
171 let primary = routed_primary
172 .or_else(|| profile.model_ref.clone())
173 .or_else(|| cfg.default.clone());
174 let chain = if !profile.fallback_chain.is_empty() {
175 profile.fallback_chain.clone()
176 } else {
177 cfg.fallback_chain.clone()
178 };
179 let mut out: Vec<String> = Vec::new();
180 if let Some(p) = primary {
181 out.push(p);
182 }
183 for r in chain {
184 if !out.contains(&r) {
185 out.push(r);
186 }
187 }
188 out
189}
190
191pub fn choose_by_difficulty(est_input_tokens: u32, r: &RoutingConfig) -> Option<String> {
195 let threshold = r
196 .threshold_input_tokens
197 .unwrap_or(DEFAULT_ROUTING_THRESHOLD);
198 match (r.cheap.as_ref(), r.frontier.as_ref()) {
199 (Some(cheap), Some(frontier)) => Some(if est_input_tokens > threshold {
200 frontier.clone()
201 } else {
202 cheap.clone()
203 }),
204 _ => None,
205 }
206}
207
208pub fn pick_cheap_model(reg: &ModelRegistry, exclude: Option<&str>) -> Option<String> {
213 reg.models
214 .iter()
215 .filter(|(k, _)| exclude != Some(k.as_str()))
216 .filter(|(_, e)| e.capabilities.is_empty() || e.capabilities.iter().any(|c| c == "chat"))
217 .filter_map(|(k, e)| e.cost_per_1k_tokens.map(|c| (c, k.clone())))
218 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
219 .map(|(_, k)| k)
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn parses_full_registry() {
228 let yaml = r#"
229schema_version: 1
230models:
231 anthropic_opus_4_7:
232 provider: anthropic
233 model: claude-opus-4-7
234 secret: env:ANTHROPIC_API_KEY
235 capabilities: [chat, tools]
236 ollama_llama3:
237 provider: ollama
238 model: llama3.2:3b
239 base_url: http://127.0.0.1:11434
240"#;
241 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
242 assert_eq!(r.schema_version, 1);
243 assert_eq!(r.models.len(), 2);
244 let opus = r.models.get("anthropic_opus_4_7").unwrap();
245 assert_eq!(opus.provider, "anthropic");
246 assert_eq!(
247 opus.secret,
248 Some(SecretRef::Env("ANTHROPIC_API_KEY".into()))
249 );
250 assert!(r.models["ollama_llama3"].secret.is_none());
251 }
252
253 #[test]
254 fn round_trip_preserves_shape() {
255 let mut r = ModelRegistry::default();
256 r.models.insert(
257 "foo".into(),
258 ModelEntry {
259 provider: "anthropic".into(),
260 model: "claude-opus-4-7".into(),
261 base_url: None,
262 secret: Some(SecretRef::Keychain {
263 service: "mur".into(),
264 account: "anthropic".into(),
265 }),
266 capabilities: vec!["chat".into()],
267 params: serde_json::Value::Null,
268 tier: None,
269 cost_per_1k_tokens: None,
270 input_cost_per_1k: None,
271 output_cost_per_1k: None,
272 context_window: None,
273 },
274 );
275 let s = serde_yaml_ng::to_string(&r).unwrap();
276 let parsed: ModelRegistry = serde_yaml_ng::from_str(&s).unwrap();
277 assert_eq!(r, parsed);
278 }
279
280 #[test]
281 fn rejects_unknown_secret_scheme() {
282 let yaml = r#"
283schema_version: 1
284models:
285 bad:
286 provider: x
287 model: y
288 secret: bogus:value
289"#;
290 let r: Result<ModelRegistry, _> = serde_yaml_ng::from_str(yaml);
291 assert!(r.is_err(), "should reject unknown scheme");
292 }
293
294 #[test]
295 fn test_registry_roundtrip_with_roles() {
296 let yaml = r#"
297schema_version: 1
298models:
299 haiku:
300 provider: anthropic
301 model: claude-haiku-4-5
302roles:
303 reflector:
304 primary: haiku
305 fallback: null
306 cost_budget_per_day_usd: 0.5
307"#;
308 let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
309 assert_eq!(reg.roles["reflector"].primary, "haiku");
310 let back = serde_yaml_ng::to_string(®).unwrap();
311 let reg2: ModelRegistry = serde_yaml_ng::from_str(&back).unwrap();
312 assert_eq!(reg, reg2);
313 }
314
315 #[test]
316 fn test_resolve_role_primary() {
317 let mut reg = ModelRegistry::default();
318 reg.models.insert(
319 "haiku".into(),
320 ModelEntry {
321 provider: "anthropic".into(),
322 model: "claude-haiku-4-5".into(),
323 base_url: None,
324 secret: None,
325 capabilities: vec![],
326 params: serde_json::Value::Null,
327 tier: None,
328 cost_per_1k_tokens: None,
329 input_cost_per_1k: None,
330 output_cost_per_1k: None,
331 context_window: None,
332 },
333 );
334 reg.roles.insert(
335 "reflector".into(),
336 RoleEntry {
337 primary: "haiku".into(),
338 fallback: None,
339 ..Default::default()
340 },
341 );
342 assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
343 }
344
345 #[test]
346 fn test_resolve_role_fallback() {
347 let mut reg = ModelRegistry::default();
348 reg.models.insert(
349 "haiku".into(),
350 ModelEntry {
351 provider: "anthropic".into(),
352 model: "claude-haiku-4-5".into(),
353 base_url: None,
354 secret: None,
355 capabilities: vec![],
356 params: serde_json::Value::Null,
357 tier: None,
358 cost_per_1k_tokens: None,
359 input_cost_per_1k: None,
360 output_cost_per_1k: None,
361 context_window: None,
362 },
363 );
364 reg.roles.insert(
365 "reflector".into(),
366 RoleEntry {
367 primary: "nonexistent".into(),
368 fallback: Some("haiku".into()),
369 ..Default::default()
370 },
371 );
372 assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
373 }
374
375 #[test]
376 fn test_resolve_role_none() {
377 let reg = ModelRegistry::default();
378 assert_eq!(reg.resolve_role("reflector"), None);
379 }
380
381 #[test]
382 fn model_entry_parses_tier_field() {
383 let yaml = r#"
384schema_version: 1
385models:
386 haiku:
387 provider: anthropic
388 model: claude-haiku-4-5
389 tier: local
390 opus:
391 provider: anthropic
392 model: claude-opus-4-7
393 tier: frontier
394 cost_per_1k_tokens: 0.015
395"#;
396 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
397 assert_eq!(r.models["haiku"].tier, Some(RouteTier::Local));
398 assert_eq!(r.models["opus"].tier, Some(RouteTier::Frontier));
399 assert_eq!(r.models["opus"].cost_per_1k_tokens, Some(0.015));
400 let mut r2 = ModelRegistry::default();
402 r2.models.insert(
403 "x".into(),
404 ModelEntry {
405 provider: "ollama".into(),
406 model: "llama3".into(),
407 base_url: None,
408 secret: None,
409 capabilities: vec![],
410 params: serde_json::Value::Null,
411 tier: None,
412 cost_per_1k_tokens: None,
413 input_cost_per_1k: None,
414 output_cost_per_1k: None,
415 context_window: None,
416 },
417 );
418 let yaml = serde_yaml_ng::to_string(&r2).unwrap();
419 assert!(
420 !yaml.contains("tier:"),
421 "absent tier should not be serialized: {yaml}"
422 );
423 }
424
425 #[test]
426 fn role_entry_parses_route_policy() {
427 let yaml = r#"
428schema_version: 1
429models:
430 haiku:
431 provider: anthropic
432 model: claude-haiku-4-5
433 opus:
434 provider: anthropic
435 model: claude-opus-4-7
436roles:
437 dev:
438 primary: opus
439 route_policy: !force_frontier
440 model_id: opus
441 reflector:
442 primary: haiku
443 route_policy: prefer_local
444 curator:
445 primary: haiku
446 route_policy: force_local
447 chat:
448 primary: haiku
449"#;
450 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
451 assert_eq!(
452 r.roles["dev"].route_policy,
453 Some(RoutePolicy::ForceFrontier {
454 model_id: "opus".into()
455 })
456 );
457 assert_eq!(
458 r.roles["reflector"].route_policy,
459 Some(RoutePolicy::PreferLocal)
460 );
461 assert_eq!(
462 r.roles["curator"].route_policy,
463 Some(RoutePolicy::ForceLocal)
464 );
465 assert_eq!(r.roles["chat"].route_policy, None);
466 }
467
468 #[test]
469 fn parses_split_cost_fields() {
470 let yaml = r#"
471schema_version: 1
472models:
473 opus:
474 provider: anthropic
475 model: claude-opus-4-8
476 input_cost_per_1k: 0.005
477 output_cost_per_1k: 0.025
478 context_window: 200000
479"#;
480 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
481 let e = r.models.get("opus").unwrap();
482 assert_eq!(e.input_cost_per_1k, Some(0.005));
483 assert_eq!(e.output_cost_per_1k, Some(0.025));
484 assert_eq!(e.context_window, Some(200_000));
485 }
486
487 #[test]
488 fn default_model_entry_is_empty() {
489 let e = ModelEntry::default();
490 assert!(e.provider.is_empty());
491 assert_eq!(e.input_cost_per_1k, None);
492 assert_eq!(e.output_cost_per_1k, None);
493 assert_eq!(e.context_window, None);
494 }
495
496 #[test]
497 fn effective_costs_fallback_matrix() {
498 let mut e = ModelEntry {
500 cost_per_1k_tokens: Some(0.01),
501 ..Default::default()
502 };
503 assert_eq!(e.effective_costs(), (Some(0.01), Some(0.01)));
504
505 e = ModelEntry {
507 input_cost_per_1k: Some(0.005),
508 output_cost_per_1k: Some(0.025),
509 ..Default::default()
510 };
511 assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
512
513 e = ModelEntry {
515 cost_per_1k_tokens: Some(0.01),
516 input_cost_per_1k: Some(0.005),
517 output_cost_per_1k: Some(0.025),
518 ..Default::default()
519 };
520 assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
521
522 e = ModelEntry::default();
524 assert_eq!(e.effective_costs(), (None, None));
525 }
526}
527
528#[cfg(test)]
529mod io_tests {
530 use super::*;
531 use tempfile::tempdir;
532
533 #[test]
534 fn load_returns_empty_when_file_missing() {
535 let dir = tempdir().unwrap();
536 let r = ModelRegistry::load_from(&dir.path().join("nope.yaml")).unwrap();
537 assert_eq!(r.models.len(), 0);
538 assert_eq!(r.schema_version, 1);
539 }
540
541 #[test]
542 fn save_then_load_round_trips() {
543 let dir = tempdir().unwrap();
544 let p = dir.path().join("models.yaml");
545 let mut r = ModelRegistry::default();
546 r.models.insert(
547 "x".into(),
548 ModelEntry {
549 provider: "ollama".into(),
550 model: "llama3.2:3b".into(),
551 base_url: None,
552 secret: None,
553 capabilities: vec![],
554 params: serde_json::Value::Null,
555 tier: None,
556 cost_per_1k_tokens: None,
557 input_cost_per_1k: None,
558 output_cost_per_1k: None,
559 context_window: None,
560 },
561 );
562 r.save_to(&p).unwrap();
563 let r2 = ModelRegistry::load_from(&p).unwrap();
564 assert_eq!(r, r2);
565 }
566
567 #[test]
568 fn save_uses_atomic_rename() {
569 let dir = tempdir().unwrap();
570 let p = dir.path().join("models.yaml");
571 ModelRegistry::default().save_to(&p).unwrap();
572 let temp = dir.path().join("models.yaml.tmp");
573 assert!(!temp.exists(), "atomic temp left behind");
574 }
575}
576
577#[cfg(test)]
578mod switch_tests {
579 use super::*;
580 use crate::agent::AgentProfile;
581 use crate::config::{ModelSwitchConfig, RoutingConfig};
582
583 fn profile(model_ref: Option<&str>, chain: &[&str]) -> AgentProfile {
584 let mut p = AgentProfile::default_for_tests();
585 p.model_ref = model_ref.map(|s| s.to_string());
586 p.fallback_chain = chain.iter().map(|s| s.to_string()).collect();
587 p
588 }
589
590 #[test]
591 fn per_agent_primary_and_chain_win_over_global() {
592 let cfg = ModelSwitchConfig {
593 default: Some("global_default".into()),
594 fallback_chain: vec!["g1".into(), "g2".into()],
595 ..Default::default()
596 };
597 let p = profile(Some("agent_primary"), &["agent_primary", "agent_fb"]);
598 assert_eq!(
600 resolve_model_refs(&p, &cfg, None),
601 vec!["agent_primary", "agent_fb"]
602 );
603 }
604
605 #[test]
606 fn falls_back_to_global_default_and_chain() {
607 let cfg = ModelSwitchConfig {
608 default: Some("global_default".into()),
609 fallback_chain: vec!["g1".into(), "global_default".into()],
610 ..Default::default()
611 };
612 let p = profile(None, &[]); assert_eq!(
615 resolve_model_refs(&p, &cfg, None),
616 vec!["global_default", "g1"]
617 );
618 }
619
620 #[test]
621 fn routed_primary_overrides_model_ref() {
622 let cfg = ModelSwitchConfig {
623 fallback_chain: vec!["g1".into()],
624 ..Default::default()
625 };
626 let p = profile(Some("agent_primary"), &[]);
627 assert_eq!(
628 resolve_model_refs(&p, &cfg, Some("frontier".into())),
629 vec!["frontier", "g1"]
630 );
631 }
632
633 #[test]
634 fn no_config_no_agent_yields_empty() {
635 let cfg = ModelSwitchConfig::default();
637 assert!(resolve_model_refs(&profile(None, &[]), &cfg, None).is_empty());
638 }
639
640 #[test]
641 fn difficulty_picks_frontier_over_threshold() {
642 let r = RoutingConfig {
643 enabled: true,
644 cheap: Some("cheap".into()),
645 frontier: Some("frontier".into()),
646 threshold_input_tokens: Some(1000),
647 ..Default::default()
648 };
649 assert_eq!(choose_by_difficulty(1500, &r), Some("frontier".into()));
650 assert_eq!(choose_by_difficulty(500, &r), Some("cheap".into()));
651 let bad = RoutingConfig {
653 enabled: true,
654 cheap: Some("c".into()),
655 frontier: None,
656 threshold_input_tokens: None,
657 ..Default::default()
658 };
659 assert_eq!(choose_by_difficulty(9999, &bad), None);
660 }
661
662 #[test]
663 fn pick_cheap_model_lowest_cost_chat_excluding_primary() {
664 let mut reg = ModelRegistry::default();
665 let mk = |cost: f64, caps: &[&str]| ModelEntry {
666 provider: "x".into(),
667 model: "m".into(),
668 capabilities: caps.iter().map(|s| s.to_string()).collect(),
669 cost_per_1k_tokens: Some(cost),
670 ..Default::default()
671 };
672 reg.models.insert("frontier".into(), mk(0.01, &["chat"]));
673 reg.models.insert("cheap".into(), mk(0.0001, &["chat"]));
674 reg.models
675 .insert("embed".into(), mk(0.00001, &["embedding"])); assert_eq!(
678 pick_cheap_model(®, Some("cheap")),
679 Some("frontier".into())
680 ); assert_eq!(pick_cheap_model(®, None), Some("cheap".into()));
682 let mut empty = ModelRegistry::default();
684 empty.models.insert("e".into(), mk(0.0, &["embedding"]));
685 assert_eq!(pick_cheap_model(&empty, None), None);
686 }
687}