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