Skip to main content

systemprompt_cli/commands/admin/config/
services_io.rs

1//! Shared load/save for the services files the `admin config catalog` and
2//! `admin config gateway` setters edit.
3//!
4//! The provider catalog and gateway routes are services-tree files
5//! (`ai/providers.yaml`, `ai/gateway.yaml`), not profile sections. Each setter
6//! loads its one file typed, mutates it, validates the result against the
7//! *merged* services config the process booted with — so a route naming a
8//! provider declared in another include still validates — and writes the file
9//! back. A file created by an edit is appended to the root `includes:` so the
10//! next boot actually loads it.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18use serde::{Deserialize, Serialize};
19use systemprompt_loader::ServicesBootstrap;
20use systemprompt_models::services::{GatewayState, ProviderRegistry, ServicesConfig};
21
22use super::config_section::{ConfigSection, GATEWAY_INCLUDE_RELATIVE, PROVIDERS_INCLUDE_RELATIVE};
23
24#[derive(Debug, Default, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct ProvidersFile {
27    #[serde(default)]
28    pub providers: ProviderRegistry,
29}
30
31#[derive(Debug, Default, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct GatewayFile {
34    #[serde(default)]
35    pub gateway: Option<GatewayState>,
36}
37
38#[derive(Debug)]
39pub struct ServicesFile<T> {
40    pub path: PathBuf,
41    pub existed: bool,
42    pub content: T,
43}
44
45pub(super) fn load_providers_file() -> Result<ServicesFile<ProvidersFile>> {
46    load_file(ConfigSection::Providers.file_path()?)
47}
48
49pub(super) fn load_gateway_file() -> Result<ServicesFile<GatewayFile>> {
50    load_file(ConfigSection::Gateway.file_path()?)
51}
52
53fn load_file<T: Default + for<'de> Deserialize<'de>>(path: PathBuf) -> Result<ServicesFile<T>> {
54    if !path.exists() {
55        return Ok(ServicesFile {
56            path,
57            existed: false,
58            content: T::default(),
59        });
60    }
61    let raw = std::fs::read_to_string(&path)
62        .with_context(|| format!("Failed to read {}", path.display()))?;
63    let content: T = serde_yaml::from_str(&raw)
64        .with_context(|| format!("Failed to parse {}", path.display()))?;
65    Ok(ServicesFile {
66        path,
67        existed: true,
68        content,
69    })
70}
71
72pub(super) fn save_file<T: Serialize>(file: &ServicesFile<T>, relative: &str) -> Result<()> {
73    if let Some(parent) = file.path.parent() {
74        std::fs::create_dir_all(parent)
75            .with_context(|| format!("Failed to create {}", parent.display()))?;
76    }
77    let body = serde_yaml::to_string(&file.content).context("Failed to serialize services file")?;
78    std::fs::write(&file.path, body)
79        .with_context(|| format!("Failed to write {}", file.path.display()))?;
80    if !file.existed {
81        ensure_included(relative)?;
82    }
83    Ok(())
84}
85
86pub(super) fn booted_services() -> Result<&'static ServicesConfig> {
87    ServicesBootstrap::get().context("services config is not loaded")
88}
89
90// Why: the registry is validated as the loader will see it — every include's
91// providers plus this file's edited list — so a name that collides with
92// another include fails here, at the edit, rather than at the next boot.
93pub(super) fn merged_registry_after_edit(
94    before: &ProviderRegistry,
95    after: &ProviderRegistry,
96) -> Result<ProviderRegistry> {
97    let booted = booted_services()?;
98    let mut merged = ProviderRegistry {
99        providers: booted
100            .providers
101            .providers
102            .iter()
103            .filter(|p| before.find_provider(p.name.as_str()).is_none())
104            .cloned()
105            .collect(),
106    };
107    for provider in &after.providers {
108        if merged.find_provider(provider.name.as_str()).is_some() {
109            anyhow::bail!(
110                "provider '{}' is already declared by another services include",
111                provider.name.as_str()
112            );
113        }
114        merged.providers.push(provider.clone());
115    }
116    merged
117        .validate()
118        .context("provider registry is invalid after edit; refusing to write")?;
119    Ok(merged)
120}
121
122fn ensure_included(relative: &str) -> Result<()> {
123    let root = ConfigSection::Services.file_path()?;
124    append_include(&root, relative)
125}
126
127// Why: the root aggregator is operator-authored and commented, so the include
128// is spliced in as text rather than round-tripped through a YAML value that
129// would drop every comment.
130pub fn append_include(root: &Path, relative: &str) -> Result<()> {
131    let existing = std::fs::read_to_string(root).unwrap_or_default();
132    let already = existing.lines().any(|line| {
133        let item = line.trim_start().strip_prefix("- ").map(str::trim);
134        item.is_some_and(|value| value.trim_matches(['"', '\'']) == relative)
135    });
136    if already {
137        return Ok(());
138    }
139    let entry = format!("  - {relative}\n");
140    let updated = match existing.find("\nincludes:") {
141        Some(idx) => {
142            let insert_at = idx + "\nincludes:".len();
143            let line_end = existing[insert_at..]
144                .find('\n')
145                .map_or(existing.len(), |n| insert_at + n + 1);
146            format!("{}{entry}{}", &existing[..line_end], &existing[line_end..])
147        },
148        None if existing.starts_with("includes:") => {
149            let line_end = existing.find('\n').map_or(existing.len(), |n| n + 1);
150            format!("{}{entry}{}", &existing[..line_end], &existing[line_end..])
151        },
152        None => {
153            let sep = if existing.is_empty() || existing.ends_with('\n') {
154                ""
155            } else {
156                "\n"
157            };
158            format!("{existing}{sep}includes:\n{entry}")
159        },
160    };
161    std::fs::write(root, updated).with_context(|| format!("Failed to write {}", root.display()))
162}
163
164pub(super) const fn providers_relative() -> &'static str {
165    PROVIDERS_INCLUDE_RELATIVE
166}
167
168pub(super) const fn gateway_relative() -> &'static str {
169    GATEWAY_INCLUDE_RELATIVE
170}