Skip to main content

systemprompt_api/services/gateway/service/
mod.rs

1//! Gateway dispatch entry point: route resolution, policy and quota checks,
2//! upstream send, and response finalization.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6#![expect(
7    clippy::clone_on_ref_ptr,
8    reason = "Arc::clone usage is intentional and ergonomic in this gateway dispatch path"
9)]
10
11pub mod abandon;
12pub mod credentials;
13mod error;
14pub mod finalize;
15mod guards;
16mod pricing;
17pub mod resolve;
18pub mod stages;
19
20pub use self::error::{
21    DispatchError, GovernanceDenied, GuardForbidden, GuardUnavailable, PolicyDenied,
22    PromptRepairRequired, QuotaExceeded, SafetyBlocked,
23};
24pub(super) use self::finalize::run_response_safety_scan;
25
26use std::sync::Arc;
27
28use anyhow::{Result, anyhow};
29use axum::body::Body;
30use axum::response::Response;
31use bytes::Bytes;
32use systemprompt_database::DbPool;
33use systemprompt_models::services::{GatewayConfig, ProviderRegistry, QuotaFaultMode};
34
35use self::abandon::AbandonGuard;
36use self::finalize::{FinalizeCtx, attach_request_id, finalize};
37use self::guards::{enforce_quota, enforce_request_guards};
38use self::pricing::{dispatch_pricing, trace_dispatch};
39use self::resolve::{ResolvedUpstream, resolve_upstream};
40use self::stages::{GovernedDispatch, PreparedDispatch, ScannedDispatch, UpstreamRelay};
41use super::audit::{GatewayAudit, GatewayRequestContext};
42use super::policy::{GatewayPolicySpec, PolicyResolver};
43use super::protocol::canonical::CanonicalRequest;
44use super::protocol::inbound::InboundAdapter;
45
46pub const REQUEST_ID_HEADER: &str = "x-systemprompt-request-id";
47pub const RECOVERY_COUNT_HEADER: &str = "x-systemprompt-recovery-count";
48
49#[derive(Debug, Clone, Copy)]
50pub struct GatewayService;
51
52#[derive(Debug)]
53pub struct DispatchInputs {
54    pub request: CanonicalRequest,
55    pub raw_body: Bytes,
56    pub ctx: GatewayRequestContext,
57    pub inbound: Arc<dyn InboundAdapter>,
58    pub forward_headers: Vec<(String, String)>,
59    pub identity_headers: Vec<(String, String)>,
60}
61
62impl GatewayService {
63    pub async fn dispatch(
64        config: &GatewayConfig,
65        registry: &ProviderRegistry,
66        db: &DbPool,
67        repos: &super::GatewayRepositories,
68        inputs: DispatchInputs,
69    ) -> Result<Response<Body>, DispatchError> {
70        let DispatchInputs {
71            request,
72            raw_body,
73            ctx,
74            inbound,
75            forward_headers,
76            identity_headers,
77        } = inputs;
78        let (policy, evaluation_session) =
79            dispatch_policy(repos, &ctx, config.quota_fault_mode).await?;
80        let stream_usage = inbound.wants_stream_usage(&raw_body);
81        let ai_request_id = ctx.ai_request_id.clone();
82        let upstream = resolve_upstream(config, registry, &request, &ai_request_id).await?;
83        let pricing = dispatch_pricing(config, registry, &request, &upstream, evaluation_session)?;
84
85        trace_dispatch(&ctx, &request, &upstream);
86        let audit = open_audit(repos, &ctx, &request, &raw_body, &identity_headers).await?;
87        let mut guard = AbandonGuard::arm(Arc::clone(&audit));
88        let result = dispatch_opened(OpenedDispatch {
89            config,
90            db,
91            repos,
92            audit,
93            policy,
94            stream_usage,
95            ai_request_id,
96            upstream,
97            pricing,
98            request,
99            raw_body,
100            ctx,
101            inbound,
102            forward_headers,
103        })
104        .await;
105        // Why: every `Err` from the opened dispatch has already recorded itself
106        // on the audit row, and an `Ok` has handed the row to its completion
107        // task or stream tap. The guard is for the third outcome — the future
108        // being dropped before it returns either.
109        guard.disarm();
110        result
111    }
112}
113
114struct OpenedDispatch<'a> {
115    config: &'a GatewayConfig,
116    db: &'a DbPool,
117    repos: &'a super::GatewayRepositories,
118    audit: Arc<GatewayAudit>,
119    policy: GatewayPolicySpec,
120    stream_usage: bool,
121    ai_request_id: systemprompt_identifiers::AiRequestId,
122    upstream: ResolvedUpstream<'a>,
123    pricing: systemprompt_models::services::ModelPricing,
124    request: CanonicalRequest,
125    raw_body: Bytes,
126    ctx: GatewayRequestContext,
127    inbound: Arc<dyn InboundAdapter>,
128    forward_headers: Vec<(String, String)>,
129}
130
131async fn dispatch_opened(opened: OpenedDispatch<'_>) -> Result<Response<Body>, DispatchError> {
132    let OpenedDispatch {
133        config,
134        db,
135        repos,
136        audit,
137        policy,
138        stream_usage,
139        ai_request_id,
140        upstream,
141        pricing,
142        request,
143        raw_body,
144        ctx,
145        inbound,
146        forward_headers,
147    } = opened;
148    audit
149        .pin_pricing(pricing)
150        .map_err(DispatchError::PreAudit)?;
151
152    if let Some(descriptor) = upstream.route_match_descriptor.as_deref() {
153        audit.set_route_match(descriptor).await;
154    }
155
156    enforce_quota(db, repos, &policy, &audit, config.quota_fault_mode).await?;
157    enforce_request_guards(db, &ctx.user_id, &upstream, &request, &audit).await?;
158
159    let prepared = PreparedDispatch::build(
160        config,
161        &upstream,
162        request,
163        &audit,
164        UpstreamRelay {
165            raw_body: &raw_body,
166            inbound: inbound.as_ref(),
167        },
168    )
169    .await?;
170    let governed = GovernedDispatch::enforce(prepared, db, &ctx, &audit).await?;
171    let scanned =
172        ScannedDispatch::enforce(governed, repos, &ai_request_id, &policy.safety, &audit).await?;
173
174    let evaluation = scanned.admit_evaluation(repos, &ctx, &pricing).await?;
175    let retry_policy = if evaluation {
176        super::protocol::outbound::retry::RetryPolicy::none()
177    } else {
178        super::protocol::outbound::retry::current_policy()
179    };
180    let outcome = super::protocol::outbound::retry::with_policy(
181        retry_policy,
182        scanned.send(&upstream, &forward_headers, &audit),
183    )
184    .await?;
185
186    let mut response = finalize(
187        outcome,
188        FinalizeCtx {
189            audit: Arc::clone(&audit),
190            db: db.clone(),
191            repos: repos.clone(),
192            ai_request_id: ai_request_id.clone(),
193            policy,
194            quota_fault_mode: config.quota_fault_mode,
195            inbound,
196            request_model: scanned.request_model().to_owned(),
197            stream_usage,
198        },
199    )
200    .await;
201    stages::recovery::attach_recovery_count(&mut response, scanned.recovery_count());
202    Ok(attach_request_id(response, &ai_request_id))
203}
204
205async fn dispatch_policy(
206    repos: &super::GatewayRepositories,
207    ctx: &GatewayRequestContext,
208    fault_mode: QuotaFaultMode,
209) -> Result<(GatewayPolicySpec, bool), DispatchError> {
210    if ctx.session_id.is_none() {
211        return Err(DispatchError::PreAudit(anyhow!(
212            "gateway dispatch missing authenticated session (session_id)"
213        )));
214    }
215
216    let resolver = PolicyResolver::from_repository(repos.gateway_policies.clone());
217    let policy = resolver
218        .resolve(fault_mode)
219        .await
220        .map_err(|e| DispatchError::PreAudit(anyhow!(PolicyDenied(e.to_string()))))?;
221    let evaluation_session = super::evaluation::preflight(repos, ctx, &policy)
222        .await
223        .map_err(DispatchError::PreAudit)?;
224    Ok((policy, evaluation_session))
225}
226
227async fn open_audit(
228    repos: &super::GatewayRepositories,
229    ctx: &GatewayRequestContext,
230    request: &CanonicalRequest,
231    raw_body: &Bytes,
232    identity_headers: &[(String, String)],
233) -> Result<Arc<GatewayAudit>, DispatchError> {
234    let audit = Arc::new(GatewayAudit::new(repos, ctx.clone()));
235    if let Err(error) = audit.open(request, raw_body).await {
236        if let Err(settlement_error) = audit
237            .fail("Gateway admission failed before provider dispatch")
238            .await
239        {
240            tracing::error!(%settlement_error, "Could not record failed gateway admission");
241        }
242        return Err(DispatchError::PreAudit(error));
243    }
244    if !identity_headers.is_empty() {
245        tracing::info!(
246            ai_request_id = %ctx.ai_request_id,
247            user_id = %ctx.user_id,
248            headers = ?identity_headers,
249            "Gateway consumed client identity headers"
250        );
251    }
252    Ok(audit)
253}