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