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        Ok(crate::home::mur_home_or_err()?.join("models.yaml"))
314    }
315
316    /// Return the primary model ID for `role`, or the fallback if the primary
317    /// is not in the `models` map, or `None` if the role is not configured.
318    pub fn resolve_role(&self, role: &str) -> Option<&str> {
319        let entry = self.roles.get(role)?;
320        if self.models.contains_key(&entry.primary) {
321            return Some(&entry.primary);
322        }
323        // primary not in registry — try fallback
324        if let Some(fb) = &entry.fallback
325            && self.models.contains_key(fb)
326        {
327            return Some(fb);
328        }
329        // role configured but no available model
330        None
331    }
332}
333
334use crate::agent::AgentProfile;
335use crate::config::{DEFAULT_ROUTING_THRESHOLD, ModelSwitchConfig, RoutingConfig};
336
337/// Build the ordered list of model_refs to try: `[primary, ...fallback]`.
338/// Priority per-agent → global. The primary is de-duplicated out of the chain
339/// (no point retrying the same ref back-to-back). Returns empty when nothing is
340/// configured, so the caller keeps today's single-inline-model behaviour.
341pub fn resolve_model_refs(
342    profile: &AgentProfile,
343    cfg: &ModelSwitchConfig,
344    routed_primary: Option<String>,
345) -> Vec<String> {
346    let primary = routed_primary
347        .or_else(|| profile.model_ref.clone())
348        .or_else(|| cfg.default.clone());
349    let chain = if !profile.fallback_chain.is_empty() {
350        profile.fallback_chain.clone()
351    } else {
352        cfg.fallback_chain.clone()
353    };
354    let mut out: Vec<String> = Vec::new();
355    if let Some(p) = primary {
356        out.push(p);
357    }
358    for r in chain {
359        if !out.contains(&r) {
360            out.push(r);
361        }
362    }
363    out
364}
365
366/// Opt-in difficulty heuristic: pick `frontier` when the estimated input token
367/// count exceeds the threshold, else `cheap`. `None` when misconfigured (caller
368/// falls through to model_ref/global default).
369pub fn choose_by_difficulty(est_input_tokens: u32, r: &RoutingConfig) -> Option<String> {
370    let threshold = r
371        .threshold_input_tokens
372        .unwrap_or(DEFAULT_ROUTING_THRESHOLD);
373    match (r.cheap.as_ref(), r.frontier.as_ref()) {
374        (Some(cheap), Some(frontier)) => Some(if est_input_tokens > threshold {
375            frontier.clone()
376        } else {
377            cheap.clone()
378        }),
379        _ => None,
380    }
381}
382
383/// Registry capability strings. The baseline (`chat`) is legacy-permissive —
384/// an entry with no `capabilities` at all predates the field and is assumed
385/// chat-capable. Everything above the baseline is fail-closed.
386pub const CAP_CHAT: &str = "chat";
387pub const CAP_TOOLS: &str = "tools";
388pub const CAP_VISION: &str = "vision";
389
390/// A capability the request needs from whatever model serves it. Derived from
391/// the request itself (an image in the messages, a tool list) and never from
392/// config: a router may only substitute a model that can do the job.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum Requirement {
395    /// The request carries an image; the model has to be able to see it.
396    Vision,
397    /// The request declares tools; the model has to be able to call them.
398    Tools,
399}
400
401impl Requirement {
402    /// The registry capability an entry must declare to satisfy this.
403    pub fn capability(self) -> &'static str {
404        match self {
405            Requirement::Vision => CAP_VISION,
406            Requirement::Tools => CAP_TOOLS,
407        }
408    }
409
410    /// Does an entry that declares NO capabilities at all satisfy this?
411    ///
412    /// The two requirements differ in how they fail, and the answer follows
413    /// the failure mode rather than a blanket rule:
414    ///
415    /// - `Vision`: **no**. A model that cannot see answers an image request
416    ///   with confident nonsense — silent, and unrecoverable for that turn.
417    ///   That is the failure this gate exists to prevent, so silence about
418    ///   vision is treated as absence of it.
419    /// - `Tools`: **yes**. A model that cannot call tools fails loudly (the
420    ///   provider rejects the request) and the existing retry/advance path
421    ///   already handles it. Treating undeclared as incapable would drop every
422    ///   entry written before `capabilities` existed — in practice most of a
423    ///   real registry — out of the fallback chain of every tool-carrying turn,
424    ///   which is a large regression bought for very little.
425    ///
426    /// An entry that DOES declare capabilities is taken at its word either
427    /// way: if it enumerated what it can do and left `tools` out, that is a
428    /// statement, not silence.
429    fn permitted_when_undeclared(self) -> bool {
430        match self {
431            Requirement::Vision => false,
432            Requirement::Tools => true,
433        }
434    }
435}
436
437/// Can this entry serve a request needing `reqs`?
438///
439/// No registry write path emits `vision` today, so a `Vision` requirement
440/// disqualifies every current entry — auto-substitution goes inert for image
441/// requests rather than answering them blind. The same code makes a finer
442/// distinction the day entries start declaring it; there is no second version
443/// of this function to write later.
444pub fn satisfies(e: &ModelEntry, reqs: &[Requirement]) -> bool {
445    let chat_capable = e.capabilities.is_empty() || e.capabilities.iter().any(|c| c == CAP_CHAT);
446    if !chat_capable {
447        return false;
448    }
449    reqs.iter().all(|r| {
450        if e.capabilities.is_empty() {
451            r.permitted_when_undeclared()
452        } else {
453            e.capabilities.iter().any(|c| c == r.capability())
454        }
455    })
456}
457
458/// Pick the cheapest registry entry that can serve a request needing `reqs`,
459/// excluding `exclude` (the agent's own primary). None when no qualifying
460/// entry exists → caller keeps normal candidates (fail-expensive).
461pub fn pick_cheap_model(
462    reg: &ModelRegistry,
463    exclude: Option<&str>,
464    reqs: &[Requirement],
465) -> Option<String> {
466    reg.models
467        .iter()
468        .filter(|(k, _)| exclude != Some(k.as_str()))
469        .filter(|(_, e)| satisfies(e, reqs))
470        .filter_map(|(k, e)| e.projected_cost(4_000, 1_000).map(|c| (c, k.clone())))
471        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
472        .map(|(_, k)| k)
473}
474
475#[cfg(test)]
476mod tests;
477
478#[cfg(test)]
479mod io_tests;
480
481#[cfg(test)]
482mod switch_tests;