mecha_core/provider/
preflight.rs1use crate::config::ProviderConfig;
30use serde::Deserialize;
31
32#[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 #[serde(default)]
62 pub n_ctx: Option<u64>,
63}
64
65pub 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
83pub 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 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 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 #[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 #[test]
198 fn a_vision_model_served_with_no_one_configured_to_use_it_is_reported() {
199 let c = cfg(); 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 #[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 #[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}