Skip to main content

systemprompt_cli/commands/admin/config/
provider.rs

1//! `admin config provider` command: manage AI providers in the
2//! `ai/config.yaml`.
3//!
4//! [`ProviderCommands`] lists providers, sets the default, and toggles a
5//! provider's enabled flag, editing the AI config YAML in place.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use anyhow::Result;
11use clap::{Args, Subcommand};
12use systemprompt_config::ProfileBootstrap;
13
14use super::types::{
15    ConfigSection, ProviderInfo, ProviderListOutput, ProviderSetOutput, read_yaml_file,
16    write_yaml_file,
17};
18use crate::CliConfig;
19use crate::shared::{CommandOutput, render_result};
20
21#[derive(Debug, Subcommand)]
22pub enum ProviderCommands {
23    #[command(about = "List AI providers")]
24    List(ListArgs),
25
26    #[command(about = "Set default provider")]
27    Set(SetArgs),
28
29    #[command(about = "Enable a provider")]
30    Enable(EnableArgs),
31
32    #[command(about = "Disable a provider")]
33    Disable(DisableArgs),
34}
35
36#[derive(Debug, Clone, Copy, Args)]
37pub struct ListArgs;
38
39#[derive(Debug, Clone, Args)]
40pub struct SetArgs {
41    #[arg(value_name = "PROVIDER")]
42    pub provider: String,
43}
44
45#[derive(Debug, Clone, Args)]
46pub struct EnableArgs {
47    #[arg(value_name = "PROVIDER")]
48    pub provider: String,
49}
50
51#[derive(Debug, Clone, Args)]
52pub struct DisableArgs {
53    #[arg(value_name = "PROVIDER")]
54    pub provider: String,
55}
56
57pub fn execute(cmd: ProviderCommands, config: &CliConfig) -> Result<()> {
58    match cmd {
59        ProviderCommands::List(_args) => {
60            let result = list_providers()?;
61            render_result(
62                &CommandOutput::table_of(
63                    vec!["name", "enabled", "is_default", "model", "endpoint"],
64                    &result.providers,
65                )
66                .with_title("AI Providers"),
67                config,
68            );
69        },
70        ProviderCommands::Set(args) => {
71            let result = set_default_provider(&args.provider)?;
72            render_result(
73                &CommandOutput::card_value("Provider Updated", &result),
74                config,
75            );
76        },
77        ProviderCommands::Enable(args) => {
78            let result = set_provider_enabled(&args.provider, true)?;
79            render_result(
80                &CommandOutput::card_value("Provider Enabled", &result),
81                config,
82            );
83        },
84        ProviderCommands::Disable(args) => {
85            let result = set_provider_enabled(&args.provider, false)?;
86            render_result(
87                &CommandOutput::card_value("Provider Disabled", &result),
88                config,
89            );
90        },
91    }
92    Ok(())
93}
94
95fn get_ai_config_path() -> Result<std::path::PathBuf> {
96    ConfigSection::Ai.file_path()
97}
98
99fn list_providers() -> Result<ProviderListOutput> {
100    let registry = &ProfileBootstrap::get()?.providers;
101    let file_path = get_ai_config_path()?;
102    let content = read_yaml_file(&file_path)?;
103
104    let ai = content
105        .get("ai")
106        .ok_or_else(|| anyhow::anyhow!("Missing 'ai' section in config"))?;
107
108    let default_provider = ai
109        .get("default_provider")
110        .and_then(|v| v.as_str())
111        .unwrap_or("unknown")
112        .to_owned();
113
114    let providers_section = ai.get("providers");
115
116    let mut providers = Vec::new();
117
118    if let Some(serde_yaml::Value::Mapping(providers_map)) = providers_section {
119        for (name, config) in providers_map {
120            let name_str = name.as_str().unwrap_or("unknown").to_owned();
121
122            let enabled = config
123                .get("enabled")
124                .and_then(serde_yaml::Value::as_bool)
125                .unwrap_or(true);
126
127            let model = config
128                .get("default_model")
129                .and_then(|v| v.as_str())
130                .unwrap_or("unknown")
131                .to_owned();
132
133            let endpoint = registry
134                .find_provider(&name_str)
135                .map(|entry| entry.endpoint.clone());
136
137            providers.push(ProviderInfo {
138                name: name_str.clone(),
139                enabled,
140                is_default: name_str == default_provider,
141                model,
142                endpoint,
143            });
144        }
145    }
146
147    Ok(ProviderListOutput {
148        providers,
149        default_provider,
150    })
151}
152
153fn set_default_provider(provider: &str) -> Result<ProviderSetOutput> {
154    let registry = &ProfileBootstrap::get()?.providers;
155    if registry.find_provider(provider).is_none() {
156        let available: Vec<&str> = registry.providers.iter().map(|p| p.name.as_str()).collect();
157        anyhow::bail!(
158            "Unknown provider: '{}' is not in profile.providers. Available: {:?}",
159            provider,
160            available
161        );
162    }
163
164    let file_path = get_ai_config_path()?;
165    let mut content = read_yaml_file(&file_path)?;
166
167    let policy = content.get("ai").and_then(|ai| ai.get("providers"));
168    let enabled = policy
169        .and_then(|p| p.get(provider))
170        .and_then(|p| p.get("enabled"))
171        .and_then(serde_yaml::Value::as_bool)
172        .unwrap_or(true);
173    if !enabled {
174        anyhow::bail!(
175            "Provider '{}' is disabled in AI policy; enable it first \
176             (admin config provider enable {})",
177            provider,
178            provider
179        );
180    }
181
182    // JSON: mutates one key in the untyped AI config document, leaving unknown
183    // operator-authored keys in place.
184    if let Some(serde_yaml::Value::Mapping(ai_map)) = content.get_mut("ai") {
185        ai_map.insert(
186            serde_yaml::Value::String("default_provider".to_owned()),
187            serde_yaml::Value::String(provider.to_owned()),
188        );
189    }
190
191    write_yaml_file(&file_path, &content)?;
192
193    Ok(ProviderSetOutput {
194        provider: provider.to_owned(),
195        action: "set_default".to_owned(),
196        message: format!("Default provider set to '{}'", provider),
197    })
198}
199
200fn set_provider_enabled(provider: &str, enabled: bool) -> Result<ProviderSetOutput> {
201    let file_path = get_ai_config_path()?;
202    let mut content = read_yaml_file(&file_path)?;
203
204    let ai = content
205        .get_mut("ai")
206        .ok_or_else(|| anyhow::anyhow!("Missing 'ai' section"))?;
207
208    let providers = ai
209        .get_mut("providers")
210        .ok_or_else(|| anyhow::anyhow!("Missing 'providers' section"))?;
211
212    let provider_config = providers
213        .get_mut(provider)
214        .ok_or_else(|| anyhow::anyhow!("Unknown provider: '{}'", provider))?;
215
216    // JSON: mutates one key in the untyped AI config document, leaving unknown
217    // operator-authored keys in place.
218    if let serde_yaml::Value::Mapping(config_map) = provider_config {
219        config_map.insert(
220            serde_yaml::Value::String("enabled".to_owned()),
221            serde_yaml::Value::Bool(enabled),
222        );
223    }
224
225    write_yaml_file(&file_path, &content)?;
226
227    let action = if enabled { "enabled" } else { "disabled" };
228
229    Ok(ProviderSetOutput {
230        provider: provider.to_owned(),
231        action: action.to_owned(),
232        message: format!("Provider '{}' {}", provider, action),
233    })
234}