systemprompt_api/services/gateway/protocol/outbound/
mod.rs1pub 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#[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#[derive(Debug, Clone)]
140pub struct PreparedBody {
141 pub bytes: bytes::Bytes,
142 pub raw_lane: bool,
143}
144
145#[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}