Skip to main content

systemprompt_api/routes/gateway/
bridge.rs

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