mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:10:11 +00:00
* fix(setup): remove redundant LLM vars and API keys from bootstrap .env Only true chicken-and-egg vars belong in ~/.ironclaw/.env — things needed to connect to the DB or decrypt secrets (DATABASE_BACKEND, DATABASE_URL, LIBSQL_PATH, SECRETS_MASTER_KEY, ONBOARD_COMPLETED). LLM settings (LLM_BACKEND, LLM_BASE_URL, OLLAMA_BASE_URL, model name, provider-specific URLs) are persisted to the DB via persist_settings() and loaded by Config::from_db_with_toml() after connection. API keys are stored encrypted in the secrets DB and injected via inject_llm_keys_from_secrets(). Writing them as plaintext to .env was redundant and a security regression. Also fixes for_model_discovery() and build_nearai_model_fetch_config() to use env_or_override() instead of std::env::var(), so they can read NEARAI_API_KEY from the thread-safe overlay during the onboarding wizard (where inject_single_var() sets the key after the user enters it). Also fixes incorrect secret names in README (anthropic_api_key → llm_anthropic_api_key, openai_api_key → llm_openai_api_key). Supersedes #266 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add missing fallback_deliverable field to job_monitor tests Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * docs: address review comments on bootstrap .env and README - Update write_bootstrap_env() docstring to reflect current behavior (no LLM vars, no credentials) - Fix Layer 1 .env examples in README to remove LLM_BACKEND/LLM_BASE_URL - Fix legacy secret name in README example (anthropic_api_key → llm_anthropic_api_key) - Document channel/sandbox vars in bootstrap vars list - Add cleanup comment in test explaining empty-value-as-unset behavior Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
353 lines
10 KiB
Rust
353 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 = crate::config::helpers::env_or_override("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,
|
|
cheap_model: None,
|
|
smart_routing_cascade: false,
|
|
openai_codex: None,
|
|
}
|
|
}
|