This commit is contained in:
2026-03-30 19:20:47 +08:00
parent 3c4095c7a1
commit 3bece7ec47
4 changed files with 116 additions and 73 deletions
+46 -10
View File
@@ -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( fn resolve_model(
env_var: &str, env_var: &str,
settings: &Settings, settings: &Settings,
default: &str, default: &str,
) -> Result<String, ConfigError> { ) -> Result<String, ConfigError> {
Ok(optional_env(env_var)? if let Some(model) = settings.selected_model.clone() {
.or_else(|| settings.selected_model.clone()) Ok(model)
.unwrap_or_else(|| default.to_string())) } else if let Some(model) = optional_env(env_var)? {
Ok(model)
} else {
Ok(default.to_string())
}
} }
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> { pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
let registry = ProviderRegistry::load(); let registry = ProviderRegistry::load();
// Determine backend: env var > settings > default ("nearai") // Determine backend: db settings > env var > default ("nearai")
let backend = if let Some(b) = optional_env("LLM_BACKEND")? { let (backend, backend_source) = if let Some(ref b) = settings.llm_backend {
b (b.clone(), "db:llm_backend")
} else if let Some(ref b) = settings.llm_backend { } else if let Some(b) = optional_env("LLM_BACKEND")? {
b.clone() (b, "env:LLM_BACKEND")
} else { } 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 // Validate the backend is known
let backend_lower = backend.to_lowercase(); let backend_lower = backend.to_lowercase();
+11 -8
View File
@@ -204,19 +204,22 @@ impl Config {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
crate::bootstrap::load_optimclaw_env(); crate::bootstrap::load_optimclaw_env();
// Load all settings from DB into a Settings struct // Start with TOML config as a base (lowest priority among the two).
let mut db_settings = match store.get_all_settings(user_id).await { let mut settings = Settings::default();
Ok(map) => Settings::from_db_map(&map), 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) => { Err(e) => {
tracing::warn!("Failed to load settings from DB, using defaults: {}", e); tracing::warn!("Failed to load settings from DB, using defaults: {}", e);
Settings::default()
} }
}; };
// Overlay TOML config file (values win over DB settings) Self::build(&settings).await
Self::apply_toml_overlay(&mut db_settings, toml_path)?;
Self::build(&db_settings).await
} }
/// Load configuration from environment variables only (no database). /// Load configuration from environment variables only (no database).
+44 -50
View File
@@ -179,7 +179,7 @@ fn create_registry_provider(
} }
match config.protocol { 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::Anthropic => create_anthropic_from_registry(config),
ProviderProtocol::Ollama => create_ollama_from_registry(config), ProviderProtocol::Ollama => create_ollama_from_registry(config),
ProviderProtocol::GithubCopilot => { ProviderProtocol::GithubCopilot => {
@@ -247,71 +247,65 @@ async fn create_bedrock_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvid
fn create_openai_compat_from_registry( fn create_openai_compat_from_registry(
config: &RegistryProviderConfig, config: &RegistryProviderConfig,
request_timeout_secs: u64,
) -> Result<Arc<dyn LlmProvider>, LlmError> { ) -> Result<Arc<dyn LlmProvider>, LlmError> {
use rig::providers::openai; // Use NearAiChatProvider (direct HTTP client) instead of rig-core here.
// rig-core serialises message content as JSON arrays
let mut extra_headers = reqwest::header::HeaderMap::new(); // (`[{"type":"text","text":"..."}]`) which many local/simple OpenAI-compatible
for (key, value) in &config.extra_headers { // servers (e.g. optimllm) reject with a 422. NearAiChatProvider always
let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) { // sends content as a plain string, which every compliant server accepts.
Ok(n) => n, let nearai_config = NearAiConfig {
Err(e) => { model: config.model.clone(),
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name"); base_url: config.base_url.clone(),
continue; 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 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);
}
let api_key = config // NearAiChatProvider uses the api_key path when api_key is Some, otherwise
.api_key // it falls through to interactive NearAI OAuth — which we never want here.
.as_ref() // Ensure there's always an api_key so we stay on the plain Bearer-token path.
.map(|k| k.expose_secret().to_string()) // Local servers (e.g. optimllm) ignore the Authorization header entirely.
.unwrap_or_else(|| { if nearai_config.api_key.is_none() {
tracing::warn!( tracing::warn!(
provider = %config.provider_id, provider = %config.provider_id,
"No API key configured for {}. Requests will likely fail with 401. \ "No API key configured for {}; using 'no-key' placeholder. \
Check your .env or secrets store.", Requests to auth-required endpoints will fail with 401.",
config.provider_id, 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 nearai_config = NearAiConfig {
api_key: Some(nearai_config.api_key.unwrap_or_else(|| {
use secrecy::SecretString;
SecretString::new("no-key".into())
})),
..nearai_config
};
let client: openai::Client = builder.build().map_err(|e| LlmError::RequestFailed { // Session manager is required by the constructor signature but never used:
provider: config.provider_id.clone(), // NearAiChatProvider only calls the session manager when api_key is None,
reason: format!("Failed to create OpenAI-compatible client: {e}"), // and we always set one above.
})?; let session = Arc::new(SessionManager::new(crate::llm::SessionConfig::default()));
// 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);
tracing::debug!( tracing::debug!(
provider = %config.provider_id, provider = %config.provider_id,
model = %config.model, model = %config.model,
base_url = %config.base_url, base_url = %config.base_url,
"Using OpenAI-compatible provider" "Using OpenAI-compatible provider (plain-string content)"
); );
let adapter = RigAdapter::new(model, &config.model) // flatten_tool_messages=false: send proper role:"tool" messages (OpenAI spec).
.with_unsupported_params(config.unsupported_params.clone()); let provider = NearAiChatProvider::new_with_options(nearai_config, session, false, request_timeout_secs)?;
Ok(Arc::new(adapter)) Ok(Arc::new(provider))
} }
fn create_anthropic_from_registry( fn create_anthropic_from_registry(
+10
View File
@@ -301,6 +301,10 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
} }
crate::llm::Role::User => { crate::llm::Role::User => {
if msg.content_parts.is_empty() { 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)); history.push(RigMessage::user(&msg.content));
} else { } else {
// Build multimodal user message with text + image parts // Build multimodal user message with text + image parts
@@ -364,6 +368,12 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
history.push(RigMessage::assistant(&msg.content)); history.push(RigMessage::assistant(&msg.content));
} }
} else { } else {
// Skip empty assistant messages — these occur when thinking-tag stripping
// leaves a blank response; sending "content": "" causes 400 on strict
// OpenAI-compatible providers (e.g. Kimi).
if msg.content.is_empty() {
continue;
}
history.push(RigMessage::assistant(&msg.content)); history.push(RigMessage::assistant(&msg.content));
} }
} }