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
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 #[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
203pub 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}