providers-kit 0.0.2

Portable enum-dispatch Kit over multiple model provider APIs.
Documentation
use std::sync::Arc;

use reqwest::Method;
use url::Url;

use super::wire::GeminiErrorEnvelope;
use crate::KitError;
use crate::error::ValidationViolation;
use crate::error::{ErrorPhase, ResponseViolation};
use crate::transport::HttpResponse;

use crate::config::GeminiConfig;
use crate::config::TransportConfig;
use crate::transport::{AuthMode, AuthScheme, CheckedRequestSpec, ProviderHttpContext};

#[derive(Clone)]
pub(crate) struct GeminiApiContext {
    config: GeminiConfig,
    transport: ProviderHttpContext,
}

pub(super) const GEMINI_REQUESTS: CheckedRequestSpec =
    CheckedRequestSpec::new(GeminiErrorEnvelope::parse);

impl GeminiApiContext {
    pub(crate) fn shared(
        config: GeminiConfig,
        transport: TransportConfig,
    ) -> Result<Arc<Self>, KitError> {
        let transport = ProviderHttpContext::new(
            config.base_url().clone(),
            AuthScheme::Header {
                name: "x-goog-api-key".to_string(),
                value: config.api_key().clone(),
                prefix: None,
            },
            transport,
        )?;

        Ok(Arc::new(Self { config, transport }))
    }

    pub(crate) fn config(&self) -> &GeminiConfig {
        &self.config
    }

    pub(crate) fn transport(&self) -> &ProviderHttpContext {
        &self.transport
    }

    pub(crate) fn exact_model_id(
        &self,
        target: &'static str,
        model: &str,
    ) -> Result<String, KitError> {
        if model.strip_prefix("models/").is_some_and(|suffix| {
            !suffix.is_empty()
                && suffix
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
        }) {
            Ok(model.to_string())
        } else {
            Err(KitError::invalid_input(
                ValidationViolation::InvalidFormat {
                    field: "model_id",
                    rule: "exact models/ resource identifier",
                },
                format!(
                    "{target} must use exact Gemini model ids like `models/{{model}}`; got `{model}`"
                ),
            ))
        }
    }

    pub(crate) fn request(
        &self,
        method: Method,
        path: &str,
    ) -> Result<reqwest::RequestBuilder, KitError> {
        self.transport.request(method, path)
    }

    pub(crate) fn request_absolute(
        &self,
        method: Method,
        url: Url,
        authenticated: bool,
    ) -> reqwest::RequestBuilder {
        let auth = if authenticated {
            AuthMode::Apply
        } else {
            AuthMode::Skip
        };
        self.transport.request_absolute(method, url, auth)
    }

    pub(crate) fn absolute_url(&self, path: &str) -> Result<Url, KitError> {
        self.transport.url(path)
    }

    pub(crate) fn absolute_url_with_page(
        &self,
        path: &str,
        limit: Option<usize>,
        cursor: Option<&str>,
    ) -> Result<Url, KitError> {
        self.transport.url_with_query(path, |url| {
            let mut pairs = url.query_pairs_mut();
            if let Some(limit) = limit {
                pairs.append_pair("pageSize", &limit.to_string());
            }
            if let Some(cursor) = cursor {
                pairs.append_pair("pageToken", cursor);
            }
        })
    }

    pub(crate) async fn start_resumable_upload(
        &self,
        file_name: &str,
        media_type: &str,
        content_length: usize,
    ) -> Result<Url, KitError> {
        let url = self.absolute_url("upload/v1beta/files")?;
        let body = serde_json::json!({
            "file": {
                "display_name": file_name
            }
        });
        let response = self
            .request_absolute(Method::POST, url.clone(), true)
            .header("X-Goog-Upload-Protocol", "resumable")
            .header("X-Goog-Upload-Command", "start")
            .header(
                "X-Goog-Upload-Header-Content-Length",
                content_length.to_string(),
            )
            .header("X-Goog-Upload-Header-Content-Type", media_type)
            .header("Content-Type", "application/json")
            .json(&body);
        let response = GEMINI_REQUESTS
            .response(url.as_str(), response, self.transport.execution())
            .await?;
        HttpResponse::from_response(response).normalize(|response| {
            let upload_url = response
                .headers()
                .get("x-goog-upload-url")
                .and_then(|value| value.to_str().ok())
                .ok_or_else(|| {
                    KitError::invalid_response(ResponseViolation::MissingField {
                        field: "x-goog-upload-url",
                    })
                    .with_message(
                        "Gemini resumable upload response did not include x-goog-upload-url"
                            .to_string(),
                    )
                    .with_diagnostic(None)
                    .at_phase(ErrorPhase::ResponseHeaders)
                })?;
            let upload_url = Url::parse(upload_url).map_err(|_| {
                KitError::invalid_response(ResponseViolation::InvalidField {
                    field: "x-goog-upload-url",
                })
                .with_message("Gemini upload URL was invalid".to_string())
                .with_diagnostic(None)
                .at_phase(ErrorPhase::ResponseHeaders)
            })?;
            if !matches!(upload_url.scheme(), "http" | "https")
                || upload_url.host_str().is_none()
                || !upload_url.username().is_empty()
                || upload_url.password().is_some()
            {
                return Err(KitError::invalid_response(ResponseViolation::InvalidField {
                    field: "x-goog-upload-url",
                })
                .with_message(
                    "Gemini upload URL must be HTTP(S) with a host and no userinfo".to_string(),
                )
                .with_diagnostic(None)
                .at_phase(ErrorPhase::ResponseHeaders));
            }
            Ok(upload_url)
        })
    }

    pub(crate) async fn upload_bytes<T>(
        &self,
        upload_url: Url,
        data: &[u8],
    ) -> Result<HttpResponse<T>, KitError>
    where
        T: serde::de::DeserializeOwned,
    {
        let upload_path = upload_url.to_string();
        GEMINI_REQUESTS
            .json(
                &upload_path,
                self.request_absolute(Method::POST, upload_url, false)
                    .header("X-Goog-Upload-Offset", "0")
                    .header("X-Goog-Upload-Command", "upload, finalize")
                    .header("Content-Length", data.len().to_string())
                    .body(data.to_vec()),
                self.transport.execution(),
            )
            .await
    }
}