use super::provider::EmbedderProvider;
use crate::error::{ConproxyError, Result};
use async_trait::async_trait;
use parking_lot::RwLock;
use serde::Deserialize;
use std::time::Duration;
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
pub struct OpenAiEmbedder {
api_key: String,
model: String,
base_url: String,
client: reqwest::Client,
dimensions: RwLock<usize>,
}
impl OpenAiEmbedder {
pub fn new(
api_key: String,
model: String,
base_url: Option<String>,
timeout: Duration,
) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| ConproxyError::Embedding(format!("failed to build HTTP client: {e}")))?;
Ok(Self {
api_key,
model,
base_url: base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
client,
dimensions: RwLock::new(0),
})
}
fn endpoint(&self) -> String {
format!("{}/embeddings", self.base_url)
}
}
#[derive(Deserialize)]
struct OpenAiResponse {
data: Vec<OpenAiEmbedding>,
}
#[derive(Deserialize)]
struct OpenAiEmbedding {
index: usize,
embedding: Vec<f32>,
}
#[async_trait]
impl EmbedderProvider for OpenAiEmbedder {
async fn embed(&self, text: &str) -> Result<Vec<f32>> {
let resp = self
.client
.post(self.endpoint())
.bearer_auth(&self.api_key)
.json(&serde_json::json!({
"model": self.model,
"input": text,
}))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ConproxyError::Embedding(format!(
"OpenAI API error {status}: {body}"
)));
}
let parsed: OpenAiResponse = resp.json().await?;
let embedding = parsed
.data
.into_iter()
.next()
.ok_or_else(|| ConproxyError::Embedding("OpenAI returned no embeddings".into()))?
.embedding;
let dim = embedding.len();
let mut guard = self.dimensions.write();
if *guard == 0 {
*guard = dim;
}
Ok(embedding)
}
async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let inputs: Vec<&str> = texts.to_vec();
let resp = self
.client
.post(self.endpoint())
.bearer_auth(&self.api_key)
.json(&serde_json::json!({
"model": self.model,
"input": inputs,
}))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ConproxyError::Embedding(format!(
"OpenAI API error {status}: {body}"
)));
}
let parsed: OpenAiResponse = resp.json().await?;
let mut data = parsed.data;
data.sort_by_key(|d| d.index);
let embeddings: Vec<Vec<f32>> = data.into_iter().map(|d| d.embedding).collect();
if embeddings.len() != texts.len() {
return Err(ConproxyError::Embedding(format!(
"OpenAI returned {} embeddings, expected {}",
embeddings.len(),
texts.len()
)));
}
let dim = embeddings.first().map(|v| v.len()).unwrap_or(0);
let mut guard = self.dimensions.write();
if *guard == 0 {
*guard = dim;
}
Ok(embeddings)
}
fn dimensions(&self) -> usize {
*self.dimensions.read()
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::arithmetic_side_effects
)]
mod tests {
use super::*;
#[test]
fn test_new_with_default_base_url() {
let embedder = OpenAiEmbedder::new(
"test-key".to_string(),
"text-embedding-3-small".to_string(),
None,
Duration::from_secs(5),
)
.expect("construct");
assert_eq!(embedder.endpoint(), "https://api.openai.com/v1/embeddings");
}
#[test]
fn test_new_with_custom_base_url() {
let embedder = OpenAiEmbedder::new(
"test-key".to_string(),
"text-embedding-3-small".to_string(),
Some("https://custom.example.com/openai/v1".to_string()),
Duration::from_secs(5),
)
.expect("construct");
assert_eq!(
embedder.endpoint(),
"https://custom.example.com/openai/v1/embeddings"
);
}
#[test]
fn test_dimensions_starts_at_zero() {
let embedder = OpenAiEmbedder::new(
"test-key".to_string(),
"text-embedding-3-small".to_string(),
None,
Duration::from_secs(5),
)
.expect("construct");
assert_eq!(embedder.dimensions(), 0);
}
#[test]
fn test_dimensions_remains_zero_without_embed_call() {
let embedder = OpenAiEmbedder::new(
"test-key".to_string(),
"text-embedding-3-small".to_string(),
None,
Duration::from_secs(5),
)
.expect("construct");
assert_eq!(embedder.dimensions(), 0);
}
#[tokio::test]
async fn test_embed_http_error_propagates() {
let embedder = OpenAiEmbedder::new(
"test-key".to_string(),
"text-embedding-3-small".to_string(),
Some("http://127.0.0.1:1".to_string()),
Duration::from_millis(500),
)
.expect("construct");
let result = embedder.embed("hello").await;
assert!(result.is_err(), "expected error for unreachable host");
}
#[tokio::test]
async fn test_embed_mock_success_and_non_2xx() {
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::post;
use axum::{Json, Router};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::net::TcpListener;
#[derive(Clone)]
struct MockState {
hits: Arc<AtomicUsize>,
fail_once: Arc<AtomicUsize>,
}
async fn embeddings(
State(st): State<MockState>,
body: axum::body::Bytes,
) -> std::result::Result<Json<serde_json::Value>, StatusCode> {
st.hits.fetch_add(1, Ordering::Relaxed);
let _ = body;
if st.fail_once.fetch_add(1, Ordering::Relaxed) == 0 {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Json(serde_json::json!({
"data": [{
"embedding": [0.1, 0.2, 0.3],
"index": 0,
"object": "embedding"
}],
"model": "text-embedding-3-small",
"object": "list"
})))
}
let st = MockState {
hits: Arc::new(AtomicUsize::new(0)),
fail_once: Arc::new(AtomicUsize::new(0)),
};
let app = Router::new()
.route("/v1/embeddings", post(embeddings))
.with_state(st.clone());
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve");
});
let base = format!("http://{addr}/v1");
let embedder = OpenAiEmbedder::new(
"test-key".to_string(),
"text-embedding-3-small".to_string(),
Some(base),
Duration::from_secs(5),
)
.expect("construct");
let err = embedder.embed("hello").await;
assert!(err.is_err(), "first call should surface non-2xx");
let vec = embedder.embed("hello").await.expect("second call ok");
assert_eq!(vec, vec![0.1, 0.2, 0.3]);
assert_eq!(embedder.dimensions(), 3);
assert!(st.hits.load(Ordering::Relaxed) >= 2);
}
}