systemprompt_api/services/gateway/protocol/outbound/
mod.rs1pub mod anthropic;
14pub mod gemini;
15pub mod openai_chat;
16pub mod openai_responses;
17
18use std::sync::Arc;
19
20use anyhow::Result;
21use async_trait::async_trait;
22use futures_util::stream::BoxStream;
23use systemprompt_models::profile::GatewayRoute;
24use systemprompt_models::services::ai::ModelLimits;
25use thiserror::Error;
26
27use super::canonical::CanonicalRequest;
28use super::canonical_response::{CanonicalEvent, CanonicalResponse};
29
30#[derive(Debug, Error)]
34pub enum UpstreamError {
35 #[error("{provider} returned {status}: {message}")]
36 Status {
37 provider: String,
38 status: u16,
39 message: String,
40 body: bytes::Bytes,
48 retry_after: Option<String>,
50 request_id: Option<String>,
52 },
53 #[error("{provider} request failed: {source}")]
54 Transport {
55 provider: String,
56 #[source]
57 source: reqwest::Error,
58 },
59}
60
61impl UpstreamError {
62 pub async fn from_response(provider: &str, response: reqwest::Response) -> Self {
65 let status = response.status().as_u16();
66 let header = |name: &str| {
67 response
68 .headers()
69 .get(name)
70 .and_then(|v| v.to_str().ok())
71 .map(ToOwned::to_owned)
72 };
73 let retry_after = header("retry-after");
74 let request_id = header("request-id").or_else(|| header("x-request-id"));
75 let body = response.bytes().await.unwrap_or_default();
76 Self::Status {
77 provider: provider.to_owned(),
78 status,
79 message: extract_upstream_message(&String::from_utf8_lossy(&body)),
80 body,
81 retry_after,
82 request_id,
83 }
84 }
85}
86
87pub(in crate::services::gateway) fn http_client() -> &'static reqwest::Client {
90 static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
91 CLIENT.get_or_init(reqwest::Client::new)
92}
93
94pub(in crate::services::gateway) async fn send_checked(
95 provider: &str,
96 req: reqwest::RequestBuilder,
97) -> Result<reqwest::Response> {
98 let response = req.send().await.map_err(|e| {
99 anyhow::Error::new(UpstreamError::Transport {
100 provider: provider.to_owned(),
101 source: e,
102 })
103 })?;
104 if !response.status().is_success() {
105 return Err(anyhow::Error::new(
106 UpstreamError::from_response(provider, response).await,
107 ));
108 }
109 Ok(response)
110}
111
112pub fn extract_upstream_message(body: &str) -> String {
113 serde_json::from_str::<serde_json::Value>(body)
114 .ok()
115 .and_then(|v| v["error"]["message"].as_str().map(ToOwned::to_owned))
116 .unwrap_or_else(|| body.chars().take(500).collect())
117}
118
119#[derive(Debug)]
120pub struct OutboundCtx<'a> {
121 pub route: &'a GatewayRoute,
122 pub endpoint: &'a str,
123 pub api_key: &'a str,
124 pub request: &'a CanonicalRequest,
125 pub upstream_model: &'a str,
126 pub model_limits: Option<ModelLimits>,
127 pub forward_headers: &'a [(String, String)],
130 pub raw_body: Option<&'a bytes::Bytes>,
133}
134
135#[expect(
136 missing_debug_implementations,
137 reason = "variants hold streaming bodies that intentionally do not implement Debug"
138)]
139pub enum OutboundOutcome {
140 Buffered(Box<CanonicalResponse>),
141 Streaming(BoxStream<'static, Result<CanonicalEvent, String>>),
142 RawBuffered {
145 body: bytes::Bytes,
146 content_type: Option<String>,
147 canonical: Box<CanonicalResponse>,
148 },
149 RawStreaming {
153 content_type: Option<String>,
154 stream: BoxStream<'static, Result<bytes::Bytes, String>>,
155 },
156}
157
158#[derive(Debug, Clone)]
163pub struct PreparedBody {
164 pub bytes: bytes::Bytes,
165 pub raw_lane: bool,
166}
167
168#[async_trait]
171pub trait OutboundAdapter: Send + Sync {
172 fn build_body(&self, ctx: &OutboundCtx<'_>) -> Result<PreparedBody>;
176
177 async fn send(&self, ctx: OutboundCtx<'_>, body: &PreparedBody) -> Result<OutboundOutcome>;
178}
179
180#[derive(Debug, Clone, Copy)]
181pub struct OutboundAdapterRegistration {
182 pub tag: &'static str,
183 pub factory: fn() -> Arc<dyn OutboundAdapter>,
184}
185
186inventory::collect!(OutboundAdapterRegistration);