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