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, 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        name: None,
158        description: None,
159        model_pattern: args.model_pattern.clone(),
160        provider: ProviderId::new(&args.provider),
161        upstream_model: args.upstream_model.clone(),
162        extra_headers: HashMap::new(),
163        pricing: None,
164        when: None,
165        requires: None,
166        fallback_provider: None,
167        fallback_upstream_model: None,
168    };
169    route.ensure_id();
170    let spec = spec_mut(file)?;
171    spec.routes
172        .retain(|r| r.model_pattern != args.model_pattern);
173    spec.routes.push(route);
174    Ok(format!(
175        "Route {} -> {} added",
176        args.model_pattern, args.provider
177    ))
178}
179
180pub fn set_default_provider(file: &mut GatewayFile, provider: &str) -> Result<String> {
181    spec_mut(file)?.default_provider = Some(ProviderId::new(provider));
182    Ok(format!("Gateway default provider set to {}", provider))
183}
184
185pub fn clear_default_provider(file: &mut GatewayFile) -> Result<String> {
186    spec_mut(file)?.default_provider = None;
187    Ok("Gateway default provider cleared".to_owned())
188}
189
190pub fn remove_route(file: &mut GatewayFile, model_pattern: &str) -> Result<String> {
191    let spec = spec_mut(file)?;
192    let before = spec.routes.len();
193    spec.routes.retain(|r| r.model_pattern != model_pattern);
194    if spec.routes.len() == before {
195        bail!("No route found for model pattern {}", model_pattern);
196    }
197    Ok(format!("Route {} removed", model_pattern))
198}
199
200pub fn validate_gateway(file: &GatewayFile, registry: &ProviderRegistry) -> Result<()> {
201    let Some(state) = &file.gateway else {
202        return Ok(());
203    };
204    let resolved = state.clone().into_spec().resolve();
205    resolved
206        .validate(registry)
207        .map_err(|e| anyhow!("gateway validation failed: {e}"))
208}
209
210fn list_routes(config: &CliConfig) -> Result<()> {
211    let items: Vec<ListItem> = booted_services()?
212        .gateway_config()
213        .map(|gateway| gateway.routes.clone())
214        .unwrap_or_default()
215        .iter()
216        .map(|r| {
217            let route = format!("{} -> {}", r.model_pattern, r.provider.as_str());
218            ListItem::new(route, String::new(), String::new())
219        })
220        .collect();
221    render_result(
222        &CommandOutput::list(items).with_title("Gateway Routes"),
223        config,
224    );
225    Ok(())
226}