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
31#[derive(Debug, Deserialize)]
32pub struct EnabledHostsRequest {
33    pub host_id: String,
34    pub enabled: bool,
35}
36
37#[derive(Debug, Serialize)]
38pub struct SetHostPrefResponse {
39    pub host_id: String,
40    pub enabled: bool,
41}
42
43pub async fn set_enabled_host(
44    jwt_extractor: Arc<JwtContextExtractor>,
45    ctx: systemprompt_runtime::AppContext,
46    headers: HeaderMap,
47    Json(body): Json<EnabledHostsRequest>,
48) -> Result<Json<SetHostPrefResponse>, (StatusCode, String)> {
49    let credential = extract_credential(&headers).ok_or_else(|| {
50        (
51            StatusCode::UNAUTHORIZED,
52            "Missing Authorization or x-api-key credential".to_owned(),
53        )
54    })?;
55    let (claims, _user) = jwt_extractor
56        .decode_for_gateway(&JwtToken::new(credential))
57        .await
58        .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
59
60    if !KNOWN_HOSTS.iter().any(|h| *h == body.host_id) {
61        return Err((
62            StatusCode::BAD_REQUEST,
63            format!("unknown host: {}", body.host_id),
64        ));
65    }
66
67    bridge_data::upsert_host_pref(&ctx, &claims.user_id, &body.host_id, body.enabled)
68        .await
69        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
70
71    Ok(Json(SetHostPrefResponse {
72        host_id: body.host_id,
73        enabled: body.enabled,
74    }))
75}
76
77#[derive(Debug, Deserialize)]
78pub struct HostModelFilterRequest {
79    pub host_id: String,
80    /// API-surface tags the host should advertise. `None` clears the override
81    /// (host falls back to its built-in default); `Some(empty)` means "all
82    /// models" (no restriction).
83    #[serde(default)]
84    pub model_protocols: Option<Vec<String>>,
85}
86
87#[derive(Debug, Serialize)]
88pub struct HostModelFilterResponse {
89    pub host_id: String,
90    pub model_protocols: Option<Vec<String>>,
91}
92
93pub async fn set_host_model_filter(
94    jwt_extractor: Arc<JwtContextExtractor>,
95    ctx: systemprompt_runtime::AppContext,
96    headers: HeaderMap,
97    Json(body): Json<HostModelFilterRequest>,
98) -> Result<Json<HostModelFilterResponse>, (StatusCode, String)> {
99    let credential = extract_credential(&headers).ok_or_else(|| {
100        (
101            StatusCode::UNAUTHORIZED,
102            "Missing Authorization or x-api-key credential".to_owned(),
103        )
104    })?;
105    let (claims, _user) = jwt_extractor
106        .decode_for_gateway(&JwtToken::new(credential))
107        .await
108        .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
109
110    if !KNOWN_HOSTS.iter().any(|h| *h == body.host_id) {
111        return Err((
112            StatusCode::BAD_REQUEST,
113            format!("unknown host: {}", body.host_id),
114        ));
115    }
116
117    let normalized = body
118        .model_protocols
119        .as_ref()
120        .map(|tags| {
121            tags.iter()
122                .map(|tag| {
123                    ApiSurface::from_tag(tag)
124                        .map(|s| s.as_tag().to_owned())
125                        .ok_or_else(|| {
126                            (
127                                StatusCode::BAD_REQUEST,
128                                format!("unknown API surface: {tag}"),
129                            )
130                        })
131                })
132                .collect::<Result<Vec<String>, _>>()
133        })
134        .transpose()?;
135
136    bridge_data::set_host_model_protocols(
137        &ctx,
138        &claims.user_id,
139        &body.host_id,
140        normalized.as_deref(),
141    )
142    .await
143    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
144
145    Ok(Json(HostModelFilterResponse {
146        host_id: body.host_id,
147        model_protocols: normalized,
148    }))
149}
150
151pub async fn pubkey() -> impl IntoResponse {
152    match manifest_signing::pubkey_b64() {
153        Ok(b64) => (StatusCode::OK, Json(json!({ "pubkey": b64 }))).into_response(),
154        Err(e) => (
155            StatusCode::INTERNAL_SERVER_ERROR,
156            Json(json!({ "error": e.to_string() })),
157        )
158            .into_response(),
159    }
160}
161
162pub async fn profile() -> Result<Json<BridgeProfileResponse>, (StatusCode, String)> {
163    let profile = ProfileBootstrap::get().map_err(|e| {
164        (
165            StatusCode::SERVICE_UNAVAILABLE,
166            format!("Profile not ready: {e}"),
167        )
168    })?;
169
170    let gateway = profile
171        .gateway
172        .as_ref()
173        .and_then(systemprompt_models::profile::GatewayState::resolved)
174        .filter(|g| g.enabled)
175        .ok_or_else(|| (StatusCode::NOT_FOUND, "Gateway not enabled".to_owned()))?;
176
177    let base = profile.server.api_external_url.trim_end_matches('/');
178    let prefix = gateway.inference_path_prefix.trim_end_matches('/');
179    let inference_gateway_base_url = format!("{base}{prefix}");
180
181    let organization_uuid = profile
182        .cloud
183        .as_ref()
184        .and_then(|cloud| cloud.tenant_id.as_ref())
185        .map(canonicalize_org_uuid);
186
187    let secrets = systemprompt_config::SecretsBootstrap::get().ok();
188    let response = bridge_profile::build(
189        inference_gateway_base_url,
190        gateway.auth_scheme.clone(),
191        organization_uuid,
192        &profile.providers,
193        |name| {
194            secrets
195                .and_then(|s| s.get(name))
196                .is_some_and(|k| !k.is_empty())
197        },
198    );
199
200    Ok(Json(response))
201}
202
203/// Normalise a tenant id to a canonical RFC-4122 UUID for the outbound header.
204///
205/// Codex CLI threads this into the outbound `x-tenant` header, where downstream
206/// tenant attribution expects a canonical RFC-4122 UUID, not the internal
207/// `local_`-prefixed form. Only this bridge-facing handler peels the prefix.
208pub fn canonicalize_org_uuid(tenant_id: &TenantId) -> String {
209    let raw = tenant_id.as_str();
210    let suffix = raw.strip_prefix("local_").unwrap_or(raw);
211    if let Ok(parsed) = Uuid::parse_str(suffix) {
212        return parsed.to_string();
213    }
214    Uuid::new_v5(&Uuid::NAMESPACE_OID, raw.as_bytes()).to_string()
215}