systemprompt_cli/commands/admin/config/
gateway.rs1use std::collections::HashMap;
16
17use anyhow::{Result, anyhow, bail};
18use clap::{Args, Subcommand};
19use systemprompt_identifiers::ProviderId;
20use systemprompt_manifest::services::{
21 GatewayConfigSpec, GatewayRoute, GatewayState, ProviderRegistry,
22};
23
24use super::services_io::{
25 GatewayFile, booted_services, gateway_relative, load_gateway_file, save_file,
26};
27use super::types::ConfigMutationOutput;
28use crate::CliConfig;
29use crate::shared::{CommandOutput, render_result};
30use systemprompt_models::artifacts::ListItem;
31
32#[derive(Debug, Subcommand)]
33pub enum GatewayCommands {
34 #[command(about = "Enable the gateway")]
35 Enable,
36
37 #[command(about = "Disable the gateway")]
38 Disable,
39
40 #[command(subcommand, about = "Manage gateway routes")]
41 Route(RouteCommands),
42
43 #[command(
44 subcommand,
45 about = "Manage the default provider (catch-all fallback route)"
46 )]
47 DefaultProvider(DefaultProviderCommands),
48}
49
50#[derive(Debug, Subcommand)]
51pub enum DefaultProviderCommands {
52 #[command(about = "Set the default provider (must exist in the services provider registry)")]
53 Set {
54 #[arg(
55 long,
56 help = "Provider name declared in the services provider registry",
57 value_parser = crate::shared::parse_provider_id
58 )]
59 provider: ProviderId,
60 },
61
62 #[command(about = "Clear the default provider")]
63 Clear,
64}
65
66#[derive(Debug, Subcommand)]
67pub enum RouteCommands {
68 #[command(about = "Add or replace a route (upsert by model pattern)")]
69 Add(RouteAddArgs),
70
71 #[command(about = "Remove a route by model pattern")]
72 Remove {
73 #[arg(long, help = "Model pattern to remove (e.g. claude-*)")]
74 model_pattern: String,
75 },
76
77 #[command(about = "List configured routes")]
78 List,
79}
80
81#[derive(Debug, Clone, Args)]
82pub struct RouteAddArgs {
83 #[arg(long, help = "Model pattern (e.g. claude-*)")]
84 pub model_pattern: String,
85
86 #[arg(
87 long,
88 help = "Provider name (must exist in the services provider registry)",
89 value_parser = crate::shared::parse_provider_id
90 )]
91 pub provider: ProviderId,
92
93 #[arg(long, help = "Upstream model name the provider expects (optional)")]
94 pub upstream_model: Option<String>,
95}
96
97pub async fn execute(command: &GatewayCommands, config: &CliConfig) -> Result<()> {
98 match command {
99 GatewayCommands::Route(RouteCommands::List) => list_routes(config),
100 GatewayCommands::Enable => apply(config, |file| set_enabled(file, true)).await,
101 GatewayCommands::Disable => apply(config, |file| set_enabled(file, false)).await,
102 GatewayCommands::Route(RouteCommands::Add(args)) => {
103 apply(config, |file| add_route(file, args)).await
104 },
105 GatewayCommands::Route(RouteCommands::Remove { model_pattern }) => {
106 apply(config, |file| remove_route(file, model_pattern)).await
107 },
108 GatewayCommands::DefaultProvider(DefaultProviderCommands::Set { provider }) => {
109 apply(config, |file| set_default_provider(file, provider)).await
110 },
111 GatewayCommands::DefaultProvider(DefaultProviderCommands::Clear) => {
112 apply(config, clear_default_provider).await
113 },
114 }
115}
116
117async fn apply(
118 config: &CliConfig,
119 mutate: impl FnOnce(&mut GatewayFile) -> Result<String>,
120) -> Result<()> {
121 let mut file = load_gateway_file()?;
122 let message = mutate(&mut file.content)?;
123
124 let registry = &booted_services()?.providers;
125 validate_gateway(&file.content, registry)?;
126 save_file(&file, gateway_relative())?;
127 let source = file.path.display().to_string();
128 let outcome =
129 super::reconcile::reconcile_authz(file.content.gateway.as_ref(), registry, &source).await;
130
131 render_result(
132 &CommandOutput::card_value(
133 "Gateway Updated",
134 &ConfigMutationOutput {
135 field: "gateway".to_owned(),
136 message: super::reconcile::append_reconcile_notice(message, &outcome),
137 },
138 ),
139 config,
140 );
141 Ok(())
142}
143
144pub fn spec_mut(file: &mut GatewayFile) -> Result<&mut GatewayConfigSpec> {
145 file.gateway
146 .get_or_insert_with(|| GatewayState::Spec(GatewayConfigSpec::default()))
147 .as_spec_mut()
148 .ok_or_else(|| anyhow!("gateway is in a resolved state and cannot be edited"))
149}
150
151pub fn set_enabled(file: &mut GatewayFile, enabled: bool) -> Result<String> {
152 spec_mut(file)?.enabled = enabled;
153 Ok(format!("Gateway enabled = {}", enabled))
154}
155
156pub fn add_route(file: &mut GatewayFile, args: &RouteAddArgs) -> Result<String> {
157 let mut route = GatewayRoute {
158 id: None,
159 name: None,
160 description: None,
161 model_pattern: args.model_pattern.clone(),
162 provider: args.provider.clone(),
163 upstream_model: args.upstream_model.clone(),
164 extra_headers: HashMap::new(),
165 pricing: None,
166 when: None,
167 requires: None,
168 fallbacks: Vec::new(),
169 by_scope: None,
170 };
171 route.ensure_id();
172 let spec = spec_mut(file)?;
173 spec.routes
174 .retain(|r| r.model_pattern != args.model_pattern);
175 spec.routes.push(route);
176 Ok(format!(
177 "Route {} -> {} added",
178 args.model_pattern, args.provider
179 ))
180}
181
182pub fn set_default_provider(file: &mut GatewayFile, provider: &ProviderId) -> Result<String> {
183 spec_mut(file)?.default_provider = Some(provider.clone());
184 Ok(format!("Gateway default provider set to {}", provider))
185}
186
187pub fn clear_default_provider(file: &mut GatewayFile) -> Result<String> {
188 spec_mut(file)?.default_provider = None;
189 Ok("Gateway default provider cleared".to_owned())
190}
191
192pub fn remove_route(file: &mut GatewayFile, model_pattern: &str) -> Result<String> {
193 let spec = spec_mut(file)?;
194 let before = spec.routes.len();
195 spec.routes.retain(|r| r.model_pattern != model_pattern);
196 if spec.routes.len() == before {
197 bail!("No route found for model pattern {}", model_pattern);
198 }
199 Ok(format!("Route {} removed", model_pattern))
200}
201
202pub fn validate_gateway(file: &GatewayFile, registry: &ProviderRegistry) -> Result<()> {
203 let Some(state) = &file.gateway else {
204 return Ok(());
205 };
206 let resolved = state.clone().into_spec().resolve();
207 resolved
208 .validate(registry)
209 .map_err(|e| anyhow!("gateway validation failed: {e}"))
210}
211
212fn list_routes(config: &CliConfig) -> Result<()> {
213 let items: Vec<ListItem> = booted_services()?
214 .gateway_config()
215 .map(|gateway| gateway.routes.clone())
216 .unwrap_or_default()
217 .iter()
218 .map(|r| {
219 let route = format!("{} -> {}", r.model_pattern, r.provider.as_str());
220 ListItem::new(route, String::new(), String::new())
221 })
222 .collect();
223 render_result(
224 &CommandOutput::list(items).with_title("Gateway Routes"),
225 config,
226 );
227 Ok(())
228}