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 = "ollama")]
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    /// # Errors
40    /// A message naming the exact variable that is missing, per role.
41    pub fn require(self, prefix: &str) -> Result<(String, String, Auth), String> {
42        let url = self.url.ok_or_else(|| {
43            format!(
44                "{prefix}=openai requires {prefix}_URL — the server's origin and port, \
45                 no path (e.g. http://localhost:8020). There is no default: `openai` \
46                 is a protocol, and only you know which server speaks it here."
47            )
48        })?;
49        let model = self.model.ok_or_else(|| {
50            format!(
51                "{prefix}=openai requires {prefix}_MODEL — the model identifier the server expects"
52            )
53        })?;
54        Ok((url, model, self.auth))
55    }
56}
57
58/// A variable's value, or `None` when it is unset.
59fn env_opt(name: &str) -> Option<String> {
60    std::env::var(name).ok()
61}
62
63/// Read a role's API token and turn it into what the transport will send.
64///
65/// The token lives in the environment and **nowhere else** — never in the
66/// TOML (arbitration B1, enforced by [`crate::config`]'s `deny_unknown_fields`
67/// and its redacted refusal), and never as a language-binding constructor
68/// argument either, for the same reason: an argument sits in the caller's own
69/// source, one `git add .` away from a public history the way a TOML value
70/// would be.
71///
72/// # Errors
73/// A variable that is set to an empty or blank value. That is not the same as
74/// unset: unset means "send no credential", while empty is a caller whose
75/// shell expansion produced nothing, and silently sending no credential would
76/// surface as a `401` they cannot explain.
77pub fn role_auth(name: &str) -> Result<Auth, String> {
78    match env_opt(name) {
79        None => Ok(Auth::None),
80        Some(token) if token.trim().is_empty() => Err(format!(
81            "{name} is set but empty — unset it entirely to send no credential. An \
82             empty token would go out as `Authorization: Bearer `, which a server \
83             rejects as a bad credential rather than a missing one."
84        )),
85        Some(token) => Ok(Auth::Bearer(token)),
86    }
87}
88
89/// The embedding role's endpoint, honouring the legacy `VELESDB_MEMORY_OLLAMA_*`
90/// aliases (C1), plus an alias-conflict notice for the caller to print.
91///
92/// The notice is returned rather than printed here: a library must not write
93/// to a caller's stderr on its behalf (the daemon prints it gated on
94/// `VELESDB_MEMORY_QUIET`; a language binding embedded in someone else's
95/// process gets to decide for itself, and today chooses not to).
96///
97/// # Errors
98/// An `_API_TOKEN` that is set but empty.
99#[cfg(feature = "ollama")]
100pub fn embedder_env_endpoint() -> Result<(RemoteEndpoint, Option<String>), String> {
101    let url = resolve_alias(
102        env_opt("VELESDB_MEMORY_EMBEDDER_URL").as_deref(),
103        env_opt("VELESDB_MEMORY_OLLAMA_URL").as_deref(),
104    );
105    let model = resolve_alias(
106        env_opt("VELESDB_MEMORY_EMBEDDER_MODEL").as_deref(),
107        env_opt("VELESDB_MEMORY_OLLAMA_MODEL").as_deref(),
108    );
109    let mut conflicts = Vec::new();
110    if url.conflicting {
111        conflicts.push(("VELESDB_MEMORY_EMBEDDER_URL", "VELESDB_MEMORY_OLLAMA_URL"));
112    }
113    if model.conflicting {
114        conflicts.push((
115            "VELESDB_MEMORY_EMBEDDER_MODEL",
116            "VELESDB_MEMORY_OLLAMA_MODEL",
117        ));
118    }
119    let endpoint = RemoteEndpoint {
120        url: url.value,
121        model: model.value,
122        auth: role_auth("VELESDB_MEMORY_EMBEDDER_API_TOKEN")?,
123    };
124    Ok((endpoint, alias_conflict_notice(&conflicts)))
125}
126
127#[cfg(all(test, feature = "ollama"))]
128#[path = "remote_endpoint_tests.rs"]
129mod tests;