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