Skip to main content

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;