mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +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]> * fix: Telegram bot token validation fails intermittently (HTTP 404) (#1166) * fix: Telegram bot token validation fails intermittently (HTTP 404) * fix: code style * fix * fix * fix * review fix --------- Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: Nick Pismenkov <[email protected]>
350 lines
10 KiB
Rust
350 lines
10 KiB
Rust
//! 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,
|
|
}
|
|
}
|