systemprompt_api/services/gateway/service/
mod.rs1#![expect(
4 clippy::clone_on_ref_ptr,
5 reason = "Arc::clone usage is intentional and ergonomic in this gateway dispatch path"
6)]
7
8mod finalize;
9mod resolve;
10
11use std::sync::Arc;
12
13use anyhow::{Result, anyhow};
14use axum::body::Body;
15use axum::response::Response;
16use bytes::Bytes;
17use systemprompt_ai::SafetyConfig;
18use systemprompt_database::DbPool;
19use systemprompt_identifiers::{AiRequestId, UserId};
20use systemprompt_models::profile::{GatewayConfig, ProviderRegistry};
21
22use self::finalize::{
23 FinalizeCtx, apply_system_prompt_override, attach_request_id, finalize, run_request_safety_scan,
24};
25use self::resolve::{ResolvedUpstream, resolve_upstream};
26use super::audit::{GatewayAudit, GatewayRequestContext};
27use super::policy::{PolicyResolver, QuotaWindow};
28use super::protocol::canonical::CanonicalRequest;
29use super::protocol::inbound::InboundAdapter;
30use super::protocol::outbound::{OutboundCtx, OutboundOutcome};
31use super::quota;
32
33pub const REQUEST_ID_HEADER: &str = "x-systemprompt-request-id";
34
35#[derive(Debug, Clone, Copy)]
36pub struct GatewayService;
37
38#[derive(Debug)]
39pub struct DispatchInputs {
40 pub request: CanonicalRequest,
41 pub raw_body: Bytes,
42 pub ctx: GatewayRequestContext,
43 pub inbound: Arc<dyn InboundAdapter>,
44}
45
46#[derive(Debug, thiserror::Error)]
47pub enum DispatchError {
48 #[error(transparent)]
49 PreAudit(anyhow::Error),
50 #[error(transparent)]
51 Recorded(anyhow::Error),
52}
53
54#[derive(Debug, thiserror::Error)]
55#[error("{0}")]
56pub struct PolicyDenied(pub String);
57
58#[derive(Debug, thiserror::Error)]
59#[error("{message}")]
60pub struct QuotaExceeded {
61 pub message: String,
62 pub retry_after_seconds: i32,
63}
64
65#[derive(Debug, thiserror::Error)]
66#[error("{message}")]
67pub struct SafetyBlocked {
68 pub category: String,
69 pub message: String,
70}
71
72impl GatewayService {
73 pub async fn dispatch(
74 config: &GatewayConfig,
75 registry: &ProviderRegistry,
76 db: &DbPool,
77 inputs: DispatchInputs,
78 ) -> Result<Response<Body>, DispatchError> {
79 let DispatchInputs {
80 mut request,
81 raw_body,
82 ctx,
83 inbound,
84 } = inputs;
85 if ctx.session_id.is_none() {
86 return Err(DispatchError::PreAudit(anyhow!(
87 "gateway dispatch missing conversation binding (session_id)"
88 )));
89 }
90
91 let ai_request_id = ctx.ai_request_id.clone();
92 let upstream = resolve_upstream(config, registry, &request, &ai_request_id)?;
93
94 tracing::info!(
95 ai_request_id = %ai_request_id,
96 user_id = %ctx.user_id,
97 model = %request.model,
98 provider = %upstream.route.provider,
99 upstream = %upstream.provider.endpoint,
100 wire_protocol = %ctx.wire_protocol,
101 streaming = request.stream,
102 "Gateway request dispatched"
103 );
104
105 let resolver = PolicyResolver::new(db).map_err(DispatchError::PreAudit)?;
106 let policy = resolver.resolve().await;
107
108 let audit = Arc::new(
109 GatewayAudit::new(db, ctx.clone())
110 .map_err(|e| DispatchError::PreAudit(anyhow!("audit init failed: {e}")))?,
111 );
112
113 if let Err(e) = audit.open(&request, &raw_body).await {
114 tracing::error!(error = %e, "audit open failed — proceeding without audit row");
115 }
116
117 enforce_quota(db, &ctx.user_id, &policy.quota_windows, &audit).await?;
118 enforce_request_safety(db, &ai_request_id, &request, &policy.safety, &audit).await?;
119
120 let outcome = send_to_upstream(config, &upstream, &mut request, &audit).await?;
121
122 let response = finalize(
123 outcome,
124 FinalizeCtx {
125 audit: Arc::clone(&audit),
126 db: db.clone(),
127 ai_request_id: ai_request_id.clone(),
128 policy,
129 inbound,
130 request_model: request.model.clone(),
131 },
132 )
133 .await;
134 Ok(attach_request_id(response, &ai_request_id))
135 }
136}
137
138async fn send_to_upstream(
139 config: &GatewayConfig,
140 upstream: &ResolvedUpstream<'_>,
141 request: &mut CanonicalRequest,
142 audit: &GatewayAudit,
143) -> Result<OutboundOutcome, DispatchError> {
144 let upstream_model = upstream
145 .route
146 .effective_upstream_model(&request.model)
147 .to_owned();
148 if let Some(descriptor) =
149 apply_system_prompt_override(config, &upstream.provider.name, &upstream_model, request)
150 .await
151 {
152 audit.set_system_prompt_override(&descriptor).await;
153 }
154 let model_limits = upstream
155 .provider
156 .find_model(&upstream_model)
157 .map(|m| m.limits);
158 let outbound_ctx = OutboundCtx {
159 route: upstream.route.as_ref(),
160 endpoint: &upstream.provider.endpoint,
161 api_key: upstream.api_key,
162 request,
163 upstream_model: &upstream_model,
164 model_limits,
165 };
166
167 match upstream.adapter.send(outbound_ctx).await {
168 Ok(o) => Ok(o),
169 Err(e) => {
170 audit_upstream_failure(audit, upstream.provider.name.as_str(), &request.model, &e)
171 .await;
172 Err(DispatchError::Recorded(e))
173 },
174 }
175}
176
177async fn enforce_quota(
178 db: &DbPool,
179 user_id: &UserId,
180 quota_windows: &[QuotaWindow],
181 audit: &GatewayAudit,
182) -> Result<(), DispatchError> {
183 let reservation = quota::precheck_and_reserve(db, user_id, quota_windows)
184 .await
185 .map_err(DispatchError::Recorded)?;
186 let Some(decision) = reservation else {
187 return Ok(());
188 };
189 if decision.allow {
190 return Ok(());
191 }
192 let msg = format!(
193 "quota exceeded for window {}s (used {}/{:?})",
194 decision.window_seconds, decision.state.requests, decision.limit_requests
195 );
196 if let Err(e) = audit.fail(&msg).await {
197 tracing::warn!(error = %e, "quota audit fail failed");
198 }
199 Err(DispatchError::Recorded(
200 QuotaExceeded {
201 message: msg,
202 retry_after_seconds: decision.window_seconds,
203 }
204 .into(),
205 ))
206}
207
208async fn enforce_request_safety(
209 db: &DbPool,
210 ai_request_id: &AiRequestId,
211 request: &CanonicalRequest,
212 safety: &SafetyConfig,
213 audit: &GatewayAudit,
214) -> Result<(), DispatchError> {
215 let findings = run_request_safety_scan(db, ai_request_id, request, safety).await;
216 let Some(finding) = findings
217 .iter()
218 .find(|f| safety.block_categories.contains(&f.category))
219 else {
220 return Ok(());
221 };
222 let msg = format!(
223 "request blocked by safety policy: category '{}'",
224 finding.category
225 );
226 tracing::warn!(
227 ai_request_id = %ai_request_id,
228 category = %finding.category,
229 scanner = %finding.scanner,
230 "Gateway blocked request by safety policy"
231 );
232 if let Err(e) = audit.fail(&msg).await {
233 tracing::warn!(error = %e, "safety-block audit fail failed");
234 }
235 Err(DispatchError::Recorded(
236 SafetyBlocked {
237 category: finding.category.clone(),
238 message: msg,
239 }
240 .into(),
241 ))
242}
243
244async fn audit_upstream_failure(
245 audit: &GatewayAudit,
246 provider: &str,
247 model: &str,
248 error: &anyhow::Error,
249) {
250 tracing::warn!(
251 provider = %provider,
252 model = %model,
253 error = %error,
254 "gateway upstream call failed"
255 );
256 if let Err(audit_err) = audit.fail(&error.to_string()).await {
257 tracing::warn!(error = %audit_err, "upstream audit fail failed");
258 }
259}