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