systemprompt_api/routes/gateway/
bridge.rs1use 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 #[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
198fn 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}