Skip to main content

systemprompt_api/services/gateway/protocol/outbound/
mod.rs

1//! Outbound protocol adapters: canonical model to upstream provider.
2//!
3//! The [`OutboundAdapter`] trait sends a [`CanonicalRequest`] to an upstream
4//! provider and yields an [`OutboundOutcome`] — a buffered response or a stream
5//! of canonical events. Adapters register themselves via
6//! [`OutboundAdapterRegistration`] (collected by `inventory`) so the upstream
7//! registry can resolve one by provider tag. Implementations cover Anthropic,
8//! `OpenAI` Chat Completions, and `OpenAI` Responses.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13pub mod anthropic;
14pub mod gemini;
15pub mod openai_chat;
16pub mod openai_responses;
17pub mod retry;
18
19use std::sync::Arc;
20
21use anyhow::Result;
22use async_trait::async_trait;
23use futures_util::stream::BoxStream;
24use systemprompt_models::services::GatewayRoute;
25use systemprompt_models::services::ai::ModelLimits;
26use thiserror::Error;
27
28use super::canonical::CanonicalRequest;
29use super::canonical_response::{CanonicalEvent, CanonicalResponse};
30
31/// Upstream provider failure, carried inside the `anyhow::Error` an adapter
32/// returns so the route layer can recover the real HTTP status by downcast
33/// instead of flattening every failure to 502.
34#[derive(Debug, Error)]
35pub enum UpstreamError {
36    #[error("{provider} returned {status}: {message}")]
37    Status {
38        provider: String,
39        status: u16,
40        message: String,
41        body: bytes::Bytes,
42        retry_after: Option<String>,
43        request_id: Option<String>,
44    },
45    #[error("{provider} request failed: {source}")]
46    Transport {
47        provider: String,
48        #[source]
49        source: reqwest::Error,
50    },
51}
52
53impl UpstreamError {
54    pub async fn from_response(provider: &str, response: reqwest::Response) -> Self {
55        let status = response.status().as_u16();
56        let header = |name: &str| {
57            response
58                .headers()
59                .get(name)
60                .and_then(|v| v.to_str().ok())
61                .map(ToOwned::to_owned)
62        };
63        let retry_after = header("retry-after");
64        let request_id = header("request-id").or_else(|| header("x-request-id"));
65        let body = response.bytes().await.unwrap_or_default();
66        Self::Status {
67            provider: provider.to_owned(),
68            status,
69            message: extract_upstream_message(&String::from_utf8_lossy(&body)),
70            body,
71            retry_after,
72            request_id,
73        }
74    }
75}
76
77// Why: one process-wide client — a client per request would open a fresh
78// connection pool and TLS handshake on every gateway call.
79pub(in crate::services::gateway) fn http_client() -> &'static reqwest::Client {
80    static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
81    CLIENT.get_or_init(reqwest::Client::new)
82}
83
84// Why: every adapter's one upstream call, so the bounded retry for transient
85// capacity failures lives here rather than four times over. Retrying is safe
86// at this point precisely because it is the only point: no byte of a response
87// has been relayed, and neither the buffered nor the streaming lane has begun.
88pub(in crate::services::gateway) async fn send_checked(
89    provider: &str,
90    req: reqwest::RequestBuilder,
91) -> Result<reqwest::Response> {
92    let policy = retry::current_policy();
93    let (response, _retries) = retry::send_with_retry(provider, req, &policy).await?;
94    Ok(response)
95}
96
97pub fn extract_upstream_message(body: &str) -> String {
98    serde_json::from_str::<serde_json::Value>(body)
99        .ok()
100        .and_then(|v| v["error"]["message"].as_str().map(ToOwned::to_owned))
101        .unwrap_or_else(|| body.chars().take(500).collect())
102}
103
104#[derive(Debug)]
105pub struct OutboundCtx<'a> {
106    pub route: &'a GatewayRoute,
107    pub endpoint: &'a str,
108    pub api_key: &'a str,
109    pub api_key_is_bearer: bool,
110    pub request: &'a CanonicalRequest,
111    pub upstream_model: &'a str,
112    pub model_limits: Option<ModelLimits>,
113    pub forward_headers: &'a [(String, String)],
114    pub raw_body: Option<&'a bytes::Bytes>,
115}
116
117#[expect(
118    missing_debug_implementations,
119    reason = "variants hold streaming bodies that intentionally do not implement Debug"
120)]
121pub enum OutboundOutcome {
122    Buffered(Box<CanonicalResponse>),
123    Streaming(BoxStream<'static, Result<CanonicalEvent, String>>),
124    RawBuffered {
125        body: bytes::Bytes,
126        content_type: Option<String>,
127        canonical: Box<CanonicalResponse>,
128    },
129    RawStreaming {
130        content_type: Option<String>,
131        stream: BoxStream<'static, Result<bytes::Bytes, String>>,
132    },
133}
134
135/// The exact bytes an adapter will put on the wire.
136///
137/// `raw_lane` records that the bytes started as the caller's own; they are
138/// still normalised in place, so they are not byte-identical to what arrived.
139#[derive(Debug, Clone)]
140pub struct PreparedBody {
141    pub bytes: bytes::Bytes,
142    pub raw_lane: bool,
143}
144
145#[async_trait]
146pub trait OutboundAdapter: Send + Sync {
147    fn build_body(&self, ctx: &OutboundCtx<'_>) -> Result<PreparedBody>;
148
149    async fn send(&self, ctx: OutboundCtx<'_>, body: &PreparedBody) -> Result<OutboundOutcome>;
150}
151
152#[derive(Debug, Clone, Copy)]
153pub struct OutboundAdapterRegistration {
154    pub tag: &'static str,
155    pub factory: fn() -> Arc<dyn OutboundAdapter>,
156}
157
158inventory::collect!(OutboundAdapterRegistration);
159
160// Why: an upstream that answers 2xx with a body carrying no turn has failed,
161// and the failure is the provider's rather than the caller's, so it is
162// reported the same way a genuine 502 from that provider would be.
163const DEFECTIVE_BODY_STATUS: u16 = 502;
164
165// Why: a buffered parser is total and will happily turn `{}` into a
166// well-formed canonical response with empty content and zero usage, which the
167// gateway then relays as a successful turn in which the model said nothing.
168// Rejecting here converts that into the upstream failure it always was, and
169// the raw body reaches both the log and the audit row so the cause is visible.
170pub(in crate::services::gateway) fn reject_defective_body(
171    provider: &str,
172    wire: &str,
173    defect: &systemprompt_models::wire::defect::BodyDefect,
174    body: &bytes::Bytes,
175) -> anyhow::Error {
176    let excerpt: String = String::from_utf8_lossy(body).chars().take(512).collect();
177    tracing::warn!(
178        provider = %provider,
179        wire = %wire,
180        defect = %defect,
181        body = %excerpt,
182        "upstream returned a success status with a body carrying no turn"
183    );
184    anyhow::Error::new(UpstreamError::Status {
185        provider: provider.to_owned(),
186        status: DEFECTIVE_BODY_STATUS,
187        message: format!("{defect}: {excerpt}"),
188        body: body.clone(),
189        retry_after: None,
190        request_id: None,
191    })
192}
193
194// Why: a body that fails to deserialize used to default to an empty canonical
195// response, so the request billed nothing and was audited as completed with no
196// content. It is an upstream contract breach and reaches the client as one.
197pub(in crate::services::gateway) fn reject_unparsable_body(
198    provider: &str,
199    wire: &str,
200    error: &systemprompt_models::wire::error::WireParseError,
201    body: &bytes::Bytes,
202) -> anyhow::Error {
203    let excerpt: String = String::from_utf8_lossy(body).chars().take(512).collect();
204    tracing::error!(
205        provider = %provider,
206        wire = %wire,
207        error = %error,
208        body = %excerpt,
209        "upstream returned a success status with a body that does not parse"
210    );
211    anyhow::Error::new(UpstreamError::Status {
212        provider: provider.to_owned(),
213        status: DEFECTIVE_BODY_STATUS,
214        message: format!("{error}: {excerpt}"),
215        body: body.clone(),
216        retry_after: None,
217        request_id: None,
218    })
219}