Skip to main content

systemprompt_cli/commands/admin/config/
gateway.rs

1//! `admin config gateway` — edit the profile's gateway section: enable state,
2//! routing patterns, and the default provider.
3//!
4//! Every mutation resolves the resulting spec and validates it against the
5//! profile's provider registry (`profile.providers`), so a route or
6//! default-provider that names a provider absent from the registry fails at the
7//! edit rather than at the next boot. The gateway owns no catalog: providers
8//! and models live in `profile.providers` (see `admin config catalog`).
9
10use 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::{CommandOutput, render_result};
23use systemprompt_models::artifacts::ListItem;
24
25#[derive(Debug, Subcommand)]
26pub enum GatewayCommands {
27    #[command(about = "Enable the gateway")]
28    Enable,
29
30    #[command(about = "Disable the gateway")]
31    Disable,
32
33    #[command(subcommand, about = "Manage gateway routes")]
34    Route(RouteCommands),
35
36    #[command(
37        subcommand,
38        about = "Manage the default provider (catch-all fallback route)"
39    )]
40    DefaultProvider(DefaultProviderCommands),
41}
42
43#[derive(Debug, Subcommand)]
44pub enum DefaultProviderCommands {
45    #[command(about = "Set the default provider (must exist in profile.providers)")]
46    Set {
47        #[arg(long, help = "Provider name declared in profile.providers")]
48        provider: String,
49    },
50
51    #[command(about = "Clear the default provider")]
52    Clear,
53}
54
55#[derive(Debug, Subcommand)]
56pub enum RouteCommands {
57    #[command(about = "Add or replace a route (upsert by model pattern)")]
58    Add(RouteAddArgs),
59
60    #[command(about = "Remove a route by model pattern")]
61    Remove {
62        #[arg(long, help = "Model pattern to remove (e.g. claude-*)")]
63        model_pattern: String,
64    },
65
66    #[command(about = "List configured routes")]
67    List,
68}
69
70#[derive(Debug, Clone, Args)]
71pub struct RouteAddArgs {
72    #[arg(long, help = "Model pattern (e.g. claude-*)")]
73    pub model_pattern: String,
74
75    #[arg(long, help = "Provider name (must exist in profile.providers)")]
76    pub provider: String,
77
78    #[arg(long, help = "Upstream model name the provider expects (optional)")]
79    pub upstream_model: Option<String>,
80}
81
82pub async fn execute(command: &GatewayCommands, config: &CliConfig) -> Result<()> {
83    match command {
84        GatewayCommands::Route(RouteCommands::List) => list_routes(config),
85        GatewayCommands::Enable => apply(config, |profile| set_enabled(profile, true)).await,
86        GatewayCommands::Disable => apply(config, |profile| set_enabled(profile, false)).await,
87        GatewayCommands::Route(RouteCommands::Add(args)) => {
88            apply(config, |profile| add_route(profile, args)).await
89        },
90        GatewayCommands::Route(RouteCommands::Remove { model_pattern }) => {
91            apply(config, |profile| remove_route(profile, model_pattern)).await
92        },
93        GatewayCommands::DefaultProvider(DefaultProviderCommands::Set { provider }) => {
94            apply(config, |profile| set_default_provider(profile, provider)).await
95        },
96        GatewayCommands::DefaultProvider(DefaultProviderCommands::Clear) => {
97            apply(config, clear_default_provider).await
98        },
99    }
100}
101
102async fn apply(
103    config: &CliConfig,
104    mutate: impl FnOnce(&mut Profile) -> Result<String>,
105) -> Result<()> {
106    let profile_path = ProfileBootstrap::get_path()?;
107    let mut profile = load_profile(profile_path)?;
108    let message = mutate(&mut profile)?;
109
110    validate_gateway(&profile)?;
111    save_profile(&profile, profile_path)?;
112    let outcome = super::reconcile::reconcile_authz(&profile, profile_path).await;
113
114    render_result(
115        &CommandOutput::card_value(
116            "Gateway Updated",
117            &ConfigMutationOutput {
118                field: "gateway".to_owned(),
119                message: super::reconcile::append_reconcile_notice(message, &outcome),
120            },
121        ),
122        config,
123    );
124    Ok(())
125}
126
127fn spec_mut(profile: &mut Profile) -> Result<&mut GatewayConfigSpec> {
128    profile
129        .gateway
130        .get_or_insert_with(|| GatewayState::Spec(GatewayConfigSpec::default()))
131        .as_spec_mut()
132        .ok_or_else(|| anyhow!("gateway is in a resolved state and cannot be edited"))
133}
134
135fn set_enabled(profile: &mut Profile, enabled: bool) -> Result<String> {
136    spec_mut(profile)?.enabled = enabled;
137    Ok(format!("Gateway enabled = {}", enabled))
138}
139
140fn add_route(profile: &mut Profile, args: &RouteAddArgs) -> Result<String> {
141    let mut route = GatewayRoute {
142        id: RouteId::new(""),
143        model_pattern: args.model_pattern.clone(),
144        provider: ProviderId::new(&args.provider),
145        upstream_model: args.upstream_model.clone(),
146        extra_headers: HashMap::new(),
147        pricing: None,
148        when: None,
149    };
150    route.ensure_id();
151    let spec = spec_mut(profile)?;
152    spec.routes
153        .retain(|r| r.model_pattern != args.model_pattern);
154    spec.routes.push(route);
155    Ok(format!(
156        "Route {} -> {} added",
157        args.model_pattern, args.provider
158    ))
159}
160
161fn set_default_provider(profile: &mut Profile, provider: &str) -> Result<String> {
162    spec_mut(profile)?.default_provider = Some(ProviderId::new(provider));
163    Ok(format!("Gateway default provider set to {}", provider))
164}
165
166fn clear_default_provider(profile: &mut Profile) -> Result<String> {
167    spec_mut(profile)?.default_provider = None;
168    Ok("Gateway default provider cleared".to_owned())
169}
170
171fn remove_route(profile: &mut Profile, model_pattern: &str) -> Result<String> {
172    let spec = spec_mut(profile)?;
173    let before = spec.routes.len();
174    spec.routes.retain(|r| r.model_pattern != model_pattern);
175    if spec.routes.len() == before {
176        bail!("No route found for model pattern {}", model_pattern);
177    }
178    Ok(format!("Route {} removed", model_pattern))
179}
180
181fn validate_gateway(profile: &Profile) -> Result<()> {
182    let Some(state) = &profile.gateway else {
183        return Ok(());
184    };
185    let resolved = state.clone().into_spec().resolve();
186    resolved
187        .validate(&profile.providers)
188        .map_err(|e| anyhow!("gateway validation failed: {e}"))
189}
190
191fn list_routes(config: &CliConfig) -> Result<()> {
192    let profile_path = ProfileBootstrap::get_path()?;
193    let profile = load_profile(profile_path)?;
194    let items: Vec<ListItem> = profile
195        .gateway
196        .map(|state| state.into_spec().routes)
197        .unwrap_or_default()
198        .iter()
199        .map(|r| {
200            let route = format!("{} -> {}", r.model_pattern, r.provider.as_str());
201            ListItem::new(route, String::new(), String::new())
202        })
203        .collect();
204    render_result(
205        &CommandOutput::list(items).with_title("Gateway Routes"),
206        config,
207    );
208    Ok(())
209}