Skip to main content

systemprompt_cli/commands/admin/config/
gateway.rs

1//! `admin config gateway` — edit the services gateway section
2//! (`services/ai/gateway.yaml`): enable state, routing patterns, and the
3//! default provider.
4//!
5//! Every mutation resolves the resulting spec and validates it against the
6//! merged services provider registry, so a route or default-provider that names
7//! a provider absent from the registry fails at the edit rather than at the
8//! next boot. The gateway owns no catalog: providers and models live in
9//! `services/ai/providers.yaml` (see `admin config catalog`). The profile is
10//! never touched.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use 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        strategy: systemprompt_manifest::services::SelectionStrategy::Ordered,
171        weight: None,
172        context_fallbacks: Vec::new(),
173    };
174    route.ensure_id();
175    let spec = spec_mut(file)?;
176    spec.routes
177        .retain(|r| r.model_pattern != args.model_pattern);
178    spec.routes.push(route);
179    Ok(format!(
180        "Route {} -> {} added",
181        args.model_pattern, args.provider
182    ))
183}
184
185pub fn set_default_provider(file: &mut GatewayFile, provider: &ProviderId) -> Result<String> {
186    spec_mut(file)?.default_provider = Some(provider.clone());
187    Ok(format!("Gateway default provider set to {}", provider))
188}
189
190pub fn clear_default_provider(file: &mut GatewayFile) -> Result<String> {
191    spec_mut(file)?.default_provider = None;
192    Ok("Gateway default provider cleared".to_owned())
193}
194
195pub fn remove_route(file: &mut GatewayFile, model_pattern: &str) -> Result<String> {
196    let spec = spec_mut(file)?;
197    let before = spec.routes.len();
198    spec.routes.retain(|r| r.model_pattern != model_pattern);
199    if spec.routes.len() == before {
200        bail!("No route found for model pattern {}", model_pattern);
201    }
202    Ok(format!("Route {} removed", model_pattern))
203}
204
205pub fn validate_gateway(file: &GatewayFile, registry: &ProviderRegistry) -> Result<()> {
206    let Some(state) = &file.gateway else {
207        return Ok(());
208    };
209    let resolved = state.clone().into_spec().resolve();
210    resolved
211        .validate(registry)
212        .map_err(|e| anyhow!("gateway validation failed: {e}"))
213}
214
215fn list_routes(config: &CliConfig) -> Result<()> {
216    let items: Vec<ListItem> = booted_services()?
217        .gateway_config()
218        .map(|gateway| gateway.routes.clone())
219        .unwrap_or_default()
220        .iter()
221        .map(|r| {
222            let route = format!("{} -> {}", r.model_pattern, r.provider.as_str());
223            ListItem::new(route, String::new(), String::new())
224        })
225        .collect();
226    render_result(
227        &CommandOutput::list(items).with_title("Gateway Routes"),
228        config,
229    );
230    Ok(())
231}