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;