use crate::error::{ModelError, ModelResult};
use crate::traits::Embedder;
pub struct OpenAiEmbedder {
api_key: String,
base_url: String,
model: String,
dim: usize,
client: reqwest::Client,
}
impl OpenAiEmbedder {
pub fn new(api_key: String) -> Self {
Self::new_with_base_url(
api_key,
"https://api.openai.com/v1",
"text-embedding-3-small",
1536,
)
.expect("new() is given an explicit key, so it won't trigger an Auth error")
}
pub fn with_model(api_key: String, model: &str, dim: usize) -> Self {
Self::new_with_base_url(api_key, "https://api.openai.com/v1", model, dim)
.expect("with_model() is given an explicit key, so it won't trigger an Auth error")
}
pub fn new_with_base_url(
api_key: String,
base_url: &str,
model: &str,
dim: usize,
) -> ModelResult<Self> {
let api_key = if api_key.is_empty() {
std::env::var("OPENAI_API_KEY").unwrap_or_default()
} else {
api_key
};
if api_key.is_empty() {
return Err(ModelError::Auth(model.to_string()));
}
Ok(Self {
api_key,
base_url: base_url.to_string(),
model: model.to_string(),
dim,
client: reqwest::Client::new(),
})
}
pub fn embed_sync(&self, texts: &[String]) -> ModelResult<Vec<Vec<f32>>> {
<Self as Embedder>::embed_sync(self, texts)
}
}
#[async_trait::async_trait]
impl Embedder for OpenAiEmbedder {
fn dim(&self) -> usize {
self.dim
}
async fn embed(&self, texts: &[String]) -> ModelResult<Vec<Vec<f32>>> {
let body = serde_json::json!({
"model": self.model,
"input": texts,
});
let url = format!("{}/embeddings", self.base_url);
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&body)
.send()
.await
.map_err(|e| ModelError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
return match status {
401 | 403 => Err(ModelError::Auth(self.model.clone())),
429 => Err(ModelError::RateLimited),
_ => Err(ModelError::Unavailable(format!("HTTP {status}"))),
};
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| ModelError::Parse(e.to_string()))?;
let embeddings: Vec<Vec<f32>> = data["data"]
.as_array()
.ok_or_else(|| ModelError::Parse("missing data array".into()))?
.iter()
.map(|v| {
v["embedding"]
.as_array()
.ok_or_else(|| ModelError::Parse("missing embedding".into()))
.map(|arr| {
arr.iter()
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
.collect()
})
})
.collect::<Result<_, _>>()?;
if embeddings.len() != texts.len() {
return Err(ModelError::Parse(format!(
"expected {} embeddings, got {}",
texts.len(),
embeddings.len()
)));
}
Ok(embeddings)
}
fn embed_sync(&self, texts: &[String]) -> ModelResult<Vec<Vec<f32>>> {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(self.embed(texts))
}
fn backend_id(&self) -> &str {
&self.model
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openai_embedder_implements_trait() {
let e = OpenAiEmbedder::new("sk-test".into());
assert_eq!(e.dim(), 1536);
assert_eq!(e.backend_id(), "text-embedding-3-small");
}
#[test]
fn embed_without_network_returns_error() {
let e = OpenAiEmbedder::new("sk-test".into());
let result = e.embed_sync(&["hello".into()]);
assert!(result.is_err());
}
}