From 3bece7ec47add1967fa9a3c886dd62961b3ee333 Mon Sep 17 00:00:00 2001 From: OutBackDingo Date: Mon, 30 Mar 2026 19:20:47 +0800 Subject: [PATCH] updated --- src/config/llm.rs | 56 ++++++++++++++++++---- src/config/mod.rs | 19 ++++---- src/llm/mod.rs | 104 +++++++++++++++++++---------------------- src/llm/rig_adapter.rs | 10 ++++ 4 files changed, 116 insertions(+), 73 deletions(-) diff --git a/src/config/llm.rs b/src/config/llm.rs index 8cc9f04a..d4517b22 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -46,28 +46,64 @@ impl LlmConfig { } } - /// Resolve a model name from env var -> settings.selected_model -> hardcoded default. + /// Resolve a model name from settings.selected_model -> env var -> hardcoded default. fn resolve_model( env_var: &str, settings: &Settings, default: &str, ) -> Result { - Ok(optional_env(env_var)? - .or_else(|| settings.selected_model.clone()) - .unwrap_or_else(|| default.to_string())) + if let Some(model) = settings.selected_model.clone() { + Ok(model) + } else if let Some(model) = optional_env(env_var)? { + Ok(model) + } else { + Ok(default.to_string()) + } } pub(crate) fn resolve(settings: &Settings) -> Result { let registry = ProviderRegistry::load(); - // Determine backend: env var > settings > default ("nearai") - let backend = if let Some(b) = optional_env("LLM_BACKEND")? { - b - } else if let Some(ref b) = settings.llm_backend { - b.clone() + // Determine backend: db settings > env var > default ("nearai") + let (backend, backend_source) = if let Some(ref b) = settings.llm_backend { + (b.clone(), "db:llm_backend") + } else if let Some(b) = optional_env("LLM_BACKEND")? { + (b, "env:LLM_BACKEND") } else { - "nearai".to_string() + ("nearai".to_string(), "default") }; + tracing::info!( + backend = %backend, + source = %backend_source, + db_llm_backend = ?settings.llm_backend, + "Resolving LLM backend" + ); + // Warn operators when a DB-persisted value silently overrides LLM_BACKEND. + if backend_source == "db:llm_backend" + && let Ok(env_val) = std::env::var("LLM_BACKEND") + && !env_val.is_empty() + { + tracing::warn!( + db_value = %backend, + env_value = %env_val, + "LLM_BACKEND env var is set but DB setting takes priority. \ + Unset llm_backend in the DB (via settings UI) to use the env var." + ); + } + + // Validate the backend is known + // Warn operators when a DB-persisted value silently overrides LLM_BACKEND. + if backend_source == "db:llm_backend" + && let Ok(env_val) = std::env::var("LLM_BACKEND") + && !env_val.is_empty() + { + tracing::warn!( + db_value = %backend, + env_value = %env_val, + "LLM_BACKEND env var is set but DB setting takes priority. \ + Unset llm_backend in the DB (via settings UI) to use the env var." + ); + } // Validate the backend is known let backend_lower = backend.to_lowercase(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 027b2884..8d298ea6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -204,19 +204,22 @@ impl Config { let _ = dotenvy::dotenv(); crate::bootstrap::load_optimclaw_env(); - // Load all settings from DB into a Settings struct - let mut db_settings = match store.get_all_settings(user_id).await { - Ok(map) => Settings::from_db_map(&map), + // Start with TOML config as a base (lowest priority among the two). + let mut settings = Settings::default(); + Self::apply_toml_overlay(&mut settings, toml_path)?; + + // Overlay DB settings on top so DB values win over TOML. + match store.get_all_settings(user_id).await { + Ok(map) => { + let db_settings = Settings::from_db_map(&map); + settings.merge_from(&db_settings); + } Err(e) => { tracing::warn!("Failed to load settings from DB, using defaults: {}", e); - Settings::default() } }; - // Overlay TOML config file (values win over DB settings) - Self::apply_toml_overlay(&mut db_settings, toml_path)?; - - Self::build(&db_settings).await + Self::build(&settings).await } /// Load configuration from environment variables only (no database). diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 77accad1..7cda8739 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -179,7 +179,7 @@ fn create_registry_provider( } match config.protocol { - ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config), + ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config, request_timeout_secs), ProviderProtocol::Anthropic => create_anthropic_from_registry(config), ProviderProtocol::Ollama => create_ollama_from_registry(config), ProviderProtocol::GithubCopilot => { @@ -247,71 +247,65 @@ async fn create_bedrock_provider(config: &LlmConfig) -> Result Result, LlmError> { - use rig::providers::openai; + // Use NearAiChatProvider (direct HTTP client) instead of rig-core here. + // rig-core serialises message content as JSON arrays + // (`[{"type":"text","text":"..."}]`) which many local/simple OpenAI-compatible + // servers (e.g. optimllm) reject with a 422. NearAiChatProvider always + // sends content as a plain string, which every compliant server accepts. + let nearai_config = NearAiConfig { + model: config.model.clone(), + base_url: config.base_url.clone(), + api_key: config.api_key.clone(), + cheap_model: None, + fallback_model: None, + max_retries: 0, + 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, + }; - let mut extra_headers = reqwest::header::HeaderMap::new(); - for (key, value) in &config.extra_headers { - let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) { - Ok(n) => n, - Err(e) => { - tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name"); - continue; - } - }; - let val = match reqwest::header::HeaderValue::from_str(value) { - Ok(v) => v, - Err(e) => { - tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value"); - continue; - } - }; - extra_headers.insert(name, val); + // NearAiChatProvider uses the api_key path when api_key is Some, otherwise + // it falls through to interactive NearAI OAuth — which we never want here. + // Ensure there's always an api_key so we stay on the plain Bearer-token path. + // Local servers (e.g. optimllm) ignore the Authorization header entirely. + if nearai_config.api_key.is_none() { + tracing::warn!( + provider = %config.provider_id, + "No API key configured for {}; using 'no-key' placeholder. \ + Requests to auth-required endpoints will fail with 401.", + config.provider_id, + ); } + let nearai_config = NearAiConfig { + api_key: Some(nearai_config.api_key.unwrap_or_else(|| { + use secrecy::SecretString; + SecretString::new("no-key".into()) + })), + ..nearai_config + }; - let api_key = config - .api_key - .as_ref() - .map(|k| k.expose_secret().to_string()) - .unwrap_or_else(|| { - tracing::warn!( - provider = %config.provider_id, - "No API key configured for {}. Requests will likely fail with 401. \ - Check your .env or secrets store.", - config.provider_id, - ); - "no-key".to_string() - }); - - let mut builder = openai::Client::builder().api_key(&api_key); - if !config.base_url.is_empty() { - builder = builder.base_url(&config.base_url); - } - if !extra_headers.is_empty() { - builder = builder.http_headers(extra_headers); - } - - let client: openai::Client = builder.build().map_err(|e| LlmError::RequestFailed { - provider: config.provider_id.clone(), - reason: format!("Failed to create OpenAI-compatible client: {e}"), - })?; - - // Use CompletionsClient (Chat Completions API) instead of the default - // Client (Responses API). The Responses API path in rig-core handles - // tool results differently, which breaks OptimClaw's tool call flow. - let client = client.completions_api(); - let model = client.completion_model(&config.model); + // Session manager is required by the constructor signature but never used: + // NearAiChatProvider only calls the session manager when api_key is None, + // and we always set one above. + let session = Arc::new(SessionManager::new(crate::llm::SessionConfig::default())); tracing::debug!( provider = %config.provider_id, model = %config.model, base_url = %config.base_url, - "Using OpenAI-compatible provider" + "Using OpenAI-compatible provider (plain-string content)" ); - let adapter = RigAdapter::new(model, &config.model) - .with_unsupported_params(config.unsupported_params.clone()); - Ok(Arc::new(adapter)) + // flatten_tool_messages=false: send proper role:"tool" messages (OpenAI spec). + let provider = NearAiChatProvider::new_with_options(nearai_config, session, false, request_timeout_secs)?; + Ok(Arc::new(provider)) } fn create_anthropic_from_registry( diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs index c6b6c995..1edcdfb1 100644 --- a/src/llm/rig_adapter.rs +++ b/src/llm/rig_adapter.rs @@ -301,6 +301,10 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec { if msg.content_parts.is_empty() { + // Skip empty user messages — some providers (e.g. Kimi) reject "content": "" + if msg.content.is_empty() { + continue; + } history.push(RigMessage::user(&msg.content)); } else { // Build multimodal user message with text + image parts @@ -364,6 +368,12 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec