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)]
47#[non_exhaustive] // diagnosis outcomes grow as failure modes are learned; matching externally requires a wildcard arm
48pub enum Reachability {
49 /// The server answered and lists the configured model.
50 Reachable,
51 /// Nothing answered: wrong port, wrong host, server down.
52 Unreachable {
53 /// The transport's own words, for the operator's log.
54 detail: String,
55 },
56 /// The server answered and refused the credential.
57 Unauthorized,
58 /// The server answered, and the configured name is not among the ids it
59 /// advertises. **Not** a proven absence: a server may route an alias it
60 /// does not list. Measured on 2026-08-02 against a local oMLX server —
61 /// `/v1/models` answered 200 with seven ids, none containing `ornith`,
62 /// while `/v1/chat/completions` accepted `ornith-35b` and echoed it back
63 /// (#1782). The old name for this variant asserted that absence, and the
64 /// line it produced sent an operator to repair a healthy configuration.
65 ModelNotAdvertised {
66 /// How many models it did list — `0` reads very differently from `12`.
67 listed: usize,
68 },
69 /// Something answered but serves no listing. Not a fault by itself: a
70 /// gateway may expose only the endpoints it proxies.
71 ListingUnsupported,
72}
73
74/// Listing endpoint, relative to a normalised base URL.
75const MODELS_PATH: &str = "/v1/models";
76
77/// Ask `base_url` whether it is there and whether it has `model`.
78///
79/// `timeout` is the caller's, and it is the whole budget: a startup path may
80/// not wait on a stalled server. Never returns an error — a probe that could
81/// fail would need its own error handling at every call site, and the verdict
82/// it produces is already the answer.
83#[must_use]
84pub fn probe_openai(
85 base_url: &str,
86 model: &str,
87 token: Option<&str>,
88 timeout: Duration,
89) -> Reachability {
90 let url = format!("{}{MODELS_PATH}", crate::openai::base_url(base_url));
91 // `uniform` also sets the OVERALL deadline, which this construction never
92 // did: the doc above has always promised "the whole budget", but without
93 // `.timeout()` the worst case was one budget each for connect, write and
94 // read — three times the promise. Aligning the code with its own contract
95 // is the one behavioral change of the agent consolidation.
96 let agent =
97 crate::http_client::bounded_agent(crate::http_client::AgentBudget::uniform(timeout));
98 let mut request = agent.get(&url);
99 if let Some(secret) = token {
100 request = request.set("Authorization", &format!("Bearer {secret}"));
101 }
102 match request.call() {
103 Ok(response) => classify_listing(&response.into_string().unwrap_or_default(), model),
104 Err(ureq::Error::Status(401 | 403, _)) => Reachability::Unauthorized,
105 Err(ureq::Error::Status(404 | 405, _)) => Reachability::ListingUnsupported,
106 Err(ureq::Error::Status(code, _)) => Reachability::Unreachable {
107 detail: format!("the server answered HTTP {code}"),
108 },
109 Err(ureq::Error::Transport(transport)) => Reachability::Unreachable {
110 detail: transport.to_string(),
111 },
112 }
113}
114
115/// Read an OpenAI-shaped listing without pulling in a JSON parser for four
116/// characters of structure: the ids are `"id":"…"` and nothing else in that
117/// response has that key.
118fn classify_listing(body: &str, model: &str) -> Reachability {
119 let ids: Vec<&str> = body
120 .split("\"id\"")
121 .skip(1)
122 .filter_map(|chunk| chunk.split('"').nth(1))
123 .collect();
124 if ids.is_empty() {
125 return Reachability::ListingUnsupported;
126 }
127 if ids.iter().any(|id| names_the_same_model(id, model)) {
128 Reachability::Reachable
129 } else {
130 Reachability::ModelNotAdvertised { listed: ids.len() }
131 }
132}
133
134/// Do a listed id and a configured name mean the same model?
135///
136/// Ollama's listing carries the implicit tag it applies on pull: measured on
137/// 2026-08-02, `/v1/models` answers `bge-m3:latest` for a configuration that
138/// says `bge-m3`. Strict equality would report a loaded, serving model as
139/// absent — and a warning that fires when everything is fine is a warning
140/// people learn to skip, which costs more than the silence this replaces.
141///
142/// The tolerance is that one implicit tag and nothing else. `bge-m3:v2` and
143/// `bge-m3:v3` are different models, and a prefix rule would call them equal.
144fn names_the_same_model(listed: &str, configured: &str) -> bool {
145 listed == configured
146 || listed.strip_suffix(":latest") == Some(configured)
147 || configured.strip_suffix(":latest") == Some(listed)
148}
149
150/// The one line an operator should see at startup, or `None` when there is
151/// nothing to say.
152///
153/// `None` on success is the point: a warning that also fires when everything
154/// works is a warning people filter out. Nothing here interpolates the
155/// credential — not the value, not the header name — because this line's
156/// destination is a log file.
157/// What the probe found, and what an operator can do about it.
158fn finding(outcome: &Reachability) -> Option<(String, &'static str)> {
159 match outcome {
160 Reachability::Reachable => None,
161 Reachability::Unreachable { detail } => Some((
162 format!("unreachable ({detail})"),
163 "start the server, or correct the URL",
164 )),
165 Reachability::Unauthorized => Some((
166 "refused the credential".to_owned(),
167 "set the role's _API_TOKEN in the environment (never in the TOML)",
168 )),
169 Reachability::ModelNotAdvertised { listed } => Some((
170 format!(
171 "answered, but does not advertise this model alias among the \
172 {listed} it lists — the alias may still be routable by the server"
173 ),
174 "no action if the server routes this alias; otherwise name one it lists",
175 )),
176 Reachability::ListingUnsupported => Some((
177 "answered, but serves no model listing — reachability unconfirmed".to_owned(),
178 "no action if this is a gateway; otherwise check the URL's base path",
179 )),
180 }
181}
182
183/// Whether the verdict ESTABLISHES that the backend cannot serve writes, or
184/// merely fails to confirm that it can.
185///
186/// The distinction is the whole of #1782. `/v1/models` is a light signal, and
187/// a light signal has limits: it proves a server is up, and it proves nothing
188/// about an alias it does not mention. Stating degradation as a fact on that
189/// basis is what turned a healthy oMLX configuration into a bug report. The
190/// probe stays light either way — confirming an alias would mean asking
191/// `/v1/chat/completions`, which pulls a 35-billion-parameter model at startup.
192fn proves_backend_unusable(outcome: &Reachability) -> bool {
193 match outcome {
194 Reachability::Unreachable { .. } | Reachability::Unauthorized => true,
195 Reachability::Reachable
196 | Reachability::ModelNotAdvertised { .. }
197 | Reachability::ListingUnsupported => false,
198 }
199}
200
201#[must_use]
202pub fn warning_line(role: &str, url: &str, model: &str, outcome: &Reachability) -> Option<String> {
203 let (what, action) = finding(outcome)?;
204 let consequence = if proves_backend_unusable(outcome) {
205 "Graph enrichment will degrade silently for every write until it is fixed"
206 } else {
207 "Whether graph enrichment works is therefore unconfirmed — this is not \
208 proof that it is broken"
209 };
210 Some(format!(
211 "velesdb-memory: the {role} backend at {url} (model {model}) {what}. \
212 {consequence} — {action}, then restart. To run without it, unset \
213 VELESDB_MEMORY_EXTRACTOR or turn autograph off."
214 ))
215}
216
217#[cfg(test)]
218#[path = "reachability_tests.rs"]
219mod tests;