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