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