Skip to main content

systemprompt_cli/commands/admin/config/
catalog.rs

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