Skip to main content

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

1//! Outbound adapter targeting the Google Gemini generativeLanguage API.
2//!
3//! [`GeminiOutbound`] renders the canonical model to a Gemini `generateContent`
4//! request via [`systemprompt_models::wire::gemini`], sends it upstream, and
5//! returns either a buffered [`CanonicalResponse`] or a stream of canonical
6//! events translated from the Gemini `?alt=sse` byte stream. Auth rides the
7//! `x-goog-api-key` header.
8
9use anyhow::{Result, anyhow};
10use async_trait::async_trait;
11use serde_json::Value;
12use systemprompt_models::profile::WireProtocol;
13use systemprompt_models::wire::gemini;
14
15use super::super::canonical_response::CanonicalResponse;
16use super::{
17    OutboundAdapter, OutboundCtx, OutboundOutcome, UpstreamError, extract_upstream_message,
18};
19
20#[cfg(feature = "test-api")]
21pub mod test_api {
22    pub use systemprompt_models::wire::gemini::{
23        build_request_body, parse_response, sse_to_canonical_events,
24    };
25}
26
27#[derive(Debug, Clone, Copy, Default)]
28pub struct GeminiOutbound;
29
30#[async_trait]
31impl OutboundAdapter for GeminiOutbound {
32    fn provider_tag(&self) -> &'static str {
33        WireProtocol::Gemini.as_tag()
34    }
35
36    async fn send(&self, ctx: OutboundCtx<'_>) -> Result<OutboundOutcome> {
37        let body = gemini::build_request_body(
38            ctx.request,
39            ctx.model_limits.and_then(|l| l.max_thinking_budget),
40        );
41        let path = gemini::upstream_path(ctx.upstream_model, ctx.request.stream);
42        let url = format!("{}{path}", ctx.endpoint.trim_end_matches('/'));
43
44        let client = reqwest::Client::new();
45        let mut req = client
46            .post(&url)
47            .header(gemini::API_KEY_HEADER, ctx.api_key)
48            .header("content-type", "application/json")
49            .json(&body);
50        for (name, value) in &ctx.route.extra_headers {
51            req = req.header(name.as_str(), value.as_str());
52        }
53
54        let upstream_response = req.send().await.map_err(|e| {
55            anyhow::Error::new(UpstreamError::Transport {
56                provider: self.provider_tag(),
57                source: e,
58            })
59        })?;
60
61        let status = upstream_response.status();
62        if !status.is_success() {
63            let err = upstream_response
64                .text()
65                .await
66                .unwrap_or_else(|e| format!("<failed to read upstream body: {e}>"));
67            return Err(anyhow::Error::new(UpstreamError::Status {
68                provider: self.provider_tag(),
69                status: status.as_u16(),
70                message: extract_upstream_message(&err),
71            }));
72        }
73
74        if ctx.request.stream {
75            let stream = upstream_response.bytes_stream();
76            let event_stream = gemini::sse_to_canonical_events(stream, ctx.request.model.clone());
77            return Ok(OutboundOutcome::Streaming(event_stream));
78        }
79
80        let bytes = upstream_response
81            .bytes()
82            .await
83            .map_err(|e| anyhow!("Failed to read Gemini response: {e}"))?;
84        let value: Value = serde_json::from_slice(&bytes)
85            .map_err(|e| anyhow!("Gemini response not valid JSON: {e}"))?;
86        let canon: CanonicalResponse = gemini::parse_response(&value, ctx.request.model.as_str());
87        Ok(OutboundOutcome::Buffered(Box::new(canon)))
88    }
89}