mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user