mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(llm): declarative provider registry, replace hardcoded provider configs Replace the hardcoded LlmBackend enum and per-provider config structs with a declarative JSON registry. Adding a new OpenAI-compatible provider now requires zero Rust code changes -- just add an entry to providers.json. - Add providers.json with 14 providers (openai, anthropic, ollama, openai_compatible, tinfoil, openrouter, groq, nvidia, venice, together, fireworks, deepseek, cerebras, sambanova) - Add src/llm/registry.rs with ProviderProtocol, SetupHint, ProviderDefinition, and ProviderRegistry types - Rewrite src/config/llm.rs: remove LlmBackend enum and 5 per-provider config structs, replace with generic RegistryProviderConfig - Simplify src/llm/mod.rs: remove 5 create_*_provider functions, dispatch on ProviderProtocol (3 code paths for all providers) - Dynamic setup wizard: menu built from registry.selectable(), generic credential collection dispatched by SetupHint kind - Dynamic secret injection: inject_llm_keys_from_secrets() discovers secret-to-env mappings from registry instead of hardcoded list - Users can extend with ~/.ironclaw/providers.json (no recompile) - Subsumes open provider PRs: Groq #570, NVIDIA NIM #576, Venice.ai #451 (Gemini #476 excluded -- not OpenAI-compatible) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): self-sufficient provider auth, onboard --provider-only, extract SessionConfig - NearAiChatProvider handles its own session auth lazily in resolve_bearer_token() instead of requiring main.rs to pre-check. Triggers OAuth/API-key login on first request when no token exists. - Add `ironclaw onboard --provider-only` to reconfigure just the LLM provider and model selection without re-running the full wizard. - Extract auth_base_url and session_path from NearAiConfig into LlmConfig::session (SessionConfig). Callers now use config.llm.session directly instead of reaching into nearai fields. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): address PR review comments on provider registry - Use registry.selectable() instead of registry.all() for secret injection to avoid duplicates from user provider overrides. - Fix selectable() dedup bug: check setup hint on the final (overridden) definition, not the first occurrence. User overrides that add a setup hint are now included correctly. - Only store openai_compatible_base_url for providers that actually use LLM_BASE_URL, preventing base URL pollution for groq/nvidia/etc. - Normalize provider_id to canonical registry def.id instead of using the raw user-supplied alias string. - Add comment explaining why .completions_api() is used over the default Responses API path. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(docker): copy providers.json into build context The declarative provider registry uses `include_str!("../../providers.json")` at compile time, so the file must be present in the Docker builder stage. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): address second-round PR review comments (#618) - Make --channels-only and --provider-only mutually exclusive via clap conflicts_with (Copilot: cli/mod.rs) - Add 5s timeout to fetch_openai_compatible_models(), matching the other three model-fetch helpers (Copilot: wizard.rs) - Apply models_filter from setup hints when listing models, so Groq's "chat" filter actually excludes non-chat models (Copilot: wizard.rs) - Normalize LlmConfig.backend to the canonical provider ID instead of the raw user-supplied alias string (Copilot: llm.rs) - Add models_filter() accessor to SetupHint with regression test Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): relax flaky parallel speedup timing threshold The test_parallel_speedup test asserted <500ms but CI runners can be slow enough to exceed that while still proving parallelism. Bumped to 800ms which still validates parallel execution (sequential would be ~600ms minimum) while tolerating CI jitter. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): handle api_key_login path in resolve_bearer_token, warn on missing keys - resolve_bearer_token() now checks NEARAI_API_KEY env var after ensure_authenticated(), handling the case where the user entered an API key via the interactive login flow (which sets the env var but not a session token) - Add tracing::warn when creating an OpenAI-compatible provider without an API key, making 401 errors easier to diagnose - Add regression test for resolve_bearer_token auth paths Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in nearai_chat test [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): correct bearer token priority, handle setup-less providers (#618) - resolve_bearer_token(): session token now takes priority over NEARAI_API_KEY env var, preventing unexpected auth mode switches. The env var fallback only triggers after ensure_authenticated() when no session token was stored (api_key_login path). - run_provider_setup(): providers with setup: None no longer error, allowing env-var-only providers to be kept during re-onboarding. - Split bearer token test into 3 focused tests: config api_key path, session token path, and session-beats-env-var precedence test. - Add test for wizard handling of providers without setup hints. Co-Authored-By: Claude Opus 4.6 <[email protected]> * test(llm): comprehensive tests for provider registry, config, and auth Add 13 new tests covering the critical paths in the provider system: Bearer token auth priority (nearai_chat.rs): - config api_key wins over session token and env var - session token wins over env var (prevents mid-run auth mode switches) - config api_key path works in isolation - session token path works in isolation Config resolution (config/llm.rs): - backend alias normalization (open_ai → openai) - unknown backend falls back to openai_compatible - nearai aliases (nearai, near_ai, near) all resolve correctly - base URL resolution priority (env > settings > registry default) Registry dedup (registry.rs): - user override adds setup hint → appears in selectable() - user override removes setup hint → excluded from selectable() - selectable() preserves insertion order during dedup - all built-in ApiKey providers have api_key_env set Wizard (wizard.rs): - setup: None providers don't error during re-onboarding Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
331 lines
12 KiB
Rust
331 lines
12 KiB
Rust
//! Configuration for IronClaw.
|
|
//!
|
|
//! Settings are loaded with priority: env var > database > default.
|
|
//! `DATABASE_URL` lives in `~/.ironclaw/.env` (loaded via dotenvy early
|
|
//! in startup). Everything else comes from env vars, the DB settings
|
|
//! table, or auto-detection.
|
|
|
|
mod agent;
|
|
mod builder;
|
|
mod channels;
|
|
mod database;
|
|
mod embeddings;
|
|
mod heartbeat;
|
|
pub(crate) mod helpers;
|
|
mod hygiene;
|
|
mod llm;
|
|
mod routines;
|
|
mod safety;
|
|
mod sandbox;
|
|
mod secrets;
|
|
mod skills;
|
|
mod tunnel;
|
|
mod wasm;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::OnceLock;
|
|
|
|
use crate::error::ConfigError;
|
|
use crate::settings::Settings;
|
|
|
|
// Re-export all public types so `crate::config::FooConfig` continues to work.
|
|
pub use self::agent::AgentConfig;
|
|
pub use self::builder::BuilderModeConfig;
|
|
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig};
|
|
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
|
|
pub use self::embeddings::EmbeddingsConfig;
|
|
pub use self::heartbeat::HeartbeatConfig;
|
|
pub use self::hygiene::HygieneConfig;
|
|
pub use self::llm::{LlmConfig, NearAiConfig, RegistryProviderConfig};
|
|
pub use self::routines::RoutineConfig;
|
|
pub use self::safety::SafetyConfig;
|
|
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
|
pub use self::secrets::SecretsConfig;
|
|
pub use self::skills::SkillsConfig;
|
|
pub use self::tunnel::TunnelConfig;
|
|
pub use self::wasm::WasmConfig;
|
|
pub use crate::llm::session::SessionConfig;
|
|
|
|
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
|
///
|
|
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
|
|
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
|
/// real env vars first, then falls back to this overlay.
|
|
static INJECTED_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
|
|
/// Main configuration for the agent.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Config {
|
|
pub database: DatabaseConfig,
|
|
pub llm: LlmConfig,
|
|
pub embeddings: EmbeddingsConfig,
|
|
pub tunnel: TunnelConfig,
|
|
pub channels: ChannelsConfig,
|
|
pub agent: AgentConfig,
|
|
pub safety: SafetyConfig,
|
|
pub wasm: WasmConfig,
|
|
pub secrets: SecretsConfig,
|
|
pub builder: BuilderModeConfig,
|
|
pub heartbeat: HeartbeatConfig,
|
|
pub hygiene: HygieneConfig,
|
|
pub routines: RoutineConfig,
|
|
pub sandbox: SandboxModeConfig,
|
|
pub claude_code: ClaudeCodeConfig,
|
|
pub skills: SkillsConfig,
|
|
pub observability: crate::observability::ObservabilityConfig,
|
|
}
|
|
|
|
impl Config {
|
|
/// Create a full Config for integration tests without reading env vars.
|
|
///
|
|
/// Requires the `libsql` feature. Sets up:
|
|
/// - libSQL database at the given path
|
|
/// - WASM and embeddings disabled
|
|
/// - Skills enabled with the given directories
|
|
/// - Heartbeat, routines, sandbox, builder all disabled
|
|
/// - Safety with injection check off, 100k output limit
|
|
#[cfg(feature = "libsql")]
|
|
pub fn for_testing(
|
|
libsql_path: std::path::PathBuf,
|
|
skills_dir: std::path::PathBuf,
|
|
installed_skills_dir: std::path::PathBuf,
|
|
) -> Self {
|
|
Self {
|
|
database: DatabaseConfig {
|
|
backend: DatabaseBackend::LibSql,
|
|
url: secrecy::SecretString::from("unused://test".to_string()),
|
|
pool_size: 1,
|
|
ssl_mode: SslMode::Disable,
|
|
libsql_path: Some(libsql_path),
|
|
libsql_url: None,
|
|
libsql_auth_token: None,
|
|
},
|
|
llm: LlmConfig::for_testing(),
|
|
embeddings: EmbeddingsConfig::default(),
|
|
tunnel: TunnelConfig::default(),
|
|
channels: ChannelsConfig {
|
|
cli: CliConfig { enabled: false },
|
|
http: None,
|
|
gateway: None,
|
|
signal: None,
|
|
wasm_channels_dir: std::path::PathBuf::from("/tmp/ironclaw-test-channels"),
|
|
wasm_channels_enabled: false,
|
|
wasm_channel_owner_ids: HashMap::new(),
|
|
},
|
|
agent: AgentConfig::for_testing(),
|
|
safety: SafetyConfig {
|
|
max_output_length: 100_000,
|
|
injection_check_enabled: false,
|
|
},
|
|
wasm: WasmConfig {
|
|
enabled: false,
|
|
..WasmConfig::default()
|
|
},
|
|
secrets: SecretsConfig::default(),
|
|
builder: BuilderModeConfig {
|
|
enabled: false,
|
|
..BuilderModeConfig::default()
|
|
},
|
|
heartbeat: HeartbeatConfig::default(),
|
|
hygiene: HygieneConfig::default(),
|
|
routines: RoutineConfig {
|
|
enabled: false,
|
|
..RoutineConfig::default()
|
|
},
|
|
sandbox: SandboxModeConfig {
|
|
enabled: false,
|
|
..SandboxModeConfig::default()
|
|
},
|
|
claude_code: ClaudeCodeConfig::default(),
|
|
skills: SkillsConfig {
|
|
enabled: true,
|
|
local_dir: skills_dir,
|
|
installed_dir: installed_skills_dir,
|
|
..SkillsConfig::default()
|
|
},
|
|
observability: crate::observability::ObservabilityConfig::default(),
|
|
}
|
|
}
|
|
|
|
/// Load configuration from environment variables and the database.
|
|
///
|
|
/// Priority: env var > TOML config file > DB settings > default.
|
|
/// This is the primary way to load config after DB is connected.
|
|
pub async fn from_db(
|
|
store: &(dyn crate::db::SettingsStore + Sync),
|
|
user_id: &str,
|
|
) -> Result<Self, ConfigError> {
|
|
Self::from_db_with_toml(store, user_id, None).await
|
|
}
|
|
|
|
/// Load from DB with an optional TOML config file overlay.
|
|
pub async fn from_db_with_toml(
|
|
store: &(dyn crate::db::SettingsStore + Sync),
|
|
user_id: &str,
|
|
toml_path: Option<&std::path::Path>,
|
|
) -> Result<Self, ConfigError> {
|
|
let _ = dotenvy::dotenv();
|
|
crate::bootstrap::load_ironclaw_env();
|
|
|
|
// Load all settings from DB into a Settings struct
|
|
let mut db_settings = match store.get_all_settings(user_id).await {
|
|
Ok(map) => Settings::from_db_map(&map),
|
|
Err(e) => {
|
|
tracing::warn!("Failed to load settings from DB, using defaults: {}", e);
|
|
Settings::default()
|
|
}
|
|
};
|
|
|
|
// Overlay TOML config file (values win over DB settings)
|
|
Self::apply_toml_overlay(&mut db_settings, toml_path)?;
|
|
|
|
Self::build(&db_settings).await
|
|
}
|
|
|
|
/// Load configuration from environment variables only (no database).
|
|
///
|
|
/// Used during early startup before the database is connected,
|
|
/// and by CLI commands that don't have DB access.
|
|
/// Falls back to legacy `settings.json` on disk if present.
|
|
///
|
|
/// Loads both `./.env` (standard, higher priority) and `~/.ironclaw/.env`
|
|
/// (lower priority) via dotenvy, which never overwrites existing vars.
|
|
pub async fn from_env() -> Result<Self, ConfigError> {
|
|
Self::from_env_with_toml(None).await
|
|
}
|
|
|
|
/// Load from env with an optional TOML config file overlay.
|
|
pub async fn from_env_with_toml(
|
|
toml_path: Option<&std::path::Path>,
|
|
) -> Result<Self, ConfigError> {
|
|
let _ = dotenvy::dotenv();
|
|
crate::bootstrap::load_ironclaw_env();
|
|
let mut settings = Settings::load();
|
|
|
|
// Overlay TOML config file (values win over JSON settings)
|
|
Self::apply_toml_overlay(&mut settings, toml_path)?;
|
|
|
|
Self::build(&settings).await
|
|
}
|
|
|
|
/// Load and merge a TOML config file into settings.
|
|
///
|
|
/// If `explicit_path` is `Some`, loads from that path (errors are fatal).
|
|
/// If `None`, tries the default path `~/.ironclaw/config.toml` (missing
|
|
/// file is silently ignored).
|
|
fn apply_toml_overlay(
|
|
settings: &mut Settings,
|
|
explicit_path: Option<&std::path::Path>,
|
|
) -> Result<(), ConfigError> {
|
|
let path = explicit_path
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(Settings::default_toml_path);
|
|
|
|
match Settings::load_toml(&path) {
|
|
Ok(Some(toml_settings)) => {
|
|
settings.merge_from(&toml_settings);
|
|
tracing::debug!("Loaded TOML config from {}", path.display());
|
|
}
|
|
Ok(None) => {
|
|
if explicit_path.is_some() {
|
|
return Err(ConfigError::ParseError(format!(
|
|
"Config file not found: {}",
|
|
path.display()
|
|
)));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if explicit_path.is_some() {
|
|
return Err(ConfigError::ParseError(format!(
|
|
"Failed to load config file {}: {}",
|
|
path.display(),
|
|
e
|
|
)));
|
|
}
|
|
tracing::warn!("Failed to load default config file: {}", e);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Build config from settings (shared by from_env and from_db).
|
|
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
|
|
Ok(Self {
|
|
database: DatabaseConfig::resolve()?,
|
|
llm: LlmConfig::resolve(settings)?,
|
|
embeddings: EmbeddingsConfig::resolve(settings)?,
|
|
tunnel: TunnelConfig::resolve(settings)?,
|
|
channels: ChannelsConfig::resolve(settings)?,
|
|
agent: AgentConfig::resolve(settings)?,
|
|
safety: SafetyConfig::resolve()?,
|
|
wasm: WasmConfig::resolve()?,
|
|
secrets: SecretsConfig::resolve().await?,
|
|
builder: BuilderModeConfig::resolve()?,
|
|
heartbeat: HeartbeatConfig::resolve(settings)?,
|
|
hygiene: HygieneConfig::resolve()?,
|
|
routines: RoutineConfig::resolve()?,
|
|
sandbox: SandboxModeConfig::resolve()?,
|
|
claude_code: ClaudeCodeConfig::resolve()?,
|
|
skills: SkillsConfig::resolve()?,
|
|
observability: crate::observability::ObservabilityConfig {
|
|
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Load API keys from the encrypted secrets store into a thread-safe overlay.
|
|
///
|
|
/// This bridges the gap between secrets stored during onboarding and the
|
|
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
|
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
|
/// so explicit env vars always win.
|
|
pub async fn inject_llm_keys_from_secrets(
|
|
secrets: &dyn crate::secrets::SecretsStore,
|
|
user_id: &str,
|
|
) {
|
|
// Static mappings for well-known providers.
|
|
// The registry's setup hints define secret_name -> env_var mappings,
|
|
// so new providers added to providers.json get injection automatically.
|
|
let mut mappings: Vec<(&str, &str)> = vec![("llm_nearai_api_key", "NEARAI_API_KEY")];
|
|
|
|
// Dynamically discover secret->env mappings from the provider registry.
|
|
// Uses selectable() which deduplicates user overrides correctly.
|
|
let registry = crate::llm::ProviderRegistry::load();
|
|
let dynamic_mappings: Vec<(String, String)> = registry
|
|
.selectable()
|
|
.iter()
|
|
.filter_map(|def| {
|
|
def.api_key_env.as_ref().and_then(|env_var| {
|
|
def.setup
|
|
.as_ref()
|
|
.and_then(|s| s.secret_name())
|
|
.map(|secret_name| (secret_name.to_string(), env_var.clone()))
|
|
})
|
|
})
|
|
.collect();
|
|
for (secret, env_var) in &dynamic_mappings {
|
|
mappings.push((secret, env_var));
|
|
}
|
|
|
|
let mut injected = HashMap::new();
|
|
|
|
for (secret_name, env_var) in mappings {
|
|
match std::env::var(env_var) {
|
|
Ok(val) if !val.is_empty() => continue,
|
|
_ => {}
|
|
}
|
|
match secrets.get_decrypted(user_id, secret_name).await {
|
|
Ok(decrypted) => {
|
|
injected.insert(env_var.to_string(), decrypted.expose().to_string());
|
|
tracing::debug!("Loaded secret '{}' for env var '{}'", secret_name, env_var);
|
|
}
|
|
Err(_) => {
|
|
// Secret doesn't exist, that's fine
|
|
}
|
|
}
|
|
}
|
|
|
|
let _ = INJECTED_VARS.set(injected);
|
|
}
|