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};
44use crate::services_root::ServicesRootBootstrap;
45
46use discovery::{discover_marketplaces, discover_plugins, discover_skills};
47use includes::resolve_includes_recursively;
48use merge::{resolve_skill_instruction_includes, resolve_system_prompt_includes};
49use types::IncludeResolveCtx;
50
51#[derive(Debug)]
52pub struct ConfigLoader {
53    base_path: PathBuf,
54    config_path: PathBuf,
55}
56
57impl ConfigLoader {
58    #[must_use]
59    pub fn new(config_path: PathBuf) -> Self {
60        let base_path = config_path
61            .parent()
62            .unwrap_or_else(|| Path::new("."))
63            .to_path_buf();
64        Self {
65            base_path,
66            config_path,
67        }
68    }
69
70    pub fn for_active_profile() -> ConfigLoadResult<Self> {
71        let profile = ProfileBootstrap::get()?;
72        let root = ServicesRootBootstrap::active_root_or(&profile.paths.services);
73        Ok(Self::new(root.join("config").join("config.yaml")))
74    }
75
76    pub fn load() -> ConfigLoadResult<ServicesConfig> {
77        Self::for_active_profile()?.run_cached()
78    }
79
80    pub fn reload() -> ConfigLoadResult<ServicesConfig> {
81        Self::for_active_profile()?.run_uncached()
82    }
83
84    fn run_uncached(&self) -> ConfigLoadResult<ServicesConfig> {
85        let config = self.run()?;
86        let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
87        cache_store(key, &config);
88        Ok(config)
89    }
90
91    #[cfg(any(test, feature = "expose-internals"))]
92    pub fn load_cached_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
93        Self::new(path.to_path_buf()).run_cached()
94    }
95
96    #[cfg(any(test, feature = "expose-internals"))]
97    pub fn reload_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
98        Self::new(path.to_path_buf()).run_uncached()
99    }
100
101    fn run_cached(&self) -> ConfigLoadResult<ServicesConfig> {
102        let key = self.cache_key();
103
104        if let Some(cached) = cache_read(&key) {
105            return Ok(cached);
106        }
107
108        let config = self.run()?;
109        cache_store(key, &config);
110        Ok(config)
111    }
112
113    // Why: `fs::canonicalize` requires the path to exist, even for cache keys.
114    fn cache_key(&self) -> PathBuf {
115        let Some(parent) = self.config_path.parent() else {
116            return self.config_path.clone();
117        };
118        let Some(name) = self.config_path.file_name() else {
119            return self.config_path.clone();
120        };
121        fs::canonicalize(parent).map_or_else(|_| self.config_path.clone(), |dir| dir.join(name))
122    }
123
124    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
125        Self::new(path.to_path_buf()).run()
126    }
127
128    #[cfg(any(test, feature = "expose-internals"))]
129    pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
130        Self::new(path.to_path_buf()).run_from_content(content)
131    }
132
133    pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
134        Self::load_from_path(path).map(|_| ())
135    }
136
137    fn run(&self) -> ConfigLoadResult<ServicesConfig> {
138        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
139            path: self.config_path.clone(),
140            source: e,
141        })?;
142        self.run_from_content(&content)
143    }
144
145    fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
146        let mut merged: ServicesConfig =
147            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
148                path: self.config_path.clone(),
149                source: e,
150            })?;
151
152        let includes = std::mem::take(&mut merged.includes);
153
154        let mut visited: HashSet<PathBuf> = HashSet::new();
155        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
156            visited.insert(canonical_root);
157        }
158        {
159            let mut ctx = IncludeResolveCtx {
160                visited: &mut visited,
161                merged: &mut merged,
162                chain: vec![self.config_path.clone()],
163            };
164            for include_path in &includes {
165                resolve_includes_recursively(
166                    &self.base_path,
167                    include_path,
168                    &self.config_path,
169                    &mut ctx,
170                )?;
171            }
172        }
173
174        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
175        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
176        gateway::resolve_file_gateway_includes(&self.base_path, &mut merged)?;
177        gateway::project_gateway(&mut merged);
178
179        discover_skills(&self.base_path, &mut merged)?;
180        discover_plugins(&self.base_path, &mut merged)?;
181        discover_marketplaces(&self.base_path, &mut merged)?;
182
183        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
184            merged.settings.services_path = Some(val);
185        }
186        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
187            merged.settings.skills_path = Some(val);
188        }
189        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
190            merged.settings.config_path = Some(val);
191        }
192
193        if let Ok(profile) = ProfileBootstrap::get()
194            && !profile.services.is_identity()
195        {
196            merged
197                .apply_port_offset(profile.services.port_offset)
198                .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
199        }
200
201        demote_providers_without_credentials(&mut merged);
202
203        merged
204            .validate()
205            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
206
207        Ok(merged)
208    }
209
210    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
211        #[derive(serde::Deserialize)]
212        struct IncludesOnly {
213            #[serde(default)]
214            includes: Vec<String>,
215        }
216
217        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
218            path: self.config_path.clone(),
219            source: e,
220        })?;
221        let parsed: IncludesOnly =
222            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
223                path: self.config_path.clone(),
224                source: e,
225            })?;
226        Ok(parsed.includes)
227    }
228
229    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
230        self.get_includes()?
231            .into_iter()
232            .map(|include| {
233                let exists = self.base_path.join(&include).exists();
234                Ok((include, exists))
235            })
236            .collect()
237    }
238
239    #[must_use]
240    pub fn base_path(&self) -> &Path {
241        &self.base_path
242    }
243}
244
245static CONFIG_CACHE: OnceLock<RwLock<HashMap<PathBuf, ServicesConfig>>> = OnceLock::new();
246
247fn config_cache() -> &'static RwLock<HashMap<PathBuf, ServicesConfig>> {
248    CONFIG_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
249}
250
251fn cache_read(key: &Path) -> Option<ServicesConfig> {
252    config_cache()
253        .read()
254        .unwrap_or_else(PoisonError::into_inner)
255        .get(key)
256        .cloned()
257}
258
259fn cache_store(key: PathBuf, config: &ServicesConfig) {
260    config_cache()
261        .write()
262        .unwrap_or_else(PoisonError::into_inner)
263        .insert(key, config.clone());
264}
265
266fn demote_providers_without_credentials(config: &mut ServicesConfig) {
267    let Ok(secrets) = systemprompt_config::SecretsBootstrap::get() else {
268        return;
269    };
270
271    for provider in &mut config.providers.providers {
272        if !provider.surface.is_advertised() {
273            continue;
274        }
275        if secrets.get(provider.api_key_secret.as_str()).is_some() {
276            continue;
277        }
278        tracing::warn!(
279            provider = %provider.name.as_str(),
280            "provider has no credential in the secret store; its models will not be advertised"
281        );
282        provider.surface = ApiSurface::Backend;
283    }
284}