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;