Skip to main content

velesdb_memory/
remote_endpoint.rs

1//! Where a remote embedding/extraction backend's URL, model and credential
2//! come from — one shape for both roles, on purpose. The two were configured
3//! differently for historical reasons only — extraction by role, embedding by
4//! product — and an operator who has configured one should not have to learn
5//! the other (#1751, arbitration C1).
6//!
7//! Lives in the library, not the daemon binary, so every caller that resolves
8//! the embedding role's remote backend reads the same four
9//! `VELESDB_MEMORY_EMBEDDER*` variables the same way: the daemon and both
10//! language bindings (#1886), instead of the bindings reimplementing (or, as
11//! it was until #1886, simply never reading) the resolution the daemon
12//! already had.
13
14#[cfg(feature = "embedder-http")]
15use crate::config::{alias_conflict_notice, resolve_alias};
16use crate::http_client::Auth;
17
18/// A remote backend's configuration, read from one role's environment.
19#[derive(Debug)]
20pub struct RemoteEndpoint {
21    /// Server origin and port, no path. `None` when unset.
22    pub url: Option<String>,
23    /// Model identifier the server expects. `None` when unset.
24    pub model: Option<String>,
25    /// The credential, already resolved to what the transport puts on the wire.
26    pub auth: Auth,
27}
28
29impl RemoteEndpoint {
30    /// The URL and model, both **required** — the `openai` shape.
31    ///
32    /// Neither has a default, and that is the design rather than an omission:
33    /// `openai` names a *protocol*, spoken by oMLX, llama.cpp, LM Studio, vLLM
34    /// and a dozen hosted providers. Guessing a URL would pick one of them for
35    /// the caller, and guessing a model would send a name no server on that
36    /// list is obliged to know. Ollama keeps its defaults because it genuinely
37    /// has one canonical local address.
38    ///
39    /// The URL's scheme is the local-or-cloud switch, and nothing else is:
40    /// `http://` reaches a server on this machine, `https://` a hosted
41    /// provider (TLS in the client since 0.14.1, #2025) — same variables
42    /// either way. A provider that serves only chat completions (`OpenRouter`)
43    /// can back the *extractor* role while the embedder stays local: the
44    /// roles are configured independently on purpose.
45    ///
46    /// # Errors
47    /// A message naming the exact variable that is missing, per role.
48    pub fn require(self, prefix: &str) -> Result<(String, String, Auth), String> {
49        let url = self.url.ok_or_else(|| {
50            format!(
51                "{prefix}=openai requires {prefix}_URL — the server's origin and port, \
52                 no path (e.g. http://localhost:8020). There is no default: `openai` \
53                 is a protocol, and only you know which server speaks it here."
54            )
55        })?;
56        let model = self.model.ok_or_else(|| {
57            format!(
58                "{prefix}=openai requires {prefix}_MODEL — the model identifier the server expects"
59            )
60        })?;
61        Ok((url, model, self.auth))
62    }
63}
64
65/// A variable's value, or `None` when it is unset.
66fn env_opt(name: &str) -> Option<String> {
67    std::env::var(name).ok()
68}
69
70/// Read a role's API token and turn it into what the transport will send.
71///
72/// The token lives in the environment and **nowhere else** — never in the
73/// TOML (arbitration B1, enforced by [`crate::config`]'s `deny_unknown_fields`
74/// and its redacted refusal), and never as a language-binding constructor
75/// argument either, for the same reason: an argument sits in the caller's own
76/// source, one `git add .` away from a public history the way a TOML value
77/// would be.
78///
79/// # Errors
80/// A variable that is set to an empty or blank value. That is not the same as
81/// unset: unset means "send no credential", while empty is a caller whose
82/// shell expansion produced nothing, and silently sending no credential would
83/// surface as a `401` they cannot explain.
84pub fn role_auth(name: &str) -> Result<Auth, String> {
85    match env_opt(name) {
86        None => Ok(Auth::None),
87        Some(token) if token.trim().is_empty() => Err(format!(
88            "{name} is set but empty — unset it entirely to send no credential. An \
89             empty token would go out as `Authorization: Bearer `, which a server \
90             rejects as a bad credential rather than a missing one."
91        )),
92        Some(token) => Ok(Auth::Bearer(token)),
93    }
94}
95
96/// The embedding role's endpoint, honouring the legacy `VELESDB_MEMORY_OLLAMA_*`
97/// aliases (C1), plus an alias-conflict notice for the caller to print.
98///
99/// The notice is returned rather than printed here: a library must not write
100/// to a caller's stderr on its behalf (the daemon prints it gated on
101/// `VELESDB_MEMORY_QUIET`; a language binding embedded in someone else's
102/// process gets to decide for itself, and today chooses not to).
103///
104/// # Errors
105/// An `_API_TOKEN` that is set but empty.
106#[cfg(feature = "embedder-http")]
107pub fn embedder_env_endpoint() -> Result<(RemoteEndpoint, Option<String>), String> {
108    let url = resolve_alias(
109        env_opt("VELESDB_MEMORY_EMBEDDER_URL").as_deref(),
110        env_opt("VELESDB_MEMORY_OLLAMA_URL").as_deref(),
111    );
112    let model = resolve_alias(
113        env_opt("VELESDB_MEMORY_EMBEDDER_MODEL").as_deref(),
114        env_opt("VELESDB_MEMORY_OLLAMA_MODEL").as_deref(),
115    );
116    let mut conflicts = Vec::new();
117    if url.conflicting {
118        conflicts.push(("VELESDB_MEMORY_EMBEDDER_URL", "VELESDB_MEMORY_OLLAMA_URL"));
119    }
120    if model.conflicting {
121        conflicts.push((
122            "VELESDB_MEMORY_EMBEDDER_MODEL",
123            "VELESDB_MEMORY_OLLAMA_MODEL",
124        ));
125    }
126    let endpoint = RemoteEndpoint {
127        url: url.value,
128        model: model.value,
129        auth: role_auth("VELESDB_MEMORY_EMBEDDER_API_TOKEN")?,
130    };
131    Ok((endpoint, alias_conflict_notice(&conflicts)))
132}
133
134#[cfg(all(test, feature = "embedder-http"))]
135#[path = "remote_endpoint_tests.rs"]
136mod tests;