systemprompt_api/routes/gateway/
bridge.rs1use 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}