Skip to main content

systemprompt_loader/config_loader/
mod.rs

1//! Reads, parses, and merges the active services configuration.
2//!
3//! [`ConfigLoader`] is the only public entry point. It resolves the active
4//! profile (via [`systemprompt_config::ProfileBootstrap`]) to a YAML path,
5//! parses the root file, recursively resolves the `includes:` graph
6//! (rejecting cycles and duplicate definitions), inlines `!include`
7//! references inside agent system prompts, skill instructions and gateway
8//! prompt overrides, projects the gateway spec to its resolved runtime form,
9//! and finally validates the merged configuration — provider registry and
10//! gateway references included — before returning it to the caller.
11//!
12//! A provider whose `api_key_secret` does not resolve is demoted to
13//! `surface: backend` before validation, so its models stop being advertised
14//! rather than being offered in every client's picker and failing on first use.
15//! It is not a boot failure: an instance serving one provider must still start
16//! when an unrelated credential is absent. An uninitialised secret store means
17//! "unknown", never "absent" — several entry points load services with no
18//! secrets at all, and demoting there would empty the catalog. That also keeps
19//! the memoisation below benign: the worst case is the old behaviour.
20//!
21//! [`ConfigLoader::load`] — the active-profile entry point — memoises its
22//! result for the lifetime of the process. The explicit
23//! [`ConfigLoader::load_from_path`] and [`ConfigLoader::validate_file`] forms
24//! are one-shot reads of a caller-supplied file and are never cached.
25//!
26//! Copyright (c) systemprompt.io — Business Source License 1.1.
27//! See <https://systemprompt.io> for licensing details.
28
29mod discovery;
30pub mod gateway;
31mod includes;
32mod merge;
33mod types;
34
35use std::collections::{HashMap, HashSet};
36use std::fs;
37use std::path::{Path, PathBuf};
38use std::sync::{OnceLock, PoisonError, RwLock};
39
40use systemprompt_config::ProfileBootstrap;
41use systemprompt_models::services::{ApiSurface, ServicesConfig};
42
43use crate::error::{ConfigLoadError, ConfigLoadResult};
44
45use discovery::{discover_marketplaces, discover_plugins, discover_skills};
46use includes::resolve_includes_recursively;
47use merge::{resolve_skill_instruction_includes, resolve_system_prompt_includes};
48use types::IncludeResolveCtx;
49
50#[derive(Debug)]
51pub struct ConfigLoader {
52    base_path: PathBuf,
53    config_path: PathBuf,
54}
55
56impl ConfigLoader {
57    #[must_use]
58    pub fn new(config_path: PathBuf) -> Self {
59        let base_path = config_path
60            .parent()
61            .unwrap_or_else(|| Path::new("."))
62            .to_path_buf();
63        Self {
64            base_path,
65            config_path,
66        }
67    }
68
69    pub fn for_active_profile() -> ConfigLoadResult<Self> {
70        let profile = ProfileBootstrap::get()?;
71        let config_path = PathBuf::from(profile.paths.config());
72        Ok(Self::new(config_path))
73    }
74
75    pub fn load() -> ConfigLoadResult<ServicesConfig> {
76        Self::for_active_profile()?.run_cached()
77    }
78
79    pub fn reload() -> ConfigLoadResult<ServicesConfig> {
80        Self::for_active_profile()?.run_uncached()
81    }
82
83    fn run_uncached(&self) -> ConfigLoadResult<ServicesConfig> {
84        let config = self.run()?;
85        let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
86        cache_store(key, &config);
87        Ok(config)
88    }
89
90    #[cfg(any(test, feature = "expose-internals"))]
91    pub fn load_cached_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
92        Self::new(path.to_path_buf()).run_cached()
93    }
94
95    #[cfg(any(test, feature = "expose-internals"))]
96    pub fn reload_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
97        Self::new(path.to_path_buf()).run_uncached()
98    }
99
100    fn run_cached(&self) -> ConfigLoadResult<ServicesConfig> {
101        let key = self.cache_key();
102
103        if let Some(cached) = cache_read(&key) {
104            return Ok(cached);
105        }
106
107        let config = self.run()?;
108        cache_store(key, &config);
109        Ok(config)
110    }
111
112    // Why: canonicalising the file itself makes the cache key depend on the file
113    // still existing — `fs::canonicalize` fails once it is removed and the key
114    // silently falls back to the uncanonicalised path, missing the entry at the
115    // exact moment the cache is meant to cover for the missing file. Only the
116    // directory is resolved, which survives the file's removal and still folds
117    // away symlinked roots (on macOS `/var` vs `/private/var`).
118    fn cache_key(&self) -> PathBuf {
119        let Some(parent) = self.config_path.parent() else {
120            return self.config_path.clone();
121        };
122        let Some(name) = self.config_path.file_name() else {
123            return self.config_path.clone();
124        };
125        fs::canonicalize(parent).map_or_else(|_| self.config_path.clone(), |dir| dir.join(name))
126    }
127
128    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
129        Self::new(path.to_path_buf()).run()
130    }
131
132    #[cfg(any(test, feature = "expose-internals"))]
133    pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
134        Self::new(path.to_path_buf()).run_from_content(content)
135    }
136
137    pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
138        Self::load_from_path(path).map(|_| ())
139    }
140
141    fn run(&self) -> ConfigLoadResult<ServicesConfig> {
142        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
143            path: self.config_path.clone(),
144            source: e,
145        })?;
146        self.run_from_content(&content)
147    }
148
149    fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
150        let mut merged: ServicesConfig =
151            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
152                path: self.config_path.clone(),
153                source: e,
154            })?;
155
156        let includes = std::mem::take(&mut merged.includes);
157
158        let mut visited: HashSet<PathBuf> = HashSet::new();
159        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
160            visited.insert(canonical_root);
161        }
162        {
163            let mut ctx = IncludeResolveCtx {
164                visited: &mut visited,
165                merged: &mut merged,
166                chain: vec![self.config_path.clone()],
167            };
168            for include_path in &includes {
169                resolve_includes_recursively(
170                    &self.base_path,
171                    include_path,
172                    &self.config_path,
173                    &mut ctx,
174                )?;
175            }
176        }
177
178        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
179        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
180        gateway::resolve_file_gateway_includes(&self.base_path, &mut merged)?;
181        gateway::project_gateway(&mut merged);
182
183        discover_skills(&self.base_path, &mut merged)?;
184        discover_plugins(&self.base_path, &mut merged)?;
185        discover_marketplaces(&self.base_path, &mut merged)?;
186
187        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
188            merged.settings.services_path = Some(val);
189        }
190        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
191            merged.settings.skills_path = Some(val);
192        }
193        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
194            merged.settings.config_path = Some(val);
195        }
196
197        if let Ok(profile) = ProfileBootstrap::get()
198            && !profile.services.is_identity()
199        {
200            merged
201                .apply_port_offset(profile.services.port_offset)
202                .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
203        }
204
205        demote_providers_without_credentials(&mut merged);
206
207        merged
208            .validate()
209            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
210
211        Ok(merged)
212    }
213
214    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
215        #[derive(serde::Deserialize)]
216        struct IncludesOnly {
217            #[serde(default)]
218            includes: Vec<String>,
219        }
220
221        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
222            path: self.config_path.clone(),
223            source: e,
224        })?;
225        let parsed: IncludesOnly =
226            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
227                path: self.config_path.clone(),
228                source: e,
229            })?;
230        Ok(parsed.includes)
231    }
232
233    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
234        self.get_includes()?
235            .into_iter()
236            .map(|include| {
237                let exists = self.base_path.join(&include).exists();
238                Ok((include, exists))
239            })
240            .collect()
241    }
242
243    #[must_use]
244    pub fn base_path(&self) -> &Path {
245        &self.base_path
246    }
247}
248
249static CONFIG_CACHE: OnceLock<RwLock<HashMap<PathBuf, ServicesConfig>>> = OnceLock::new();
250
251fn config_cache() -> &'static RwLock<HashMap<PathBuf, ServicesConfig>> {
252    CONFIG_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
253}
254
255fn cache_read(key: &Path) -> Option<ServicesConfig> {
256    config_cache()
257        .read()
258        .unwrap_or_else(PoisonError::into_inner)
259        .get(key)
260        .cloned()
261}
262
263fn cache_store(key: PathBuf, config: &ServicesConfig) {
264    config_cache()
265        .write()
266        .unwrap_or_else(PoisonError::into_inner)
267        .insert(key, config.clone());
268}
269
270fn demote_providers_without_credentials(config: &mut ServicesConfig) {
271    let Ok(secrets) = systemprompt_config::SecretsBootstrap::get() else {
272        return;
273    };
274
275    for provider in &mut config.providers.providers {
276        if !provider.surface.is_advertised() {
277            continue;
278        }
279        if secrets.get(provider.api_key_secret.as_str()).is_some() {
280            continue;
281        }
282        // Why: the logging layer redacts any field named `secret`, so naming
283        // the missing one here rendered as `[REDACTED]`. `cloud doctor` names
284        // it in full.
285        tracing::warn!(
286            provider = %provider.name.as_str(),
287            "provider has no credential in the secret store; its models will not be advertised"
288        );
289        provider.surface = ApiSurface::Backend;
290    }
291}