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/// Who pays when this model answers.
22///
23/// A ChatGPT-subscription model (`provider: codex`) and an OpenAI Platform
24/// model can share a model id and a wire format while landing on different
25/// bills, so the registry says which. `None` on entries written before this
26/// field existed — readers render that as unknown, never as free.
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum BillingMode {
30    /// Covered by a flat subscription (ChatGPT Plus/Pro via Codex).
31    Subscription,
32    /// Metered per token against an API key.
33    UsageBilled,
34    /// Runs on this machine; no bill.
35    Local,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
39pub struct ModelEntry {
40    #[serde(default)]
41    pub provider: String,
42    /// Who makes this model — the models.dev catalog vendor (`deepseek`,
43    /// `groq`, `mistral`, …).
44    ///
45    /// Distinct from `provider`, which is the wire protocol MUR dials: a
46    /// DeepSeek entry is `provider: openai` + `vendor: deepseek`, because the
47    /// runtime reaches it over the OpenAI protocol while the catalog files it
48    /// under DeepSeek. Only recorded when the two differ — for Anthropic,
49    /// OpenAI and Ollama the protocol already names the vendor.
50    ///
51    /// `None` on entries written before this field existed; readers should go
52    /// through [`ModelEntry::vendor_candidates`] rather than reading it raw.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub vendor: Option<String>,
55    #[serde(default)]
56    pub model: String,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub base_url: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub secret: Option<SecretRef>,
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub capabilities: Vec<String>,
63    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
64    pub params: serde_json::Value,
65    /// Routing tier: cheap/local vs frontier/expensive.
66    /// When absent, the router infers based on provider.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub tier: Option<RouteTier>,
69    /// Estimated USD cost per 1000 output tokens.
70    /// Used for ledger cost estimates.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub cost_per_1k_tokens: Option<f64>,
73    /// Estimated USD cost per 1000 input tokens.
74    /// New field for split input/output cost tracking.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub input_cost_per_1k: Option<f64>,
77    /// Estimated USD cost per 1000 output tokens.
78    /// New field for split input/output cost tracking.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub output_cost_per_1k: Option<f64>,
81    /// Model context window size in tokens.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub context_window: Option<u64>,
84    /// Output-token ceiling sent on every request to this model that does not
85    /// set its own.
86    ///
87    /// On models with extended thinking this budget covers thinking AND the
88    /// visible reply together, so a high `effort` can spend all of it before
89    /// any text is produced. `None` leaves the provider client's own default
90    /// in place (the Anthropic client's built-in constant; nothing at all for
91    /// OpenAI-protocol clients, which then get the server's default). A
92    /// request that names its own `max_tokens` keeps it — this is a default,
93    /// not a clamp.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub max_tokens: Option<u32>,
96    /// When the rates above were recorded.
97    ///
98    /// Vendors move prices; a rate written months ago is a guess wearing the
99    /// costume of a fact, and nothing else on this struct can tell the two
100    /// apart. `None` means unknown — entries predating this field, or hand-
101    /// written ones — which is honest rather than defaulting to "fresh".
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub priced_at: Option<chrono::DateTime<chrono::Utc>>,
104    /// See [`BillingMode`]. `None` = unknown.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub billing: Option<BillingMode>,
107    /// Whether the model id came from the provider's live catalog
108    /// (`Some(true)`) or was typed by hand when discovery failed
109    /// (`Some(false)`). `None` on entries that predate the field.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub catalog_verified: Option<bool>,
112}
113
114/// Vendor label implied by an endpoint host: `https://api.deepseek.com/v1` →
115/// `deepseek`. Best-effort — a host that does not carry the vendor's name
116/// (Google's `generativelanguage.googleapis.com`) yields the wrong label,
117/// which is why `vendor` is recorded explicitly on new entries.
118fn vendor_label_of_url(base_url: Option<&str>) -> Option<String> {
119    let host = base_url?
120        .split("//")
121        .nth(1)
122        .unwrap_or(base_url?)
123        .split(['/', ':'])
124        .next()
125        .unwrap_or("");
126    let label = host.strip_prefix("api.").unwrap_or(host);
127    let first = label.split('.').next().unwrap_or("");
128    (!first.is_empty()).then(|| first.to_string())
129}
130
131const LOCAL_MODEL_PROVIDERS: &[&str] = &[
132    "ollama",
133    "mlx",
134    "llamacpp",
135    "llama_cpp",
136    "localai",
137    "lmstudio",
138    "local",
139];
140
141/// Whether a provider is a known in-process or on-machine model backend.
142pub fn provider_is_local(provider: &str) -> bool {
143    LOCAL_MODEL_PROVIDERS.contains(&provider.to_ascii_lowercase().as_str())
144}
145
146/// Canonical billing inference for registry entries without explicit metadata.
147pub fn inferred_billing_for_provider(provider: &str) -> BillingMode {
148    if provider_is_local(provider) {
149        BillingMode::Local
150    } else if matches!(provider.to_ascii_lowercase().as_str(), "codex" | "claude") {
151        BillingMode::Subscription
152    } else {
153        BillingMode::UsageBilled
154    }
155}
156
157impl ModelEntry {
158    /// How this model is paid for, for the cost gates. An explicit `billing:`
159    /// is the answer; without one the provider decides what it can:
160    /// `ollama` runs on this machine, `codex` and `claude` ride a flat
161    /// subscription. Everything else — including a loopback `base_url`, which
162    /// is just as often the model gateway fronting a metered API — is treated
163    /// as metered. Guessing "free" is the one mistake a cost gate must not
164    /// make; a wrong "metered" costs the user one line in `models.yaml`
165    /// (`billing: local`), and the gate says so when it applies.
166    pub fn billing_or_inferred(&self) -> BillingMode {
167        self.billing
168            .unwrap_or_else(|| inferred_billing_for_provider(&self.provider))
169    }
170
171    /// Effective route tier, honoring an explicit registry tier first.
172    pub fn effective_route_tier(&self) -> RouteTier {
173        self.tier.unwrap_or_else(|| {
174            if provider_is_local(&self.provider) {
175                RouteTier::Local
176            } else {
177                RouteTier::Frontier
178            }
179        })
180    }
181    /// Resolve effective per-1k rates as `(input, output)`.
182    ///
183    /// The deprecated `cost_per_1k_tokens` is treated as the output rate and
184    /// also as the input fallback, so legacy single-rate entries keep working.
185    pub fn effective_costs(&self) -> (Option<f64>, Option<f64>) {
186        let output = self.output_cost_per_1k.or(self.cost_per_1k_tokens);
187        let input = self.input_cost_per_1k.or(self.cost_per_1k_tokens);
188        (input, output)
189    }
190
191    /// Projected USD cost for a token workload. A known rate on only one side
192    /// is used for the other side; no rates remains unknown rather than free.
193    pub fn projected_cost(&self, input_tokens: u64, output_tokens: u64) -> Option<f64> {
194        let (input, output) = self.effective_costs();
195        let fallback = input.or(output)?;
196        let input = input.unwrap_or(fallback);
197        let output = output.unwrap_or(fallback);
198        Some(input_tokens as f64 / 1_000.0 * input + output_tokens as f64 / 1_000.0 * output)
199    }
200
201    /// Catalog vendor names to try for this entry, most specific first.
202    ///
203    /// The recorded `vendor` wins. Failing that — legacy entries, or anything
204    /// written by hand — the host of `base_url` is tried
205    /// (`https://api.deepseek.com` → `deepseek`), then `provider`, which names
206    /// the vendor only when the vendor happens to have its own client.
207    ///
208    /// Every caller that asks an external catalog about an entry must go
209    /// through this. Asking with `provider` alone reports every
210    /// OpenAI-compatible third party as unknown.
211    pub fn vendor_candidates(&self) -> Vec<String> {
212        let mut out: Vec<String> = Vec::with_capacity(3);
213        let mut push = |v: &str| {
214            if !v.is_empty() && !out.iter().any(|e| e == v) {
215                out.push(v.to_string());
216            }
217        };
218        if let Some(v) = self.vendor.as_deref() {
219            push(v);
220        }
221        if let Some(label) = vendor_label_of_url(self.base_url.as_deref()) {
222            push(&label);
223        }
224        push(&self.provider);
225        out
226    }
227
228    /// Whether this entry carries any rate at all.
229    pub fn is_priced(&self) -> bool {
230        let (input, output) = self.effective_costs();
231        input.is_some() || output.is_some()
232    }
233
234    /// Stamp `priced_at` with `now`, but only if a rate is actually present —
235    /// a date on an unpriced entry would claim a freshness it does not have.
236    /// Never overwrites an existing stamp with an older one.
237    pub fn stamp_priced_at(&mut self, now: chrono::DateTime<chrono::Utc>) {
238        if self.is_priced() && self.priced_at.is_none_or(|prev| prev < now) {
239            self.priced_at = Some(now);
240        }
241    }
242
243    /// How long ago the rates were recorded, or `None` when unstamped.
244    pub fn price_age(&self, now: chrono::DateTime<chrono::Utc>) -> Option<chrono::TimeDelta> {
245        self.priced_at.map(|at| now - at)
246    }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
250pub struct RoleEntry {
251    /// Registry model ID (key in `models:`) to use as primary.
252    pub primary: String,
253    /// Fallback model ID if primary is unavailable.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub fallback: Option<String>,
256    /// Optional daily cost cap in USD.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub cost_budget_per_day_usd: Option<f64>,
259    /// If true, only use local models when handling sensitive data.
260    #[serde(default)]
261    pub privacy_local_only: bool,
262    /// Per-role routing policy override.
263    /// When absent, the router uses the default heuristic.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub route_policy: Option<RoutePolicy>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269pub struct ModelRegistry {
270    pub schema_version: u32,
271    #[serde(default)]
272    pub models: BTreeMap<String, ModelEntry>,
273    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
274    pub roles: BTreeMap<String, RoleEntry>,
275}
276
277impl Default for ModelRegistry {
278    fn default() -> Self {
279        Self {
280            schema_version: 1,
281            models: BTreeMap::new(),
282            roles: BTreeMap::new(),
283        }
284    }
285}
286
287impl ModelRegistry {
288    pub fn load_from(path: &Path) -> anyhow::Result<Self> {
289        if !path.exists() {
290            return Ok(Self::default());
291        }
292        let body = std::fs::read_to_string(path)?;
293        if body.trim().is_empty() {
294            return Ok(Self::default());
295        }
296        Ok(serde_yaml_ng::from_str(&body)?)
297    }
298
299    pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
300        if let Some(parent) = path.parent() {
301            std::fs::create_dir_all(parent)?;
302        }
303        let body = serde_yaml_ng::to_string(self)?;
304        let tmp = path.with_extension("yaml.tmp");
305        std::fs::write(&tmp, body)?;
306        std::fs::rename(&tmp, path)?;
307        Ok(())
308    }
309
310    pub fn default_path() -> anyhow::Result<PathBuf> {
311        // Honor MUR_HOME (used by test harnesses and Windows CI, where
312        // `dirs::home_dir()` reads SHGetKnownFolderPath and ignores HOME).
313        if let Ok(p) = std::env::var("MUR_HOME")
314            && !p.is_empty()
315        {
316            return Ok(PathBuf::from(p).join("models.yaml"));
317        }
318        let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
319        Ok(home.join(".mur/models.yaml"))
320    }
321
322    /// Return the primary model ID for `role`, or the fallback if the primary
323    /// is not in the `models` map, or `None` if the role is not configured.
324    pub fn resolve_role(&self, role: &str) -> Option<&str> {
325        let entry = self.roles.get(role)?;
326        if self.models.contains_key(&entry.primary) {
327            return Some(&entry.primary);
328        }
329        // primary not in registry — try fallback
330        if let Some(fb) = &entry.fallback
331            && self.models.contains_key(fb)
332        {
333            return Some(fb);
334        }
335        // role configured but no available model
336        None
337    }
338}
339
340use crate::agent::AgentProfile;
341use crate::config::{DEFAULT_ROUTING_THRESHOLD, ModelSwitchConfig, RoutingConfig};
342
343/// Build the ordered list of model_refs to try: `[primary, ...fallback]`.
344/// Priority per-agent → global. The primary is de-duplicated out of the chain
345/// (no point retrying the same ref back-to-back). Returns empty when nothing is
346/// configured, so the caller keeps today's single-inline-model behaviour.
347pub fn resolve_model_refs(
348    profile: &AgentProfile,
349    cfg: &ModelSwitchConfig,
350    routed_primary: Option<String>,
351) -> Vec<String> {
352    let primary = routed_primary
353        .or_else(|| profile.model_ref.clone())
354        .or_else(|| cfg.default.clone());
355    let chain = if !profile.fallback_chain.is_empty() {
356        profile.fallback_chain.clone()
357    } else {
358        cfg.fallback_chain.clone()
359    };
360    let mut out: Vec<String> = Vec::new();
361    if let Some(p) = primary {
362        out.push(p);
363    }
364    for r in chain {
365        if !out.contains(&r) {
366            out.push(r);
367        }
368    }
369    out
370}
371
372/// Opt-in difficulty heuristic: pick `frontier` when the estimated input token
373/// count exceeds the threshold, else `cheap`. `None` when misconfigured (caller
374/// falls through to model_ref/global default).
375pub fn choose_by_difficulty(est_input_tokens: u32, r: &RoutingConfig) -> Option<String> {
376    let threshold = r
377        .threshold_input_tokens
378        .unwrap_or(DEFAULT_ROUTING_THRESHOLD);
379    match (r.cheap.as_ref(), r.frontier.as_ref()) {
380        (Some(cheap), Some(frontier)) => Some(if est_input_tokens > threshold {
381            frontier.clone()
382        } else {
383            cheap.clone()
384        }),
385        _ => None,
386    }
387}
388
389/// Registry capability strings. The baseline (`chat`) is legacy-permissive —
390/// an entry with no `capabilities` at all predates the field and is assumed
391/// chat-capable. Everything above the baseline is fail-closed.
392pub const CAP_CHAT: &str = "chat";
393pub const CAP_TOOLS: &str = "tools";
394pub const CAP_VISION: &str = "vision";
395
396/// A capability the request needs from whatever model serves it. Derived from
397/// the request itself (an image in the messages, a tool list) and never from
398/// config: a router may only substitute a model that can do the job.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub enum Requirement {
401    /// The request carries an image; the model has to be able to see it.
402    Vision,
403    /// The request declares tools; the model has to be able to call them.
404    Tools,
405}
406
407impl Requirement {
408    /// The registry capability an entry must declare to satisfy this.
409    pub fn capability(self) -> &'static str {
410        match self {
411            Requirement::Vision => CAP_VISION,
412            Requirement::Tools => CAP_TOOLS,
413        }
414    }
415
416    /// Does an entry that declares NO capabilities at all satisfy this?
417    ///
418    /// The two requirements differ in how they fail, and the answer follows
419    /// the failure mode rather than a blanket rule:
420    ///
421    /// - `Vision`: **no**. A model that cannot see answers an image request
422    ///   with confident nonsense — silent, and unrecoverable for that turn.
423    ///   That is the failure this gate exists to prevent, so silence about
424    ///   vision is treated as absence of it.
425    /// - `Tools`: **yes**. A model that cannot call tools fails loudly (the
426    ///   provider rejects the request) and the existing retry/advance path
427    ///   already handles it. Treating undeclared as incapable would drop every
428    ///   entry written before `capabilities` existed — in practice most of a
429    ///   real registry — out of the fallback chain of every tool-carrying turn,
430    ///   which is a large regression bought for very little.
431    ///
432    /// An entry that DOES declare capabilities is taken at its word either
433    /// way: if it enumerated what it can do and left `tools` out, that is a
434    /// statement, not silence.
435    fn permitted_when_undeclared(self) -> bool {
436        match self {
437            Requirement::Vision => false,
438            Requirement::Tools => true,
439        }
440    }
441}
442
443/// Can this entry serve a request needing `reqs`?
444///
445/// No registry write path emits `vision` today, so a `Vision` requirement
446/// disqualifies every current entry — auto-substitution goes inert for image
447/// requests rather than answering them blind. The same code makes a finer
448/// distinction the day entries start declaring it; there is no second version
449/// of this function to write later.
450pub fn satisfies(e: &ModelEntry, reqs: &[Requirement]) -> bool {
451    let chat_capable = e.capabilities.is_empty() || e.capabilities.iter().any(|c| c == CAP_CHAT);
452    if !chat_capable {
453        return false;
454    }
455    reqs.iter().all(|r| {
456        if e.capabilities.is_empty() {
457            r.permitted_when_undeclared()
458        } else {
459            e.capabilities.iter().any(|c| c == r.capability())
460        }
461    })
462}
463
464/// Pick the cheapest registry entry that can serve a request needing `reqs`,
465/// excluding `exclude` (the agent's own primary). None when no qualifying
466/// entry exists → caller keeps normal candidates (fail-expensive).
467pub fn pick_cheap_model(
468    reg: &ModelRegistry,
469    exclude: Option<&str>,
470    reqs: &[Requirement],
471) -> Option<String> {
472    reg.models
473        .iter()
474        .filter(|(k, _)| exclude != Some(k.as_str()))
475        .filter(|(_, e)| satisfies(e, reqs))
476        .filter_map(|(k, e)| e.projected_cost(4_000, 1_000).map(|c| (c, k.clone())))
477        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
478        .map(|(_, k)| k)
479}
480
481#[cfg(test)]
482mod tests {
483
484    #[test]
485    fn vendor_candidates_prefer_the_recorded_vendor_then_the_host_then_provider() {
486        // Recorded vendor wins — this is what new entries carry.
487        let e = ModelEntry {
488            provider: "openai".into(),
489            vendor: Some("deepseek".into()),
490            base_url: Some("https://api.deepseek.com/v1".into()),
491            ..Default::default()
492        };
493        assert_eq!(e.vendor_candidates(), vec!["deepseek", "openai"]);
494
495        // Legacy entry with no vendor: the endpoint host still identifies it,
496        // which is how registries written before the field keep working.
497        let legacy = ModelEntry {
498            provider: "openai".into(),
499            base_url: Some("https://api.deepseek.com/v1".into()),
500            ..Default::default()
501        };
502        assert_eq!(legacy.vendor_candidates(), vec!["deepseek", "openai"]);
503
504        // Nothing to infer: provider is all there is.
505        let bare = ModelEntry {
506            provider: "anthropic".into(),
507            ..Default::default()
508        };
509        assert_eq!(bare.vendor_candidates(), vec!["anthropic"]);
510
511        // No duplicate when host and provider agree.
512        let same = ModelEntry {
513            provider: "openai".into(),
514            base_url: Some("https://api.openai.com/v1".into()),
515            ..Default::default()
516        };
517        assert_eq!(same.vendor_candidates(), vec!["openai"]);
518    }
519
520    #[test]
521    fn vendor_is_omitted_from_yaml_when_absent_and_round_trips_when_set() {
522        let bare = ModelEntry {
523            provider: "anthropic".into(),
524            model: "claude-opus-5".into(),
525            ..Default::default()
526        };
527        let y = serde_yaml_ng::to_string(&bare).unwrap();
528        assert!(!y.contains("vendor"), "{y}");
529
530        let tagged = ModelEntry {
531            provider: "openai".into(),
532            vendor: Some("groq".into()),
533            model: "llama-3.3".into(),
534            ..Default::default()
535        };
536        let y = serde_yaml_ng::to_string(&tagged).unwrap();
537        let back: ModelEntry = serde_yaml_ng::from_str(&y).unwrap();
538        assert_eq!(back.vendor.as_deref(), Some("groq"));
539    }
540    use super::*;
541
542    #[test]
543    fn parses_full_registry() {
544        let yaml = r#"
545schema_version: 1
546models:
547  anthropic_opus_4_7:
548    provider: anthropic
549    model: claude-opus-4-7
550    secret: env:ANTHROPIC_API_KEY
551    capabilities: [chat, tools]
552  ollama_llama3:
553    provider: ollama
554    model: llama3.2:3b
555    base_url: http://127.0.0.1:11434
556"#;
557        let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
558        assert_eq!(r.schema_version, 1);
559        assert_eq!(r.models.len(), 2);
560        let opus = r.models.get("anthropic_opus_4_7").unwrap();
561        assert_eq!(opus.provider, "anthropic");
562        assert_eq!(
563            opus.secret,
564            Some(SecretRef::Env("ANTHROPIC_API_KEY".into()))
565        );
566        assert!(r.models["ollama_llama3"].secret.is_none());
567    }
568
569    #[test]
570    fn round_trip_preserves_shape() {
571        let mut r = ModelRegistry::default();
572        r.models.insert(
573            "foo".into(),
574            ModelEntry {
575                provider: "anthropic".into(),
576                model: "claude-opus-4-7".into(),
577                base_url: None,
578                secret: Some(SecretRef::Keychain {
579                    service: "mur".into(),
580                    account: "anthropic".into(),
581                }),
582                capabilities: vec!["chat".into()],
583                params: serde_json::Value::Null,
584                tier: None,
585                cost_per_1k_tokens: None,
586                input_cost_per_1k: None,
587                output_cost_per_1k: None,
588                context_window: None,
589                priced_at: None,
590                ..Default::default()
591            },
592        );
593        let s = serde_yaml_ng::to_string(&r).unwrap();
594        let parsed: ModelRegistry = serde_yaml_ng::from_str(&s).unwrap();
595        assert_eq!(r, parsed);
596    }
597
598    #[test]
599    fn rejects_unknown_secret_scheme() {
600        let yaml = r#"
601schema_version: 1
602models:
603  bad:
604    provider: x
605    model: y
606    secret: bogus:value
607"#;
608        let r: Result<ModelRegistry, _> = serde_yaml_ng::from_str(yaml);
609        assert!(r.is_err(), "should reject unknown scheme");
610    }
611
612    #[test]
613    fn test_registry_roundtrip_with_roles() {
614        let yaml = r#"
615schema_version: 1
616models:
617  haiku:
618    provider: anthropic
619    model: claude-haiku-4-5
620roles:
621  reflector:
622    primary: haiku
623    fallback: null
624    cost_budget_per_day_usd: 0.5
625"#;
626        let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
627        assert_eq!(reg.roles["reflector"].primary, "haiku");
628        let back = serde_yaml_ng::to_string(&reg).unwrap();
629        let reg2: ModelRegistry = serde_yaml_ng::from_str(&back).unwrap();
630        assert_eq!(reg, reg2);
631    }
632
633    #[test]
634    fn test_resolve_role_primary() {
635        let mut reg = ModelRegistry::default();
636        reg.models.insert(
637            "haiku".into(),
638            ModelEntry {
639                provider: "anthropic".into(),
640                model: "claude-haiku-4-5".into(),
641                base_url: None,
642                secret: None,
643                capabilities: vec![],
644                params: serde_json::Value::Null,
645                tier: None,
646                cost_per_1k_tokens: None,
647                input_cost_per_1k: None,
648                output_cost_per_1k: None,
649                context_window: None,
650                priced_at: None,
651                ..Default::default()
652            },
653        );
654        reg.roles.insert(
655            "reflector".into(),
656            RoleEntry {
657                primary: "haiku".into(),
658                fallback: None,
659                ..Default::default()
660            },
661        );
662        assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
663    }
664
665    #[test]
666    fn test_resolve_role_fallback() {
667        let mut reg = ModelRegistry::default();
668        reg.models.insert(
669            "haiku".into(),
670            ModelEntry {
671                provider: "anthropic".into(),
672                model: "claude-haiku-4-5".into(),
673                base_url: None,
674                secret: None,
675                capabilities: vec![],
676                params: serde_json::Value::Null,
677                tier: None,
678                cost_per_1k_tokens: None,
679                input_cost_per_1k: None,
680                output_cost_per_1k: None,
681                context_window: None,
682                priced_at: None,
683                ..Default::default()
684            },
685        );
686        reg.roles.insert(
687            "reflector".into(),
688            RoleEntry {
689                primary: "nonexistent".into(),
690                fallback: Some("haiku".into()),
691                ..Default::default()
692            },
693        );
694        assert_eq!(reg.resolve_role("reflector"), Some("haiku"));
695    }
696
697    #[test]
698    fn test_resolve_role_none() {
699        let reg = ModelRegistry::default();
700        assert_eq!(reg.resolve_role("reflector"), None);
701    }
702
703    #[test]
704    fn model_entry_parses_tier_field() {
705        let yaml = r#"
706schema_version: 1
707models:
708  haiku:
709    provider: anthropic
710    model: claude-haiku-4-5
711    tier: local
712  opus:
713    provider: anthropic
714    model: claude-opus-4-7
715    tier: frontier
716    cost_per_1k_tokens: 0.015
717"#;
718        let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
719        assert_eq!(r.models["haiku"].tier, Some(RouteTier::Local));
720        assert_eq!(r.models["opus"].tier, Some(RouteTier::Frontier));
721        assert_eq!(r.models["opus"].cost_per_1k_tokens, Some(0.015));
722        // Missing tier is None.
723        let mut r2 = ModelRegistry::default();
724        r2.models.insert(
725            "x".into(),
726            ModelEntry {
727                provider: "ollama".into(),
728                model: "llama3".into(),
729                base_url: None,
730                secret: None,
731                capabilities: vec![],
732                params: serde_json::Value::Null,
733                tier: None,
734                cost_per_1k_tokens: None,
735                input_cost_per_1k: None,
736                output_cost_per_1k: None,
737                context_window: None,
738                priced_at: None,
739                ..Default::default()
740            },
741        );
742        let yaml = serde_yaml_ng::to_string(&r2).unwrap();
743        assert!(
744            !yaml.contains("tier:"),
745            "absent tier should not be serialized: {yaml}"
746        );
747    }
748
749    #[test]
750    fn role_entry_parses_route_policy() {
751        let yaml = r#"
752schema_version: 1
753models:
754  haiku:
755    provider: anthropic
756    model: claude-haiku-4-5
757  opus:
758    provider: anthropic
759    model: claude-opus-4-7
760roles:
761  dev:
762    primary: opus
763    route_policy: !force_frontier
764      model_id: opus
765  reflector:
766    primary: haiku
767    route_policy: prefer_local
768  curator:
769    primary: haiku
770    route_policy: force_local
771  chat:
772    primary: haiku
773"#;
774        let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
775        assert_eq!(
776            r.roles["dev"].route_policy,
777            Some(RoutePolicy::ForceFrontier {
778                model_id: "opus".into()
779            })
780        );
781        assert_eq!(
782            r.roles["reflector"].route_policy,
783            Some(RoutePolicy::PreferLocal)
784        );
785        assert_eq!(
786            r.roles["curator"].route_policy,
787            Some(RoutePolicy::ForceLocal)
788        );
789        assert_eq!(r.roles["chat"].route_policy, None);
790    }
791
792    #[test]
793    fn parses_split_cost_fields() {
794        let yaml = r#"
795schema_version: 1
796models:
797  opus:
798    provider: anthropic
799    model: claude-opus-4-8
800    input_cost_per_1k: 0.005
801    output_cost_per_1k: 0.025
802    context_window: 200000
803"#;
804        let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
805        let e = r.models.get("opus").unwrap();
806        assert_eq!(e.input_cost_per_1k, Some(0.005));
807        assert_eq!(e.output_cost_per_1k, Some(0.025));
808        assert_eq!(e.context_window, Some(200_000));
809    }
810
811    #[test]
812    fn max_tokens_parses_and_is_omitted_when_unset() {
813        let yaml = r#"
814schema_version: 1
815models:
816  opus:
817    provider: claude
818    model: claude-opus-5-5
819    max_tokens: 128000
820  plain:
821    provider: anthropic
822    model: claude-opus-5-5
823"#;
824        let r: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
825        assert_eq!(r.models["opus"].max_tokens, Some(128_000));
826        assert_eq!(r.models["plain"].max_tokens, None);
827        // Unset stays out of the file, so saving an untouched registry does
828        // not grow a `max_tokens: null` line on every entry.
829        let out = serde_yaml_ng::to_string(&r.models["plain"]).unwrap();
830        assert!(!out.contains("max_tokens"), "{out}");
831    }
832
833    #[test]
834    fn default_model_entry_is_empty() {
835        let e = ModelEntry::default();
836        assert!(e.provider.is_empty());
837        assert_eq!(e.input_cost_per_1k, None);
838        assert_eq!(e.output_cost_per_1k, None);
839        assert_eq!(e.context_window, None);
840    }
841
842    #[test]
843    fn effective_costs_fallback_matrix() {
844        // legacy only → both fall back to the blended rate
845        let mut e = ModelEntry {
846            cost_per_1k_tokens: Some(0.01),
847            ..Default::default()
848        };
849        assert_eq!(e.effective_costs(), (Some(0.01), Some(0.01)));
850
851        // split only → split wins, legacy ignored
852        e = ModelEntry {
853            input_cost_per_1k: Some(0.005),
854            output_cost_per_1k: Some(0.025),
855            ..Default::default()
856        };
857        assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
858
859        // both → split wins
860        e = ModelEntry {
861            cost_per_1k_tokens: Some(0.01),
862            input_cost_per_1k: Some(0.005),
863            output_cost_per_1k: Some(0.025),
864            ..Default::default()
865        };
866        assert_eq!(e.effective_costs(), (Some(0.005), Some(0.025)));
867
868        // none → none
869        e = ModelEntry::default();
870        assert_eq!(e.effective_costs(), (None, None));
871    }
872}
873
874#[cfg(test)]
875mod io_tests {
876    use super::*;
877    use tempfile::tempdir;
878
879    #[test]
880    fn load_returns_empty_when_file_missing() {
881        let dir = tempdir().unwrap();
882        let r = ModelRegistry::load_from(&dir.path().join("nope.yaml")).unwrap();
883        assert_eq!(r.models.len(), 0);
884        assert_eq!(r.schema_version, 1);
885    }
886
887    #[test]
888    fn save_then_load_round_trips() {
889        let dir = tempdir().unwrap();
890        let p = dir.path().join("models.yaml");
891        let mut r = ModelRegistry::default();
892        r.models.insert(
893            "x".into(),
894            ModelEntry {
895                provider: "ollama".into(),
896                model: "llama3.2:3b".into(),
897                base_url: None,
898                secret: None,
899                capabilities: vec![],
900                params: serde_json::Value::Null,
901                tier: None,
902                cost_per_1k_tokens: None,
903                input_cost_per_1k: None,
904                output_cost_per_1k: None,
905                context_window: None,
906                priced_at: None,
907                ..Default::default()
908            },
909        );
910        r.save_to(&p).unwrap();
911        let r2 = ModelRegistry::load_from(&p).unwrap();
912        assert_eq!(r, r2);
913    }
914
915    #[test]
916    fn save_uses_atomic_rename() {
917        let dir = tempdir().unwrap();
918        let p = dir.path().join("models.yaml");
919        ModelRegistry::default().save_to(&p).unwrap();
920        let temp = dir.path().join("models.yaml.tmp");
921        assert!(!temp.exists(), "atomic temp left behind");
922    }
923}
924
925#[cfg(test)]
926mod switch_tests {
927    use super::*;
928    use crate::agent::AgentProfile;
929    use crate::config::{ModelSwitchConfig, RoutingConfig};
930
931    fn profile(model_ref: Option<&str>, chain: &[&str]) -> AgentProfile {
932        let mut p = AgentProfile::default_for_tests();
933        p.model_ref = model_ref.map(|s| s.to_string());
934        p.fallback_chain = chain.iter().map(|s| s.to_string()).collect();
935        p
936    }
937
938    #[test]
939    fn per_agent_primary_and_chain_win_over_global() {
940        let cfg = ModelSwitchConfig {
941            default: Some("global_default".into()),
942            fallback_chain: vec!["g1".into(), "g2".into()],
943            ..Default::default()
944        };
945        let p = profile(Some("agent_primary"), &["agent_primary", "agent_fb"]);
946        // per-agent model_ref is primary; per-agent chain used; primary de-duped.
947        assert_eq!(
948            resolve_model_refs(&p, &cfg, None),
949            vec!["agent_primary", "agent_fb"]
950        );
951    }
952
953    #[test]
954    fn falls_back_to_global_default_and_chain() {
955        let cfg = ModelSwitchConfig {
956            default: Some("global_default".into()),
957            fallback_chain: vec!["g1".into(), "global_default".into()],
958            ..Default::default()
959        };
960        let p = profile(None, &[]); // no per-agent model_ref or chain
961        // primary = global default; global chain used; primary de-duped out.
962        assert_eq!(
963            resolve_model_refs(&p, &cfg, None),
964            vec!["global_default", "g1"]
965        );
966    }
967
968    #[test]
969    fn routed_primary_overrides_model_ref() {
970        let cfg = ModelSwitchConfig {
971            fallback_chain: vec!["g1".into()],
972            ..Default::default()
973        };
974        let p = profile(Some("agent_primary"), &[]);
975        assert_eq!(
976            resolve_model_refs(&p, &cfg, Some("frontier".into())),
977            vec!["frontier", "g1"]
978        );
979    }
980
981    #[test]
982    fn no_config_no_agent_yields_empty() {
983        // Nothing configured → empty vec (caller falls back to inline model).
984        let cfg = ModelSwitchConfig::default();
985        assert!(resolve_model_refs(&profile(None, &[]), &cfg, None).is_empty());
986    }
987
988    #[test]
989    fn difficulty_picks_frontier_over_threshold() {
990        let r = RoutingConfig {
991            enabled: true,
992            cheap: Some("cheap".into()),
993            frontier: Some("frontier".into()),
994            threshold_input_tokens: Some(1000),
995        };
996        assert_eq!(choose_by_difficulty(1500, &r), Some("frontier".into()));
997        assert_eq!(choose_by_difficulty(500, &r), Some("cheap".into()));
998        // Misconfigured (missing frontier) → None (fall through).
999        let bad = RoutingConfig {
1000            enabled: true,
1001            cheap: Some("c".into()),
1002            frontier: None,
1003            threshold_input_tokens: None,
1004        };
1005        assert_eq!(choose_by_difficulty(9999, &bad), None);
1006    }
1007
1008    #[test]
1009    fn pick_cheap_model_lowest_cost_chat_excluding_primary() {
1010        let mut reg = ModelRegistry::default();
1011        let mk = |cost: f64, caps: &[&str]| ModelEntry {
1012            provider: "x".into(),
1013            model: "m".into(),
1014            capabilities: caps.iter().map(|s| s.to_string()).collect(),
1015            cost_per_1k_tokens: Some(cost),
1016            ..Default::default()
1017        };
1018        reg.models.insert("frontier".into(), mk(0.01, &["chat"]));
1019        reg.models.insert("cheap".into(), mk(0.0001, &["chat"]));
1020        reg.models
1021            .insert("embed".into(), mk(0.00001, &["embedding"])); // not chat → skip
1022        // cheapest chat-capable, excluding the agent's own primary:
1023        assert_eq!(
1024            pick_cheap_model(&reg, Some("cheap"), &[]),
1025            Some("frontier".into())
1026        ); // cheap excluded
1027        assert_eq!(pick_cheap_model(&reg, None, &[]), Some("cheap".into()));
1028        // no chat entries → None (Smart inert)
1029        let mut empty = ModelRegistry::default();
1030        empty.models.insert("e".into(), mk(0.0, &["embedding"]));
1031        assert_eq!(pick_cheap_model(&empty, None, &[]), None);
1032    }
1033
1034    #[test]
1035    fn satisfies_is_permissive_at_baseline_and_fail_closed_above_it() {
1036        let mk = |caps: &[&str]| ModelEntry {
1037            provider: "x".into(),
1038            model: "m".into(),
1039            capabilities: caps.iter().map(|s| s.to_string()).collect(),
1040            ..Default::default()
1041        };
1042        // Baseline: an entry written before the field existed is still chat.
1043        assert!(satisfies(&mk(&[]), &[]));
1044        assert!(satisfies(&mk(&["chat"]), &[]));
1045        assert!(!satisfies(&mk(&["embedding"]), &[]));
1046        // Above baseline: unstated is not permission.
1047        assert!(!satisfies(&mk(&[]), &[Requirement::Vision]));
1048        assert!(!satisfies(&mk(&["chat"]), &[Requirement::Vision]));
1049        assert!(satisfies(&mk(&["chat", "vision"]), &[Requirement::Vision]));
1050        assert!(!satisfies(&mk(&["chat", "vision"]), &[Requirement::Tools]));
1051        assert!(satisfies(
1052            &mk(&["chat", "vision", "tools"]),
1053            &[Requirement::Vision, Requirement::Tools]
1054        ));
1055    }
1056
1057    /// Tools and Vision disagree about silence on purpose. A tool-incapable
1058    /// model fails loudly and the chain advances; a blind one answers with
1059    /// confident nonsense. So an entry that declares nothing keeps its place in
1060    /// the chain for a tool turn — otherwise every pre-`capabilities` entry
1061    /// (most of a real registry) would drop out of every tool-carrying request
1062    /// — while the same silence disqualifies it for an image.
1063    #[test]
1064    fn undeclared_capabilities_pass_tools_but_never_vision() {
1065        let mk = |caps: &[&str]| ModelEntry {
1066            provider: "x".into(),
1067            model: "m".into(),
1068            capabilities: caps.iter().map(|s| s.to_string()).collect(),
1069            ..Default::default()
1070        };
1071        // Silence: permitted for tools, never for vision.
1072        assert!(satisfies(&mk(&[]), &[Requirement::Tools]));
1073        assert!(!satisfies(&mk(&[]), &[Requirement::Vision]));
1074        assert!(!satisfies(
1075            &mk(&[]),
1076            &[Requirement::Vision, Requirement::Tools]
1077        ));
1078        // A declaration is taken at its word in both directions.
1079        assert!(!satisfies(&mk(&["chat"]), &[Requirement::Tools]));
1080        assert!(satisfies(&mk(&["chat", "tools"]), &[Requirement::Tools]));
1081    }
1082
1083    /// The incident, as a regression test: an image request against a registry
1084    /// where nothing declares vision must find no cheap candidate at all.
1085    #[test]
1086    fn pick_cheap_model_declines_when_no_entry_declares_the_requirement() {
1087        let mk = |cost: f64, caps: &[&str]| ModelEntry {
1088            provider: "x".into(),
1089            model: "m".into(),
1090            capabilities: caps.iter().map(|s| s.to_string()).collect(),
1091            cost_per_1k_tokens: Some(cost),
1092            ..Default::default()
1093        };
1094        let mut reg = ModelRegistry::default();
1095        reg.models
1096            .insert("cheap_text".into(), mk(0.0001, &["chat"]));
1097        reg.models.insert("legacy".into(), mk(0.0002, &[]));
1098        reg.models
1099            .insert("frontier".into(), mk(0.01, &["chat", "vision"]));
1100        // No requirement -> cheapest wins (today's behaviour, unchanged).
1101        assert_eq!(pick_cheap_model(&reg, None, &[]), Some("cheap_text".into()));
1102        // Vision required -> only the declaring entry qualifies, cost be damned.
1103        assert_eq!(
1104            pick_cheap_model(&reg, None, &[Requirement::Vision]),
1105            Some("frontier".into())
1106        );
1107        // Nothing declares vision -> None, so Smart goes inert.
1108        let mut blind = ModelRegistry::default();
1109        blind
1110            .models
1111            .insert("cheap_text".into(), mk(0.0001, &["chat"]));
1112        blind.models.insert("legacy".into(), mk(0.0002, &[]));
1113        assert_eq!(pick_cheap_model(&blind, None, &[Requirement::Vision]), None);
1114    }
1115
1116    /// A price with no date is a guess wearing the costume of a fact. But a
1117    /// date on an entry that carries no price would be the same lie in the
1118    /// other direction, so the stamp is conditional on there being a rate.
1119    #[test]
1120    fn priced_at_stamps_only_priced_entries() {
1121        let now = chrono::Utc::now();
1122
1123        let mut unpriced = ModelEntry {
1124            provider: "openai".into(),
1125            model: "local-thing".into(),
1126            ..Default::default()
1127        };
1128        unpriced.stamp_priced_at(now);
1129        assert_eq!(unpriced.priced_at, None);
1130        assert_eq!(unpriced.price_age(now), None);
1131
1132        let mut priced = ModelEntry {
1133            output_cost_per_1k: Some(0.025),
1134            ..unpriced.clone()
1135        };
1136        priced.stamp_priced_at(now);
1137        assert_eq!(priced.priced_at, Some(now));
1138
1139        // A legacy single-rate entry counts as priced.
1140        let mut legacy = ModelEntry {
1141            cost_per_1k_tokens: Some(0.01),
1142            ..unpriced.clone()
1143        };
1144        legacy.stamp_priced_at(now);
1145        assert!(legacy.priced_at.is_some());
1146
1147        // Re-stamping never moves the date backwards.
1148        let earlier = now - chrono::TimeDelta::days(30);
1149        priced.stamp_priced_at(earlier);
1150        assert_eq!(priced.priced_at, Some(now));
1151    }
1152
1153    /// Entries written before this field existed must keep loading, and must
1154    /// report an unknown age rather than inheriting today's date.
1155    #[test]
1156    fn registry_without_priced_at_still_loads_and_reports_unknown_age() {
1157        let yaml = r#"
1158schema_version: 1
1159models:
1160  opus:
1161    provider: anthropic
1162    model: claude-opus-5
1163    input_cost_per_1k: 0.005
1164    output_cost_per_1k: 0.025
1165"#;
1166        let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
1167        let e = &reg.models["opus"];
1168        assert_eq!(e.priced_at, None);
1169        assert_eq!(e.price_age(chrono::Utc::now()), None);
1170        // Round-trips without inventing the field.
1171        let out = serde_yaml_ng::to_string(&reg).unwrap();
1172        assert!(!out.contains("priced_at"), "{out}");
1173    }
1174
1175    /// `mur model add --input-cost/--output-cost` leaves `cost_per_1k_tokens`
1176    /// unset, so an entry priced the current way must still be rankable.
1177    #[test]
1178    fn pick_cheap_model_sees_split_cost_entries() {
1179        let mut reg = ModelRegistry::default();
1180        let split = |input: f64, output: f64| ModelEntry {
1181            provider: "x".into(),
1182            model: "m".into(),
1183            capabilities: vec!["chat".into()],
1184            input_cost_per_1k: Some(input),
1185            output_cost_per_1k: Some(output),
1186            ..Default::default()
1187        };
1188        reg.models.insert("dear".into(), split(0.005, 0.025));
1189        reg.models.insert("cheap".into(), split(0.0001, 0.0004));
1190        assert_eq!(pick_cheap_model(&reg, None, &[]), Some("cheap".into()));
1191
1192        // Input-only entries are priced too, rather than silently skipped.
1193        let mut input_only = ModelRegistry::default();
1194        input_only.models.insert(
1195            "in".into(),
1196            ModelEntry {
1197                provider: "x".into(),
1198                model: "m".into(),
1199                capabilities: vec!["chat".into()],
1200                input_cost_per_1k: Some(0.002),
1201                ..Default::default()
1202            },
1203        );
1204        assert_eq!(pick_cheap_model(&input_only, None, &[]), Some("in".into()));
1205    }
1206
1207    #[test]
1208    fn subscription_metadata_round_trips_without_a_secret() {
1209        let yaml = r#"schema_version: 1
1210models:
1211  chatgpt_sol:
1212    provider: codex
1213    model: gpt-5.6-sol
1214    base_url: http://127.0.0.1:8088/codex/v1
1215    tier: frontier
1216    billing: subscription
1217    catalog_verified: true
1218"#;
1219        let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
1220        let entry = &reg.models["chatgpt_sol"];
1221        assert_eq!(entry.billing, Some(BillingMode::Subscription));
1222        assert_eq!(entry.catalog_verified, Some(true));
1223        assert!(entry.secret.is_none());
1224        let out = serde_yaml_ng::to_string(&reg).unwrap();
1225        assert!(out.contains("billing: subscription"), "{out}");
1226        assert!(out.contains("catalog_verified: true"), "{out}");
1227    }
1228
1229    /// Entries written before billing metadata existed keep loading and
1230    /// stay unknown — never inheriting a billing mode on reserialize.
1231    /// Explicit `billing:` wins. Without it, the provider decides what can be
1232    /// decided — ollama runs here, codex/claude ride a subscription — and
1233    /// everything else is treated as metered, because guessing "free" is the
1234    /// one mistake a cost gate must not make.
1235    #[test]
1236    fn provider_inference_and_route_tier_cover_existing_aliases() {
1237        for provider in [
1238            "ollama",
1239            "mlx",
1240            "llamacpp",
1241            "llama_cpp",
1242            "localai",
1243            "lmstudio",
1244            "local",
1245        ] {
1246            assert_eq!(
1247                inferred_billing_for_provider(provider),
1248                BillingMode::Local,
1249                "{provider}"
1250            );
1251            let entry = ModelEntry {
1252                provider: provider.into(),
1253                ..Default::default()
1254            };
1255            assert_eq!(entry.effective_route_tier(), RouteTier::Local, "{provider}");
1256        }
1257        for provider in ["claude", "codex"] {
1258            assert_eq!(
1259                inferred_billing_for_provider(provider),
1260                BillingMode::Subscription
1261            );
1262        }
1263        assert_eq!(
1264            inferred_billing_for_provider("unknown"),
1265            BillingMode::UsageBilled
1266        );
1267    }
1268
1269    #[test]
1270    fn projected_cost_uses_four_to_one_workload_and_legacy_rates() {
1271        let split = ModelEntry {
1272            input_cost_per_1k: Some(1.0),
1273            output_cost_per_1k: Some(10.0),
1274            ..Default::default()
1275        };
1276        assert_eq!(split.projected_cost(4_000, 1_000), Some(14.0));
1277        let legacy = ModelEntry {
1278            cost_per_1k_tokens: Some(2.0),
1279            ..Default::default()
1280        };
1281        assert_eq!(legacy.projected_cost(4_000, 1_000), Some(10.0));
1282        assert_eq!(ModelEntry::default().projected_cost(4_000, 1_000), None);
1283    }
1284
1285    #[test]
1286    fn pick_cheap_model_uses_projected_four_to_one_cost() {
1287        let mut reg = ModelRegistry::default();
1288        reg.models.insert(
1289            "cheap_input".into(),
1290            ModelEntry {
1291                provider: "openai".into(),
1292                model: "a".into(),
1293                input_cost_per_1k: Some(0.1),
1294                output_cost_per_1k: Some(2.0),
1295                ..Default::default()
1296            },
1297        );
1298        reg.models.insert(
1299            "cheap_output".into(),
1300            ModelEntry {
1301                provider: "openai".into(),
1302                model: "b".into(),
1303                input_cost_per_1k: Some(1.0),
1304                output_cost_per_1k: Some(0.1),
1305                ..Default::default()
1306            },
1307        );
1308        assert_eq!(
1309            pick_cheap_model(&reg, None, &[]),
1310            Some("cheap_input".into())
1311        );
1312    }
1313
1314    #[test]
1315    fn billing_is_inferred_from_the_provider_when_not_declared() {
1316        let mut e = ModelEntry {
1317            provider: "ollama".into(),
1318            model: "llama3.2:3b".into(),
1319            ..Default::default()
1320        };
1321        assert_eq!(e.billing_or_inferred(), BillingMode::Local);
1322        e.provider = "codex".into();
1323        assert_eq!(e.billing_or_inferred(), BillingMode::Subscription);
1324        e.provider = "claude".into();
1325        assert_eq!(e.billing_or_inferred(), BillingMode::Subscription);
1326        e.provider = "openai".into();
1327        assert_eq!(
1328            e.billing_or_inferred(),
1329            BillingMode::UsageBilled,
1330            "unknown is metered"
1331        );
1332        e.provider = "anthropic".into();
1333        assert_eq!(e.billing_or_inferred(), BillingMode::UsageBilled);
1334        // A declaration overrides every inference — an LM Studio entry is
1335        // `provider: openai` and the user marks it local.
1336        e.billing = Some(BillingMode::Local);
1337        assert_eq!(e.billing_or_inferred(), BillingMode::Local);
1338    }
1339
1340    #[test]
1341    fn entry_without_billing_metadata_stays_unknown() {
1342        let yaml = r#"schema_version: 1
1343models:
1344  gpt:
1345    provider: openai
1346    model: gpt-4o
1347    secret: env:OPENAI_API_KEY
1348"#;
1349        let reg: ModelRegistry = serde_yaml_ng::from_str(yaml).unwrap();
1350        let entry = &reg.models["gpt"];
1351        assert_eq!(entry.billing, None);
1352        assert_eq!(entry.catalog_verified, None);
1353        let out = serde_yaml_ng::to_string(&reg).unwrap();
1354        assert!(!out.contains("billing"), "{out}");
1355        assert!(!out.contains("catalog_verified"), "{out}");
1356        for (raw, mode) in [
1357            ("subscription", BillingMode::Subscription),
1358            ("usage_billed", BillingMode::UsageBilled),
1359            ("local", BillingMode::Local),
1360        ] {
1361            let m: BillingMode = serde_yaml_ng::from_str(raw).unwrap();
1362            assert_eq!(m, mode);
1363        }
1364    }
1365}