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