Skip to main content

mur_common/
model.rs

1//! Named model registry shared by all agents.
2//!
3//! On disk: `~/.mur/models.yaml`. Schema:
4//!
5//! ```yaml
6//! schema_version: 1
7//! models:
8//!   anthropic_opus_4_7:
9//!     provider: anthropic
10//!     model: claude-opus-4-7
11//!     secret: env:ANTHROPIC_API_KEY
12//!     capabilities: [chat, tools]
13//! ```
14
15use 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    /// Routing tier: cheap/local vs frontier/expensive.
36    /// When absent, the router infers based on provider.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub tier: Option<RouteTier>,
39    /// Estimated USD cost per 1000 output tokens.
40    /// Used for ledger cost estimates.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub cost_per_1k_tokens: Option<f64>,
43    /// Estimated USD cost per 1000 input tokens.
44    /// New field for split input/output cost tracking.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub input_cost_per_1k: Option<f64>,
47    /// Estimated USD cost per 1000 output tokens.
48    /// New field for split input/output cost tracking.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub output_cost_per_1k: Option<f64>,
51    /// Model context window size in tokens.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub context_window: Option<u64>,
54    /// When the rates above were recorded.
55    ///
56    /// Vendors move prices; a rate written months ago is a guess wearing the
57    /// costume of a fact, and nothing else on this struct can tell the two
58    /// apart. `None` means unknown — entries predating this field, or hand-
59    /// written ones — which is honest rather than defaulting to "fresh".
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub priced_at: Option<chrono::DateTime<chrono::Utc>>,
62}
63
64impl ModelEntry {
65    /// Resolve effective per-1k rates as `(input, output)`.
66    ///
67    /// The deprecated `cost_per_1k_tokens` is treated as the output rate and
68    /// also as the input fallback, so legacy single-rate entries keep working.
69    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    /// Whether this entry carries any rate at all.
76    pub fn is_priced(&self) -> bool {
77        let (input, output) = self.effective_costs();
78        input.is_some() || output.is_some()
79    }
80
81    /// Stamp `priced_at` with `now`, but only if a rate is actually present —
82    /// a date on an unpriced entry would claim a freshness it does not have.
83    /// Never overwrites an existing stamp with an older one.
84    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    /// How long ago the rates were recorded, or `None` when unstamped.
91    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    /// Registry model ID (key in `models:`) to use as primary.
99    pub primary: String,
100    /// Fallback model ID if primary is unavailable.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub fallback: Option<String>,
103    /// Optional daily cost cap in USD.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub cost_budget_per_day_usd: Option<f64>,
106    /// If true, only use local models when handling sensitive data.
107    #[serde(default)]
108    pub privacy_local_only: bool,
109    /// Per-role routing policy override.
110    /// When absent, the router uses the default heuristic.
111    #[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        // Honor MUR_HOME (used by test harnesses and Windows CI, where
159        // `dirs::home_dir()` reads SHGetKnownFolderPath and ignores HOME).
160        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    /// Return the primary model ID for `role`, or the fallback if the primary
170    /// is not in the `models` map, or `None` if the role is not configured.
171    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        // primary not in registry — try fallback
177        if let Some(fb) = &entry.fallback
178            && self.models.contains_key(fb)
179        {
180            return Some(fb);
181        }
182        // role configured but no available model
183        None
184    }
185}
186
187use crate::agent::AgentProfile;
188use crate::config::{DEFAULT_ROUTING_THRESHOLD, ModelSwitchConfig, RoutingConfig};
189
190/// Build the ordered list of model_refs to try: `[primary, ...fallback]`.
191/// Priority per-agent → global. The primary is de-duplicated out of the chain
192/// (no point retrying the same ref back-to-back). Returns empty when nothing is
193/// configured, so the caller keeps today's single-inline-model behaviour.
194pub 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
219/// Opt-in difficulty heuristic: pick `frontier` when the estimated input token
220/// count exceeds the threshold, else `cheap`. `None` when misconfigured (caller
221/// falls through to model_ref/global default).
222pub 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
236/// Pick the cheapest chat-capable model_ref for Smart background routing,
237/// excluding `exclude` (the agent's own primary). Chat-capable = capabilities
238/// contains "chat" OR is empty (legacy entries assumed chat). None when no
239/// qualifying entry exists → caller keeps normal candidates (fail-expensive).
240pub 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            // Not the deprecated field directly: `mur model add --output-cost`
247            // deliberately leaves it unset, so reading it drops every entry
248            // added with the current flags instead of ranking it.
249            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(&reg).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        // Missing tier is None.
438        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        // legacy only → both fall back to the blended rate
537        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        // split only → split wins, legacy ignored
544        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        // both → split wins
552        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        // none → none
561        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        // per-agent model_ref is primary; per-agent chain used; primary de-duped.
638        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, &[]); // no per-agent model_ref or chain
652        // primary = global default; global chain used; primary de-duped out.
653        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        // Nothing configured → empty vec (caller falls back to inline model).
675        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        // Misconfigured (missing frontier) → None (fall through).
691        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"])); // not chat → skip
715        // cheapest chat-capable, excluding the agent's own primary:
716        assert_eq!(
717            pick_cheap_model(&reg, Some("cheap")),
718            Some("frontier".into())
719        ); // cheap excluded
720        assert_eq!(pick_cheap_model(&reg, None), Some("cheap".into()));
721        // no chat entries → None (Smart inert)
722        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    /// A price with no date is a guess wearing the costume of a fact. But a
728    /// date on an entry that carries no price would be the same lie in the
729    /// other direction, so the stamp is conditional on there being a rate.
730    #[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        // A legacy single-rate entry counts as priced.
751        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        // Re-stamping never moves the date backwards.
759        let earlier = now - chrono::TimeDelta::days(30);
760        priced.stamp_priced_at(earlier);
761        assert_eq!(priced.priced_at, Some(now));
762    }
763
764    /// Entries written before this field existed must keep loading, and must
765    /// report an unknown age rather than inheriting today's date.
766    #[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 = &reg.models["opus"];
779        assert_eq!(e.priced_at, None);
780        assert_eq!(e.price_age(chrono::Utc::now()), None);
781        // Round-trips without inventing the field.
782        let out = serde_yaml_ng::to_string(&reg).unwrap();
783        assert!(!out.contains("priced_at"), "{out}");
784    }
785
786    /// `mur model add --input-cost/--output-cost` leaves `cost_per_1k_tokens`
787    /// unset, so an entry priced the current way must still be rankable.
788    #[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(&reg, None), Some("cheap".into()));
802
803        // Input-only entries are priced too, rather than silently skipped.
804        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}