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