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