Skip to main content

systemprompt_api/routes/gateway/
bridge.rs

1//! Bridge profile endpoint: providers, hosts, and per-host protocol overrides.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::sync::Arc;
7
8use axum::Json;
9use axum::http::{HeaderMap, StatusCode};
10use axum::response::IntoResponse;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use systemprompt_config::ProfileBootstrap;
14use systemprompt_identifiers::{JwtToken, TenantId};
15use systemprompt_loader::ServicesBootstrap;
16use systemprompt_models::bridge::profile as bridge_profile;
17use systemprompt_models::services::ApiSurface;
18
19use systemprompt_security::manifest_signing;
20use uuid::Uuid;
21
22pub use systemprompt_models::bridge::profile::{
23    BridgeProfileResponse, ProviderHealth, provider_health,
24};
25
26use super::bridge_data;
27use super::messages::extract_credential;
28use crate::services::middleware::JwtContextExtractor;
29
30pub(super) use systemprompt_models::bridge::profile::KNOWN_HOSTS;
31
32pub fn instance_enabled_hosts(
33    services: &systemprompt_models::services::ServicesConfig,
34) -> Vec<String> {
35    KNOWN_HOSTS
36        .iter()
37        .filter(|host| {
38            services
39                .external_agents
40                .iter()
41                .find(|(id, _)| id.as_str().replace('_', "-") == **host)
42                .is_none_or(|(_, agent)| agent.enabled)
43        })
44        .map(|s| (*s).to_owned())
45        .collect()
46}
47
48#[derive(Debug, Deserialize)]
49pub struct EnabledHostsRequest {
50    pub host_id: String,
51    pub enabled: bool,
52}
53
54#[derive(Debug, Serialize)]
55pub struct SetHostPrefResponse {
56    pub host_id: String,
57    pub enabled: bool,
58}
59
60pub async fn set_enabled_host(
61    jwt_extractor: Arc<JwtContextExtractor>,
62    ctx: systemprompt_runtime::AppContext,
63    headers: HeaderMap,
64    Json(body): Json<EnabledHostsRequest>,
65) -> Result<Json<SetHostPrefResponse>, (StatusCode, String)> {
66    let credential = extract_credential(&headers).ok_or_else(|| {
67        (
68            StatusCode::UNAUTHORIZED,
69            "Missing Authorization or x-api-key credential".to_owned(),
70        )
71    })?;
72    let (claims, _user) = jwt_extractor
73        .decode_for_gateway(&JwtToken::new(credential))
74        .await
75        .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
76
77    if !KNOWN_HOSTS.iter().any(|h| *h == body.host_id) {
78        return Err((
79            StatusCode::BAD_REQUEST,
80            format!("unknown host: {}", body.host_id),
81        ));
82    }
83
84    if body.enabled {
85        let services = bridge_data::load_services_config()
86            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("services: {e}")))?;
87        if !instance_enabled_hosts(&services).contains(&body.host_id) {
88            return Err((
89                StatusCode::UNPROCESSABLE_ENTITY,
90                format!("host '{}' is disabled on this installation", body.host_id),
91            ));
92        }
93    }
94
95    bridge_data::upsert_host_pref(&ctx, &claims.user_id, &body.host_id, body.enabled)
96        .await
97        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
98
99    Ok(Json(SetHostPrefResponse {
100        host_id: body.host_id,
101        enabled: body.enabled,
102    }))
103}
104
105#[derive(Debug, Deserialize)]
106pub struct HostModelFilterRequest {
107    pub host_id: String,
108    #[serde(default)]
109    pub model_protocols: Option<Vec<String>>,
110}
111
112#[derive(Debug, Serialize)]
113pub struct HostModelFilterResponse {
114    pub host_id: String,
115    pub model_protocols: Option<Vec<String>>,
116}
117
118pub async fn set_host_model_filter(
119    jwt_extractor: Arc<JwtContextExtractor>,
120    ctx: systemprompt_runtime::AppContext,
121    headers: HeaderMap,
122    Json(body): Json<HostModelFilterRequest>,
123) -> Result<Json<HostModelFilterResponse>, (StatusCode, String)> {
124    let credential = extract_credential(&headers).ok_or_else(|| {
125        (
126            StatusCode::UNAUTHORIZED,
127            "Missing Authorization or x-api-key credential".to_owned(),
128        )
129    })?;
130    let (claims, _user) = jwt_extractor
131        .decode_for_gateway(&JwtToken::new(credential))
132        .await
133        .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
134
135    if !KNOWN_HOSTS.iter().any(|h| *h == body.host_id) {
136        return Err((
137            StatusCode::BAD_REQUEST,
138            format!("unknown host: {}", body.host_id),
139        ));
140    }
141
142    let normalized = body
143        .model_protocols
144        .as_ref()
145        .map(|tags| {
146            tags.iter()
147                .map(|tag| {
148                    ApiSurface::from_tag(tag)
149                        .map(|s| s.as_tag().to_owned())
150                        .ok_or_else(|| {
151                            (
152                                StatusCode::BAD_REQUEST,
153                                format!("unknown API surface: {tag}"),
154                            )
155                        })
156                })
157                .collect::<Result<Vec<String>, _>>()
158        })
159        .transpose()?;
160
161    bridge_data::set_host_model_protocols(
162        &ctx,
163        &claims.user_id,
164        &body.host_id,
165        normalized.as_deref(),
166    )
167    .await
168    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
169
170    Ok(Json(HostModelFilterResponse {
171        host_id: body.host_id,
172        model_protocols: normalized,
173    }))
174}
175
176pub async fn pubkey() -> impl IntoResponse {
177    match manifest_signing::pubkey_b64() {
178        Ok(b64) => (StatusCode::OK, Json(json!({ "pubkey": b64 }))).into_response(),
179        Err(e) => (
180            StatusCode::INTERNAL_SERVER_ERROR,
181            Json(json!({ "error": e.to_string() })),
182        )
183            .into_response(),
184    }
185}
186
187pub async fn profile() -> Result<Json<BridgeProfileResponse>, (StatusCode, String)> {
188    let profile = ProfileBootstrap::get().map_err(|e| {
189        (
190            StatusCode::SERVICE_UNAVAILABLE,
191            format!("Profile not ready: {e}"),
192        )
193    })?;
194
195    let services = ServicesBootstrap::get().map_err(|e| {
196        (
197            StatusCode::SERVICE_UNAVAILABLE,
198            format!("Services config not ready: {e}"),
199        )
200    })?;
201    let gateway = services
202        .gateway_config()
203        .filter(|g| g.enabled)
204        .ok_or_else(|| (StatusCode::NOT_FOUND, "Gateway not enabled".to_owned()))?;
205
206    let base = profile.server.api_external_url.trim_end_matches('/');
207    let prefix = gateway.inference_path_prefix.trim_end_matches('/');
208    let inference_gateway_base_url = format!("{base}{prefix}");
209
210    let organization_uuid = profile
211        .cloud
212        .as_ref()
213        .and_then(|cloud| cloud.tenant_id.as_ref())
214        .map(canonicalize_org_uuid);
215
216    let secrets = systemprompt_config::SecretsBootstrap::get().ok();
217    let response = bridge_profile::build(
218        bridge_profile::BridgeProfileParams {
219            inference_gateway_base_url,
220            auth_scheme: gateway.auth_scheme.clone(),
221            organization_uuid,
222            default_model: gateway.default_model.clone(),
223            registry: &services.providers,
224        },
225        |name| {
226            secrets
227                .and_then(|s| s.get(name))
228                .is_some_and(|k| !k.is_empty())
229        },
230    );
231
232    Ok(Json(response))
233}
234
235pub fn canonicalize_org_uuid(tenant_id: &TenantId) -> String {
236    let raw = tenant_id.as_str();
237    let suffix = raw.strip_prefix("local_").unwrap_or(raw);
238    if let Ok(parsed) = Uuid::parse_str(suffix) {
239        return parsed.to_string();
240    }
241    Uuid::new_v5(&Uuid::NAMESPACE_OID, raw.as_bytes()).to_string()
242}