systemprompt_api/services/gateway/protocol/outbound/
mod.rs1pub 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#[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
77pub(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
84pub(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#[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
160const DEFECTIVE_BODY_STATUS: u16 = 502;
164
165pub(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
194pub(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}