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