mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
* refactor: simplify config resolution and consolidate main.rs init into AppBuilder - Add parse_bool_env() and parse_string_env() helpers to eliminate repetitive 5-line optional_env/parse/map_err/unwrap_or boilerplate across 12 config files - Add EmbeddingsConfig::create_provider() to centralize embeddings construction (fixes hardcoded 1536 dimensions and missing Ollama provider in app.rs) - Extract init_cli_tracing(), setup_wasm_channels(), start_tunnel(), run_memory_command(), run_worker(), run_claude_bridge() from main.rs - Replace ~600 lines of inline init in main.rs with AppBuilder::build_all() - Expose catalog_entries from AppComponents for gateway registry entries - Net reduction: ~738 lines across 15 files Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: propagate dev_loaded_tool_names from AppBuilder and add parse_option_env helper Address PR review feedback: - Capture dev_loaded_tool_names from WASM loading in init_extensions() and expose via AppComponents so bootstrap_hooks receives the actual dev tool names instead of an empty slice (fixes silent hook skip) - Add parse_option_env<T>() helper for Option<T> config fields, simplifying max_cost_per_day_cents and max_actions_per_hour in agent.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: fetch real NEAR AI pricing and unify cost calculation path CostGuard was independently looking up pricing via costs::model_cost(), falling back to GPT-4o default rates when NEAR AI model names didn't match the static table — causing ~3x cost overestimates in logs. - Add pricing map to NearAiChatProvider that fetches real rates from /v1/model/list at startup (background, non-blocking) - Update cost_per_token() to check fetched pricing first, then static table, then default - Add cost_per_token parameter to CostGuard::record_llm_call() so the dispatcher passes provider-sourced rates directly Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: update default NEAR AI model to GLM-latest Replace fireworks llama4-maverick-instruct-basic with zai-org/GLM-latest as the default model in config and setup wizard. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: align wizard default model name with config Change "zai/GLM-latest" to "zai-org/GLM-latest" in wizard.rs to match the default in config/llm.rs. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
250 lines
8.0 KiB
Rust
250 lines
8.0 KiB
Rust
use std::sync::Arc;
|
|
|
|
use secrecy::{ExposeSecret, SecretString};
|
|
|
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
|
use crate::error::ConfigError;
|
|
use crate::llm::SessionManager;
|
|
use crate::settings::Settings;
|
|
use crate::workspace::EmbeddingProvider;
|
|
|
|
/// Embeddings provider configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct EmbeddingsConfig {
|
|
/// Whether embeddings are enabled.
|
|
pub enabled: bool,
|
|
/// Provider to use: "openai", "nearai", or "ollama"
|
|
pub provider: String,
|
|
/// OpenAI API key (for OpenAI provider).
|
|
pub openai_api_key: Option<SecretString>,
|
|
/// Model to use for embeddings.
|
|
pub model: String,
|
|
/// Ollama base URL (for Ollama provider). Defaults to http://localhost:11434.
|
|
pub ollama_base_url: String,
|
|
/// Embedding vector dimension. Inferred from the model name when not set explicitly.
|
|
pub dimension: usize,
|
|
}
|
|
|
|
impl Default for EmbeddingsConfig {
|
|
fn default() -> Self {
|
|
let model = "text-embedding-3-small".to_string();
|
|
let dimension = default_dimension_for_model(&model);
|
|
Self {
|
|
enabled: false,
|
|
provider: "openai".to_string(),
|
|
openai_api_key: None,
|
|
model,
|
|
ollama_base_url: "http://localhost:11434".to_string(),
|
|
dimension,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Infer the embedding dimension from a well-known model name.
|
|
///
|
|
/// Falls back to 1536 (OpenAI text-embedding-3-small default) for unknown models.
|
|
fn default_dimension_for_model(model: &str) -> usize {
|
|
match model {
|
|
"text-embedding-3-small" => 1536,
|
|
"text-embedding-3-large" => 3072,
|
|
"text-embedding-ada-002" => 1536,
|
|
"nomic-embed-text" => 768,
|
|
"mxbai-embed-large" => 1024,
|
|
"all-minilm" => 384,
|
|
_ => 1536,
|
|
}
|
|
}
|
|
|
|
impl EmbeddingsConfig {
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
|
|
|
|
let provider = optional_env("EMBEDDING_PROVIDER")?
|
|
.unwrap_or_else(|| settings.embeddings.provider.clone());
|
|
|
|
let model =
|
|
optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone());
|
|
|
|
let ollama_base_url = optional_env("OLLAMA_BASE_URL")?
|
|
.or_else(|| settings.ollama_base_url.clone())
|
|
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
|
|
|
let dimension =
|
|
parse_optional_env("EMBEDDING_DIMENSION", default_dimension_for_model(&model))?;
|
|
|
|
let enabled = parse_bool_env("EMBEDDING_ENABLED", settings.embeddings.enabled)?;
|
|
|
|
Ok(Self {
|
|
enabled,
|
|
provider,
|
|
openai_api_key,
|
|
model,
|
|
ollama_base_url,
|
|
dimension,
|
|
})
|
|
}
|
|
|
|
/// Get the OpenAI API key if configured.
|
|
pub fn openai_api_key(&self) -> Option<&str> {
|
|
self.openai_api_key.as_ref().map(|s| s.expose_secret())
|
|
}
|
|
|
|
/// Create the appropriate embedding provider based on configuration.
|
|
///
|
|
/// Returns `None` if embeddings are disabled or the required credentials
|
|
/// are missing. The `nearai_base_url` and `session` are needed only for
|
|
/// the NEAR AI provider but must be passed unconditionally.
|
|
pub fn create_provider(
|
|
&self,
|
|
nearai_base_url: &str,
|
|
session: Arc<SessionManager>,
|
|
) -> Option<Arc<dyn EmbeddingProvider>> {
|
|
if !self.enabled {
|
|
tracing::info!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
|
|
return None;
|
|
}
|
|
|
|
match self.provider.as_str() {
|
|
"nearai" => {
|
|
tracing::info!(
|
|
"Embeddings enabled via NEAR AI (model: {}, dim: {})",
|
|
self.model,
|
|
self.dimension,
|
|
);
|
|
Some(Arc::new(
|
|
crate::workspace::NearAiEmbeddings::new(nearai_base_url, session)
|
|
.with_model(&self.model, self.dimension),
|
|
))
|
|
}
|
|
"ollama" => {
|
|
tracing::info!(
|
|
"Embeddings enabled via Ollama (model: {}, url: {}, dim: {})",
|
|
self.model,
|
|
self.ollama_base_url,
|
|
self.dimension,
|
|
);
|
|
Some(Arc::new(
|
|
crate::workspace::OllamaEmbeddings::new(&self.ollama_base_url)
|
|
.with_model(&self.model, self.dimension),
|
|
))
|
|
}
|
|
_ => {
|
|
if let Some(api_key) = self.openai_api_key() {
|
|
tracing::info!(
|
|
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
|
self.model,
|
|
self.dimension,
|
|
);
|
|
Some(Arc::new(crate::workspace::OpenAiEmbeddings::with_model(
|
|
api_key,
|
|
&self.model,
|
|
self.dimension,
|
|
)))
|
|
} else {
|
|
tracing::warn!("Embeddings configured but OPENAI_API_KEY not set");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::helpers::ENV_MUTEX;
|
|
use crate::settings::{EmbeddingsSettings, Settings};
|
|
|
|
/// Clear all embedding-related env vars.
|
|
fn clear_embedding_env() {
|
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
|
unsafe {
|
|
std::env::remove_var("EMBEDDING_ENABLED");
|
|
std::env::remove_var("EMBEDDING_PROVIDER");
|
|
std::env::remove_var("EMBEDDING_MODEL");
|
|
std::env::remove_var("OPENAI_API_KEY");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn embeddings_disabled_not_overridden_by_openai_key() {
|
|
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("OPENAI_API_KEY", "sk-test-key-for-issue-129");
|
|
}
|
|
|
|
let settings = Settings {
|
|
embeddings: EmbeddingsSettings {
|
|
enabled: false,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert!(
|
|
!config.enabled,
|
|
"embeddings should remain disabled when settings.embeddings.enabled=false, \
|
|
even when OPENAI_API_KEY is set (issue #129)"
|
|
);
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("OPENAI_API_KEY");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn embeddings_enabled_from_settings() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_embedding_env();
|
|
|
|
let settings = Settings {
|
|
embeddings: EmbeddingsSettings {
|
|
enabled: true,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert!(
|
|
config.enabled,
|
|
"embeddings should be enabled when settings say so"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embeddings_env_override_takes_precedence() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
|
|
clear_embedding_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("EMBEDDING_ENABLED", "true");
|
|
}
|
|
|
|
let settings = Settings {
|
|
embeddings: EmbeddingsSettings {
|
|
enabled: false,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert!(
|
|
config.enabled,
|
|
"EMBEDDING_ENABLED=true env var should override settings"
|
|
);
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("EMBEDDING_ENABLED");
|
|
}
|
|
}
|
|
}
|