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_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}