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}
55
56impl ModelEntry {
57    /// Resolve effective per-1k rates as `(input, output)`.
58    ///
59    /// The deprecated `cost_per_1k_tokens` is treated as the output rate and
60    /// also as the input fallback, so legacy single-rate entries keep working.
61    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    /// Registry model ID (key in `models:`) to use as primary.
71    pub primary: String,
72    /// Fallback model ID if primary is unavailable.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub fallback: Option<String>,
75    /// Optional daily cost cap in USD.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub cost_budget_per_day_usd: Option<f64>,
78    /// If true, only use local models when handling sensitive data.
79    #[serde(default)]
80    pub privacy_local_only: bool,
81    /// Per-role routing policy override.
82    /// When absent, the router uses the default heuristic.
83    #[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        // Honor MUR_HOME (used by test harnesses and Windows CI, where
131        // `dirs::home_dir()` reads SHGetKnownFolderPath and ignores HOME).
132        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    /// Return the primary model ID for `role`, or the fallback if the primary
142    /// is not in the `models` map, or `None` if the role is not configured.
143    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        // primary not in registry — try fallback
149        if let Some(fb) = &entry.fallback
150            && self.models.contains_key(fb)
151        {
152            return Some(fb);
153        }
154        // role configured but no available model
155        None
156    }
157}
158
159use crate::agent::AgentProfile;
160use crate::config::{DEFAULT_ROUTING_THRESHOLD, ModelSwitchConfig, RoutingConfig};
161
162/// Build the ordered list of model_refs to try: `[primary, ...fallback]`.
163/// Priority per-agent → global. The primary is de-duplicated out of the chain
164/// (no point retrying the same ref back-to-back). Returns empty when nothing is
165/// configured, so the caller keeps today's single-inline-model behaviour.
166pub 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
191/// Opt-in difficulty heuristic: pick `frontier` when the estimated input token
192/// count exceeds the threshold, else `cheap`. `None` when misconfigured (caller
193/// falls through to model_ref/global default).
194pub 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
208/// Pick the cheapest chat-capable model_ref for Smart background routing,
209/// excluding `exclude` (the agent's own primary). Chat-capable = capabilities
210/// contains "chat" OR is empty (legacy entries assumed chat). None when no
211/// qualifying entry exists → caller keeps normal candidates (fail-expensive).
212pub 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(&reg).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        // Missing tier is None.
401        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        // legacy only → both fall back to the blended rate
499        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        // split only → split wins, legacy ignored
506        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        // both → split wins
514        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        // none → none
523        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        // per-agent model_ref is primary; per-agent chain used; primary de-duped.
599        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, &[]); // no per-agent model_ref or chain
613        // primary = global default; global chain used; primary de-duped out.
614        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        // Nothing configured → empty vec (caller falls back to inline model).
636        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        // Misconfigured (missing frontier) → None (fall through).
652        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"])); // not chat → skip
676        // cheapest chat-capable, excluding the agent's own primary:
677        assert_eq!(
678            pick_cheap_model(&reg, Some("cheap")),
679            Some("frontier".into())
680        ); // cheap excluded
681        assert_eq!(pick_cheap_model(&reg, None), Some("cheap".into()));
682        // no chat entries → None (Smart inert)
683        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}