Skip to main content

systemprompt_cli/commands/admin/config/
catalog.rs

1//! `admin config catalog` — edit the profile's provider registry
2//! (`profile.providers`).
3//!
4//! Mutates the typed `ProviderRegistry` on the profile — adding or removing
5//! providers and the models each provider serves — then revalidates the whole
6//! profile before writing it back. This is how an instance declares a custom
7//! provider such as `minimax` (its wire protocol, endpoint, credential, and
8//! model catalog) without hand-editing YAML.
9
10use std::collections::HashMap;
11
12use anyhow::{Result, bail};
13use clap::{Args, Subcommand};
14use systemprompt_config::ProfileBootstrap;
15use systemprompt_identifiers::{ModelId, ProviderId, SecretName};
16use systemprompt_models::Profile;
17use systemprompt_models::profile::{ApiSurface, ProviderEntry, ProviderModel, WireProtocol};
18
19use super::profile_io::{load_profile, save_profile};
20use super::types::ConfigMutationOutput;
21use crate::CliConfig;
22use crate::shared::{CommandOutput, render_result};
23use systemprompt_models::artifacts::ListItem;
24
25#[derive(Debug, Subcommand)]
26pub enum CatalogCommands {
27    #[command(subcommand, about = "Manage registry providers")]
28    Provider(ProviderCommands),
29
30    #[command(subcommand, about = "Manage the models a provider serves")]
31    Model(ModelCommands),
32}
33
34#[derive(Debug, Subcommand)]
35pub enum ProviderCommands {
36    #[command(about = "List declared providers")]
37    List,
38    #[command(about = "Add or replace a provider")]
39    Add(ProviderAddArgs),
40    #[command(about = "Remove a provider by name")]
41    Remove {
42        #[arg(long)]
43        name: String,
44    },
45}
46
47#[derive(Debug, Subcommand)]
48pub enum ModelCommands {
49    #[command(about = "Add or replace a model under a provider")]
50    Add(ModelAddArgs),
51    #[command(about = "Remove a model by id from a provider")]
52    Remove {
53        #[arg(long, help = "Provider that serves the model")]
54        provider: String,
55        #[arg(long)]
56        id: String,
57    },
58}
59
60#[derive(Debug, Clone, Args)]
61pub struct ProviderAddArgs {
62    #[arg(long)]
63    pub name: String,
64    #[arg(
65        long,
66        help = "Wire codec: anthropic | openai-chat | openai-responses | gemini"
67    )]
68    pub wire: String,
69    #[arg(
70        long,
71        help = "Client API surface: anthropic | openai | gemini | backend"
72    )]
73    pub surface: String,
74    #[arg(long)]
75    pub endpoint: String,
76    #[arg(long)]
77    pub api_key_secret: String,
78    #[arg(long = "header", help = "Extra header as KEY=VALUE (repeatable)")]
79    pub headers: Vec<String>,
80}
81
82#[derive(Debug, Clone, Args)]
83pub struct ModelAddArgs {
84    #[arg(long, help = "Provider that serves this model")]
85    pub provider: String,
86    #[arg(long)]
87    pub id: String,
88    #[arg(long = "alias", help = "Model alias (repeatable)")]
89    pub aliases: Vec<String>,
90    #[arg(
91        long,
92        help = "Vendor-side model name to forward upstream (defaults to id)"
93    )]
94    pub upstream_model: Option<String>,
95}
96
97pub async fn execute(command: &CatalogCommands, _config: &CliConfig) -> Result<()> {
98    if matches!(command, CatalogCommands::Provider(ProviderCommands::List)) {
99        return list_providers();
100    }
101
102    let profile_path = ProfileBootstrap::get_path()?;
103    let mut profile = load_profile(profile_path)?;
104
105    let message = match command {
106        CatalogCommands::Provider(ProviderCommands::List) => unreachable!("handled above"),
107        CatalogCommands::Provider(ProviderCommands::Add(args)) => add_provider(&mut profile, args)?,
108        CatalogCommands::Provider(ProviderCommands::Remove { name }) => {
109            remove_provider(&mut profile, name)?
110        },
111        CatalogCommands::Model(ModelCommands::Add(args)) => add_model(&mut profile, args)?,
112        CatalogCommands::Model(ModelCommands::Remove { provider, id }) => {
113            remove_model(&mut profile, provider, id)?
114        },
115    };
116
117    save_profile(&profile, profile_path)?;
118    let outcome = super::reconcile::reconcile_authz(&profile, profile_path).await;
119
120    render_result(&CommandOutput::card_value(
121        "Provider Registry Updated",
122        &ConfigMutationOutput {
123            field: "providers".to_owned(),
124            message: super::reconcile::append_reconcile_notice(message, &outcome),
125        },
126    ));
127    Ok(())
128}
129
130fn parse_wire(raw: &str) -> Result<WireProtocol> {
131    WireProtocol::from_tag(raw).ok_or_else(|| {
132        anyhow::anyhow!(
133            "invalid --wire '{raw}'; expected one of: anthropic, openai-chat, \
134             openai-responses, gemini"
135        )
136    })
137}
138
139fn parse_surface(raw: &str) -> Result<ApiSurface> {
140    ApiSurface::from_tag(raw).ok_or_else(|| {
141        anyhow::anyhow!(
142            "invalid --surface '{raw}'; expected one of: anthropic, openai, gemini, backend"
143        )
144    })
145}
146
147fn parse_headers(raw: &[String]) -> Result<HashMap<String, String>> {
148    raw.iter()
149        .map(|h| {
150            h.split_once('=')
151                .map(|(k, v)| (k.to_owned(), v.to_owned()))
152                .ok_or_else(|| anyhow::anyhow!("invalid --header '{h}'; expected KEY=VALUE"))
153        })
154        .collect()
155}
156
157fn add_provider(profile: &mut Profile, args: &ProviderAddArgs) -> Result<String> {
158    // Preserve the existing model catalog when replacing a provider in place.
159    let models = profile
160        .providers
161        .find_provider(&args.name)
162        .map(|p| p.models.clone())
163        .unwrap_or_default();
164    let entry = ProviderEntry {
165        name: ProviderId::new(&args.name),
166        wire: parse_wire(&args.wire)?,
167        surface: parse_surface(&args.surface)?,
168        endpoint: args.endpoint.clone(),
169        api_key_secret: SecretName::new(&args.api_key_secret),
170        extra_headers: parse_headers(&args.headers)?,
171        models,
172    };
173    profile
174        .providers
175        .providers
176        .retain(|p| p.name.as_str() != args.name);
177    profile.providers.providers.push(entry);
178    Ok(format!(
179        "Provider {} (wire {}, surface {}) added",
180        args.name, args.wire, args.surface
181    ))
182}
183
184fn remove_provider(profile: &mut Profile, name: &str) -> Result<String> {
185    let before = profile.providers.providers.len();
186    profile
187        .providers
188        .providers
189        .retain(|p| p.name.as_str() != name);
190    if profile.providers.providers.len() == before {
191        bail!("No provider named {}", name);
192    }
193    Ok(format!("Provider {} removed", name))
194}
195
196fn add_model(profile: &mut Profile, args: &ModelAddArgs) -> Result<String> {
197    let provider = profile
198        .providers
199        .providers
200        .iter_mut()
201        .find(|p| p.name.as_str() == args.provider)
202        .ok_or_else(|| anyhow::anyhow!("No provider named {}", args.provider))?;
203    let model = ProviderModel {
204        id: ModelId::new(&args.id),
205        aliases: args.aliases.iter().map(ModelId::new).collect(),
206        upstream_model: args.upstream_model.clone(),
207        pricing: systemprompt_models::services::ai::ModelPricing::default(),
208        capabilities: systemprompt_models::services::ai::ModelCapabilities::default(),
209        limits: systemprompt_models::services::ai::ModelLimits::default(),
210    };
211    provider.models.retain(|m| m.id.as_str() != args.id);
212    provider.models.push(model);
213    Ok(format!("Model {} added to {}", args.id, args.provider))
214}
215
216fn remove_model(profile: &mut Profile, provider_name: &str, id: &str) -> Result<String> {
217    let provider = profile
218        .providers
219        .providers
220        .iter_mut()
221        .find(|p| p.name.as_str() == provider_name)
222        .ok_or_else(|| anyhow::anyhow!("No provider named {}", provider_name))?;
223    let before = provider.models.len();
224    provider.models.retain(|m| m.id.as_str() != id);
225    if provider.models.len() == before {
226        bail!("No model with id {} under provider {}", id, provider_name);
227    }
228    Ok(format!("Model {} removed from {}", id, provider_name))
229}
230
231fn list_providers() -> Result<()> {
232    let profile_path = ProfileBootstrap::get_path()?;
233    let profile = load_profile(profile_path)?;
234    let items: Vec<ListItem> = profile
235        .providers
236        .providers
237        .iter()
238        .map(|p| {
239            let models: Vec<&str> = p.models.iter().map(|m| m.id.as_str()).collect();
240            let row = format!(
241                "{} [wire {} / surface {}] {} ({} models: {})",
242                p.name.as_str(),
243                p.wire,
244                p.surface,
245                p.endpoint,
246                models.len(),
247                models.join(", ")
248            );
249            ListItem::new(row, String::new(), String::new())
250        })
251        .collect();
252    render_result(&CommandOutput::list(items).with_title("Provider Registry"));
253    Ok(())
254}