mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
refactor(setup): extract init logic from wizard into owning modules (#1210)
* refactor(setup): extract init logic from wizard into owning modules Move database, LLM model discovery, and secrets initialization logic out of the setup wizard and into their owning modules, following the CLAUDE.md principle that module-specific initialization must live in the owning module as a public factory function. Database (src/db/mod.rs, src/config/database.rs): - Add DatabaseConfig::from_postgres_url() and from_libsql_path() - Add connect_without_migrations() for connectivity testing - Add validate_postgres() returning structured PgDiagnostic results LLM (src/llm/models.rs — new file): - Extract 8 model-fetching functions from wizard.rs (~380 lines) - fetch_anthropic_models, fetch_openai_models, fetch_ollama_models, fetch_openai_compatible_models, build_nearai_model_fetch_config, and OpenAI sorting/filtering helpers Secrets (src/secrets/mod.rs): - Add resolve_master_key() unifying env var + keychain resolution - Add crypto_from_hex() convenience wrapper Wizard restructuring (src/setup/wizard.rs): - Replace cfg-gated db_pool/db_backend fields with generic db: Option<Arc<dyn Database>> + db_handles: Option<DatabaseHandles> - Delete 6 backend-specific methods (reconnect_postgres/libsql, test_database_connection_postgres/libsql, run_migrations_postgres/ libsql, create_postgres/libsql_secrets_store) - Simplify persist_settings, try_load_existing_settings, persist_session_to_db, init_secrets_context to backend-agnostic implementations using the new module factories - Eliminate all references to deadpool_postgres, PoolConfig, LibSqlBackend, Store::from_pool, refinery::embed_migrations Net: -878 lines from wizard, +395 lines in owning modules, +378 new. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(settings): add wizard re-run regression tests Add 10 tests covering settings preservation during wizard re-runs: - provider_only rerun preserves channels/embeddings/heartbeat - channels_only rerun preserves provider/model/embeddings - quick mode rerun preserves prior channels and heartbeat - full rerun same provider preserves model through merge - full rerun different provider clears model through merge - incremental persist doesn't clobber prior steps - switching DB backend allows fresh connection settings - merge preserves true booleans when overlay has default false - embeddings survive rerun that skips step 5 These cover the scenarios where re-running the wizard would previously risk resetting models, providers, or channel settings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(setup): eliminate cfg(feature) gates from wizard methods Replace compile-time #[cfg(feature)] dispatch in the wizard with runtime dispatch via DatabaseBackend enum and cfg!() macro constants. - Merge step_database_postgres + step_database_libsql into step_database using runtime backend selection - Rewrite auto_setup_database without feature gates - Remove cfg(feature = "postgres") from mask_password_in_url (pure fn) - Remove cfg(feature = "postgres") from test_mask_password_in_url Only one internal #[cfg(feature = "postgres")] remains: guarding the call to db::validate_postgres() which is itself feature-gated. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(db): fold PG validation into connect_without_migrations Move PostgreSQL prerequisite validation (version >= 15, pgvector) from the wizard into connect_without_migrations() in the db module. The validation now returns DatabaseError directly with user-facing messages, eliminating the PgDiagnostic enum and the last #[cfg(feature)] gate from the wizard. The wizard's test_database_connection() is now a 5-line method that calls the db module factory and stores the result. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review comments [skip-regression-check] - Use .as_ref().map() to avoid partial move of db_config.libsql_path (gemini-code-assist) - Default to available backend when DATABASE_BACKEND is invalid, not unconditionally to Postgres which may not be compiled (Copilot) - Match DatabaseBackend::Postgres explicitly instead of _ => wildcard in connect_with_handles, connect_without_migrations, and create_secrets_store to avoid silently routing LibSql configs through the Postgres path when libsql feature is disabled (Copilot) - Upgrade Ollama connection failure log from info to warn with the base URL for better visibility in wizard UX (Copilot) - Clarify crypto_from_hex doc: SecretsCrypto validates key length, not hex encoding (Copilot) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian's PR review feedback [skip-regression-check] - Update src/setup/README.md to reflect Arc<dyn Database> flow - Remove stale "Test PostgreSQL connection" doc comment - Replace unwrap_or(0) in validate_postgres with descriptive error - Add NearAiConfig::for_model_discovery() constructor - Narrow pub to pub(crate) for internal model helpers Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address Copilot review comments (quick-mode postgres gate, empty env vars) [skip-regression-check] - Gate DATABASE_URL auto-detection on POSTGRES_AVAILABLE in quick mode so libsql-only builds don't attempt a postgres connection - Match empty-env-var filtering in key source detection to align with resolve_master_key() behavior - Filter empty strings to None in DatabaseConfig::from_libsql_path() for turso_url/turso_token Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
57c397bd50
commit
e81fb7e5cb
@@ -170,6 +170,40 @@ impl DatabaseConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a config from a raw PostgreSQL URL (for wizard/testing).
|
||||||
|
pub fn from_postgres_url(url: &str, pool_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
backend: DatabaseBackend::Postgres,
|
||||||
|
url: SecretString::from(url.to_string()),
|
||||||
|
pool_size,
|
||||||
|
ssl_mode: SslMode::from_env(),
|
||||||
|
libsql_path: None,
|
||||||
|
libsql_url: None,
|
||||||
|
libsql_auth_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a config for a libSQL database (for wizard/testing).
|
||||||
|
///
|
||||||
|
/// Empty strings for `turso_url` and `turso_token` are treated as `None`.
|
||||||
|
pub fn from_libsql_path(
|
||||||
|
path: &str,
|
||||||
|
turso_url: Option<&str>,
|
||||||
|
turso_token: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
let turso_url = turso_url.filter(|s| !s.is_empty());
|
||||||
|
let turso_token = turso_token.filter(|s| !s.is_empty());
|
||||||
|
Self {
|
||||||
|
backend: DatabaseBackend::LibSql,
|
||||||
|
url: SecretString::from("unused://libsql".to_string()),
|
||||||
|
pool_size: 1,
|
||||||
|
ssl_mode: SslMode::default(),
|
||||||
|
libsql_path: Some(PathBuf::from(path)),
|
||||||
|
libsql_url: turso_url.map(String::from),
|
||||||
|
libsql_auth_token: turso_token.map(|t| SecretString::from(t.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the database URL (exposes the secret).
|
/// Get the database URL (exposes the secret).
|
||||||
pub fn url(&self) -> &str {
|
pub fn url(&self) -> &str {
|
||||||
self.url.expose_secret()
|
self.url.expose_secret()
|
||||||
|
|||||||
+140
-11
@@ -104,7 +104,7 @@ pub async fn connect_with_handles(
|
|||||||
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
|
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
_ => {
|
crate::config::DatabaseBackend::Postgres => {
|
||||||
let pg = postgres::PgBackend::new(config)
|
let pg = postgres::PgBackend::new(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
||||||
@@ -115,10 +115,11 @@ pub async fn connect_with_handles(
|
|||||||
|
|
||||||
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
|
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "postgres"))]
|
#[allow(unreachable_patterns)]
|
||||||
_ => Err(DatabaseError::Pool(
|
_ => Err(DatabaseError::Pool(format!(
|
||||||
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
|
"Database backend '{}' is not available. Rebuild with the appropriate feature flag.",
|
||||||
)),
|
config.backend
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ pub async fn create_secrets_store(
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
_ => {
|
crate::config::DatabaseBackend::Postgres => {
|
||||||
let pg = postgres::PgBackend::new(config)
|
let pg = postgres::PgBackend::new(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
||||||
@@ -172,14 +173,142 @@ pub async fn create_secrets_store(
|
|||||||
crypto,
|
crypto,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "postgres"))]
|
#[allow(unreachable_patterns)]
|
||||||
_ => Err(DatabaseError::Pool(
|
_ => Err(DatabaseError::Pool(format!(
|
||||||
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
|
"Database backend '{}' is not available for secrets. Rebuild with the appropriate feature flag.",
|
||||||
.to_string(),
|
config.backend
|
||||||
)),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Wizard / testing helpers ====================
|
||||||
|
|
||||||
|
/// Connect to the database WITHOUT running migrations, validating
|
||||||
|
/// prerequisites when applicable (PostgreSQL version, pgvector).
|
||||||
|
///
|
||||||
|
/// Returns both the `Database` trait object and backend-specific handles.
|
||||||
|
/// Used by the wizard to test connectivity before committing — call
|
||||||
|
/// [`Database::run_migrations`] on the returned trait object when ready.
|
||||||
|
pub async fn connect_without_migrations(
|
||||||
|
config: &crate::config::DatabaseConfig,
|
||||||
|
) -> Result<(Arc<dyn Database>, DatabaseHandles), DatabaseError> {
|
||||||
|
let mut handles = DatabaseHandles::default();
|
||||||
|
|
||||||
|
match config.backend {
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
crate::config::DatabaseBackend::LibSql => {
|
||||||
|
use secrecy::ExposeSecret as _;
|
||||||
|
|
||||||
|
let default_path = crate::config::default_libsql_path();
|
||||||
|
let db_path = config.libsql_path.as_deref().unwrap_or(&default_path);
|
||||||
|
|
||||||
|
let backend = if let Some(ref url) = config.libsql_url {
|
||||||
|
let token = config.libsql_auth_token.as_ref().ok_or_else(|| {
|
||||||
|
DatabaseError::Pool(
|
||||||
|
"LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
libsql::LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
libsql::LibSqlBackend::new_local(db_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
handles.libsql_db = Some(backend.shared_db());
|
||||||
|
|
||||||
|
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
|
||||||
|
}
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
crate::config::DatabaseBackend::Postgres => {
|
||||||
|
let pg = postgres::PgBackend::new(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
||||||
|
|
||||||
|
handles.pg_pool = Some(pg.pool());
|
||||||
|
|
||||||
|
// Validate PostgreSQL prerequisites (version, pgvector)
|
||||||
|
validate_postgres(&pg.pool()).await?;
|
||||||
|
|
||||||
|
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
|
||||||
|
}
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => Err(DatabaseError::Pool(format!(
|
||||||
|
"Database backend '{}' is not available. Rebuild with the appropriate feature flag.",
|
||||||
|
config.backend
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate PostgreSQL prerequisites (version >= 15, pgvector available).
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` if all prerequisites are met, or a `DatabaseError`
|
||||||
|
/// with a user-facing message describing the issue.
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), DatabaseError> {
|
||||||
|
let client = pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Pool(format!("Failed to connect: {}", e)))?;
|
||||||
|
|
||||||
|
// Check PostgreSQL server version (need 15+ for pgvector).
|
||||||
|
let version_row = client
|
||||||
|
.query_one("SHOW server_version", &[])
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Query(format!("Failed to query server version: {}", e)))?;
|
||||||
|
let version_str: &str = version_row.get(0);
|
||||||
|
let major_version = version_str
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DatabaseError::Pool(format!(
|
||||||
|
"Could not parse PostgreSQL version from '{}'. \
|
||||||
|
Expected a numeric major version (e.g., '15.2').",
|
||||||
|
version_str
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
const MIN_PG_MAJOR_VERSION: u32 = 15;
|
||||||
|
|
||||||
|
if major_version < MIN_PG_MAJOR_VERSION {
|
||||||
|
return Err(DatabaseError::Pool(format!(
|
||||||
|
"PostgreSQL {} detected. IronClaw requires PostgreSQL {} or later \
|
||||||
|
for pgvector support.\n\
|
||||||
|
Upgrade: https://www.postgresql.org/download/",
|
||||||
|
version_str, MIN_PG_MAJOR_VERSION
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pgvector extension is available.
|
||||||
|
let pgvector_row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT 1 FROM pg_available_extensions WHERE name = 'vector'",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DatabaseError::Query(format!("Failed to check pgvector availability: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if pgvector_row.is_none() {
|
||||||
|
return Err(DatabaseError::Pool(format!(
|
||||||
|
"pgvector extension not found on your PostgreSQL server.\n\n\
|
||||||
|
Install it:\n \
|
||||||
|
macOS: brew install pgvector\n \
|
||||||
|
Ubuntu: apt install postgresql-{0}-pgvector\n \
|
||||||
|
Docker: use the pgvector/pgvector:pg{0} image\n \
|
||||||
|
Source: https://github.com/pgvector/pgvector#installation\n\n\
|
||||||
|
Then restart PostgreSQL and re-run: ironclaw onboard",
|
||||||
|
major_version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Sub-traits ====================
|
// ==================== Sub-traits ====================
|
||||||
//
|
//
|
||||||
// Each sub-trait groups related persistence methods. The `Database` supertrait
|
// Each sub-trait groups related persistence methods. The `Database` supertrait
|
||||||
|
|||||||
@@ -163,3 +163,42 @@ pub struct NearAiConfig {
|
|||||||
/// Enable cascade mode for smart routing. Default: true.
|
/// Enable cascade mode for smart routing. Default: true.
|
||||||
pub smart_routing_cascade: bool,
|
pub smart_routing_cascade: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl NearAiConfig {
|
||||||
|
/// Create a minimal config suitable for listing available models.
|
||||||
|
///
|
||||||
|
/// Reads `NEARAI_API_KEY` from the environment and selects the
|
||||||
|
/// appropriate base URL (cloud-api when API key is present,
|
||||||
|
/// private.near.ai for session-token auth).
|
||||||
|
pub(crate) fn for_model_discovery() -> Self {
|
||||||
|
let api_key = std::env::var("NEARAI_API_KEY")
|
||||||
|
.ok()
|
||||||
|
.filter(|k| !k.is_empty())
|
||||||
|
.map(SecretString::from);
|
||||||
|
|
||||||
|
let default_base = if api_key.is_some() {
|
||||||
|
"https://cloud-api.near.ai"
|
||||||
|
} else {
|
||||||
|
"https://private.near.ai"
|
||||||
|
};
|
||||||
|
let base_url =
|
||||||
|
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
model: String::new(),
|
||||||
|
cheap_model: None,
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
fallback_model: None,
|
||||||
|
max_retries: 3,
|
||||||
|
circuit_breaker_threshold: None,
|
||||||
|
circuit_breaker_recovery_secs: 30,
|
||||||
|
response_cache_enabled: false,
|
||||||
|
response_cache_ttl_secs: 3600,
|
||||||
|
response_cache_max_entries: 1000,
|
||||||
|
failover_cooldown_secs: 300,
|
||||||
|
failover_cooldown_threshold: 3,
|
||||||
|
smart_routing_cascade: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub mod session;
|
|||||||
pub mod smart_routing;
|
pub mod smart_routing;
|
||||||
|
|
||||||
pub mod image_models;
|
pub mod image_models;
|
||||||
|
pub mod models;
|
||||||
pub mod reasoning_models;
|
pub mod reasoning_models;
|
||||||
pub mod vision_models;
|
pub mod vision_models;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//! Model discovery and fetching for multiple LLM providers.
|
||||||
|
|
||||||
|
/// Fetch models from the Anthropic API.
|
||||||
|
///
|
||||||
|
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
|
||||||
|
pub(crate) async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> {
|
||||||
|
let static_defaults = vec![
|
||||||
|
(
|
||||||
|
"claude-opus-4-6".into(),
|
||||||
|
"Claude Opus 4.6 (latest flagship)".into(),
|
||||||
|
),
|
||||||
|
("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()),
|
||||||
|
("claude-opus-4-5".into(), "Claude Opus 4.5".into()),
|
||||||
|
("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()),
|
||||||
|
("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let api_key = cached_key
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
|
||||||
|
.filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER);
|
||||||
|
|
||||||
|
// Fall back to OAuth token if no API key
|
||||||
|
let oauth_token = if api_key.is_none() {
|
||||||
|
crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (key_or_token, is_oauth) = match (api_key, oauth_token) {
|
||||||
|
(Some(k), _) => (k, false),
|
||||||
|
(None, Some(t)) => (t, true),
|
||||||
|
(None, None) => return static_defaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut request = client
|
||||||
|
.get("https://api.anthropic.com/v1/models")
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.timeout(std::time::Duration::from_secs(5));
|
||||||
|
|
||||||
|
if is_oauth {
|
||||||
|
request = request
|
||||||
|
.bearer_auth(&key_or_token)
|
||||||
|
.header("anthropic-beta", "oauth-2025-04-20");
|
||||||
|
} else {
|
||||||
|
request = request.header("x-api-key", &key_or_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = match request.send().await {
|
||||||
|
Ok(r) if r.status().is_success() => r,
|
||||||
|
_ => return static_defaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ModelEntry {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ModelsResponse {
|
||||||
|
data: Vec<ModelEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
match resp.json::<ModelsResponse>().await {
|
||||||
|
Ok(body) => {
|
||||||
|
let mut models: Vec<(String, String)> = body
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| !m.id.contains("embedding") && !m.id.contains("audio"))
|
||||||
|
.map(|m| {
|
||||||
|
let label = m.id.clone();
|
||||||
|
(m.id, label)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if models.is_empty() {
|
||||||
|
return static_defaults;
|
||||||
|
}
|
||||||
|
models.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
models
|
||||||
|
}
|
||||||
|
Err(_) => static_defaults,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch models from the OpenAI API.
|
||||||
|
///
|
||||||
|
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
|
||||||
|
pub(crate) async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> {
|
||||||
|
let static_defaults = vec![
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex".into(),
|
||||||
|
"GPT-5.3 Codex (latest flagship)".into(),
|
||||||
|
),
|
||||||
|
("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()),
|
||||||
|
("gpt-5.2".into(), "GPT-5.2".into()),
|
||||||
|
(
|
||||||
|
"gpt-5.1-codex-mini".into(),
|
||||||
|
"GPT-5.1 Codex Mini (fast)".into(),
|
||||||
|
),
|
||||||
|
("gpt-5".into(), "GPT-5".into()),
|
||||||
|
("gpt-5-mini".into(), "GPT-5 Mini".into()),
|
||||||
|
("gpt-4.1".into(), "GPT-4.1".into()),
|
||||||
|
("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()),
|
||||||
|
("o4-mini".into(), "o4-mini (fast reasoning)".into()),
|
||||||
|
("o3".into(), "o3 (reasoning)".into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let api_key = cached_key
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
|
||||||
|
.filter(|k| !k.is_empty());
|
||||||
|
|
||||||
|
let api_key = match api_key {
|
||||||
|
Some(k) => k,
|
||||||
|
None => return static_defaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = match client
|
||||||
|
.get("https://api.openai.com/v1/models")
|
||||||
|
.bearer_auth(&api_key)
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.status().is_success() => r,
|
||||||
|
_ => return static_defaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ModelEntry {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ModelsResponse {
|
||||||
|
data: Vec<ModelEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
match resp.json::<ModelsResponse>().await {
|
||||||
|
Ok(body) => {
|
||||||
|
let mut models: Vec<(String, String)> = body
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| is_openai_chat_model(&m.id))
|
||||||
|
.map(|m| {
|
||||||
|
let label = m.id.clone();
|
||||||
|
(m.id, label)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if models.is_empty() {
|
||||||
|
return static_defaults;
|
||||||
|
}
|
||||||
|
sort_openai_models(&mut models);
|
||||||
|
models
|
||||||
|
}
|
||||||
|
Err(_) => static_defaults,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_openai_chat_model(model_id: &str) -> bool {
|
||||||
|
let id = model_id.to_ascii_lowercase();
|
||||||
|
|
||||||
|
let is_chat_family = id.starts_with("gpt-")
|
||||||
|
|| id.starts_with("chatgpt-")
|
||||||
|
|| id.starts_with("o1")
|
||||||
|
|| id.starts_with("o3")
|
||||||
|
|| id.starts_with("o4")
|
||||||
|
|| id.starts_with("o5");
|
||||||
|
|
||||||
|
let is_non_chat_variant = id.contains("realtime")
|
||||||
|
|| id.contains("audio")
|
||||||
|
|| id.contains("transcribe")
|
||||||
|
|| id.contains("tts")
|
||||||
|
|| id.contains("embedding")
|
||||||
|
|| id.contains("moderation")
|
||||||
|
|| id.contains("image");
|
||||||
|
|
||||||
|
is_chat_family && !is_non_chat_variant
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn openai_model_priority(model_id: &str) -> usize {
|
||||||
|
let id = model_id.to_ascii_lowercase();
|
||||||
|
|
||||||
|
const EXACT_PRIORITY: &[&str] = &[
|
||||||
|
"gpt-5.3-codex",
|
||||||
|
"gpt-5.2-codex",
|
||||||
|
"gpt-5.2",
|
||||||
|
"gpt-5.1-codex-mini",
|
||||||
|
"gpt-5",
|
||||||
|
"gpt-5-mini",
|
||||||
|
"gpt-5-nano",
|
||||||
|
"o4-mini",
|
||||||
|
"o3",
|
||||||
|
"o1",
|
||||||
|
"gpt-4.1",
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
"gpt-4o",
|
||||||
|
"gpt-4o-mini",
|
||||||
|
];
|
||||||
|
if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) {
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFIX_PRIORITY: &[&str] = &[
|
||||||
|
"gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-",
|
||||||
|
];
|
||||||
|
if let Some(pos) = PREFIX_PRIORITY
|
||||||
|
.iter()
|
||||||
|
.position(|prefix| id.starts_with(prefix))
|
||||||
|
{
|
||||||
|
return EXACT_PRIORITY.len() + pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sort_openai_models(models: &mut [(String, String)]) {
|
||||||
|
models.sort_by(|a, b| {
|
||||||
|
openai_model_priority(&a.0)
|
||||||
|
.cmp(&openai_model_priority(&b.0))
|
||||||
|
.then_with(|| a.0.cmp(&b.0))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch installed models from a local Ollama instance.
|
||||||
|
///
|
||||||
|
/// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error.
|
||||||
|
pub(crate) async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> {
|
||||||
|
let static_defaults = vec![
|
||||||
|
("llama3".into(), "llama3".into()),
|
||||||
|
("mistral".into(), "mistral".into()),
|
||||||
|
("codellama".into(), "codellama".into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let resp = match client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.status().is_success() => r,
|
||||||
|
Ok(_) => return static_defaults,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not connect to Ollama at {base_url}. Is it running? Using static defaults."
|
||||||
|
);
|
||||||
|
return static_defaults;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ModelEntry {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TagsResponse {
|
||||||
|
models: Vec<ModelEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
match resp.json::<TagsResponse>().await {
|
||||||
|
Ok(body) => {
|
||||||
|
let models: Vec<(String, String)> = body
|
||||||
|
.models
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
let label = m.name.clone();
|
||||||
|
(m.name, label)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if models.is_empty() {
|
||||||
|
return static_defaults;
|
||||||
|
}
|
||||||
|
models
|
||||||
|
}
|
||||||
|
Err(_) => static_defaults,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch models from a generic OpenAI-compatible /v1/models endpoint.
|
||||||
|
///
|
||||||
|
/// Used for registry providers like Groq, NVIDIA NIM, etc.
|
||||||
|
pub(crate) async fn fetch_openai_compatible_models(
|
||||||
|
base_url: &str,
|
||||||
|
cached_key: Option<&str>,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
if base_url.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/models", base_url.trim_end_matches('/'));
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5));
|
||||||
|
if let Some(key) = cached_key {
|
||||||
|
req = req.bearer_auth(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = match req.send().await {
|
||||||
|
Ok(r) if r.status().is_success() => r,
|
||||||
|
_ => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Model {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ModelsResponse {
|
||||||
|
data: Vec<Model>,
|
||||||
|
}
|
||||||
|
|
||||||
|
match resp.json::<ModelsResponse>().await {
|
||||||
|
Ok(body) => body
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
let label = m.id.clone();
|
||||||
|
(m.id, label)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Err(_) => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models.
|
||||||
|
///
|
||||||
|
/// Uses [`NearAiConfig::for_model_discovery()`] to construct a minimal NEAR AI
|
||||||
|
/// config, then wraps it in an `LlmConfig` with session config for auth.
|
||||||
|
pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
||||||
|
let auth_base_url =
|
||||||
|
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||||
|
|
||||||
|
crate::config::LlmConfig {
|
||||||
|
backend: "nearai".to_string(),
|
||||||
|
session: crate::llm::session::SessionConfig {
|
||||||
|
auth_base_url,
|
||||||
|
session_path: crate::config::llm::default_session_path(),
|
||||||
|
},
|
||||||
|
nearai: crate::config::NearAiConfig::for_model_discovery(),
|
||||||
|
provider: None,
|
||||||
|
bedrock: None,
|
||||||
|
request_timeout_secs: 120,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,3 +109,59 @@ pub fn create_secrets_store(
|
|||||||
|
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Try to resolve an existing master key from env var or OS keychain.
|
||||||
|
///
|
||||||
|
/// Resolution order:
|
||||||
|
/// 1. `SECRETS_MASTER_KEY` environment variable (hex-encoded)
|
||||||
|
/// 2. OS keychain (macOS Keychain / Linux secret-service)
|
||||||
|
///
|
||||||
|
/// Returns `None` if no key is available (caller should generate one).
|
||||||
|
pub async fn resolve_master_key() -> Option<String> {
|
||||||
|
// 1. Check env var
|
||||||
|
if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY")
|
||||||
|
&& !env_key.is_empty()
|
||||||
|
{
|
||||||
|
return Some(env_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try OS keychain
|
||||||
|
if let Ok(keychain_key_bytes) = keychain::get_master_key().await {
|
||||||
|
let key_hex: String = keychain_key_bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect();
|
||||||
|
return Some(key_hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a `SecretsCrypto` from a master key string.
|
||||||
|
///
|
||||||
|
/// The key is typically hex-encoded (from `generate_master_key_hex` or
|
||||||
|
/// the `SECRETS_MASTER_KEY` env var), but `SecretsCrypto::new` validates
|
||||||
|
/// only key length, not encoding. Any sufficiently long string works.
|
||||||
|
pub fn crypto_from_hex(hex: &str) -> Result<std::sync::Arc<SecretsCrypto>, SecretError> {
|
||||||
|
let crypto = SecretsCrypto::new(secrecy::SecretString::from(hex.to_string()))?;
|
||||||
|
Ok(std::sync::Arc::new(crypto))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crypto_from_hex_valid() {
|
||||||
|
// 32 bytes = 64 hex chars
|
||||||
|
let hex = "0123456789abcdef".repeat(4); // 64 hex chars
|
||||||
|
let result = crypto_from_hex(&hex);
|
||||||
|
assert!(result.is_ok()); // safety: test assertion
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crypto_from_hex_invalid() {
|
||||||
|
let result = crypto_from_hex("too_short");
|
||||||
|
assert!(result.is_err()); // safety: test assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+499
@@ -1747,4 +1747,503 @@ mod tests {
|
|||||||
"None selected_model should stay None"
|
"None selected_model should stay None"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Wizard re-run regression tests ===
|
||||||
|
//
|
||||||
|
// These tests simulate the merge ordering used by the wizard's `run()` method
|
||||||
|
// to verify that re-running the wizard (or a subset of steps) doesn't
|
||||||
|
// accidentally reset settings from prior runs.
|
||||||
|
|
||||||
|
/// Simulates `ironclaw onboard --provider-only` re-running on a fully
|
||||||
|
/// configured installation. Only provider + model should change; all
|
||||||
|
/// other settings (channels, embeddings, heartbeat) must survive.
|
||||||
|
#[test]
|
||||||
|
fn provider_only_rerun_preserves_unrelated_settings() {
|
||||||
|
// Prior completed run with everything configured
|
||||||
|
let prior = Settings {
|
||||||
|
onboard_completed: true,
|
||||||
|
database_backend: Some("libsql".to_string()),
|
||||||
|
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
|
||||||
|
llm_backend: Some("openai".to_string()),
|
||||||
|
selected_model: Some("gpt-4o".to_string()),
|
||||||
|
embeddings: EmbeddingsSettings {
|
||||||
|
enabled: true,
|
||||||
|
provider: "openai".to_string(),
|
||||||
|
model: "text-embedding-3-small".to_string(),
|
||||||
|
},
|
||||||
|
channels: ChannelSettings {
|
||||||
|
http_enabled: true,
|
||||||
|
http_port: Some(8080),
|
||||||
|
signal_enabled: true,
|
||||||
|
signal_account: Some("+1234567890".to_string()),
|
||||||
|
wasm_channels: vec!["telegram".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
heartbeat: HeartbeatSettings {
|
||||||
|
enabled: true,
|
||||||
|
interval_secs: 900,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
|
||||||
|
// provider_only mode: reconnect_existing_db loads from DB,
|
||||||
|
// then user picks a new provider + model via step_inference_provider
|
||||||
|
let mut current = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// Simulate step_inference_provider: user switches to anthropic
|
||||||
|
current.llm_backend = Some("anthropic".to_string());
|
||||||
|
current.selected_model = None; // cleared because backend changed
|
||||||
|
|
||||||
|
// Simulate step_model_selection: user picks a model
|
||||||
|
current.selected_model = Some("claude-sonnet-4-5".to_string());
|
||||||
|
|
||||||
|
// Verify: provider/model changed
|
||||||
|
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
|
||||||
|
assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5"));
|
||||||
|
|
||||||
|
// Verify: everything else preserved
|
||||||
|
assert!(current.channels.http_enabled, "HTTP channel must survive");
|
||||||
|
assert_eq!(current.channels.http_port, Some(8080));
|
||||||
|
assert!(current.channels.signal_enabled, "Signal must survive");
|
||||||
|
assert_eq!(
|
||||||
|
current.channels.wasm_channels,
|
||||||
|
vec!["telegram".to_string()],
|
||||||
|
"WASM channels must survive"
|
||||||
|
);
|
||||||
|
assert!(current.embeddings.enabled, "Embeddings must survive");
|
||||||
|
assert_eq!(current.embeddings.provider, "openai");
|
||||||
|
assert!(current.heartbeat.enabled, "Heartbeat must survive");
|
||||||
|
assert_eq!(current.heartbeat.interval_secs, 900);
|
||||||
|
assert_eq!(
|
||||||
|
current.database_backend.as_deref(),
|
||||||
|
Some("libsql"),
|
||||||
|
"DB backend must survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates `ironclaw onboard --channels-only` re-running on a fully
|
||||||
|
/// configured installation. Only channel settings should change;
|
||||||
|
/// provider, model, embeddings, heartbeat must survive.
|
||||||
|
#[test]
|
||||||
|
fn channels_only_rerun_preserves_unrelated_settings() {
|
||||||
|
let prior = Settings {
|
||||||
|
onboard_completed: true,
|
||||||
|
database_backend: Some("postgres".to_string()),
|
||||||
|
database_url: Some("postgres://host/db".to_string()),
|
||||||
|
llm_backend: Some("anthropic".to_string()),
|
||||||
|
selected_model: Some("claude-sonnet-4-5".to_string()),
|
||||||
|
embeddings: EmbeddingsSettings {
|
||||||
|
enabled: true,
|
||||||
|
provider: "nearai".to_string(),
|
||||||
|
model: "text-embedding-3-small".to_string(),
|
||||||
|
},
|
||||||
|
heartbeat: HeartbeatSettings {
|
||||||
|
enabled: true,
|
||||||
|
interval_secs: 1800,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
channels: ChannelSettings {
|
||||||
|
http_enabled: false,
|
||||||
|
wasm_channels: vec!["telegram".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
|
||||||
|
// channels_only mode: reconnect_existing_db loads from DB
|
||||||
|
let mut current = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// Simulate step_channels: user enables HTTP and adds discord
|
||||||
|
current.channels.http_enabled = true;
|
||||||
|
current.channels.http_port = Some(9090);
|
||||||
|
current.channels.wasm_channels = vec!["telegram".to_string(), "discord".to_string()];
|
||||||
|
|
||||||
|
// Verify: channels changed
|
||||||
|
assert!(current.channels.http_enabled);
|
||||||
|
assert_eq!(current.channels.http_port, Some(9090));
|
||||||
|
assert_eq!(current.channels.wasm_channels.len(), 2);
|
||||||
|
|
||||||
|
// Verify: everything else preserved
|
||||||
|
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
|
||||||
|
assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5"));
|
||||||
|
assert!(current.embeddings.enabled);
|
||||||
|
assert_eq!(current.embeddings.provider, "nearai");
|
||||||
|
assert!(current.heartbeat.enabled);
|
||||||
|
assert_eq!(current.heartbeat.interval_secs, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates quick mode re-run on an installation that previously
|
||||||
|
/// completed a full setup. Quick mode only touches DB + security +
|
||||||
|
/// provider + model; channels, embeddings, heartbeat, extensions
|
||||||
|
/// should survive via the merge_from ordering.
|
||||||
|
#[test]
|
||||||
|
fn quick_mode_rerun_preserves_prior_channels_and_heartbeat() {
|
||||||
|
let prior = Settings {
|
||||||
|
onboard_completed: true,
|
||||||
|
database_backend: Some("libsql".to_string()),
|
||||||
|
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
|
||||||
|
llm_backend: Some("openai".to_string()),
|
||||||
|
selected_model: Some("gpt-4o".to_string()),
|
||||||
|
channels: ChannelSettings {
|
||||||
|
http_enabled: true,
|
||||||
|
http_port: Some(8080),
|
||||||
|
signal_enabled: true,
|
||||||
|
wasm_channels: vec!["telegram".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
embeddings: EmbeddingsSettings {
|
||||||
|
enabled: true,
|
||||||
|
provider: "openai".to_string(),
|
||||||
|
model: "text-embedding-3-small".to_string(),
|
||||||
|
},
|
||||||
|
heartbeat: HeartbeatSettings {
|
||||||
|
enabled: true,
|
||||||
|
interval_secs: 600,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
let from_db = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// Quick mode flow:
|
||||||
|
// 1. auto_setup_database sets DB fields
|
||||||
|
let step1 = Settings {
|
||||||
|
database_backend: Some("libsql".to_string()),
|
||||||
|
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. try_load_existing_settings → merge DB → merge step1 on top
|
||||||
|
let mut current = step1.clone();
|
||||||
|
current.merge_from(&from_db);
|
||||||
|
current.merge_from(&step1);
|
||||||
|
|
||||||
|
// 3. step_inference_provider: user picks anthropic this time
|
||||||
|
current.llm_backend = Some("anthropic".to_string());
|
||||||
|
current.selected_model = None; // cleared because backend changed
|
||||||
|
|
||||||
|
// 4. step_model_selection: user picks model
|
||||||
|
current.selected_model = Some("claude-opus-4-6".to_string());
|
||||||
|
|
||||||
|
// Verify: provider/model updated
|
||||||
|
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
|
||||||
|
assert_eq!(current.selected_model.as_deref(), Some("claude-opus-4-6"));
|
||||||
|
|
||||||
|
// Verify: channels, embeddings, heartbeat survived quick mode
|
||||||
|
assert!(
|
||||||
|
current.channels.http_enabled,
|
||||||
|
"HTTP channel must survive quick mode re-run"
|
||||||
|
);
|
||||||
|
assert_eq!(current.channels.http_port, Some(8080));
|
||||||
|
assert!(
|
||||||
|
current.channels.signal_enabled,
|
||||||
|
"Signal must survive quick mode re-run"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
current.channels.wasm_channels,
|
||||||
|
vec!["telegram".to_string()],
|
||||||
|
"WASM channels must survive quick mode re-run"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
current.embeddings.enabled,
|
||||||
|
"Embeddings must survive quick mode re-run"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
current.heartbeat.enabled,
|
||||||
|
"Heartbeat must survive quick mode re-run"
|
||||||
|
);
|
||||||
|
assert_eq!(current.heartbeat.interval_secs, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full wizard re-run where user keeps the same provider. The model
|
||||||
|
/// selection from the prior run should be pre-populated (not reset).
|
||||||
|
///
|
||||||
|
/// Regression: re-running with the same provider should preserve model.
|
||||||
|
#[test]
|
||||||
|
fn full_rerun_same_provider_preserves_model_through_merge() {
|
||||||
|
let prior = Settings {
|
||||||
|
onboard_completed: true,
|
||||||
|
database_backend: Some("postgres".to_string()),
|
||||||
|
database_url: Some("postgres://host/db".to_string()),
|
||||||
|
llm_backend: Some("anthropic".to_string()),
|
||||||
|
selected_model: Some("claude-sonnet-4-5".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
let from_db = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// Step 1: user keeps same DB
|
||||||
|
let step1 = Settings {
|
||||||
|
database_backend: Some("postgres".to_string()),
|
||||||
|
database_url: Some("postgres://host/db".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut current = step1.clone();
|
||||||
|
current.merge_from(&from_db);
|
||||||
|
current.merge_from(&step1);
|
||||||
|
|
||||||
|
// After merge, prior settings recovered
|
||||||
|
assert_eq!(
|
||||||
|
current.llm_backend.as_deref(),
|
||||||
|
Some("anthropic"),
|
||||||
|
"Prior provider must be recovered from DB"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
current.selected_model.as_deref(),
|
||||||
|
Some("claude-sonnet-4-5"),
|
||||||
|
"Prior model must be recovered from DB"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: user picks same provider (anthropic)
|
||||||
|
// set_llm_backend_preserving_model checks if backend changed
|
||||||
|
let backend_changed = current.llm_backend.as_deref() != Some("anthropic");
|
||||||
|
current.llm_backend = Some("anthropic".to_string());
|
||||||
|
if backend_changed {
|
||||||
|
current.selected_model = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model should NOT be cleared since backend didn't change
|
||||||
|
assert_eq!(
|
||||||
|
current.selected_model.as_deref(),
|
||||||
|
Some("claude-sonnet-4-5"),
|
||||||
|
"Model must survive when re-selecting same provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full wizard re-run where user switches provider. Model should be
|
||||||
|
/// cleared since the old model is invalid for the new backend.
|
||||||
|
#[test]
|
||||||
|
fn full_rerun_different_provider_clears_model_through_merge() {
|
||||||
|
let prior = Settings {
|
||||||
|
onboard_completed: true,
|
||||||
|
database_backend: Some("postgres".to_string()),
|
||||||
|
database_url: Some("postgres://host/db".to_string()),
|
||||||
|
llm_backend: Some("anthropic".to_string()),
|
||||||
|
selected_model: Some("claude-sonnet-4-5".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
let from_db = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// Step 1 merge
|
||||||
|
let step1 = Settings {
|
||||||
|
database_backend: Some("postgres".to_string()),
|
||||||
|
database_url: Some("postgres://host/db".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut current = step1.clone();
|
||||||
|
current.merge_from(&from_db);
|
||||||
|
current.merge_from(&step1);
|
||||||
|
|
||||||
|
// Step 3: user switches to openai
|
||||||
|
let backend_changed = current.llm_backend.as_deref() != Some("openai");
|
||||||
|
assert!(backend_changed, "switching providers should be detected");
|
||||||
|
current.llm_backend = Some("openai".to_string());
|
||||||
|
if backend_changed {
|
||||||
|
current.selected_model = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(current.llm_backend.as_deref(), Some("openai"));
|
||||||
|
assert!(
|
||||||
|
current.selected_model.is_none(),
|
||||||
|
"Model must be cleared when switching providers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates incremental save correctness: persist_after_step after
|
||||||
|
/// Step 3 (provider) should not clobber settings set in Step 2 (security).
|
||||||
|
///
|
||||||
|
/// The wizard persists the full settings object after each step. This
|
||||||
|
/// test verifies that incremental saves are idempotent for prior steps.
|
||||||
|
#[test]
|
||||||
|
fn incremental_persist_does_not_clobber_prior_steps() {
|
||||||
|
// After steps 1-2, settings has DB + security
|
||||||
|
let after_step2 = Settings {
|
||||||
|
database_backend: Some("libsql".to_string()),
|
||||||
|
secrets_master_key_source: KeySource::Keychain,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// persist_after_step saves to DB
|
||||||
|
let db_map_after_step2 = after_step2.to_db_map();
|
||||||
|
|
||||||
|
// Step 3 adds provider
|
||||||
|
let mut after_step3 = after_step2.clone();
|
||||||
|
after_step3.llm_backend = Some("openai".to_string());
|
||||||
|
|
||||||
|
// persist_after_step saves again — the full settings object
|
||||||
|
let db_map_after_step3 = after_step3.to_db_map();
|
||||||
|
|
||||||
|
// Reload from DB after step 3
|
||||||
|
let restored = Settings::from_db_map(&db_map_after_step3);
|
||||||
|
|
||||||
|
// Step 2's settings must survive step 3's persist
|
||||||
|
assert_eq!(
|
||||||
|
restored.secrets_master_key_source,
|
||||||
|
KeySource::Keychain,
|
||||||
|
"Step 2 security setting must survive step 3 persist"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restored.database_backend.as_deref(),
|
||||||
|
Some("libsql"),
|
||||||
|
"Step 1 DB setting must survive step 3 persist"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restored.llm_backend.as_deref(),
|
||||||
|
Some("openai"),
|
||||||
|
"Step 3 provider setting must be saved"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also verify that a partial step 2 reload doesn't regress
|
||||||
|
// (loading the step 2 snapshot and merging with step 3 state)
|
||||||
|
let from_step2_db = Settings::from_db_map(&db_map_after_step2);
|
||||||
|
let mut merged = after_step3.clone();
|
||||||
|
merged.merge_from(&from_step2_db);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
merged.llm_backend.as_deref(),
|
||||||
|
Some("openai"),
|
||||||
|
"Step 3 provider must not be clobbered by step 2 snapshot merge"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged.secrets_master_key_source,
|
||||||
|
KeySource::Keychain,
|
||||||
|
"Step 2 security must survive merge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switching database backend should allow fresh connection settings.
|
||||||
|
/// When user switches from postgres to libsql, the old database_url
|
||||||
|
/// should not prevent the new libsql_path from being used.
|
||||||
|
#[test]
|
||||||
|
fn switching_db_backend_allows_fresh_connection_settings() {
|
||||||
|
let prior = Settings {
|
||||||
|
database_backend: Some("postgres".to_string()),
|
||||||
|
database_url: Some("postgres://host/db".to_string()),
|
||||||
|
llm_backend: Some("openai".to_string()),
|
||||||
|
selected_model: Some("gpt-4o".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
let from_db = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// User picks libsql this time, wizard clears stale postgres settings
|
||||||
|
let step1 = Settings {
|
||||||
|
database_backend: Some("libsql".to_string()),
|
||||||
|
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
|
||||||
|
database_url: None, // explicitly not set for libsql
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut current = step1.clone();
|
||||||
|
current.merge_from(&from_db);
|
||||||
|
current.merge_from(&step1);
|
||||||
|
|
||||||
|
// libsql chosen
|
||||||
|
assert_eq!(current.database_backend.as_deref(), Some("libsql"));
|
||||||
|
assert_eq!(
|
||||||
|
current.libsql_path.as_deref(),
|
||||||
|
Some("/home/user/.ironclaw/ironclaw.db")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prior provider/model should survive (unrelated to DB switch)
|
||||||
|
assert_eq!(current.llm_backend.as_deref(), Some("openai"));
|
||||||
|
assert_eq!(current.selected_model.as_deref(), Some("gpt-4o"));
|
||||||
|
|
||||||
|
// Note: database_url from prior run persists in merge because
|
||||||
|
// step1.database_url is None (== default), so merge_from doesn't
|
||||||
|
// override it. This is expected — the .env writer decides which
|
||||||
|
// vars to emit based on database_backend. The stale URL is
|
||||||
|
// harmless because the libsql backend ignores it.
|
||||||
|
assert_eq!(
|
||||||
|
current.database_url.as_deref(),
|
||||||
|
Some("postgres://host/db"),
|
||||||
|
"stale database_url persists (harmless, ignored by libsql backend)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: merge_from must handle boolean fields correctly.
|
||||||
|
/// A prior run with heartbeat.enabled=true must not be reset to false
|
||||||
|
/// when merging with a Settings that has heartbeat.enabled=false (default).
|
||||||
|
#[test]
|
||||||
|
fn merge_preserves_true_booleans_when_overlay_has_default_false() {
|
||||||
|
let prior = Settings {
|
||||||
|
heartbeat: HeartbeatSettings {
|
||||||
|
enabled: true,
|
||||||
|
interval_secs: 600,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
channels: ChannelSettings {
|
||||||
|
http_enabled: true,
|
||||||
|
signal_enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
let from_db = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// New wizard run only sets DB (everything else is default/false)
|
||||||
|
let step1 = Settings {
|
||||||
|
database_backend: Some("libsql".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut current = step1.clone();
|
||||||
|
current.merge_from(&from_db);
|
||||||
|
current.merge_from(&step1);
|
||||||
|
|
||||||
|
// true booleans from prior run must survive
|
||||||
|
assert!(
|
||||||
|
current.heartbeat.enabled,
|
||||||
|
"heartbeat.enabled=true must not be reset to false by default overlay"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
current.channels.http_enabled,
|
||||||
|
"http_enabled=true must not be reset to false by default overlay"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
current.channels.signal_enabled,
|
||||||
|
"signal_enabled=true must not be reset to false by default overlay"
|
||||||
|
);
|
||||||
|
assert_eq!(current.heartbeat.interval_secs, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: embeddings settings (provider, model, enabled) must
|
||||||
|
/// survive a wizard re-run that doesn't touch step 5.
|
||||||
|
#[test]
|
||||||
|
fn embeddings_survive_rerun_that_skips_step5() {
|
||||||
|
let prior = Settings {
|
||||||
|
onboard_completed: true,
|
||||||
|
llm_backend: Some("nearai".to_string()),
|
||||||
|
selected_model: Some("qwen".to_string()),
|
||||||
|
embeddings: EmbeddingsSettings {
|
||||||
|
enabled: true,
|
||||||
|
provider: "nearai".to_string(),
|
||||||
|
model: "text-embedding-3-large".to_string(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let db_map = prior.to_db_map();
|
||||||
|
let from_db = Settings::from_db_map(&db_map);
|
||||||
|
|
||||||
|
// Full re-run: step 1 only sets DB
|
||||||
|
let step1 = Settings {
|
||||||
|
database_backend: Some("libsql".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut current = step1.clone();
|
||||||
|
current.merge_from(&from_db);
|
||||||
|
current.merge_from(&step1);
|
||||||
|
|
||||||
|
// Before step 5 (embeddings) runs, check that prior values are present
|
||||||
|
assert!(current.embeddings.enabled);
|
||||||
|
assert_eq!(current.embeddings.provider, "nearai");
|
||||||
|
assert_eq!(current.embeddings.model, "text-embedding-3-large");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-13
@@ -114,6 +114,13 @@ Step 9: Background Tasks (heartbeat)
|
|||||||
|
|
||||||
**Goal:** Select backend, establish connection, run migrations.
|
**Goal:** Select backend, establish connection, run migrations.
|
||||||
|
|
||||||
|
**Init delegation:** Backend-specific connection logic lives in `src/db/mod.rs`
|
||||||
|
(`connect_without_migrations()`), not in the wizard. The wizard calls
|
||||||
|
`test_database_connection()` which delegates to the db module factory. Feature-flag
|
||||||
|
branching (`#[cfg(feature = ...)]`) is confined to `src/db/mod.rs`. PostgreSQL
|
||||||
|
validation (version >= 15, pgvector) is handled by `validate_postgres()` in
|
||||||
|
`src/db/mod.rs`.
|
||||||
|
|
||||||
**Decision tree:**
|
**Decision tree:**
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -121,26 +128,23 @@ Both features compiled?
|
|||||||
├─ Yes → DATABASE_BACKEND env var set?
|
├─ Yes → DATABASE_BACKEND env var set?
|
||||||
│ ├─ Yes → use that backend
|
│ ├─ Yes → use that backend
|
||||||
│ └─ No → interactive selection (PostgreSQL vs libSQL)
|
│ └─ No → interactive selection (PostgreSQL vs libSQL)
|
||||||
├─ Only postgres feature → step_database_postgres()
|
├─ Only postgres feature → prompt for DATABASE_URL, test connection
|
||||||
└─ Only libsql feature → step_database_libsql()
|
└─ Only libsql feature → prompt for path, test connection
|
||||||
```
|
```
|
||||||
|
|
||||||
**PostgreSQL path** (`step_database_postgres`):
|
**PostgreSQL path:**
|
||||||
1. Check `DATABASE_URL` from env or settings
|
1. Check `DATABASE_URL` from env or settings
|
||||||
2. Test connection (creates `deadpool_postgres::Pool`)
|
2. Test connection via `connect_without_migrations()` (validates version, pgvector)
|
||||||
3. Optionally run refinery migrations
|
3. Optionally run migrations
|
||||||
4. Store pool in `self.db_pool`
|
|
||||||
|
|
||||||
**libSQL path** (`step_database_libsql`):
|
**libSQL path:**
|
||||||
1. Offer local path (default: `~/.ironclaw/ironclaw.db`)
|
1. Offer local path (default: `~/.ironclaw/ironclaw.db`)
|
||||||
2. Optional Turso cloud sync (URL + auth token)
|
2. Optional Turso cloud sync (URL + auth token)
|
||||||
3. Test connection (creates `LibSqlBackend`)
|
3. Test connection via `connect_without_migrations()`
|
||||||
4. Always run migrations (idempotent CREATE IF NOT EXISTS)
|
4. Always run migrations (idempotent CREATE IF NOT EXISTS)
|
||||||
5. Store backend in `self.db_backend`
|
|
||||||
|
|
||||||
**Invariant:** After Step 1, exactly one of `self.db_pool` or
|
**Invariant:** After Step 1, `self.db` is `Some(Arc<dyn Database>)`.
|
||||||
`self.db_backend` is `Some`. This is required for settings persistence
|
This is required for settings persistence in `save_and_summarize()`.
|
||||||
in `save_and_summarize()`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -338,7 +342,7 @@ key first, then falls back to the standard env var.
|
|||||||
1. Check `self.secrets_crypto` (set in Step 2) → use if available
|
1. Check `self.secrets_crypto` (set in Step 2) → use if available
|
||||||
2. Else try `SECRETS_MASTER_KEY` env var
|
2. Else try `SECRETS_MASTER_KEY` env var
|
||||||
3. Else try `get_master_key()` from keychain (only in `channels_only` mode)
|
3. Else try `get_master_key()` from keychain (only in `channels_only` mode)
|
||||||
4. Create backend-appropriate secrets store (respects selected database backend)
|
4. Create secrets store using `self.db` (`Arc<dyn Database>`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+253
-978
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user