#![warn(missing_docs)]
#![warn(rustdoc::broken_intra_doc_links)]
use std::sync::Once;
use std::time::Duration;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
const OLLAMA_DEFAULT_BASE_URL: &str = "http://localhost:11434";
const VOYAGE_DEFAULT_BASE_URL: &str = "https://api.voyageai.com/v1";
const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
const GEMINI_DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
pub const VOYAGE_API_KEY_ENV: &str = "VOYAGE_API_KEY";
pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
pub const GEMINI_API_KEY_ENV: &str = "GEMINI_API_KEY";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Provider {
Ollama,
Voyage,
OpenAi,
Gemini,
}
impl Provider {
fn label(self) -> &'static str {
match self {
Provider::Ollama => "ollama",
Provider::Voyage => "voyage",
Provider::OpenAi => "openai",
Provider::Gemini => "gemini",
}
}
fn default_base_url(self) -> &'static str {
match self {
Provider::Ollama => OLLAMA_DEFAULT_BASE_URL,
Provider::Voyage => VOYAGE_DEFAULT_BASE_URL,
Provider::OpenAi => OPENAI_DEFAULT_BASE_URL,
Provider::Gemini => GEMINI_DEFAULT_BASE_URL,
}
}
fn api_key_env(self) -> Option<&'static str> {
match self {
Provider::Ollama => None,
Provider::Voyage => Some(VOYAGE_API_KEY_ENV),
Provider::OpenAi => Some(OPENAI_API_KEY_ENV),
Provider::Gemini => Some(GEMINI_API_KEY_ENV),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EmbedKind {
Document,
Query,
}
impl EmbedKind {
fn as_str(self) -> &'static str {
match self {
EmbedKind::Document => "document",
EmbedKind::Query => "query",
}
}
fn gemini_task_type(self) -> &'static str {
match self {
EmbedKind::Document => "RETRIEVAL_DOCUMENT",
EmbedKind::Query => "RETRIEVAL_QUERY",
}
}
}
pub type TransportError = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("failed to build HTTP client: {0}")]
ClientBuild(#[source] TransportError),
#[error("{provider}: no API key (pass .api_key(..) or set {env})")]
#[non_exhaustive]
MissingApiKey {
provider: &'static str,
env: &'static str,
},
#[error("{provider} request failed: {source}")]
#[non_exhaustive]
Request {
provider: &'static str,
#[source]
source: TransportError,
},
#[error("{provider} returned HTTP {status}: {body}")]
#[non_exhaustive]
Api {
provider: &'static str,
status: u16,
body: String,
},
#[error("{provider} failed to decode response: {source}")]
#[non_exhaustive]
Decode {
provider: &'static str,
#[source]
source: TransportError,
},
#[error("{provider} returned {got} embeddings for {expected} inputs")]
#[non_exhaustive]
CountMismatch {
provider: &'static str,
got: usize,
expected: usize,
},
#[error("{provider} returned dimension {got} (expected {expected})")]
#[non_exhaustive]
DimMismatch {
provider: &'static str,
got: usize,
expected: usize,
},
}
fn install_ring() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
#[derive(Clone)]
pub struct ClientBuilder {
provider: Provider,
model: String,
base_url: Option<String>,
api_key: Option<String>,
output_dimension: Option<usize>,
timeout: Duration,
max_batch: Option<usize>,
}
fn redacted(key: &Option<String>) -> Option<&'static str> {
key.as_ref().map(|_| "<redacted>")
}
impl std::fmt::Debug for ClientBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientBuilder")
.field("provider", &self.provider)
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("api_key", &redacted(&self.api_key))
.field("output_dimension", &self.output_dimension)
.field("timeout", &self.timeout)
.field("max_batch", &self.max_batch)
.finish()
}
}
impl ClientBuilder {
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn output_dimension(mut self, dim: usize) -> Self {
self.output_dimension = Some(dim);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn max_batch(mut self, max_batch: usize) -> Self {
self.max_batch = Some(max_batch.max(1));
self
}
pub fn build(self) -> Result<Client, Error> {
install_ring();
let api_key = match self.provider.api_key_env() {
None => None,
Some(env) => {
let key = self
.api_key
.or_else(|| std::env::var(env).ok())
.filter(|k| !k.trim().is_empty());
match key {
Some(k) => Some(k),
None => {
return Err(Error::MissingApiKey {
provider: self.provider.label(),
env,
});
}
}
}
};
let http = reqwest::Client::builder()
.timeout(self.timeout)
.build()
.map_err(|e| Error::ClientBuild(Box::new(e)))?;
let base_url = self
.base_url
.map(|b| b.trim().trim_end_matches('/').to_string())
.filter(|b| !b.is_empty())
.unwrap_or_else(|| self.provider.default_base_url().to_string());
Ok(Client {
http,
provider: self.provider,
model: self.model,
base_url,
api_key,
output_dimension: self.output_dimension,
max_batch: self.max_batch,
})
}
}
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
provider: Provider,
model: String,
base_url: String,
api_key: Option<String>,
output_dimension: Option<usize>,
max_batch: Option<usize>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("provider", &self.provider)
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("api_key", &redacted(&self.api_key))
.field("output_dimension", &self.output_dimension)
.field("max_batch", &self.max_batch)
.finish_non_exhaustive()
}
}
impl Client {
pub fn builder(provider: Provider, model: impl Into<String>) -> ClientBuilder {
ClientBuilder {
provider,
model: model.into(),
base_url: None,
api_key: None,
output_dimension: None,
timeout: DEFAULT_TIMEOUT,
max_batch: None,
}
}
pub fn provider(&self) -> Provider {
self.provider
}
pub async fn embed(&self, texts: &[String], kind: EmbedKind) -> Result<Vec<Vec<f32>>, Error> {
if texts.is_empty() {
return Ok(Vec::new());
}
let batch = self.max_batch.unwrap_or(texts.len()).max(1);
let mut out = Vec::with_capacity(texts.len());
for chunk in texts.chunks(batch) {
let vectors = match self.provider {
Provider::Ollama => providers::ollama::embed(self, chunk).await?,
Provider::Voyage => providers::voyage::embed(self, chunk, kind).await?,
Provider::OpenAi => providers::openai::embed(self, chunk).await?,
Provider::Gemini => providers::gemini::embed(self, chunk, kind).await?,
};
out.extend(vectors);
}
self.validate(out, texts.len())
}
fn require_key(&self) -> &str {
self.api_key
.as_deref()
.expect("invariant: build() resolves an api_key for keyed providers")
}
fn validate(&self, vectors: Vec<Vec<f32>>, expected: usize) -> Result<Vec<Vec<f32>>, Error> {
let provider = self.provider.label();
if vectors.len() != expected {
return Err(Error::CountMismatch {
provider,
got: vectors.len(),
expected,
});
}
if let Some(dim) = self.output_dimension {
for v in &vectors {
if v.len() != dim {
return Err(Error::DimMismatch {
provider,
got: v.len(),
expected: dim,
});
}
}
}
Ok(vectors)
}
}
mod providers;
#[cfg(test)]
mod tests;