mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
fix: persist OpenAI-compatible provider and respect embeddings disable (#177)
* fix: persist OpenAI-compatible provider and respect embeddings disable (#129) Three interrelated bugs caused the agent to ignore user choices made during onboarding when using an OpenAI-compatible LLM provider: 1. Session auth ran before DB config reload, so Config::from_env() defaulted to NearAi and attempted Clerk auth before the real backend was known. Moved session auth to after final config resolution. 2. EmbeddingsConfig::resolve() force-enabled embeddings whenever OPENAI_API_KEY was present, ignoring the user's explicit disable. Changed to respect the stored setting as source of truth. 3. LLM_BACKEND was not saved to the bootstrap .env file, so Config::from_env() always defaulted to NearAi before the DB was connected. Now saves LLM_BACKEND, LLM_BASE_URL, and OLLAMA_BASE_URL alongside the database bootstrap vars. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add SAFETY comments and sanitize .env value escaping Address PR review feedback: - Add SAFETY comments to all unsafe env var manipulation in config tests (gemini-code-assist). - Escape backslashes and double quotes in save_bootstrap_env() to prevent env var injection via malicious URLs (gemini-code-assist). - Add test verifying injection attempt is neutralized. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: incorporate PR #138 changes (chat completions, model sorting, tool schemas) Includes all changes from bigguybobby's PR #138: - Use Chat Completions API for OpenAI-compatible providers (avoids Responses API assumptions like required tool call IDs) - Fall back to settings.selected_model when LLM_MODEL env var is unset - Update OpenAI model list (add gpt-5 family) with priority-based sorting - Add is_openai_chat_model() filter with broader exclusion patterns - Fix http tool: headers schema → array of {name,value}, body → string type, parse_headers_param() accepts both legacy object and array formats - Fix json tool: data schema → string type, parse_json_input() normalizer, validate uses strict string-only check - Add mutex-serialized config tests for env var manipulation - Update NEAR AI config comment for accuracy Co-Authored-By: Bobby (bigguybobby) <[email protected]> Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Bobby (bigguybobby) <[email protected]>
This commit is contained in:
co-authored by
Illia Polosukhin
Claude Opus 4.6
Bobby
parent
c3340c60ef
commit
750a94030b
+15
-2
@@ -299,16 +299,26 @@ Contains only the settings needed BEFORE database connection. Written by
|
||||
```env
|
||||
DATABASE_BACKEND="libsql"
|
||||
LIBSQL_PATH="/Users/name/.ironclaw/ironclaw.db"
|
||||
LLM_BACKEND="openai_compatible"
|
||||
LLM_BASE_URL="http://my-vllm:8000/v1"
|
||||
```
|
||||
|
||||
Or for PostgreSQL:
|
||||
Or for PostgreSQL + NEAR AI:
|
||||
```env
|
||||
DATABASE_BACKEND="postgres"
|
||||
DATABASE_URL="postgres://user:pass@localhost/ironclaw"
|
||||
LLM_BACKEND="nearai"
|
||||
```
|
||||
|
||||
Or for Ollama:
|
||||
```env
|
||||
LLM_BACKEND="ollama"
|
||||
OLLAMA_BASE_URL="http://localhost:11434"
|
||||
```
|
||||
|
||||
**Why separate?** Chicken-and-egg: you need `DATABASE_BACKEND` to know
|
||||
which database to connect to, so it can't be stored in the database.
|
||||
which database to connect to, and `LLM_BACKEND` to know whether to
|
||||
attempt NEAR AI session auth -- neither can be stored in the database.
|
||||
|
||||
**Layer 2: Database settings table** (everything else)
|
||||
|
||||
@@ -339,6 +349,9 @@ Final step of the wizard:
|
||||
- DATABASE_URL (if postgres)
|
||||
- LIBSQL_PATH (if libsql)
|
||||
- LIBSQL_URL (if turso sync)
|
||||
- LLM_BACKEND (always, when set)
|
||||
- LLM_BASE_URL (if openai_compatible)
|
||||
- OLLAMA_BASE_URL (if ollama)
|
||||
4. Print configuration summary
|
||||
```
|
||||
|
||||
|
||||
+115
-10
@@ -1530,6 +1530,18 @@ impl SetupWizard {
|
||||
env_vars.push(("LIBSQL_URL", url.clone()));
|
||||
}
|
||||
|
||||
// LLM bootstrap vars: same chicken-and-egg problem as DATABASE_BACKEND.
|
||||
// Config::from_env() needs the backend before the DB is connected.
|
||||
if let Some(ref backend) = self.settings.llm_backend {
|
||||
env_vars.push(("LLM_BACKEND", backend.clone()));
|
||||
}
|
||||
if let Some(ref url) = self.settings.openai_compatible_base_url {
|
||||
env_vars.push(("LLM_BASE_URL", url.clone()));
|
||||
}
|
||||
if let Some(ref url) = self.settings.ollama_base_url {
|
||||
env_vars.push(("OLLAMA_BASE_URL", url.clone()));
|
||||
}
|
||||
|
||||
if !env_vars.is_empty() {
|
||||
let pairs: Vec<(&str, &str)> =
|
||||
env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
@@ -1764,8 +1776,10 @@ async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String
|
||||
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
|
||||
async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> {
|
||||
let static_defaults = vec![
|
||||
("gpt-5".into(), "GPT-5 (flagship)".into()),
|
||||
("gpt-5-mini".into(), "GPT-5 Mini (fast)".into()),
|
||||
("gpt-4.1".into(), "GPT-4.1".into()),
|
||||
("gpt-4o".into(), "GPT-4o".into()),
|
||||
("gpt-4o-mini".into(), "GPT-4o Mini (fast)".into()),
|
||||
("o3".into(), "o3 (reasoning)".into()),
|
||||
];
|
||||
|
||||
@@ -1800,19 +1814,12 @@ async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)>
|
||||
data: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
// Prefixes that indicate chat-relevant models
|
||||
let chat_prefixes = ["gpt-4", "gpt-3.5", "o1", "o3", "o4", "chatgpt"];
|
||||
|
||||
match resp.json::<ModelsResponse>().await {
|
||||
Ok(body) => {
|
||||
let mut models: Vec<(String, String)> = body
|
||||
.data
|
||||
.into_iter()
|
||||
.filter(|m| {
|
||||
chat_prefixes.iter().any(|p| m.id.starts_with(p))
|
||||
&& !m.id.contains("realtime")
|
||||
&& !m.id.contains("audio")
|
||||
})
|
||||
.filter(|m| is_openai_chat_model(&m.id))
|
||||
.map(|m| {
|
||||
let label = m.id.clone();
|
||||
(m.id, label)
|
||||
@@ -1821,13 +1828,74 @@ async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)>
|
||||
if models.is_empty() {
|
||||
return static_defaults;
|
||||
}
|
||||
models.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
sort_openai_models(&mut models);
|
||||
models
|
||||
}
|
||||
Err(_) => static_defaults,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn openai_model_priority(model_id: &str) -> usize {
|
||||
let id = model_id.to_ascii_lowercase();
|
||||
|
||||
const EXACT_PRIORITY: &[&str] = &[
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
"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-", "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
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -2158,12 +2226,49 @@ mod tests {
|
||||
let _guard = EnvGuard::clear("OPENAI_API_KEY");
|
||||
let models = fetch_openai_models(None).await;
|
||||
assert!(!models.is_empty());
|
||||
assert_eq!(models[0].0, "gpt-5");
|
||||
assert!(
|
||||
models.iter().any(|(id, _)| id.contains("gpt")),
|
||||
"static defaults should include a GPT model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_openai_chat_model_includes_gpt5_and_filters_non_chat_variants() {
|
||||
assert!(is_openai_chat_model("gpt-5"));
|
||||
assert!(is_openai_chat_model("gpt-5-mini-2026-01-01"));
|
||||
assert!(is_openai_chat_model("o3-2025-04-16"));
|
||||
assert!(!is_openai_chat_model("chatgpt-image-latest"));
|
||||
assert!(!is_openai_chat_model("gpt-4o-realtime-preview"));
|
||||
assert!(!is_openai_chat_model("gpt-4o-mini-transcribe"));
|
||||
assert!(!is_openai_chat_model("text-embedding-3-large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_openai_models_prioritizes_best_models_first() {
|
||||
let mut models = vec![
|
||||
("gpt-4o-mini".to_string(), "gpt-4o-mini".to_string()),
|
||||
("gpt-5-mini".to_string(), "gpt-5-mini".to_string()),
|
||||
("o3".to_string(), "o3".to_string()),
|
||||
("gpt-4.1".to_string(), "gpt-4.1".to_string()),
|
||||
("gpt-5".to_string(), "gpt-5".to_string()),
|
||||
];
|
||||
|
||||
sort_openai_models(&mut models);
|
||||
|
||||
let ordered: Vec<String> = models.into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(
|
||||
ordered,
|
||||
vec![
|
||||
"gpt-5".to_string(),
|
||||
"gpt-5-mini".to_string(),
|
||||
"o3".to_string(),
|
||||
"gpt-4.1".to_string(),
|
||||
"gpt-4o-mini".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_ollama_models_unreachable_fallback() {
|
||||
// Point at a port nothing listens on
|
||||
|
||||
Reference in New Issue
Block a user