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