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