From 8c2131db48bafaf4422d21b09cd37dfc150bb13c Mon Sep 17 00:00:00 2001 From: smkrv <17809065+smkrv@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:27:11 +0300 Subject: [PATCH] feat(embeddings): add EMBEDDING_BASE_URL for OpenAI-compatible embedding providers (#1082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(embeddings): add EMBEDDING_BASE_URL for OpenAI-compatible embedding providers Allow configuring a custom base URL for OpenAI-compatible embedding endpoints (e.g., Azure OpenAI, local proxies, vLLM) via the EMBEDDING_BASE_URL environment variable. When unset, defaults to https://api.openai.com. Changes: - Extract hardcoded OpenAI URL to OPENAI_API_BASE_URL constant - Add base_url field to OpenAiEmbeddings with builder method with_base_url() - Auto-prepend https:// for schemeless URLs, strip trailing slashes - Add openai_base_url field to EmbeddingsConfig, parsed from EMBEDDING_BASE_URL - Wire base URL through create_provider() with debug logging - Add EMBEDDING_BASE_URL to clear_embedding_env() in tests - Add unit tests for URL validation and env var parsing * refactor: address Gemini review — in-place trailing slash strip, simplify config logic - Use while/pop() instead of trim_end_matches().to_string() for zero extra allocation when stripping trailing slashes in with_base_url() - Remove double openai_base_url check in create_provider() — create provider first, then branch on base_url for logging + configuration --------- Co-authored-by: SMKRV --- src/config/embeddings.rs | 70 +++++++++++++++++++++++++++++++++---- src/workspace/embeddings.rs | 68 ++++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/src/config/embeddings.rs b/src/config/embeddings.rs index c5a84c00..a1c3ecd7 100644 --- a/src/config/embeddings.rs +++ b/src/config/embeddings.rs @@ -23,6 +23,9 @@ pub struct EmbeddingsConfig { pub ollama_base_url: String, /// Embedding vector dimension. Inferred from the model name when not set explicitly. pub dimension: usize, + /// Custom base URL for OpenAI-compatible embedding providers. + /// When set, overrides the default `https://api.openai.com`. + pub openai_base_url: Option, } impl Default for EmbeddingsConfig { @@ -36,6 +39,7 @@ impl Default for EmbeddingsConfig { model, ollama_base_url: "http://localhost:11434".to_string(), dimension, + openai_base_url: None, } } } @@ -74,6 +78,8 @@ impl EmbeddingsConfig { let enabled = parse_bool_env("EMBEDDING_ENABLED", settings.embeddings.enabled)?; + let openai_base_url = optional_env("EMBEDDING_BASE_URL")?; + Ok(Self { enabled, provider, @@ -81,6 +87,7 @@ impl EmbeddingsConfig { model, ollama_base_url, dimension, + openai_base_url, }) } @@ -130,16 +137,27 @@ impl EmbeddingsConfig { } _ => { if let Some(api_key) = self.openai_api_key() { - tracing::debug!( - "Embeddings enabled via OpenAI (model: {}, dim: {})", - self.model, - self.dimension, - ); - Some(Arc::new(crate::workspace::OpenAiEmbeddings::with_model( + let mut provider = crate::workspace::OpenAiEmbeddings::with_model( api_key, &self.model, self.dimension, - ))) + ); + if let Some(ref base_url) = self.openai_base_url { + tracing::debug!( + "Embeddings enabled via OpenAI (model: {}, base_url: {}, dim: {})", + self.model, + base_url, + self.dimension, + ); + provider = provider.with_base_url(base_url); + } else { + tracing::debug!( + "Embeddings enabled via OpenAI (model: {}, dim: {})", + self.model, + self.dimension, + ); + } + Some(Arc::new(provider)) } else { tracing::warn!("Embeddings configured but OPENAI_API_KEY not set"); None @@ -164,6 +182,7 @@ mod tests { std::env::remove_var("EMBEDDING_PROVIDER"); std::env::remove_var("EMBEDDING_MODEL"); std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("EMBEDDING_BASE_URL"); } } @@ -247,4 +266,41 @@ mod tests { std::env::remove_var("EMBEDDING_ENABLED"); } } + + #[test] + fn embedding_base_url_parsed_from_env() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_embedding_env(); + + // SAFETY: Under ENV_MUTEX, no concurrent env access. + unsafe { + std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com"); + } + + let settings = Settings::default(); + let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed"); + assert_eq!( + config.openai_base_url.as_deref(), + Some("https://custom.example.com"), + "EMBEDDING_BASE_URL env var should be parsed into openai_base_url" + ); + + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("EMBEDDING_BASE_URL"); + } + } + + #[test] + fn embedding_base_url_defaults_to_none() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_embedding_env(); + + let settings = Settings::default(); + let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed"); + assert!( + config.openai_base_url.is_none(), + "openai_base_url should be None when EMBEDDING_BASE_URL is not set" + ); + } } diff --git a/src/workspace/embeddings.rs b/src/workspace/embeddings.rs index 42340fcb..e40337eb 100644 --- a/src/workspace/embeddings.rs +++ b/src/workspace/embeddings.rs @@ -60,12 +60,18 @@ pub trait EmbeddingProvider: Send + Sync { } } +/// 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 { @@ -78,6 +84,7 @@ impl OpenAiEmbeddings { api_key: api_key.into(), model: "text-embedding-3-small".to_string(), dimension: 1536, + base_url: OPENAI_API_BASE_URL.to_string(), } } @@ -88,6 +95,7 @@ impl OpenAiEmbeddings { api_key: api_key.into(), model: "text-embedding-ada-002".to_string(), dimension: 1536, + base_url: OPENAI_API_BASE_URL.to_string(), } } @@ -98,6 +106,7 @@ impl OpenAiEmbeddings { api_key: api_key.into(), model: "text-embedding-3-large".to_string(), dimension: 3072, + base_url: OPENAI_API_BASE_URL.to_string(), } } @@ -112,8 +121,35 @@ impl OpenAiEmbeddings { 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)] @@ -173,9 +209,11 @@ impl EmbeddingProvider for OpenAiEmbeddings { input: texts, }; + let url = format!("{}/v1/embeddings", self.base_url); + let response = self .client - .post("https://api.openai.com/v1/embeddings") + .post(&url) .header("Authorization", format!("Bearer {}", self.api_key)) .json(&request) .send() @@ -575,9 +613,37 @@ mod tests { 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"); } }