feat(embeddings): add EMBEDDING_BASE_URL for OpenAI-compatible embedding providers (#1082)

* 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 <[email protected]>
This commit is contained in:
smkrv
2026-03-12 15:27:11 -07:00
committed by GitHub
co-authored by SMKRV
parent d7024f557f
commit 8c2131db48
2 changed files with 130 additions and 8 deletions
+63 -7
View File
@@ -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<String>,
}
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"
);
}
}
+67 -1
View File
@@ -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");
}
}