use crate::cost::usage::Usage;
use crate::embed::{EmbedRequest, Embedder, Embedding, EmbeddingCapabilities, Embeddings, Purpose};
use crate::error::{Error, Result};
use crate::model::{ModelId, Reach};
use crate::provider::Access;
use crate::secret::Secret;
use crate::transport::{HttpRequest, HttpTransport};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::sync::Arc;
pub const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
pub struct GeminiEmbeddings {
base_url: String,
transport: Arc<dyn HttpTransport>,
key: Secret,
known: BTreeMap<String, EmbeddingCapabilities>,
}
pub fn with(transport: Arc<dyn HttpTransport>, key: Secret) -> GeminiEmbeddings {
GeminiEmbeddings {
base_url: DEFAULT_BASE_URL.to_string(),
transport,
key,
known: BTreeMap::new(),
}
}
#[cfg(feature = "reqwest")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
pub fn from_env(timeout: std::time::Duration) -> Result<GeminiEmbeddings> {
Ok(with(
Arc::new(crate::transport::Reqwest::new(timeout)?),
Secret::from_env("gemini-api-key", "GEMINI_API_KEY")?,
))
}
fn task_type(purpose: Purpose) -> &'static str {
match purpose {
Purpose::Document => "RETRIEVAL_DOCUMENT",
Purpose::Query => "RETRIEVAL_QUERY",
}
}
impl GeminiEmbeddings {
#[must_use]
pub fn knowing(
mut self,
model: impl Into<String>,
capabilities: EmbeddingCapabilities,
) -> Self {
self.known.insert(model.into(), capabilities);
self
}
pub fn reach(&self) -> Reach {
Reach::FirstPartyApi
}
#[must_use]
pub fn at(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_string();
self
}
fn body(&self, request: &EmbedRequest) -> Value {
let qualified = format!("models/{}", request.model.as_str());
let requests: Vec<Value> = request
.inputs
.iter()
.map(|text| {
let mut one = json!({
"model": qualified,
"content": { "parts": [{ "text": text }] },
});
if let Some(purpose) = request.purpose {
one["taskType"] = json!(task_type(purpose));
}
if let Some(dimensions) = request.dimensions {
one["outputDimensionality"] = json!(dimensions);
}
one
})
.collect();
json!({ "requests": requests })
}
fn read(&self, body: &Value, request: &EmbedRequest) -> Result<Embeddings> {
let rows = body
.get("embeddings")
.and_then(Value::as_array)
.ok_or_else(|| Error::Unreadable("the reply had no embeddings array".into()))?;
if rows.len() != request.inputs.len() {
return Err(Error::Unreadable(format!(
"{} inputs were sent and {} vectors came back. This API carries no index, so \
position is all there is and nothing can be lined up",
request.inputs.len(),
rows.len()
)));
}
let mut vectors = Vec::with_capacity(rows.len());
for (position, row) in rows.iter().enumerate() {
let numbers = row.get("values").and_then(Value::as_array).ok_or_else(|| {
Error::Unreadable(format!(
"the embedding at position {position} had no values"
))
})?;
let mut vector = Vec::with_capacity(numbers.len());
for number in numbers {
vector.push(number.as_f64().ok_or_else(|| {
Error::Unreadable(format!(
"the vector at position {position} held something that was not a number"
))
})? as f32);
}
vectors.push(Embedding::new(request.model.clone(), vector));
}
if let (Some(asked), Some(got)) = (
request.dimensions,
vectors.first().map(Embedding::dimensions),
) {
if usize::try_from(asked).is_ok_and(|asked| asked != got) {
return Err(Error::Unsupported(format!(
"gemini-embeddings: vectors came back with {got} dimensions after {asked} \
were asked for, so {} does not resize",
request.model.as_str()
)));
}
}
Ok(Embeddings::new(
vectors,
request.model.clone(),
Usage::absent(),
))
}
fn header(&self) -> Result<(String, String)> {
let key = self
.key
.expose_str()
.map_err(|_| Error::Auth("the API key is not valid UTF-8".into()))?;
Ok(("x-goog-api-key".to_string(), key.to_string()))
}
}
#[async_trait]
impl Embedder for GeminiEmbeddings {
fn id(&self) -> &str {
"gemini-embeddings"
}
fn capabilities(&self, model: &ModelId) -> Option<EmbeddingCapabilities> {
self.known.get(model.as_str()).copied()
}
async fn embed(&self, request: EmbedRequest) -> Result<Embeddings> {
if request.is_empty() {
return Err(Error::InvalidRequest(
"there is nothing to embed".to_string(),
));
}
if let (Some(asked), Some(known)) = (request.dimensions, self.capabilities(&request.model))
{
if !known.resizable {
return Err(Error::Unsupported(format!(
"{asked} dimensions were asked for and {} does not resize",
request.model.as_str()
)));
}
}
let (name, value) = self.header()?;
let http = HttpRequest::new(
format!(
"{}/models/{}:batchEmbedContents",
self.base_url,
request.model.as_str()
),
serde_json::to_vec(&self.body(&request))
.map_err(|e| Error::Unreadable(format!("the request would not serialise: {e}")))?,
)
.with_header(name, value)
.with_header("content-type", "application/json");
let response = self.transport.send(http).await?;
response.check()?;
let body: Value = serde_json::from_slice(&response.body)
.map_err(|e| Error::Unreadable(format!("the reply was not JSON: {e}")))?;
self.read(&body, &request)
}
async fn validate(&self, model: &ModelId) -> Access {
let (name, value) = match self.header() {
Ok(header) => header,
Err(e) => return Access::denied(format!("{}: {e}", self.id())),
};
let http = HttpRequest::get(format!("{}/models/{}", self.base_url, model.as_str()))
.with_header(name, value);
let response = match self.transport.send(http).await {
Ok(response) => response,
Err(e) => return doubt_or_refusal(self.id(), &e),
};
match response.check() {
Ok(()) => Access::Ready,
Err(e) => doubt_or_refusal(self.id(), &e),
}
}
}
fn doubt_or_refusal(id: &str, e: &Error) -> Access {
match e {
Error::Auth(reason) | Error::NotFound(reason) => Access::denied(format!("{id}: {reason}")),
other => Access::unknown(format!("{id}: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::HttpResponse;
use std::sync::Mutex;
struct Scripted {
replies: Mutex<Vec<Result<HttpResponse>>>,
sent: Mutex<Vec<(String, Vec<u8>)>>,
}
#[async_trait]
impl HttpTransport for Scripted {
async fn send(&self, request: HttpRequest) -> Result<HttpResponse> {
self.sent
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((request.url.clone(), request.body.clone()));
let mut replies = self.replies.lock().unwrap_or_else(|e| e.into_inner());
if replies.is_empty() {
return Err(Error::Transient("the script ran out".into()));
}
replies.remove(0)
}
}
fn scripted(replies: Vec<Result<HttpResponse>>) -> Arc<Scripted> {
Arc::new(Scripted {
replies: Mutex::new(replies),
sent: Mutex::new(Vec::new()),
})
}
fn embedder(transport: Arc<Scripted>) -> GeminiEmbeddings {
with(transport, Secret::new("gemini-api-key", "test-key"))
}
fn reply(json: Value) -> Result<HttpResponse> {
Ok(HttpResponse::new(200, json.to_string().into_bytes()))
}
fn vectors(rows: &[&[f32]]) -> Value {
json!({
"embeddings": rows.iter().map(|v| json!({ "values": v })).collect::<Vec<_>>()
})
}
#[tokio::test]
async fn a_purpose_reaches_the_wire_as_a_task_type() {
let transport = scripted(vec![
reply(vectors(&[&[1.0]])),
reply(vectors(&[&[1.0]])),
reply(vectors(&[&[1.0]])),
]);
let embedder = embedder(Arc::clone(&transport));
embedder
.embed(EmbedRequest::one("text-embedding-004", "a"))
.await
.unwrap();
embedder
.embed(EmbedRequest::one("text-embedding-004", "a").for_purpose(Purpose::Document))
.await
.unwrap();
embedder
.embed(EmbedRequest::one("text-embedding-004", "a").for_purpose(Purpose::Query))
.await
.unwrap();
let sent = transport.sent.lock().unwrap();
let read = |i: usize| -> Value { serde_json::from_slice(&sent[i].1).unwrap() };
assert!(
read(0)["requests"][0].get("taskType").is_none(),
"nothing said, nothing sent"
);
assert_eq!(read(1)["requests"][0]["taskType"], "RETRIEVAL_DOCUMENT");
assert_eq!(read(2)["requests"][0]["taskType"], "RETRIEVAL_QUERY");
}
#[tokio::test]
async fn the_model_is_written_in_the_path_and_in_every_element() {
let transport = scripted(vec![reply(vectors(&[&[1.0], &[2.0]]))]);
embedder(Arc::clone(&transport))
.embed(EmbedRequest::new(
"text-embedding-004".into(),
vec!["a".into(), "b".into()],
))
.await
.unwrap();
let sent = transport.sent.lock().unwrap();
assert!(
sent[0]
.0
.ends_with("/models/text-embedding-004:batchEmbedContents"),
"{}",
sent[0].0
);
let body: Value = serde_json::from_slice(&sent[0].1).unwrap();
assert_eq!(body["requests"][0]["model"], "models/text-embedding-004");
assert_eq!(body["requests"][1]["model"], "models/text-embedding-004");
assert_eq!(body["requests"][1]["content"]["parts"][0]["text"], "b");
}
#[tokio::test]
async fn a_count_that_does_not_match_is_unrecoverable_here() {
let transport = scripted(vec![reply(vectors(&[&[1.0]]))]);
match embedder(transport)
.embed(EmbedRequest::new(
"text-embedding-004".into(),
vec!["a".into(), "b".into()],
))
.await
{
Err(Error::Unreadable(message)) => {
assert!(message.contains("no index"), "{message}");
}
other => panic!("a short batch has to fail: {other:?}"),
}
}
#[tokio::test]
async fn every_call_reports_usage_as_absent_rather_than_nought() {
let transport = scripted(vec![reply(vectors(&[&[1.0, 2.0]]))]);
let answer = embedder(transport)
.embed(EmbedRequest::one("text-embedding-004", "a"))
.await
.unwrap();
assert_eq!(answer.usage, Usage::absent());
assert_eq!(answer.usage.coverage(), crate::UsageCoverage::Absent);
}
#[tokio::test]
async fn a_ledger_holding_one_of_these_says_at_least() {
let transport = scripted(vec![reply(vectors(&[&[1.0]]))]);
let answer = embedder(transport)
.embed(EmbedRequest::one("text-embedding-004", "a"))
.await
.unwrap();
let mut ledger = crate::Ledger::new();
ledger.record_unpriced(answer.model.clone(), answer.usage);
let total = ledger
.total()
.unwrap_or_else(|| unreachable!("one currency"));
assert!(!total.is_exact(), "{total}");
assert_eq!(ledger.calls(), 1, "the call still happened");
}
#[tokio::test]
async fn dimensions_are_asked_for_and_checked_on_the_way_back() {
let transport = scripted(vec![reply(vectors(&[&[1.0, 2.0, 3.0]]))]);
match embedder(Arc::clone(&transport))
.embed(EmbedRequest::one("text-embedding-004", "a").with_dimensions(2))
.await
{
Err(Error::Unsupported(message)) => {
assert!(message.contains("does not resize"), "{message}");
}
other => panic!("silently wrong width is the failure: {other:?}"),
}
let sent = transport.sent.lock().unwrap();
let body: Value = serde_json::from_slice(&sent[0].1).unwrap();
assert_eq!(body["requests"][0]["outputDimensionality"], 2);
}
#[tokio::test]
async fn every_vector_carries_a_model_even_though_the_reply_names_none() {
let transport = scripted(vec![reply(vectors(&[&[1.0], &[2.0]]))]);
let answer = embedder(transport)
.embed(EmbedRequest::new(
"text-embedding-004".into(),
vec!["a".into(), "b".into()],
))
.await
.unwrap();
for vector in &answer.vectors {
assert_eq!(vector.model.as_str(), "text-embedding-004");
}
assert_eq!(answer.dimensions(), Some(1));
}
#[tokio::test]
async fn a_rejected_credential_is_denied_rather_than_unknown() {
let transport = scripted(vec![Ok(HttpResponse::new(401, b"no".to_vec()))]);
let answer = embedder(transport)
.validate(&"text-embedding-004".into())
.await;
assert!(answer.is_denied(), "{answer}");
}
#[tokio::test]
async fn a_model_that_is_not_there_is_denied_and_a_bad_moment_is_not() {
let missing = scripted(vec![Ok(HttpResponse::new(404, b"nope".to_vec()))]);
assert!(embedder(missing)
.validate(&"not-a-model".into())
.await
.is_denied());
let flaky = scripted(vec![Err(Error::Transient("the network went away".into()))]);
assert!(embedder(flaky)
.validate(&"text-embedding-004".into())
.await
.is_unknown());
}
#[tokio::test]
async fn an_empty_batch_never_reaches_the_wire() {
let transport = scripted(vec![]);
let answer = embedder(Arc::clone(&transport))
.embed(EmbedRequest::new("text-embedding-004".into(), vec![]))
.await;
assert!(
matches!(answer, Err(Error::InvalidRequest(_))),
"{answer:?}"
);
assert!(transport.sent.lock().unwrap().is_empty());
}
}