1use std::{
9 collections::HashMap,
10 env as process_env,
11 error::Error,
12 ffi::{OsStr, OsString},
13 fmt,
14 path::PathBuf,
15};
16
17pub const RELAY_KNOWLEDGE_HOME: &str = "RELAY_KNOWLEDGE_HOME";
18pub const RELAY_KNOWLEDGE_CONFIG_DIR: &str = "RELAY_KNOWLEDGE_CONFIG_DIR";
19pub const RELAY_KNOWLEDGE_DATA_DIR: &str = "RELAY_KNOWLEDGE_DATA_DIR";
20pub const RELAY_KNOWLEDGE_STATE_DIR: &str = "RELAY_KNOWLEDGE_STATE_DIR";
21pub const RELAY_KNOWLEDGE_CACHE_DIR: &str = "RELAY_KNOWLEDGE_CACHE_DIR";
22pub const RELAY_KNOWLEDGE_LOG_DIR: &str = "RELAY_KNOWLEDGE_LOG_DIR";
23pub const RELAY_KNOWLEDGE_TEMP_DIR: &str = "RELAY_KNOWLEDGE_TEMP_DIR";
24pub const RELAY_KNOWLEDGE_RUNTIME_DIR: &str = "RELAY_KNOWLEDGE_RUNTIME_DIR";
25pub const RELAY_KNOWLEDGE_SERVICE_DIR: &str = "RELAY_KNOWLEDGE_SERVICE_DIR";
26pub const RELAY_KNOWLEDGE_HTTP_BIND: &str = "RELAY_KNOWLEDGE_HTTP_BIND";
27pub const RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS";
28pub const RELAY_KNOWLEDGE_HTTP_SHUTDOWN_TIMEOUT_MS: &str =
29 "RELAY_KNOWLEDGE_HTTP_SHUTDOWN_TIMEOUT_MS";
30pub const RELAY_KNOWLEDGE_HTTP_MAX_BODY_BYTES: &str = "RELAY_KNOWLEDGE_HTTP_MAX_BODY_BYTES";
31pub const RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS: &str = "RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS";
32pub const RELAY_KNOWLEDGE_QOS_MAX_IN_FLIGHT_REQUESTS: &str =
33 "RELAY_KNOWLEDGE_QOS_MAX_IN_FLIGHT_REQUESTS";
34pub const RELAY_KNOWLEDGE_QOS_MAX_QUEUE_DEPTH: &str = "RELAY_KNOWLEDGE_QOS_MAX_QUEUE_DEPTH";
35pub const RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED: &str =
36 "RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED";
37pub const RELAY_KNOWLEDGE_MCP_ENDPOINT: &str = "RELAY_KNOWLEDGE_MCP_ENDPOINT";
38pub const RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS: &str = "RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS";
39pub const RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES: &str = "RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES";
40pub const RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE: &str =
41 "RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE";
42pub const RELAY_KNOWLEDGE_MCP_MAX_LIMIT: &str = "RELAY_KNOWLEDGE_MCP_MAX_LIMIT";
43pub const RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES: &str = "RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES";
44pub const RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS: &str =
45 "RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS";
46pub const RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED: &str =
47 "RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED";
48pub const RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH: &str = "RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH";
49pub const RELAY_KNOWLEDGE_SEMANTIC_BACKEND: &str = "RELAY_KNOWLEDGE_SEMANTIC_BACKEND";
50pub const RELAY_KNOWLEDGE_VECTOR_BACKEND: &str = "RELAY_KNOWLEDGE_VECTOR_BACKEND";
51pub const RELAY_KNOWLEDGE_LLM_PROVIDER: &str = "RELAY_KNOWLEDGE_LLM_PROVIDER";
52pub const RELAY_KNOWLEDGE_EMBEDDING_BASE_URL: &str = "RELAY_KNOWLEDGE_EMBEDDING_BASE_URL";
53pub const RELAY_KNOWLEDGE_EMBEDDING_API_KEY: &str = "RELAY_KNOWLEDGE_EMBEDDING_API_KEY";
54pub const RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL: &str = "RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL";
55pub const RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL: &str = "RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL";
56pub const RELAY_KNOWLEDGE_EMBEDDING_DIMENSION: &str = "RELAY_KNOWLEDGE_EMBEDDING_DIMENSION";
57pub const RELAY_KNOWLEDGE_EMBEDDING_BATCH_SIZE: &str = "RELAY_KNOWLEDGE_EMBEDDING_BATCH_SIZE";
58pub const RELAY_KNOWLEDGE_EMBEDDING_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_EMBEDDING_TIMEOUT_MS";
59pub const RELAY_KNOWLEDGE_EMBEDDING_MAX_CONCURRENCY: &str =
60 "RELAY_KNOWLEDGE_EMBEDDING_MAX_CONCURRENCY";
61pub const RELAY_KNOWLEDGE_RERANK_BACKEND: &str = "RELAY_KNOWLEDGE_RERANK_BACKEND";
62pub const RELAY_KNOWLEDGE_RERANK_MODEL: &str = "RELAY_KNOWLEDGE_RERANK_MODEL";
63pub const RELAY_KNOWLEDGE_RERANK_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_RERANK_TIMEOUT_MS";
64pub const RELAY_KNOWLEDGE_RERANK_CANDIDATE_MULTIPLIER: &str =
65 "RELAY_KNOWLEDGE_RERANK_CANDIDATE_MULTIPLIER";
66pub const RELAY_KNOWLEDGE_RERANK_MAX_CANDIDATES: &str = "RELAY_KNOWLEDGE_RERANK_MAX_CANDIDATES";
67pub const RELAY_KNOWLEDGE_WORKER_EMBEDDING_ENDPOINT: &str =
68 "RELAY_KNOWLEDGE_WORKER_EMBEDDING_ENDPOINT";
69pub const RELAY_KNOWLEDGE_WORKER_OCR_ENDPOINT: &str = "RELAY_KNOWLEDGE_WORKER_OCR_ENDPOINT";
70pub const RELAY_KNOWLEDGE_WORKER_VISION_ENDPOINT: &str = "RELAY_KNOWLEDGE_WORKER_VISION_ENDPOINT";
71pub const RELAY_KNOWLEDGE_WORKER_EXTRACTOR_ENDPOINT: &str =
72 "RELAY_KNOWLEDGE_WORKER_EXTRACTOR_ENDPOINT";
73pub const RELAY_KNOWLEDGE_WORKER_MAX_IN_FLIGHT: &str = "RELAY_KNOWLEDGE_WORKER_MAX_IN_FLIGHT";
74pub const RELAY_KNOWLEDGE_SILENT_UPDATES_ENABLED: &str = "RELAY_KNOWLEDGE_SILENT_UPDATES_ENABLED";
75pub const RELAY_KNOWLEDGE_FILE_INDEX_ENABLED: &str = "RELAY_KNOWLEDGE_FILE_INDEX_ENABLED";
76pub const RELAY_KNOWLEDGE_FILE_INDEX_ROOTS: &str = "RELAY_KNOWLEDGE_FILE_INDEX_ROOTS";
77pub const RELAY_KNOWLEDGE_FILE_INDEX_EXCLUDES: &str = "RELAY_KNOWLEDGE_FILE_INDEX_EXCLUDES";
78pub const RELAY_KNOWLEDGE_FILE_INDEX_MAX_DEPTH: &str = "RELAY_KNOWLEDGE_FILE_INDEX_MAX_DEPTH";
79pub const RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILE_BYTES: &str =
80 "RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILE_BYTES";
81pub const RELAY_KNOWLEDGE_FILE_INDEX_SCAN_INTERVAL_MS: &str =
82 "RELAY_KNOWLEDGE_FILE_INDEX_SCAN_INTERVAL_MS";
83pub const RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS: &str =
84 "RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS";
85pub const RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILES_PER_ROOT: &str =
86 "RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILES_PER_ROOT";
87pub const RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS";
88pub const RELAY_OTEL_ENDPOINT: &str = "RELAY_OTEL_ENDPOINT";
89pub const RELAY_OTEL_TRACES: &str = "RELAY_OTEL_TRACES";
90pub const RELAY_OTEL_METRICS: &str = "RELAY_OTEL_METRICS";
91pub const RELAY_OTEL_EXPORT_TIMEOUT_MS: &str = "RELAY_OTEL_EXPORT_TIMEOUT_MS";
92pub const RELAY_OTEL_SERVICE_ENVIRONMENT: &str = "RELAY_OTEL_SERVICE_ENVIRONMENT";
93pub const HTTPS_PROXY: &str = "HTTPS_PROXY";
94pub const HTTPS_PROXY_LOWER: &str = "https_proxy";
95pub const HTTP_PROXY: &str = "HTTP_PROXY";
96pub const HTTP_PROXY_LOWER: &str = "http_proxy";
97pub const ALL_PROXY: &str = "ALL_PROXY";
98pub const ALL_PROXY_LOWER: &str = "all_proxy";
99pub const NO_PROXY: &str = "NO_PROXY";
100pub const NO_PROXY_LOWER: &str = "no_proxy";
101pub const SSL_VERIFY: &str = "SSL_VERIFY";
102pub const SSL_VERIFY_LOWER: &str = "ssl_verify";
103
104const HOME: &str = "HOME";
105const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME";
106const XDG_DATA_HOME: &str = "XDG_DATA_HOME";
107const XDG_STATE_HOME: &str = "XDG_STATE_HOME";
108const XDG_CACHE_HOME: &str = "XDG_CACHE_HOME";
109const XDG_RUNTIME_DIR: &str = "XDG_RUNTIME_DIR";
110const APPDATA: &str = "APPDATA";
111const LOCALAPPDATA: &str = "LOCALAPPDATA";
112const TMPDIR: &str = "TMPDIR";
113const TEMP: &str = "TEMP";
114const TMP: &str = "TMP";
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum PlatformKind {
119 Unix,
120 Macos,
121 Windows,
122 Other,
123}
124
125impl PlatformKind {
126 pub const fn current() -> Self {
128 if cfg!(target_os = "windows") {
129 Self::Windows
130 } else if cfg!(target_os = "macos") {
131 Self::Macos
132 } else if cfg!(unix) {
133 Self::Unix
134 } else {
135 Self::Other
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct PlatformEnvironment {
143 pub platform: PlatformKind,
144 pub home_dir: Option<PathBuf>,
145 pub xdg_config_home: Option<PathBuf>,
146 pub xdg_data_home: Option<PathBuf>,
147 pub xdg_state_home: Option<PathBuf>,
148 pub xdg_cache_home: Option<PathBuf>,
149 pub xdg_runtime_dir: Option<PathBuf>,
150 pub app_data: Option<PathBuf>,
151 pub local_app_data: Option<PathBuf>,
152 pub temp_dir: Option<PathBuf>,
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct PathEnvOverrides {
158 pub home: Option<PathBuf>,
159 pub config_dir: Option<PathBuf>,
160 pub data_dir: Option<PathBuf>,
161 pub state_dir: Option<PathBuf>,
162 pub cache_dir: Option<PathBuf>,
163 pub log_dir: Option<PathBuf>,
164 pub temp_dir: Option<PathBuf>,
165 pub runtime_dir: Option<PathBuf>,
166 pub service_dir: Option<PathBuf>,
167}
168
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct NetworkEnvOverrides {
172 pub http_bind: Option<String>,
173 pub http_request_timeout_ms: Option<u64>,
174 pub http_shutdown_timeout_ms: Option<u64>,
175 pub http_max_body_bytes: Option<u64>,
176 pub proxy: Option<String>,
177 pub no_proxy: Option<String>,
178 pub ssl_verify: Option<bool>,
179 pub qos_max_connections: Option<usize>,
180 pub qos_max_in_flight_requests: Option<usize>,
181 pub qos_max_queue_depth: Option<usize>,
182}
183
184#[derive(Debug, Clone, Default, PartialEq, Eq)]
186pub struct AgentEnvOverrides {
187 pub mcp_streamable_http_enabled: Option<bool>,
188 pub mcp_endpoint: Option<String>,
189 pub mcp_allowed_origins: Option<String>,
190 pub mcp_allowed_scopes: Option<String>,
191 pub mcp_allow_unspecified_scope: Option<bool>,
192 pub mcp_max_limit: Option<usize>,
193 pub mcp_max_context_bytes: Option<usize>,
194 pub mcp_allow_remote_clients: Option<bool>,
195 pub audit_sink_enabled: Option<bool>,
196 pub audit_queue_depth: Option<usize>,
197}
198
199#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct RetrievalEnvOverrides {
202 pub semantic_backend: Option<String>,
203 pub vector_backend: Option<String>,
204 pub llm_provider: Option<String>,
205 pub embedding_base_url: Option<String>,
206 pub embedding_api_key: Option<String>,
207 pub text_embedding_model: Option<String>,
208 pub image_embedding_model: Option<String>,
209 pub embedding_dimension: Option<usize>,
210 pub embedding_batch_size: Option<usize>,
211 pub embedding_timeout_ms: Option<u64>,
212 pub embedding_max_concurrency: Option<usize>,
213 pub rerank_backend: Option<String>,
214 pub rerank_model: Option<String>,
215 pub rerank_timeout_ms: Option<u64>,
216 pub rerank_candidate_multiplier: Option<usize>,
217 pub rerank_max_candidates: Option<usize>,
218}
219
220#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct WorkerEnvOverrides {
223 pub embedding_endpoint: Option<String>,
224 pub ocr_endpoint: Option<String>,
225 pub vision_endpoint: Option<String>,
226 pub extractor_endpoint: Option<String>,
227 pub max_in_flight: Option<usize>,
228 pub silent_updates_enabled: Option<bool>,
229}
230
231#[derive(Debug, Clone, Default, PartialEq, Eq)]
233pub struct FileIndexEnvOverrides {
234 pub enabled: Option<bool>,
235 pub roots: Option<String>,
236 pub excludes: Option<String>,
237 pub max_depth: Option<usize>,
238 pub max_file_bytes: Option<u64>,
239 pub scan_interval_ms: Option<u64>,
240 pub scan_timeout_ms: Option<u64>,
241 pub max_files_per_root: Option<usize>,
242 pub query_timeout_ms: Option<u64>,
243}
244
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
247pub struct TelemetryEnvOverrides {
248 pub otel_endpoint: Option<String>,
249 pub otel_traces: Option<bool>,
250 pub otel_metrics: Option<bool>,
251 pub export_timeout_ms: Option<u64>,
252 pub service_environment: Option<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct EnvironmentConfig {
258 pub platform: PlatformEnvironment,
259 pub paths: PathEnvOverrides,
260 pub network: NetworkEnvOverrides,
261 pub agent: AgentEnvOverrides,
262 pub retrieval: RetrievalEnvOverrides,
263 pub workers: WorkerEnvOverrides,
264 pub file_index: FileIndexEnvOverrides,
265 pub telemetry: TelemetryEnvOverrides,
266}
267
268impl EnvironmentConfig {
269 pub fn from_process() -> Result<Self, EnvError> {
271 Self::from_pairs(PlatformKind::current(), process_env::vars_os())
272 }
273
274 pub fn from_pairs<I, K, V>(platform: PlatformKind, pairs: I) -> Result<Self, EnvError>
276 where
277 I: IntoIterator<Item = (K, V)>,
278 K: Into<OsString>,
279 V: Into<OsString>,
280 {
281 let values = pairs
282 .into_iter()
283 .map(|(key, value)| (normalize_key(platform, key.into()), value.into()))
284 .collect::<HashMap<_, _>>();
285 let temp_variables: &[&'static str] = if platform == PlatformKind::Windows {
286 &[TEMP, TMP, TMPDIR]
287 } else {
288 &[TMPDIR, TEMP, TMP]
289 };
290
291 Ok(Self {
292 platform: PlatformEnvironment {
293 platform,
294 home_dir: path_var(&values, HOME)?,
295 xdg_config_home: path_var(&values, XDG_CONFIG_HOME)?,
296 xdg_data_home: path_var(&values, XDG_DATA_HOME)?,
297 xdg_state_home: path_var(&values, XDG_STATE_HOME)?,
298 xdg_cache_home: path_var(&values, XDG_CACHE_HOME)?,
299 xdg_runtime_dir: path_var(&values, XDG_RUNTIME_DIR)?,
300 app_data: path_var(&values, APPDATA)?,
301 local_app_data: path_var(&values, LOCALAPPDATA)?,
302 temp_dir: first_path_var(&values, temp_variables)?,
303 },
304 paths: PathEnvOverrides {
305 home: path_var(&values, RELAY_KNOWLEDGE_HOME)?,
306 config_dir: path_var(&values, RELAY_KNOWLEDGE_CONFIG_DIR)?,
307 data_dir: path_var(&values, RELAY_KNOWLEDGE_DATA_DIR)?,
308 state_dir: path_var(&values, RELAY_KNOWLEDGE_STATE_DIR)?,
309 cache_dir: path_var(&values, RELAY_KNOWLEDGE_CACHE_DIR)?,
310 log_dir: path_var(&values, RELAY_KNOWLEDGE_LOG_DIR)?,
311 temp_dir: path_var(&values, RELAY_KNOWLEDGE_TEMP_DIR)?,
312 runtime_dir: path_var(&values, RELAY_KNOWLEDGE_RUNTIME_DIR)?,
313 service_dir: path_var(&values, RELAY_KNOWLEDGE_SERVICE_DIR)?,
314 },
315 network: NetworkEnvOverrides {
316 http_bind: string_var(&values, RELAY_KNOWLEDGE_HTTP_BIND)?,
317 http_request_timeout_ms: positive_u64_var(
318 &values,
319 RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS,
320 )?,
321 http_shutdown_timeout_ms: positive_u64_var(
322 &values,
323 RELAY_KNOWLEDGE_HTTP_SHUTDOWN_TIMEOUT_MS,
324 )?,
325 http_max_body_bytes: positive_u64_var(
326 &values,
327 RELAY_KNOWLEDGE_HTTP_MAX_BODY_BYTES,
328 )?,
329 proxy: first_string_var(
330 &values,
331 &[
332 HTTPS_PROXY,
333 HTTPS_PROXY_LOWER,
334 HTTP_PROXY,
335 HTTP_PROXY_LOWER,
336 ALL_PROXY,
337 ALL_PROXY_LOWER,
338 ],
339 )?,
340 no_proxy: first_string_var(&values, &[NO_PROXY, NO_PROXY_LOWER])?,
341 ssl_verify: first_bool_var(&values, &[SSL_VERIFY, SSL_VERIFY_LOWER])?,
342 qos_max_connections: positive_usize_var(
343 &values,
344 RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS,
345 )?,
346 qos_max_in_flight_requests: positive_usize_var(
347 &values,
348 RELAY_KNOWLEDGE_QOS_MAX_IN_FLIGHT_REQUESTS,
349 )?,
350 qos_max_queue_depth: positive_usize_var(
351 &values,
352 RELAY_KNOWLEDGE_QOS_MAX_QUEUE_DEPTH,
353 )?,
354 },
355 agent: AgentEnvOverrides {
356 mcp_streamable_http_enabled: bool_var(
357 &values,
358 RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED,
359 )?,
360 mcp_endpoint: string_var(&values, RELAY_KNOWLEDGE_MCP_ENDPOINT)?,
361 mcp_allowed_origins: string_var(&values, RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS)?,
362 mcp_allowed_scopes: string_var(&values, RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES)?,
363 mcp_allow_unspecified_scope: bool_var(
364 &values,
365 RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE,
366 )?,
367 mcp_max_limit: positive_usize_var(&values, RELAY_KNOWLEDGE_MCP_MAX_LIMIT)?,
368 mcp_max_context_bytes: positive_usize_var(
369 &values,
370 RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES,
371 )?,
372 mcp_allow_remote_clients: bool_var(
373 &values,
374 RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS,
375 )?,
376 audit_sink_enabled: bool_var(&values, RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED)?,
377 audit_queue_depth: positive_usize_var(
378 &values,
379 RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH,
380 )?,
381 },
382 retrieval: RetrievalEnvOverrides {
383 semantic_backend: string_var(&values, RELAY_KNOWLEDGE_SEMANTIC_BACKEND)?,
384 vector_backend: string_var(&values, RELAY_KNOWLEDGE_VECTOR_BACKEND)?,
385 llm_provider: string_var(&values, RELAY_KNOWLEDGE_LLM_PROVIDER)?,
386 embedding_base_url: string_var(&values, RELAY_KNOWLEDGE_EMBEDDING_BASE_URL)?,
387 embedding_api_key: string_var(&values, RELAY_KNOWLEDGE_EMBEDDING_API_KEY)?,
388 text_embedding_model: string_var(&values, RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL)?,
389 image_embedding_model: string_var(&values, RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL)?,
390 embedding_dimension: positive_usize_var(
391 &values,
392 RELAY_KNOWLEDGE_EMBEDDING_DIMENSION,
393 )?,
394 embedding_batch_size: positive_usize_var(
395 &values,
396 RELAY_KNOWLEDGE_EMBEDDING_BATCH_SIZE,
397 )?,
398 embedding_timeout_ms: positive_u64_var(
399 &values,
400 RELAY_KNOWLEDGE_EMBEDDING_TIMEOUT_MS,
401 )?,
402 embedding_max_concurrency: positive_usize_var(
403 &values,
404 RELAY_KNOWLEDGE_EMBEDDING_MAX_CONCURRENCY,
405 )?,
406 rerank_backend: string_var(&values, RELAY_KNOWLEDGE_RERANK_BACKEND)?,
407 rerank_model: string_var(&values, RELAY_KNOWLEDGE_RERANK_MODEL)?,
408 rerank_timeout_ms: positive_u64_var(&values, RELAY_KNOWLEDGE_RERANK_TIMEOUT_MS)?,
409 rerank_candidate_multiplier: positive_usize_var(
410 &values,
411 RELAY_KNOWLEDGE_RERANK_CANDIDATE_MULTIPLIER,
412 )?,
413 rerank_max_candidates: positive_usize_var(
414 &values,
415 RELAY_KNOWLEDGE_RERANK_MAX_CANDIDATES,
416 )?,
417 },
418 workers: WorkerEnvOverrides {
419 embedding_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_EMBEDDING_ENDPOINT)?,
420 ocr_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_OCR_ENDPOINT)?,
421 vision_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_VISION_ENDPOINT)?,
422 extractor_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_EXTRACTOR_ENDPOINT)?,
423 max_in_flight: positive_usize_var(&values, RELAY_KNOWLEDGE_WORKER_MAX_IN_FLIGHT)?,
424 silent_updates_enabled: bool_var(&values, RELAY_KNOWLEDGE_SILENT_UPDATES_ENABLED)?,
425 },
426 file_index: FileIndexEnvOverrides {
427 enabled: bool_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_ENABLED)?,
428 roots: string_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_ROOTS)?,
429 excludes: string_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_EXCLUDES)?,
430 max_depth: positive_usize_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_MAX_DEPTH)?,
431 max_file_bytes: positive_u64_var(
432 &values,
433 RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILE_BYTES,
434 )?,
435 scan_interval_ms: positive_u64_var(
436 &values,
437 RELAY_KNOWLEDGE_FILE_INDEX_SCAN_INTERVAL_MS,
438 )?,
439 scan_timeout_ms: positive_u64_var(
440 &values,
441 RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS,
442 )?,
443 max_files_per_root: positive_usize_var(
444 &values,
445 RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILES_PER_ROOT,
446 )?,
447 query_timeout_ms: positive_u64_var(&values, RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS)?,
448 },
449 telemetry: TelemetryEnvOverrides {
450 otel_endpoint: string_var(&values, RELAY_OTEL_ENDPOINT)?,
451 otel_traces: bool_var(&values, RELAY_OTEL_TRACES)?,
452 otel_metrics: bool_var(&values, RELAY_OTEL_METRICS)?,
453 export_timeout_ms: positive_u64_var(&values, RELAY_OTEL_EXPORT_TIMEOUT_MS)?,
454 service_environment: string_var(&values, RELAY_OTEL_SERVICE_ENVIRONMENT)?,
455 },
456 })
457 }
458}
459
460fn normalize_key(platform: PlatformKind, key: OsString) -> OsString {
461 if platform == PlatformKind::Windows {
462 key.to_str()
463 .map(|value| OsString::from(value.to_ascii_uppercase()))
464 .unwrap_or(key)
465 } else {
466 key
467 }
468}
469
470#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct EnvError {
473 pub variable: &'static str,
474 pub kind: EnvErrorKind,
475}
476
477impl EnvError {
478 fn empty(variable: &'static str) -> Self {
479 Self {
480 variable,
481 kind: EnvErrorKind::EmptyValue,
482 }
483 }
484
485 fn invalid_unicode(variable: &'static str) -> Self {
486 Self {
487 variable,
488 kind: EnvErrorKind::InvalidUnicode,
489 }
490 }
491
492 fn invalid_integer(variable: &'static str, value: &str) -> Self {
493 Self {
494 variable,
495 kind: EnvErrorKind::InvalidInteger {
496 value: value.to_owned(),
497 },
498 }
499 }
500
501 fn zero(variable: &'static str) -> Self {
502 Self {
503 variable,
504 kind: EnvErrorKind::ZeroValue,
505 }
506 }
507
508 fn invalid_boolean(variable: &'static str, value: &str) -> Self {
509 Self {
510 variable,
511 kind: EnvErrorKind::InvalidBoolean {
512 value: value.to_owned(),
513 },
514 }
515 }
516}
517
518#[derive(Debug, Clone, PartialEq, Eq)]
520pub enum EnvErrorKind {
521 EmptyValue,
522 InvalidUnicode,
523 InvalidInteger { value: String },
524 InvalidBoolean { value: String },
525 ZeroValue,
526}
527
528impl fmt::Display for EnvError {
529 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
530 match &self.kind {
531 EnvErrorKind::EmptyValue => write!(formatter, "{} must not be empty", self.variable),
532 EnvErrorKind::InvalidUnicode => {
533 write!(formatter, "{} must be valid UTF-8", self.variable)
534 }
535 EnvErrorKind::InvalidInteger { value } => {
536 write!(
537 formatter,
538 "{} must be a positive integer, got '{value}'",
539 self.variable
540 )
541 }
542 EnvErrorKind::InvalidBoolean { value } => write!(
543 formatter,
544 "{} must be true or false, got '{value}'",
545 self.variable
546 ),
547 EnvErrorKind::ZeroValue => {
548 write!(formatter, "{} must be greater than zero", self.variable)
549 }
550 }
551 }
552}
553
554impl Error for EnvError {}
555
556fn path_var(
557 values: &HashMap<OsString, OsString>,
558 variable: &'static str,
559) -> Result<Option<PathBuf>, EnvError> {
560 values
561 .get(OsStr::new(variable))
562 .map(|value| {
563 reject_empty(value, variable)?;
564 Ok(PathBuf::from(value))
565 })
566 .transpose()
567}
568
569fn first_path_var(
570 values: &HashMap<OsString, OsString>,
571 variables: &[&'static str],
572) -> Result<Option<PathBuf>, EnvError> {
573 for variable in variables {
574 if let Some(value) = path_var(values, variable)? {
575 return Ok(Some(value));
576 }
577 }
578
579 Ok(None)
580}
581
582fn string_var(
583 values: &HashMap<OsString, OsString>,
584 variable: &'static str,
585) -> Result<Option<String>, EnvError> {
586 values
587 .get(OsStr::new(variable))
588 .map(|value| {
589 reject_empty(value, variable)?;
590 value
591 .to_str()
592 .map(str::to_owned)
593 .ok_or_else(|| EnvError::invalid_unicode(variable))
594 })
595 .transpose()
596}
597
598fn first_string_var(
599 values: &HashMap<OsString, OsString>,
600 variables: &[&'static str],
601) -> Result<Option<String>, EnvError> {
602 for variable in variables {
603 if let Some(value) = string_var(values, variable)? {
604 return Ok(Some(value));
605 }
606 }
607
608 Ok(None)
609}
610
611fn bool_var(
612 values: &HashMap<OsString, OsString>,
613 variable: &'static str,
614) -> Result<Option<bool>, EnvError> {
615 string_var(values, variable)?
616 .map(|value| parse_bool(variable, &value))
617 .transpose()
618}
619
620fn first_bool_var(
621 values: &HashMap<OsString, OsString>,
622 variables: &[&'static str],
623) -> Result<Option<bool>, EnvError> {
624 for variable in variables {
625 if let Some(value) = bool_var(values, variable)? {
626 return Ok(Some(value));
627 }
628 }
629
630 Ok(None)
631}
632
633fn positive_u64_var(
634 values: &HashMap<OsString, OsString>,
635 variable: &'static str,
636) -> Result<Option<u64>, EnvError> {
637 string_var(values, variable)?
638 .map(|value| parse_positive_u64(variable, &value))
639 .transpose()
640}
641
642fn positive_usize_var(
643 values: &HashMap<OsString, OsString>,
644 variable: &'static str,
645) -> Result<Option<usize>, EnvError> {
646 string_var(values, variable)?
647 .map(|value| parse_positive_usize(variable, &value))
648 .transpose()
649}
650
651fn parse_positive_u64(variable: &'static str, value: &str) -> Result<u64, EnvError> {
652 let parsed = value
653 .parse::<u64>()
654 .map_err(|_| EnvError::invalid_integer(variable, value))?;
655
656 if parsed == 0 {
657 return Err(EnvError::zero(variable));
658 }
659
660 Ok(parsed)
661}
662
663fn parse_positive_usize(variable: &'static str, value: &str) -> Result<usize, EnvError> {
664 let parsed = value
665 .parse::<usize>()
666 .map_err(|_| EnvError::invalid_integer(variable, value))?;
667
668 if parsed == 0 {
669 return Err(EnvError::zero(variable));
670 }
671
672 Ok(parsed)
673}
674
675fn parse_bool(variable: &'static str, value: &str) -> Result<bool, EnvError> {
676 match value.to_ascii_lowercase().as_str() {
677 "true" | "1" | "yes" | "on" => Ok(true),
678 "false" | "0" | "no" | "off" => Ok(false),
679 _ => Err(EnvError::invalid_boolean(variable, value)),
680 }
681}
682
683fn reject_empty(value: &OsString, variable: &'static str) -> Result<(), EnvError> {
684 if value.is_empty() {
685 return Err(EnvError::empty(variable));
686 }
687
688 Ok(())
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn parses_platform_and_relay_overrides() {
697 let config = EnvironmentConfig::from_pairs(
698 PlatformKind::Unix,
699 [
700 (HOME, "/home/alice"),
701 (TMPDIR, "/var/tmp"),
702 (RELAY_KNOWLEDGE_HOME, "/opt/relay-runtime"),
703 (RELAY_KNOWLEDGE_HTTP_BIND, "127.0.0.1:9000"),
704 (HTTPS_PROXY, "https://proxy.internal:8443"),
705 (NO_PROXY, "localhost,.internal"),
706 (SSL_VERIFY, "false"),
707 (RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS, "512"),
708 (RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED, "true"),
709 (RELAY_KNOWLEDGE_MCP_ENDPOINT, "/mcp"),
710 (RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS, "http://localhost:8791"),
711 (RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES, "docs,src"),
712 (RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE, "true"),
713 (RELAY_KNOWLEDGE_MCP_MAX_LIMIT, "5"),
714 (RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES, "8192"),
715 (RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS, "false"),
716 (RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED, "true"),
717 (RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH, "256"),
718 (RELAY_KNOWLEDGE_SEMANTIC_BACKEND, "external"),
719 (RELAY_KNOWLEDGE_VECTOR_BACKEND, "external"),
720 (RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL, "text-embed-3-small"),
721 (RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL, "clip-vit-b32"),
722 (RELAY_KNOWLEDGE_EMBEDDING_DIMENSION, "1536"),
723 (RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS, "120000"),
724 (RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS, "600"),
725 (RELAY_OTEL_ENDPOINT, "http://collector.internal:4318"),
726 (RELAY_OTEL_TRACES, "true"),
727 (RELAY_OTEL_METRICS, "false"),
728 (RELAY_OTEL_EXPORT_TIMEOUT_MS, "1500"),
729 (RELAY_OTEL_SERVICE_ENVIRONMENT, "test"),
730 ],
731 )
732 .expect("environment should parse");
733
734 assert_eq!(config.platform.platform, PlatformKind::Unix);
735 assert_eq!(config.platform.home_dir, Some(PathBuf::from("/home/alice")));
736 assert_eq!(config.platform.temp_dir, Some(PathBuf::from("/var/tmp")));
737 assert_eq!(config.paths.home, Some(PathBuf::from("/opt/relay-runtime")));
738 assert_eq!(config.network.http_bind, Some("127.0.0.1:9000".to_owned()));
739 assert_eq!(
740 config.network.proxy,
741 Some("https://proxy.internal:8443".to_owned())
742 );
743 assert_eq!(
744 config.network.no_proxy,
745 Some("localhost,.internal".to_owned())
746 );
747 assert_eq!(config.network.ssl_verify, Some(false));
748 assert_eq!(config.network.qos_max_connections, Some(512));
749 assert_eq!(config.agent.mcp_streamable_http_enabled, Some(true));
750 assert_eq!(config.agent.mcp_endpoint, Some("/mcp".to_owned()));
751 assert_eq!(
752 config.agent.mcp_allowed_origins,
753 Some("http://localhost:8791".to_owned())
754 );
755 assert_eq!(config.agent.mcp_allowed_scopes, Some("docs,src".to_owned()));
756 assert_eq!(config.agent.mcp_allow_unspecified_scope, Some(true));
757 assert_eq!(config.agent.mcp_max_limit, Some(5));
758 assert_eq!(config.agent.mcp_max_context_bytes, Some(8192));
759 assert_eq!(config.agent.mcp_allow_remote_clients, Some(false));
760 assert_eq!(config.agent.audit_sink_enabled, Some(true));
761 assert_eq!(config.agent.audit_queue_depth, Some(256));
762 assert_eq!(
763 config.retrieval.semantic_backend,
764 Some("external".to_owned())
765 );
766 assert_eq!(config.retrieval.vector_backend, Some("external".to_owned()));
767 assert_eq!(
768 config.retrieval.text_embedding_model,
769 Some("text-embed-3-small".to_owned())
770 );
771 assert_eq!(
772 config.retrieval.image_embedding_model,
773 Some("clip-vit-b32".to_owned())
774 );
775 assert_eq!(config.retrieval.embedding_dimension, Some(1536));
776 assert_eq!(config.file_index.scan_timeout_ms, Some(120000));
777 assert_eq!(config.file_index.query_timeout_ms, Some(600));
778 assert_eq!(
779 config.telemetry.otel_endpoint,
780 Some("http://collector.internal:4318".to_owned())
781 );
782 assert_eq!(config.telemetry.otel_traces, Some(true));
783 assert_eq!(config.telemetry.otel_metrics, Some(false));
784 assert_eq!(config.telemetry.export_timeout_ms, Some(1500));
785 assert_eq!(
786 config.telemetry.service_environment,
787 Some("test".to_owned())
788 );
789 }
790
791 #[test]
792 fn rejects_empty_path_values() {
793 let error = EnvironmentConfig::from_pairs(PlatformKind::Unix, [(RELAY_KNOWLEDGE_HOME, "")])
794 .expect_err("empty path should fail");
795
796 assert_eq!(error.variable, RELAY_KNOWLEDGE_HOME);
797 assert_eq!(error.kind, EnvErrorKind::EmptyValue);
798 }
799
800 #[test]
801 fn rejects_invalid_numeric_values() {
802 let error = EnvironmentConfig::from_pairs(
803 PlatformKind::Unix,
804 [(RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS, "many")],
805 )
806 .expect_err("invalid numeric value should fail");
807
808 assert_eq!(error.variable, RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS);
809 assert_eq!(
810 error.kind,
811 EnvErrorKind::InvalidInteger {
812 value: "many".to_owned()
813 }
814 );
815 }
816
817 #[test]
818 fn rejects_zero_numeric_values() {
819 let error = EnvironmentConfig::from_pairs(
820 PlatformKind::Unix,
821 [(RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS, "0")],
822 )
823 .expect_err("zero timeout should fail");
824
825 assert_eq!(error.variable, RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS);
826 assert_eq!(error.kind, EnvErrorKind::ZeroValue);
827 }
828
829 #[test]
830 fn rejects_zero_otel_export_timeout() {
831 let error = EnvironmentConfig::from_pairs(
832 PlatformKind::Unix,
833 [(RELAY_OTEL_EXPORT_TIMEOUT_MS, "0")],
834 )
835 .expect_err("zero otel export timeout should fail");
836
837 assert_eq!(error.variable, RELAY_OTEL_EXPORT_TIMEOUT_MS);
838 assert_eq!(error.kind, EnvErrorKind::ZeroValue);
839 }
840
841 #[test]
842 fn rejects_invalid_otel_boolean_values() {
843 let error =
844 EnvironmentConfig::from_pairs(PlatformKind::Unix, [(RELAY_OTEL_TRACES, "yes?")])
845 .expect_err("invalid otel boolean should fail");
846
847 assert_eq!(error.variable, RELAY_OTEL_TRACES);
848 assert_eq!(
849 error.kind,
850 EnvErrorKind::InvalidBoolean {
851 value: "yes?".to_owned()
852 }
853 );
854 }
855
856 #[test]
857 fn https_proxy_takes_precedence_over_http_proxy() {
858 let config = EnvironmentConfig::from_pairs(
859 PlatformKind::Unix,
860 [
861 (HTTP_PROXY, "http://http-proxy:8080"),
862 (HTTPS_PROXY, "http://https-proxy:8080"),
863 (NO_PROXY, "localhost"),
864 ],
865 )
866 .expect("environment should parse");
867
868 assert_eq!(
869 config.network.proxy,
870 Some("http://https-proxy:8080".to_owned())
871 );
872 assert_eq!(config.network.no_proxy, Some("localhost".to_owned()));
873 }
874
875 #[test]
876 fn rejects_invalid_boolean_values() {
877 let error = EnvironmentConfig::from_pairs(PlatformKind::Unix, [(SSL_VERIFY, "sometimes")])
878 .expect_err("invalid boolean should fail");
879
880 assert_eq!(error.variable, SSL_VERIFY);
881 assert_eq!(
882 error.kind,
883 EnvErrorKind::InvalidBoolean {
884 value: "sometimes".to_owned()
885 }
886 );
887 }
888
889 #[test]
890 fn windows_environment_names_are_case_insensitive() {
891 let config = EnvironmentConfig::from_pairs(
892 PlatformKind::Windows,
893 [
894 ("home", "/home/alice"),
895 ("appdata", "/roaming"),
896 ("localappdata", "/local"),
897 ("relay_knowledge_http_bind", "localhost:8791"),
898 ("ssl_verify", "off"),
899 ],
900 )
901 .expect("environment should parse");
902
903 assert_eq!(config.platform.home_dir, Some(PathBuf::from("/home/alice")));
904 assert_eq!(config.platform.app_data, Some(PathBuf::from("/roaming")));
905 assert_eq!(
906 config.platform.local_app_data,
907 Some(PathBuf::from("/local"))
908 );
909 assert_eq!(config.network.http_bind, Some("localhost:8791".to_owned()));
910 assert_eq!(config.network.ssl_verify, Some(false));
911 }
912
913 #[test]
914 fn windows_temp_prefers_temp_tmp_before_tmpdir() {
915 let config = EnvironmentConfig::from_pairs(
916 PlatformKind::Windows,
917 [
918 (TMPDIR, "/posix/tmp"),
919 (TEMP, "/windows/temp"),
920 (TMP, "/windows/tmp"),
921 ],
922 )
923 .expect("environment should parse");
924
925 assert_eq!(
926 config.platform.temp_dir,
927 Some(PathBuf::from("/windows/temp"))
928 );
929 }
930}