Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions bkmr/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ impl Default for ShellOpts {
}
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct EmbeddingsOpts {
/// Base URL for the embeddings API endpoint
#[serde(default = "default_embeddings_api_base")]
pub api_base: String,

/// Model name to use for embeddings
#[serde(default = "default_embeddings_model")]
pub model: String,
}

fn default_embeddings_api_base() -> String {
"https://api.openai.com/v1".to_string()
}

fn default_embeddings_model() -> String {
"text-embedding-3-small".to_string()
}

impl Default for EmbeddingsOpts {
fn default() -> Self {
Self {
api_base: default_embeddings_api_base(),
model: default_embeddings_model(),
}
}
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Settings {
/// Path to the SQLite database file
Expand All @@ -94,6 +122,10 @@ pub struct Settings {
#[serde(default)]
pub base_paths: HashMap<String, String>,

/// Options for embeddings configuration
#[serde(default)]
pub embeddings_opts: EmbeddingsOpts,

/// Tracks configuration source (not serialized)
#[serde(skip)]
pub config_source: ConfigSource,
Expand Down Expand Up @@ -143,6 +175,7 @@ impl Default for Settings {
fzf_opts: FzfOpts::default(),
shell_opts: ShellOpts::default(),
base_paths: HashMap::new(),
embeddings_opts: EmbeddingsOpts::default(),
config_source: ConfigSource::Default,
}
}
Expand Down Expand Up @@ -293,6 +326,22 @@ fn apply_env_overrides(settings: &mut Settings) {
used_env_vars = true;
}

// Embeddings configuration from environment variables
// Support both OPENAI_API_BASE and legacy OPENAI_API_URL for backward compatibility
if let Ok(api_base) = std::env::var("OPENAI_API_BASE")
.or_else(|_| std::env::var("OPENAI_API_URL"))
{
trace!("Using OPENAI_API_BASE/URL from environment: {}", api_base);
settings.embeddings_opts.api_base = api_base;
used_env_vars = true;
}

if let Ok(model) = std::env::var("OPENAI_MODEL") {
trace!("Using OPENAI_MODEL from environment: {}", model);
settings.embeddings_opts.model = model;
used_env_vars = true;
}

// If we've used environment variables and were using defaults before,
// update the source
if used_env_vars && settings.config_source == ConfigSource::Default {
Expand Down Expand Up @@ -561,6 +610,7 @@ mod tests {
},
shell_opts: ShellOpts { interactive: true },
base_paths: HashMap::new(),
embeddings_opts: EmbeddingsOpts::default(),
config_source: ConfigSource::ConfigFile,
};

Expand Down
23 changes: 22 additions & 1 deletion bkmr/src/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,25 @@ interactive = true
# SCRIPTS_HOME = "$HOME/scripts"
# DOCS_HOME = "$HOME/documents"
# WORK_SCRIPTS = "/work/automation/scripts"
# PROJECT_NOTES = "$HOME/projects/notes"
# PROJECT_NOTES = "$HOME/projects/notes"

# Embeddings configuration for semantic search
# Supports any OpenAI-compatible embeddings provider
# Environment variables (OPENAI_API_BASE, OPENAI_MODEL) override these settings
[embeddings_opts]
# Base URL for the embeddings API endpoint
# Default: "https://api.openai.com/v1" (OpenAI)
# Examples:
# - Ollama (local): "http://localhost:11434/v1"
# - HuggingFace: "https://api-inference.huggingface.co/v1"
# - Voyage AI: "https://api.voyageai.com/v1"
api_base = "https://api.openai.com/v1"

# Model name to use for embeddings
# Default: "text-embedding-3-small" (OpenAI's latest efficient model)
# Examples:
# - OpenAI: "text-embedding-3-small", "text-embedding-3-large"
# - Ollama: "nomic-embed-text", "mxbai-embed-large", "all-minilm"
# - HuggingFace: "sentence-transformers/all-MiniLM-L6-v2"
# - Voyage AI: "voyage-2"
model = "text-embedding-3-small"
10 changes: 7 additions & 3 deletions bkmr/src/infrastructure/di/service_container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ impl ServiceContainer {
pub fn new(config: &Settings, openai: bool) -> ApplicationResult<Self> {
// Base infrastructure
let bookmark_repository = Self::create_repository(&config.db_url)?;
let embedder = Self::create_embedder(openai)?;
let embedder = Self::create_embedder(openai, config)?;
let clipboard_service = Arc::new(ClipboardServiceImpl::new());
let interpolation_service = Self::create_interpolation_service();
let template_service = Self::create_template_service();
Expand Down Expand Up @@ -100,9 +100,13 @@ impl ServiceContainer {
Ok(Arc::new(repository))
}

fn create_embedder(openai: bool) -> ApplicationResult<Arc<dyn Embedder>> {
fn create_embedder(openai: bool, config: &Settings) -> ApplicationResult<Arc<dyn Embedder>> {
if openai {
Ok(Arc::new(OpenAiEmbedding::default()))
// Use configuration from Settings (which includes config file + env overrides)
Ok(Arc::new(OpenAiEmbedding::from_config(
&config.embeddings_opts.api_base,
&config.embeddings_opts.model,
)))
} else {
Ok(Arc::new(DummyEmbedding))
}
Expand Down
151 changes: 131 additions & 20 deletions bkmr/src/infrastructure/embeddings/openai_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,44 @@ use std::any::Any;
use std::env;
use tracing::{debug, instrument};

/// Implementation using OpenAI's embedding API
/// Implementation using OpenAI-compatible embedding API
///
/// Supports any OpenAI-compatible embeddings provider (OpenAI, Ollama, HuggingFace, Voyage AI, etc.)
///
/// ## Configuration
///
/// Environment variables (for backward compatibility, all use OPENAI_* prefix):
/// - `OPENAI_API_KEY`: API key for authentication (required for most providers)
/// - `OPENAI_API_BASE`: Base URL for the API endpoint (optional, defaults to "https://api.openai.com/v1")
/// - `OPENAI_MODEL`: Model name to use for embeddings (optional, defaults to "text-embedding-3-small")
///
/// ## Supported Providers
///
/// This implementation supports any provider that follows the OpenAI embeddings API specification:
/// - **OpenAI**: Use defaults (official OpenAI API)
/// - **Ollama**: Set OPENAI_API_BASE="http://localhost:11434" and OPENAI_MODEL="nomic-embed-text"
/// - **HuggingFace**: Set OPENAI_API_BASE to HF endpoint and appropriate model
/// - **Voyage AI**: Set OPENAI_API_BASE and OPENAI_MODEL accordingly (uses X-Api-Key header)
/// - **Custom**: Any OpenAI-compatible endpoint
///
/// ## Authentication
///
/// Auth headers are automatically detected based on the URL:
/// - Voyage AI (api.voyageai.com): Uses `X-Api-Key` header
/// - Localhost/Ollama: No authentication required
/// - All others: Uses `Authorization: Bearer` header
///
/// ## Non-Compatible Providers
///
/// **Note**: This implementation is specifically designed for OpenAI-compatible REST APIs.
/// Providers that use different API patterns (gRPC, different request/response formats,
/// OAuth flows, AWS SigV4, etc.) would require a different architecture with provider-specific
/// adapters. Examples of non-compatible providers:
/// - Cohere (different API format)
/// - Anthropic (different API structure)
/// - Providers requiring OAuth or AWS SigV4
/// - Streaming-only APIs
/// - gRPC-based services
#[derive(Debug, Clone)]
pub struct OpenAiEmbedding {
url: String,
Expand All @@ -14,23 +51,97 @@ pub struct OpenAiEmbedding {

impl Default for OpenAiEmbedding {
fn default() -> Self {
Self {
url: "https://api.openai.com".to_string(),
model: "text-embedding-ada-002".to_string(),
Self::from_env()
}
}

impl OpenAiEmbedding {
/// Create a new OpenAI-compatible embedder with explicit configuration
pub fn new(url: String, model: String) -> Self {
Self { url, model }
}

/// Create embedder from configuration
///
/// Configuration is read from Settings which loads from:
/// 1. Config file (~/.config/bkmr/config.toml)
/// 2. Environment variables (OPENAI_API_BASE, OPENAI_MODEL) - these override config file
/// 3. Defaults if neither is set
///
/// This ensures backward compatibility while supporting production-ready config files.
pub fn from_config(api_base: &str, model: &str) -> Self {
debug!("OpenAI embedder configured with URL: {}, Model: {}", api_base, model);
Self {
url: api_base.to_string(),
model: model.to_string()
}
}

/// Create embedder from environment variables (legacy method for backward compatibility)
///
/// Reads configuration from:
/// - OPENAI_API_BASE (defaults to "https://api.openai.com/v1")
/// - OPENAI_MODEL (defaults to "text-embedding-3-small")
///
/// Note: Also checks legacy OPENAI_API_URL for backward compatibility
///
/// **Deprecated**: Use `from_config` with Settings instead for production deployments.
/// This method is kept for backward compatibility with existing code that doesn't use Settings.
pub fn from_env() -> Self {
// Check OPENAI_API_BASE first, then fall back to legacy OPENAI_API_URL
let url = env::var("OPENAI_API_BASE")
.or_else(|_| env::var("OPENAI_API_URL"))
.unwrap_or_else(|_| "https://api.openai.com/v1".to_string());

let model = env::var("OPENAI_MODEL")
.unwrap_or_else(|_| "text-embedding-3-small".to_string());

debug!("OpenAI embedder configured with URL: {}, Model: {}", url, model);

Self { url, model }
}

/// Determine the appropriate authentication header based on the URL
///
/// - Voyage AI (api.voyageai.com): X-Api-Key
/// - Localhost/127.0.0.1: No auth
/// - All others: Authorization: Bearer
fn get_auth_header(&self, api_key: &str) -> Option<(&'static str, String)> {
let url_lower = self.url.to_lowercase();

// No auth for localhost/Ollama
if url_lower.contains("localhost") || url_lower.contains("127.0.0.1") {
debug!("No authentication required for localhost");
return None;
}

// Voyage AI uses X-Api-Key
if url_lower.contains("api.voyageai.com") {
debug!("Using X-Api-Key authentication for Voyage AI");
return Some(("X-Api-Key", api_key.to_string()));
}

// Default: Bearer token
debug!("Using Bearer token authentication");
Some(("Authorization", format!("Bearer {}", api_key)))
}
}

impl Embedder for OpenAiEmbedding {
#[instrument]
fn embed(&self, text: &str) -> DomainResult<Option<Vec<f32>>> {
debug!("OpenAI embedding request for text length: {}", text.len());

let api_key = env::var("OPENAI_API_KEY").map_err(|_| {
DomainError::CannotFetchMetadata(
"OPENAI_API_KEY environment variable not set".to_string(),
)
})?;
let api_key = env::var("OPENAI_API_KEY").unwrap_or_default();

// Validate API key if authentication is required
if self.get_auth_header(&api_key).is_some() {
if api_key.is_empty() {
return Err(DomainError::CannotFetchMetadata(
"OPENAI_API_KEY environment variable not set".to_string(),
));
}
}

let client = reqwest::blocking::Client::new();

Expand All @@ -39,11 +150,17 @@ impl Embedder for OpenAiEmbedding {
model: self.model.clone(),
};

let response = client
.post(format!("{}/v1/embeddings", self.url))
.header("Authorization", format!("Bearer {}", api_key))
.json(&request)
.send()
// Build request with appropriate auth header
let mut request_builder = client
.post(format!("{}/embeddings", self.url))
.json(&request);

// Add auth header if required
if let Some((header_name, header_value)) = self.get_auth_header(&api_key) {
request_builder = request_builder.header(header_name, header_value);
}

let response = request_builder.send()
.map_err(|e| {
DomainError::CannotFetchMetadata(format!("OpenAI API request failed: {}", e))
})?;
Expand Down Expand Up @@ -75,12 +192,6 @@ impl Embedder for OpenAiEmbedding {
}
}

impl OpenAiEmbedding {
pub fn new(url: String, model: String) -> Self {
Self { url, model }
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading