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