systemprompt_cli/commands/admin/config/
catalog.rs1use 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}