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