1use std::{
2 error::Error,
3 fmt,
4 path::{Component, PathBuf},
5 time::Duration,
6};
7
8use crate::{
9 api::{AgentAccessPolicy, AgentPolicyError},
10 domain::{RerankMode, RerankModeError, WorkerKind},
11 env::{
12 EnvError, EnvironmentConfig, PlatformKind, RELAY_KNOWLEDGE_EMBEDDING_API_KEY,
13 RELAY_KNOWLEDGE_EMBEDDING_BASE_URL, RELAY_KNOWLEDGE_EMBEDDING_DIMENSION,
14 RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL, RELAY_KNOWLEDGE_RERANK_MODEL,
15 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL, RetrievalEnvOverrides,
16 },
17 net::{NetworkConfig, NetworkConfigError, NetworkRuntime, NetworkRuntimeError},
18 observability::{ObservabilityRuntime, TelemetryConfig},
19 paths::{PathError, RuntimePaths, default_user_document_roots},
20 retrieval::{
21 DEFAULT_EMBEDDING_BATCH_SIZE, DEFAULT_EMBEDDING_MAX_CONCURRENCY, DEFAULT_EMBEDDING_TIMEOUT,
22 DEFAULT_RERANK_CANDIDATE_MULTIPLIER, DEFAULT_RERANK_MAX_CANDIDATES, DEFAULT_RERANK_TIMEOUT,
23 EmbeddingProviderKind, EmbeddingProviderKindError, LOCAL_RERANK_MODEL,
24 LOCAL_SEMANTIC_MODEL, LOCAL_VECTOR_DIMENSION, LOCAL_VECTOR_MODEL, ReadModelBackendConfig,
25 ReadModelBackendMode, ReadModelBackendModeError, ReadModelMetadata, RemoteEmbeddingConfig,
26 RerankConfig,
27 },
28 storage::StorageTopology,
29};
30
31use super::update::{UpdateRuntimeConfig, UpdateRuntimeConfigError};
32
33#[derive(Debug, Clone)]
35pub struct RuntimeConfiguration {
36 pub paths: RuntimePaths,
37 pub network: NetworkRuntime,
38 pub observability: ObservabilityRuntime,
39 pub agent: AgentRuntimeConfig,
40 pub retrieval: ReadModelBackendConfig,
41 pub workers: WorkerRuntimeConfig,
42 pub file_index: FileIndexRuntimeConfig,
43 pub updates: UpdateRuntimeConfig,
44 pub storage: StorageRuntimeConfig,
45}
46
47impl RuntimeConfiguration {
48 pub async fn from_process_environment() -> Result<Self, RuntimeConfigurationError> {
50 let environment =
51 EnvironmentConfig::from_process().map_err(RuntimeConfigurationError::Environment)?;
52
53 Self::from_environment(&environment).await
54 }
55
56 pub async fn from_environment(
58 environment: &EnvironmentConfig,
59 ) -> Result<Self, RuntimeConfigurationError> {
60 let network = NetworkConfig::from_overrides(&environment.network)
61 .map_err(RuntimeConfigurationError::Network)?;
62 let observability =
63 ObservabilityRuntime::new(TelemetryConfig::from_environment(&environment.telemetry));
64 let agent = AgentRuntimeConfig::from_environment(environment, network.http.request_timeout)
65 .map_err(RuntimeConfigurationError::Agent)?;
66 let retrieval = retrieval_config_from_environment(&environment.retrieval)
67 .map_err(RuntimeConfigurationError::Retrieval)?;
68 let workers = WorkerRuntimeConfig::from_environment(environment)
69 .map_err(RuntimeConfigurationError::Workers)?;
70 let file_index = FileIndexRuntimeConfig::from_environment(environment)
71 .map_err(RuntimeConfigurationError::FileIndex)?;
72 let updates = UpdateRuntimeConfig::from_environment(&environment.updates)
73 .map_err(RuntimeConfigurationError::Updates)?;
74 let storage = StorageRuntimeConfig::from_environment(environment)
75 .map_err(RuntimeConfigurationError::Storage)?;
76
77 Ok(Self {
78 paths: RuntimePaths::resolve(&environment.platform, &environment.paths)
79 .map_err(RuntimeConfigurationError::Paths)?,
80 network: NetworkRuntime::from_config(network),
81 observability,
82 agent,
83 retrieval,
84 workers,
85 file_index,
86 updates,
87 storage,
88 })
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct StorageRuntimeConfig {
95 pub topology: StorageTopology,
96}
97
98impl StorageRuntimeConfig {
99 pub fn from_environment(
100 environment: &EnvironmentConfig,
101 ) -> Result<Self, StorageRuntimeConfigError> {
102 let topology = environment
103 .storage_topology
104 .as_deref()
105 .map(parse_storage_topology)
106 .transpose()?
107 .unwrap_or(StorageTopology::SingleSqlite);
108
109 Ok(Self { topology })
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum StorageRuntimeConfigError {
116 InvalidTopology(String),
117}
118
119impl fmt::Display for StorageRuntimeConfigError {
120 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 Self::InvalidTopology(value) => write!(
123 formatter,
124 "storage topology '{value}' must be single_sqlite or partitioned_sqlite"
125 ),
126 }
127 }
128}
129
130impl Error for StorageRuntimeConfigError {}
131
132fn parse_storage_topology(value: &str) -> Result<StorageTopology, StorageRuntimeConfigError> {
133 match StorageTopology::parse(value) {
134 Ok(topology) => Ok(topology),
135 Err(_) => Err(StorageRuntimeConfigError::InvalidTopology(value.to_owned())),
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct FileIndexRuntimeConfig {
142 pub enabled: bool,
143 pub roots: Vec<FileIndexRootConfig>,
144 pub excludes: Vec<String>,
145 pub max_depth: usize,
146 pub max_file_bytes: u64,
147 pub scan_interval: Duration,
148 pub scan_timeout: Duration,
149 pub max_files_per_root: usize,
150 pub query_timeout: Duration,
151}
152
153impl FileIndexRuntimeConfig {
154 pub const DEFAULT_MAX_DEPTH: usize = 32;
155 pub const DEFAULT_MAX_FILE_BYTES: u64 = 512 * 1024 * 1024;
156 pub const DEFAULT_SCAN_INTERVAL: Duration = Duration::from_secs(900);
157 pub const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_secs(300);
158 pub const DEFAULT_MAX_FILES_PER_ROOT: usize = 50_000;
159 pub const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_millis(750);
160
161 pub fn from_environment(
162 environment: &EnvironmentConfig,
163 ) -> Result<Self, FileIndexRuntimeConfigError> {
164 let mut roots = default_user_document_roots(&environment.platform)
165 .map_err(FileIndexRuntimeConfigError::Paths)?
166 .into_iter()
167 .map(|path| FileIndexRootConfig::new("user-documents", path))
168 .collect::<Vec<_>>();
169 for root in split_semicolon(environment.file_index.roots.as_deref())? {
170 roots.push(file_index_root_from_environment(
171 "local-files",
172 root,
173 environment.platform.platform,
174 )?);
175 }
176 roots.sort_by(|left, right| {
177 left.scope_id
178 .cmp(&right.scope_id)
179 .then(left.root_id.cmp(&right.root_id))
180 });
181 roots.dedup_by(|left, right| {
182 left.scope_id == right.scope_id && left.root_id == right.root_id
183 });
184
185 Ok(Self {
186 enabled: environment.file_index.enabled.unwrap_or(false),
187 roots,
188 excludes: split_semicolon(environment.file_index.excludes.as_deref())?,
189 max_depth: environment
190 .file_index
191 .max_depth
192 .unwrap_or(Self::DEFAULT_MAX_DEPTH),
193 max_file_bytes: environment
194 .file_index
195 .max_file_bytes
196 .unwrap_or(Self::DEFAULT_MAX_FILE_BYTES),
197 scan_interval: Duration::from_millis(
198 environment
199 .file_index
200 .scan_interval_ms
201 .unwrap_or(duration_millis(Self::DEFAULT_SCAN_INTERVAL)),
202 ),
203 scan_timeout: Duration::from_millis(
204 environment
205 .file_index
206 .scan_timeout_ms
207 .unwrap_or(duration_millis(Self::DEFAULT_SCAN_TIMEOUT)),
208 ),
209 max_files_per_root: environment
210 .file_index
211 .max_files_per_root
212 .unwrap_or(Self::DEFAULT_MAX_FILES_PER_ROOT),
213 query_timeout: Duration::from_millis(
214 environment
215 .file_index
216 .query_timeout_ms
217 .unwrap_or(duration_millis(Self::DEFAULT_QUERY_TIMEOUT)),
218 ),
219 })
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct FileIndexRootConfig {
226 pub scope_id: String,
227 pub root_id: String,
228 pub root_path: PathBuf,
229}
230
231impl FileIndexRootConfig {
232 pub fn new(scope_id: impl Into<String>, root_path: PathBuf) -> Self {
233 let root_path = normalize_file_index_root_path(root_path);
234 let root_id = format!(
235 "root-{:016x}",
236 stable_hash64(root_path.to_string_lossy().as_bytes())
237 );
238
239 Self {
240 scope_id: scope_id.into(),
241 root_id,
242 root_path,
243 }
244 }
245}
246
247fn normalize_file_index_root_path(root_path: PathBuf) -> PathBuf {
248 if let Ok(canonical) = std::fs::canonicalize(&root_path) {
249 return canonical;
250 }
251
252 let mut normalized = PathBuf::new();
253 for component in root_path.components() {
254 match component {
255 Component::CurDir => {}
256 Component::ParentDir => normalized.push(".."),
257 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
258 Component::RootDir => normalized.push(component.as_os_str()),
259 Component::Normal(value) => normalized.push(value),
260 }
261 }
262
263 if normalized.as_os_str().is_empty() {
264 root_path
265 } else {
266 normalized
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum FileIndexRuntimeConfigError {
273 EmptyListValue,
274 RelativeRoot(String),
275 Paths(PathError),
276}
277
278impl fmt::Display for FileIndexRuntimeConfigError {
279 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
280 match self {
281 Self::EmptyListValue => {
282 write!(formatter, "file index lists must not contain empty values")
283 }
284 Self::RelativeRoot(path) => {
285 write!(
286 formatter,
287 "file index root '{path}' must be an absolute path"
288 )
289 }
290 Self::Paths(error) => write!(formatter, "{error}"),
291 }
292 }
293}
294
295impl Error for FileIndexRuntimeConfigError {}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct WorkerRuntimeConfig {
300 pub embedding_endpoint: Option<String>,
301 pub ocr_endpoint: Option<String>,
302 pub vision_endpoint: Option<String>,
303 pub extractor_endpoint: Option<String>,
304 pub max_in_flight: usize,
305 pub code_index_max_in_flight: usize,
306 pub silent_updates_enabled: bool,
307}
308
309impl WorkerRuntimeConfig {
310 pub const DEFAULT_MAX_IN_FLIGHT: usize = 2;
311 pub const DEFAULT_CODE_INDEX_MAX_IN_FLIGHT: usize = 2;
312 pub const MAX_CODE_INDEX_MAX_IN_FLIGHT: usize = 8;
313
314 pub fn from_environment(
316 environment: &EnvironmentConfig,
317 ) -> Result<Self, WorkerRuntimeConfigError> {
318 Ok(Self {
319 embedding_endpoint: validate_worker_endpoint(
320 environment.workers.embedding_endpoint.clone(),
321 )?,
322 ocr_endpoint: validate_worker_endpoint(environment.workers.ocr_endpoint.clone())?,
323 vision_endpoint: validate_worker_endpoint(environment.workers.vision_endpoint.clone())?,
324 extractor_endpoint: validate_worker_endpoint(
325 environment.workers.extractor_endpoint.clone(),
326 )?,
327 max_in_flight: environment
328 .workers
329 .max_in_flight
330 .unwrap_or(Self::DEFAULT_MAX_IN_FLIGHT),
331 code_index_max_in_flight: environment
332 .workers
333 .code_index_max_in_flight
334 .unwrap_or(Self::DEFAULT_CODE_INDEX_MAX_IN_FLIGHT)
335 .min(Self::MAX_CODE_INDEX_MAX_IN_FLIGHT),
336 silent_updates_enabled: environment.workers.silent_updates_enabled.unwrap_or(false),
337 })
338 }
339
340 pub fn endpoint_for(&self, kind: WorkerKind) -> Option<&str> {
342 match kind {
343 WorkerKind::Embedding => self.embedding_endpoint.as_deref(),
344 WorkerKind::Ocr => self.ocr_endpoint.as_deref(),
345 WorkerKind::Vision => self.vision_endpoint.as_deref(),
346 WorkerKind::Extractor => self.extractor_endpoint.as_deref(),
347 }
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum WorkerRuntimeConfigError {
354 InvalidEndpoint(String),
355}
356
357impl fmt::Display for WorkerRuntimeConfigError {
358 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
359 match self {
360 Self::InvalidEndpoint(value) => write!(
361 formatter,
362 "worker endpoint '{value}' must use http:// and include a host"
363 ),
364 }
365 }
366}
367
368impl Error for WorkerRuntimeConfigError {}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct AgentRuntimeConfig {
373 pub mcp_streamable_http_enabled: bool,
374 pub mcp_endpoint: String,
375 pub mcp_allowed_origins: Vec<String>,
376 pub access_policy: AgentAccessPolicy,
377 pub audit_sink_enabled: bool,
378 pub audit_queue_depth: usize,
379}
380
381impl AgentRuntimeConfig {
382 pub const DEFAULT_AUDIT_QUEUE_DEPTH: usize = 1024;
383
384 pub fn from_environment(
386 environment: &EnvironmentConfig,
387 request_timeout: Duration,
388 ) -> Result<Self, AgentRuntimeConfigError> {
389 let max_runtime_ms = agent_runtime_budget_ms(request_timeout);
390 let access_policy = AgentAccessPolicy::new(
391 split_csv(environment.agent.mcp_allowed_scopes.as_deref())?,
392 environment
393 .agent
394 .mcp_allow_unspecified_scope
395 .unwrap_or(false),
396 environment
397 .agent
398 .mcp_max_limit
399 .unwrap_or(AgentAccessPolicy::DEFAULT_MAX_LIMIT),
400 environment
401 .agent
402 .mcp_max_context_bytes
403 .unwrap_or(AgentAccessPolicy::DEFAULT_MAX_CONTEXT_BYTES),
404 max_runtime_ms,
405 environment.agent.mcp_allow_remote_clients.unwrap_or(false),
406 )
407 .map_err(AgentRuntimeConfigError::Policy)?;
408
409 Ok(Self {
410 mcp_streamable_http_enabled: environment
411 .agent
412 .mcp_streamable_http_enabled
413 .unwrap_or(false),
414 mcp_endpoint: validate_endpoint(
415 environment.agent.mcp_endpoint.as_deref().unwrap_or("/mcp"),
416 )?,
417 mcp_allowed_origins: split_csv(environment.agent.mcp_allowed_origins.as_deref())?,
418 access_policy,
419 audit_sink_enabled: environment.agent.audit_sink_enabled.unwrap_or(false),
420 audit_queue_depth: environment
421 .agent
422 .audit_queue_depth
423 .unwrap_or(Self::DEFAULT_AUDIT_QUEUE_DEPTH),
424 })
425 }
426
427 pub fn with_streamable_http_enabled(mut self) -> Self {
429 self.mcp_streamable_http_enabled = true;
430 self
431 }
432}
433
434#[derive(Debug, Clone, PartialEq, Eq)]
436pub enum AgentRuntimeConfigError {
437 InvalidEndpoint(String),
438 EmptyListValue,
439 Policy(AgentPolicyError),
440}
441
442impl fmt::Display for AgentRuntimeConfigError {
443 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
444 match self {
445 Self::InvalidEndpoint(value) => {
446 write!(
447 formatter,
448 "MCP endpoint '{value}' must be an absolute HTTP path"
449 )
450 }
451 Self::EmptyListValue => {
452 write!(formatter, "MCP comma-separated values must not be empty")
453 }
454 Self::Policy(error) => write!(formatter, "{error}"),
455 }
456 }
457}
458
459impl Error for AgentRuntimeConfigError {}
460
461#[derive(Debug, Clone, PartialEq, Eq)]
463pub enum RuntimeConfigurationError {
464 Environment(EnvError),
465 Paths(PathError),
466 Network(NetworkConfigError),
467 NetworkRuntime(NetworkRuntimeError),
468 Agent(AgentRuntimeConfigError),
469 Retrieval(RetrievalRuntimeConfigError),
470 Workers(WorkerRuntimeConfigError),
471 FileIndex(FileIndexRuntimeConfigError),
472 Updates(UpdateRuntimeConfigError),
473 Storage(StorageRuntimeConfigError),
474}
475
476impl fmt::Display for RuntimeConfigurationError {
477 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
478 match self {
479 Self::Environment(error) => write!(formatter, "{error}"),
480 Self::Paths(error) => write!(formatter, "{error}"),
481 Self::Network(error) => write!(formatter, "{error}"),
482 Self::NetworkRuntime(error) => write!(formatter, "{error}"),
483 Self::Agent(error) => write!(formatter, "{error}"),
484 Self::Retrieval(error) => write!(formatter, "{error}"),
485 Self::Workers(error) => write!(formatter, "{error}"),
486 Self::FileIndex(error) => write!(formatter, "{error}"),
487 Self::Updates(error) => write!(formatter, "{error}"),
488 Self::Storage(error) => write!(formatter, "{error}"),
489 }
490 }
491}
492
493impl Error for RuntimeConfigurationError {}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
497pub enum RetrievalRuntimeConfigError {
498 InvalidBackend(ReadModelBackendModeError),
499 InvalidRerankBackend(RerankModeError),
500 InvalidProvider(EmbeddingProviderKindError),
501 EmptyModelName(&'static str),
502 MissingRemoteValue(&'static str),
503 InvalidRemoteBaseUrl(String),
504 DimensionTooLarge(usize),
505}
506
507impl fmt::Display for RetrievalRuntimeConfigError {
508 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
509 match self {
510 Self::InvalidBackend(error) => write!(formatter, "{error}"),
511 Self::InvalidRerankBackend(error) => write!(formatter, "{error}"),
512 Self::InvalidProvider(error) => write!(formatter, "{error}"),
513 Self::EmptyModelName(variable) => {
514 write!(formatter, "{variable} must not be blank")
515 }
516 Self::MissingRemoteValue(variable) => {
517 write!(
518 formatter,
519 "{variable} is required when a read model backend is external"
520 )
521 }
522 Self::InvalidRemoteBaseUrl(value) => {
523 write!(
524 formatter,
525 "embedding base URL '{value}' must use http:// or https://"
526 )
527 }
528 Self::DimensionTooLarge(value) => {
529 write!(formatter, "embedding dimension {value} does not fit in u32")
530 }
531 }
532 }
533}
534
535impl Error for RetrievalRuntimeConfigError {}
536
537fn retrieval_config_from_environment(
538 overrides: &RetrievalEnvOverrides,
539) -> Result<ReadModelBackendConfig, RetrievalRuntimeConfigError> {
540 let semantic_mode = parse_backend_mode(overrides.semantic_backend.as_deref())?;
541 let vector_mode = parse_backend_mode(overrides.vector_backend.as_deref())?;
542 let remote_required = semantic_mode == ReadModelBackendMode::External
543 || vector_mode == ReadModelBackendMode::External;
544 require_remote_model_metadata(overrides, remote_required)?;
545 let dimension = match overrides.embedding_dimension {
546 Some(value) => u32::try_from(value)
547 .map_err(|_| RetrievalRuntimeConfigError::DimensionTooLarge(value))?,
548 None => LOCAL_VECTOR_DIMENSION,
549 };
550 let text_model = model_name_override(
551 overrides.text_embedding_model.as_deref(),
552 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL,
553 LOCAL_VECTOR_MODEL,
554 )?;
555 let semantic_model = model_name_override(
556 overrides.text_embedding_model.as_deref(),
557 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL,
558 LOCAL_SEMANTIC_MODEL,
559 )?;
560 let image_model = model_name_override(
561 overrides.image_embedding_model.as_deref(),
562 RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL,
563 "relay-local-image-hash-v1",
564 )?;
565
566 let remote_embedding = remote_embedding_config_from_environment(overrides, remote_required)?;
567 let rerank = rerank_config_from_environment(overrides)?;
568
569 Ok(ReadModelBackendConfig {
570 semantic_mode,
571 vector_mode,
572 semantic_model: ReadModelMetadata {
573 name: semantic_model,
574 dimension,
575 },
576 vector_model: ReadModelMetadata {
577 name: text_model,
578 dimension,
579 },
580 image_model: ReadModelMetadata {
581 name: image_model,
582 dimension,
583 },
584 remote_embedding,
585 rerank,
586 })
587}
588
589fn rerank_config_from_environment(
590 overrides: &RetrievalEnvOverrides,
591) -> Result<RerankConfig, RetrievalRuntimeConfigError> {
592 let mode = overrides
593 .rerank_backend
594 .as_deref()
595 .map(RerankMode::parse)
596 .transpose()
597 .map_err(RetrievalRuntimeConfigError::InvalidRerankBackend)?
598 .unwrap_or(RerankMode::Local);
599 let model = match mode {
600 RerankMode::Disabled => None,
601 RerankMode::Local => Some(model_name_override(
602 overrides.rerank_model.as_deref(),
603 RELAY_KNOWLEDGE_RERANK_MODEL,
604 LOCAL_RERANK_MODEL,
605 )?),
606 RerankMode::External => overrides
607 .rerank_model
608 .as_deref()
609 .map(|model| model_name_override(Some(model), RELAY_KNOWLEDGE_RERANK_MODEL, ""))
610 .transpose()?,
611 };
612 let timeout = overrides
613 .rerank_timeout_ms
614 .map(Duration::from_millis)
615 .unwrap_or(DEFAULT_RERANK_TIMEOUT);
616
617 Ok(RerankConfig {
618 mode,
619 model,
620 timeout,
621 candidate_multiplier: overrides
622 .rerank_candidate_multiplier
623 .unwrap_or(DEFAULT_RERANK_CANDIDATE_MULTIPLIER),
624 max_candidates: overrides
625 .rerank_max_candidates
626 .unwrap_or(DEFAULT_RERANK_MAX_CANDIDATES),
627 })
628}
629
630fn require_remote_model_metadata(
631 overrides: &RetrievalEnvOverrides,
632 required: bool,
633) -> Result<(), RetrievalRuntimeConfigError> {
634 if !required {
635 return Ok(());
636 }
637 if overrides.text_embedding_model.is_none() {
638 return Err(RetrievalRuntimeConfigError::MissingRemoteValue(
639 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL,
640 ));
641 }
642 if overrides.embedding_dimension.is_none() {
643 return Err(RetrievalRuntimeConfigError::MissingRemoteValue(
644 RELAY_KNOWLEDGE_EMBEDDING_DIMENSION,
645 ));
646 }
647
648 Ok(())
649}
650
651fn remote_embedding_config_from_environment(
652 overrides: &RetrievalEnvOverrides,
653 required: bool,
654) -> Result<Option<RemoteEmbeddingConfig>, RetrievalRuntimeConfigError> {
655 if !required {
656 return Ok(None);
657 }
658 let provider = overrides
659 .llm_provider
660 .as_deref()
661 .map(EmbeddingProviderKind::parse)
662 .transpose()
663 .map_err(RetrievalRuntimeConfigError::InvalidProvider)?
664 .unwrap_or(EmbeddingProviderKind::OpenAiCompatible);
665 let base_url = required_remote_value(
666 overrides.embedding_base_url.as_deref(),
667 RELAY_KNOWLEDGE_EMBEDDING_BASE_URL,
668 )?;
669 if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
670 return Err(RetrievalRuntimeConfigError::InvalidRemoteBaseUrl(base_url));
671 }
672 let api_key = required_remote_value(
673 overrides.embedding_api_key.as_deref(),
674 RELAY_KNOWLEDGE_EMBEDDING_API_KEY,
675 )?;
676 let batch_size = overrides
677 .embedding_batch_size
678 .unwrap_or(DEFAULT_EMBEDDING_BATCH_SIZE);
679 let timeout = overrides
680 .embedding_timeout_ms
681 .map(Duration::from_millis)
682 .unwrap_or(DEFAULT_EMBEDDING_TIMEOUT);
683 let max_concurrency = overrides
684 .embedding_max_concurrency
685 .unwrap_or(DEFAULT_EMBEDDING_MAX_CONCURRENCY);
686
687 Ok(Some(RemoteEmbeddingConfig {
688 provider,
689 base_url,
690 api_key,
691 batch_size,
692 timeout,
693 max_concurrency,
694 }))
695}
696
697fn required_remote_value(
698 value: Option<&str>,
699 variable: &'static str,
700) -> Result<String, RetrievalRuntimeConfigError> {
701 match value.map(str::trim) {
702 Some(trimmed) if !trimmed.is_empty() => Ok(trimmed.to_owned()),
703 _ => Err(RetrievalRuntimeConfigError::MissingRemoteValue(variable)),
704 }
705}
706
707fn model_name_override(
708 value: Option<&str>,
709 variable: &'static str,
710 default: &'static str,
711) -> Result<String, RetrievalRuntimeConfigError> {
712 match value {
713 Some(raw) => {
714 let trimmed = raw.trim();
715 if trimmed.is_empty() {
716 Err(RetrievalRuntimeConfigError::EmptyModelName(variable))
717 } else {
718 Ok(trimmed.to_owned())
719 }
720 }
721 None => Ok(default.to_owned()),
722 }
723}
724
725fn parse_backend_mode(
726 value: Option<&str>,
727) -> Result<ReadModelBackendMode, RetrievalRuntimeConfigError> {
728 value
729 .map(ReadModelBackendMode::parse)
730 .transpose()
731 .map_err(RetrievalRuntimeConfigError::InvalidBackend)
732 .map(|mode| mode.unwrap_or(ReadModelBackendMode::Local))
733}
734
735fn validate_endpoint(value: &str) -> Result<String, AgentRuntimeConfigError> {
736 let trimmed = value.trim();
737 if !trimmed.starts_with('/')
738 || trimmed.contains(char::is_whitespace)
739 || trimmed.contains('?')
740 || trimmed.contains('#')
741 {
742 return Err(AgentRuntimeConfigError::InvalidEndpoint(value.to_owned()));
743 }
744
745 Ok(trimmed.to_owned())
746}
747
748fn validate_worker_endpoint(
749 value: Option<String>,
750) -> Result<Option<String>, WorkerRuntimeConfigError> {
751 value
752 .map(|endpoint| {
753 let trimmed = endpoint.trim();
754 if is_valid_worker_http_endpoint(trimmed) {
755 Ok(trimmed.to_owned())
756 } else {
757 Err(WorkerRuntimeConfigError::InvalidEndpoint(endpoint))
758 }
759 })
760 .transpose()
761}
762
763fn is_valid_worker_http_endpoint(value: &str) -> bool {
764 let Some(remainder) = value.strip_prefix("http://") else {
765 return false;
766 };
767 let authority = remainder
768 .split_once('/')
769 .map_or(remainder, |(authority, _)| authority);
770 if authority.is_empty() || authority.contains(char::is_whitespace) {
771 return false;
772 }
773 if let Some((host, port)) = authority.rsplit_once(':') {
774 return !host.is_empty() && port.parse::<u16>().is_ok_and(|port| port > 0);
775 }
776
777 !authority.is_empty()
778}
779
780fn split_csv(value: Option<&str>) -> Result<Vec<String>, AgentRuntimeConfigError> {
781 value
782 .map(|items| {
783 items
784 .split(',')
785 .map(str::trim)
786 .map(|item| {
787 if item.is_empty() {
788 Err(AgentRuntimeConfigError::EmptyListValue)
789 } else {
790 Ok(item.to_owned())
791 }
792 })
793 .collect()
794 })
795 .unwrap_or_else(|| Ok(Vec::new()))
796}
797
798fn split_semicolon(value: Option<&str>) -> Result<Vec<String>, FileIndexRuntimeConfigError> {
799 value
800 .map(|items| {
801 items
802 .split(';')
803 .map(str::trim)
804 .map(|item| {
805 if item.is_empty() {
806 Err(FileIndexRuntimeConfigError::EmptyListValue)
807 } else {
808 Ok(item.to_owned())
809 }
810 })
811 .collect()
812 })
813 .unwrap_or_else(|| Ok(Vec::new()))
814}
815
816fn file_index_root_from_environment(
817 scope_id: &'static str,
818 root: String,
819 platform: PlatformKind,
820) -> Result<FileIndexRootConfig, FileIndexRuntimeConfigError> {
821 if !is_absolute_file_index_root(&root, platform) {
822 return Err(FileIndexRuntimeConfigError::RelativeRoot(root));
823 }
824
825 Ok(FileIndexRootConfig::new(scope_id, PathBuf::from(root)))
826}
827
828fn is_absolute_file_index_root(root: &str, platform: PlatformKind) -> bool {
829 match platform {
830 PlatformKind::Windows => is_absolute_windows_path(root),
831 _ => PathBuf::from(root).is_absolute(),
832 }
833}
834
835fn is_absolute_windows_path(root: &str) -> bool {
836 let bytes = root.as_bytes();
837 let drive_rooted = bytes.len() >= 3
838 && bytes[0].is_ascii_alphabetic()
839 && bytes[1] == b':'
840 && matches!(bytes[2], b'\\' | b'/');
841 if drive_rooted {
842 return true;
843 }
844
845 if !(root.starts_with("\\\\") || root.starts_with("//")) {
846 return false;
847 }
848 root[2..]
849 .split(['\\', '/'])
850 .filter(|component| !component.is_empty())
851 .take(2)
852 .count()
853 == 2
854}
855
856fn stable_hash64(bytes: &[u8]) -> u64 {
857 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
858 const FNV_PRIME: u64 = 0x100000001b3;
859
860 let mut hash = FNV_OFFSET_BASIS;
861 for byte in bytes {
862 hash ^= u64::from(*byte);
863 hash = hash.wrapping_mul(FNV_PRIME);
864 }
865
866 hash
867}
868
869fn duration_millis(duration: Duration) -> u64 {
870 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
871}
872
873fn agent_runtime_budget_ms(request_timeout: Duration) -> u64 {
874 let budget = request_timeout.saturating_sub(Duration::from_millis(1));
875 duration_millis(budget).max(1)
876}
877
878#[cfg(test)]
879mod tests;