Skip to main content

mecha_core/provider/
preflight.rs

1//! One request to a local server, asking it what it is actually serving.
2//!
3//! `Sandbox::preflight` is the precedent and the argument is the same one
4//! level up: a configured sandbox that does not work is worse than none,
5//! because `shell` declares narrower capabilities when confined and the
6//! interlock believes the claim. Config here makes three claims a run then
7//! narrows around, and until now nothing checked any of them:
8//!
9//! - **`context_window`** decides the compaction threshold, the tool-output
10//!   budget, the fuel gauge and what overflow recovery expects. `-c` is
11//!   divided across slots, so the right value is `-c / -np` and the wrong
12//!   one is `-c` — which is the same number until `-np` moves off 1, which
13//!   is exactly what makes it easy to write down wrong.
14//! - **`vision`** decides whether an image is put in front of the model or
15//!   rendered as its own filename.
16//! - **`model`** decides nothing at all on this backend — llama-server
17//!   ignores the request's `model` field — but it decides what every session
18//!   record, scorecard and price calculation *says* was answering.
19//!
20//! **Warn, never refuse.** A mismatch makes a run compact at the wrong
21//! moment or quietly not send a picture; neither is a reason to refuse to
22//! start, and a preflight that can stop a working machine from booting is
23//! one people disable. That is the opposite of the sandbox's bargain, where
24//! falling through means running unconfined.
25//!
26//! The comparison is a pure function over a struct, tested without a server.
27//! The network call is the thin part.
28
29use crate::config::ProviderConfig;
30use serde::Deserialize;
31
32/// The subset of llama-server's `GET /props` this cares about.
33///
34/// `#[serde(default)]` throughout: this is another program's output across
35/// versions, and a field that moved should cost a check, never a parse
36/// failure that takes the warning down with it.
37#[derive(Debug, Default, Clone, Deserialize)]
38pub struct Props {
39    #[serde(default)]
40    pub model_alias: Option<String>,
41    #[serde(default)]
42    pub total_slots: Option<u64>,
43    #[serde(default)]
44    pub modalities: Modalities,
45    #[serde(default)]
46    pub default_generation_settings: GenerationSettings,
47}
48
49#[derive(Debug, Default, Clone, Deserialize)]
50pub struct Modalities {
51    #[serde(default)]
52    pub vision: bool,
53}
54
55#[derive(Debug, Default, Clone, Deserialize)]
56pub struct GenerationSettings {
57    /// The **per-slot** context, which is what `context_window` must equal.
58    /// llama-server has already done the `-c / -np` division here, which is
59    /// what makes reading it cheaper and more correct than reimplementing
60    /// the arithmetic.
61    #[serde(default)]
62    pub n_ctx: Option<u64>,
63}
64
65/// Ask a local server what it is serving. `None` when it did not answer in
66/// the shape expected — an endpoint that is not llama-server, or is not up.
67///
68/// Deliberately silent on failure: a provider that is merely not running yet
69/// must not print a warning on every start of a machine that does not use it.
70pub async fn fetch(base_url: &str) -> Option<Props> {
71    let url = format!("{}/props", base_url.trim_end_matches('/'));
72    let http = reqwest::Client::builder()
73        .timeout(std::time::Duration::from_secs(3))
74        .build()
75        .ok()?;
76    let body = http.get(&url).send().await.ok()?;
77    if !body.status().is_success() {
78        return None;
79    }
80    body.json::<Props>().await.ok()
81}
82
83/// What config claims against what is served. Empty means they agree.
84///
85/// Pure, so the interesting half is unit-tested without a model on the
86/// machine — the same split `compact.rs` uses, and for the same reason:
87/// getting this wrong is silent.
88pub fn disagreements(name: &str, cfg: &ProviderConfig, props: &Props) -> Vec<String> {
89    let mut out = Vec::new();
90
91    if let (Some(declared), Some(served)) =
92        (cfg.context_window, props.default_generation_settings.n_ctx)
93    {
94        if declared != served {
95            let slots = props.total_slots.unwrap_or(1);
96            let hint = if slots > 1 {
97                format!(
98                    " The server has {slots} slots and divides `-c` evenly across them, so the \
99                     value to write down is `-c / {slots}` and not `-c`."
100                )
101            } else {
102                String::new()
103            };
104            out.push(format!(
105                "[providers.{name}] context_window = {declared}, but the server is serving \
106                 {served} tokens per slot.{hint} The compaction threshold, the tool-output \
107                 budget and the fuel gauge are all derived from the configured number, so a \
108                 stale one is worse than none."
109            ));
110        }
111    }
112
113    // **Both directions, and they fail differently.** This is the check that
114    // would have caught a multimodal model served with no projector for as
115    // long as anyone cared to look.
116    match (cfg.vision_enabled(), props.modalities.vision) {
117        (true, false) => out.push(format!(
118            "[providers.{name}] vision = true, but the server reports no vision. Every image \
119             will silently arrive as a line of text naming the file. A vision model is two \
120             files: the weights, and a projector that `--mmproj` must name. `--mmproj-auto` \
121             only fires for `-hf` downloads, so a server started with `-m <path>` gets nothing \
122             from it."
123        )),
124        (false, true) => out.push(format!(
125            "[providers.{name}] is serving a vision model — the projector is loaded and paid \
126             for in memory — but `vision` is not set, so no image will ever be sent to it. Set \
127             `vision = true`."
128        )),
129        _ => {}
130    }
131
132    // Not an error, and worth saying anyway: llama-server ignores the
133    // request's `model` field, so naming one is not selecting it — only
134    // deciding what gets recorded. A session, a scorecard and a price that
135    // all name the wrong model are wrong quietly and forever.
136    if let (Some(declared), Some(served)) = (cfg.model.as_deref(), props.model_alias.as_deref()) {
137        if declared != served {
138            out.push(format!(
139                "[providers.{name}] model = {declared:?}, but the server is serving \
140                 {served:?}. llama-server ignores the request's `model` field, so this does \
141                 not change which weights answer — it changes what every session record and \
142                 scorecard says answered."
143            ));
144        }
145    }
146
147    out
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn cfg() -> ProviderConfig {
155        let mut c = crate::config::Config::default()
156            .providers
157            .get("anthropic")
158            .cloned()
159            .unwrap();
160        c.kind = "local".into();
161        c.model = None;
162        c.api_key_env = None;
163        c
164    }
165
166    fn props(n_ctx: u64, slots: u64, vision: bool) -> Props {
167        Props {
168            model_alias: None,
169            total_slots: Some(slots),
170            modalities: Modalities { vision },
171            default_generation_settings: GenerationSettings { n_ctx: Some(n_ctx) },
172        }
173    }
174
175    #[test]
176    fn agreement_is_silent() {
177        let mut c = cfg();
178        c.context_window = Some(32768);
179        assert!(disagreements("local", &c, &props(32768, 1, false)).is_empty());
180    }
181
182    /// The `-c / -np` trap: the two numbers are equal until `-np` moves off
183    /// 1, which is what makes it easy to write down wrong and impossible to
184    /// notice.
185    #[test]
186    fn a_context_window_naming_c_rather_than_c_over_np_is_caught_with_the_arithmetic() {
187        let mut c = cfg();
188        c.context_window = Some(262144);
189        let found = disagreements("local", &c, &props(65536, 4, false));
190        assert_eq!(found.len(), 1);
191        assert!(found[0].contains("65536"), "{}", found[0]);
192        assert!(found[0].contains("`-c / 4`"), "{}", found[0]);
193    }
194
195    /// The bug this whole module was written for, in the direction nobody
196    /// looks: the model has eyes and nothing is using them.
197    #[test]
198    fn a_vision_model_served_with_no_one_configured_to_use_it_is_reported() {
199        let c = cfg(); // vision unset, and `local` defaults to false
200        let found = disagreements("local", &c, &props(8192, 1, true));
201        assert_eq!(found.len(), 1);
202        assert!(found[0].contains("vision = true"), "{}", found[0]);
203    }
204
205    /// And the direction that looks like the feature working.
206    #[test]
207    fn vision_declared_against_a_text_only_server_says_mmproj() {
208        let mut c = cfg();
209        c.vision = Some(true);
210        let found = disagreements("local", &c, &props(8192, 1, false));
211        assert_eq!(found.len(), 1);
212        assert!(found[0].contains("--mmproj"), "{}", found[0]);
213        assert!(
214            found[0].contains("silently"),
215            "the failure is silent, and the warning has to say so: {}",
216            found[0]
217        );
218    }
219
220    /// A field llama-server stops sending must cost a check, never the
221    /// warning that would have named it.
222    #[test]
223    fn a_props_body_missing_everything_parses_and_reports_nothing() {
224        let parsed: Props = serde_json::from_str("{}").unwrap();
225        let mut c = cfg();
226        c.context_window = Some(32768);
227        assert!(disagreements("local", &c, &parsed).is_empty());
228    }
229}