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;