use crate::config::ProviderConfig;
use serde::Deserialize;
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Props {
#[serde(default)]
pub model_alias: Option<String>,
#[serde(default)]
pub total_slots: Option<u64>,
#[serde(default)]
pub modalities: Modalities,
#[serde(default)]
pub default_generation_settings: GenerationSettings,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Modalities {
#[serde(default)]
pub vision: bool,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct GenerationSettings {
#[serde(default)]
pub n_ctx: Option<u64>,
}
pub async fn fetch(base_url: &str) -> Option<Props> {
let url = format!("{}/props", base_url.trim_end_matches('/'));
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(3))
.build()
.ok()?;
let body = http.get(&url).send().await.ok()?;
if !body.status().is_success() {
return None;
}
body.json::<Props>().await.ok()
}
pub fn disagreements(name: &str, cfg: &ProviderConfig, props: &Props) -> Vec<String> {
let mut out = Vec::new();
if let (Some(declared), Some(served)) =
(cfg.context_window, props.default_generation_settings.n_ctx)
{
if declared != served {
let slots = props.total_slots.unwrap_or(1);
let hint = if slots > 1 {
format!(
" The server has {slots} slots and divides `-c` evenly across them, so the \
value to write down is `-c / {slots}` and not `-c`."
)
} else {
String::new()
};
out.push(format!(
"[providers.{name}] context_window = {declared}, but the server is serving \
{served} tokens per slot.{hint} The compaction threshold, the tool-output \
budget and the fuel gauge are all derived from the configured number, so a \
stale one is worse than none."
));
}
}
match (cfg.vision_enabled(), props.modalities.vision) {
(true, false) => out.push(format!(
"[providers.{name}] vision = true, but the server reports no vision. Every image \
will silently arrive as a line of text naming the file. A vision model is two \
files: the weights, and a projector that `--mmproj` must name. `--mmproj-auto` \
only fires for `-hf` downloads, so a server started with `-m <path>` gets nothing \
from it."
)),
(false, true) => out.push(format!(
"[providers.{name}] is serving a vision model — the projector is loaded and paid \
for in memory — but `vision` is not set, so no image will ever be sent to it. Set \
`vision = true`."
)),
_ => {}
}
if let (Some(declared), Some(served)) = (cfg.model.as_deref(), props.model_alias.as_deref()) {
if declared != served {
out.push(format!(
"[providers.{name}] model = {declared:?}, but the server is serving \
{served:?}. llama-server ignores the request's `model` field, so this does \
not change which weights answer — it changes what every session record and \
scorecard says answered."
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> ProviderConfig {
let mut c = crate::config::Config::default()
.providers
.get("anthropic")
.cloned()
.unwrap();
c.kind = "local".into();
c.model = None;
c.api_key_env = None;
c
}
fn props(n_ctx: u64, slots: u64, vision: bool) -> Props {
Props {
model_alias: None,
total_slots: Some(slots),
modalities: Modalities { vision },
default_generation_settings: GenerationSettings { n_ctx: Some(n_ctx) },
}
}
#[test]
fn agreement_is_silent() {
let mut c = cfg();
c.context_window = Some(32768);
assert!(disagreements("local", &c, &props(32768, 1, false)).is_empty());
}
#[test]
fn a_context_window_naming_c_rather_than_c_over_np_is_caught_with_the_arithmetic() {
let mut c = cfg();
c.context_window = Some(262144);
let found = disagreements("local", &c, &props(65536, 4, false));
assert_eq!(found.len(), 1);
assert!(found[0].contains("65536"), "{}", found[0]);
assert!(found[0].contains("`-c / 4`"), "{}", found[0]);
}
#[test]
fn a_vision_model_served_with_no_one_configured_to_use_it_is_reported() {
let c = cfg(); let found = disagreements("local", &c, &props(8192, 1, true));
assert_eq!(found.len(), 1);
assert!(found[0].contains("vision = true"), "{}", found[0]);
}
#[test]
fn vision_declared_against_a_text_only_server_says_mmproj() {
let mut c = cfg();
c.vision = Some(true);
let found = disagreements("local", &c, &props(8192, 1, false));
assert_eq!(found.len(), 1);
assert!(found[0].contains("--mmproj"), "{}", found[0]);
assert!(
found[0].contains("silently"),
"the failure is silent, and the warning has to say so: {}",
found[0]
);
}
#[test]
fn a_props_body_missing_everything_parses_and_reports_nothing() {
let parsed: Props = serde_json::from_str("{}").unwrap();
let mut c = cfg();
c.context_window = Some(32768);
assert!(disagreements("local", &c, &parsed).is_empty());
}
}