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)]
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;