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