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 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub priced_at: Option<chrono::DateTime<chrono::Utc>>,
62}
63
64impl ModelEntry {
65 pub fn effective_costs(&self) -> (Option<f64>, Option<f64>) {
70 let output = self.output_cost_per_1k.or(self.cost_per_1k_tokens);
71 let input = self.input_cost_per_1k.or(self.cost_per_1k_tokens);
72 (input, output)
73 }
74
75 pub fn is_priced(&self) -> bool {
77 let (input, output) = self.effective_costs();
78 input.is_some() || output.is_some()
79 }
80
81 pub fn stamp_priced_at(&mut self, now: chrono::DateTime<chrono::Utc>) {
85 if self.is_priced() && self.priced_at.is_none_or(|prev| prev < now) {
86 self.priced_at = Some(now);
87 }
88 }
89
90 pub fn price_age(&self, now: chrono::DateTime<chrono::Utc>) -> Option<chrono::TimeDelta> {
92 self.priced_at.map(|at| now - at)
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
97pub struct RoleEntry {
98 pub primary: String,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub fallback: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub cost_budget_per_day_usd: Option<f64>,
106 #[serde(default)]
108 pub privacy_local_only: bool,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub route_policy: Option<RoutePolicy>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct ModelRegistry {
117 pub schema_version: u32,
118 #[serde(default)]
119 pub models: BTreeMap<String, ModelEntry>,
120 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
121 pub roles: BTreeMap<String, RoleEntry>,
122}
123
124impl Default for ModelRegistry {
125 fn default() -> Self {
126 Self {
127 schema_version: 1,
128 models: BTreeMap::new(),
129 roles: BTreeMap::new(),
130 }
131 }
132}
133
134impl ModelRegistry {
135 pub fn load_from(path: &Path) -> anyhow::Result<Self> {
136 if !path.exists() {
137 return Ok(Self::default());
138 }
139 let body = std::fs::read_to_string(path)?;
140 if body.trim().is_empty() {
141 return Ok(Self::default());
142 }
143 Ok(serde_yaml_ng::from_str(&body)?)
144 }
145
146 pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
147 if let Some(parent) = path.parent() {
148 std::fs::create_dir_all(parent)?;
149 }
150 let body = serde_yaml_ng::to_string(self)?;
151 let tmp = path.with_extension("yaml.tmp");
152 std::fs::write(&tmp, body)?;
153 std::fs::rename(&tmp, path)?;
154 Ok(())
155 }
156
157 pub fn default_path() -> anyhow::Result<PathBuf> {
158 if let Ok(p) = std::env::var("MUR_HOME")
161 && !p.is_empty()
162 {
163 return Ok(PathBuf::from(p).join("models.yaml"));
164 }
165 let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
166 Ok(home.join(".mur/models.yaml"))
167 }
168
169 pub fn resolve_role(&self, role: &str) -> Option<&str> {
172 let entry = self.roles.get(role)?;
173 if self.models.contains_key(&entry.primary) {
174 return Some(&entry.primary);
175 }
176 if let Some(fb) = &entry.fallback
178 && self.models.contains_key(fb)
179 {
180 return Some(fb);
181 }
182 None
184 }
185}
186
187use crate::agent::AgentProfile;
188use crate::config::{DEFAULT_ROUTING_THRESHOLD, ModelSwitchConfig, RoutingConfig};
189
190pub fn resolve_model_refs(
195 profile: &AgentProfile,
196 cfg: &ModelSwitchConfig,
197 routed_primary: Option<String>,
198) -> Vec<String> {
199 let primary = routed_primary
200 .or_else(|| profile.model_ref.clone())
201 .or_else(|| cfg.default.clone());
202 let chain = if !profile.fallback_chain.is_empty() {
203 profile.fallback_chain.clone()
204 } else {
205 cfg.fallback_chain.clone()
206 };
207 let mut out: Vec<String> = Vec::new();
208 if let Some(p) = primary {
209 out.push(p);
210 }
211 for r in chain {
212 if !out.contains(&r) {
213 out.push(r);
214 }
215 }
216 out
217}
218
219pub fn choose_by_difficulty(est_input_tokens: u32, r: &RoutingConfig) -> Option<String> {
223 let threshold = r
224 .threshold_input_tokens
225 .unwrap_or(DEFAULT_ROUTING_THRESHOLD);
226 match (r.cheap.as_ref(), r.frontier.as_ref()) {
227 (Some(cheap), Some(frontier)) => Some(if est_input_tokens > threshold {
228 frontier.clone()
229 } else {
230 cheap.clone()
231 }),
232 _ => None,
233 }
234}
235
236pub fn pick_cheap_model(reg: &ModelRegistry, exclude: Option<&str>) -> Option<String> {
241 reg.models
242 .iter()
243 .filter(|(k, _)| exclude != Some(k.as_str()))
244 .filter(|(_, e)| e.capabilities.is_empty() || e.capabilities.iter().any(|c| c == "chat"))
245 .filter_map(|(k, e)| {
246 let (input, output) = e.effective_costs();
250 output.or(input).map(|c| (c, k.clone()))
251 })
252 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
253 .map(|(_, k)| k)
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn parses_full_registry() {
262 let yaml = r#"
263schema_version: 1
264models:
265 anthropic_opus_4_7:
266 provider: anthropic
267 model: claude-opus-4-7
268 secret: env:ANTHROPIC_API_KEY
269 capabilities: [chat, tools]
270 ollama_llama3:
271 provider: ollama
272 model: llama3.2:3b
273 base_url: http://127.0.0.1:11434
274"#;
275 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
276 assert_eq!(r.schema_version, 1);
277 assert_eq!(r.models.len(), 2);
278 let opus = r.models.get("anthropic_opus_4_7").unwrap();
279 assert_eq!(opus.provider, "anthropic");
280 assert_eq!(
281 opus.secret,
282 Some(SecretRef::Env("ANTHROPIC_API_KEY".into()))
283 );
284 assert!(r.models["ollama_llama3"].secret.is_none());
285 }
286
287 #[test]
288 fn round_trip_preserves_shape() {
289 let mut r = ModelRegistry::default();
290 r.models.insert(
291 "foo".into(),
292 ModelEntry {
293 provider: "anthropic".into(),
294 model: "claude-opus-4-7".into(),
295 base_url: None,
296 secret: Some(SecretRef::Keychain {
297 service: "mur".into(),
298 account: "anthropic".into(),
299 }),
300 capabilities: vec!["chat".into()],
301 params: serde_json::Value::Null,
302 tier: None,
303 cost_per_1k_tokens: None,
304 input_cost_per_1k: None,
305 output_cost_per_1k: None,
306 context_window: None,
307 priced_at: None,
308 },
309 );
310 let s = serde_yaml_ng::to_string(&r).unwrap();
311 let parsed: ModelRegistry = serde_yaml_ng::from_str(&s).unwrap();
312 assert_eq!(r, parsed);
313 }
314
315 #[test]
316 fn rejects_unknown_secret_scheme() {
317 let yaml = r#"
318schema_version: 1
319models:
320 bad:
321 provider: x
322 model: y
323 secret: bogus:value
324"#;
325 let r: Result<ModelRegistry, _> = serde_yaml_ng::from_str(yaml);
326 assert!(r.is_err(), "should reject unknown scheme");
327 }
328
329 #[test]
330 fn test_registry_roundtrip_with_roles() {
331 let yaml = r#"
332schema_version: 1
333models:
334 haiku:
335 provider: anthropic
336 model: claude-haiku-4-5
337roles:
338 reflector:
339 primary: haiku
340 fallback: null
341 cost_budget_per_day_usd: 0.5
342"#;
343 let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
344 assert_eq!(reg.roles["reflector"].primary, "haiku");
345 let back = serde_yaml_ng::to_string(®).unwrap();
346 let reg2: ModelRegistry = serde_yaml_ng::from_str(&back).unwrap();
347 assert_eq!(reg, reg2);
348 }
349
350 #[test]
351 fn test_resolve_role_primary() {
352 let mut reg = ModelRegistry::default();
353 reg.models.insert(
354 "haiku".into(),
355 ModelEntry {
356 provider: "anthropic".into(),
357 model: "claude-haiku-4-5".into(),
358 base_url: None,
359 secret: None,
360 capabilities: vec![],
361 params: serde_json::Value::Null,
362 tier: None,
363 cost_per_1k_tokens: None,
364 input_cost_per_1k: None,
365 output_cost_per_1k: None,
366 context_window: None,
367 priced_at: None,
368 },
369 );
370 reg.roles.insert(
371 "reflector".into(),
372 RoleEntry {
373 primary: "haiku".into(),
374 fallback: None,
375 ..Default::default()
376 },
377 );
378 assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
379 }
380
381 #[test]
382 fn test_resolve_role_fallback() {
383 let mut reg = ModelRegistry::default();
384 reg.models.insert(
385 "haiku".into(),
386 ModelEntry {
387 provider: "anthropic".into(),
388 model: "claude-haiku-4-5".into(),
389 base_url: None,
390 secret: None,
391 capabilities: vec![],
392 params: serde_json::Value::Null,
393 tier: None,
394 cost_per_1k_tokens: None,
395 input_cost_per_1k: None,
396 output_cost_per_1k: None,
397 context_window: None,
398 priced_at: None,
399 },
400 );
401 reg.roles.insert(
402 "reflector".into(),
403 RoleEntry {
404 primary: "nonexistent".into(),
405 fallback: Some("haiku".into()),
406 ..Default::default()
407 },
408 );
409 assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
410 }
411
412 #[test]
413 fn test_resolve_role_none() {
414 let reg = ModelRegistry::default();
415 assert_eq!(reg.resolve_role("reflector"), None);
416 }
417
418 #[test]
419 fn model_entry_parses_tier_field() {
420 let yaml = r#"
421schema_version: 1
422models:
423 haiku:
424 provider: anthropic
425 model: claude-haiku-4-5
426 tier: local
427 opus:
428 provider: anthropic
429 model: claude-opus-4-7
430 tier: frontier
431 cost_per_1k_tokens: 0.015
432"#;
433 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
434 assert_eq!(r.models["haiku"].tier, Some(RouteTier::Local));
435 assert_eq!(r.models["opus"].tier, Some(RouteTier::Frontier));
436 assert_eq!(r.models["opus"].cost_per_1k_tokens, Some(0.015));
437 let mut r2 = ModelRegistry::default();
439 r2.models.insert(
440 "x".into(),
441 ModelEntry {
442 provider: "ollama".into(),
443 model: "llama3".into(),
444 base_url: None,
445 secret: None,
446 capabilities: vec![],
447 params: serde_json::Value::Null,
448 tier: None,
449 cost_per_1k_tokens: None,
450 input_cost_per_1k: None,
451 output_cost_per_1k: None,
452 context_window: None,
453 priced_at: None,
454 },
455 );
456 let yaml = serde_yaml_ng::to_string(&r2).unwrap();
457 assert!(
458 !yaml.contains("tier:"),
459 "absent tier should not be serialized: {yaml}"
460 );
461 }
462
463 #[test]
464 fn role_entry_parses_route_policy() {
465 let yaml = r#"
466schema_version: 1
467models:
468 haiku:
469 provider: anthropic
470 model: claude-haiku-4-5
471 opus:
472 provider: anthropic
473 model: claude-opus-4-7
474roles:
475 dev:
476 primary: opus
477 route_policy: !force_frontier
478 model_id: opus
479 reflector:
480 primary: haiku
481 route_policy: prefer_local
482 curator:
483 primary: haiku
484 route_policy: force_local
485 chat:
486 primary: haiku
487"#;
488 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
489 assert_eq!(
490 r.roles["dev"].route_policy,
491 Some(RoutePolicy::ForceFrontier {
492 model_id: "opus".into()
493 })
494 );
495 assert_eq!(
496 r.roles["reflector"].route_policy,
497 Some(RoutePolicy::PreferLocal)
498 );
499 assert_eq!(
500 r.roles["curator"].route_policy,
501 Some(RoutePolicy::ForceLocal)
502 );
503 assert_eq!(r.roles["chat"].route_policy, None);
504 }
505
506 #[test]
507 fn parses_split_cost_fields() {
508 let yaml = r#"
509schema_version: 1
510models:
511 opus:
512 provider: anthropic
513 model: claude-opus-4-8
514 input_cost_per_1k: 0.005
515 output_cost_per_1k: 0.025
516 context_window: 200000
517"#;
518 let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
519 let e = r.models.get("opus").unwrap();
520 assert_eq!(e.input_cost_per_1k, Some(0.005));
521 assert_eq!(e.output_cost_per_1k, Some(0.025));
522 assert_eq!(e.context_window, Some(200_000));
523 }
524
525 #[test]
526 fn default_model_entry_is_empty() {
527 let e = ModelEntry::default();
528 assert!(e.provider.is_empty());
529 assert_eq!(e.input_cost_per_1k, None);
530 assert_eq!(e.output_cost_per_1k, None);
531 assert_eq!(e.context_window, None);
532 }
533
534 #[test]
535 fn effective_costs_fallback_matrix() {
536 let mut e = ModelEntry {
538 cost_per_1k_tokens: Some(0.01),
539 ..Default::default()
540 };
541 assert_eq!(e.effective_costs(), (Some(0.01), Some(0.01)));
542
543 e = ModelEntry {
545 input_cost_per_1k: Some(0.005),
546 output_cost_per_1k: Some(0.025),
547 ..Default::default()
548 };
549 assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
550
551 e = ModelEntry {
553 cost_per_1k_tokens: Some(0.01),
554 input_cost_per_1k: Some(0.005),
555 output_cost_per_1k: Some(0.025),
556 ..Default::default()
557 };
558 assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
559
560 e = ModelEntry::default();
562 assert_eq!(e.effective_costs(), (None, None));
563 }
564}
565
566#[cfg(test)]
567mod io_tests {
568 use super::*;
569 use tempfile::tempdir;
570
571 #[test]
572 fn load_returns_empty_when_file_missing() {
573 let dir = tempdir().unwrap();
574 let r = ModelRegistry::load_from(&dir.path().join("nope.yaml")).unwrap();
575 assert_eq!(r.models.len(), 0);
576 assert_eq!(r.schema_version, 1);
577 }
578
579 #[test]
580 fn save_then_load_round_trips() {
581 let dir = tempdir().unwrap();
582 let p = dir.path().join("models.yaml");
583 let mut r = ModelRegistry::default();
584 r.models.insert(
585 "x".into(),
586 ModelEntry {
587 provider: "ollama".into(),
588 model: "llama3.2:3b".into(),
589 base_url: None,
590 secret: None,
591 capabilities: vec![],
592 params: serde_json::Value::Null,
593 tier: None,
594 cost_per_1k_tokens: None,
595 input_cost_per_1k: None,
596 output_cost_per_1k: None,
597 context_window: None,
598 priced_at: None,
599 },
600 );
601 r.save_to(&p).unwrap();
602 let r2 = ModelRegistry::load_from(&p).unwrap();
603 assert_eq!(r, r2);
604 }
605
606 #[test]
607 fn save_uses_atomic_rename() {
608 let dir = tempdir().unwrap();
609 let p = dir.path().join("models.yaml");
610 ModelRegistry::default().save_to(&p).unwrap();
611 let temp = dir.path().join("models.yaml.tmp");
612 assert!(!temp.exists(), "atomic temp left behind");
613 }
614}
615
616#[cfg(test)]
617mod switch_tests {
618 use super::*;
619 use crate::agent::AgentProfile;
620 use crate::config::{ModelSwitchConfig, RoutingConfig};
621
622 fn profile(model_ref: Option<&str>, chain: &[&str]) -> AgentProfile {
623 let mut p = AgentProfile::default_for_tests();
624 p.model_ref = model_ref.map(|s| s.to_string());
625 p.fallback_chain = chain.iter().map(|s| s.to_string()).collect();
626 p
627 }
628
629 #[test]
630 fn per_agent_primary_and_chain_win_over_global() {
631 let cfg = ModelSwitchConfig {
632 default: Some("global_default".into()),
633 fallback_chain: vec!["g1".into(), "g2".into()],
634 ..Default::default()
635 };
636 let p = profile(Some("agent_primary"), &["agent_primary", "agent_fb"]);
637 assert_eq!(
639 resolve_model_refs(&p, &cfg, None),
640 vec!["agent_primary", "agent_fb"]
641 );
642 }
643
644 #[test]
645 fn falls_back_to_global_default_and_chain() {
646 let cfg = ModelSwitchConfig {
647 default: Some("global_default".into()),
648 fallback_chain: vec!["g1".into(), "global_default".into()],
649 ..Default::default()
650 };
651 let p = profile(None, &[]); assert_eq!(
654 resolve_model_refs(&p, &cfg, None),
655 vec!["global_default", "g1"]
656 );
657 }
658
659 #[test]
660 fn routed_primary_overrides_model_ref() {
661 let cfg = ModelSwitchConfig {
662 fallback_chain: vec!["g1".into()],
663 ..Default::default()
664 };
665 let p = profile(Some("agent_primary"), &[]);
666 assert_eq!(
667 resolve_model_refs(&p, &cfg, Some("frontier".into())),
668 vec!["frontier", "g1"]
669 );
670 }
671
672 #[test]
673 fn no_config_no_agent_yields_empty() {
674 let cfg = ModelSwitchConfig::default();
676 assert!(resolve_model_refs(&profile(None, &[]), &cfg, None).is_empty());
677 }
678
679 #[test]
680 fn difficulty_picks_frontier_over_threshold() {
681 let r = RoutingConfig {
682 enabled: true,
683 cheap: Some("cheap".into()),
684 frontier: Some("frontier".into()),
685 threshold_input_tokens: Some(1000),
686 ..Default::default()
687 };
688 assert_eq!(choose_by_difficulty(1500, &r), Some("frontier".into()));
689 assert_eq!(choose_by_difficulty(500, &r), Some("cheap".into()));
690 let bad = RoutingConfig {
692 enabled: true,
693 cheap: Some("c".into()),
694 frontier: None,
695 threshold_input_tokens: None,
696 ..Default::default()
697 };
698 assert_eq!(choose_by_difficulty(9999, &bad), None);
699 }
700
701 #[test]
702 fn pick_cheap_model_lowest_cost_chat_excluding_primary() {
703 let mut reg = ModelRegistry::default();
704 let mk = |cost: f64, caps: &[&str]| ModelEntry {
705 provider: "x".into(),
706 model: "m".into(),
707 capabilities: caps.iter().map(|s| s.to_string()).collect(),
708 cost_per_1k_tokens: Some(cost),
709 ..Default::default()
710 };
711 reg.models.insert("frontier".into(), mk(0.01, &["chat"]));
712 reg.models.insert("cheap".into(), mk(0.0001, &["chat"]));
713 reg.models
714 .insert("embed".into(), mk(0.00001, &["embedding"])); assert_eq!(
717 pick_cheap_model(®, Some("cheap")),
718 Some("frontier".into())
719 ); assert_eq!(pick_cheap_model(®, None), Some("cheap".into()));
721 let mut empty = ModelRegistry::default();
723 empty.models.insert("e".into(), mk(0.0, &["embedding"]));
724 assert_eq!(pick_cheap_model(&empty, None), None);
725 }
726
727 #[test]
731 fn priced_at_stamps_only_priced_entries() {
732 let now = chrono::Utc::now();
733
734 let mut unpriced = ModelEntry {
735 provider: "openai".into(),
736 model: "local-thing".into(),
737 ..Default::default()
738 };
739 unpriced.stamp_priced_at(now);
740 assert_eq!(unpriced.priced_at, None);
741 assert_eq!(unpriced.price_age(now), None);
742
743 let mut priced = ModelEntry {
744 output_cost_per_1k: Some(0.025),
745 ..unpriced.clone()
746 };
747 priced.stamp_priced_at(now);
748 assert_eq!(priced.priced_at, Some(now));
749
750 let mut legacy = ModelEntry {
752 cost_per_1k_tokens: Some(0.01),
753 ..unpriced.clone()
754 };
755 legacy.stamp_priced_at(now);
756 assert!(legacy.priced_at.is_some());
757
758 let earlier = now - chrono::TimeDelta::days(30);
760 priced.stamp_priced_at(earlier);
761 assert_eq!(priced.priced_at, Some(now));
762 }
763
764 #[test]
767 fn registry_without_priced_at_still_loads_and_reports_unknown_age() {
768 let yaml = r#"
769schema_version: 1
770models:
771 opus:
772 provider: anthropic
773 model: claude-opus-5
774 input_cost_per_1k: 0.005
775 output_cost_per_1k: 0.025
776"#;
777 let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
778 let e = ®.models["opus"];
779 assert_eq!(e.priced_at, None);
780 assert_eq!(e.price_age(chrono::Utc::now()), None);
781 let out = serde_yaml_ng::to_string(®).unwrap();
783 assert!(!out.contains("priced_at"), "{out}");
784 }
785
786 #[test]
789 fn pick_cheap_model_sees_split_cost_entries() {
790 let mut reg = ModelRegistry::default();
791 let split = |input: f64, output: f64| ModelEntry {
792 provider: "x".into(),
793 model: "m".into(),
794 capabilities: vec!["chat".into()],
795 input_cost_per_1k: Some(input),
796 output_cost_per_1k: Some(output),
797 ..Default::default()
798 };
799 reg.models.insert("dear".into(), split(0.005, 0.025));
800 reg.models.insert("cheap".into(), split(0.0001, 0.0004));
801 assert_eq!(pick_cheap_model(®, None), Some("cheap".into()));
802
803 let mut input_only = ModelRegistry::default();
805 input_only.models.insert(
806 "in".into(),
807 ModelEntry {
808 provider: "x".into(),
809 model: "m".into(),
810 capabilities: vec!["chat".into()],
811 input_cost_per_1k: Some(0.002),
812 ..Default::default()
813 },
814 );
815 assert_eq!(pick_cheap_model(&input_only, None), Some("in".into()));
816 }
817}