use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use crate::embedding::{EmbeddingProvider, EmbeddingVector};
pub fn default_dimensions(model: &str) -> Option<usize> {
match model {
"text-embedding-3-small" => Some(1536),
"text-embedding-3-large" => Some(3072),
"text-embedding-ada-002" => Some(1536),
_ => None,
}
}
#[derive(Debug)]
struct ApiClient {
inner: reqwest::Client,
}
impl ApiClient {
fn new() -> Result<Self> {
let inner = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(5))
.build()
.context("Failed to construct reqwest client for embedding API")?;
Ok(Self { inner })
}
}
#[derive(Debug, Serialize)]
struct EmbeddingRequest<'a> {
input: &'a str,
model: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
dimensions: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct EmbeddingResponse {
data: Vec<EmbeddingData>,
}
#[derive(Debug, Deserialize)]
struct EmbeddingData {
embedding: Vec<f32>,
}
pub struct ApiEmbeddingProvider {
endpoint: String,
api_key: String,
model: String,
dimensions: usize,
client: Mutex<Option<Arc<ApiClient>>>,
}
impl std::fmt::Debug for ApiEmbeddingProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiEmbeddingProvider")
.field("endpoint", &self.endpoint)
.field("model", &self.model)
.field("dimensions", &self.dimensions)
.finish_non_exhaustive()
}
}
impl ApiEmbeddingProvider {
pub fn new(
endpoint: String,
api_key: String,
model: String,
dimensions: Option<usize>,
) -> Result<Self> {
let dimensions = match dimensions {
Some(d) if d > 0 => d,
_ => default_dimensions(&model)
.ok_or_else(|| anyhow::anyhow!(
"Embedding model '{model}' has no known dimensionality; specify `dimensions` in [embedding] config"
))?,
};
Ok(Self {
endpoint,
api_key,
model,
dimensions,
client: Mutex::new(None),
})
}
fn http(&self) -> Result<Arc<ApiClient>> {
let mut guard = self.client.lock();
if let Some(c) = guard.as_ref() {
return Ok(Arc::clone(c));
}
let c = Arc::new(ApiClient::new()?);
*guard = Some(Arc::clone(&c));
Ok(c)
}
}
#[async_trait]
impl EmbeddingProvider for ApiEmbeddingProvider {
async fn embed(&self, text: &str) -> Result<EmbeddingVector> {
let client = self.http()?;
let req = EmbeddingRequest {
input: text,
model: &self.model,
dimensions: None, };
let resp = client
.inner
.post(&self.endpoint)
.bearer_auth(&self.api_key)
.json(&req)
.send()
.await
.context("Embedding API request failed")?;
let status = resp.status();
let body = resp
.text()
.await
.context("Failed to read embedding API response body")?;
if !status.is_success() {
anyhow::bail!(
"Embedding API returned HTTP {status}: {}",
truncate(&body, 200)
);
}
let parsed: EmbeddingResponse = serde_json::from_str(&body)
.context("Failed to parse embedding API response as JSON")?;
let vec = parsed
.data
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Embedding API returned no vectors in response"))?
.embedding;
if vec.len() != self.dimensions {
anyhow::bail!(
"Embedding dimension mismatch: configured {} got {}",
self.dimensions,
vec.len()
);
}
Ok(EmbeddingVector::DenseF32(vec))
}
fn name(&self) -> &str {
"api-embedding"
}
}
fn truncate(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_dimensions() {
assert_eq!(default_dimensions("text-embedding-3-small"), Some(1536));
assert_eq!(default_dimensions("text-embedding-3-large"), Some(3072));
assert_eq!(default_dimensions("unknown-model"), None);
}
#[test]
fn test_truncate_ascii() {
assert_eq!(truncate("hello world", 5), "hello...");
}
#[test]
fn test_truncate_korean_safe_boundary() {
let s = "가나다라마";
let _ = truncate(s, 7);
}
#[test]
fn test_constructor_requires_known_dim() {
let p = ApiEmbeddingProvider::new(
"https://example.com".into(),
"k".into(),
"text-embedding-3-small".into(),
None,
)
.unwrap();
assert_eq!(p.dimensions, 1536);
}
#[test]
fn test_constructor_explicit_dim() {
let p = ApiEmbeddingProvider::new(
"https://example.com".into(),
"k".into(),
"custom-model".into(),
Some(768),
)
.unwrap();
assert_eq!(p.dimensions, 768);
}
#[test]
fn test_constructor_unknown_model_no_dim_fails() {
let r = ApiEmbeddingProvider::new(
"https://example.com".into(),
"k".into(),
"custom-model".into(),
None,
);
assert!(r.is_err());
}
}