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//! Gemini, `OpenAI` Chat Completions, and `OpenAI` Responses. Where a request
9//! goes and how it authenticates is the resolved [`UpstreamCall`] — the same
10//! seam the in-process AI service sends through — so an adapter renders its
11//! wire and never decides a URL, an auth header or a hosting variant itself.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16pub mod anthropic;
17pub mod gemini;
18pub mod openai_chat;
19pub mod openai_responses;
20pub mod retry;
21
22use std::sync::Arc;
23
24use anyhow::Result;
25use async_trait::async_trait;
26use futures_util::stream::BoxStream;
27use systemprompt_ai::UpstreamCall;
28use systemprompt_models::services::GatewayRoute;
29use systemprompt_models::services::ai::ModelLimits;
30use thiserror::Error;
31
32use super::canonical::CanonicalRequest;
33use super::canonical_response::{CanonicalEvent, CanonicalResponse};
34
35/// Upstream provider failure, carried inside the `anyhow::Error` an adapter
36/// returns so the route layer can recover the real HTTP status by downcast
37/// instead of flattening every failure to 502.
38#[derive(Debug, Error)]
39pub enum UpstreamError {
40    #[error("{provider} returned {status}: {message}")]
41    Status {
42        provider: String,
43        status: u16,
44        message: String,
45        body: bytes::Bytes,
46        retry_after: Option<String>,
47        request_id: Option<String>,
48    },
49    #[error("{provider} request failed: {source}")]
50    Transport {
51        provider: String,
52        #[source]
53        source: reqwest::Error,
54    },
55}
56
57impl UpstreamError {
58    pub async fn from_response(provider: &str, response: reqwest::Response) -> Self {
59        let status = response.status().as_u16();
60        let header = |name: &str| {
61            response
62                .headers()
63                .get(name)
64                .and_then(|v| v.to_str().ok())
65                .map(ToOwned::to_owned)
66        };
67        let retry_after = header("retry-after");
68        let request_id = header("request-id").or_else(|| header("x-request-id"));
69        let body = response
70            .bytes()
71            .await
72            .unwrap_or_else(|e| bytes::Bytes::from(format!("<unreadable body: {e}>")));
73        Self::Status {
74            provider: provider.to_owned(),
75            status,
76            message: extract_upstream_message(&String::from_utf8_lossy(&body)),
77            body,
78            retry_after,
79            request_id,
80        }
81    }
82}
83
84pub(in crate::services::gateway) fn http_client() -> &'static reqwest::Client {
85    static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
86    CLIENT.get_or_init(reqwest::Client::new)
87}
88
89pub(in crate::services::gateway) async fn send_checked(
90    provider: &str,
91    req: reqwest::RequestBuilder,
92) -> Result<reqwest::Response> {
93    let policy = retry::current_policy();
94    let (response, _retries) = retry::send_with_retry(provider, req, &policy).await?;
95    Ok(response)
96}
97
98pub fn extract_upstream_message(body: &str) -> String {
99    serde_json::from_str::<serde_json::Value>(body)
100        .ok()
101        .and_then(|v| v["error"]["message"].as_str().map(ToOwned::to_owned))
102        .unwrap_or_else(|| body.chars().take(500).collect())
103}
104
105#[derive(Debug)]
106pub struct OutboundCtx<'a> {
107    pub route: &'a GatewayRoute,
108    pub upstream: &'a UpstreamCall,
109    pub request: &'a CanonicalRequest,
110    pub upstream_model: &'a str,
111    pub model_limits: Option<ModelLimits>,
112    pub automatic_prompt_caching: bool,
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/// Adapters are looked up by wire name as `Arc<dyn OutboundAdapter>`;
146/// `#[async_trait]` keeps the trait object-safe.
147#[async_trait]
148pub trait OutboundAdapter: Send + Sync {
149    fn build_body(&self, ctx: &OutboundCtx<'_>) -> Result<PreparedBody>;
150
151    async fn send(&self, ctx: OutboundCtx<'_>, body: &PreparedBody) -> Result<OutboundOutcome>;
152}
153
154#[derive(Debug, Clone, Copy)]
155pub struct OutboundAdapterRegistration {
156    pub tag: &'static str,
157    pub factory: fn() -> Arc<dyn OutboundAdapter>,
158}
159
160inventory::collect!(OutboundAdapterRegistration);
161
162const DEFECTIVE_BODY_STATUS: u16 = 502;
163
164pub(in crate::services::gateway) fn reject_defective_body(
165    provider: &str,
166    wire: &str,
167    defect: &systemprompt_models::wire::defect::BodyDefect,
168    body: &bytes::Bytes,
169) -> anyhow::Error {
170    let excerpt: String = String::from_utf8_lossy(body).chars().take(512).collect();
171    tracing::warn!(
172        provider = %provider,
173        wire = %wire,
174        defect = %defect,
175        body = %excerpt,
176        "upstream returned a success status with a body carrying no turn"
177    );
178    anyhow::Error::new(UpstreamError::Status {
179        provider: provider.to_owned(),
180        status: DEFECTIVE_BODY_STATUS,
181        message: format!("{defect}: {excerpt}"),
182        body: body.clone(),
183        retry_after: None,
184        request_id: None,
185    })
186}
187
188pub(in crate::services::gateway) fn reject_unparsable_body(
189    provider: &str,
190    wire: &str,
191    error: &systemprompt_models::wire::error::WireParseError,
192    body: &bytes::Bytes,
193) -> anyhow::Error {
194    let excerpt: String = String::from_utf8_lossy(body).chars().take(512).collect();
195    tracing::error!(
196        provider = %provider,
197        wire = %wire,
198        error = %error,
199        body = %excerpt,
200        "upstream returned a success status with a body that does not parse"
201    );
202    anyhow::Error::new(UpstreamError::Status {
203        provider: provider.to_owned(),
204        status: DEFECTIVE_BODY_STATUS,
205        message: format!("{error}: {excerpt}"),
206        body: body.clone(),
207        retry_after: None,
208        request_id: None,
209    })
210}