Skip to main content

newt_core/
model_card.rs

1//! Data-driven **model cards** (#853) — the central abstraction for standing a
2//! model up. One card holds *all* the settings needed to serve exactly one model
3//! on one backend (vLLM or ollama), plus its sampling `tuning` and reasoning
4//! `capability` bits. Authoring a new card is how you add a model newt has never
5//! heard of.
6//!
7//! Three Cs: knowledge lives in **data**, defaults are **overridable**. A card is
8//! deserialized from TOML *or* YAML; every field is `Option` so a partial overlay
9//! overrides only what it sets, giving the precedence
10//! `built-in < ~/.newt/models/<name> < --card < CLI flag`. [`ModelCard::merge`],
11//! [`ModelCard::validate`], and [`resolve`] are **pure / IO-free** (the file reads
12//! in [`load_card_file`] / [`load_dropin_dir`] are the only IO) — mirroring the
13//! `dgx_vllm` / `dgx_pull` discipline.
14//!
15//! **IDENTITIES ONLY** (public-repo rule, like `newt-cli::dgx_registry`): a card
16//! carries model identities + serving *profiles* — never a host / IP / GPU / DNS.
17//! The endpoint stays in the operator's local `[dgx]` config, resolved at runtime.
18//! Fields that *look* hardware-ish but are not: `gpu_mem` is a
19//! `--gpu-memory-utilization` **fraction**, `tensor_parallel` a topology knob,
20//! `served_name` an OpenAI alias — none identify a machine. [`no_hardware_leak`]
21//! enforces this and is applied to every built-in card.
22
23use std::path::Path;
24
25use serde::{Deserialize, Serialize};
26
27/// The inference backend a card targets. newt-cli's `InferenceTool` is the
28/// CLI-side sibling; this core-side enum lets the harness read a card without a
29/// newt-cli dependency (crate boundary). A future pass can unify them.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum Backend {
33    /// vLLM (OpenAI-compatible server; FP8/FP16 + multi-node tensor-parallel).
34    Vllm,
35    /// Ollama (GGUF single-node convenience runtime).
36    Ollama,
37    /// llama.cpp (GGUF experimental large-model single-node path).
38    LlamaCpp,
39}
40
41impl Backend {
42    /// Stable lowercase token (matches `InferenceTool::as_str`).
43    #[must_use]
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Vllm => "vllm",
47            Self::Ollama => "ollama",
48            Self::LlamaCpp => "llama_cpp",
49        }
50    }
51}
52
53impl std::str::FromStr for Backend {
54    type Err = String;
55    fn from_str(s: &str) -> Result<Self, String> {
56        match s.trim().to_ascii_lowercase().as_str() {
57            "vllm" => Ok(Self::Vllm),
58            "ollama" => Ok(Self::Ollama),
59            "llama_cpp" | "llama.cpp" | "llamacpp" => Ok(Self::LlamaCpp),
60            other => Err(format!(
61                "unknown backend `{other}` (expected vllm | ollama | llama_cpp)"
62            )),
63        }
64    }
65}
66
67/// vLLM serving profile — the `vllm serve` knobs. Every field `Option` for layered
68/// override.
69#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct VllmProfile {
72    /// `--served-model-name` (the OpenAI alias clients send as `model`).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub served_name: Option<String>,
75    /// `--max-model-len` (context window).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub max_model_len: Option<u32>,
78    /// `--tensor-parallel-size` (topology knob — not a machine id).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub tensor_parallel: Option<u8>,
81    /// `--gpu-memory-utilization` **fraction** (0.0–1.0) — a knob, not a machine id.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub gpu_mem: Option<f64>,
84    /// `--reasoning-parser` (e.g. `qwen3`) so CoT lands in `reasoning_content`.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub reasoning_parser: Option<String>,
87    /// `--tool-call-parser` (e.g. `qwen3_xml`) so tool calls surface as `tool_calls`.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub tool_call_parser: Option<String>,
90    /// `--enable-auto-tool-choice`.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub enable_auto_tool_choice: Option<bool>,
93    /// Extra raw `vllm serve` argv appended verbatim (the escape hatch).
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub extra: Vec<String>,
96}
97
98impl VllmProfile {
99    #[must_use]
100    pub(crate) fn merge(self, o: Self) -> Self {
101        Self {
102            served_name: o.served_name.or(self.served_name),
103            max_model_len: o.max_model_len.or(self.max_model_len),
104            tensor_parallel: o.tensor_parallel.or(self.tensor_parallel),
105            gpu_mem: o.gpu_mem.or(self.gpu_mem),
106            reasoning_parser: o.reasoning_parser.or(self.reasoning_parser),
107            tool_call_parser: o.tool_call_parser.or(self.tool_call_parser),
108            enable_auto_tool_choice: o.enable_auto_tool_choice.or(self.enable_auto_tool_choice),
109            extra: if o.extra.is_empty() {
110                self.extra
111            } else {
112                o.extra
113            },
114        }
115    }
116}
117
118/// A named family's default serving knobs — the layer UNDER a card's own
119/// `[vllm]` table in [`resolve`]. Deliberately NOT a full [`ModelCard`]: a
120/// family default is never served directly (no `name`/`backend`/footprint to
121/// carry), it exists purely to be shared, and the card's own declarations
122/// always win field-by-field over it (the same `.or()` precedence every other
123/// override layer in this module uses).
124#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct FamilyDefaults {
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub vllm: Option<VllmProfile>,
129}
130
131/// Ollama serving profile.
132#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct OllamaProfile {
135    /// Ollama tag, e.g. `ornith:35b`.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub tag: Option<String>,
138    /// `num_ctx` — set EXPLICITLY (a large auto value can OOM the KV cache).
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub num_ctx: Option<u32>,
141    /// Optional Modelfile template override.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub template: Option<String>,
144}
145
146impl OllamaProfile {
147    #[must_use]
148    fn merge(self, o: Self) -> Self {
149        Self {
150            tag: o.tag.or(self.tag),
151            num_ctx: o.num_ctx.or(self.num_ctx),
152            template: o.template.or(self.template),
153        }
154    }
155}
156
157/// Per-model sampling tuning (seeds a `[[model_tuning]]` entry on `setup`).
158#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct Tuning {
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub temperature: Option<f64>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub top_p: Option<f64>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub top_k: Option<u32>,
167    /// The context-token budget the harness should plan against.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub context_tokens: Option<u32>,
170}
171
172impl Tuning {
173    #[must_use]
174    fn merge(self, o: Self) -> Self {
175        Self {
176            temperature: o.temperature.or(self.temperature),
177            top_p: o.top_p.or(self.top_p),
178            top_k: o.top_k.or(self.top_k),
179            context_tokens: o.context_tokens.or(self.context_tokens),
180        }
181    }
182}
183
184/// Reasoning capability bits the harness reads (retires the `reasoning.rs`
185/// name-match in a later issue).
186#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
187#[serde(deny_unknown_fields)]
188pub struct Capability {
189    /// The model opens its turn with a reasoning / `<think>` block that must be
190    /// split out of the answer.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub emits_leading_reasoning: Option<bool>,
193    /// Thinking is on by default (the per-turn toggle's fail-safe value).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub thinking_default: Option<bool>,
196    /// The server returns CoT in a separate response field of this name (e.g.
197    /// `reasoning_content`); `None` = inline `<think>` inside `content`.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub reasoning_content_field: Option<String>,
200}
201
202impl Capability {
203    #[must_use]
204    fn merge(self, o: Self) -> Self {
205        Self {
206            emits_leading_reasoning: o.emits_leading_reasoning.or(self.emits_leading_reasoning),
207            thinking_default: o.thinking_default.or(self.thinking_default),
208            reasoning_content_field: o.reasoning_content_field.or(self.reasoning_content_field),
209        }
210    }
211}
212
213/// A model card: everything needed to stand up one model. `name` is required (it
214/// is the merge key); every other field is `Option` so a partial overlay overrides
215/// only what it sets.
216#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct ModelCard {
219    /// Model identity, e.g. `Ornith-1.0-35B`. The merge / drop-in key.
220    pub name: String,
221    /// Which backend `setup` stands up.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub backend: Option<Backend>,
224    /// Approximate resident footprint (GiB) — a serving knob (like
225    /// `ModelVariant.gib`), not a machine id; used by the hardware-fit gate.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub footprint_gib: Option<f64>,
228    /// Likely exceeds a typical node (e.g. the 397B) — `setup` warns / requires
229    /// `--force`.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub gated: Option<bool>,
232    /// The model family this card belongs to (e.g. `"qwen3"`), if any — looks
233    /// up [`family_defaults`] as the base layer UNDER this card's own `[vllm]`
234    /// table in [`resolve`], so cards in the same family (different sizes of
235    /// the same tokenizer/parser lineage) don't each duplicate
236    /// `reasoning_parser`/`tool_call_parser`/etc. `None` means no family layer
237    /// applies — today's pre-family behavior, unchanged.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub family: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub vllm: Option<VllmProfile>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub ollama: Option<OllamaProfile>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub tuning: Option<Tuning>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub capability: Option<Capability>,
248}
249
250fn merge_opt<T>(base: Option<T>, overlay: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
251    match (base, overlay) {
252        (Some(b), Some(o)) => Some(f(b, o)),
253        (b, None) => b,
254        (None, o) => o,
255    }
256}
257
258impl ModelCard {
259    /// Overlay `self` under `overlay` — overlay wins field-by-field, deep-merging
260    /// the nested tables. Pure. Precedence chains left-to-right:
261    /// `builtin.merge(dropin).merge(one_off).merge(flags)`.
262    #[must_use]
263    pub fn merge(self, overlay: Self) -> Self {
264        Self {
265            name: if overlay.name.trim().is_empty() {
266                self.name
267            } else {
268                overlay.name
269            },
270            backend: overlay.backend.or(self.backend),
271            footprint_gib: overlay.footprint_gib.or(self.footprint_gib),
272            gated: overlay.gated.or(self.gated),
273            family: overlay.family.or(self.family),
274            vllm: merge_opt(self.vllm, overlay.vllm, VllmProfile::merge),
275            ollama: merge_opt(self.ollama, overlay.ollama, OllamaProfile::merge),
276            tuning: merge_opt(self.tuning, overlay.tuning, Tuning::merge),
277            capability: merge_opt(self.capability, overlay.capability, Capability::merge),
278        }
279    }
280
281    /// Reject a structurally-invalid card **loudly** (never silently): empty name,
282    /// no backend, a backend whose serving block is absent, or a `family` naming
283    /// no known family defaults (almost always a typo — silently applying no
284    /// defaults would be a quieter, harder-to-notice version of the same
285    /// mistake `deny_unknown_fields` guards against elsewhere in this module).
286    ///
287    /// # Errors
288    /// Returns a human-readable reason when the card cannot be stood up.
289    pub fn validate(&self) -> Result<(), String> {
290        if self.name.trim().is_empty() {
291            return Err("model card: `name` is empty".to_string());
292        }
293        if let Some(family) = self.family.as_deref() {
294            if family_defaults(family).is_none() {
295                return Err(format!(
296                    "model card `{}`: family `{family}` has no known defaults \
297                     (check for a typo, or add cards/families/{family}.toml)",
298                    self.name
299                ));
300            }
301        }
302        match self.backend {
303            None => Err(format!(
304                "model card `{}`: no `backend` set (vllm | ollama | llama_cpp)",
305                self.name
306            )),
307            Some(Backend::Vllm) if self.vllm.is_none() => Err(format!(
308                "model card `{}`: backend=vllm but no [vllm] serving block",
309                self.name
310            )),
311            Some(Backend::Ollama) if self.ollama.is_none() => Err(format!(
312                "model card `{}`: backend=ollama but no [ollama] serving block",
313                self.name
314            )),
315            Some(_) => Ok(()),
316        }
317    }
318
319    /// The card as canonical TOML (for `card show` / seeding `~/.newt`).
320    ///
321    /// # Errors
322    /// Propagates a serialization error (should not occur for a valid card).
323    pub fn to_toml(&self) -> Result<String, String> {
324        toml::to_string_pretty(self).map_err(|e| format!("card serialize: {e}"))
325    }
326}
327
328/// Parse a card from `contents` as TOML or YAML, dispatched by `ext` (with or
329/// without a leading dot). Pure over the bytes + extension.
330///
331/// # Errors
332/// Returns a parse error, or an "unknown extension" error for anything other than
333/// `toml` / `yaml` / `yml`.
334pub fn parse_card(contents: &str, ext: &str) -> Result<ModelCard, String> {
335    match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
336        "toml" => toml::from_str(contents).map_err(|e| format!("card TOML: {e}")),
337        "yaml" | "yml" => serde_yaml::from_str(contents).map_err(|e| format!("card YAML: {e}")),
338        other => Err(format!(
339            "card: unknown extension `.{other}` (expected .toml / .yaml / .yml)"
340        )),
341    }
342}
343
344/// Load a single card file (`--card <path>`), dispatching TOML/YAML on extension.
345///
346/// # Errors
347/// Returns a read error or a parse error.
348pub fn load_card_file(path: &Path) -> Result<ModelCard, String> {
349    let contents =
350        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
351    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
352    parse_card(&contents, ext)
353}
354
355/// Load every droppable card in `dir` (`*.toml` / `*.yaml` / `*.yml`). A missing
356/// dir yields an empty list; an unreadable/invalid file is skipped (best-effort,
357/// like the language-pack loader). The merge itself stays pure via [`resolve`].
358#[must_use]
359pub fn load_dropin_dir(dir: &Path) -> Vec<ModelCard> {
360    let mut out = Vec::new();
361    let Ok(rd) = std::fs::read_dir(dir) else {
362        return out;
363    };
364    for entry in rd.flatten() {
365        let p = entry.path();
366        let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
367        if matches!(ext, "toml" | "yaml" | "yml") {
368            if let Ok(card) = load_card_file(&p) {
369                out.push(card);
370            }
371        }
372    }
373    out
374}
375
376/// Resolve the effective card for a `builtin`: apply the `builtin`'s own
377/// [`family_defaults`] as the base layer under its `[vllm]` table (a card's own
378/// settings always win field-by-field — family defaults only fill gaps), then
379/// overlay every drop-in whose `name` matches (merge-by-name, the language-pack
380/// rule), then an optional one-off (`--card`). **Pure** over the inputs +
381/// the embedded family table.
382#[must_use]
383pub fn resolve(builtin: ModelCard, dropins: &[ModelCard], one_off: Option<ModelCard>) -> ModelCard {
384    let mut card = builtin;
385    if let Some(family) = card.family.clone() {
386        if let Some(defaults) = family_defaults(&family) {
387            card.vllm = merge_opt(defaults.vllm, card.vllm, VllmProfile::merge);
388        }
389    }
390    let name = card.name.clone();
391    for d in dropins.iter().filter(|d| d.name == name) {
392        card = card.merge(d.clone());
393    }
394    if let Some(o) = one_off {
395        card = card.merge(o);
396    }
397    card
398}
399
400/// The built-in family-default tables shipped with newt (embedded DATA) — named
401/// serving-knob presets a card opts into via its own `family` field, so cards
402/// sharing a tokenizer/parser lineage (different sizes of the same family)
403/// don't each duplicate the same `[vllm]` settings. Adding a new family is a
404/// new `cards/families/<name>.toml` file plus one entry here — config, not code.
405#[must_use]
406pub fn family_defaults(family: &str) -> Option<FamilyDefaults> {
407    const EMBEDDED: &[(&str, &str)] = &[("qwen3", include_str!("cards/families/qwen3.toml"))];
408    let want = family.trim().to_ascii_lowercase();
409    EMBEDDED
410        .iter()
411        .find(|(name, _)| *name == want)
412        .map(|(_, toml)| toml::from_str(toml).expect("built-in family-defaults file is valid TOML"))
413}
414
415/// The built-in model cards shipped with newt (embedded DATA) — the base layer of
416/// the precedence chain. A drop-in `~/.newt/models/<name>.toml` overrides one by
417/// name; `--card <path>` overlays a one-off. Ornith-1.0-35B is the reference card;
418/// the 397B ships `gated` (it almost certainly exceeds a single node).
419#[must_use]
420pub fn builtin_cards() -> Vec<ModelCard> {
421    const EMBEDDED: &[&str] = &[
422        include_str!("cards/ornith-1.0-35b.toml"),
423        include_str!("cards/ornith-1.0-397b.toml"),
424    ];
425    EMBEDDED
426        .iter()
427        .map(|s| parse_card(s, "toml").expect("built-in card is valid TOML"))
428        .collect()
429}
430
431/// The built-in card whose `name` matches (case-insensitive), if any.
432#[must_use]
433pub fn builtin_card(name: &str) -> Option<ModelCard> {
434    let want = name.trim().to_ascii_lowercase();
435    builtin_cards()
436        .into_iter()
437        .find(|c| c.name.to_ascii_lowercase() == want)
438}
439
440/// Scan a card for a leaked machine identity — an RFC1918 / CGNAT IPv4 literal in
441/// any string field. Built-in cards MUST pass (identities only; the endpoint lives
442/// in local `[dgx]` config). Returns the offending values (empty = clean).
443///
444/// A pragmatic guard, not a full network parser: it flags dotted-quad literals in
445/// the private / carrier-grade ranges, which is what a stray endpoint looks like.
446#[must_use]
447pub fn no_hardware_leak(card: &ModelCard) -> Vec<String> {
448    // Serialize to TOML and scan the text — covers every string field uniformly.
449    let text = card.to_toml().unwrap_or_default();
450    let mut hits = Vec::new();
451    for tok in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
452        if is_private_ipv4(tok) {
453            hits.push(tok.to_string());
454        }
455    }
456    hits
457}
458
459/// True for an RFC1918 (`10.`, `172.16–31.`, `192.168.`) or CGNAT (`100.64–127.`)
460/// dotted-quad. Pure.
461fn is_private_ipv4(s: &str) -> bool {
462    let octets: Vec<&str> = s.split('.').collect();
463    if octets.len() != 4 {
464        return false;
465    }
466    let mut o = [0u16; 4];
467    for (i, part) in octets.iter().enumerate() {
468        match part.parse::<u16>() {
469            Ok(v) if v <= 255 && (part.len() == 1 || !part.starts_with('0')) => o[i] = v,
470            _ => return false,
471        }
472    }
473    o[0] == 10
474        || (o[0] == 172 && (16..=31).contains(&o[1]))
475        || (o[0] == 192 && o[1] == 168)
476        || (o[0] == 100 && (64..=127).contains(&o[1]))
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use std::str::FromStr;
483
484    fn ornith_toml() -> &'static str {
485        r#"
486name = "Ornith-1.0-35B"
487backend = "vllm"
488footprint_gib = 35.0
489
490[vllm]
491served_name = "Ornith-1.0-35B"
492max_model_len = 262144
493reasoning_parser = "qwen3"
494tool_call_parser = "qwen3_xml"
495enable_auto_tool_choice = true
496
497[tuning]
498temperature = 0.6
499top_p = 0.95
500top_k = 20
501context_tokens = 262144
502
503[capability]
504emits_leading_reasoning = true
505thinking_default = true
506reasoning_content_field = "reasoning_content"
507"#
508    }
509
510    // ── Backend vocabulary ────────────────────────────────────────────────
511    #[test]
512    fn backend_tokens_round_trip() {
513        for (tok, b) in [
514            ("vllm", Backend::Vllm),
515            ("ollama", Backend::Ollama),
516            ("llama_cpp", Backend::LlamaCpp),
517        ] {
518            assert_eq!(Backend::from_str(tok).unwrap(), b);
519            assert_eq!(b.as_str(), tok);
520        }
521        // Tolerant aliases + case + whitespace.
522        assert_eq!(Backend::from_str("  VLLM ").unwrap(), Backend::Vllm);
523        assert_eq!(Backend::from_str("llama.cpp").unwrap(), Backend::LlamaCpp);
524        // Unknown fails loudly.
525        assert!(Backend::from_str("tgi").is_err());
526    }
527
528    // ── TOML / YAML parity ────────────────────────────────────────────────
529    #[test]
530    fn parses_from_toml_and_yaml_identically() {
531        let from_toml = parse_card(ornith_toml(), "toml").expect("toml");
532        // The same card in YAML.
533        let yaml = r#"
534name: Ornith-1.0-35B
535backend: vllm
536footprint_gib: 35.0
537vllm:
538  served_name: Ornith-1.0-35B
539  max_model_len: 262144
540  reasoning_parser: qwen3
541  tool_call_parser: qwen3_xml
542  enable_auto_tool_choice: true
543tuning:
544  temperature: 0.6
545  top_p: 0.95
546  top_k: 20
547  context_tokens: 262144
548capability:
549  emits_leading_reasoning: true
550  thinking_default: true
551  reasoning_content_field: reasoning_content
552"#;
553        let from_yaml = parse_card(yaml, "yml").expect("yaml");
554        assert_eq!(from_toml, from_yaml, "TOML and YAML must parse identically");
555        // And a TOML round-trip is stable.
556        let reparsed = parse_card(&from_toml.to_toml().unwrap(), "toml").unwrap();
557        assert_eq!(reparsed, from_toml);
558    }
559
560    #[test]
561    fn parse_rejects_unknown_extension_and_bad_syntax() {
562        assert!(parse_card(ornith_toml(), "json").is_err());
563        assert!(parse_card("name = \"x\"\nbogus_field = 1", "toml").is_err()); // deny_unknown_fields
564    }
565
566    // ── Merge / layered override ──────────────────────────────────────────
567    #[test]
568    fn overlay_wins_field_by_field_and_deep_merges() {
569        let base = parse_card(ornith_toml(), "toml").unwrap();
570        // An overlay that sets ONLY vllm.max_model_len + tuning.temperature.
571        let overlay: ModelCard = toml::from_str(
572            "name = \"Ornith-1.0-35B\"\n[vllm]\nmax_model_len = 65536\n[tuning]\ntemperature = 1.0",
573        )
574        .unwrap();
575        let merged = base.clone().merge(overlay);
576        let v = merged.vllm.as_ref().unwrap();
577        // The overlaid field wins…
578        assert_eq!(v.max_model_len, Some(65536));
579        assert_eq!(merged.tuning.as_ref().unwrap().temperature, Some(1.0));
580        // …and every un-set field is INHERITED from the base (deep merge).
581        assert_eq!(v.reasoning_parser.as_deref(), Some("qwen3"));
582        assert_eq!(v.tool_call_parser.as_deref(), Some("qwen3_xml"));
583        assert_eq!(merged.tuning.as_ref().unwrap().top_p, Some(0.95));
584        assert_eq!(
585            merged.capability.as_ref().unwrap().thinking_default,
586            Some(true)
587        );
588    }
589
590    #[test]
591    fn resolve_applies_precedence_builtin_dropin_oneoff() {
592        let builtin = parse_card(ornith_toml(), "toml").unwrap();
593        // Drop-in bumps gpu_mem; matched by name.
594        let dropin: ModelCard =
595            toml::from_str("name = \"Ornith-1.0-35B\"\n[vllm]\ngpu_mem = 0.9").unwrap();
596        // A drop-in for a DIFFERENT model must be ignored.
597        let other: ModelCard =
598            toml::from_str("name = \"Other\"\n[vllm]\nmax_model_len = 1").unwrap();
599        // One-off (--card) overrides max_model_len last.
600        let one_off: ModelCard =
601            toml::from_str("name = \"Ornith-1.0-35B\"\n[vllm]\nmax_model_len = 131072").unwrap();
602        let card = resolve(builtin, &[dropin, other], Some(one_off));
603        let v = card.vllm.unwrap();
604        assert_eq!(v.gpu_mem, Some(0.9), "drop-in applied");
605        assert_eq!(v.max_model_len, Some(131072), "one-off wins over built-in");
606        assert_eq!(
607            v.reasoning_parser.as_deref(),
608            Some("qwen3"),
609            "base inherited"
610        );
611    }
612
613    // ── Family defaults ───────────────────────────────────────────────────
614    #[test]
615    fn family_defaults_returns_known_family_case_insensitively() {
616        for name in ["qwen3", "QWEN3", "Qwen3"] {
617            let d = family_defaults(name).unwrap_or_else(|| panic!("{name} should be known"));
618            let v = d.vllm.unwrap();
619            assert_eq!(v.reasoning_parser.as_deref(), Some("qwen3"));
620            assert_eq!(v.tool_call_parser.as_deref(), Some("qwen3_xml"));
621            assert_eq!(v.enable_auto_tool_choice, Some(true));
622        }
623    }
624
625    #[test]
626    fn family_defaults_is_none_for_an_unknown_family() {
627        assert!(family_defaults("nemotron").is_none());
628        assert!(family_defaults("bogus").is_none());
629    }
630
631    #[test]
632    fn resolve_fills_vllm_gaps_from_family_defaults() {
633        let card: ModelCard = toml::from_str(
634            "name = \"test\"\nbackend = \"vllm\"\nfamily = \"qwen3\"\n\
635             [vllm]\nserved_name = \"test\"\nmax_model_len = 8192\n",
636        )
637        .unwrap();
638        let resolved = resolve(card, &[], None);
639        let v = resolved.vllm.unwrap();
640        // The card's own fields stand...
641        assert_eq!(v.served_name.as_deref(), Some("test"));
642        assert_eq!(v.max_model_len, Some(8192));
643        // ...and the family fills what the card didn't set.
644        assert_eq!(v.reasoning_parser.as_deref(), Some("qwen3"));
645        assert_eq!(v.tool_call_parser.as_deref(), Some("qwen3_xml"));
646        assert_eq!(v.enable_auto_tool_choice, Some(true));
647    }
648
649    #[test]
650    fn resolve_cards_own_vllm_field_wins_over_family_default() {
651        let card: ModelCard = toml::from_str(
652            "name = \"test\"\nbackend = \"vllm\"\nfamily = \"qwen3\"\n\
653             [vllm]\ntool_call_parser = \"custom_xml\"\n",
654        )
655        .unwrap();
656        let resolved = resolve(card, &[], None);
657        let v = resolved.vllm.unwrap();
658        assert_eq!(
659            v.tool_call_parser.as_deref(),
660            Some("custom_xml"),
661            "the card's own declaration overrides the family default, never the reverse"
662        );
663        assert_eq!(
664            v.reasoning_parser.as_deref(),
665            Some("qwen3"),
666            "a field the card didn't set still inherits from the family"
667        );
668    }
669
670    #[test]
671    fn resolve_with_no_family_is_unaffected_by_family_defaults() {
672        // Backward compatible: a card with no `family` behaves exactly as
673        // before family defaults existed — no layer applied, no panic.
674        let card: ModelCard =
675            toml::from_str("name = \"test\"\nbackend = \"vllm\"\n[vllm]\nserved_name = \"test\"\n")
676                .unwrap();
677        let resolved = resolve(card, &[], None);
678        assert_eq!(resolved.vllm.unwrap().reasoning_parser, None);
679    }
680
681    #[test]
682    fn resolve_with_an_unknown_family_leaves_vllm_unchanged() {
683        // resolve() itself never errors — validate() is where an unknown
684        // family surfaces as a loud, actionable problem (see below). A card
685        // naming a family with no known defaults just gets no extra layer.
686        let card: ModelCard = toml::from_str(
687            "name = \"test\"\nbackend = \"vllm\"\nfamily = \"bogus\"\n\
688             [vllm]\nserved_name = \"test\"\n",
689        )
690        .unwrap();
691        let resolved = resolve(card, &[], None);
692        assert_eq!(resolved.vllm.unwrap().served_name.as_deref(), Some("test"));
693    }
694
695    #[test]
696    fn validate_rejects_an_unknown_family_name() {
697        let card: ModelCard = toml::from_str(
698            "name = \"test\"\nbackend = \"vllm\"\nfamily = \"qwenn3\"\n\
699             [vllm]\nserved_name = \"test\"\n",
700        )
701        .unwrap();
702        let err = card
703            .validate()
704            .expect_err("unknown family must be rejected");
705        assert!(err.contains("qwenn3"), "{err}");
706    }
707
708    #[test]
709    fn validate_accepts_a_known_family_name() {
710        let card: ModelCard = toml::from_str(
711            "name = \"test\"\nbackend = \"vllm\"\nfamily = \"qwen3\"\n\
712             [vllm]\nserved_name = \"test\"\n",
713        )
714        .unwrap();
715        assert!(card.validate().is_ok());
716    }
717
718    // ── Validate ──────────────────────────────────────────────────────────
719    #[test]
720    fn validate_accepts_a_well_formed_card() {
721        assert!(parse_card(ornith_toml(), "toml")
722            .unwrap()
723            .validate()
724            .is_ok());
725    }
726
727    #[test]
728    fn validate_rejects_empty_name_missing_backend_and_missing_block() {
729        let mut c = parse_card(ornith_toml(), "toml").unwrap();
730        c.name = "  ".into();
731        assert!(c.validate().is_err(), "empty name");
732
733        let no_backend: ModelCard = toml::from_str("name = \"X\"").unwrap();
734        assert!(no_backend.validate().is_err(), "no backend");
735
736        let vllm_no_block: ModelCard = toml::from_str("name = \"X\"\nbackend = \"vllm\"").unwrap();
737        assert!(vllm_no_block.validate().is_err(), "backend=vllm, no [vllm]");
738
739        let ollama_no_block: ModelCard =
740            toml::from_str("name = \"X\"\nbackend = \"ollama\"").unwrap();
741        assert!(
742            ollama_no_block.validate().is_err(),
743            "backend=ollama, no [ollama]"
744        );
745    }
746
747    // ── No hardware leak ──────────────────────────────────────────────────
748    #[test]
749    fn no_hardware_leak_flags_a_private_ip_and_passes_clean_cards() {
750        // Clean card: no leak.
751        let clean = parse_card(ornith_toml(), "toml").unwrap();
752        assert!(
753            no_hardware_leak(&clean).is_empty(),
754            "identities-only card is clean"
755        );
756
757        // A card that smuggled an endpoint into served_name is caught. Build the
758        // private IP from octets so no literal RFC1918 dotted-quad sits in this
759        // public source (the pre-push network-leak guard).
760        let ip = format!("{}.{}.{}.{}", 192, 168, 1, 100);
761        let leaky: ModelCard = toml::from_str(&format!(
762            "name = \"X\"\nbackend = \"vllm\"\n[vllm]\nserved_name = \"http://{ip}:8000\""
763        ))
764        .unwrap();
765        assert_eq!(no_hardware_leak(&leaky), vec![ip]);
766    }
767
768    #[test]
769    fn is_private_ipv4_ranges() {
770        // Build the private-range samples from octets so no literal RFC1918/CGNAT
771        // dotted-quad appears in this public source (the pre-push leak guard).
772        for o in [
773            [10, 0, 0, 1],
774            [172, 16, 0, 1],
775            [172, 31, 255, 255],
776            [192, 168, 1, 1],
777            [100, 64, 0, 1],
778        ] {
779            let ip = format!("{}.{}.{}.{}", o[0], o[1], o[2], o[3]);
780            assert!(is_private_ipv4(&ip), "{ip} is private");
781        }
782        for ip in [
783            "8.8.8.8",
784            "172.15.0.1",
785            "172.32.0.1",
786            "1.2.3",
787            "256.1.1.1",
788            "01.2.3.4",
789        ] {
790            assert!(!is_private_ipv4(ip), "{ip} is NOT flagged");
791        }
792    }
793
794    // ── #854: built-in Ornith cards ───────────────────────────────────────
795    #[test]
796    fn builtin_cards_parse_validate_and_are_leak_free() {
797        let cards = builtin_cards();
798        assert!(!cards.is_empty(), "built-in cards present");
799        for c in &cards {
800            c.validate()
801                .unwrap_or_else(|e| panic!("built-in `{}` invalid: {e}", c.name));
802            assert!(
803                no_hardware_leak(c).is_empty(),
804                "built-in `{}` leaks a private IP",
805                c.name
806            );
807            // Identities only: a built-in card pins NO node-specific knob.
808            if let Some(v) = c.vllm.as_ref() {
809                assert!(v.gpu_mem.is_none(), "{}: gpu_mem is node-specific", c.name);
810                assert!(
811                    v.tensor_parallel.is_none(),
812                    "{}: tensor_parallel is node-specific",
813                    c.name
814                );
815            }
816        }
817    }
818
819    #[test]
820    fn ornith_35b_card_has_the_expected_settings() {
821        // Resolved (not the raw builtin card): reasoning_parser/tool_call_parser/
822        // enable_auto_tool_choice now come from the qwen3 family defaults, not
823        // this card's own [vllm] table — this test asserts the EFFECTIVE
824        // settings a real `card setup` would use, same as before the refactor.
825        // `resolve()` with no dropins/one-off only touches `vllm`, so every
826        // other field is identical to the raw builtin card.
827        let raw = builtin_card("Ornith-1.0-35B").expect("Ornith-1.0-35B present");
828        let c = resolve(raw, &[], None);
829        assert_eq!(c.backend, Some(Backend::Vllm));
830        assert_eq!(c.gated, None, "the 35B is runnable, not gated");
831        let v = c.vllm.unwrap();
832        assert_eq!(v.max_model_len, Some(262_144));
833        assert_eq!(v.reasoning_parser.as_deref(), Some("qwen3"));
834        assert_eq!(v.tool_call_parser.as_deref(), Some("qwen3_xml"));
835        assert_eq!(v.enable_auto_tool_choice, Some(true));
836        assert!(v.extra.iter().any(|a| a == "--enable-prefix-caching"));
837        assert!(v.extra.iter().any(|a| a == "--trust-remote-code"));
838        let t = c.tuning.unwrap();
839        assert_eq!(t.temperature, Some(0.6));
840        assert_eq!(t.top_p, Some(0.95));
841        assert_eq!(t.top_k, Some(20));
842        assert_eq!(t.context_tokens, Some(262_144));
843        let cap = c.capability.unwrap();
844        assert_eq!(cap.emits_leading_reasoning, Some(true));
845        assert_eq!(cap.thinking_default, Some(true));
846        assert_eq!(
847            cap.reasoning_content_field.as_deref(),
848            Some("reasoning_content")
849        );
850        let o = c.ollama.unwrap();
851        assert_eq!(o.tag.as_deref(), Some("ornith:35b"));
852        assert!(
853            o.num_ctx.unwrap() < 262_144,
854            "ollama num_ctx is capped below the native window (OOM guard)"
855        );
856    }
857
858    #[test]
859    fn ornith_397b_is_gated() {
860        let c = builtin_card("Ornith-1.0-397B").expect("397B present as a gated card");
861        assert_eq!(c.gated, Some(true), "the 397B must be hardware-gated");
862    }
863
864    #[test]
865    fn builtin_card_is_overridable_by_name() {
866        // A drop-in overrides only what it sets; the rest inherits from the built-in.
867        let base = builtin_card("Ornith-1.0-35B").unwrap();
868        let dropin: ModelCard =
869            toml::from_str("name = \"Ornith-1.0-35B\"\n[ollama]\nnum_ctx = 65536").unwrap();
870        let resolved = resolve(base, std::slice::from_ref(&dropin), None);
871        assert_eq!(
872            resolved.ollama.unwrap().num_ctx,
873            Some(65536),
874            "drop-in wins"
875        );
876        assert_eq!(
877            resolved.vllm.unwrap().reasoning_parser.as_deref(),
878            Some("qwen3"),
879            "un-set fields inherit from the built-in"
880        );
881    }
882}