systemprompt-api 0.28.0

Axum-based HTTP server and API gateway for systemprompt.io AI governance infrastructure. Exposes governed agents, MCP, A2A, and admin endpoints with rate limiting and RBAC.
Documentation
//! Outbound adapter targeting the Google Gemini generativeLanguage API.
//!
//! [`GeminiOutbound`] renders the canonical model to a Gemini `generateContent`
//! request via [`systemprompt_models::wire::gemini`], sends it upstream, and
//! returns either a buffered [`CanonicalResponse`] or a stream of canonical
//! events translated from the Gemini `?alt=sse` byte stream. Auth rides the
//! `x-goog-api-key` header.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use anyhow::{Result, anyhow};
use async_trait::async_trait;
use serde_json::Value;
use systemprompt_models::wire::gemini;

use super::super::canonical_response::CanonicalResponse;
use super::{OutboundAdapter, OutboundCtx, OutboundOutcome, PreparedBody, UpstreamError};

#[cfg(feature = "test-api")]
pub mod test_api {
    pub use systemprompt_models::wire::gemini::{
        build_request_body, parse_response, sse_to_canonical_events,
    };
}

#[derive(Debug, Clone, Copy, Default)]
pub struct GeminiOutbound;

#[async_trait]
impl OutboundAdapter for GeminiOutbound {
    fn build_body(&self, ctx: &OutboundCtx<'_>) -> Result<PreparedBody> {
        Ok(PreparedBody {
            bytes: bytes::Bytes::from(
                serde_json::to_vec(&gemini::build_request_body(ctx.request, ctx.model_limits))
                    .map_err(|e| anyhow!("render request body: {e}"))?,
            ),
            raw_lane: false,
        })
    }

    async fn send(&self, ctx: OutboundCtx<'_>, body: &PreparedBody) -> Result<OutboundOutcome> {
        let path = gemini::upstream_path(ctx.upstream_model, ctx.request.stream);
        let url = format!("{}{path}", ctx.endpoint.trim_end_matches('/'));

        let client = reqwest::Client::new();
        let mut req = client
            .post(&url)
            .header(gemini::API_KEY_HEADER, ctx.api_key)
            .header("content-type", "application/json")
            .body(body.bytes.clone());
        for (name, value) in &ctx.route.extra_headers {
            req = req.header(name.as_str(), value.as_str());
        }

        let upstream_response = req.send().await.map_err(|e| {
            anyhow::Error::new(UpstreamError::Transport {
                provider: ctx.route.provider.as_str().to_owned(),
                source: e,
            })
        })?;

        if !upstream_response.status().is_success() {
            return Err(anyhow::Error::new(
                UpstreamError::from_response(ctx.route.provider.as_str(), upstream_response).await,
            ));
        }

        if ctx.request.stream {
            let stream = upstream_response.bytes_stream();
            let event_stream = gemini::sse_to_canonical_events(stream, ctx.request.model.clone());
            return Ok(OutboundOutcome::Streaming(event_stream));
        }

        let bytes = upstream_response
            .bytes()
            .await
            .map_err(|e| anyhow!("Failed to read Gemini response: {e}"))?;
        let value: Value = serde_json::from_slice(&bytes)
            .map_err(|e| anyhow!("Gemini response not valid JSON: {e}"))?;
        let canon: CanonicalResponse = gemini::parse_response(&value, ctx.request.model.as_str());
        Ok(OutboundOutcome::Buffered(Box::new(canon)))
    }
}