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