velesdb_memory/reachability.rs
1//! Is a configured remote inference backend actually reachable? (#1751, D2)
2//!
3//! ## What this closes, and what it deliberately does not
4//!
5//! The daemon already refuses to start when `autograph` is on and no
6//! extraction backend is **configured** — a deterministic misconfiguration the
7//! operator can fix before anything runs. It never checked that a configured
8//! backend is **reachable**, and that gap is what let an extractor stay broken
9//! for weeks: `autograph` degrading in-flight is the correct default (losing
10//! the enrichment beats losing the fact), which is exactly why nothing ever
11//! said so. Silent in-flight is right; silent *forever* is the defect.
12//!
13//! So this module produces a **signal**, never a refusal. The issue enumerated
14//! three options — a startup warning, a queryable failure counter, or nothing
15//! — and the arbitration chose the warning and rejected the counter ("a
16//! counter you have to ask for is worth nothing here"). Refusing to start was
17//! never on that list, and two facts argue against inventing it:
18//!
19//! 1. the arbitration's own first property forbids turning a successful
20//! `remember` into an error;
21//! 2. unreachable is **transient**. A service manager can start this daemon
22//! before the model server is up, and a daemon that refuses to boot for
23//! that reason is worse than the silence it replaces.
24//!
25//! The signal is a startup snapshot. It does **not** re-probe: a backend that
26//! comes up a minute later simply works, with no further word, because the
27//! in-flight path was never the thing that was broken.
28//!
29//! ## Why a listing and not a generation
30//!
31//! `GET /v1/models` is served by every OpenAI-compatible server, answers from
32//! a table, and loads nothing. Asking `/v1/chat/completions` "are you there"
33//! would pull a 35-billion-parameter model into memory to answer — turning a
34//! startup check into exactly the cold-load cost tracked separately in #1727.
35//!
36//! ## Four verdicts, because they lead to four different actions
37//!
38//! Collapsing them into "not working" is what sends an operator to check a
39//! port that is fine. Nothing answered, something answered but does not have
40//! the model, something answered and rejected the credential, and something
41//! answered but does not serve a listing at all are four different mornings.
42
43use std::time::Duration;
44
45/// What a probe found. Every variant is one distinct next action.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum Reachability {
48 /// The server answered and lists the configured model.
49 Reachable,
50 /// Nothing answered: wrong port, wrong host, server down.
51 Unreachable {
52 /// The transport's own words, for the operator's log.
53 detail: String,
54 },
55 /// The server answered and refused the credential.
56 Unauthorized,
57 /// The server answered, and the configured name is not among the ids it
58 /// advertises. **Not** a proven absence: a server may route an alias it
59 /// does not list. Measured on 2026-08-02 against a local oMLX server —
60 /// `/v1/models` answered 200 with seven ids, none containing `ornith`,
61 /// while `/v1/chat/completions` accepted `ornith-35b` and echoed it back
62 /// (#1782). The old name for this variant asserted that absence, and the
63 /// line it produced sent an operator to repair a healthy configuration.
64 ModelNotAdvertised {
65 /// How many models it did list — `0` reads very differently from `12`.
66 listed: usize,
67 },
68 /// Something answered but serves no listing. Not a fault by itself: a
69 /// gateway may expose only the endpoints it proxies.
70 ListingUnsupported,
71}
72
73/// Listing endpoint, relative to a normalised base URL.
74const MODELS_PATH: &str = "/v1/models";
75
76/// Ask `base_url` whether it is there and whether it has `model`.
77///
78/// `timeout` is the caller's, and it is the whole budget: a startup path may
79/// not wait on a stalled server. Never returns an error — a probe that could
80/// fail would need its own error handling at every call site, and the verdict
81/// it produces is already the answer.
82#[must_use]
83pub fn probe_openai(
84 base_url: &str,
85 model: &str,
86 token: Option<&str>,
87 timeout: Duration,
88) -> Reachability {
89 let url = format!("{}{MODELS_PATH}", crate::openai::base_url(base_url));
90 let agent = ureq::AgentBuilder::new()
91 .timeout_connect(timeout)
92 .timeout_read(timeout)
93 .timeout_write(timeout)
94 .build();
95 let mut request = agent.get(&url);
96 if let Some(secret) = token {
97 request = request.set("Authorization", &format!("Bearer {secret}"));
98 }
99 match request.call() {
100 Ok(response) => classify_listing(&response.into_string().unwrap_or_default(), model),
101 Err(ureq::Error::Status(401 | 403, _)) => Reachability::Unauthorized,
102 Err(ureq::Error::Status(404 | 405, _)) => Reachability::ListingUnsupported,
103 Err(ureq::Error::Status(code, _)) => Reachability::Unreachable {
104 detail: format!("the server answered HTTP {code}"),
105 },
106 Err(ureq::Error::Transport(transport)) => Reachability::Unreachable {
107 detail: transport.to_string(),
108 },
109 }
110}
111
112/// Read an OpenAI-shaped listing without pulling in a JSON parser for four
113/// characters of structure: the ids are `"id":"…"` and nothing else in that
114/// response has that key.
115fn classify_listing(body: &str, model: &str) -> Reachability {
116 let ids: Vec<&str> = body
117 .split("\"id\"")
118 .skip(1)
119 .filter_map(|chunk| chunk.split('"').nth(1))
120 .collect();
121 if ids.is_empty() {
122 return Reachability::ListingUnsupported;
123 }
124 if ids.iter().any(|id| names_the_same_model(id, model)) {
125 Reachability::Reachable
126 } else {
127 Reachability::ModelNotAdvertised { listed: ids.len() }
128 }
129}
130
131/// Do a listed id and a configured name mean the same model?
132///
133/// Ollama's listing carries the implicit tag it applies on pull: measured on
134/// 2026-08-02, `/v1/models` answers `bge-m3:latest` for a configuration that
135/// says `bge-m3`. Strict equality would report a loaded, serving model as
136/// absent — and a warning that fires when everything is fine is a warning
137/// people learn to skip, which costs more than the silence this replaces.
138///
139/// The tolerance is that one implicit tag and nothing else. `bge-m3:v2` and
140/// `bge-m3:v3` are different models, and a prefix rule would call them equal.
141fn names_the_same_model(listed: &str, configured: &str) -> bool {
142 listed == configured
143 || listed.strip_suffix(":latest") == Some(configured)
144 || configured.strip_suffix(":latest") == Some(listed)
145}
146
147/// The one line an operator should see at startup, or `None` when there is
148/// nothing to say.
149///
150/// `None` on success is the point: a warning that also fires when everything
151/// works is a warning people filter out. Nothing here interpolates the
152/// credential — not the value, not the header name — because this line's
153/// destination is a log file.
154/// What the probe found, and what an operator can do about it.
155fn finding(outcome: &Reachability) -> Option<(String, &'static str)> {
156 match outcome {
157 Reachability::Reachable => None,
158 Reachability::Unreachable { detail } => Some((
159 format!("unreachable ({detail})"),
160 "start the server, or correct the URL",
161 )),
162 Reachability::Unauthorized => Some((
163 "refused the credential".to_owned(),
164 "set the role's _API_TOKEN in the environment (never in the TOML)",
165 )),
166 Reachability::ModelNotAdvertised { listed } => Some((
167 format!(
168 "answered, but does not advertise this model alias among the \
169 {listed} it lists — the alias may still be routable by the server"
170 ),
171 "no action if the server routes this alias; otherwise name one it lists",
172 )),
173 Reachability::ListingUnsupported => Some((
174 "answered, but serves no model listing — reachability unconfirmed".to_owned(),
175 "no action if this is a gateway; otherwise check the URL's base path",
176 )),
177 }
178}
179
180/// Whether the verdict ESTABLISHES that the backend cannot serve writes, or
181/// merely fails to confirm that it can.
182///
183/// The distinction is the whole of #1782. `/v1/models` is a light signal, and
184/// a light signal has limits: it proves a server is up, and it proves nothing
185/// about an alias it does not mention. Stating degradation as a fact on that
186/// basis is what turned a healthy oMLX configuration into a bug report. The
187/// probe stays light either way — confirming an alias would mean asking
188/// `/v1/chat/completions`, which pulls a 35-billion-parameter model at startup.
189fn proves_backend_unusable(outcome: &Reachability) -> bool {
190 match outcome {
191 Reachability::Unreachable { .. } | Reachability::Unauthorized => true,
192 Reachability::Reachable
193 | Reachability::ModelNotAdvertised { .. }
194 | Reachability::ListingUnsupported => false,
195 }
196}
197
198#[must_use]
199pub fn warning_line(role: &str, url: &str, model: &str, outcome: &Reachability) -> Option<String> {
200 let (what, action) = finding(outcome)?;
201 let consequence = if proves_backend_unusable(outcome) {
202 "Graph enrichment will degrade silently for every write until it is fixed"
203 } else {
204 "Whether graph enrichment works is therefore unconfirmed — this is not \
205 proof that it is broken"
206 };
207 Some(format!(
208 "velesdb-memory: the {role} backend at {url} (model {model}) {what}. \
209 {consequence} — {action}, then restart. To run without it, unset \
210 VELESDB_MEMORY_EXTRACTOR or turn autograph off."
211 ))
212}
213
214#[cfg(test)]
215#[path = "reachability_tests.rs"]
216mod tests;