Files
optimclaw/src/workspace/embeddings.rs
T
8920322589 fix: staging CI triage — consolidate retry parsing, fix flaky tests, add docs (#1427)
* fix: consolidate retry-after parsing and fix flaky OAuth env tests (#1288, #1280)

- Extract shared `parse_retry_after()` into `src/llm/retry.rs` supporting
  both delay-seconds and RFC2822 formats, replacing duplicated inline parsing
  in anthropic_oauth.rs, nearai_chat.rs, and embeddings.rs
- Fix flaky `bind_rejects_wildcard_*` tests in oauth_helpers.rs by adding
  `tokio::sync::Mutex` to serialize env var access (matching the ENV_MUTEX
  pattern in oauth_defaults.rs)
- Add regression tests for parse_retry_after edge cases

Closes #1288, #1280

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add comments explaining CLI_ENABLED=false in service templates (#990)

Clarify that CLI_ENABLED=false is needed in daemon mode (launchd/systemd)
to prevent blocking on stdin when running as a background service.

Closes #990

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review comments on retry-after consolidation

- Change parse_retry_after() return type from Option<Duration> to Duration
  (it never returns None due to the 60s fallback)
- Fix doc comment: reference RFC 7231 §7.1.1 for HTTP-date, not RFC 2822
- Add parse_retry_after_http_date test for the RFC 2822 date parsing branch
- Remove stale per-file test helpers (parse_retry_after_*_for_test) that
  duplicated old inline logic instead of testing the shared function
- Remove unnecessary comments above #[cfg(test)] imports
- Use crate-wide ENV_MUTEX instead of local tokio::sync::Mutex in
  oauth_helpers tests to prevent cross-module env-var races

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: reword await_holding_lock safety comment

Drop runtime-flavor assumption; justify by short-lived awaited operation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 18:33:15 -07:00

644 lines
19 KiB
Rust

//! Embedding providers for semantic search.
//!
//! Embeddings convert text into dense vectors that capture semantic meaning.
//! Similar concepts have similar vectors, enabling semantic search.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
/// Error type for embedding operations.
#[derive(Debug, thiserror::Error)]
pub enum EmbeddingError {
#[error("HTTP request failed: {0}")]
HttpError(String),
#[error("Invalid response: {0}")]
InvalidResponse(String),
#[error("Rate limited, retry after {retry_after:?}")]
RateLimited {
retry_after: Option<std::time::Duration>,
},
#[error("Authentication failed")]
AuthFailed,
#[error("Text too long: {length} > {max}")]
TextTooLong { length: usize, max: usize },
}
impl From<reqwest::Error> for EmbeddingError {
fn from(e: reqwest::Error) -> Self {
EmbeddingError::HttpError(e.to_string())
}
}
/// Trait for embedding providers.
#[async_trait]
pub trait EmbeddingProvider: Send + Sync {
/// Get the embedding dimension.
fn dimension(&self) -> usize;
/// Get the model name.
fn model_name(&self) -> &str;
/// Maximum input length in characters.
fn max_input_length(&self) -> usize;
/// Generate an embedding for a single text.
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError>;
/// Generate embeddings for multiple texts (batched).
///
/// Default implementation calls embed() for each text.
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let mut embeddings = Vec::with_capacity(texts.len());
for text in texts {
embeddings.push(self.embed(text).await?);
}
Ok(embeddings)
}
}
/// Default base URL for the OpenAI API.
const OPENAI_API_BASE_URL: &str = "https://api.openai.com";
/// OpenAI embedding provider using text-embedding-ada-002 or text-embedding-3-small.
///
/// Supports any OpenAI-compatible embedding endpoint via [`with_base_url`](Self::with_base_url).
pub struct OpenAiEmbeddings {
client: reqwest::Client,
api_key: String,
model: String,
dimension: usize,
base_url: String,
}
impl OpenAiEmbeddings {
/// Create a new OpenAI embedding provider with the default model.
///
/// Uses text-embedding-3-small which has 1536 dimensions.
pub fn new(api_key: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
model: "text-embedding-3-small".to_string(),
dimension: 1536,
base_url: OPENAI_API_BASE_URL.to_string(),
}
}
/// Use text-embedding-ada-002 model.
pub fn ada_002(api_key: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
model: "text-embedding-ada-002".to_string(),
dimension: 1536,
base_url: OPENAI_API_BASE_URL.to_string(),
}
}
/// Use text-embedding-3-large model.
pub fn large(api_key: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
model: "text-embedding-3-large".to_string(),
dimension: 3072,
base_url: OPENAI_API_BASE_URL.to_string(),
}
}
/// Use a custom model with specified dimension.
pub fn with_model(
api_key: impl Into<String>,
model: impl Into<String>,
dimension: usize,
) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
dimension,
base_url: OPENAI_API_BASE_URL.to_string(),
}
}
/// Set a custom base URL for OpenAI-compatible embedding providers.
///
/// The URL must use `http://` or `https://` scheme. If no scheme is present,
/// `https://` is prepended automatically. Trailing slashes are stripped.
pub fn with_base_url(mut self, base_url: &str) -> Self {
let url = base_url.trim();
// Auto-prepend https:// if no scheme is present.
let mut url = if !url.starts_with("http://") && !url.starts_with("https://") {
tracing::debug!(
"No scheme in embedding base URL '{}', prepending https://",
url
);
format!("https://{url}")
} else {
url.to_string()
};
while url.ends_with('/') {
url.pop();
}
self.base_url = url;
self
}
}
#[derive(Debug, Serialize)]
struct OpenAiEmbeddingRequest<'a> {
model: &'a str,
input: &'a [String],
}
#[derive(Debug, Deserialize)]
struct OpenAiEmbeddingResponse {
data: Vec<OpenAiEmbeddingData>,
}
#[derive(Debug, Deserialize)]
struct OpenAiEmbeddingData {
embedding: Vec<f32>,
}
#[async_trait]
impl EmbeddingProvider for OpenAiEmbeddings {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.model
}
fn max_input_length(&self) -> usize {
// text-embedding-3-small/large: 8191 tokens (~32k chars)
// text-embedding-ada-002: 8191 tokens
32_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
if text.len() > self.max_input_length() {
return Err(EmbeddingError::TextTooLong {
length: text.len(),
max: self.max_input_length(),
});
}
let embeddings = self.embed_batch(&[text.to_string()]).await?;
embeddings
.into_iter()
.next()
.ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string()))
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
if texts.is_empty() {
return Ok(Vec::new());
}
let request = OpenAiEmbeddingRequest {
model: &self.model,
input: texts,
};
let url = format!("{}/v1/embeddings", self.base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&request)
.send()
.await?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(EmbeddingError::AuthFailed);
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
return Err(EmbeddingError::RateLimited { retry_after });
}
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(EmbeddingError::HttpError(format!(
"Status {}: {}",
status, error_text
)));
}
let result: OpenAiEmbeddingResponse = response.json().await.map_err(|e| {
EmbeddingError::InvalidResponse(format!("Failed to parse response: {}", e))
})?;
Ok(result.data.into_iter().map(|d| d.embedding).collect())
}
}
/// NEAR AI embedding provider using the NEAR AI API.
///
/// Uses the same session-based auth as the LLM provider.
pub struct NearAiEmbeddings {
client: reqwest::Client,
base_url: String,
session: std::sync::Arc<crate::llm::SessionManager>,
model: String,
dimension: usize,
}
impl NearAiEmbeddings {
/// Create a new NEAR AI embedding provider.
///
/// Uses the same session manager as the LLM provider for auth.
pub fn new(
base_url: impl Into<String>,
session: std::sync::Arc<crate::llm::SessionManager>,
) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.into(),
session,
model: "text-embedding-3-small".to_string(),
dimension: 1536,
}
}
/// Use a specific model.
pub fn with_model(mut self, model: impl Into<String>, dimension: usize) -> Self {
self.model = model.into();
self.dimension = dimension;
self
}
}
#[derive(Debug, Serialize)]
struct NearAiEmbeddingRequest<'a> {
model: &'a str,
input: &'a [String],
}
#[derive(Debug, Deserialize)]
struct NearAiEmbeddingResponse {
data: Vec<NearAiEmbeddingData>,
}
#[derive(Debug, Deserialize)]
struct NearAiEmbeddingData {
embedding: Vec<f32>,
}
#[async_trait]
impl EmbeddingProvider for NearAiEmbeddings {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.model
}
fn max_input_length(&self) -> usize {
32_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
if text.len() > self.max_input_length() {
return Err(EmbeddingError::TextTooLong {
length: text.len(),
max: self.max_input_length(),
});
}
let embeddings = self.embed_batch(&[text.to_string()]).await?;
embeddings
.into_iter()
.next()
.ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string()))
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
use secrecy::ExposeSecret;
if texts.is_empty() {
return Ok(Vec::new());
}
let request = NearAiEmbeddingRequest {
model: &self.model,
input: texts,
};
let token = self
.session
.get_token()
.await
.map_err(|_| EmbeddingError::AuthFailed)?;
let url = format!("{}/v1/embeddings", self.base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token.expose_secret()))
.json(&request)
.send()
.await?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(EmbeddingError::AuthFailed);
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
return Err(EmbeddingError::RateLimited { retry_after });
}
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(EmbeddingError::HttpError(format!(
"Status {}: {}",
status, error_text
)));
}
let result: NearAiEmbeddingResponse = response.json().await.map_err(|e| {
EmbeddingError::InvalidResponse(format!("Failed to parse response: {}", e))
})?;
Ok(result.data.into_iter().map(|d| d.embedding).collect())
}
}
/// Ollama embedding provider using a local Ollama instance.
///
/// Ollama serves embedding models (e.g. `nomic-embed-text`, `mxbai-embed-large`)
/// via a REST API, typically at `http://localhost:11434`.
pub struct OllamaEmbeddings {
client: reqwest::Client,
base_url: String,
model: String,
dimension: usize,
}
impl OllamaEmbeddings {
/// Create a new Ollama embedding provider.
///
/// Defaults to `nomic-embed-text` (768 dimensions).
pub fn new(base_url: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.into(),
model: "nomic-embed-text".to_string(),
dimension: 768,
}
}
/// Use a specific model with a given dimension.
pub fn with_model(mut self, model: impl Into<String>, dimension: usize) -> Self {
self.model = model.into();
self.dimension = dimension;
self
}
}
#[derive(Debug, Serialize)]
struct OllamaEmbedRequest<'a> {
model: &'a str,
input: &'a [String],
}
#[derive(Debug, Deserialize)]
struct OllamaEmbedResponse {
embeddings: Vec<Vec<f32>>,
}
#[async_trait]
impl EmbeddingProvider for OllamaEmbeddings {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.model
}
fn max_input_length(&self) -> usize {
// Most Ollama embedding models support 8192 tokens (~32k chars)
32_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
if text.len() > self.max_input_length() {
return Err(EmbeddingError::TextTooLong {
length: text.len(),
max: self.max_input_length(),
});
}
let embeddings = self.embed_batch(&[text.to_string()]).await?;
embeddings
.into_iter()
.next()
.ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string()))
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
if texts.is_empty() {
return Ok(Vec::new());
}
let request = OllamaEmbedRequest {
model: &self.model,
input: texts,
};
let url = format!("{}/api/embed", self.base_url);
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(EmbeddingError::HttpError(format!(
"Ollama returned HTTP {}: {}",
status, error_text
)));
}
let result: OllamaEmbedResponse = response.json().await.map_err(|e| {
EmbeddingError::InvalidResponse(format!("Failed to parse Ollama response: {}", e))
})?;
// Validate that returned embeddings match the configured dimension.
for (i, emb) in result.embeddings.iter().enumerate() {
if emb.len() != self.dimension {
return Err(EmbeddingError::InvalidResponse(format!(
"Ollama returned embedding of dimension {}, expected {} at index {}",
emb.len(),
self.dimension,
i
)));
}
}
Ok(result.embeddings)
}
}
/// A mock embedding provider for testing.
///
/// Generates deterministic embeddings based on text hash.
/// Useful for unit and integration tests.
pub struct MockEmbeddings {
dimension: usize,
}
impl MockEmbeddings {
/// Create a new mock embeddings provider with the given dimension.
pub fn new(dimension: usize) -> Self {
Self { dimension }
}
}
#[async_trait]
impl EmbeddingProvider for MockEmbeddings {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
"mock-embedding"
}
fn max_input_length(&self) -> usize {
10_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
// Generate a deterministic embedding based on text hash
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
let hash = hasher.finish();
let mut embedding = Vec::with_capacity(self.dimension);
let mut seed = hash;
for _ in 0..self.dimension {
// Simple LCG for deterministic random values
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let value = (seed as f32 / u64::MAX as f32) * 2.0 - 1.0;
embedding.push(value);
}
// Normalize to unit length
let magnitude: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
if magnitude > 0.0 {
for x in &mut embedding {
*x /= magnitude;
}
}
Ok(embedding)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mock_embeddings() {
let provider = MockEmbeddings::new(128);
let embedding = provider.embed("hello world").await.unwrap();
assert_eq!(embedding.len(), 128);
// Check normalization (should be unit vector)
let magnitude: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((magnitude - 1.0).abs() < 0.001);
}
#[tokio::test]
async fn test_mock_embeddings_deterministic() {
let provider = MockEmbeddings::new(64);
let emb1 = provider.embed("test").await.unwrap();
let emb2 = provider.embed("test").await.unwrap();
// Same input should produce same embedding
assert_eq!(emb1, emb2);
}
#[tokio::test]
async fn test_mock_embeddings_batch() {
let provider = MockEmbeddings::new(64);
let texts = vec!["hello".to_string(), "world".to_string()];
let embeddings = provider.embed_batch(&texts).await.unwrap();
assert_eq!(embeddings.len(), 2);
assert_eq!(embeddings[0].len(), 64);
assert_eq!(embeddings[1].len(), 64);
// Different texts should produce different embeddings
assert_ne!(embeddings[0], embeddings[1]);
}
#[test]
fn test_openai_embeddings_config() {
let provider = OpenAiEmbeddings::new("test-key");
assert_eq!(provider.dimension(), 1536);
assert_eq!(provider.model_name(), "text-embedding-3-small");
assert_eq!(provider.base_url, OPENAI_API_BASE_URL);
let provider = OpenAiEmbeddings::large("test-key");
assert_eq!(provider.dimension(), 3072);
assert_eq!(provider.model_name(), "text-embedding-3-large");
assert_eq!(provider.base_url, OPENAI_API_BASE_URL);
}
#[test]
fn test_openai_with_base_url_valid() {
let provider =
OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com");
assert_eq!(provider.base_url, "https://custom.example.com");
}
#[test]
fn test_openai_with_base_url_strips_trailing_slashes() {
let provider =
OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com///");
assert_eq!(provider.base_url, "https://custom.example.com");
}
#[test]
fn test_openai_with_base_url_http_scheme() {
let provider = OpenAiEmbeddings::new("test-key").with_base_url("http://localhost:8080");
assert_eq!(provider.base_url, "http://localhost:8080");
}
#[test]
fn test_openai_with_base_url_schemeless_prepends_https() {
let provider = OpenAiEmbeddings::new("test-key").with_base_url("custom.example.com/v1");
assert_eq!(provider.base_url, "https://custom.example.com/v1");
}
}