mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a23ab41c | ||
|
|
19dcaad6cf | ||
|
|
6ef8bc28eb |
@@ -10,7 +10,9 @@ use std::borrow::Cow;
|
||||
|
||||
use crate::agent::session::PendingApproval;
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, FinishReason, Reasoning, ReasoningContext, RespondResult};
|
||||
use crate::llm::{
|
||||
ChatMessage, FinishReason, Reasoning, ReasoningContext, RespondResult, ResponseMetadata,
|
||||
};
|
||||
|
||||
/// Signal from the delegate indicating how the loop should proceed.
|
||||
pub enum LoopSignal {
|
||||
@@ -38,6 +40,8 @@ pub enum LoopOutcome {
|
||||
Stopped,
|
||||
/// Max iterations exceeded.
|
||||
MaxIterations,
|
||||
/// Loop terminated early with a clear failure reason.
|
||||
Failure(String),
|
||||
/// A tool requires user approval before continuing (chat delegate only).
|
||||
NeedApproval(Box<PendingApproval>),
|
||||
}
|
||||
@@ -103,6 +107,7 @@ pub trait LoopDelegate: Send + Sync {
|
||||
async fn handle_text_response(
|
||||
&self,
|
||||
text: &str,
|
||||
metadata: ResponseMetadata,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
) -> TextAction;
|
||||
|
||||
@@ -209,7 +214,10 @@ pub async fn run_agentic_loop(
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
}
|
||||
|
||||
match delegate.handle_text_response(&text, reason_ctx).await {
|
||||
match delegate
|
||||
.handle_text_response(&text, output.metadata, reason_ctx)
|
||||
.await
|
||||
{
|
||||
TextAction::Return(outcome) => return Ok(outcome),
|
||||
TextAction::Continue => {}
|
||||
}
|
||||
@@ -279,7 +287,7 @@ pub fn truncate_for_preview(s: &str, max: usize) -> Cow<'_, str> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::{RespondOutput, TokenUsage, ToolCall};
|
||||
use crate::llm::{RespondOutput, ResponseAnomaly, ResponseMetadata, TokenUsage, ToolCall};
|
||||
use crate::testing::StubLlm;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -303,6 +311,7 @@ mod tests {
|
||||
result: RespondResult::Text(text.to_string()),
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::Stop,
|
||||
metadata: ResponseMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +323,7 @@ mod tests {
|
||||
},
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::ToolUse,
|
||||
metadata: ResponseMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +401,7 @@ mod tests {
|
||||
async fn handle_text_response(
|
||||
&self,
|
||||
text: &str,
|
||||
_metadata: ResponseMetadata,
|
||||
_reason_ctx: &mut ReasoningContext,
|
||||
) -> TextAction {
|
||||
TextAction::Return(LoopOutcome::Response(text.to_string()))
|
||||
@@ -508,6 +519,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_text_response_metadata_can_fail_fast() {
|
||||
struct FailOnMalformedResponse;
|
||||
|
||||
#[async_trait]
|
||||
impl LoopDelegate for FailOnMalformedResponse {
|
||||
async fn check_signals(&self) -> LoopSignal {
|
||||
LoopSignal::Continue
|
||||
}
|
||||
|
||||
async fn before_llm_call(
|
||||
&self,
|
||||
_: &mut ReasoningContext,
|
||||
_: usize,
|
||||
) -> Option<LoopOutcome> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn call_llm(
|
||||
&self,
|
||||
_: &Reasoning,
|
||||
_: &mut ReasoningContext,
|
||||
_: usize,
|
||||
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
|
||||
Ok(RespondOutput {
|
||||
result: RespondResult::Text("fallback".to_string()),
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::Stop,
|
||||
metadata: ResponseMetadata {
|
||||
anomaly: Some(ResponseAnomaly::EmptyToolCompletion),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_text_response(
|
||||
&self,
|
||||
_: &str,
|
||||
metadata: ResponseMetadata,
|
||||
_: &mut ReasoningContext,
|
||||
) -> TextAction {
|
||||
assert_eq!(metadata.anomaly, Some(ResponseAnomaly::EmptyToolCompletion));
|
||||
TextAction::Return(LoopOutcome::Failure(
|
||||
"malformed tool completion".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn execute_tool_calls(
|
||||
&self,
|
||||
_: Vec<ToolCall>,
|
||||
_: Option<String>,
|
||||
_: &mut ReasoningContext,
|
||||
) -> Result<Option<LoopOutcome>, crate::error::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
let delegate = FailOnMalformedResponse;
|
||||
let reasoning = stub_reasoning();
|
||||
let mut ctx = ReasoningContext::new();
|
||||
let outcome = run_agentic_loop(
|
||||
&delegate,
|
||||
&reasoning,
|
||||
&mut ctx,
|
||||
&AgenticLoopConfig::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(outcome, LoopOutcome::Failure(ref reason) if reason == "malformed tool completion")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_max_iterations_reached() {
|
||||
struct ContinueDelegate;
|
||||
@@ -535,6 +619,7 @@ mod tests {
|
||||
async fn handle_text_response(
|
||||
&self,
|
||||
_: &str,
|
||||
_: ResponseMetadata,
|
||||
ctx: &mut ReasoningContext,
|
||||
) -> TextAction {
|
||||
ctx.messages.push(ChatMessage::assistant("still working"));
|
||||
@@ -671,6 +756,7 @@ mod tests {
|
||||
},
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::Length, // response was truncated
|
||||
metadata: ResponseMetadata::default(),
|
||||
};
|
||||
let delegate = MockDelegate::new(vec![truncated_output, text_output("Summarized it.")]);
|
||||
let reasoning = stub_reasoning();
|
||||
@@ -719,6 +805,7 @@ mod tests {
|
||||
},
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::Length,
|
||||
metadata: ResponseMetadata::default(),
|
||||
};
|
||||
// Three truncated responses, then a text response
|
||||
let delegate = MockDelegate::new(vec![
|
||||
|
||||
+22
-14
@@ -947,12 +947,6 @@ impl Agent {
|
||||
/// Best-effort: logs warnings on failure but does not propagate errors,
|
||||
/// since the in-memory model switch already succeeded.
|
||||
///
|
||||
/// The DB setting is the primary persistence layer. For LLM settings the
|
||||
/// resolution priority is `DB > env > TOML > default`, so writing to DB
|
||||
/// is sufficient for the change to survive restarts. The `.env` and TOML
|
||||
/// files are only updated as a courtesy when they already contain a model
|
||||
/// var, to avoid user confusion.
|
||||
///
|
||||
/// In multi-tenant mode, only the per-user DB setting is written — global
|
||||
/// .env and TOML files are shared across users and must not be mutated.
|
||||
async fn persist_selected_model(&self, tenant: &crate::tenant::TenantCtx, model: &str) {
|
||||
@@ -978,18 +972,22 @@ impl Agent {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Best-effort update of .env and TOML if they already contain a
|
||||
// model var. DB is authoritative (DB > env > TOML), but keeping
|
||||
// these in sync avoids confusion when users inspect the files.
|
||||
// 3. Update .env and TOML config file (sync I/O in spawn_blocking).
|
||||
let model_owned = model.to_string();
|
||||
let backend = self.deps.llm_backend.clone();
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||
// 3a. Update the backend-specific model env var in ~/.ironclaw/.env
|
||||
// only if the var already exists (don't inject new vars).
|
||||
// 2a. Update the backend-specific model env var in ~/.ironclaw/.env.
|
||||
//
|
||||
// Env vars have the HIGHEST priority in LlmConfig::resolve_model()
|
||||
// (env var > TOML > DB > default). If the .env file has e.g.
|
||||
// NEARAI_MODEL=old-model, it shadows everything else. We must
|
||||
// update this var or the /model change is invisible on restart.
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
let model_env = registry.model_env_var(&backend);
|
||||
let env_var_prefix = format!("{}=", model_env);
|
||||
|
||||
// Only update the .env file if the var is actually set there
|
||||
// (avoid injecting new vars the user never configured).
|
||||
let env_path = crate::bootstrap::ironclaw_env_path();
|
||||
let env_has_var = std::fs::read_to_string(&env_path)
|
||||
.ok()
|
||||
@@ -1007,8 +1005,10 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Update TOML config file if it already exists.
|
||||
// Don't create a new one — DB persistence is sufficient.
|
||||
// 2b. Update (or create) the TOML config file.
|
||||
//
|
||||
// The TOML overlay has higher priority than DB settings on
|
||||
// startup, so it MUST stay in sync with the DB.
|
||||
let toml_path = crate::settings::Settings::default_toml_path();
|
||||
match crate::settings::Settings::load_toml(&toml_path) {
|
||||
Ok(Some(mut settings)) => {
|
||||
@@ -1018,7 +1018,15 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No config file on disk; DB persistence is sufficient.
|
||||
// No config file yet — create one so the model choice
|
||||
// survives restarts even when the DB is unavailable.
|
||||
let settings = crate::settings::Settings {
|
||||
selected_model: Some(model_owned),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = settings.save_toml(&toml_path) {
|
||||
tracing::warn!("Failed to create config.toml for model persistence: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load config.toml for model persistence: {}", e);
|
||||
|
||||
@@ -219,6 +219,11 @@ impl Agent {
|
||||
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
|
||||
}
|
||||
.into()),
|
||||
LoopOutcome::Failure(reason) => Err(crate::error::LlmError::InvalidResponse {
|
||||
provider: "agent".to_string(),
|
||||
reason,
|
||||
}
|
||||
.into()),
|
||||
LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
|
||||
}
|
||||
}
|
||||
@@ -439,6 +444,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
async fn handle_text_response(
|
||||
&self,
|
||||
text: &str,
|
||||
_metadata: crate::llm::ResponseMetadata,
|
||||
_reason_ctx: &mut ReasoningContext,
|
||||
) -> TextAction {
|
||||
// Strip internal "[Called tool ...]" text that can leak when
|
||||
|
||||
+3
-20
@@ -229,35 +229,18 @@ impl AppBuilder {
|
||||
let store = crate::secrets::create_secrets_store(crypto, handles);
|
||||
|
||||
if let Some(ref secrets) = store {
|
||||
// Migrate any plaintext API keys from the settings table to the
|
||||
// encrypted secrets store. Idempotent — safe to run on every startup.
|
||||
if let Some(ref db) = self.db {
|
||||
crate::config::migrate_plaintext_llm_keys(
|
||||
db.as_ref(),
|
||||
secrets.as_ref(),
|
||||
&self.config.owner_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Inject LLM API keys from encrypted storage
|
||||
crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), &self.config.owner_id)
|
||||
.await;
|
||||
|
||||
// Re-resolve only the LLM config with newly available keys,
|
||||
// including keys hydrated from the secrets store.
|
||||
let settings_store: Option<&(dyn crate::db::SettingsStore + Sync)> =
|
||||
// Re-resolve only the LLM config with newly available keys.
|
||||
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
|
||||
self.db.as_ref().map(|db| db.as_ref() as _);
|
||||
let toml_path = self.toml_path.as_deref();
|
||||
let owner_id = self.config.owner_id.clone();
|
||||
if let Err(e) = self
|
||||
.config
|
||||
.re_resolve_llm_with_secrets(
|
||||
settings_store,
|
||||
&owner_id,
|
||||
toml_path,
|
||||
Some(secrets.as_ref()),
|
||||
)
|
||||
.re_resolve_llm(store, &owner_id, toml_path)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}");
|
||||
|
||||
@@ -7,15 +7,10 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
|
||||
/// Sentinel value the frontend sends to mean "key is unchanged, don't touch it".
|
||||
const API_KEY_UNCHANGED: &str = "••••••••";
|
||||
|
||||
pub async fn settings_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
@@ -30,34 +25,12 @@ pub async fn settings_list_handler(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Build a map of sensitive keys so we can annotate and mask them.
|
||||
let sensitive_keys = ["llm_builtin_overrides", "llm_custom_providers"];
|
||||
let mut sensitive_map: std::collections::HashMap<String, serde_json::Value> = rows
|
||||
.iter()
|
||||
.filter(|r| sensitive_keys.contains(&r.key.as_str()))
|
||||
.map(|r| (r.key.clone(), r.value.clone()))
|
||||
.collect();
|
||||
if !sensitive_map.is_empty() {
|
||||
annotate_secret_key_presence(&state, &user.user_id, &mut sensitive_map).await;
|
||||
mask_settings_api_keys(&mut sensitive_map);
|
||||
}
|
||||
|
||||
let settings = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let value = if sensitive_keys.contains(&r.key.as_str()) {
|
||||
sensitive_map
|
||||
.get(&r.key)
|
||||
.cloned()
|
||||
.unwrap_or(r.value.clone())
|
||||
} else {
|
||||
r.value
|
||||
};
|
||||
SettingResponse {
|
||||
key: r.key,
|
||||
value,
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
}
|
||||
.map(|r| SettingResponse {
|
||||
key: r.key,
|
||||
value: r.value,
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -82,22 +55,9 @@ pub async fn settings_get_handler(
|
||||
})?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Mask any plaintext API keys that may exist from legacy data.
|
||||
let value = if matches!(
|
||||
key.as_str(),
|
||||
"llm_builtin_overrides" | "llm_custom_providers"
|
||||
) {
|
||||
let mut map = std::collections::HashMap::from([(key.clone(), row.value.clone())]);
|
||||
annotate_secret_key_presence(&state, &user.user_id, &mut map).await;
|
||||
mask_settings_api_keys(&mut map);
|
||||
map.remove(&key).unwrap_or(row.value)
|
||||
} else {
|
||||
row.value
|
||||
};
|
||||
|
||||
Ok(Json(SettingResponse {
|
||||
key: row.key,
|
||||
value,
|
||||
value: row.value,
|
||||
updated_at: row.updated_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
@@ -112,27 +72,8 @@ pub async fn settings_set_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
// Guard: cannot remove a custom provider that is currently active.
|
||||
if key == "llm_custom_providers" {
|
||||
guard_active_provider_not_removed(store, &user.user_id, &body.value).await?;
|
||||
validate_custom_providers(&body.value)?;
|
||||
}
|
||||
|
||||
// Extract API keys from LLM settings and vault them in the secrets store.
|
||||
// The sanitized value has api_key fields removed (stored encrypted instead).
|
||||
let sanitized_value = match key.as_str() {
|
||||
"llm_builtin_overrides" => {
|
||||
extract_builtin_override_keys(&state, &user.user_id, &body.value).await?
|
||||
}
|
||||
"llm_custom_providers" => {
|
||||
extract_custom_provider_keys(&state, &user.user_id, &body.value).await?
|
||||
}
|
||||
_ => body.value.clone(),
|
||||
};
|
||||
|
||||
store
|
||||
.set_setting(&user.user_id, &key, &sanitized_value)
|
||||
.set_setting(&user.user_id, &key, &body.value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to set setting '{}': {}", key, e);
|
||||
@@ -142,110 +83,6 @@ pub async fn settings_set_handler(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
const VALID_ADAPTERS: &[&str] = &["open_ai_completions", "anthropic", "ollama"];
|
||||
|
||||
/// Valid provider ID: lowercase alphanumeric and hyphens, 1-64 chars.
|
||||
fn is_valid_provider_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
/// Returns `Err(422)` if any provider has an invalid ID or unrecognised adapter.
|
||||
fn validate_custom_providers(value: &serde_json::Value) -> Result<(), StatusCode> {
|
||||
let providers = match value.as_array() {
|
||||
Some(arr) => arr,
|
||||
None => return Ok(()),
|
||||
};
|
||||
for p in providers {
|
||||
let id = p.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !is_valid_provider_id(id) {
|
||||
tracing::warn!(
|
||||
id = %id,
|
||||
"Rejected custom provider with invalid ID (must be lowercase alphanumeric/hyphens, 1-64 chars)"
|
||||
);
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
validate_custom_providers_adapters(value)
|
||||
}
|
||||
|
||||
/// Returns `Err(422)` if any provider in the incoming list has an unrecognised adapter.
|
||||
fn validate_custom_providers_adapters(value: &serde_json::Value) -> Result<(), StatusCode> {
|
||||
let providers = match value.as_array() {
|
||||
Some(arr) => arr,
|
||||
None => return Ok(()),
|
||||
};
|
||||
for p in providers {
|
||||
let adapter = p.get("adapter").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if adapter.is_empty() {
|
||||
tracing::warn!("Rejected custom provider with missing adapter field");
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
if !VALID_ADAPTERS.contains(&adapter) {
|
||||
tracing::warn!(adapter = %adapter, "Rejected unknown LLM adapter");
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `Err(409)` if the active `llm_backend` is a custom provider that
|
||||
/// would be removed by the incoming update to `llm_custom_providers`.
|
||||
async fn guard_active_provider_not_removed(
|
||||
store: &Arc<dyn crate::db::Database>,
|
||||
user_id: &str,
|
||||
new_value: &serde_json::Value,
|
||||
) -> Result<(), StatusCode> {
|
||||
// Get the currently active backend.
|
||||
let active_backend = match store.get_setting(user_id, "llm_backend").await {
|
||||
Ok(Some(v)) => match v.as_str() {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => return Ok(()),
|
||||
},
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
// Parse the incoming provider list.
|
||||
let new_providers: Vec<serde_json::Value> = match new_value.as_array() {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Check whether the active backend exists in the OLD custom providers list.
|
||||
let old_providers_value = match store.get_setting(user_id, "llm_custom_providers").await {
|
||||
Ok(Some(v)) => v,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let old_providers: Vec<serde_json::Value> = match old_providers_value.as_array() {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let active_was_custom = old_providers
|
||||
.iter()
|
||||
.any(|p| p.get("id").and_then(|v| v.as_str()) == Some(&active_backend));
|
||||
if !active_was_custom {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reject if the active provider is absent from the new list.
|
||||
let still_present = new_providers
|
||||
.iter()
|
||||
.any(|p| p.get("id").and_then(|v| v.as_str()) == Some(&active_backend));
|
||||
if !still_present {
|
||||
tracing::warn!(
|
||||
active_backend = %active_backend,
|
||||
"Rejected attempt to delete the active custom LLM provider"
|
||||
);
|
||||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn settings_delete_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
@@ -255,14 +92,6 @@ pub async fn settings_delete_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
// Guard: deleting llm_custom_providers is equivalent to setting it to [].
|
||||
// Reject if the active backend is a custom provider that would be removed.
|
||||
if key == "llm_custom_providers" {
|
||||
guard_active_provider_not_removed(store, &user.user_id, &serde_json::Value::Array(vec![]))
|
||||
.await?;
|
||||
}
|
||||
|
||||
store
|
||||
.delete_setting(&user.user_id, &key)
|
||||
.await
|
||||
@@ -282,16 +111,11 @@ pub async fn settings_export_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let mut settings = store.get_all_settings(&user.user_id).await.map_err(|e| {
|
||||
let settings = store.get_all_settings(&user.user_id).await.map_err(|e| {
|
||||
tracing::error!("Failed to export settings: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Indicate key presence from secrets store without exposing values.
|
||||
annotate_secret_key_presence(&state, &user.user_id, &mut settings).await;
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
Ok(Json(SettingsExportResponse { settings }))
|
||||
}
|
||||
|
||||
@@ -304,21 +128,8 @@ pub async fn settings_import_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
// Vault any API keys present in the imported settings, same as the
|
||||
// individual SET handler does, so plaintext keys never reach the DB.
|
||||
let mut sanitized = body.settings.clone();
|
||||
if let Some(v) = sanitized.get("llm_builtin_overrides").cloned() {
|
||||
let clean = extract_builtin_override_keys(&state, &user.user_id, &v).await?;
|
||||
sanitized.insert("llm_builtin_overrides".to_string(), clean);
|
||||
}
|
||||
if let Some(v) = sanitized.get("llm_custom_providers").cloned() {
|
||||
let clean = extract_custom_provider_keys(&state, &user.user_id, &v).await?;
|
||||
sanitized.insert("llm_custom_providers".to_string(), clean);
|
||||
}
|
||||
|
||||
store
|
||||
.set_all_settings(&user.user_id, &sanitized)
|
||||
.set_all_settings(&user.user_id, &body.settings)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to import settings: {}", e);
|
||||
@@ -327,639 +138,3 @@ pub async fn settings_import_handler(
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM API key vaulting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Canonical secret name for a built-in provider's API key.
|
||||
fn builtin_secret_name(provider_id: &str) -> String {
|
||||
format!("llm_builtin_{}_api_key", provider_id)
|
||||
}
|
||||
|
||||
/// Canonical secret name for a custom provider's API key.
|
||||
fn custom_secret_name(provider_id: &str) -> String {
|
||||
format!("llm_custom_{}_api_key", provider_id)
|
||||
}
|
||||
|
||||
/// Returns true if the `api_key` value is a real key (not sentinel/empty).
|
||||
fn is_real_api_key(key: &str) -> bool {
|
||||
!key.is_empty() && key != API_KEY_UNCHANGED
|
||||
}
|
||||
|
||||
/// Require the secrets store when real API keys are present.
|
||||
/// Returns `Ok(None)` when no secrets store and no real keys (passthrough).
|
||||
fn require_secrets_store(
|
||||
state: &GatewayState,
|
||||
has_real_keys: bool,
|
||||
) -> Result<Option<&Arc<dyn SecretsStore + Send + Sync>>, StatusCode> {
|
||||
match state.secrets_store.as_ref() {
|
||||
Some(s) => Ok(Some(s)),
|
||||
None if has_real_keys => {
|
||||
tracing::error!("Cannot store API keys: secrets store is not available");
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract API keys from builtin overrides, store in secrets, return sanitized JSON.
|
||||
async fn extract_builtin_override_keys(
|
||||
state: &GatewayState,
|
||||
user_id: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, StatusCode> {
|
||||
let obj = match value.as_object() {
|
||||
Some(o) => o,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let has_real_keys = obj.values().any(|v| {
|
||||
v.get("api_key")
|
||||
.and_then(|k| k.as_str())
|
||||
.is_some_and(is_real_api_key)
|
||||
});
|
||||
let secrets = match require_secrets_store(state, has_real_keys)? {
|
||||
Some(s) => s,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let mut sanitized = obj.clone();
|
||||
|
||||
for (provider_id, override_val) in obj {
|
||||
if let Some(api_key) = override_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if !is_real_api_key(api_key) {
|
||||
// Unchanged or empty — remove from settings, keep existing secret.
|
||||
if let Some(o) = sanitized
|
||||
.get_mut(provider_id)
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.remove("api_key");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
vault_secret(
|
||||
secrets.as_ref(),
|
||||
user_id,
|
||||
&builtin_secret_name(provider_id),
|
||||
api_key,
|
||||
provider_id,
|
||||
)
|
||||
.await?;
|
||||
if let Some(o) = sanitized
|
||||
.get_mut(provider_id)
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.remove("api_key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Object(sanitized))
|
||||
}
|
||||
|
||||
/// Extract API keys from custom providers, store in secrets, return sanitized JSON.
|
||||
async fn extract_custom_provider_keys(
|
||||
state: &GatewayState,
|
||||
user_id: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, StatusCode> {
|
||||
let arr = match value.as_array() {
|
||||
Some(a) => a,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let has_real_keys = arr.iter().any(|v| {
|
||||
v.get("api_key")
|
||||
.and_then(|k| k.as_str())
|
||||
.is_some_and(is_real_api_key)
|
||||
});
|
||||
let secrets = match require_secrets_store(state, has_real_keys)? {
|
||||
Some(s) => s,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let mut sanitized = arr.clone();
|
||||
|
||||
for (idx, provider_val) in arr.iter().enumerate() {
|
||||
let provider_id = provider_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if provider_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(api_key) = provider_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if !is_real_api_key(api_key) {
|
||||
if let Some(o) = sanitized[idx].as_object_mut() {
|
||||
o.remove("api_key");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
vault_secret(
|
||||
secrets.as_ref(),
|
||||
user_id,
|
||||
&custom_secret_name(provider_id),
|
||||
api_key,
|
||||
provider_id,
|
||||
)
|
||||
.await?;
|
||||
if let Some(o) = sanitized[idx].as_object_mut() {
|
||||
o.remove("api_key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Array(sanitized))
|
||||
}
|
||||
|
||||
/// Encrypt and store an API key in the secrets store.
|
||||
async fn vault_secret(
|
||||
secrets: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
secret_name: &str,
|
||||
api_key: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<(), StatusCode> {
|
||||
secrets
|
||||
.create(
|
||||
user_id,
|
||||
CreateSecretParams {
|
||||
name: secret_name.to_string(),
|
||||
value: SecretString::from(api_key.to_string()),
|
||||
provider: Some(provider_id.to_string()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"Failed to store secret '{}' for provider '{}': {}",
|
||||
secret_name,
|
||||
provider_id,
|
||||
e
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mask plaintext API keys in settings values before returning to the frontend.
|
||||
///
|
||||
/// Any `api_key` field still present in the settings JSON (legacy plaintext)
|
||||
/// is replaced with the sentinel so the frontend shows "key configured".
|
||||
fn mask_settings_api_keys(settings: &mut std::collections::HashMap<String, serde_json::Value>) {
|
||||
if let Some(obj) = settings
|
||||
.get_mut("llm_builtin_overrides")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
for override_val in obj.values_mut() {
|
||||
if let Some(o) = override_val.as_object_mut()
|
||||
&& o.contains_key("api_key")
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(arr) = settings
|
||||
.get_mut("llm_custom_providers")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
{
|
||||
for provider_val in arr.iter_mut() {
|
||||
if let Some(o) = provider_val.as_object_mut()
|
||||
&& o.contains_key("api_key")
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the secrets store for vaulted API keys and annotate the settings map.
|
||||
///
|
||||
/// For builtin overrides and custom providers whose API key was stripped from
|
||||
/// settings (stored in secrets), this adds `api_key: "••••••••"` so the
|
||||
/// frontend knows a key is configured without seeing the actual value.
|
||||
async fn annotate_secret_key_presence(
|
||||
state: &GatewayState,
|
||||
user_id: &str,
|
||||
settings: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let secrets = match state.secrets_store.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Annotate builtin overrides
|
||||
if let Some(obj) = settings
|
||||
.get_mut("llm_builtin_overrides")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
let provider_ids: Vec<String> = obj.keys().cloned().collect();
|
||||
for provider_id in provider_ids {
|
||||
let has_key_in_settings = obj
|
||||
.get(&provider_id)
|
||||
.and_then(|v| v.get("api_key"))
|
||||
.is_some();
|
||||
if has_key_in_settings {
|
||||
continue; // Will be masked by mask_settings_api_keys
|
||||
}
|
||||
let secret_name = builtin_secret_name(&provider_id);
|
||||
if secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Some(o) = obj.get_mut(&provider_id).and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Annotate custom providers
|
||||
if let Some(arr) = settings
|
||||
.get_mut("llm_custom_providers")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
{
|
||||
for provider_val in arr.iter_mut() {
|
||||
let provider_id = provider_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if provider_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let has_key_in_settings = provider_val.get("api_key").is_some();
|
||||
if has_key_in_settings {
|
||||
continue;
|
||||
}
|
||||
let secret_name = custom_secret_name(&provider_id);
|
||||
if secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Some(o) = provider_val.as_object_mut()
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_mask_settings_api_keys_builtin_overrides() {
|
||||
let mut settings = HashMap::new();
|
||||
settings.insert(
|
||||
"llm_builtin_overrides".to_string(),
|
||||
serde_json::json!({
|
||||
"openai": { "api_key": "sk-secret-123", "model": "gpt-4" },
|
||||
"anthropic": { "model": "claude-3" }
|
||||
}),
|
||||
);
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
let overrides = settings["llm_builtin_overrides"].as_object().unwrap();
|
||||
assert_eq!(
|
||||
overrides["openai"]["api_key"].as_str().unwrap(),
|
||||
API_KEY_UNCHANGED,
|
||||
);
|
||||
assert_eq!(overrides["openai"]["model"].as_str().unwrap(), "gpt-4");
|
||||
assert!(overrides["anthropic"].get("api_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_settings_api_keys_custom_providers() {
|
||||
let mut settings = HashMap::new();
|
||||
settings.insert(
|
||||
"llm_custom_providers".to_string(),
|
||||
serde_json::json!([
|
||||
{ "id": "my-llm", "api_key": "secret-key", "adapter": "open_ai_completions" },
|
||||
{ "id": "no-key", "adapter": "ollama" }
|
||||
]),
|
||||
);
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
let providers = settings["llm_custom_providers"].as_array().unwrap();
|
||||
assert_eq!(providers[0]["api_key"].as_str().unwrap(), API_KEY_UNCHANGED,);
|
||||
assert!(providers[1].get("api_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_settings_no_llm_keys_is_noop() {
|
||||
let mut settings = HashMap::new();
|
||||
settings.insert("some_other_setting".to_string(), serde_json::json!("value"));
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
assert_eq!(settings["some_other_setting"].as_str().unwrap(), "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_secret_name_format() {
|
||||
assert_eq!(builtin_secret_name("openai"), "llm_builtin_openai_api_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_secret_name_format() {
|
||||
assert_eq!(custom_secret_name("my-groq"), "llm_custom_my-groq_api_key");
|
||||
}
|
||||
|
||||
fn test_secrets_store() -> Arc<dyn SecretsStore + Send + Sync> {
|
||||
let crypto = Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
crate::secrets::keychain::generate_master_key_hex(),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(crypto))
|
||||
}
|
||||
|
||||
fn test_gateway_state(secrets: Arc<dyn SecretsStore + Send + Sync>) -> GatewayState {
|
||||
GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: Arc::new(crate::channels::web::sse::SseManager::new()),
|
||||
workspace: None,
|
||||
workspace_pool: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
log_level_handle: None,
|
||||
extension_manager: None,
|
||||
tool_registry: None,
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
scheduler: None,
|
||||
owner_id: "test".to_string(),
|
||||
default_sender_id: "test".to_string(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: None,
|
||||
llm_provider: None,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
chat_rate_limiter: crate::channels::web::server::PerUserRateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: Some(secrets),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_builtin_keys_vaults_and_strips() {
|
||||
let secrets = test_secrets_store();
|
||||
let state = test_gateway_state(Arc::clone(&secrets));
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "sk-test-key", "model": "gpt-4" },
|
||||
"anthropic": { "model": "claude-3" }
|
||||
});
|
||||
|
||||
let result = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let obj = result.as_object().unwrap();
|
||||
assert!(
|
||||
obj["openai"].get("api_key").is_none(),
|
||||
"api_key should be stripped"
|
||||
);
|
||||
assert_eq!(obj["openai"]["model"].as_str().unwrap(), "gpt-4");
|
||||
assert_eq!(obj["anthropic"]["model"].as_str().unwrap(), "claude-3");
|
||||
|
||||
let decrypted = secrets
|
||||
.get_decrypted("test", "llm_builtin_openai_api_key")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "sk-test-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_custom_keys_vaults_and_strips() {
|
||||
let secrets = test_secrets_store();
|
||||
let state = test_gateway_state(Arc::clone(&secrets));
|
||||
|
||||
let input = serde_json::json!([
|
||||
{ "id": "my-llm", "api_key": "gsk-custom-key", "adapter": "open_ai_completions" },
|
||||
{ "id": "local", "adapter": "ollama" }
|
||||
]);
|
||||
|
||||
let result = extract_custom_provider_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let arr = result.as_array().unwrap();
|
||||
assert!(
|
||||
arr[0].get("api_key").is_none(),
|
||||
"api_key should be stripped"
|
||||
);
|
||||
assert_eq!(arr[0]["id"].as_str().unwrap(), "my-llm");
|
||||
assert!(arr[1].get("api_key").is_none());
|
||||
|
||||
let decrypted = secrets
|
||||
.get_decrypted("test", "llm_custom_my-llm_api_key")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "gsk-custom-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unchanged_sentinel_preserves_existing_secret() {
|
||||
let secrets = test_secrets_store();
|
||||
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams {
|
||||
name: "llm_builtin_openai_api_key".to_string(),
|
||||
value: SecretString::from("sk-original".to_string()),
|
||||
provider: Some("openai".to_string()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = test_gateway_state(Arc::clone(&secrets));
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "••••••••", "model": "gpt-4" }
|
||||
});
|
||||
|
||||
let result = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result["openai"].get("api_key").is_none());
|
||||
|
||||
let decrypted = secrets
|
||||
.get_decrypted("test", "llm_builtin_openai_api_key")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "sk-original");
|
||||
}
|
||||
|
||||
/// When secrets store is unavailable, attempting to save a real API key
|
||||
/// must fail with 503 rather than silently storing plaintext.
|
||||
#[tokio::test]
|
||||
async fn test_extract_builtin_keys_rejects_without_secrets_store() {
|
||||
let state = GatewayState {
|
||||
secrets_store: None,
|
||||
..test_gateway_state(test_secrets_store())
|
||||
};
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "sk-real-key", "model": "gpt-4" }
|
||||
});
|
||||
|
||||
let err = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
/// When secrets store is unavailable but no real keys are present
|
||||
/// (only sentinels or no api_key at all), the call should succeed.
|
||||
#[tokio::test]
|
||||
async fn test_extract_builtin_keys_allows_no_keys_without_secrets_store() {
|
||||
let state = GatewayState {
|
||||
secrets_store: None,
|
||||
..test_gateway_state(test_secrets_store())
|
||||
};
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "••••••••", "model": "gpt-4" },
|
||||
"anthropic": { "model": "claude-3" }
|
||||
});
|
||||
|
||||
let result = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
// Without secrets store, the value passes through unchanged (no vaulting needed).
|
||||
assert!(result.as_object().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_custom_keys_rejects_without_secrets_store() {
|
||||
let state = GatewayState {
|
||||
secrets_store: None,
|
||||
..test_gateway_state(test_secrets_store())
|
||||
};
|
||||
|
||||
let input = serde_json::json!([
|
||||
{ "id": "my-llm", "api_key": "gsk-real-key", "adapter": "open_ai_completions" }
|
||||
]);
|
||||
|
||||
let err = extract_custom_provider_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// --- Provider ID validation tests ---
|
||||
|
||||
#[test]
|
||||
fn test_valid_provider_ids() {
|
||||
assert!(is_valid_provider_id("my-llm"));
|
||||
assert!(is_valid_provider_id("openai"));
|
||||
assert!(is_valid_provider_id("custom-provider-123"));
|
||||
assert!(is_valid_provider_id("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_provider_ids() {
|
||||
assert!(!is_valid_provider_id(""), "empty ID");
|
||||
assert!(!is_valid_provider_id("My-LLM"), "uppercase");
|
||||
assert!(!is_valid_provider_id("my llm"), "spaces");
|
||||
assert!(!is_valid_provider_id("my_llm"), "underscores");
|
||||
assert!(!is_valid_provider_id("../../etc"), "path traversal");
|
||||
assert!(!is_valid_provider_id("a.b"), "dots");
|
||||
assert!(
|
||||
!is_valid_provider_id(&"a".repeat(65)),
|
||||
"exceeds 64 char limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_custom_providers_rejects_bad_id() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "UPPER-CASE", "adapter": "open_ai_completions" }
|
||||
]);
|
||||
assert_eq!(
|
||||
validate_custom_providers(&input).unwrap_err(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_custom_providers_accepts_valid() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "my-llm", "adapter": "open_ai_completions" },
|
||||
{ "id": "local-ollama", "adapter": "ollama" }
|
||||
]);
|
||||
assert!(validate_custom_providers(&input).is_ok());
|
||||
}
|
||||
|
||||
// --- Adapter validation tests ---
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_rejects_unknown() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "test", "adapter": "not_a_real_adapter" }
|
||||
]);
|
||||
assert_eq!(
|
||||
validate_custom_providers_adapters(&input).unwrap_err(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_rejects_missing() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "test" }
|
||||
]);
|
||||
assert_eq!(
|
||||
validate_custom_providers_adapters(&input).unwrap_err(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_accepts_all_valid() {
|
||||
for adapter in VALID_ADAPTERS {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "test", "adapter": adapter }
|
||||
]);
|
||||
assert!(
|
||||
validate_custom_providers_adapters(&input).is_ok(),
|
||||
"adapter '{}' should be accepted",
|
||||
adapter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_non_array_is_ok() {
|
||||
let input = serde_json::json!("not-an-array");
|
||||
assert!(validate_custom_providers_adapters(&input).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ impl GatewayChannel {
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -171,7 +170,6 @@ impl GatewayChannel {
|
||||
startup_time: std::time::Instant::now(),
|
||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
active_config: server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -213,7 +211,6 @@ impl GatewayChannel {
|
||||
routine_engine: Arc::clone(&self.state.routine_engine),
|
||||
startup_time: self.state.startup_time,
|
||||
active_config: self.state.active_config.clone(),
|
||||
secrets_store: self.state.secrets_store.clone(),
|
||||
};
|
||||
mutate(&mut new_state);
|
||||
self.state = Arc::new(new_state);
|
||||
@@ -331,15 +328,6 @@ impl GatewayChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the secrets store for encrypting LLM API keys in settings handlers.
|
||||
pub fn with_secrets_store(
|
||||
mut self,
|
||||
ss: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||
) -> Self {
|
||||
self.rebuild_state(|s| s.secrets_store = Some(ss));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the per-user workspace pool for multi-user mode.
|
||||
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
|
||||
self.rebuild_state(|s| s.workspace_pool = Some(pool));
|
||||
|
||||
+109
-552
@@ -46,10 +46,6 @@ use crate::channels::web::handlers::routines::{
|
||||
routines_delete_handler, routines_detail_handler, routines_list_handler,
|
||||
routines_summary_handler, routines_toggle_handler, routines_trigger_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::settings::{
|
||||
settings_delete_handler, settings_export_handler, settings_get_handler,
|
||||
settings_import_handler, settings_list_handler, settings_set_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::skills::{
|
||||
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
||||
};
|
||||
@@ -57,7 +53,6 @@ use crate::channels::web::log_layer::LogBroadcaster;
|
||||
use crate::channels::web::sse::SseManager;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
||||
use crate::config::helpers::validate_base_url;
|
||||
use crate::db::Database;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::orchestrator::job_manager::ContainerJobManager;
|
||||
@@ -383,8 +378,6 @@ pub struct GatewayState {
|
||||
pub startup_time: std::time::Instant,
|
||||
/// Snapshot of active (resolved) configuration for the frontend.
|
||||
pub active_config: ActiveConfigSnapshot,
|
||||
/// Secrets store for encrypting LLM API keys (and future sensitive settings).
|
||||
pub secrets_store: Option<Arc<dyn crate::secrets::SecretsStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// Start the gateway HTTP server.
|
||||
@@ -519,13 +512,6 @@ pub async fn start_server(
|
||||
"/api/settings/{key}",
|
||||
axum::routing::delete(settings_delete_handler),
|
||||
)
|
||||
// LLM utilities
|
||||
.route(
|
||||
"/api/llm/test_connection",
|
||||
post(llm_test_connection_handler),
|
||||
)
|
||||
.route("/api/llm/list_models", post(llm_list_models_handler))
|
||||
.route("/api/llm/env_defaults", get(llm_env_defaults_handler))
|
||||
// Gateway control plane
|
||||
.route("/api/gateway/status", get(gateway_status_handler))
|
||||
// OpenAI-compatible API
|
||||
@@ -552,7 +538,6 @@ pub async fn start_server(
|
||||
let statics = Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/style.css", get(css_handler))
|
||||
.route("/providers.js", get(providers_js_handler))
|
||||
.route("/app.js", get(js_handler))
|
||||
.route("/theme-init.js", get(theme_init_handler))
|
||||
.route("/favicon.ico", get(favicon_handler))
|
||||
@@ -666,16 +651,6 @@ async fn css_handler() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
async fn providers_js_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/providers.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn js_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
@@ -2674,458 +2649,133 @@ async fn routines_runs_handler(
|
||||
})))
|
||||
}
|
||||
|
||||
/// Fields shared by `test_connection` and `list_models` requests.
|
||||
///
|
||||
/// When `api_key` is absent the handler falls back to the encrypted secrets
|
||||
/// store, using `provider_id` + `provider_type` to locate the vaulted key.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TestConnectionRequest {
|
||||
adapter: String,
|
||||
base_url: String,
|
||||
/// Model to use for the test chat completion request.
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
/// Provider identifier used to look up the vaulted API key when `api_key`
|
||||
/// is not supplied by the frontend (key already stored in secrets).
|
||||
#[serde(default)]
|
||||
provider_id: Option<String>,
|
||||
/// `"builtin"` or `"custom"` — determines the secret name prefix.
|
||||
#[serde(default)]
|
||||
provider_type: Option<String>,
|
||||
}
|
||||
// --- Settings handlers ---
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct TestConnectionResponse {
|
||||
ok: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn llm_test_connection_handler(
|
||||
async fn settings_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(mut body): Json<TestConnectionRequest>,
|
||||
) -> Json<TestConnectionResponse> {
|
||||
resolve_api_key_from_secrets(
|
||||
&state,
|
||||
&user.user_id,
|
||||
&mut body.api_key,
|
||||
&body.provider_id,
|
||||
&body.provider_type,
|
||||
)
|
||||
.await;
|
||||
Json(test_provider_connection(body).await)
|
||||
) -> Result<Json<SettingsListResponse>, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let rows = store.list_settings(&user.user_id).await.map_err(|e| {
|
||||
tracing::error!("Failed to list settings: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let settings = rows
|
||||
.into_iter()
|
||||
.map(|r| SettingResponse {
|
||||
key: r.key,
|
||||
value: r.value,
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SettingsListResponse { settings }))
|
||||
}
|
||||
|
||||
/// When the frontend doesn't supply an `api_key` (because it was already vaulted),
|
||||
/// look it up from the encrypted secrets store using `provider_id` + `provider_type`.
|
||||
async fn resolve_api_key_from_secrets(
|
||||
state: &GatewayState,
|
||||
user_id: &str,
|
||||
api_key: &mut Option<String>,
|
||||
provider_id: &Option<String>,
|
||||
provider_type: &Option<String>,
|
||||
) {
|
||||
// Already have a key from the request — nothing to resolve.
|
||||
if api_key.as_ref().is_some_and(|k| !k.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let pid = match provider_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
let secrets = match state.secrets_store.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
let secret_name = match provider_type.as_deref() {
|
||||
Some("custom") => format!("llm_custom_{}_api_key", pid),
|
||||
_ => format!("llm_builtin_{}_api_key", pid),
|
||||
};
|
||||
if let Ok(decrypted) = secrets.get_decrypted(user_id, &secret_name).await {
|
||||
*api_key = Some(decrypted.expose().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a base URL belongs to a NEAR AI private endpoint.
|
||||
///
|
||||
/// Matches `private.near.ai` exactly or any subdomain of it
|
||||
/// (e.g. `us.private.near.ai`). Rejects lookalikes like
|
||||
/// `private-evil.near.ai` or `myprivate.near.ai`.
|
||||
fn is_nearai_private_endpoint(base_url: &str) -> bool {
|
||||
url::Url::parse(base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
|
||||
.is_some_and(|host| host == "private.near.ai" || host.ends_with(".private.near.ai"))
|
||||
}
|
||||
|
||||
async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionResponse {
|
||||
if let Err(e) = validate_base_url(&req.base_url, "base_url") {
|
||||
return TestConnectionResponse {
|
||||
ok: false,
|
||||
message: format!("Invalid base URL: {e}"),
|
||||
};
|
||||
}
|
||||
|
||||
if req.model.trim().is_empty() {
|
||||
return TestConnectionResponse {
|
||||
ok: false,
|
||||
message: "Model is required for connection test".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return TestConnectionResponse {
|
||||
ok: false,
|
||||
message: format!("Failed to build HTTP client: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let base = req.base_url.trim_end_matches('/');
|
||||
|
||||
match req.adapter.as_str() {
|
||||
"anthropic" => {
|
||||
let anthropic_base = if base.ends_with("/v1") || base.contains("/v1/") {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/v1")
|
||||
};
|
||||
let url = format!("{anthropic_base}/messages");
|
||||
let body = serde_json::json!({
|
||||
"model": req.model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
});
|
||||
let mut builder = client
|
||||
.post(&url)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body);
|
||||
if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) {
|
||||
builder = builder.header("x-api-key", key);
|
||||
}
|
||||
interpret_chat_response(builder.send().await)
|
||||
}
|
||||
"ollama" => {
|
||||
let url = format!("{base}/api/chat");
|
||||
let body = serde_json::json!({
|
||||
"model": req.model,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"stream": false
|
||||
});
|
||||
let builder = client.post(&url).json(&body);
|
||||
interpret_chat_response(builder.send().await)
|
||||
}
|
||||
_ => {
|
||||
// OpenAI-compatible (including nearai): POST /v1/chat/completions
|
||||
// If base already ends with /v1, append directly; otherwise insert /v1.
|
||||
let chat_url = if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
};
|
||||
let body = serde_json::json!({
|
||||
"model": req.model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
});
|
||||
let mut builder = client.post(&chat_url).json(&body);
|
||||
if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) {
|
||||
builder = builder.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
interpret_chat_response(builder.send().await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn interpret_chat_response(
|
||||
result: Result<reqwest::Response, reqwest::Error>,
|
||||
) -> TestConnectionResponse {
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
if status.is_success() {
|
||||
TestConnectionResponse {
|
||||
ok: true,
|
||||
message: format!("Connected ({})", status),
|
||||
}
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
TestConnectionResponse {
|
||||
ok: false,
|
||||
message: format!("Authentication failed ({})", status),
|
||||
}
|
||||
} else if status == reqwest::StatusCode::BAD_REQUEST
|
||||
|| status == reqwest::StatusCode::UNPROCESSABLE_ENTITY
|
||||
{
|
||||
// 400/422 = server reachable, likely wrong endpoint variant — connectivity OK
|
||||
TestConnectionResponse {
|
||||
ok: true,
|
||||
message: format!("Server reachable ({})", status),
|
||||
}
|
||||
} else if status == reqwest::StatusCode::NOT_FOUND {
|
||||
// 404 = /models endpoint not found — server reachable but not OpenAI-compatible
|
||||
TestConnectionResponse {
|
||||
ok: false,
|
||||
message: format!(
|
||||
"Server reachable but /models endpoint not found ({}). \
|
||||
Check the base URL and adapter type.",
|
||||
status
|
||||
),
|
||||
}
|
||||
} else if status.is_client_error() {
|
||||
TestConnectionResponse {
|
||||
ok: false,
|
||||
message: format!("Client error ({})", status),
|
||||
}
|
||||
} else {
|
||||
TestConnectionResponse {
|
||||
ok: false,
|
||||
message: format!("Server error ({})", status),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResponse {
|
||||
ok: false,
|
||||
message: format!("Connection failed: {e}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListModelsRequest {
|
||||
adapter: String,
|
||||
base_url: String,
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
provider_id: Option<String>,
|
||||
#[serde(default)]
|
||||
provider_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ListModelsResponse {
|
||||
ok: bool,
|
||||
models: Vec<String>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn llm_list_models_handler(
|
||||
async fn settings_get_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(mut body): Json<ListModelsRequest>,
|
||||
) -> Json<ListModelsResponse> {
|
||||
resolve_api_key_from_secrets(
|
||||
&state,
|
||||
&user.user_id,
|
||||
&mut body.api_key,
|
||||
&body.provider_id,
|
||||
&body.provider_type,
|
||||
)
|
||||
.await;
|
||||
Json(fetch_provider_models(body).await)
|
||||
Path(key): Path<String>,
|
||||
) -> Result<Json<SettingResponse>, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let row = store
|
||||
.get_setting_full(&user.user_id, &key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to get setting '{}': {}", key, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(SettingResponse {
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
updated_at: row.updated_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse {
|
||||
if let Err(e) = validate_base_url(&req.base_url, "base_url") {
|
||||
return ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: format!("Invalid base URL: {e}"),
|
||||
};
|
||||
}
|
||||
async fn settings_set_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(key): Path<String>,
|
||||
Json(body): Json<SettingWriteRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
store
|
||||
.set_setting(&user.user_id, &key, &body.value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to set setting '{}': {}", key, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: format!("Failed to build HTTP client: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let base = req.base_url.trim_end_matches('/');
|
||||
let auth = req.api_key.as_deref().filter(|k| !k.is_empty());
|
||||
|
||||
match req.adapter.as_str() {
|
||||
"ollama" => {
|
||||
let url = format!("{base}/api/tags");
|
||||
match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let body: serde_json::Value = r.json().await.unwrap_or_default();
|
||||
let models: Vec<String> = body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if models.is_empty() {
|
||||
ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: "No models found".to_string(),
|
||||
}
|
||||
} else {
|
||||
ListModelsResponse {
|
||||
ok: true,
|
||||
message: format!("{} model(s) found", models.len()),
|
||||
models,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r) => ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: format!("Server returned {}", r.status()),
|
||||
},
|
||||
Err(e) => ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: format!("Connection failed: {e}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// OpenAI-compatible, Anthropic, and NEAR AI all support GET /models.
|
||||
// NEAR AI private endpoints and Anthropic need a /v1 prefix.
|
||||
let effective_base = if (req.adapter == "nearai" && is_nearai_private_endpoint(base))
|
||||
|| (req.adapter == "anthropic" && !base.ends_with("/v1") && !base.contains("/v1/"))
|
||||
{
|
||||
format!("{base}/v1")
|
||||
} else {
|
||||
base.to_string()
|
||||
};
|
||||
let url = format!("{effective_base}/models");
|
||||
let mut builder = client.get(&url);
|
||||
if req.adapter == "anthropic" {
|
||||
// Anthropic requires a version header and uses x-api-key for authentication
|
||||
builder = builder.header("anthropic-version", "2023-06-01");
|
||||
if let Some(key) = auth {
|
||||
builder = builder.header("x-api-key", key);
|
||||
}
|
||||
} else if let Some(key) = auth {
|
||||
builder = builder.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
match builder.send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let body: serde_json::Value = r.json().await.unwrap_or_default();
|
||||
// OpenAI: {"data": [{"id": "..."}]}
|
||||
// Anthropic: {"data": [{"id": "..."}]}
|
||||
let models: Vec<String> = body["data"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if models.is_empty() {
|
||||
ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: "No models found in response".to_string(),
|
||||
}
|
||||
} else {
|
||||
ListModelsResponse {
|
||||
ok: true,
|
||||
message: format!("{} model(s) found", models.len()),
|
||||
models,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r) => ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: format!("Server returned {} — list models not supported", r.status()),
|
||||
},
|
||||
Err(e) => ListModelsResponse {
|
||||
ok: false,
|
||||
models: vec![],
|
||||
message: format!("Connection failed: {e}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// --- LLM env defaults handler ---
|
||||
async fn settings_delete_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(key): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
store
|
||||
.delete_setting(&user.user_id, &key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to delete setting '{}': {}", key, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
/// Returns env-var-based defaults for each builtin LLM provider.
|
||||
///
|
||||
/// The frontend uses these as fallback values when the DB has no overrides.
|
||||
/// API keys are never returned — only a boolean `has_api_key`.
|
||||
async fn llm_env_defaults_handler(
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
) -> Json<serde_json::Value> {
|
||||
Json(build_llm_env_defaults())
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn build_llm_env_defaults() -> serde_json::Value {
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::llm::registry::ProviderRegistry;
|
||||
async fn settings_export_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<SettingsExportResponse>, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let settings = store.get_all_settings(&user.user_id).await.map_err(|e| {
|
||||
tracing::error!("Failed to export settings: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let registry = ProviderRegistry::load();
|
||||
let mut defaults = serde_json::Map::new();
|
||||
Ok(Json(SettingsExportResponse { settings }))
|
||||
}
|
||||
|
||||
// Helper: read env var via optional_env (checks real env + injected overlay).
|
||||
let read_env = |key: &str| -> Option<String> { optional_env(key).ok().flatten() };
|
||||
async fn settings_import_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(body): Json<SettingsImportRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
store
|
||||
.set_all_settings(&user.user_id, &body.settings)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to import settings: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// NEAR AI is a special case (not in the registry)
|
||||
{
|
||||
let mut entry = serde_json::Map::new();
|
||||
// Only expose presence of API key, never the value itself.
|
||||
entry.insert(
|
||||
"has_api_key".to_string(),
|
||||
serde_json::Value::Bool(read_env("NEARAI_API_KEY").is_some()),
|
||||
);
|
||||
if let Some(model) = read_env("NEARAI_MODEL") {
|
||||
entry.insert("model".to_string(), serde_json::Value::String(model));
|
||||
}
|
||||
if let Some(url) = read_env("NEARAI_BASE_URL") {
|
||||
entry.insert("base_url".to_string(), serde_json::Value::String(url));
|
||||
}
|
||||
defaults.insert("nearai".to_string(), serde_json::Value::Object(entry));
|
||||
}
|
||||
|
||||
// Registry-based providers
|
||||
for def in registry.all() {
|
||||
let mut entry = serde_json::Map::new();
|
||||
|
||||
if let Some(ref api_key_env) = def.api_key_env {
|
||||
entry.insert(
|
||||
"has_api_key".to_string(),
|
||||
serde_json::Value::Bool(read_env(api_key_env).is_some()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(model) = read_env(&def.model_env) {
|
||||
entry.insert("model".to_string(), serde_json::Value::String(model));
|
||||
}
|
||||
|
||||
if let Some(ref base_url_env) = def.base_url_env
|
||||
&& let Some(url) = read_env(base_url_env)
|
||||
{
|
||||
entry.insert("base_url".to_string(), serde_json::Value::String(url));
|
||||
}
|
||||
|
||||
defaults.insert(def.id.clone(), serde_json::Value::Object(entry));
|
||||
}
|
||||
|
||||
serde_json::Value::Object(defaults)
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// --- Gateway control plane handlers ---
|
||||
@@ -3373,67 +3023,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- LLM env defaults handler tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_llm_env_defaults_returns_nearai_env_vars() {
|
||||
// SAFETY: test-only; tokio::test runs single-threaded by default.
|
||||
unsafe {
|
||||
std::env::set_var("NEARAI_API_KEY", "test-key-123");
|
||||
std::env::set_var("NEARAI_MODEL", "test-model");
|
||||
std::env::set_var("NEARAI_BASE_URL", "https://test.near.ai/v1");
|
||||
}
|
||||
|
||||
let result = build_llm_env_defaults();
|
||||
let map = result.as_object().expect("should be an object");
|
||||
|
||||
// Check NEAR AI entry
|
||||
let nearai = map
|
||||
.get("nearai")
|
||||
.and_then(|v| v.as_object())
|
||||
.expect("nearai entry");
|
||||
// API key should NOT be exposed — only has_api_key presence flag.
|
||||
assert_eq!(
|
||||
nearai.get("has_api_key").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert!(
|
||||
nearai.get("api_key").is_none(),
|
||||
"raw api_key must never be returned"
|
||||
);
|
||||
assert_eq!(
|
||||
nearai.get("model").and_then(|v| v.as_str()),
|
||||
Some("test-model")
|
||||
);
|
||||
assert_eq!(
|
||||
nearai.get("base_url").and_then(|v| v.as_str()),
|
||||
Some("https://test.near.ai/v1")
|
||||
);
|
||||
|
||||
// Clean up
|
||||
unsafe {
|
||||
std::env::remove_var("NEARAI_API_KEY");
|
||||
std::env::remove_var("NEARAI_MODEL");
|
||||
std::env::remove_var("NEARAI_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_llm_env_defaults_includes_registry_providers() {
|
||||
let result = build_llm_env_defaults();
|
||||
let map = result.as_object().expect("should be an object");
|
||||
|
||||
// Registry providers should be present (openai, anthropic, ollama, etc.)
|
||||
assert!(map.contains_key("openai"), "should contain openai");
|
||||
assert!(map.contains_key("anthropic"), "should contain anthropic");
|
||||
assert!(map.contains_key("ollama"), "should contain ollama");
|
||||
|
||||
// Each entry should have has_api_key field
|
||||
for (id, val) in map {
|
||||
assert!(val.is_object(), "{id} should be an object");
|
||||
}
|
||||
}
|
||||
|
||||
// --- OAuth callback handler tests ---
|
||||
|
||||
/// Build a minimal `GatewayState` for testing the OAuth callback handler.
|
||||
@@ -3467,7 +3056,6 @@ mod tests {
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4774,35 +4362,4 @@ mod tests {
|
||||
assert!(!is_local_origin("not-a-url"));
|
||||
assert!(!is_local_origin(""));
|
||||
}
|
||||
|
||||
// --- is_nearai_private_endpoint tests ---
|
||||
|
||||
#[test]
|
||||
fn test_nearai_private_exact_match() {
|
||||
assert!(is_nearai_private_endpoint("https://private.near.ai/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nearai_private_subdomain() {
|
||||
assert!(is_nearai_private_endpoint("https://us.private.near.ai/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nearai_public_endpoint_not_private() {
|
||||
assert!(!is_nearai_private_endpoint("https://cloud-api.near.ai/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nearai_private_lookalike_rejected() {
|
||||
// "private" appears in the hostname but not as the correct domain
|
||||
assert!(!is_nearai_private_endpoint(
|
||||
"https://private-evil.near.ai/v1"
|
||||
));
|
||||
assert!(!is_nearai_private_endpoint("https://myprivate.near.ai/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nearai_private_non_near_ai_rejected() {
|
||||
assert!(!is_nearai_private_endpoint("https://private.evil.com/v1"));
|
||||
}
|
||||
}
|
||||
|
||||
+39
-647
@@ -5034,6 +5034,25 @@ function loadSettingsSubtab(subtab) {
|
||||
// --- Structured Settings Definitions ---
|
||||
|
||||
var INFERENCE_SETTINGS = [
|
||||
{
|
||||
group: 'cfg.group.llm',
|
||||
settings: [
|
||||
{ key: 'llm_backend', label: 'cfg.llm_backend.label', description: 'cfg.llm_backend.desc',
|
||||
type: 'select', options: ['nearai', 'anthropic', 'openai', 'ollama', 'openai_compatible', 'tinfoil', 'bedrock'] },
|
||||
{ key: 'selected_model', label: 'cfg.selected_model.label', description: 'cfg.selected_model.desc', type: 'text' },
|
||||
{ key: 'ollama_base_url', label: 'cfg.ollama_base_url.label', description: 'cfg.ollama_base_url.desc', type: 'text',
|
||||
showWhen: { key: 'llm_backend', value: 'ollama' } },
|
||||
{ key: 'openai_compatible_base_url', label: 'cfg.openai_compatible_base_url.label', description: 'cfg.openai_compatible_base_url.desc', type: 'text',
|
||||
showWhen: { key: 'llm_backend', value: 'openai_compatible' } },
|
||||
{ key: 'bedrock_region', label: 'cfg.bedrock_region.label', description: 'cfg.bedrock_region.desc', type: 'text',
|
||||
showWhen: { key: 'llm_backend', value: 'bedrock' } },
|
||||
{ key: 'bedrock_cross_region', label: 'cfg.bedrock_cross_region.label', description: 'cfg.bedrock_cross_region.desc',
|
||||
type: 'select', options: ['us', 'eu', 'apac', 'global'],
|
||||
showWhen: { key: 'llm_backend', value: 'bedrock' } },
|
||||
{ key: 'bedrock_profile', label: 'cfg.bedrock_profile.label', description: 'cfg.bedrock_profile.desc', type: 'text',
|
||||
showWhen: { key: 'llm_backend', value: 'bedrock' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'cfg.group.embeddings',
|
||||
settings: [
|
||||
@@ -5156,64 +5175,31 @@ function loadInferenceSettings() {
|
||||
Promise.all([
|
||||
apiFetch('/api/settings/export'),
|
||||
apiFetch('/api/gateway/status').catch(function() { return {}; }),
|
||||
apiFetch('/v1/models').catch(function() { return { data: [] }; })
|
||||
]).then(function(results) {
|
||||
var settings = results[0].settings || {};
|
||||
var status = results[1];
|
||||
var modelsData = results[2];
|
||||
var activeValues = {
|
||||
'llm_backend': status.llm_backend,
|
||||
'selected_model': status.llm_model
|
||||
};
|
||||
// Inject available model IDs as suggestions for the selected_model field
|
||||
var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
|
||||
if (modelIds.length > 0) {
|
||||
var llmGroup = INFERENCE_SETTINGS[0];
|
||||
for (var i = 0; i < llmGroup.settings.length; i++) {
|
||||
if (llmGroup.settings[i].key === 'selected_model') {
|
||||
llmGroup.settings[i].suggestions = modelIds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
container.innerHTML = '';
|
||||
|
||||
// LLM Provider display — derived from active Model Provider
|
||||
var activeBackend = settings['llm_backend'] || status.llm_backend || 'nearai';
|
||||
var activeModel = settings['selected_model'] || status.llm_model || '';
|
||||
var allP = (typeof BUILTIN_PROVIDERS !== 'undefined' ? BUILTIN_PROVIDERS : []);
|
||||
var customP = [];
|
||||
try {
|
||||
var cpVal = settings['llm_custom_providers'];
|
||||
customP = Array.isArray(cpVal) ? cpVal : (cpVal ? JSON.parse(cpVal) : []);
|
||||
} catch (e) { customP = []; }
|
||||
var provider = allP.concat(customP).find(function(p) { return p.id === activeBackend; });
|
||||
var providerName = provider ? (provider.name || provider.id) : activeBackend;
|
||||
if (!activeModel && provider) activeModel = provider.default_model || '';
|
||||
|
||||
var group = document.createElement('div');
|
||||
group.className = 'settings-group';
|
||||
var title = document.createElement('div');
|
||||
title.className = 'settings-group-title';
|
||||
title.textContent = I18n.t('cfg.group.llm');
|
||||
group.appendChild(title);
|
||||
|
||||
var notice = document.createElement('div');
|
||||
notice.className = 'config-notice';
|
||||
notice.id = 'llm-restart-notice';
|
||||
var restartNoticeEl = document.getElementById('config-restart-notice');
|
||||
notice.style.display = (restartNoticeEl && restartNoticeEl.style.display !== 'none') ? 'flex' : 'none';
|
||||
notice.innerHTML = '<span>\u26A0</span><span>' + escapeHtml(I18n.t('config.restartNotice')) + '</span>';
|
||||
group.appendChild(notice);
|
||||
|
||||
var backendRow = document.createElement('div');
|
||||
backendRow.className = 'settings-row';
|
||||
backendRow.innerHTML =
|
||||
'<div class="settings-label-wrap"><label class="settings-label">' + escapeHtml(I18n.t('cfg.llm_backend.label')) + '</label>' +
|
||||
'<div class="settings-description">' + escapeHtml(I18n.t('cfg.llm_backend.desc')) + '</div></div>' +
|
||||
'<div class="settings-display-value">' + escapeHtml(providerName) + '</div>';
|
||||
group.appendChild(backendRow);
|
||||
|
||||
var modelRow = document.createElement('div');
|
||||
modelRow.className = 'settings-row';
|
||||
modelRow.innerHTML =
|
||||
'<div class="settings-label-wrap"><label class="settings-label">' + escapeHtml(I18n.t('cfg.selected_model.label')) + '</label>' +
|
||||
'<div class="settings-description">' + escapeHtml(I18n.t('cfg.selected_model.desc')) + '</div></div>' +
|
||||
'<div class="settings-display-value">' + escapeHtml(activeModel || '\u2014') + '</div>';
|
||||
group.appendChild(modelRow);
|
||||
|
||||
container.appendChild(group);
|
||||
|
||||
// Remaining editable settings (embeddings, etc.)
|
||||
renderStructuredSettingsInto(container, INFERENCE_SETTINGS, settings, {});
|
||||
loadConfig();
|
||||
renderStructuredSettingsInto(container, INFERENCE_SETTINGS, settings, activeValues);
|
||||
}).catch(function(err) {
|
||||
container.innerHTML = '<div class="empty-state">' + I18n.t('common.loadFailed') + ': '
|
||||
+ escapeHtml(err.message) + '</div>';
|
||||
loadConfig();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5457,7 +5443,8 @@ function renderStructuredSettingsRow(def, value, activeValue) {
|
||||
return row;
|
||||
}
|
||||
|
||||
var RESTART_REQUIRED_KEYS = ['embeddings.enabled', 'embeddings.provider', 'embeddings.model',
|
||||
var RESTART_REQUIRED_KEYS = ['llm_backend', 'selected_model', 'ollama_base_url', 'openai_compatible_base_url',
|
||||
'bedrock_region', 'bedrock_cross_region', 'bedrock_profile', 'embeddings.enabled', 'embeddings.provider', 'embeddings.model',
|
||||
'agent.auto_approve_tools', 'tunnel.provider', 'tunnel.public_url', 'gateway.rate_limit', 'gateway.max_connections'];
|
||||
|
||||
var _settingsSavedTimers = {};
|
||||
@@ -6042,18 +6029,6 @@ document.addEventListener('click', function(e) {
|
||||
case 'switch-language':
|
||||
if (typeof switchLanguage === 'function') switchLanguage(el.dataset.lang);
|
||||
break;
|
||||
case 'set-active-provider':
|
||||
setActiveProvider(el.dataset.id);
|
||||
break;
|
||||
case 'delete-custom-provider':
|
||||
deleteCustomProvider(el.dataset.id);
|
||||
break;
|
||||
case 'edit-custom-provider':
|
||||
editCustomProvider(el.dataset.id);
|
||||
break;
|
||||
case 'configure-builtin-provider':
|
||||
configureBuiltinProvider(el.dataset.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6095,9 +6070,6 @@ document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && document.getElementById('confirm-modal').style.display === 'flex') {
|
||||
closeConfirmModal();
|
||||
}
|
||||
if (e.key === 'Escape' && document.getElementById('provider-dialog').style.display === 'flex') {
|
||||
resetProviderForm();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Settings Import/Export ---
|
||||
@@ -6185,583 +6157,3 @@ document.getElementById('settings-search-input').addEventListener('input', funct
|
||||
activePanel.appendChild(empty);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- Config Tab ---
|
||||
|
||||
// Like apiFetch but for endpoints that return 204 No Content
|
||||
function apiFetchVoid(path, options) {
|
||||
const opts = options || {};
|
||||
opts.headers = opts.headers || {};
|
||||
opts.headers['Authorization'] = 'Bearer ' + token;
|
||||
if (opts.body && typeof opts.body === 'object') {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
return fetch(path, opts).then((res) => {
|
||||
if (!res.ok) {
|
||||
return res.text().then((body) => { throw new Error(body || (res.status + ' ' + res.statusText)); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// BUILTIN_PROVIDERS and ADAPTER_LABELS are defined in /providers.js
|
||||
|
||||
let _customProviders = [];
|
||||
let _activeLlmBackend = '';
|
||||
let _selectedModel = '';
|
||||
let _builtinOverrides = {};
|
||||
let _editingProviderId = null;
|
||||
let _configuringBuiltinId = null;
|
||||
let _configLoaded = false;
|
||||
let _envDefaults = {};
|
||||
|
||||
function loadConfig() {
|
||||
const list = document.getElementById('providers-list');
|
||||
list.innerHTML = '<div class="empty-state">' + I18n.t('common.loading') + '</div>';
|
||||
|
||||
Promise.all([
|
||||
apiFetch('/api/settings/export'),
|
||||
apiFetch('/api/llm/env_defaults').catch(() => ({})),
|
||||
]).then(([d, envDefs]) => {
|
||||
const s = (d && d.settings) ? d.settings : {};
|
||||
_activeLlmBackend = s['llm_backend'] ? String(s['llm_backend']) : 'nearai';
|
||||
_selectedModel = s['selected_model'] ? String(s['selected_model']) : '';
|
||||
try {
|
||||
const val = s['llm_custom_providers'];
|
||||
_customProviders = Array.isArray(val) ? val : (val ? JSON.parse(val) : []);
|
||||
} catch (e) {
|
||||
_customProviders = [];
|
||||
}
|
||||
try {
|
||||
const val = s['llm_builtin_overrides'];
|
||||
_builtinOverrides = (val && typeof val === 'object' && !Array.isArray(val)) ? val : {};
|
||||
} catch (e) {
|
||||
_builtinOverrides = {};
|
||||
}
|
||||
_envDefaults = (envDefs && typeof envDefs === 'object') ? envDefs : {};
|
||||
_configLoaded = true;
|
||||
renderProviders();
|
||||
}).catch(() => {
|
||||
_activeLlmBackend = 'nearai';
|
||||
_selectedModel = '';
|
||||
_customProviders = [];
|
||||
_builtinOverrides = {};
|
||||
_envDefaults = {};
|
||||
_configLoaded = true;
|
||||
renderProviders();
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToProviders() {
|
||||
const section = document.getElementById('providers-section');
|
||||
if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function renderProviders() {
|
||||
const list = document.getElementById('providers-list');
|
||||
const allProviders = [...BUILTIN_PROVIDERS, ..._customProviders].sort((a, b) => {
|
||||
if (a.id === _activeLlmBackend) return -1;
|
||||
if (b.id === _activeLlmBackend) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (allProviders.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">No providers</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = allProviders.map((p) => {
|
||||
const isActive = p.id === _activeLlmBackend;
|
||||
const adapterLabel = ADAPTER_LABELS[p.adapter] || p.adapter;
|
||||
const activeBadge = isActive
|
||||
? '<span class="provider-badge provider-badge-active">' + I18n.t('status.active') + '</span>'
|
||||
: '';
|
||||
const builtinBadge = p.builtin
|
||||
? '<span class="provider-badge provider-badge-builtin">' + I18n.t('config.builtin') + '</span>'
|
||||
: '';
|
||||
const deleteBtn = !p.builtin && !isActive
|
||||
? '<button class="provider-action-btn provider-delete-btn" data-action="delete-custom-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('common.delete') + '</button>'
|
||||
: '';
|
||||
const editBtn = !p.builtin
|
||||
? '<button class="provider-action-btn" data-action="edit-custom-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('common.edit') + '</button>'
|
||||
: '';
|
||||
// Show Configure for built-in providers that support it (not bedrock — uses AWS credential chain)
|
||||
const configureBtn = p.builtin && p.id !== 'bedrock'
|
||||
? '<button class="provider-action-btn" data-action="configure-builtin-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('config.configureProvider') + '</button>'
|
||||
: '';
|
||||
const useBtn = !isActive
|
||||
? '<button class="provider-action-btn" data-action="set-active-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('config.useProvider') + '</button>'
|
||||
: '';
|
||||
const envDef = _envDefaults[p.id] || {};
|
||||
const overrideBaseUrl = p.builtin && _builtinOverrides[p.id] ? (_builtinOverrides[p.id].base_url || '') : '';
|
||||
const effectiveBaseUrl = overrideBaseUrl || envDef.base_url || p.base_url;
|
||||
const baseUrlText = effectiveBaseUrl
|
||||
? '<span class="provider-url">' + escapeHtml(effectiveBaseUrl) + '</span>'
|
||||
: '';
|
||||
// Show configured model: for active provider use _selectedModel, for others check _builtinOverrides then env defaults
|
||||
const overrideModel = p.builtin && _builtinOverrides[p.id] ? (_builtinOverrides[p.id].model || '') : '';
|
||||
const displayModel = isActive
|
||||
? (_selectedModel || envDef.model || '')
|
||||
: (overrideModel || envDef.model || '');
|
||||
const modelText = displayModel
|
||||
? '<span class="provider-current-model">' + escapeHtml(I18n.t('config.currentModel', { model: displayModel })) + '</span>'
|
||||
: '';
|
||||
|
||||
return '<div class="provider-card' + (isActive ? ' provider-card-active' : '') + '">'
|
||||
+ '<div class="provider-card-header">'
|
||||
+ '<span class="provider-name">' + escapeHtml(p.name || p.id) + '</span>'
|
||||
+ '<span class="provider-id-label">' + escapeHtml(p.id) + '</span>'
|
||||
+ activeBadge + builtinBadge
|
||||
+ '</div>'
|
||||
+ '<div class="provider-card-meta">'
|
||||
+ '<span class="provider-adapter">' + escapeHtml(adapterLabel) + '</span>'
|
||||
+ baseUrlText
|
||||
+ modelText
|
||||
+ '</div>'
|
||||
+ '<div class="provider-card-actions">'
|
||||
+ useBtn + configureBtn + editBtn + deleteBtn
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setActiveProvider(id) {
|
||||
const provider = [...BUILTIN_PROVIDERS, ..._customProviders].find((p) => p.id === id);
|
||||
// Restore the last-configured model for this provider, falling back to the provider's default
|
||||
const restoredModel =
|
||||
(_builtinOverrides[id] && _builtinOverrides[id].model) ||
|
||||
(provider && provider.default_model) ||
|
||||
null;
|
||||
const defaultModel = restoredModel;
|
||||
const modelUpdate = () => defaultModel
|
||||
? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } })
|
||||
: apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' });
|
||||
apiFetchVoid('/api/settings/llm_backend', { method: 'PUT', body: { value: id } })
|
||||
.then(() => modelUpdate())
|
||||
.then(() => {
|
||||
_activeLlmBackend = id;
|
||||
_selectedModel = defaultModel || '';
|
||||
renderProviders();
|
||||
loadInferenceSettings();
|
||||
scrollToProviders();
|
||||
document.getElementById('config-restart-notice').style.display = 'flex';
|
||||
var llmNotice = document.getElementById('llm-restart-notice');
|
||||
if (llmNotice) llmNotice.style.display = 'flex';
|
||||
showToast(I18n.t('config.providerActivated', { name: id }));
|
||||
})
|
||||
.catch((e) => showToast(I18n.t('error.unknown') + ': ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
function deleteCustomProvider(id) {
|
||||
if (id === _activeLlmBackend) {
|
||||
showToast(I18n.t('config.cannotDeleteActiveProvider'), 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm(I18n.t('config.confirmDeleteProvider', { id }))) return;
|
||||
const originalProviders = _customProviders;
|
||||
_customProviders = _customProviders.filter((p) => p.id !== id);
|
||||
saveCustomProviders().then(() => {
|
||||
renderProviders();
|
||||
showToast(I18n.t('config.providerDeleted'));
|
||||
}).catch((e) => {
|
||||
_customProviders = originalProviders;
|
||||
showToast(I18n.t('error.unknown') + ': ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function saveCustomProviders() {
|
||||
return apiFetchVoid('/api/settings/llm_custom_providers', { method: 'PUT', body: { value: _customProviders } });
|
||||
}
|
||||
|
||||
function editCustomProvider(id) {
|
||||
const p = _customProviders.find((p) => p.id === id);
|
||||
if (!p) return;
|
||||
_editingProviderId = id;
|
||||
const titleEl = document.getElementById('provider-form-title');
|
||||
titleEl.textContent = I18n.t('config.editProvider');
|
||||
titleEl.removeAttribute('data-i18n');
|
||||
document.getElementById('provider-name').value = p.name || '';
|
||||
const idField = document.getElementById('provider-id');
|
||||
idField.value = p.id;
|
||||
idField.readOnly = true;
|
||||
idField.style.opacity = '0.6';
|
||||
document.getElementById('provider-adapter').value = p.adapter || 'open_ai_completions';
|
||||
document.getElementById('provider-base-url').value = p.base_url || '';
|
||||
const editApiKeyInput = document.getElementById('provider-api-key');
|
||||
if (p.api_key === '••••••••') {
|
||||
editApiKeyInput.value = '';
|
||||
editApiKeyInput.placeholder = 'Key configured (leave blank to keep)';
|
||||
} else {
|
||||
editApiKeyInput.value = '';
|
||||
editApiKeyInput.placeholder = 'Enter API key';
|
||||
}
|
||||
document.getElementById('provider-model').value = p.default_model || '';
|
||||
openProviderDialog(true);
|
||||
document.getElementById('provider-name').focus();
|
||||
}
|
||||
|
||||
function configureBuiltinProvider(id) {
|
||||
const p = BUILTIN_PROVIDERS.find((p) => p.id === id);
|
||||
if (!p) return;
|
||||
_configuringBuiltinId = id;
|
||||
const titleEl = document.getElementById('provider-form-title');
|
||||
titleEl.textContent = I18n.t('config.configureProvider') + ': ' + (p.name || id);
|
||||
titleEl.removeAttribute('data-i18n');
|
||||
// Hide name/id/adapter rows; show base-url as editable
|
||||
document.getElementById('provider-name-row').style.display = 'none';
|
||||
document.getElementById('provider-id-row').style.display = 'none';
|
||||
document.getElementById('provider-adapter-row').style.display = 'none';
|
||||
const baseUrlInput = document.getElementById('provider-base-url');
|
||||
const override = _builtinOverrides[id] || {};
|
||||
const envDef = _envDefaults[id] || {};
|
||||
// Priority: db override > env > hardcoded default
|
||||
const effectiveBaseUrl = override.base_url || envDef.base_url || p.base_url;
|
||||
document.getElementById('provider-base-url-row').style.display = '';
|
||||
baseUrlInput.value = effectiveBaseUrl || '';
|
||||
baseUrlInput.readOnly = false;
|
||||
baseUrlInput.style.opacity = '';
|
||||
baseUrlInput.placeholder = p.base_url || '';
|
||||
document.getElementById('provider-api-key-row').style.display = p.api_key_required !== false ? '' : 'none';
|
||||
document.getElementById('fetch-models-btn').style.display = p.can_list_models ? '' : 'none';
|
||||
const apiKeyInput = document.getElementById('provider-api-key');
|
||||
const hasDbKey = override.api_key === '••••••••';
|
||||
const hasEnvKey = envDef.has_api_key === true;
|
||||
apiKeyInput.value = '';
|
||||
if (hasDbKey) {
|
||||
apiKeyInput.placeholder = 'Key configured (leave blank to keep)';
|
||||
} else if (hasEnvKey) {
|
||||
apiKeyInput.placeholder = 'Key set via environment variable';
|
||||
} else {
|
||||
apiKeyInput.placeholder = 'Enter API key';
|
||||
}
|
||||
document.getElementById('provider-model').value = override.model || envDef.model || p.default_model || '';
|
||||
openProviderDialog(true);
|
||||
document.getElementById('provider-model').focus();
|
||||
}
|
||||
|
||||
// Add provider form
|
||||
|
||||
document.getElementById('add-provider-btn').addEventListener('click', () => {
|
||||
openProviderDialog(false);
|
||||
});
|
||||
|
||||
document.getElementById('cancel-provider-btn').addEventListener('click', () => {
|
||||
resetProviderForm();
|
||||
});
|
||||
|
||||
document.getElementById('cancel-provider-footer-btn').addEventListener('click', () => {
|
||||
resetProviderForm();
|
||||
});
|
||||
|
||||
document.getElementById('provider-dialog-overlay').addEventListener('click', () => {
|
||||
resetProviderForm();
|
||||
});
|
||||
|
||||
function openProviderDialog(isEdit) {
|
||||
if (!isEdit) {
|
||||
// Add mode: ensure all rows visible
|
||||
['provider-name-row', 'provider-id-row', 'provider-adapter-row',
|
||||
'provider-base-url-row', 'provider-api-key-row'].forEach((id) => {
|
||||
document.getElementById(id).style.display = '';
|
||||
});
|
||||
document.getElementById('fetch-models-btn').style.display = '';
|
||||
}
|
||||
document.getElementById('provider-dialog').style.display = 'flex';
|
||||
if (!isEdit) {
|
||||
document.getElementById('provider-name').focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('test-provider-btn').addEventListener('click', () => {
|
||||
let adapter = document.getElementById('provider-adapter').value;
|
||||
let baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
const apiKey = document.getElementById('provider-api-key').value.trim();
|
||||
const model = document.getElementById('provider-model').value.trim();
|
||||
|
||||
// For built-in providers, use the hardcoded adapter from BUILTIN_PROVIDERS.
|
||||
// base_url comes from the form which already reflects: env > hardcoded default.
|
||||
if (_configuringBuiltinId) {
|
||||
const p = BUILTIN_PROVIDERS.find((x) => x.id === _configuringBuiltinId);
|
||||
if (p) {
|
||||
adapter = p.adapter;
|
||||
if (!baseUrl) baseUrl = p.base_url;
|
||||
}
|
||||
}
|
||||
|
||||
const btn = document.getElementById('test-provider-btn');
|
||||
const result = document.getElementById('test-connection-result');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = I18n.t('config.testing');
|
||||
result.style.display = 'none';
|
||||
result.className = 'test-connection-result';
|
||||
|
||||
// Resolve provider_id so the backend can look up vaulted API keys.
|
||||
const providerId = _configuringBuiltinId || document.getElementById('provider-id').value.trim();
|
||||
|
||||
if (!model) {
|
||||
result.textContent = I18n.t('config.modelRequired') || 'Model is required for connection test';
|
||||
result.className = 'test-connection-result test-fail';
|
||||
result.style.display = '';
|
||||
btn.disabled = false;
|
||||
btn.textContent = I18n.t('config.testConnection');
|
||||
return;
|
||||
}
|
||||
|
||||
apiFetch('/api/llm/test_connection', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
adapter, base_url: baseUrl,
|
||||
api_key: apiKey || undefined,
|
||||
model,
|
||||
provider_id: providerId || undefined,
|
||||
provider_type: _configuringBuiltinId ? 'builtin' : 'custom',
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
result.textContent = data.message;
|
||||
result.className = 'test-connection-result ' + (data.ok ? 'test-ok' : 'test-fail');
|
||||
result.style.display = '';
|
||||
})
|
||||
.catch((e) => {
|
||||
result.textContent = e.message;
|
||||
result.className = 'test-connection-result test-fail';
|
||||
result.style.display = '';
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = I18n.t('config.testConnection');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('save-provider-btn').addEventListener('click', () => {
|
||||
// Built-in configure mode: save api_key + model to llm_builtin_overrides
|
||||
if (_configuringBuiltinId) {
|
||||
const apiKey = document.getElementById('provider-api-key').value.trim();
|
||||
const model = document.getElementById('provider-model').value.trim();
|
||||
const baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
const id = _configuringBuiltinId;
|
||||
const prevOverride = _builtinOverrides[id] || {};
|
||||
const hadKey = prevOverride.api_key === '••••••••';
|
||||
const override = {};
|
||||
if (apiKey) {
|
||||
override.api_key = apiKey; // New key entered — backend will encrypt it
|
||||
} else if (hadKey) {
|
||||
override.api_key = '••••••••'; // Sentinel: keep existing encrypted key
|
||||
}
|
||||
// If neither — key is cleared (no key configured)
|
||||
if (model) override.model = model;
|
||||
if (baseUrl) override.base_url = baseUrl;
|
||||
const prev = _builtinOverrides[id];
|
||||
_builtinOverrides[id] = override;
|
||||
const isActive = id === _activeLlmBackend;
|
||||
const modelUpdate = () => {
|
||||
if (!isActive) return Promise.resolve();
|
||||
if (model) {
|
||||
return apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } });
|
||||
}
|
||||
return apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' });
|
||||
};
|
||||
apiFetchVoid('/api/settings/llm_builtin_overrides', { method: 'PUT', body: { value: _builtinOverrides } })
|
||||
.then(() => modelUpdate())
|
||||
.then(() => {
|
||||
if (isActive) _selectedModel = model;
|
||||
renderProviders();
|
||||
if (isActive) loadInferenceSettings();
|
||||
resetProviderForm();
|
||||
scrollToProviders();
|
||||
if (isActive) {
|
||||
document.getElementById('config-restart-notice').style.display = 'flex';
|
||||
var llmNotice = document.getElementById('llm-restart-notice');
|
||||
if (llmNotice) llmNotice.style.display = 'flex';
|
||||
}
|
||||
showToast(I18n.t('config.providerConfigured', { name: id }));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (prev !== undefined) { _builtinOverrides[id] = prev; } else { delete _builtinOverrides[id]; }
|
||||
showToast(I18n.t('error.unknown') + ': ' + e.message, 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('provider-name').value.trim();
|
||||
const id = document.getElementById('provider-id').value.trim();
|
||||
const adapter = document.getElementById('provider-adapter').value;
|
||||
const baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
const apiKey = document.getElementById('provider-api-key').value.trim();
|
||||
const model = document.getElementById('provider-model').value.trim();
|
||||
|
||||
if (!id || !name) {
|
||||
showToast(I18n.t('config.providerFieldsRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_editingProviderId) {
|
||||
// Update existing provider
|
||||
const idx = _customProviders.findIndex((p) => p.id === _editingProviderId);
|
||||
if (idx === -1) return;
|
||||
const original = _customProviders[idx];
|
||||
const hadCustomKey = original.api_key === '••••••••';
|
||||
let effectiveApiKey;
|
||||
if (apiKey) {
|
||||
effectiveApiKey = apiKey; // New key — backend will encrypt it
|
||||
} else if (hadCustomKey) {
|
||||
effectiveApiKey = '••••••••'; // Sentinel: keep existing encrypted key
|
||||
} else {
|
||||
effectiveApiKey = undefined; // No key
|
||||
}
|
||||
_customProviders[idx] = { ...original, name, adapter, base_url: baseUrl, default_model: model || undefined, api_key: effectiveApiKey };
|
||||
const isActive = _editingProviderId === _activeLlmBackend;
|
||||
const modelUpdate = () => {
|
||||
if (!isActive) return Promise.resolve();
|
||||
if (model) {
|
||||
return apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } });
|
||||
}
|
||||
return apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' });
|
||||
};
|
||||
saveCustomProviders().then(() => modelUpdate()).then(() => {
|
||||
if (isActive) _selectedModel = model;
|
||||
renderProviders();
|
||||
if (isActive) loadInferenceSettings();
|
||||
resetProviderForm();
|
||||
scrollToProviders();
|
||||
if (isActive) {
|
||||
document.getElementById('config-restart-notice').style.display = 'flex';
|
||||
var llmNotice = document.getElementById('llm-restart-notice');
|
||||
if (llmNotice) llmNotice.style.display = 'flex';
|
||||
}
|
||||
showToast(I18n.t('config.providerUpdated', { name }));
|
||||
}).catch((e) => {
|
||||
_customProviders[idx] = original;
|
||||
showToast(I18n.t('error.unknown') + ': ' + e.message, 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(id)) {
|
||||
showToast(I18n.t('config.providerIdInvalid'), 'error');
|
||||
return;
|
||||
}
|
||||
const allIds = [...BUILTIN_PROVIDERS.map((p) => p.id), ..._customProviders.map((p) => p.id)];
|
||||
if (allIds.includes(id)) {
|
||||
showToast(I18n.t('config.providerIdTaken', { id }), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newProvider = { id, name, adapter, base_url: baseUrl, default_model: model, api_key: apiKey || undefined, builtin: false };
|
||||
_customProviders.push(newProvider);
|
||||
|
||||
saveCustomProviders().then(() => {
|
||||
renderProviders();
|
||||
resetProviderForm();
|
||||
scrollToProviders();
|
||||
showToast(I18n.t('config.providerAdded', { name }));
|
||||
}).catch((e) => {
|
||||
_customProviders.pop();
|
||||
showToast(I18n.t('error.unknown') + ': ' + e.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
function resetProviderForm() {
|
||||
_editingProviderId = null;
|
||||
_configuringBuiltinId = null;
|
||||
document.getElementById('provider-dialog').style.display = 'none';
|
||||
// Restore all hidden rows and buttons
|
||||
['provider-name-row', 'provider-id-row', 'provider-adapter-row',
|
||||
'provider-base-url-row', 'provider-api-key-row'].forEach((id) => {
|
||||
document.getElementById(id).style.display = '';
|
||||
});
|
||||
document.getElementById('fetch-models-btn').style.display = '';
|
||||
const titleEl = document.getElementById('provider-form-title');
|
||||
titleEl.setAttribute('data-i18n', 'config.newProvider');
|
||||
titleEl.textContent = I18n.t('config.newProvider');
|
||||
const idField = document.getElementById('provider-id');
|
||||
idField.readOnly = false;
|
||||
idField.style.opacity = '';
|
||||
delete idField.dataset.edited;
|
||||
const baseUrlField = document.getElementById('provider-base-url');
|
||||
baseUrlField.readOnly = false;
|
||||
baseUrlField.style.opacity = '';
|
||||
['provider-name', 'provider-id', 'provider-base-url', 'provider-api-key', 'provider-model'].forEach((id) => {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
document.getElementById('provider-adapter').selectedIndex = 0;
|
||||
const sel = document.getElementById('provider-model-select');
|
||||
sel.innerHTML = '';
|
||||
sel.style.display = 'none';
|
||||
document.getElementById('test-connection-result').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('provider-model-select').addEventListener('change', (e) => {
|
||||
document.getElementById('provider-model').value = e.target.value;
|
||||
});
|
||||
|
||||
document.getElementById('fetch-models-btn').addEventListener('click', () => {
|
||||
let adapter = document.getElementById('provider-adapter').value;
|
||||
let baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
const apiKey = document.getElementById('provider-api-key').value.trim();
|
||||
|
||||
// For built-in providers, use the hardcoded adapter from BUILTIN_PROVIDERS.
|
||||
// base_url comes from the form which already reflects: env > hardcoded default.
|
||||
if (_configuringBuiltinId) {
|
||||
const p = BUILTIN_PROVIDERS.find((x) => x.id === _configuringBuiltinId);
|
||||
if (p) {
|
||||
adapter = p.adapter;
|
||||
if (!baseUrl) baseUrl = p.base_url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
showToast(I18n.t('config.providerBaseUrlRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('fetch-models-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = I18n.t('config.fetchingModels');
|
||||
|
||||
// Resolve provider_id so the backend can look up vaulted API keys.
|
||||
const providerId = _configuringBuiltinId || document.getElementById('provider-id').value.trim();
|
||||
|
||||
apiFetch('/api/llm/list_models', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
adapter, base_url: baseUrl,
|
||||
api_key: apiKey || undefined,
|
||||
provider_id: providerId || undefined,
|
||||
provider_type: _configuringBuiltinId ? 'builtin' : 'custom',
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
const select = document.getElementById('provider-model-select');
|
||||
if (data.ok && data.models && data.models.length > 0) {
|
||||
const currentModel = document.getElementById('provider-model').value;
|
||||
select.innerHTML = data.models
|
||||
.map((m) => `<option value="${escapeHtml(m)}"${m === currentModel ? ' selected' : ''}>${escapeHtml(m)}</option>`)
|
||||
.join('');
|
||||
select.style.display = '';
|
||||
btn.style.display = 'none';
|
||||
showToast(I18n.t('config.modelsFetched', { count: data.models.length }));
|
||||
} else {
|
||||
showToast(data.message || I18n.t('config.modelsFetchFailed'), 'error');
|
||||
}
|
||||
})
|
||||
.catch((e) => showToast(e.message, 'error'))
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = I18n.t('config.fetchModels');
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-fill provider ID from name
|
||||
document.getElementById('provider-name').addEventListener('input', (e) => {
|
||||
const idField = document.getElementById('provider-id');
|
||||
if (!idField.dataset.edited) {
|
||||
idField.value = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('provider-id').addEventListener('input', (e) => {
|
||||
e.target.dataset.edited = e.target.value ? '1' : '';
|
||||
});
|
||||
|
||||
@@ -35,27 +35,16 @@ function switchLanguage(lang) {
|
||||
if (I18n.setLanguage(lang)) {
|
||||
// Update slash commands
|
||||
updateSlashCommands();
|
||||
|
||||
|
||||
// Update language menu active state
|
||||
updateLanguageMenu();
|
||||
|
||||
// Re-render dynamically built sections that use I18n.t()
|
||||
if (typeof renderProviders === 'function' && typeof _configLoaded !== 'undefined' && _configLoaded) {
|
||||
renderProviders();
|
||||
}
|
||||
if (typeof loadInferenceSettings === 'function') {
|
||||
var inferencePanel = document.getElementById('settings-inference');
|
||||
if (inferencePanel && inferencePanel.classList.contains('active')) {
|
||||
loadInferenceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Close menu
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
// Show toast notification
|
||||
showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English'));
|
||||
}
|
||||
|
||||
@@ -38,14 +38,12 @@ I18n.register('en', {
|
||||
'tab.settings': 'Settings',
|
||||
'tab.extensions': 'Extensions',
|
||||
'tab.skills': 'Skills',
|
||||
'tab.config': 'Config',
|
||||
'tab.logs': 'Logs',
|
||||
'settings.inference': 'Inference',
|
||||
'settings.agent': 'Agent',
|
||||
'settings.channels': 'Channels',
|
||||
'settings.networking': 'Networking',
|
||||
'settings.mcp': 'MCP',
|
||||
'settings.providers': 'Providers',
|
||||
|
||||
// Status
|
||||
'status.connected': 'Connected',
|
||||
@@ -352,45 +350,6 @@ I18n.register('en', {
|
||||
'ext.removed': 'Removed {name}',
|
||||
'ext.installFailed': 'Install failed: {message}',
|
||||
|
||||
// Config Tab — Model Providers
|
||||
'config.modelProviders': 'Model Providers',
|
||||
'config.addProvider': '+ Add Provider',
|
||||
'config.newProvider': 'New Provider',
|
||||
'config.restartNotice': 'Changes take effect after restart.',
|
||||
'config.builtin': 'built-in',
|
||||
'config.useProvider': 'Use',
|
||||
'config.configureProvider': 'Configure',
|
||||
'config.providerConfigured': 'Provider "{name}" configured (restart to apply)',
|
||||
'config.currentModel': 'Model: {model}',
|
||||
'config.providerName': 'Display Name',
|
||||
'config.providerNamePlaceholder': 'My Provider',
|
||||
'config.providerId': 'Provider ID',
|
||||
'config.providerIdPlaceholder': 'my-provider',
|
||||
'config.providerIdHint': 'Lowercase letters, numbers, hyphens',
|
||||
'config.providerAdapter': 'API Adapter',
|
||||
'config.adapterOpenAI': 'OpenAI Compatible',
|
||||
'config.adapterAnthropic': 'Anthropic',
|
||||
'config.adapterOllama': 'Ollama',
|
||||
'config.providerBaseUrl': 'Base URL',
|
||||
'config.providerApiKey': 'API Key',
|
||||
'config.providerModel': 'Default Model',
|
||||
'config.providerActivated': 'Switched to {name} (restart to apply)',
|
||||
'config.providerAdded': 'Added provider "{name}" (restart to apply)',
|
||||
'config.providerUpdated': 'Provider "{name}" updated (restart to apply)',
|
||||
'config.editProvider': 'Edit Provider',
|
||||
'config.providerDeleted': 'Provider deleted',
|
||||
'config.confirmDeleteProvider': 'Delete provider "{id}"?',
|
||||
'config.cannotDeleteActiveProvider': 'Cannot delete the active provider. Switch to another provider first.',
|
||||
'config.testConnection': 'Test',
|
||||
'config.testing': 'Testing…',
|
||||
'config.fetchModels': 'Fetch available models',
|
||||
'config.modelsFetched': '{count} model(s) loaded — type to filter',
|
||||
'config.modelsFetchFailed': 'Failed to fetch models',
|
||||
'config.providerBaseUrlRequired': 'Base URL is required to fetch models',
|
||||
'config.providerFieldsRequired': 'Display name and Provider ID are required',
|
||||
'config.providerIdInvalid': 'Provider ID: use only lowercase letters, numbers, hyphens',
|
||||
'config.providerIdTaken': 'Provider ID "{id}" is already taken',
|
||||
|
||||
// Configure
|
||||
'config.title': 'Configure {name}',
|
||||
'config.telegramOwnerHint': 'After saving, IronClaw will show a one-time code. Send `/start CODE` to your bot in Telegram and IronClaw will finish setup automatically.',
|
||||
|
||||
@@ -38,14 +38,12 @@ I18n.register('zh-CN', {
|
||||
'tab.settings': '设置',
|
||||
'tab.extensions': '扩展',
|
||||
'tab.skills': '技能',
|
||||
'tab.config': '配置',
|
||||
'tab.logs': '日志',
|
||||
'settings.inference': '推理',
|
||||
'settings.agent': '代理',
|
||||
'settings.channels': '频道',
|
||||
'settings.networking': '网络',
|
||||
'settings.mcp': 'MCP',
|
||||
'settings.providers': '模型提供商',
|
||||
|
||||
// 状态
|
||||
'status.connected': '已连接',
|
||||
@@ -352,45 +350,6 @@ I18n.register('zh-CN', {
|
||||
'ext.removed': '已移除 {name}',
|
||||
'ext.installFailed': '安装失败: {message}',
|
||||
|
||||
// 配置页 — 模型提供商
|
||||
'config.modelProviders': '模型提供商',
|
||||
'config.addProvider': '+ 添加提供商',
|
||||
'config.newProvider': '新建提供商',
|
||||
'config.restartNotice': '更改将在重启后生效。',
|
||||
'config.builtin': '内置',
|
||||
'config.useProvider': '使用',
|
||||
'config.configureProvider': '配置',
|
||||
'config.providerConfigured': '提供商 "{name}" 已配置(重启后生效)',
|
||||
'config.currentModel': '模型:{model}',
|
||||
'config.providerName': '显示名称',
|
||||
'config.providerNamePlaceholder': '我的提供商',
|
||||
'config.providerId': '提供商 ID',
|
||||
'config.providerIdPlaceholder': 'my-provider',
|
||||
'config.providerIdHint': '小写字母、数字、连字符',
|
||||
'config.providerAdapter': 'API 适配器',
|
||||
'config.adapterOpenAI': 'OpenAI 兼容',
|
||||
'config.adapterAnthropic': 'Anthropic',
|
||||
'config.adapterOllama': 'Ollama',
|
||||
'config.providerBaseUrl': '基础 URL',
|
||||
'config.providerApiKey': 'API 密钥',
|
||||
'config.providerModel': '默认模型',
|
||||
'config.providerActivated': '已切换到 {name}(重启后生效)',
|
||||
'config.providerAdded': '已添加提供商 "{name}"(重启后生效)',
|
||||
'config.providerUpdated': '提供商 "{name}" 已更新(重启后生效)',
|
||||
'config.editProvider': '编辑提供商',
|
||||
'config.providerDeleted': '提供商已删除',
|
||||
'config.confirmDeleteProvider': '确定删除提供商 "{id}"?',
|
||||
'config.cannotDeleteActiveProvider': '无法删除当前正在使用的提供商,请先切换到其他提供商。',
|
||||
'config.testConnection': '测试',
|
||||
'config.testing': '测试中…',
|
||||
'config.fetchModels': '获取可用模型',
|
||||
'config.modelsFetched': '已加载 {count} 个模型,可输入过滤',
|
||||
'config.modelsFetchFailed': '获取模型列表失败',
|
||||
'config.providerBaseUrlRequired': '请先填写 Base URL',
|
||||
'config.providerFieldsRequired': '显示名称和提供商 ID 为必填项',
|
||||
'config.providerIdInvalid': '提供商 ID 只能包含小写字母、数字和连字符',
|
||||
'config.providerIdTaken': '提供商 ID "{id}" 已被占用',
|
||||
|
||||
// 配置
|
||||
'config.title': '配置 {name}',
|
||||
'config.telegramOwnerHint': '保存后,IronClaw 会显示一次性验证码。将 `/start CODE` 发送给你的 Telegram 机器人,IronClaw 会自动完成设置。',
|
||||
|
||||
@@ -45,58 +45,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider Add/Edit Dialog -->
|
||||
<div id="provider-dialog" class="provider-dialog" style="display:none">
|
||||
<div class="provider-dialog-overlay" id="provider-dialog-overlay"></div>
|
||||
<div class="provider-dialog-content">
|
||||
<div class="provider-dialog-header">
|
||||
<h2 id="provider-form-title" data-i18n="config.newProvider">New Provider</h2>
|
||||
<button class="provider-dialog-close" id="cancel-provider-btn" title="Close">×</button>
|
||||
</div>
|
||||
<div class="provider-dialog-body">
|
||||
<div class="config-form">
|
||||
<div class="config-form-row" id="provider-name-row">
|
||||
<label data-i18n="config.providerName">Display Name</label>
|
||||
<input type="text" id="provider-name" data-i18n="config.providerNamePlaceholder" data-i18n-attr="placeholder" placeholder="My Provider">
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-id-row">
|
||||
<label data-i18n="config.providerId">Provider ID</label>
|
||||
<input type="text" id="provider-id" data-i18n="config.providerIdPlaceholder" data-i18n-attr="placeholder" placeholder="my-provider">
|
||||
<span class="config-form-hint" data-i18n="config.providerIdHint">Lowercase letters, numbers, hyphens</span>
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-adapter-row">
|
||||
<label data-i18n="config.providerAdapter">API Adapter</label>
|
||||
<select id="provider-adapter">
|
||||
<option value="open_ai_completions" data-i18n="config.adapterOpenAI">OpenAI Compatible</option>
|
||||
<option value="anthropic" data-i18n="config.adapterAnthropic">Anthropic</option>
|
||||
<option value="ollama" data-i18n="config.adapterOllama">Ollama</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-base-url-row">
|
||||
<label data-i18n="config.providerBaseUrl">Base URL</label>
|
||||
<input type="text" id="provider-base-url" placeholder="https://api.example.com/v1">
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-api-key-row">
|
||||
<label data-i18n="config.providerApiKey">API Key</label>
|
||||
<input type="password" id="provider-api-key" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="config-form-row">
|
||||
<label data-i18n="config.providerModel">Default Model</label>
|
||||
<input type="text" id="provider-model" placeholder="gpt-4o">
|
||||
<button id="fetch-models-btn" class="btn-fetch-models" type="button" data-i18n="config.fetchModels">↻ Fetch available models</button>
|
||||
<select id="provider-model-select" style="display:none"></select>
|
||||
</div>
|
||||
<div id="test-connection-result" class="test-connection-result" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-dialog-footer">
|
||||
<button id="save-provider-btn" data-i18n="common.save">Save</button>
|
||||
<button id="test-provider-btn" class="btn-secondary" data-i18n="config.testConnection">Test</button>
|
||||
<button id="cancel-provider-footer-btn" class="btn-secondary" data-i18n="common.cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Confirmation Modal -->
|
||||
<div id="restart-confirm-modal" class="restart-modal" style="display: none;">
|
||||
<div class="restart-modal-overlay" id="restart-overlay"></div>
|
||||
@@ -357,23 +305,8 @@
|
||||
<button id="settings-import-btn" class="settings-toolbar-btn" data-i18n="settings.import">Import</button>
|
||||
</div>
|
||||
<div class="settings-subpanel active" id="settings-inference">
|
||||
<div class="extensions-container">
|
||||
<div id="settings-inference-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
||||
</div>
|
||||
<div class="extensions-section" id="providers-section">
|
||||
<div class="config-section-header">
|
||||
<h3 data-i18n="config.modelProviders">Model Providers</h3>
|
||||
<button id="add-provider-btn" class="btn-add-provider" data-i18n="config.addProvider">+ Add Provider</button>
|
||||
</div>
|
||||
<div class="config-notice" id="config-restart-notice" style="display:none">
|
||||
<span>⚠</span>
|
||||
<span data-i18n="config.restartNotice">Changes take effect after restart.</span>
|
||||
</div>
|
||||
<div id="providers-list" class="providers-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-container" id="settings-inference-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-subpanel" id="settings-agent">
|
||||
@@ -475,7 +408,6 @@
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
<script src="/providers.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/i18n-app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Built-in LLM provider definitions.
|
||||
// Generated from providers.json + nearai/bedrock (handled separately in llm.rs)
|
||||
// Fields: id, name, adapter, base_url, builtin, default_model, api_key_required, can_list_models
|
||||
// nearai/bedrock use special auth flows — no Configure button (api_key_required=false, can_list_models=false)
|
||||
const BUILTIN_PROVIDERS = [
|
||||
{ id: 'nearai', name: 'NEAR AI', adapter: 'nearai', base_url: 'https://cloud-api.near.ai/v1', builtin: true, default_model: 'zai-org/GLM-5-FP8', api_key_required: true, can_list_models: true },
|
||||
{ id: 'openai', name: 'OpenAI', adapter: 'open_ai_completions', base_url: 'https://api.openai.com/v1', builtin: true, default_model: 'gpt-4o-mini', api_key_required: true, can_list_models: true },
|
||||
{ id: 'anthropic', name: 'Anthropic', adapter: 'anthropic', base_url: 'https://api.anthropic.com', builtin: true, default_model: 'claude-sonnet-4-20250514', api_key_required: true, can_list_models: true },
|
||||
{ id: 'ollama', name: 'Ollama', adapter: 'ollama', base_url: 'http://localhost:11434', builtin: true, default_model: 'llama3', api_key_required: false, can_list_models: true },
|
||||
{ id: 'openai_compatible', name: 'OpenAI Compatible', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: 'default', api_key_required: false, can_list_models: false },
|
||||
{ id: 'gemini', name: 'Google Gemini', adapter: 'open_ai_completions', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', builtin: true, default_model: 'gemini-2.5-flash', api_key_required: true, can_list_models: true },
|
||||
{ id: 'groq', name: 'Groq', adapter: 'open_ai_completions', base_url: 'https://api.groq.com/openai/v1', builtin: true, default_model: 'llama-3.3-70b-versatile', api_key_required: true, can_list_models: true },
|
||||
{ id: 'openrouter', name: 'OpenRouter', adapter: 'open_ai_completions', base_url: 'https://openrouter.ai/api/v1', builtin: true, default_model: 'openai/gpt-4o', api_key_required: true, can_list_models: false },
|
||||
{ id: 'deepseek', name: 'DeepSeek', adapter: 'open_ai_completions', base_url: 'https://api.deepseek.com/v1', builtin: true, default_model: 'deepseek-chat', api_key_required: true, can_list_models: false },
|
||||
{ id: 'mistral', name: 'Mistral', adapter: 'open_ai_completions', base_url: 'https://api.mistral.ai/v1', builtin: true, default_model: 'mistral-large-latest', api_key_required: true, can_list_models: true },
|
||||
{ id: 'tinfoil', name: 'Tinfoil', adapter: 'open_ai_completions', base_url: 'https://inference.tinfoil.sh/v1', builtin: true, default_model: 'kimi-k2-5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'nvidia', name: 'NVIDIA NIM', adapter: 'open_ai_completions', base_url: 'https://integrate.api.nvidia.com/v1', builtin: true, default_model: 'meta/llama-3.3-70b-instruct', api_key_required: true, can_list_models: true },
|
||||
{ id: 'together', name: 'Together AI', adapter: 'open_ai_completions', base_url: 'https://api.together.xyz/v1', builtin: true, default_model: 'meta-llama/Llama-3-70b-chat-hf', api_key_required: true, can_list_models: false },
|
||||
{ id: 'fireworks', name: 'Fireworks AI', adapter: 'open_ai_completions', base_url: 'https://api.fireworks.ai/inference/v1', builtin: true, default_model: 'accounts/fireworks/models/llama-v3p1-70b-instruct', api_key_required: true, can_list_models: false },
|
||||
{ id: 'cerebras', name: 'Cerebras', adapter: 'open_ai_completions', base_url: 'https://api.cerebras.ai/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false },
|
||||
{ id: 'sambanova', name: 'SambaNova', adapter: 'open_ai_completions', base_url: 'https://api.sambanova.ai/v1', builtin: true, default_model: 'Meta-Llama-3.1-70B-Instruct', api_key_required: true, can_list_models: false },
|
||||
{ id: 'zai', name: 'Z.AI', adapter: 'open_ai_completions', base_url: 'https://api.z.ai/api/paas/v4', builtin: true, default_model: 'glm-5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'venice', name: 'Venice.ai', adapter: 'open_ai_completions', base_url: 'https://api.venice.ai/api/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false },
|
||||
{ id: 'minimax', name: 'MiniMax', adapter: 'open_ai_completions', base_url: 'https://api.minimax.io/v1', builtin: true, default_model: 'MiniMax-M2.5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'ionet', name: 'io.net', adapter: 'open_ai_completions', base_url: 'https://api.intelligence.io.solutions/api/v1', builtin: true, default_model: 'deepseek-coder-v2-instruct', api_key_required: true, can_list_models: true },
|
||||
{ id: 'cloudflare', name: 'Cloudflare AI', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast', api_key_required: true, can_list_models: false },
|
||||
{ id: 'yandex', name: 'Yandex AI Studio', adapter: 'open_ai_completions', base_url: 'https://ai.api.cloud.yandex.net/v1', builtin: true, default_model: 'yandexgpt-lite', api_key_required: true, can_list_models: true },
|
||||
{ id: 'bedrock', name: 'AWS Bedrock', adapter: 'bedrock', base_url: '', builtin: true, default_model: 'anthropic.claude-3-sonnet-20240229-v1:0', api_key_required: false, can_list_models: false },
|
||||
];
|
||||
|
||||
const ADAPTER_LABELS = {
|
||||
open_ai_completions: 'OpenAI Compatible',
|
||||
anthropic: 'Anthropic',
|
||||
ollama: 'Ollama',
|
||||
bedrock: 'AWS Bedrock',
|
||||
nearai: 'NEAR AI',
|
||||
};
|
||||
@@ -2801,22 +2801,10 @@ body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
#settings-inference > .extensions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extensions-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#providers-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extensions-section h3 {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
@@ -4605,12 +4593,6 @@ mark {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.settings-display-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
@@ -5447,405 +5429,3 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
|
||||
--text-muted: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --- Config Tab --- */
|
||||
|
||||
.config-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-section-header h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-add-provider {
|
||||
padding: 5px 14px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-provider:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.config-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(245, 166, 35, 0.1);
|
||||
border: 1px solid rgba(245, 166, 35, 0.3);
|
||||
border-radius: var(--radius);
|
||||
color: var(--warning);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.providers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.provider-card-active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.provider-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.provider-id-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.provider-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.provider-badge-active {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.provider-badge-builtin {
|
||||
background: rgba(161, 161, 170, 0.12);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.provider-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-adapter {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.provider-url {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.provider-current-model {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.provider-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.provider-action-btn {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: color 0.2s, border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.provider-action-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.provider-delete-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Config form */
|
||||
|
||||
.provider-dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provider-dialog-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.provider-dialog-content {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.provider-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-dialog-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.provider-dialog-close {
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.provider-dialog-close:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.provider-dialog-body {
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.provider-dialog-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-dialog-footer button {
|
||||
padding: 6px 18px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.provider-dialog-footer button:first-child {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.provider-dialog-footer button:first-child:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.provider-dialog-footer .btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.provider-dialog-footer .btn-secondary:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.config-form-row label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-form-row input,
|
||||
.config-form-row select {
|
||||
padding: 7px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.config-form-row input:focus,
|
||||
.config-form-row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.config-form-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.config-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.config-form-actions button {
|
||||
padding: 6px 18px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.config-form-actions button:first-child {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.config-form-actions button:first-child:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.config-form-actions .btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-form-actions .btn-secondary:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-fetch-models {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
padding: 5px 11px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.btn-fetch-models:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.btn-fetch-models:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.test-connection-result {
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.test-connection-result.test-ok {
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.test-connection-result.test-fail {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ impl TestGatewayBuilder {
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ fn build_state(
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -535,7 +535,6 @@ mod tests {
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-7
@@ -80,7 +80,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
|
||||
check(
|
||||
"Routines config",
|
||||
check_routines_config(&settings),
|
||||
check_routines_config(),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
@@ -434,8 +434,8 @@ fn check_embeddings(settings: &Settings) -> CheckResult {
|
||||
|
||||
// ── Routines config ─────────────────────────────────────────
|
||||
|
||||
fn check_routines_config(settings: &Settings) -> CheckResult {
|
||||
match crate::config::RoutineConfig::resolve(settings) {
|
||||
fn check_routines_config() -> CheckResult {
|
||||
match crate::config::RoutineConfig::resolve() {
|
||||
Ok(config) => {
|
||||
if config.enabled {
|
||||
CheckResult::Pass(format!(
|
||||
@@ -737,8 +737,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn check_routines_config_does_not_panic() {
|
||||
let settings = Settings::default();
|
||||
let result = check_routines_config(&settings);
|
||||
let result = check_routines_config();
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
@@ -867,8 +866,7 @@ mod tests {
|
||||
unsafe {
|
||||
std::env::remove_var("ROUTINES_ENABLED");
|
||||
}
|
||||
let settings = Settings::default();
|
||||
match check_routines_config(&settings) {
|
||||
match check_routines_config() {
|
||||
CheckResult::Pass(msg) => {
|
||||
assert!(
|
||||
msg.contains("enabled"),
|
||||
|
||||
+23
-41
@@ -1,8 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::helpers::{
|
||||
db_first_bool, db_first_or_default, optional_env, parse_bool_env, parse_option_env,
|
||||
};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -72,64 +70,49 @@ impl AgentConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = crate::settings::AgentSettings::default();
|
||||
|
||||
Ok(Self {
|
||||
name: db_first_or_default(&settings.agent.name, &defaults.name, "AGENT_NAME")?,
|
||||
max_parallel_jobs: db_first_or_default(
|
||||
&(settings.agent.max_parallel_jobs as usize),
|
||||
&(defaults.max_parallel_jobs as usize),
|
||||
name: parse_optional_env("AGENT_NAME", settings.agent.name.clone())?,
|
||||
max_parallel_jobs: parse_optional_env(
|
||||
"AGENT_MAX_PARALLEL_JOBS",
|
||||
settings.agent.max_parallel_jobs as usize,
|
||||
)?,
|
||||
job_timeout: Duration::from_secs(db_first_or_default(
|
||||
&settings.agent.job_timeout_secs,
|
||||
&defaults.job_timeout_secs,
|
||||
job_timeout: Duration::from_secs(parse_optional_env(
|
||||
"AGENT_JOB_TIMEOUT_SECS",
|
||||
settings.agent.job_timeout_secs,
|
||||
)?),
|
||||
stuck_threshold: Duration::from_secs(db_first_or_default(
|
||||
&settings.agent.stuck_threshold_secs,
|
||||
&defaults.stuck_threshold_secs,
|
||||
stuck_threshold: Duration::from_secs(parse_optional_env(
|
||||
"AGENT_STUCK_THRESHOLD_SECS",
|
||||
settings.agent.stuck_threshold_secs,
|
||||
)?),
|
||||
repair_check_interval: Duration::from_secs(db_first_or_default(
|
||||
&settings.agent.repair_check_interval_secs,
|
||||
&defaults.repair_check_interval_secs,
|
||||
repair_check_interval: Duration::from_secs(parse_optional_env(
|
||||
"SELF_REPAIR_CHECK_INTERVAL_SECS",
|
||||
settings.agent.repair_check_interval_secs,
|
||||
)?),
|
||||
max_repair_attempts: db_first_or_default(
|
||||
&settings.agent.max_repair_attempts,
|
||||
&defaults.max_repair_attempts,
|
||||
max_repair_attempts: parse_optional_env(
|
||||
"SELF_REPAIR_MAX_ATTEMPTS",
|
||||
settings.agent.max_repair_attempts,
|
||||
)?,
|
||||
use_planning: db_first_bool(
|
||||
settings.agent.use_planning,
|
||||
defaults.use_planning,
|
||||
"AGENT_USE_PLANNING",
|
||||
)?,
|
||||
session_idle_timeout: Duration::from_secs(db_first_or_default(
|
||||
&settings.agent.session_idle_timeout_secs,
|
||||
&defaults.session_idle_timeout_secs,
|
||||
use_planning: parse_bool_env("AGENT_USE_PLANNING", settings.agent.use_planning)?,
|
||||
session_idle_timeout: Duration::from_secs(parse_optional_env(
|
||||
"SESSION_IDLE_TIMEOUT_SECS",
|
||||
settings.agent.session_idle_timeout_secs,
|
||||
)?),
|
||||
allow_local_tools: parse_bool_env("ALLOW_LOCAL_TOOLS", false)?,
|
||||
max_cost_per_day_cents: parse_option_env("MAX_COST_PER_DAY_CENTS")?,
|
||||
max_actions_per_hour: parse_option_env("MAX_ACTIONS_PER_HOUR")?,
|
||||
max_cost_per_user_per_day_cents: parse_option_env("MAX_COST_PER_USER_PER_DAY_CENTS")?,
|
||||
max_tool_iterations: db_first_or_default(
|
||||
&settings.agent.max_tool_iterations,
|
||||
&defaults.max_tool_iterations,
|
||||
max_tool_iterations: parse_optional_env(
|
||||
"AGENT_MAX_TOOL_ITERATIONS",
|
||||
settings.agent.max_tool_iterations,
|
||||
)?,
|
||||
auto_approve_tools: db_first_bool(
|
||||
settings.agent.auto_approve_tools,
|
||||
defaults.auto_approve_tools,
|
||||
auto_approve_tools: parse_bool_env(
|
||||
"AGENT_AUTO_APPROVE_TOOLS",
|
||||
settings.agent.auto_approve_tools,
|
||||
)?,
|
||||
default_timezone: {
|
||||
let tz: String = db_first_or_default(
|
||||
&settings.agent.default_timezone,
|
||||
&defaults.default_timezone,
|
||||
let tz: String = parse_optional_env(
|
||||
"DEFAULT_TIMEZONE",
|
||||
settings.agent.default_timezone.clone(),
|
||||
)?;
|
||||
if crate::timezone::parse_timezone(&tz).is_none() {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
@@ -139,10 +122,9 @@ impl AgentConfig {
|
||||
}
|
||||
tz
|
||||
},
|
||||
max_tokens_per_job: db_first_or_default(
|
||||
&settings.agent.max_tokens_per_job,
|
||||
&defaults.max_tokens_per_job,
|
||||
max_tokens_per_job: parse_optional_env(
|
||||
"AGENT_MAX_TOKENS_PER_JOB",
|
||||
settings.agent.max_tokens_per_job,
|
||||
)?,
|
||||
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
|
||||
// knob — multi-tenant mode is always implied by configuring user tokens.
|
||||
|
||||
+10
-41
@@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::helpers::{db_first_bool, db_first_or_default, optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Builder mode configuration.
|
||||
@@ -34,29 +34,14 @@ impl Default for BuilderModeConfig {
|
||||
impl BuilderModeConfig {
|
||||
pub(crate) fn resolve(settings: &crate::settings::Settings) -> Result<Self, ConfigError> {
|
||||
let bs = &settings.builder;
|
||||
let defaults = crate::settings::BuilderSettings::default();
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(bs.enabled, defaults.enabled, "BUILDER_ENABLED")?,
|
||||
build_dir: if let Some(ref dir) = bs.build_dir {
|
||||
Some(dir.clone())
|
||||
} else {
|
||||
optional_env("BUILDER_DIR")?.map(PathBuf::from)
|
||||
},
|
||||
max_iterations: db_first_or_default(
|
||||
&bs.max_iterations,
|
||||
&defaults.max_iterations,
|
||||
"BUILDER_MAX_ITERATIONS",
|
||||
)?,
|
||||
timeout_secs: db_first_or_default(
|
||||
&bs.timeout_secs,
|
||||
&defaults.timeout_secs,
|
||||
"BUILDER_TIMEOUT_SECS",
|
||||
)?,
|
||||
auto_register: db_first_bool(
|
||||
bs.auto_register,
|
||||
defaults.auto_register,
|
||||
"BUILDER_AUTO_REGISTER",
|
||||
)?,
|
||||
enabled: parse_bool_env("BUILDER_ENABLED", bs.enabled)?,
|
||||
build_dir: optional_env("BUILDER_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| bs.build_dir.clone()),
|
||||
max_iterations: parse_optional_env("BUILDER_MAX_ITERATIONS", bs.max_iterations)?,
|
||||
timeout_secs: parse_optional_env("BUILDER_TIMEOUT_SECS", bs.timeout_secs)?,
|
||||
auto_register: parse_bool_env("BUILDER_AUTO_REGISTER", bs.auto_register)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,7 +79,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_settings_override_env() {
|
||||
fn env_overrides_settings() {
|
||||
let _guard = lock_env();
|
||||
let mut settings = Settings::default();
|
||||
settings.builder.timeout_secs = 123;
|
||||
@@ -104,22 +89,6 @@ mod tests {
|
||||
let cfg = BuilderModeConfig::resolve(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("BUILDER_TIMEOUT_SECS") };
|
||||
|
||||
assert_eq!(cfg.timeout_secs, 123, "DB setting should win over env");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_used_when_no_db_setting() {
|
||||
let _guard = lock_env();
|
||||
let settings = Settings::default();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe { std::env::set_var("BUILDER_TIMEOUT_SECS", "42") };
|
||||
let cfg = BuilderModeConfig::resolve(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("BUILDER_TIMEOUT_SECS") };
|
||||
|
||||
assert_eq!(
|
||||
cfg.timeout_secs, 42,
|
||||
"env should be used when DB has the default value"
|
||||
);
|
||||
assert_eq!(cfg.timeout_secs, 3);
|
||||
}
|
||||
}
|
||||
|
||||
+52
-86
@@ -5,11 +5,9 @@ use secrecy::SecretString;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{
|
||||
db_first_bool, db_first_optional_string, db_first_or_default, optional_env, parse_optional_env,
|
||||
};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::{ChannelSettings, Settings};
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Channel configurations.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -116,24 +114,15 @@ pub struct SignalConfig {
|
||||
impl ChannelsConfig {
|
||||
pub(crate) fn resolve(settings: &Settings, owner_id: &str) -> Result<Self, ConfigError> {
|
||||
let cs = &settings.channels;
|
||||
let defaults = ChannelSettings::default();
|
||||
|
||||
let http_enabled_by_env =
|
||||
optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some();
|
||||
let http_enabled_by_db =
|
||||
db_first_bool(cs.http_enabled, defaults.http_enabled, "HTTP_ENABLED")?;
|
||||
let http = if http_enabled_by_env || http_enabled_by_db {
|
||||
let http = if http_enabled_by_env || cs.http_enabled {
|
||||
Some(HttpConfig {
|
||||
host: db_first_optional_string(&cs.http_host, "HTTP_HOST")?
|
||||
host: optional_env("HTTP_HOST")?
|
||||
.or_else(|| cs.http_host.clone())
|
||||
.unwrap_or_else(|| "0.0.0.0".to_string()),
|
||||
port: {
|
||||
// defaults.http_port is None, so any Some(..) is an explicit DB override.
|
||||
if let Some(ref db_port) = cs.http_port {
|
||||
db_first_or_default(db_port, &8080, "HTTP_PORT")?
|
||||
} else {
|
||||
parse_optional_env("HTTP_PORT", 8080)?
|
||||
}
|
||||
},
|
||||
port: parse_optional_env("HTTP_PORT", cs.http_port.unwrap_or(8080))?,
|
||||
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from),
|
||||
user_id: owner_id.to_string(),
|
||||
})
|
||||
@@ -141,13 +130,10 @@ impl ChannelsConfig {
|
||||
None
|
||||
};
|
||||
|
||||
let gateway_enabled = db_first_bool(
|
||||
cs.gateway_enabled,
|
||||
defaults.gateway_enabled,
|
||||
"GATEWAY_ENABLED",
|
||||
)?;
|
||||
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
|
||||
let gateway = if gateway_enabled {
|
||||
let user_id = db_first_optional_string(&cs.gateway_user_id, "GATEWAY_USER_ID")?
|
||||
let user_id = optional_env("GATEWAY_USER_ID")?
|
||||
.or_else(|| cs.gateway_user_id.clone())
|
||||
.unwrap_or_else(|| owner_id.to_string());
|
||||
|
||||
let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
|
||||
@@ -263,16 +249,13 @@ impl ChannelsConfig {
|
||||
}
|
||||
}
|
||||
Some(GatewayConfig {
|
||||
host: db_first_optional_string(&cs.gateway_host, "GATEWAY_HOST")?
|
||||
host: optional_env("GATEWAY_HOST")?
|
||||
.or_else(|| cs.gateway_host.clone())
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
port: {
|
||||
// defaults.gateway_port is None, so any Some(..) is an explicit DB override.
|
||||
if let Some(ref db_port) = cs.gateway_port {
|
||||
db_first_or_default(db_port, &DEFAULT_GATEWAY_PORT, "GATEWAY_PORT")?
|
||||
} else {
|
||||
parse_optional_env("GATEWAY_PORT", DEFAULT_GATEWAY_PORT)?
|
||||
}
|
||||
},
|
||||
port: parse_optional_env(
|
||||
"GATEWAY_PORT",
|
||||
cs.gateway_port.unwrap_or(DEFAULT_GATEWAY_PORT),
|
||||
)?,
|
||||
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
|
||||
.or_else(|| cs.gateway_auth_token.clone()),
|
||||
user_id,
|
||||
@@ -284,22 +267,16 @@ impl ChannelsConfig {
|
||||
None
|
||||
};
|
||||
|
||||
let signal_enabled =
|
||||
db_first_bool(cs.signal_enabled, defaults.signal_enabled, "SIGNAL_ENABLED")?;
|
||||
let signal_url = db_first_optional_string(&cs.signal_http_url, "SIGNAL_HTTP_URL")?;
|
||||
let signal = if signal_enabled || signal_url.is_some() {
|
||||
let http_url = signal_url.ok_or(ConfigError::InvalidValue {
|
||||
key: "SIGNAL_HTTP_URL".to_string(),
|
||||
message: "SIGNAL_HTTP_URL is required when Signal is enabled".to_string(),
|
||||
})?;
|
||||
let account = db_first_optional_string(&cs.signal_account, "SIGNAL_ACCOUNT")?.ok_or(
|
||||
ConfigError::InvalidValue {
|
||||
let signal_url = optional_env("SIGNAL_HTTP_URL")?.or_else(|| cs.signal_http_url.clone());
|
||||
let signal = if let Some(http_url) = signal_url {
|
||||
let account = optional_env("SIGNAL_ACCOUNT")?
|
||||
.or_else(|| cs.signal_account.clone())
|
||||
.ok_or(ConfigError::InvalidValue {
|
||||
key: "SIGNAL_ACCOUNT".to_string(),
|
||||
message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(),
|
||||
},
|
||||
)?;
|
||||
})?;
|
||||
let allow_from =
|
||||
match db_first_optional_string(&cs.signal_allow_from, "SIGNAL_ALLOW_FROM")? {
|
||||
match optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone()) {
|
||||
None => vec![account.clone()],
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
@@ -307,39 +284,36 @@ impl ChannelsConfig {
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
};
|
||||
let dm_policy = db_first_optional_string(&cs.signal_dm_policy, "SIGNAL_DM_POLICY")?
|
||||
let dm_policy = optional_env("SIGNAL_DM_POLICY")?
|
||||
.or_else(|| cs.signal_dm_policy.clone())
|
||||
.unwrap_or_else(|| "pairing".to_string());
|
||||
let group_policy =
|
||||
db_first_optional_string(&cs.signal_group_policy, "SIGNAL_GROUP_POLICY")?
|
||||
.unwrap_or_else(|| "allowlist".to_string());
|
||||
let group_policy = optional_env("SIGNAL_GROUP_POLICY")?
|
||||
.or_else(|| cs.signal_group_policy.clone())
|
||||
.unwrap_or_else(|| "allowlist".to_string());
|
||||
Some(SignalConfig {
|
||||
http_url,
|
||||
account,
|
||||
allow_from,
|
||||
allow_from_groups: db_first_optional_string(
|
||||
&cs.signal_allow_from_groups,
|
||||
"SIGNAL_ALLOW_FROM_GROUPS",
|
||||
)?
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
allow_from_groups: optional_env("SIGNAL_ALLOW_FROM_GROUPS")?
|
||||
.or_else(|| cs.signal_allow_from_groups.clone())
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
dm_policy,
|
||||
group_policy,
|
||||
group_allow_from: db_first_optional_string(
|
||||
&cs.signal_group_allow_from,
|
||||
"SIGNAL_GROUP_ALLOW_FROM",
|
||||
)?
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
group_allow_from: optional_env("SIGNAL_GROUP_ALLOW_FROM")?
|
||||
.or_else(|| cs.signal_group_allow_from.clone())
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
ignore_attachments: optional_env("SIGNAL_IGNORE_ATTACHMENTS")?
|
||||
.map(|s| s.to_lowercase() == "true" || s == "1")
|
||||
.unwrap_or(false),
|
||||
@@ -351,7 +325,7 @@ impl ChannelsConfig {
|
||||
None
|
||||
};
|
||||
|
||||
let cli_enabled = db_first_bool(cs.cli_enabled, defaults.cli_enabled, "CLI_ENABLED")?;
|
||||
let cli_enabled = parse_bool_env("CLI_ENABLED", cs.cli_enabled)?;
|
||||
|
||||
Ok(Self {
|
||||
cli: CliConfig {
|
||||
@@ -360,21 +334,13 @@ impl ChannelsConfig {
|
||||
http,
|
||||
gateway,
|
||||
signal,
|
||||
wasm_channels_dir: {
|
||||
// DB-first: use settings if explicitly set, else env, else default.
|
||||
// defaults.wasm_channels_dir is None, so any Some(..) is an explicit DB override.
|
||||
if let Some(ref db_dir) = cs.wasm_channels_dir {
|
||||
db_dir.clone()
|
||||
} else {
|
||||
optional_env("WASM_CHANNELS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir)
|
||||
}
|
||||
},
|
||||
wasm_channels_enabled: db_first_bool(
|
||||
cs.wasm_channels_enabled,
|
||||
defaults.wasm_channels_enabled,
|
||||
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| cs.wasm_channels_dir.clone())
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
wasm_channels_enabled: parse_bool_env(
|
||||
"WASM_CHANNELS_ENABLED",
|
||||
cs.wasm_channels_enabled,
|
||||
)?,
|
||||
wasm_channel_owner_ids: {
|
||||
let mut ids = cs.wasm_channel_owner_ids.clone();
|
||||
|
||||
+16
-85
@@ -2,9 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::config::helpers::{
|
||||
db_first_bool, db_first_or_default, optional_env, parse_optional_env, validate_base_url,
|
||||
};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, validate_base_url};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::SessionManager;
|
||||
use crate::settings::Settings;
|
||||
@@ -73,41 +71,22 @@ pub(crate) fn default_dimension_for_model(model: &str) -> usize {
|
||||
|
||||
impl EmbeddingsConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = crate::settings::EmbeddingsSettings::default();
|
||||
|
||||
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
|
||||
|
||||
let provider = db_first_or_default(
|
||||
&settings.embeddings.provider,
|
||||
&defaults.provider,
|
||||
"EMBEDDING_PROVIDER",
|
||||
)?;
|
||||
let provider = optional_env("EMBEDDING_PROVIDER")?
|
||||
.unwrap_or_else(|| settings.embeddings.provider.clone());
|
||||
|
||||
let model = db_first_or_default(
|
||||
&settings.embeddings.model,
|
||||
&defaults.model,
|
||||
"EMBEDDING_MODEL",
|
||||
)?;
|
||||
let model =
|
||||
optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone());
|
||||
|
||||
// ollama_base_url lives on the top-level Settings, not the embeddings
|
||||
// sub-struct. Use a manual DB > env > default chain.
|
||||
let default_ollama_url = "http://localhost:11434".to_string();
|
||||
let ollama_base_url = settings
|
||||
.ollama_base_url
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.or_else(|| optional_env("OLLAMA_BASE_URL").ok().flatten())
|
||||
.unwrap_or(default_ollama_url);
|
||||
let ollama_base_url = optional_env("OLLAMA_BASE_URL")?
|
||||
.or_else(|| settings.ollama_base_url.clone())
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
|
||||
let dim_default = default_dimension_for_model(&model);
|
||||
let dimension = db_first_or_default(&dim_default, &dim_default, "EMBEDDING_DIMENSION")?;
|
||||
let dimension =
|
||||
parse_optional_env("EMBEDDING_DIMENSION", default_dimension_for_model(&model))?;
|
||||
|
||||
let enabled = db_first_bool(
|
||||
settings.embeddings.enabled,
|
||||
defaults.enabled,
|
||||
"EMBEDDING_ENABLED",
|
||||
)?;
|
||||
let enabled = parse_bool_env("EMBEDDING_ENABLED", settings.embeddings.enabled)?;
|
||||
|
||||
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
|
||||
|
||||
@@ -228,11 +207,9 @@ mod tests {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
std::env::remove_var("EMBEDDING_PROVIDER");
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
std::env::remove_var("EMBEDDING_DIMENSION");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
std::env::remove_var("EMBEDDING_BASE_URL");
|
||||
std::env::remove_var("EMBEDDING_CACHE_SIZE");
|
||||
std::env::remove_var("OLLAMA_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,21 +264,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_settings_override_env() {
|
||||
fn embeddings_env_override_takes_precedence() {
|
||||
let _guard = lock_env();
|
||||
clear_embedding_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_ENABLED", "false");
|
||||
std::env::set_var("EMBEDDING_PROVIDER", "ollama");
|
||||
std::env::set_var("EMBEDDING_MODEL", "all-minilm");
|
||||
std::env::set_var("EMBEDDING_ENABLED", "true");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
embeddings: EmbeddingsSettings {
|
||||
enabled: true,
|
||||
provider: "openai".to_string(),
|
||||
model: "text-embedding-3-large".to_string(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
@@ -309,55 +283,12 @@ mod tests {
|
||||
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert!(
|
||||
config.enabled,
|
||||
"DB enabled=true should win over env EMBEDDING_ENABLED=false"
|
||||
);
|
||||
assert_eq!(config.provider, "openai", "DB provider should win over env");
|
||||
assert_eq!(
|
||||
config.model, "text-embedding-3-large",
|
||||
"DB model should win over env"
|
||||
"EMBEDDING_ENABLED=true env var should override settings"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
std::env::remove_var("EMBEDDING_PROVIDER");
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_used_when_no_db_setting() {
|
||||
let _guard = lock_env();
|
||||
clear_embedding_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_ENABLED", "true");
|
||||
std::env::set_var("EMBEDDING_PROVIDER", "ollama");
|
||||
std::env::set_var("EMBEDDING_MODEL", "nomic-embed-text");
|
||||
}
|
||||
|
||||
// Settings left at defaults — no explicit DB/TOML override
|
||||
let settings = Settings::default();
|
||||
|
||||
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert!(
|
||||
config.enabled,
|
||||
"env EMBEDDING_ENABLED should be used when settings at default"
|
||||
);
|
||||
assert_eq!(
|
||||
config.provider, "ollama",
|
||||
"env EMBEDDING_PROVIDER should be used when settings at default"
|
||||
);
|
||||
assert_eq!(
|
||||
config.model, "nomic-embed-text",
|
||||
"env EMBEDDING_MODEL should be used when settings at default"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
std::env::remove_var("EMBEDDING_PROVIDER");
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-175
@@ -1,7 +1,4 @@
|
||||
use crate::config::helpers::{
|
||||
db_first_bool, db_first_optional_string, db_first_or_default, optional_env, parse_bool_env,
|
||||
parse_option_env,
|
||||
};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -47,11 +44,8 @@ impl Default for HeartbeatConfig {
|
||||
|
||||
impl HeartbeatConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = crate::settings::HeartbeatSettings::default();
|
||||
|
||||
// fire_at: DB > env, then parse into NaiveTime
|
||||
let fire_at_str =
|
||||
db_first_optional_string(&settings.heartbeat.fire_at, "HEARTBEAT_FIRE_AT")?;
|
||||
optional_env("HEARTBEAT_FIRE_AT")?.or_else(|| settings.heartbeat.fire_at.clone());
|
||||
let fire_at = fire_at_str
|
||||
.map(|s| {
|
||||
chrono::NaiveTime::parse_from_str(&s, "%H:%M").map_err(|e| {
|
||||
@@ -63,62 +57,44 @@ impl HeartbeatConfig {
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// quiet_hours: settings first, then env fallback
|
||||
let quiet_hours_start = settings
|
||||
.heartbeat
|
||||
.quiet_hours_start
|
||||
.or(parse_option_env::<u32>("HEARTBEAT_QUIET_START")?)
|
||||
.map(|h| {
|
||||
if h > 23 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_QUIET_START".into(),
|
||||
message: "must be 0-23".into(),
|
||||
});
|
||||
}
|
||||
Ok(h)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let quiet_hours_end = settings
|
||||
.heartbeat
|
||||
.quiet_hours_end
|
||||
.or(parse_option_env::<u32>("HEARTBEAT_QUIET_END")?)
|
||||
.map(|h| {
|
||||
if h > 23 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_QUIET_END".into(),
|
||||
message: "must be 0-23".into(),
|
||||
});
|
||||
}
|
||||
Ok(h)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(
|
||||
settings.heartbeat.enabled,
|
||||
defaults.enabled,
|
||||
"HEARTBEAT_ENABLED",
|
||||
)?,
|
||||
interval_secs: db_first_or_default(
|
||||
&settings.heartbeat.interval_secs,
|
||||
&defaults.interval_secs,
|
||||
enabled: parse_bool_env("HEARTBEAT_ENABLED", settings.heartbeat.enabled)?,
|
||||
interval_secs: parse_optional_env(
|
||||
"HEARTBEAT_INTERVAL_SECS",
|
||||
settings.heartbeat.interval_secs,
|
||||
)?,
|
||||
notify_channel: db_first_optional_string(
|
||||
&settings.heartbeat.notify_channel,
|
||||
"HEARTBEAT_NOTIFY_CHANNEL",
|
||||
)?,
|
||||
notify_user: db_first_optional_string(
|
||||
&settings.heartbeat.notify_user,
|
||||
"HEARTBEAT_NOTIFY_USER",
|
||||
)?,
|
||||
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
|
||||
.or_else(|| settings.heartbeat.notify_channel.clone()),
|
||||
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
||||
.or_else(|| settings.heartbeat.notify_user.clone()),
|
||||
fire_at,
|
||||
quiet_hours_start,
|
||||
quiet_hours_end,
|
||||
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
|
||||
.or(settings.heartbeat.quiet_hours_start)
|
||||
.map(|h| {
|
||||
if h > 23 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_QUIET_START".into(),
|
||||
message: "must be 0-23".into(),
|
||||
});
|
||||
}
|
||||
Ok(h)
|
||||
})
|
||||
.transpose()?,
|
||||
quiet_hours_end: parse_option_env::<u32>("HEARTBEAT_QUIET_END")?
|
||||
.or(settings.heartbeat.quiet_hours_end)
|
||||
.map(|h| {
|
||||
if h > 23 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_QUIET_END".into(),
|
||||
message: "must be 0-23".into(),
|
||||
});
|
||||
}
|
||||
Ok(h)
|
||||
})
|
||||
.transpose()?,
|
||||
timezone: {
|
||||
let tz =
|
||||
db_first_optional_string(&settings.heartbeat.timezone, "HEARTBEAT_TIMEZONE")?;
|
||||
let tz = optional_env("HEARTBEAT_TIMEZONE")?
|
||||
.or_else(|| settings.heartbeat.timezone.clone());
|
||||
if let Some(ref tz_str) = tz
|
||||
&& crate::timezone::parse_timezone(tz_str).is_none()
|
||||
{
|
||||
@@ -130,7 +106,7 @@ impl HeartbeatConfig {
|
||||
tz
|
||||
},
|
||||
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
|
||||
// or allow explicit override via HEARTBEAT_MULTI_TENANT. Stays env-only.
|
||||
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
|
||||
multi_tenant: parse_bool_env(
|
||||
"HEARTBEAT_MULTI_TENANT",
|
||||
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
||||
@@ -142,11 +118,10 @@ impl HeartbeatConfig {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::helpers::lock_env;
|
||||
|
||||
#[test]
|
||||
fn test_quiet_hours_settings_have_priority() {
|
||||
// DB/settings values should take priority over env
|
||||
fn test_quiet_hours_settings_fallback() {
|
||||
// When env vars are not set, settings values should be used
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.quiet_hours_start = Some(22);
|
||||
settings.heartbeat.quiet_hours_end = Some(6);
|
||||
@@ -193,116 +168,4 @@ mod tests {
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(config.timezone.as_deref(), Some("America/New_York"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_db_first_enabled_beats_env() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: under ENV_MUTEX
|
||||
unsafe { std::env::set_var("HEARTBEAT_ENABLED", "false") };
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.enabled = true; // DB says enabled
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert!(config.enabled, "DB value (true) should beat env (false)");
|
||||
|
||||
unsafe { std::env::remove_var("HEARTBEAT_ENABLED") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_db_first_interval_beats_env() {
|
||||
let _guard = lock_env();
|
||||
unsafe { std::env::set_var("HEARTBEAT_INTERVAL_SECS", "999") };
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.interval_secs = 600; // DB says 600
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(config.interval_secs, 600, "DB value should beat env");
|
||||
|
||||
unsafe { std::env::remove_var("HEARTBEAT_INTERVAL_SECS") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_db_first_notify_channel_beats_env() {
|
||||
let _guard = lock_env();
|
||||
unsafe { std::env::set_var("HEARTBEAT_NOTIFY_CHANNEL", "env-channel") };
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.notify_channel = Some("db-channel".to_string());
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(
|
||||
config.notify_channel.as_deref(),
|
||||
Some("db-channel"),
|
||||
"DB value should beat env"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var("HEARTBEAT_NOTIFY_CHANNEL") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_fallback_when_db_at_default() {
|
||||
let _guard = lock_env();
|
||||
unsafe { std::env::set_var("HEARTBEAT_INTERVAL_SECS", "999") };
|
||||
|
||||
// Settings at default => env should win
|
||||
let settings = Settings::default();
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(
|
||||
config.interval_secs, 999,
|
||||
"env should win when DB at default"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var("HEARTBEAT_INTERVAL_SECS") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fire_at_db_first() {
|
||||
let _guard = lock_env();
|
||||
unsafe { std::env::set_var("HEARTBEAT_FIRE_AT", "08:00") };
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.fire_at = Some("14:30".to_string());
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(
|
||||
config.fire_at,
|
||||
Some(chrono::NaiveTime::from_hms_opt(14, 30, 0).unwrap()),
|
||||
"DB fire_at should beat env"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var("HEARTBEAT_FIRE_AT") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timezone_db_first() {
|
||||
let _guard = lock_env();
|
||||
unsafe { std::env::set_var("HEARTBEAT_TIMEZONE", "UTC") };
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.timezone = Some("America/New_York".to_string());
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(
|
||||
config.timezone.as_deref(),
|
||||
Some("America/New_York"),
|
||||
"DB timezone should beat env"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var("HEARTBEAT_TIMEZONE") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_tenant_stays_env_only() {
|
||||
let _guard = lock_env();
|
||||
unsafe { std::env::set_var("HEARTBEAT_MULTI_TENANT", "true") };
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert!(config.multi_tenant, "multi_tenant should read from env");
|
||||
|
||||
unsafe { std::env::remove_var("HEARTBEAT_MULTI_TENANT") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,93 +331,6 @@ pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), Confi
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB-first resolution helpers (DB > env > default)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Log a warning when a DB/TOML setting shadows a set env var.
|
||||
fn warn_if_db_shadows_env(env_key: &str, db_value: &dyn std::fmt::Display) {
|
||||
if let Ok(env_val) = std::env::var(env_key)
|
||||
&& !env_val.is_empty()
|
||||
{
|
||||
tracing::warn!(
|
||||
db_value = %db_value,
|
||||
env_value = %env_val,
|
||||
"{env_key} env var is set but DB/TOML setting takes priority. \
|
||||
Remove the setting from the DB to use the env var."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve with DB > env > default priority for concrete settings fields.
|
||||
///
|
||||
/// If `settings_val != default_val`, the settings value wins (it was explicitly
|
||||
/// set in DB or TOML). Otherwise falls back to `optional_env(env_key)`, then
|
||||
/// `default_val`.
|
||||
pub(crate) fn db_first_or_default<T>(
|
||||
settings_val: &T,
|
||||
default_val: &T,
|
||||
env_key: &str,
|
||||
) -> Result<T, ConfigError>
|
||||
where
|
||||
T: std::str::FromStr + Clone + PartialEq + std::fmt::Display,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
if settings_val != default_val {
|
||||
warn_if_db_shadows_env(env_key, settings_val);
|
||||
return Ok(settings_val.clone());
|
||||
}
|
||||
parse_optional_env(env_key, default_val.clone())
|
||||
}
|
||||
|
||||
/// Resolve a bool with DB > env > default priority.
|
||||
pub(crate) fn db_first_bool(
|
||||
settings_val: bool,
|
||||
default_val: bool,
|
||||
env_key: &str,
|
||||
) -> Result<bool, ConfigError> {
|
||||
if settings_val != default_val {
|
||||
warn_if_db_shadows_env(env_key, &settings_val);
|
||||
return Ok(settings_val);
|
||||
}
|
||||
parse_bool_env(env_key, default_val)
|
||||
}
|
||||
|
||||
/// Resolve an `Option<String>` with DB > env priority (no hardcoded default).
|
||||
///
|
||||
/// Non-empty `Some` means DB set it; `None` or empty falls back to env.
|
||||
pub(crate) fn db_first_optional_string(
|
||||
settings_val: &Option<String>,
|
||||
env_key: &str,
|
||||
) -> Result<Option<String>, ConfigError> {
|
||||
if let Some(val) = settings_val
|
||||
&& !val.is_empty()
|
||||
{
|
||||
warn_if_db_shadows_env(env_key, val);
|
||||
return Ok(Some(val.clone()));
|
||||
}
|
||||
optional_env(env_key)
|
||||
}
|
||||
|
||||
/// Resolve an `Option<T>` with DB > env priority (no hardcoded default).
|
||||
///
|
||||
/// `Some(v)` means DB set it; `None` falls back to env.
|
||||
#[allow(dead_code)] // Used by Group 2 resolvers (routines, skills, etc.)
|
||||
pub(crate) fn db_first_option<T>(
|
||||
settings_val: &Option<T>,
|
||||
env_key: &str,
|
||||
) -> Result<Option<T>, ConfigError>
|
||||
where
|
||||
T: std::str::FromStr + Clone + std::fmt::Display,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
if let Some(val) = settings_val {
|
||||
warn_if_db_shadows_env(env_key, val);
|
||||
return Ok(Some(val.clone()));
|
||||
}
|
||||
parse_option_env(env_key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -606,144 +519,4 @@ mod tests {
|
||||
"Expected DNS resolution failure, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- db_first_* helper tests ---
|
||||
|
||||
#[test]
|
||||
fn db_first_or_default_prefers_settings_over_env() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_1";
|
||||
// SAFETY: under ENV_MUTEX
|
||||
unsafe { std::env::set_var(key, "from-env") };
|
||||
|
||||
let result: String =
|
||||
db_first_or_default(&"from-db".to_string(), &"default".to_string(), key)
|
||||
.expect("should resolve");
|
||||
assert_eq!(result, "from-db", "DB value should win over env");
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_or_default_falls_back_to_env() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_2";
|
||||
unsafe { std::env::set_var(key, "from-env") };
|
||||
|
||||
// settings_val == default_val → treated as "unset"
|
||||
let result: String =
|
||||
db_first_or_default(&"default".to_string(), &"default".to_string(), key)
|
||||
.expect("should resolve");
|
||||
assert_eq!(
|
||||
result, "from-env",
|
||||
"env should win when settings at default"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_or_default_uses_default_when_neither_set() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_3";
|
||||
unsafe { std::env::remove_var(key) };
|
||||
|
||||
let result: String =
|
||||
db_first_or_default(&"default".to_string(), &"default".to_string(), key)
|
||||
.expect("should resolve");
|
||||
assert_eq!(result, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_bool_prefers_settings() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_BOOL_1";
|
||||
unsafe { std::env::set_var(key, "false") };
|
||||
|
||||
let result = db_first_bool(true, false, key).expect("should resolve");
|
||||
assert!(result, "DB true should win over env false");
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_bool_falls_back_to_env() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_BOOL_2";
|
||||
unsafe { std::env::set_var(key, "true") };
|
||||
|
||||
// settings == default → falls back to env
|
||||
let result = db_first_bool(false, false, key).expect("should resolve");
|
||||
assert!(result, "env should win when settings at default");
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_optional_string_prefers_settings() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_OPT_1";
|
||||
unsafe { std::env::set_var(key, "from-env") };
|
||||
|
||||
let val = Some("from-db".to_string());
|
||||
let result = db_first_optional_string(&val, key).expect("should resolve");
|
||||
assert_eq!(result, Some("from-db".to_string()));
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_optional_string_falls_back_to_env() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_OPT_2";
|
||||
unsafe { std::env::set_var(key, "from-env") };
|
||||
|
||||
let result = db_first_optional_string(&None, key).expect("should resolve");
|
||||
assert_eq!(result, Some("from-env".to_string()));
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_optional_string_empty_treated_as_unset() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_OPT_3";
|
||||
unsafe { std::env::set_var(key, "from-env") };
|
||||
|
||||
let val = Some(String::new());
|
||||
let result = db_first_optional_string(&val, key).expect("should resolve");
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("from-env".to_string()),
|
||||
"empty string should be treated as unset"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_option_prefers_settings() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_OPT_T_1";
|
||||
unsafe { std::env::set_var(key, "99") };
|
||||
|
||||
let val: Option<u64> = Some(42);
|
||||
let result = db_first_option(&val, key).expect("should resolve");
|
||||
assert_eq!(result, Some(42));
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_first_option_falls_back_to_env() {
|
||||
let _guard = lock_env();
|
||||
let key = "IRONCLAW_TEST_DB_FIRST_OPT_T_2";
|
||||
unsafe { std::env::set_var(key, "99") };
|
||||
|
||||
let val: Option<u64> = None;
|
||||
let result = db_first_option(&val, key).expect("should resolve");
|
||||
assert_eq!(result, Some(99));
|
||||
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
}
|
||||
|
||||
+7
-20
@@ -1,7 +1,6 @@
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{db_first_bool, db_first_or_default};
|
||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Memory hygiene configuration.
|
||||
///
|
||||
@@ -31,27 +30,15 @@ impl Default for HygieneConfig {
|
||||
}
|
||||
|
||||
impl HygieneConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = crate::settings::HygieneSettings::default();
|
||||
let hs = &settings.hygiene;
|
||||
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(hs.enabled, defaults.enabled, "MEMORY_HYGIENE_ENABLED")?,
|
||||
daily_retention_days: db_first_or_default(
|
||||
&hs.daily_retention_days,
|
||||
&defaults.daily_retention_days,
|
||||
"MEMORY_HYGIENE_DAILY_RETENTION_DAYS",
|
||||
)?,
|
||||
conversation_retention_days: db_first_or_default(
|
||||
&hs.conversation_retention_days,
|
||||
&defaults.conversation_retention_days,
|
||||
enabled: parse_bool_env("MEMORY_HYGIENE_ENABLED", true)?,
|
||||
daily_retention_days: parse_optional_env("MEMORY_HYGIENE_DAILY_RETENTION_DAYS", 30)?,
|
||||
conversation_retention_days: parse_optional_env(
|
||||
"MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS",
|
||||
7,
|
||||
)?,
|
||||
cadence_hours: db_first_or_default(
|
||||
&hs.cadence_hours,
|
||||
&defaults.cadence_hours,
|
||||
"MEMORY_HYGIENE_CADENCE_HOURS",
|
||||
)?,
|
||||
cadence_hours: parse_optional_env("MEMORY_HYGIENE_CADENCE_HOURS", 12)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+58
-818
File diff suppressed because it is too large
Load Diff
+22
-215
@@ -1,15 +1,9 @@
|
||||
//! Configuration for IronClaw.
|
||||
//!
|
||||
//! Settings are loaded with priority: **DB > env > TOML > default**.
|
||||
//!
|
||||
//! Exceptions:
|
||||
//! - Bootstrap configs (database, secrets): env-only (DB not yet available)
|
||||
//! - Security-sensitive fields (allow_local_tools, allow_full_access,
|
||||
//! cost limits, auth tokens): env-only
|
||||
//! - API keys: env/secrets store only
|
||||
//!
|
||||
//! Settings are loaded with priority: env var > database > default.
|
||||
//! `DATABASE_URL` lives in `~/.ironclaw/.env` (loaded via dotenvy early
|
||||
//! in startup).
|
||||
//! in startup). Everything else comes from env vars, the DB settings
|
||||
//! table, or auto-detection.
|
||||
|
||||
mod agent;
|
||||
mod builder;
|
||||
@@ -192,9 +186,8 @@ impl Config {
|
||||
|
||||
/// Load configuration from environment variables and the database.
|
||||
///
|
||||
/// Priority: DB > env > TOML > default. TOML is loaded first as a
|
||||
/// base, then DB values are merged on top. Subsystem resolvers check
|
||||
/// DB-backed settings before env vars (except bootstrap/security fields).
|
||||
/// Priority: env var > TOML config file > DB settings > default.
|
||||
/// This is the primary way to load config after DB is connected.
|
||||
pub async fn from_db(
|
||||
store: &(dyn crate::db::SettingsStore + Sync),
|
||||
user_id: &str,
|
||||
@@ -203,9 +196,6 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Load from DB with an optional TOML config file overlay.
|
||||
///
|
||||
/// Priority: DB > env > TOML > default. TOML is loaded as the base,
|
||||
/// then DB values are merged on top. See module docs for exceptions.
|
||||
pub async fn from_db_with_toml(
|
||||
store: &(dyn crate::db::SettingsStore + Sync),
|
||||
user_id: &str,
|
||||
@@ -214,22 +204,19 @@ impl Config {
|
||||
let _ = dotenvy::dotenv();
|
||||
crate::bootstrap::load_ironclaw_env();
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load settings from DB, using defaults: {}", e);
|
||||
Settings::default()
|
||||
}
|
||||
};
|
||||
|
||||
Self::build(&settings).await
|
||||
// Overlay TOML config file (values win over DB settings)
|
||||
Self::apply_toml_overlay(&mut db_settings, toml_path)?;
|
||||
|
||||
Self::build(&db_settings).await
|
||||
}
|
||||
|
||||
/// Load configuration from environment variables only (no database).
|
||||
@@ -304,38 +291,16 @@ impl Config {
|
||||
user_id: &str,
|
||||
toml_path: Option<&std::path::Path>,
|
||||
) -> Result<(), ConfigError> {
|
||||
self.re_resolve_llm_with_secrets(store, user_id, toml_path, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Re-resolve LLM config, hydrating API keys from the secrets store.
|
||||
pub async fn re_resolve_llm_with_secrets(
|
||||
&mut self,
|
||||
store: Option<&(dyn crate::db::SettingsStore + Sync)>,
|
||||
user_id: &str,
|
||||
toml_path: Option<&std::path::Path>,
|
||||
secrets: Option<&(dyn crate::secrets::SecretsStore + Send + Sync)>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut settings = if let Some(store) = store {
|
||||
// TOML as base, then DB on top (DB wins).
|
||||
let mut s = Settings::default();
|
||||
let settings = if let Some(store) = store {
|
||||
let mut s = match store.get_all_settings(user_id).await {
|
||||
Ok(map) => Settings::from_db_map(&map),
|
||||
Err(_) => Settings::default(),
|
||||
};
|
||||
Self::apply_toml_overlay(&mut s, toml_path)?;
|
||||
if let Ok(map) = store.get_all_settings(user_id).await {
|
||||
let db_settings = Settings::from_db_map(&map);
|
||||
s.merge_from(&db_settings);
|
||||
}
|
||||
s
|
||||
} else {
|
||||
Settings::default()
|
||||
};
|
||||
|
||||
// Hydrate API keys from encrypted secrets store into the settings
|
||||
// struct so that LlmConfig::resolve() sees them without any changes
|
||||
// to its synchronous resolution logic.
|
||||
if let Some(secrets) = secrets {
|
||||
hydrate_llm_keys_from_secrets(&mut settings, secrets, user_id).await;
|
||||
}
|
||||
|
||||
self.llm = LlmConfig::resolve(&settings)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -366,13 +331,13 @@ impl Config {
|
||||
secrets: SecretsConfig::resolve().await?,
|
||||
builder: BuilderModeConfig::resolve(settings)?,
|
||||
heartbeat: HeartbeatConfig::resolve(settings)?,
|
||||
hygiene: HygieneConfig::resolve(settings)?,
|
||||
routines: RoutineConfig::resolve(settings)?,
|
||||
hygiene: HygieneConfig::resolve()?,
|
||||
routines: RoutineConfig::resolve()?,
|
||||
sandbox: SandboxModeConfig::resolve(settings)?,
|
||||
claude_code: ClaudeCodeConfig::resolve(settings)?,
|
||||
skills: SkillsConfig::resolve(settings)?,
|
||||
skills: SkillsConfig::resolve()?,
|
||||
transcription: TranscriptionConfig::resolve(settings)?,
|
||||
search: WorkspaceSearchConfig::resolve(settings)?,
|
||||
search: WorkspaceSearchConfig::resolve()?,
|
||||
workspace,
|
||||
observability: crate::observability::ObservabilityConfig {
|
||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||
@@ -536,161 +501,3 @@ fn inject_os_credential_store_tokens(injected: &mut HashMap<String, String>) {
|
||||
tracing::debug!("Refreshed ANTHROPIC_OAUTH_TOKEN from OS credential store");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate LLM API keys from the secrets store into the settings struct.
|
||||
///
|
||||
/// Called after loading settings from DB but before `LlmConfig::resolve()`.
|
||||
/// Populates `api_key` fields that were stripped from settings during the
|
||||
/// write path and stored encrypted in the secrets store instead.
|
||||
pub async fn hydrate_llm_keys_from_secrets(
|
||||
settings: &mut Settings,
|
||||
secrets: &(dyn crate::secrets::SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
) {
|
||||
// Hydrate builtin overrides
|
||||
for (provider_id, override_val) in settings.llm_builtin_overrides.iter_mut() {
|
||||
if override_val.api_key.is_some() {
|
||||
continue; // Already has a key (legacy plaintext or TOML)
|
||||
}
|
||||
let secret_name = format!("llm_builtin_{}_api_key", provider_id);
|
||||
if let Ok(decrypted) = secrets.get_decrypted(user_id, &secret_name).await {
|
||||
override_val.api_key = Some(decrypted.expose().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate custom providers
|
||||
for provider in settings.llm_custom_providers.iter_mut() {
|
||||
if provider.api_key.is_some() {
|
||||
continue;
|
||||
}
|
||||
let secret_name = format!("llm_custom_{}_api_key", provider.id);
|
||||
if let Ok(decrypted) = secrets.get_decrypted(user_id, &secret_name).await {
|
||||
provider.api_key = Some(decrypted.expose().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate plaintext API keys from the settings table to the encrypted secrets store.
|
||||
///
|
||||
/// Idempotent: skips keys that are already in the secrets store.
|
||||
/// After migration, strips plaintext keys from the settings table.
|
||||
pub async fn migrate_plaintext_llm_keys(
|
||||
settings_store: &(dyn crate::db::SettingsStore + Sync),
|
||||
secrets: &(dyn crate::secrets::SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
) {
|
||||
let settings_map = match settings_store.get_all_settings(user_id).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut migrated = 0u32;
|
||||
|
||||
// Migrate builtin overrides
|
||||
if let Some(obj) = settings_map
|
||||
.get("llm_builtin_overrides")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
let mut sanitized = obj.clone();
|
||||
for (provider_id, override_val) in obj {
|
||||
if let Some(api_key) = override_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if api_key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let secret_name = format!("llm_builtin_{}_api_key", provider_id);
|
||||
if !secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Err(e) = secrets
|
||||
.create(
|
||||
user_id,
|
||||
crate::secrets::CreateSecretParams {
|
||||
name: secret_name.clone(),
|
||||
value: secrecy::SecretString::from(api_key.to_string()),
|
||||
provider: Some(provider_id.clone()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to migrate key for builtin '{}': {}", provider_id, e);
|
||||
continue;
|
||||
}
|
||||
if let Some(o) = sanitized
|
||||
.get_mut(provider_id)
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.remove("api_key");
|
||||
}
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
if migrated > 0 {
|
||||
let _ = settings_store
|
||||
.set_setting(
|
||||
user_id,
|
||||
"llm_builtin_overrides",
|
||||
&serde_json::Value::Object(sanitized),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate custom providers
|
||||
let before = migrated;
|
||||
if let Some(arr) = settings_map
|
||||
.get("llm_custom_providers")
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
let mut sanitized = arr.clone();
|
||||
for (idx, provider_val) in arr.iter().enumerate() {
|
||||
let provider_id = provider_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if provider_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(api_key) = provider_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if api_key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let secret_name = format!("llm_custom_{}_api_key", provider_id);
|
||||
if !secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Err(e) = secrets
|
||||
.create(
|
||||
user_id,
|
||||
crate::secrets::CreateSecretParams {
|
||||
name: secret_name.clone(),
|
||||
value: secrecy::SecretString::from(api_key.to_string()),
|
||||
provider: Some(provider_id.to_string()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to migrate key for custom '{}': {}", provider_id, e);
|
||||
continue;
|
||||
}
|
||||
if let Some(o) = sanitized[idx].as_object_mut() {
|
||||
o.remove("api_key");
|
||||
}
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
if migrated > before {
|
||||
let _ = settings_store
|
||||
.set_setting(
|
||||
user_id,
|
||||
"llm_custom_providers",
|
||||
&serde_json::Value::Array(sanitized),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if migrated > 0 {
|
||||
tracing::info!(
|
||||
"Migrated {} plaintext LLM API key(s) to encrypted secrets store",
|
||||
migrated
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-37
@@ -1,6 +1,5 @@
|
||||
use crate::config::helpers::{db_first_bool, db_first_or_default};
|
||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Routines configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -36,42 +35,15 @@ impl Default for RoutineConfig {
|
||||
}
|
||||
|
||||
impl RoutineConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = crate::settings::RoutineSettings::default();
|
||||
let rs = &settings.routines;
|
||||
|
||||
let max_iterations: u32 = db_first_or_default(
|
||||
&rs.lightweight_max_iterations,
|
||||
&defaults.lightweight_max_iterations,
|
||||
"ROUTINES_LIGHTWEIGHT_MAX_ITERATIONS",
|
||||
)?;
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
let max_iterations: u32 = parse_optional_env("ROUTINES_LIGHTWEIGHT_MAX_ITERATIONS", 3)?;
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(rs.enabled, defaults.enabled, "ROUTINES_ENABLED")?,
|
||||
cron_check_interval_secs: db_first_or_default(
|
||||
&rs.cron_check_interval_secs,
|
||||
&defaults.cron_check_interval_secs,
|
||||
"ROUTINES_CRON_INTERVAL",
|
||||
)?,
|
||||
max_concurrent_routines: db_first_or_default(
|
||||
&rs.max_concurrent_routines,
|
||||
&defaults.max_concurrent_routines,
|
||||
"ROUTINES_MAX_CONCURRENT",
|
||||
)?,
|
||||
default_cooldown_secs: db_first_or_default(
|
||||
&rs.default_cooldown_secs,
|
||||
&defaults.default_cooldown_secs,
|
||||
"ROUTINES_DEFAULT_COOLDOWN",
|
||||
)?,
|
||||
max_lightweight_tokens: db_first_or_default(
|
||||
&rs.max_lightweight_tokens,
|
||||
&defaults.max_lightweight_tokens,
|
||||
"ROUTINES_MAX_TOKENS",
|
||||
)?,
|
||||
lightweight_tools_enabled: db_first_bool(
|
||||
rs.lightweight_tools_enabled,
|
||||
defaults.lightweight_tools_enabled,
|
||||
"ROUTINES_LIGHTWEIGHT_TOOLS",
|
||||
)?,
|
||||
enabled: parse_bool_env("ROUTINES_ENABLED", true)?,
|
||||
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
|
||||
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
|
||||
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
|
||||
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
|
||||
lightweight_tools_enabled: parse_bool_env("ROUTINES_LIGHTWEIGHT_TOOLS", true)?,
|
||||
lightweight_max_iterations: max_iterations.min(5), // cap at 5
|
||||
})
|
||||
}
|
||||
|
||||
+5
-31
@@ -1,4 +1,4 @@
|
||||
use crate::config::helpers::{db_first_bool, db_first_or_default};
|
||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
pub use ironclaw_safety::SafetyConfig;
|
||||
@@ -7,17 +7,11 @@ pub(crate) fn resolve_safety_config(
|
||||
settings: &crate::settings::Settings,
|
||||
) -> Result<SafetyConfig, ConfigError> {
|
||||
let ss = &settings.safety;
|
||||
let defaults = crate::settings::SafetySettings::default();
|
||||
Ok(SafetyConfig {
|
||||
max_output_length: db_first_or_default(
|
||||
&ss.max_output_length,
|
||||
&defaults.max_output_length,
|
||||
"SAFETY_MAX_OUTPUT_LENGTH",
|
||||
)?,
|
||||
injection_check_enabled: db_first_bool(
|
||||
ss.injection_check_enabled,
|
||||
defaults.injection_check_enabled,
|
||||
max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", ss.max_output_length)?,
|
||||
injection_check_enabled: parse_bool_env(
|
||||
"SAFETY_INJECTION_CHECK_ENABLED",
|
||||
ss.injection_check_enabled,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
@@ -41,10 +35,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_settings_override_env() {
|
||||
fn env_overrides_settings() {
|
||||
let _guard = lock_env();
|
||||
let mut settings = Settings::default();
|
||||
// Non-default value simulates an explicit DB/TOML setting
|
||||
settings.safety.max_output_length = 42;
|
||||
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
@@ -52,25 +45,6 @@ mod tests {
|
||||
let cfg = resolve_safety_config(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("SAFETY_MAX_OUTPUT_LENGTH") };
|
||||
|
||||
// DB value (42) wins over env value (7)
|
||||
assert_eq!(cfg.max_output_length, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_used_when_no_db_setting() {
|
||||
let _guard = lock_env();
|
||||
// Settings left at defaults — no explicit DB/TOML override
|
||||
let settings = Settings::default();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe { std::env::set_var("SAFETY_MAX_OUTPUT_LENGTH", "7") };
|
||||
unsafe { std::env::set_var("SAFETY_INJECTION_CHECK_ENABLED", "false") };
|
||||
let cfg = resolve_safety_config(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("SAFETY_MAX_OUTPUT_LENGTH") };
|
||||
unsafe { std::env::remove_var("SAFETY_INJECTION_CHECK_ENABLED") };
|
||||
|
||||
// Env values win when settings are at their defaults
|
||||
assert_eq!(cfg.max_output_length, 7);
|
||||
assert!(!cfg.injection_check_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-72
@@ -1,7 +1,4 @@
|
||||
use crate::config::helpers::{
|
||||
db_first_bool, db_first_or_default, optional_env, parse_bool_env, parse_optional_env,
|
||||
parse_string_env,
|
||||
};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, parse_string_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Docker sandbox configuration.
|
||||
@@ -57,16 +54,16 @@ impl Default for SandboxModeConfig {
|
||||
impl SandboxModeConfig {
|
||||
pub(crate) fn resolve(settings: &crate::settings::Settings) -> Result<Self, ConfigError> {
|
||||
let ss = &settings.sandbox;
|
||||
let defaults = crate::settings::SandboxSettings::default();
|
||||
|
||||
// extra_allowed_domains: DB wins if non-empty, otherwise env, otherwise empty.
|
||||
let extra_domains = if !ss.extra_allowed_domains.is_empty() {
|
||||
ss.extra_allowed_domains.clone()
|
||||
} else {
|
||||
optional_env("SANDBOX_EXTRA_DOMAINS")?
|
||||
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let extra_domains = optional_env("SANDBOX_EXTRA_DOMAINS")?
|
||||
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
|
||||
.unwrap_or_else(|| {
|
||||
if ss.extra_allowed_domains.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
ss.extra_allowed_domains.clone()
|
||||
}
|
||||
});
|
||||
|
||||
// reaper/orphan fields have no Settings counterpart — env > default only.
|
||||
let reaper_interval_secs: u64 = parse_optional_env("SANDBOX_REAPER_INTERVAL_SECS", 300)?;
|
||||
@@ -88,31 +85,15 @@ impl SandboxModeConfig {
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(ss.enabled, defaults.enabled, "SANDBOX_ENABLED")?,
|
||||
policy: db_first_or_default(&ss.policy, &defaults.policy, "SANDBOX_POLICY")?,
|
||||
// allow_full_access has no Settings counterpart — env > default only (security).
|
||||
enabled: parse_bool_env("SANDBOX_ENABLED", ss.enabled)?,
|
||||
policy: parse_string_env("SANDBOX_POLICY", ss.policy.clone())?,
|
||||
// allow_full_access has no Settings counterpart — env > default only.
|
||||
allow_full_access: parse_bool_env("SANDBOX_ALLOW_FULL_ACCESS", false)?,
|
||||
timeout_secs: db_first_or_default(
|
||||
&ss.timeout_secs,
|
||||
&defaults.timeout_secs,
|
||||
"SANDBOX_TIMEOUT_SECS",
|
||||
)?,
|
||||
memory_limit_mb: db_first_or_default(
|
||||
&ss.memory_limit_mb,
|
||||
&defaults.memory_limit_mb,
|
||||
"SANDBOX_MEMORY_LIMIT_MB",
|
||||
)?,
|
||||
cpu_shares: db_first_or_default(
|
||||
&ss.cpu_shares,
|
||||
&defaults.cpu_shares,
|
||||
"SANDBOX_CPU_SHARES",
|
||||
)?,
|
||||
image: db_first_or_default(&ss.image, &defaults.image, "SANDBOX_IMAGE")?,
|
||||
auto_pull_image: db_first_bool(
|
||||
ss.auto_pull_image,
|
||||
defaults.auto_pull_image,
|
||||
"SANDBOX_AUTO_PULL",
|
||||
)?,
|
||||
timeout_secs: parse_optional_env("SANDBOX_TIMEOUT_SECS", ss.timeout_secs)?,
|
||||
memory_limit_mb: parse_optional_env("SANDBOX_MEMORY_LIMIT_MB", ss.memory_limit_mb)?,
|
||||
cpu_shares: parse_optional_env("SANDBOX_CPU_SHARES", ss.cpu_shares)?,
|
||||
image: parse_string_env("SANDBOX_IMAGE", ss.image.clone())?,
|
||||
auto_pull_image: parse_bool_env("SANDBOX_AUTO_PULL", ss.auto_pull_image)?,
|
||||
extra_allowed_domains: extra_domains,
|
||||
reaper_interval_secs,
|
||||
orphan_threshold_secs,
|
||||
@@ -283,28 +264,19 @@ impl ClaudeCodeConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(settings: &crate::settings::Settings) -> Result<Self, ConfigError> {
|
||||
let ss = &settings.sandbox;
|
||||
let defaults = Self::default();
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(
|
||||
ss.claude_code_enabled,
|
||||
defaults.enabled,
|
||||
"CLAUDE_CODE_ENABLED",
|
||||
)?,
|
||||
// config_dir has no Settings counterpart — env > default only.
|
||||
// Use settings.sandbox.claude_code_enabled as fallback (written by setup wizard).
|
||||
enabled: parse_bool_env("CLAUDE_CODE_ENABLED", settings.sandbox.claude_code_enabled)?,
|
||||
config_dir: optional_env("CLAUDE_CONFIG_DIR")?
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or(defaults.config_dir),
|
||||
// model has no Settings counterpart — env > default only.
|
||||
model: parse_string_env("CLAUDE_CODE_MODEL", defaults.model)?,
|
||||
// max_turns has no Settings counterpart — env > default only.
|
||||
max_turns: parse_optional_env("CLAUDE_CODE_MAX_TURNS", defaults.max_turns)?,
|
||||
// memory_limit_mb has no Settings counterpart — env > default only.
|
||||
memory_limit_mb: parse_optional_env(
|
||||
"CLAUDE_CODE_MEMORY_LIMIT_MB",
|
||||
defaults.memory_limit_mb,
|
||||
)?,
|
||||
// allowed_tools has no Settings counterpart — env > default only.
|
||||
allowed_tools: optional_env("CLAUDE_CODE_ALLOWED_TOOLS")?
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
@@ -635,7 +607,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_db_settings_override_env() {
|
||||
fn sandbox_env_overrides_settings() {
|
||||
let _guard = crate::config::helpers::lock_env();
|
||||
let mut settings = crate::settings::Settings::default();
|
||||
settings.sandbox.timeout_secs = 999;
|
||||
@@ -645,26 +617,7 @@ mod tests {
|
||||
let cfg = SandboxModeConfig::resolve(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("SANDBOX_TIMEOUT_SECS") };
|
||||
|
||||
// DB value (999) wins over env (5) under DB-first priority.
|
||||
assert_eq!(cfg.timeout_secs, 999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_env_used_when_no_db_setting() {
|
||||
let _guard = crate::config::helpers::lock_env();
|
||||
// Default settings — all fields at their defaults, so DB is "unset".
|
||||
let settings = crate::settings::Settings::default();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe { std::env::set_var("SANDBOX_TIMEOUT_SECS", "42") };
|
||||
unsafe { std::env::set_var("SANDBOX_MEMORY_LIMIT_MB", "512") };
|
||||
let cfg = SandboxModeConfig::resolve(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("SANDBOX_TIMEOUT_SECS") };
|
||||
unsafe { std::env::remove_var("SANDBOX_MEMORY_LIMIT_MB") };
|
||||
|
||||
// Env values win when settings are at their defaults.
|
||||
assert_eq!(cfg.timeout_secs, 42);
|
||||
assert_eq!(cfg.memory_limit_mb, 512);
|
||||
assert_eq!(cfg.timeout_secs, 5);
|
||||
}
|
||||
|
||||
// ── ClaudeCodeConfig settings fallback tests ────────────────────
|
||||
@@ -688,7 +641,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_db_settings_override_env() {
|
||||
fn claude_code_env_overrides_settings() {
|
||||
let _guard = crate::config::helpers::lock_env();
|
||||
let mut settings = crate::settings::Settings::default();
|
||||
settings.sandbox.claude_code_enabled = true;
|
||||
@@ -698,8 +651,7 @@ mod tests {
|
||||
let cfg = ClaudeCodeConfig::resolve(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("CLAUDE_CODE_ENABLED") };
|
||||
|
||||
// DB value (true) wins over env (false) under DB-first priority.
|
||||
assert!(cfg.enabled);
|
||||
assert!(!cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+24
-77
@@ -1,6 +1,5 @@
|
||||
use crate::config::helpers::{db_first_or_default, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
use crate::workspace::FusionStrategy;
|
||||
|
||||
/// Workspace search configuration resolved from environment variables.
|
||||
@@ -34,48 +33,30 @@ impl Default for WorkspaceSearchConfig {
|
||||
}
|
||||
|
||||
impl WorkspaceSearchConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = crate::settings::SearchSettings::default();
|
||||
let ss = &settings.search;
|
||||
|
||||
// Resolve fusion_strategy string via DB-first, then parse into enum.
|
||||
let strategy_str = db_first_or_default(
|
||||
&ss.fusion_strategy,
|
||||
&defaults.fusion_strategy,
|
||||
"SEARCH_FUSION_STRATEGY",
|
||||
)?;
|
||||
let fusion_strategy = match strategy_str.to_lowercase().as_str() {
|
||||
"rrf" => FusionStrategy::Rrf,
|
||||
"weighted" => FusionStrategy::WeightedScore,
|
||||
other => {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "SEARCH_FUSION_STRATEGY".to_string(),
|
||||
message: format!("must be 'rrf' or 'weighted', got '{other}'"),
|
||||
});
|
||||
}
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
let fusion_strategy = match optional_env("SEARCH_FUSION_STRATEGY")? {
|
||||
Some(s) => match s.to_lowercase().as_str() {
|
||||
"rrf" => FusionStrategy::Rrf,
|
||||
"weighted" => FusionStrategy::WeightedScore,
|
||||
other => {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "SEARCH_FUSION_STRATEGY".to_string(),
|
||||
message: format!("must be 'rrf' or 'weighted', got '{other}'"),
|
||||
});
|
||||
}
|
||||
},
|
||||
None => FusionStrategy::default(),
|
||||
};
|
||||
|
||||
let rrf_k = db_first_or_default(&ss.rrf_k, &defaults.rrf_k, "SEARCH_RRF_K")?;
|
||||
let rrf_k = parse_optional_env("SEARCH_RRF_K", 60u32)?;
|
||||
|
||||
// Per-strategy weight defaults: RRF uses 0.5/0.5, weighted uses 0.3/0.7 (vector-biased).
|
||||
let (default_fts, default_vec) = match fusion_strategy {
|
||||
FusionStrategy::Rrf => (0.5f32, 0.5f32),
|
||||
FusionStrategy::WeightedScore => (0.3f32, 0.7f32),
|
||||
};
|
||||
|
||||
// For weights, we need to check whether the settings value differs from
|
||||
// the *static* default (0.5) to detect DB overrides. If it does, use it;
|
||||
// otherwise fall back to env, then per-strategy default.
|
||||
let fts_weight = if (ss.fts_weight - defaults.fts_weight).abs() > f32::EPSILON {
|
||||
ss.fts_weight
|
||||
} else {
|
||||
parse_optional_env("SEARCH_FTS_WEIGHT", default_fts)?
|
||||
};
|
||||
let vector_weight = if (ss.vector_weight - defaults.vector_weight).abs() > f32::EPSILON {
|
||||
ss.vector_weight
|
||||
} else {
|
||||
parse_optional_env("SEARCH_VECTOR_WEIGHT", default_vec)?
|
||||
};
|
||||
let fts_weight = parse_optional_env("SEARCH_FTS_WEIGHT", default_fts)?;
|
||||
let vector_weight = parse_optional_env("SEARCH_VECTOR_WEIGHT", default_vec)?;
|
||||
|
||||
if !fts_weight.is_finite() || fts_weight < 0.0 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
@@ -128,8 +109,7 @@ mod tests {
|
||||
let _guard = lock_env();
|
||||
clear_search_env();
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = WorkspaceSearchConfig::resolve(&settings).expect("should resolve");
|
||||
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||
assert_eq!(config.fusion_strategy, FusionStrategy::Rrf);
|
||||
assert_eq!(config.rrf_k, 60);
|
||||
assert!((config.fts_weight - 0.5).abs() < 0.001);
|
||||
@@ -137,35 +117,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_settings_override_env() {
|
||||
let _guard = lock_env();
|
||||
clear_search_env();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("SEARCH_FUSION_STRATEGY", "rrf");
|
||||
std::env::set_var("SEARCH_RRF_K", "30");
|
||||
std::env::set_var("SEARCH_FTS_WEIGHT", "0.9");
|
||||
std::env::set_var("SEARCH_VECTOR_WEIGHT", "0.1");
|
||||
}
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.search.fusion_strategy = "weighted".to_string();
|
||||
settings.search.rrf_k = 42;
|
||||
settings.search.fts_weight = 0.4;
|
||||
settings.search.vector_weight = 0.6;
|
||||
|
||||
let config = WorkspaceSearchConfig::resolve(&settings).expect("should resolve");
|
||||
assert_eq!(config.fusion_strategy, FusionStrategy::WeightedScore);
|
||||
assert_eq!(config.rrf_k, 42);
|
||||
assert!((config.fts_weight - 0.4).abs() < 0.001);
|
||||
assert!((config.vector_weight - 0.6).abs() < 0.001);
|
||||
|
||||
clear_search_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_fallback_when_settings_at_default() {
|
||||
fn env_overrides() {
|
||||
let _guard = lock_env();
|
||||
clear_search_env();
|
||||
|
||||
@@ -177,8 +129,7 @@ mod tests {
|
||||
std::env::set_var("SEARCH_VECTOR_WEIGHT", "0.1");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = WorkspaceSearchConfig::resolve(&settings).expect("should resolve");
|
||||
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||
assert_eq!(config.fusion_strategy, FusionStrategy::WeightedScore);
|
||||
assert_eq!(config.rrf_k, 30);
|
||||
assert!((config.fts_weight - 0.9).abs() < 0.001);
|
||||
@@ -197,8 +148,7 @@ mod tests {
|
||||
std::env::set_var("SEARCH_FUSION_STRATEGY", "bm25");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let result = WorkspaceSearchConfig::resolve(&settings);
|
||||
let result = WorkspaceSearchConfig::resolve();
|
||||
assert!(result.is_err());
|
||||
|
||||
clear_search_env();
|
||||
@@ -214,8 +164,7 @@ mod tests {
|
||||
std::env::set_var("SEARCH_FUSION_STRATEGY", "weighted");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = WorkspaceSearchConfig::resolve(&settings).expect("should resolve");
|
||||
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||
assert_eq!(config.fusion_strategy, FusionStrategy::WeightedScore);
|
||||
// Weighted mode should default to 0.3 FTS / 0.7 vector
|
||||
assert!((config.fts_weight - 0.3).abs() < 0.001);
|
||||
@@ -236,8 +185,7 @@ mod tests {
|
||||
std::env::set_var("SEARCH_VECTOR_WEIGHT", "0.0");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let result = WorkspaceSearchConfig::resolve(&settings);
|
||||
let result = WorkspaceSearchConfig::resolve();
|
||||
assert!(result.is_err());
|
||||
|
||||
clear_search_env();
|
||||
@@ -255,8 +203,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// RRF ignores weights, so both=0 is fine
|
||||
let settings = Settings::default();
|
||||
let config = WorkspaceSearchConfig::resolve(&settings).expect("should resolve");
|
||||
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||
assert_eq!(config.fusion_strategy, FusionStrategy::Rrf);
|
||||
|
||||
clear_search_env();
|
||||
|
||||
+5
-18
@@ -1,9 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{db_first_bool, db_first_or_default, optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Skills system configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -45,29 +44,17 @@ fn default_installed_skills_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
impl SkillsConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = crate::settings::SkillsSettings::default();
|
||||
let ss = &settings.skills;
|
||||
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(ss.enabled, defaults.enabled, "SKILLS_ENABLED")?,
|
||||
// local_dir and installed_dir are env-only (filesystem paths, no settings counterpart)
|
||||
enabled: parse_bool_env("SKILLS_ENABLED", true)?,
|
||||
local_dir: optional_env("SKILLS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_skills_dir),
|
||||
installed_dir: optional_env("SKILLS_INSTALLED_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_installed_skills_dir),
|
||||
max_active_skills: db_first_or_default(
|
||||
&ss.max_active_skills,
|
||||
&defaults.max_active_skills,
|
||||
"SKILLS_MAX_ACTIVE",
|
||||
)?,
|
||||
max_context_tokens: db_first_or_default(
|
||||
&ss.max_context_tokens,
|
||||
&defaults.max_context_tokens,
|
||||
"SKILLS_MAX_CONTEXT_TOKENS",
|
||||
)?,
|
||||
max_active_skills: parse_optional_env("SKILLS_MAX_ACTIVE", 3)?,
|
||||
max_context_tokens: parse_optional_env("SKILLS_MAX_CONTEXT_TOKENS", 4000)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::config::helpers::{db_first_bool, optional_env, validate_base_url};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, validate_base_url};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -39,8 +39,10 @@ impl Default for TranscriptionConfig {
|
||||
|
||||
impl TranscriptionConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let settings_enabled = settings.transcription.as_ref().is_some_and(|t| t.enabled);
|
||||
let enabled = db_first_bool(settings_enabled, false, "TRANSCRIPTION_ENABLED")?;
|
||||
let enabled = parse_bool_env(
|
||||
"TRANSCRIPTION_ENABLED",
|
||||
settings.transcription.as_ref().is_some_and(|t| t.enabled),
|
||||
)?;
|
||||
|
||||
let provider =
|
||||
optional_env("TRANSCRIPTION_PROVIDER")?.unwrap_or_else(|| "openai".to_string());
|
||||
|
||||
+29
-42
@@ -1,14 +1,12 @@
|
||||
use crate::config::helpers::{db_first_bool, db_first_optional_string};
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::{Settings, TunnelSettings};
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Tunnel configuration for exposing the agent to the internet.
|
||||
///
|
||||
/// Used by channels and tools that need public webhook endpoints.
|
||||
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
|
||||
///
|
||||
/// Resolution priority: DB/settings > env var > default.
|
||||
///
|
||||
/// Two modes:
|
||||
/// - **Static URL** (`TUNNEL_URL`): set the public URL directly (manual tunnel)
|
||||
/// - **Managed provider** (`TUNNEL_PROVIDER`): lifecycle-managed tunnel process
|
||||
@@ -27,10 +25,8 @@ pub struct TunnelConfig {
|
||||
|
||||
impl TunnelConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let defaults = TunnelSettings::default();
|
||||
|
||||
// Priority: DB/settings > env > default.
|
||||
let public_url = db_first_optional_string(&settings.tunnel.public_url, "TUNNEL_URL")?;
|
||||
let public_url = optional_env("TUNNEL_URL")?
|
||||
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
|
||||
|
||||
if let Some(ref url) = public_url
|
||||
&& !url.starts_with("https://")
|
||||
@@ -42,8 +38,9 @@ impl TunnelConfig {
|
||||
}
|
||||
|
||||
// Resolve managed tunnel provider config.
|
||||
// Priority: DB/settings > env > default (none).
|
||||
let provider_name = db_first_optional_string(&settings.tunnel.provider, "TUNNEL_PROVIDER")?
|
||||
// Priority: env var > settings > default (none).
|
||||
let provider_name = optional_env("TUNNEL_PROVIDER")?
|
||||
.or_else(|| settings.tunnel.provider.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let provider = if provider_name.is_empty() || provider_name == "none" {
|
||||
@@ -51,48 +48,38 @@ impl TunnelConfig {
|
||||
} else {
|
||||
Some(crate::tunnel::TunnelProviderConfig {
|
||||
provider: provider_name.clone(),
|
||||
cloudflare: db_first_optional_string(&settings.tunnel.cf_token, "TUNNEL_CF_TOKEN")?
|
||||
cloudflare: optional_env("TUNNEL_CF_TOKEN")?
|
||||
.or_else(|| settings.tunnel.cf_token.clone())
|
||||
.map(|token| crate::tunnel::CloudflareTunnelConfig { token }),
|
||||
tailscale: Some(crate::tunnel::TailscaleTunnelConfig {
|
||||
funnel: db_first_bool(
|
||||
settings.tunnel.ts_funnel,
|
||||
defaults.ts_funnel,
|
||||
"TUNNEL_TS_FUNNEL",
|
||||
)?,
|
||||
hostname: db_first_optional_string(
|
||||
&settings.tunnel.ts_hostname,
|
||||
"TUNNEL_TS_HOSTNAME",
|
||||
)?,
|
||||
funnel: optional_env("TUNNEL_TS_FUNNEL")?
|
||||
.map(|s| s == "true" || s == "1")
|
||||
.unwrap_or(settings.tunnel.ts_funnel),
|
||||
hostname: optional_env("TUNNEL_TS_HOSTNAME")?
|
||||
.or_else(|| settings.tunnel.ts_hostname.clone()),
|
||||
}),
|
||||
ngrok: {
|
||||
let ngrok_domain = db_first_optional_string(
|
||||
&settings.tunnel.ngrok_domain,
|
||||
"TUNNEL_NGROK_DOMAIN",
|
||||
)?;
|
||||
db_first_optional_string(&settings.tunnel.ngrok_token, "TUNNEL_NGROK_TOKEN")?
|
||||
let ngrok_domain = optional_env("TUNNEL_NGROK_DOMAIN")?
|
||||
.or_else(|| settings.tunnel.ngrok_domain.clone());
|
||||
optional_env("TUNNEL_NGROK_TOKEN")?
|
||||
.or_else(|| settings.tunnel.ngrok_token.clone())
|
||||
.map(|auth_token| crate::tunnel::NgrokTunnelConfig {
|
||||
auth_token,
|
||||
domain: ngrok_domain,
|
||||
})
|
||||
},
|
||||
custom: {
|
||||
let health_url = db_first_optional_string(
|
||||
&settings.tunnel.custom_health_url,
|
||||
"TUNNEL_CUSTOM_HEALTH_URL",
|
||||
)?;
|
||||
let url_pattern = db_first_optional_string(
|
||||
&settings.tunnel.custom_url_pattern,
|
||||
"TUNNEL_CUSTOM_URL_PATTERN",
|
||||
)?;
|
||||
db_first_optional_string(
|
||||
&settings.tunnel.custom_command,
|
||||
"TUNNEL_CUSTOM_COMMAND",
|
||||
)?
|
||||
.map(|start_command| crate::tunnel::CustomTunnelConfig {
|
||||
start_command,
|
||||
health_url,
|
||||
url_pattern,
|
||||
})
|
||||
let health_url = optional_env("TUNNEL_CUSTOM_HEALTH_URL")?
|
||||
.or_else(|| settings.tunnel.custom_health_url.clone());
|
||||
let url_pattern = optional_env("TUNNEL_CUSTOM_URL_PATTERN")?
|
||||
.or_else(|| settings.tunnel.custom_url_pattern.clone());
|
||||
optional_env("TUNNEL_CUSTOM_COMMAND")?
|
||||
.or_else(|| settings.tunnel.custom_command.clone())
|
||||
.map(|start_command| crate::tunnel::CustomTunnelConfig {
|
||||
start_command,
|
||||
health_url,
|
||||
url_pattern,
|
||||
})
|
||||
},
|
||||
})
|
||||
};
|
||||
|
||||
+17
-43
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{db_first_bool, db_first_or_default, optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// WASM sandbox configuration.
|
||||
@@ -46,41 +46,28 @@ fn default_tools_dir() -> PathBuf {
|
||||
impl WasmConfig {
|
||||
pub(crate) fn resolve(settings: &crate::settings::Settings) -> Result<Self, ConfigError> {
|
||||
let ws = &settings.wasm;
|
||||
let defaults = crate::settings::WasmSettings::default();
|
||||
Ok(Self {
|
||||
enabled: db_first_bool(ws.enabled, defaults.enabled, "WASM_ENABLED")?,
|
||||
tools_dir: if let Some(ref dir) = ws.tools_dir {
|
||||
dir.clone()
|
||||
} else {
|
||||
optional_env("WASM_TOOLS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_tools_dir)
|
||||
},
|
||||
default_memory_limit: db_first_or_default(
|
||||
&ws.default_memory_limit,
|
||||
&defaults.default_memory_limit,
|
||||
enabled: parse_bool_env("WASM_ENABLED", ws.enabled)?,
|
||||
tools_dir: optional_env("WASM_TOOLS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| ws.tools_dir.clone())
|
||||
.unwrap_or_else(default_tools_dir),
|
||||
default_memory_limit: parse_optional_env(
|
||||
"WASM_DEFAULT_MEMORY_LIMIT",
|
||||
ws.default_memory_limit,
|
||||
)?,
|
||||
default_timeout_secs: db_first_or_default(
|
||||
&ws.default_timeout_secs,
|
||||
&defaults.default_timeout_secs,
|
||||
default_timeout_secs: parse_optional_env(
|
||||
"WASM_DEFAULT_TIMEOUT_SECS",
|
||||
ws.default_timeout_secs,
|
||||
)?,
|
||||
default_fuel_limit: db_first_or_default(
|
||||
&ws.default_fuel_limit,
|
||||
&defaults.default_fuel_limit,
|
||||
default_fuel_limit: parse_optional_env(
|
||||
"WASM_DEFAULT_FUEL_LIMIT",
|
||||
ws.default_fuel_limit,
|
||||
)?,
|
||||
cache_compiled: db_first_bool(
|
||||
ws.cache_compiled,
|
||||
defaults.cache_compiled,
|
||||
"WASM_CACHE_COMPILED",
|
||||
)?,
|
||||
cache_dir: if let Some(ref dir) = ws.cache_dir {
|
||||
Some(dir.clone())
|
||||
} else {
|
||||
optional_env("WASM_CACHE_DIR")?.map(PathBuf::from)
|
||||
},
|
||||
cache_compiled: parse_bool_env("WASM_CACHE_COMPILED", ws.cache_compiled)?,
|
||||
cache_dir: optional_env("WASM_CACHE_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| ws.cache_dir.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +111,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_settings_override_env() {
|
||||
fn env_overrides_settings() {
|
||||
let _guard = lock_env();
|
||||
let mut settings = Settings::default();
|
||||
settings.wasm.default_fuel_limit = 42;
|
||||
@@ -134,19 +121,6 @@ mod tests {
|
||||
let cfg = WasmConfig::resolve(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("WASM_DEFAULT_FUEL_LIMIT") };
|
||||
|
||||
assert_eq!(cfg.default_fuel_limit, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_used_when_no_db_setting() {
|
||||
let _guard = lock_env();
|
||||
let settings = Settings::default();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe { std::env::set_var("WASM_DEFAULT_FUEL_LIMIT", "7") };
|
||||
let cfg = WasmConfig::resolve(&settings).expect("resolve");
|
||||
unsafe { std::env::remove_var("WASM_DEFAULT_FUEL_LIMIT") };
|
||||
|
||||
assert_eq!(cfg.default_fuel_limit, 7);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -62,9 +62,9 @@ pub use provider::{
|
||||
ToolDefinition, ToolResult, generate_tool_call_id,
|
||||
};
|
||||
pub use reasoning::{
|
||||
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
||||
TOOL_INTENT_NUDGE, TRUNCATED_TOOL_CALL_NOTICE, TokenUsage, ToolSelection, is_silent_reply,
|
||||
llm_signals_tool_intent,
|
||||
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, ResponseAnomaly,
|
||||
ResponseMetadata, SILENT_REPLY_TOKEN, TOOL_INTENT_NUDGE, TRUNCATED_TOOL_CALL_NOTICE,
|
||||
TokenUsage, ToolSelection, is_silent_reply, llm_signals_tool_intent,
|
||||
};
|
||||
pub use recording::RecordingLlm;
|
||||
pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry};
|
||||
@@ -93,8 +93,6 @@ pub async fn create_llm_provider(
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let timeout = config.request_timeout_secs;
|
||||
|
||||
tracing::info!(backend = %config.backend, "Creating LLM provider");
|
||||
|
||||
if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" {
|
||||
return create_llm_provider_with_config(&config.nearai, session, timeout);
|
||||
}
|
||||
|
||||
+134
-5
@@ -337,6 +337,23 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured anomaly classification for LLM responses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResponseAnomaly {
|
||||
/// Tool mode was requested, but the provider returned no usable tool calls
|
||||
/// and no recoverable text content.
|
||||
EmptyToolCompletion,
|
||||
/// Text mode returned no usable content after cleaning/truncation.
|
||||
EmptyTextResponse,
|
||||
}
|
||||
|
||||
/// Metadata attached to `RespondOutput` so callers can react to malformed
|
||||
/// provider behavior without inferring it from fallback strings.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ResponseMetadata {
|
||||
pub anomaly: Option<ResponseAnomaly>,
|
||||
}
|
||||
|
||||
/// Result of a response with potential tool calls.
|
||||
///
|
||||
/// Used by the agent loop to handle tool execution before returning a final response.
|
||||
@@ -359,6 +376,7 @@ pub struct RespondOutput {
|
||||
pub result: RespondResult,
|
||||
pub usage: TokenUsage,
|
||||
pub finish_reason: FinishReason,
|
||||
pub metadata: ResponseMetadata,
|
||||
}
|
||||
|
||||
/// Reasoning engine for the agent.
|
||||
@@ -744,12 +762,11 @@ Respond in JSON format:
|
||||
},
|
||||
usage,
|
||||
finish_reason: response.finish_reason,
|
||||
metadata: ResponseMetadata::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let content = response
|
||||
.content
|
||||
.unwrap_or_else(|| "I'm not sure how to respond to that.".to_string());
|
||||
let content = response.content.unwrap_or_default();
|
||||
|
||||
// Some models (e.g. GLM-4.7) emit tool calls as XML tags in content
|
||||
// instead of using the structured tool_calls field. Try to recover
|
||||
@@ -772,6 +789,7 @@ Respond in JSON format:
|
||||
},
|
||||
usage,
|
||||
finish_reason: response.finish_reason,
|
||||
metadata: ResponseMetadata::default(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -785,11 +803,18 @@ Respond in JSON format:
|
||||
// Pre-truncate at tool tags to preserve text before the tag.
|
||||
let pre_truncated = truncate_at_tool_tags(&content);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
let final_text = if cleaned.trim().is_empty() {
|
||||
let metadata = if cleaned.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
"LLM response was empty after cleaning (original len={}), using fallback",
|
||||
content.len()
|
||||
);
|
||||
ResponseMetadata {
|
||||
anomaly: Some(ResponseAnomaly::EmptyToolCompletion),
|
||||
}
|
||||
} else {
|
||||
ResponseMetadata::default()
|
||||
};
|
||||
let final_text = if metadata.anomaly.is_some() {
|
||||
"I'm not sure how to respond to that.".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
@@ -798,6 +823,7 @@ Respond in JSON format:
|
||||
result: RespondResult::Text(final_text),
|
||||
usage,
|
||||
finish_reason: response.finish_reason,
|
||||
metadata,
|
||||
})
|
||||
} else {
|
||||
// No tools, use simple completion
|
||||
@@ -812,11 +838,18 @@ Respond in JSON format:
|
||||
let response = self.llm.complete(request).await?;
|
||||
let pre_truncated = truncate_at_tool_tags(&response.content);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
let final_text = if cleaned.trim().is_empty() {
|
||||
let metadata = if cleaned.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
"LLM response was empty after cleaning (original len={}), using fallback",
|
||||
response.content.len()
|
||||
);
|
||||
ResponseMetadata {
|
||||
anomaly: Some(ResponseAnomaly::EmptyTextResponse),
|
||||
}
|
||||
} else {
|
||||
ResponseMetadata::default()
|
||||
};
|
||||
let final_text = if metadata.anomaly.is_some() {
|
||||
"I'm not sure how to respond to that.".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
@@ -830,6 +863,7 @@ Respond in JSON format:
|
||||
cache_creation_input_tokens: response.cache_creation_input_tokens,
|
||||
},
|
||||
finish_reason: response.finish_reason,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3101,9 +3135,104 @@ That's my plan."#;
|
||||
context.force_text = true;
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await.unwrap();
|
||||
let metadata = output.metadata;
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
assert_eq!(text, "I'm not sure how to respond to that.");
|
||||
assert_eq!(metadata.anomaly, Some(ResponseAnomaly::EmptyTextResponse));
|
||||
}
|
||||
RespondResult::ToolCalls { .. } => {
|
||||
panic!("Expected fallback text, not tool calls");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_with_tools_flags_empty_tool_completion() {
|
||||
use crate::testing::StubLlm;
|
||||
let llm = Arc::new(StubLlm::new(""));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let context = ReasoningContext::new()
|
||||
.with_message(ChatMessage::user("list tools"))
|
||||
.with_tools(vec![ToolDefinition {
|
||||
name: "tool_list".to_string(),
|
||||
description: "Lists tools".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
}]);
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await.unwrap();
|
||||
let metadata = output.metadata;
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
assert_eq!(text, "I'm not sure how to respond to that.");
|
||||
assert_eq!(metadata.anomaly, Some(ResponseAnomaly::EmptyToolCompletion));
|
||||
}
|
||||
RespondResult::ToolCalls { .. } => {
|
||||
panic!("Expected fallback text, not tool calls");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_with_tools_flags_empty_tool_completion_when_content_is_none() {
|
||||
use crate::llm::{
|
||||
FinishReason, LlmProvider, ToolCompletionRequest, ToolCompletionResponse,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
struct NoneContentToolLlm;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for NoneContentToolLlm {
|
||||
fn model_name(&self) -> &str {
|
||||
"none-content-tool-llm"
|
||||
}
|
||||
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||
(Decimal::ZERO, Decimal::ZERO)
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
&self,
|
||||
_request: crate::llm::CompletionRequest,
|
||||
) -> Result<crate::llm::CompletionResponse, crate::llm::LlmError> {
|
||||
unreachable!("tool-mode test should not call complete()")
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
_request: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, crate::llm::LlmError> {
|
||||
Ok(ToolCompletionResponse {
|
||||
content: None,
|
||||
tool_calls: Vec::new(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let reasoning = Reasoning::new(Arc::new(NoneContentToolLlm));
|
||||
|
||||
let context = ReasoningContext::new()
|
||||
.with_message(ChatMessage::user("list tools"))
|
||||
.with_tools(vec![ToolDefinition {
|
||||
name: "tool_list".to_string(),
|
||||
description: "Lists tools".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
}]);
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await.unwrap();
|
||||
let metadata = output.metadata;
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
assert_eq!(text, "I'm not sure how to respond to that.");
|
||||
assert_eq!(metadata.anomaly, Some(ResponseAnomaly::EmptyToolCompletion));
|
||||
}
|
||||
RespondResult::ToolCalls { .. } => {
|
||||
panic!("Expected fallback text, not tool calls");
|
||||
|
||||
@@ -301,10 +301,6 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
}
|
||||
crate::llm::Role::User => {
|
||||
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
|
||||
@@ -368,12 +364,6 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
history.push(RigMessage::assistant(&msg.content));
|
||||
}
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
@@ -1463,70 +1453,6 @@ mod tests {
|
||||
assert_eq!(history.len(), 2);
|
||||
}
|
||||
|
||||
/// Empty user messages (e.g. after thinking-tag stripping) must be skipped.
|
||||
/// Strict providers like Kimi return 400 when "content": "" is sent.
|
||||
#[test]
|
||||
fn test_empty_user_message_is_skipped() {
|
||||
let empty = ChatMessage::user("");
|
||||
let non_empty = ChatMessage::user("hello");
|
||||
let messages = vec![empty, non_empty];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 1, "empty user message must be dropped");
|
||||
match &history[0] {
|
||||
RigMessage::User { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
let first = content.iter().next().expect("one content item");
|
||||
match first {
|
||||
UserContent::Text(t) => assert_eq!(t.text, "hello"),
|
||||
other => panic!("expected Text, got {:?}", other),
|
||||
}
|
||||
}
|
||||
other => panic!("expected User message, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty assistant messages (e.g. after thinking-tag stripping) must be skipped.
|
||||
#[test]
|
||||
fn test_empty_assistant_message_is_skipped() {
|
||||
let empty_asst = ChatMessage {
|
||||
role: crate::llm::Role::Assistant,
|
||||
content: String::new(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
content_parts: vec![],
|
||||
};
|
||||
let non_empty = ChatMessage::user("hi");
|
||||
let messages = vec![empty_asst, non_empty];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 1, "empty assistant message must be dropped");
|
||||
assert!(matches!(history[0], RigMessage::User { .. }));
|
||||
}
|
||||
|
||||
/// A conversation mixing normal and empty messages: only non-empty ones survive.
|
||||
#[test]
|
||||
fn test_mixed_empty_and_non_empty_messages_filtered_correctly() {
|
||||
let user1 = ChatMessage::user("first");
|
||||
let empty_asst = ChatMessage {
|
||||
role: crate::llm::Role::Assistant,
|
||||
content: String::new(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
content_parts: vec![],
|
||||
};
|
||||
let user2 = ChatMessage::user("");
|
||||
let asst = ChatMessage::assistant("response");
|
||||
let messages = vec![user1, empty_asst, user2, asst];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 2, "only non-empty messages should survive");
|
||||
assert!(matches!(history[0], RigMessage::User { .. }));
|
||||
assert!(matches!(history[1], RigMessage::Assistant { .. }));
|
||||
}
|
||||
|
||||
// -- normalized_tool_call_id tests --
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -651,9 +651,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
if let Some(ref d) = components.db {
|
||||
gw = gw.with_store(Arc::clone(d));
|
||||
}
|
||||
if let Some(ref ss) = components.secrets_store {
|
||||
gw = gw.with_secrets_store(Arc::clone(ss));
|
||||
}
|
||||
if let Some(ref jm) = container_job_manager {
|
||||
gw = gw.with_job_manager(Arc::clone(jm));
|
||||
}
|
||||
|
||||
+47
-365
@@ -1,62 +1,14 @@
|
||||
//! User settings persistence.
|
||||
//!
|
||||
//! Stores user preferences in `~/.ironclaw` (JSON/TOML) and, for some values,
|
||||
//! in the database. Precedence between database values, environment variables,
|
||||
//! on-disk config, and built-in defaults is determined on a per-setting basis
|
||||
//! by the corresponding resolver. LLM provider settings (backend, model,
|
||||
//! api_key, base_url) prefer DB values over environment variables, as
|
||||
//! documented on their respective types.
|
||||
//! Stores user preferences in ~/.ironclaw/settings.json.
|
||||
//! Settings are loaded with env var > settings.json > default priority.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
|
||||
/// A custom LLM provider defined by the user through the web UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomLlmProviderSettings {
|
||||
/// Unique identifier (used as `llm_backend` value).
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Adapter protocol: "open_ai_completions", "anthropic", "ollama".
|
||||
pub adapter: String,
|
||||
/// Base URL for the API endpoint.
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
/// Default model identifier.
|
||||
#[serde(default)]
|
||||
pub default_model: Option<String>,
|
||||
/// Optional API key stored inline.
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Whether this is a built-in provider (should always be false for custom).
|
||||
#[serde(default)]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Per-provider overrides for built-in LLM providers (API key and/or model).
|
||||
///
|
||||
/// Stored as `llm_builtin_overrides` in the settings store, keyed by provider ID
|
||||
/// (e.g. `"openai"`, `"gemini"`). Resolved at startup during `LlmConfig::resolve()`.
|
||||
///
|
||||
/// Note: The global `selected_model` (if set) takes precedence over these
|
||||
/// per-provider overrides, which in turn take precedence over environment variables.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LlmBuiltinOverride {
|
||||
/// API key override. Takes precedence over environment variables.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
/// Model override. Takes precedence over environment variables but not `selected_model`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Base URL override. Takes precedence over environment variables.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// User settings persisted to disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
@@ -107,14 +59,6 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub llm_backend: Option<String>,
|
||||
|
||||
/// Custom LLM providers defined by the user through the web UI.
|
||||
#[serde(default)]
|
||||
pub llm_custom_providers: Vec<CustomLlmProviderSettings>,
|
||||
|
||||
/// Per-provider overrides for built-in providers (API key and/or model).
|
||||
#[serde(default)]
|
||||
pub llm_builtin_overrides: HashMap<String, LlmBuiltinOverride>,
|
||||
|
||||
/// Ollama base URL (when llm_backend = "ollama").
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
@@ -191,22 +135,6 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub builder: BuilderSettings,
|
||||
|
||||
/// Routine scheduling and execution configuration.
|
||||
#[serde(default)]
|
||||
pub routines: RoutineSettings,
|
||||
|
||||
/// Skills system configuration.
|
||||
#[serde(default)]
|
||||
pub skills: SkillsSettings,
|
||||
|
||||
/// Memory hygiene configuration.
|
||||
#[serde(default)]
|
||||
pub hygiene: HygieneSettings,
|
||||
|
||||
/// Workspace search fusion configuration.
|
||||
#[serde(default)]
|
||||
pub search: SearchSettings,
|
||||
|
||||
/// Transcription configuration.
|
||||
#[serde(default)]
|
||||
pub transcription: Option<TranscriptionSettings>,
|
||||
@@ -802,196 +730,6 @@ impl Default for BuilderSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routine scheduling and execution configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutineSettings {
|
||||
/// Whether the routines system is enabled.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// How often (seconds) to poll for cron routines that need firing.
|
||||
#[serde(default = "default_routine_cron_interval")]
|
||||
pub cron_check_interval_secs: u64,
|
||||
|
||||
/// Max routines executing concurrently.
|
||||
#[serde(default = "default_routine_max_concurrent")]
|
||||
pub max_concurrent_routines: usize,
|
||||
|
||||
/// Default cooldown between fires (seconds).
|
||||
#[serde(default = "default_routine_cooldown")]
|
||||
pub default_cooldown_secs: u64,
|
||||
|
||||
/// Max output tokens for lightweight routine LLM calls.
|
||||
#[serde(default = "default_routine_max_tokens")]
|
||||
pub max_lightweight_tokens: u32,
|
||||
|
||||
/// Enable tool execution in lightweight routines.
|
||||
#[serde(default = "default_true")]
|
||||
pub lightweight_tools_enabled: bool,
|
||||
|
||||
/// Max tool iterations for lightweight routines.
|
||||
#[serde(default = "default_routine_max_iterations")]
|
||||
pub lightweight_max_iterations: u32,
|
||||
}
|
||||
|
||||
fn default_routine_cron_interval() -> u64 {
|
||||
15
|
||||
}
|
||||
|
||||
fn default_routine_max_concurrent() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_routine_cooldown() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_routine_max_tokens() -> u32 {
|
||||
4096
|
||||
}
|
||||
|
||||
fn default_routine_max_iterations() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
impl Default for RoutineSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
cron_check_interval_secs: default_routine_cron_interval(),
|
||||
max_concurrent_routines: default_routine_max_concurrent(),
|
||||
default_cooldown_secs: default_routine_cooldown(),
|
||||
max_lightweight_tokens: default_routine_max_tokens(),
|
||||
lightweight_tools_enabled: true,
|
||||
lightweight_max_iterations: default_routine_max_iterations(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skills system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillsSettings {
|
||||
/// Whether the skills system is enabled.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Maximum number of skills that can be active simultaneously.
|
||||
#[serde(default = "default_skills_max_active")]
|
||||
pub max_active_skills: usize,
|
||||
|
||||
/// Maximum total context tokens allocated to skill prompts.
|
||||
#[serde(default = "default_skills_max_context_tokens")]
|
||||
pub max_context_tokens: usize,
|
||||
}
|
||||
|
||||
fn default_skills_max_active() -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_skills_max_context_tokens() -> usize {
|
||||
4000
|
||||
}
|
||||
|
||||
impl Default for SkillsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_active_skills: default_skills_max_active(),
|
||||
max_context_tokens: default_skills_max_context_tokens(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory hygiene configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HygieneSettings {
|
||||
/// Whether hygiene is enabled.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Days before `daily/` documents are deleted.
|
||||
#[serde(default = "default_hygiene_daily_retention")]
|
||||
pub daily_retention_days: u32,
|
||||
|
||||
/// Days before `conversations/` documents are deleted.
|
||||
#[serde(default = "default_hygiene_conversation_retention")]
|
||||
pub conversation_retention_days: u32,
|
||||
|
||||
/// Minimum hours between hygiene passes.
|
||||
#[serde(default = "default_hygiene_cadence_hours")]
|
||||
pub cadence_hours: u32,
|
||||
}
|
||||
|
||||
fn default_hygiene_daily_retention() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_hygiene_conversation_retention() -> u32 {
|
||||
7
|
||||
}
|
||||
|
||||
fn default_hygiene_cadence_hours() -> u32 {
|
||||
12
|
||||
}
|
||||
|
||||
impl Default for HygieneSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
daily_retention_days: default_hygiene_daily_retention(),
|
||||
conversation_retention_days: default_hygiene_conversation_retention(),
|
||||
cadence_hours: default_hygiene_cadence_hours(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace search fusion configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchSettings {
|
||||
/// Fusion strategy: "rrf" or "weighted".
|
||||
#[serde(default = "default_search_fusion_strategy")]
|
||||
pub fusion_strategy: String,
|
||||
|
||||
/// RRF constant k.
|
||||
#[serde(default = "default_search_rrf_k")]
|
||||
pub rrf_k: u32,
|
||||
|
||||
/// FTS weight for fusion.
|
||||
#[serde(default = "default_search_fts_weight")]
|
||||
pub fts_weight: f32,
|
||||
|
||||
/// Vector weight for fusion.
|
||||
#[serde(default = "default_search_vector_weight")]
|
||||
pub vector_weight: f32,
|
||||
}
|
||||
|
||||
fn default_search_fusion_strategy() -> String {
|
||||
"rrf".to_string()
|
||||
}
|
||||
|
||||
fn default_search_rrf_k() -> u32 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_search_fts_weight() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
fn default_search_vector_weight() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
impl Default for SearchSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fusion_strategy: default_search_fusion_strategy(),
|
||||
rrf_k: default_search_rrf_k(),
|
||||
fts_weight: default_search_fts_weight(),
|
||||
vector_weight: default_search_vector_weight(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transcription pipeline settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranscriptionSettings {
|
||||
@@ -1108,9 +846,7 @@ impl Settings {
|
||||
let content = format!(
|
||||
"# IronClaw configuration file.\n\
|
||||
#\n\
|
||||
# Priority: DB settings > env vars > this file > defaults.\n\
|
||||
# Exceptions: bootstrap fields (DATABASE_URL, etc.) and\n\
|
||||
# security-sensitive fields are env-only.\n\
|
||||
# Priority: env var > this file > database settings > defaults.\n\
|
||||
# Uncomment and edit values to override defaults.\n\
|
||||
# Run `ironclaw config init` to regenerate this file.\n\
|
||||
#\n\
|
||||
@@ -1594,53 +1330,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TOML is loaded as a base, then DB is merged on top (DB wins).
|
||||
/// When both agree, the result matches.
|
||||
/// Regression: TOML overlay must not clobber a DB-persisted selected_model
|
||||
/// when the TOML file matches the DB. This is the normal case after /model
|
||||
/// successfully writes to both DB and TOML.
|
||||
#[test]
|
||||
fn toml_and_db_matching_model_preserved() {
|
||||
// from_db_with_toml: TOML base, then DB merged on top.
|
||||
let mut toml_base = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let db_overlay = Settings {
|
||||
fn toml_overlay_preserves_matching_model() {
|
||||
// DB settings with new model from /model command.
|
||||
let mut db_settings = Settings {
|
||||
llm_backend: Some("nearai".to_string()),
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
toml_base.merge_from(&db_overlay);
|
||||
assert_eq!(
|
||||
toml_base.selected_model,
|
||||
Some("new-model".to_string()),
|
||||
"matching values: result should be the shared value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when TOML has a stale model but DB has been updated via
|
||||
/// /model command, DB must win. This matches from_db_with_toml where
|
||||
/// TOML is loaded first as base, then DB is merged on top.
|
||||
#[test]
|
||||
fn db_model_wins_over_stale_toml() {
|
||||
// TOML base with old model.
|
||||
let mut toml_base = Settings {
|
||||
selected_model: Some("old-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// DB has the new model from /model command.
|
||||
let db_overlay = Settings {
|
||||
// TOML also updated by /model command to the same value.
|
||||
let toml_settings = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// from_db_with_toml: TOML first, then DB merged on top.
|
||||
toml_base.merge_from(&db_overlay);
|
||||
db_settings.merge_from(&toml_settings);
|
||||
assert_eq!(
|
||||
toml_base.selected_model,
|
||||
db_settings.selected_model,
|
||||
Some("new-model".to_string()),
|
||||
"DB selected_model must win over stale TOML value"
|
||||
"TOML overlay must not clobber matching model"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when /model updates DB but TOML write fails, a stale TOML
|
||||
/// file would overwrite the DB value. This test documents the priority:
|
||||
/// TOML > DB (by design). persist_selected_model MUST update the TOML.
|
||||
#[test]
|
||||
fn stale_toml_overwrites_db_model() {
|
||||
// DB has the new model from /model.
|
||||
let mut db_settings = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// TOML still has the old model (write failed or was not attempted).
|
||||
let stale_toml = Settings {
|
||||
selected_model: Some("old-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
db_settings.merge_from(&stale_toml);
|
||||
// This documents the current priority: TOML wins over DB.
|
||||
// The fix in persist_selected_model ensures TOML is always updated.
|
||||
assert_eq!(
|
||||
db_settings.selected_model,
|
||||
Some("old-model".to_string()),
|
||||
"TOML overlay has higher priority than DB (by design)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1669,20 +1408,24 @@ mod tests {
|
||||
assert_eq!(reloaded.selected_model, Some("new-model".to_string()));
|
||||
}
|
||||
|
||||
/// save_toml / load_toml round-trip for selected_model.
|
||||
/// Regression: /model must create config.toml when it doesn't exist, so the
|
||||
/// model survives restarts. Previously the Ok(None) case was a no-op.
|
||||
#[test]
|
||||
fn toml_save_and_load_round_trip() {
|
||||
fn toml_created_when_missing_for_model_persist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
// No config.toml yet (fresh install, no wizard).
|
||||
assert!(Settings::load_toml(&path).unwrap().is_none());
|
||||
|
||||
// Simulate what persist_selected_model now does for the Ok(None) case.
|
||||
let settings = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
settings.save_toml(&path).unwrap();
|
||||
|
||||
// Verify the model survived.
|
||||
let loaded = Settings::load_toml(&path).unwrap().unwrap();
|
||||
assert_eq!(loaded.selected_model, Some("new-model".to_string()));
|
||||
}
|
||||
@@ -2640,65 +2383,4 @@ mod tests {
|
||||
assert_eq!(current.embeddings.provider, "nearai");
|
||||
assert_eq!(current.embeddings.model, "text-embedding-3-large");
|
||||
}
|
||||
|
||||
/// DB values must win over TOML values when both set the same field.
|
||||
///
|
||||
/// This mirrors the merge order in `Config::from_db_with_toml`:
|
||||
/// TOML is loaded as the base, then DB is merged on top.
|
||||
#[test]
|
||||
fn db_settings_win_over_toml_settings() {
|
||||
// Simulate TOML base: has llm_backend and selected_model
|
||||
let mut base = Settings {
|
||||
llm_backend: Some("openai".to_string()),
|
||||
selected_model: Some("toml-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Simulate DB overlay: has different llm_backend and selected_model
|
||||
let db = Settings {
|
||||
llm_backend: Some("anthropic".to_string()),
|
||||
selected_model: Some("db-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Merge DB on top of TOML (same order as from_db_with_toml)
|
||||
base.merge_from(&db);
|
||||
|
||||
assert_eq!(
|
||||
base.llm_backend.as_deref(),
|
||||
Some("anthropic"),
|
||||
"DB llm_backend must win over TOML"
|
||||
);
|
||||
assert_eq!(
|
||||
base.selected_model.as_deref(),
|
||||
Some("db-model"),
|
||||
"DB selected_model must win over TOML"
|
||||
);
|
||||
}
|
||||
|
||||
/// When DB has no value (default), TOML value should be preserved.
|
||||
#[test]
|
||||
fn toml_settings_used_when_db_has_no_value() {
|
||||
let mut base = Settings {
|
||||
llm_backend: Some("openai".to_string()),
|
||||
selected_model: Some("toml-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// DB has no llm_backend or selected_model (both default/None)
|
||||
let db = Settings::default();
|
||||
|
||||
base.merge_from(&db);
|
||||
|
||||
assert_eq!(
|
||||
base.llm_backend.as_deref(),
|
||||
Some("openai"),
|
||||
"TOML llm_backend should be preserved when DB has no value"
|
||||
);
|
||||
assert_eq!(
|
||||
base.selected_model.as_deref(),
|
||||
Some("toml-model"),
|
||||
"TOML selected_model should be preserved when DB has no value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
use crate::llm::{ResponseAnomaly, ResponseMetadata};
|
||||
|
||||
pub(crate) const EMPTY_TOOL_COMPLETION_NUDGE: &str = "\
|
||||
Your previous tool-enabled response was empty or malformed.\n\
|
||||
If you need to use a tool, call it now with valid arguments.\n\
|
||||
Otherwise, provide a real status update about work already completed.";
|
||||
|
||||
pub(crate) const FORCE_TEXT_RECOVERY_PROMPT: &str = "\
|
||||
Your previous tool-enabled responses were empty or malformed.\n\
|
||||
Do not call any more tools in the next reply.\n\
|
||||
Instead, provide a concise final status based only on work already completed.\n\
|
||||
If the job is complete, say so explicitly. If not, explain what blocked you.";
|
||||
|
||||
pub(crate) const EMPTY_TOOL_COMPLETION_FAILURE: &str = "the selected model repeatedly returned empty or malformed tool-completion responses and is not reliable for autonomous tool use.";
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub(crate) struct AutonomousRecoveryState {
|
||||
consecutive_empty_tool_completions: usize,
|
||||
force_text_recovery_pending: bool,
|
||||
force_text_recovery_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AutonomousRecoveryAction {
|
||||
Continue,
|
||||
ToolModeNudge,
|
||||
ForceTextRecovery,
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl AutonomousRecoveryState {
|
||||
pub(crate) fn begin_iteration(&mut self) -> bool {
|
||||
if self.force_text_recovery_pending {
|
||||
self.force_text_recovery_pending = false;
|
||||
self.force_text_recovery_active = true;
|
||||
true
|
||||
} else {
|
||||
self.force_text_recovery_active
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_text_response(
|
||||
&mut self,
|
||||
metadata: ResponseMetadata,
|
||||
text: &str,
|
||||
) -> AutonomousRecoveryAction {
|
||||
match metadata.anomaly {
|
||||
Some(ResponseAnomaly::EmptyToolCompletion) => {
|
||||
self.consecutive_empty_tool_completions =
|
||||
self.consecutive_empty_tool_completions.saturating_add(1);
|
||||
self.force_text_recovery_active = false;
|
||||
match self.consecutive_empty_tool_completions {
|
||||
1 => AutonomousRecoveryAction::ToolModeNudge,
|
||||
2 => {
|
||||
self.force_text_recovery_pending = true;
|
||||
AutonomousRecoveryAction::ForceTextRecovery
|
||||
}
|
||||
_ => AutonomousRecoveryAction::Fail,
|
||||
}
|
||||
}
|
||||
Some(ResponseAnomaly::EmptyTextResponse) if self.force_text_recovery_active => {
|
||||
self.force_text_recovery_active = false;
|
||||
AutonomousRecoveryAction::Fail
|
||||
}
|
||||
_ if !text.trim().is_empty() => {
|
||||
self.reset();
|
||||
AutonomousRecoveryAction::Continue
|
||||
}
|
||||
_ => AutonomousRecoveryAction::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_valid_tool_call(&mut self) {
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.consecutive_empty_tool_completions = 0;
|
||||
self.force_text_recovery_pending = false;
|
||||
self.force_text_recovery_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn metadata(anomaly: ResponseAnomaly) -> ResponseMetadata {
|
||||
ResponseMetadata {
|
||||
anomaly: Some(anomaly),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_empty_tool_completion_issues_nudge() {
|
||||
let mut state = AutonomousRecoveryState::default();
|
||||
let action = state.on_text_response(
|
||||
metadata(ResponseAnomaly::EmptyToolCompletion),
|
||||
"I'm not sure how to respond to that.",
|
||||
);
|
||||
assert_eq!(action, AutonomousRecoveryAction::ToolModeNudge);
|
||||
assert!(!state.begin_iteration());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_empty_tool_completion_schedules_text_recovery() {
|
||||
let mut state = AutonomousRecoveryState::default();
|
||||
let _ = state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
let action =
|
||||
state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
assert_eq!(action, AutonomousRecoveryAction::ForceTextRecovery);
|
||||
assert!(state.begin_iteration());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_text_recovery_fallback_fails() {
|
||||
let mut state = AutonomousRecoveryState::default();
|
||||
let _ = state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
let _ = state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
assert!(state.begin_iteration());
|
||||
let action =
|
||||
state.on_text_response(metadata(ResponseAnomaly::EmptyTextResponse), "fallback");
|
||||
assert_eq!(action, AutonomousRecoveryAction::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_tool_call_resets_counter() {
|
||||
let mut state = AutonomousRecoveryState::default();
|
||||
let _ = state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
state.on_valid_tool_call();
|
||||
let action =
|
||||
state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
assert_eq!(action, AutonomousRecoveryAction::ToolModeNudge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meaningful_text_after_text_recovery_resets_state() {
|
||||
let mut state = AutonomousRecoveryState::default();
|
||||
let _ = state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
let _ = state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
assert!(state.begin_iteration());
|
||||
|
||||
let action = state.on_text_response(ResponseMetadata::default(), "Still working on step 2");
|
||||
assert_eq!(action, AutonomousRecoveryAction::Continue);
|
||||
|
||||
let next =
|
||||
state.on_text_response(metadata(ResponseAnomaly::EmptyToolCompletion), "fallback");
|
||||
assert_eq!(next, AutonomousRecoveryAction::ToolModeNudge);
|
||||
}
|
||||
}
|
||||
+86
-3
@@ -21,11 +21,15 @@ use crate::agent::agentic_loop::{
|
||||
use crate::config::SafetyConfig;
|
||||
use crate::context::JobContext;
|
||||
use crate::error::WorkerError;
|
||||
use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext};
|
||||
use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, ResponseMetadata};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::execute::{execute_tool_simple, process_tool_result};
|
||||
use crate::worker::api::{CompletionReport, JobEventPayload, StatusUpdate, WorkerHttpClient};
|
||||
use crate::worker::autonomous_recovery::{
|
||||
AutonomousRecoveryAction, AutonomousRecoveryState, EMPTY_TOOL_COMPLETION_FAILURE,
|
||||
EMPTY_TOOL_COMPLETION_NUDGE, FORCE_TEXT_RECOVERY_PROMPT,
|
||||
};
|
||||
use crate::worker::proxy_llm::ProxyLlmProvider;
|
||||
|
||||
/// Configuration for the worker runtime.
|
||||
@@ -170,6 +174,7 @@ Work independently to complete this job. When finished, your final message MUST
|
||||
extra_env: self.extra_env.clone(),
|
||||
last_output: Mutex::new(String::new()),
|
||||
iteration_tracker: iteration_tracker.clone(),
|
||||
recovery_state: Mutex::new(AutonomousRecoveryState::default()),
|
||||
};
|
||||
|
||||
let config = AgenticLoopConfig {
|
||||
@@ -228,6 +233,24 @@ Work independently to complete this job. When finished, your final message MUST
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Ok(Ok(LoopOutcome::Failure(reason))) => {
|
||||
tracing::warn!("Worker failed for job {}: {}", self.config.job_id, reason);
|
||||
self.post_event(
|
||||
"result",
|
||||
serde_json::json!({
|
||||
"success": false,
|
||||
"message": reason,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
self.client
|
||||
.report_complete(&CompletionReport {
|
||||
success: false,
|
||||
message: Some(reason),
|
||||
iterations,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Ok(Ok(LoopOutcome::Stopped | LoopOutcome::NeedApproval(_))) => {
|
||||
tracing::info!("Worker for job {} stopped", self.config.job_id);
|
||||
self.client
|
||||
@@ -304,6 +327,7 @@ struct ContainerDelegate {
|
||||
/// Tracks the current iteration — shared with the outer `run` method so
|
||||
/// `CompletionReport` can include accurate iteration counts.
|
||||
iteration_tracker: Arc<Mutex<u32>>,
|
||||
recovery_state: Mutex<AutonomousRecoveryState>,
|
||||
}
|
||||
|
||||
impl ContainerDelegate {
|
||||
@@ -377,8 +401,17 @@ impl LoopDelegate for ContainerDelegate {
|
||||
// conversation. Ensure the last message is user-role before calling the LLM.
|
||||
crate::util::ensure_ends_with_user_message(&mut reason_ctx.messages);
|
||||
|
||||
// Refresh tools (in case WASM tools were built)
|
||||
reason_ctx.available_tools = self.tools.tool_definitions().await;
|
||||
let force_text_recovery = {
|
||||
let mut recovery = self.recovery_state.lock().await;
|
||||
recovery.begin_iteration()
|
||||
};
|
||||
if force_text_recovery {
|
||||
tracing::warn!("Switching to text-only recovery after malformed tool completions");
|
||||
reason_ctx.available_tools.clear();
|
||||
} else {
|
||||
// Refresh tools (in case WASM tools were built)
|
||||
reason_ctx.available_tools = self.tools.tool_definitions().await;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -399,8 +432,53 @@ impl LoopDelegate for ContainerDelegate {
|
||||
async fn handle_text_response(
|
||||
&self,
|
||||
text: &str,
|
||||
metadata: ResponseMetadata,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
) -> TextAction {
|
||||
let action = {
|
||||
let mut recovery = self.recovery_state.lock().await;
|
||||
recovery.on_text_response(metadata, text)
|
||||
};
|
||||
match action {
|
||||
AutonomousRecoveryAction::ToolModeNudge => {
|
||||
tracing::warn!("Malformed empty tool completion detected; retrying in tool mode");
|
||||
self.post_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": "Model returned an empty tool-completion response; retrying with a stronger tool-use nudge.",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::user(EMPTY_TOOL_COMPLETION_NUDGE));
|
||||
return TextAction::Continue;
|
||||
}
|
||||
AutonomousRecoveryAction::ForceTextRecovery => {
|
||||
tracing::warn!(
|
||||
"Repeated malformed tool completions detected; switching to text-only recovery"
|
||||
);
|
||||
self.post_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": "Model returned repeated empty tool-completion responses; requesting a final status update without tools.",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::user(FORCE_TEXT_RECOVERY_PROMPT));
|
||||
return TextAction::Continue;
|
||||
}
|
||||
AutonomousRecoveryAction::Fail => {
|
||||
tracing::warn!("Failing fast after repeated malformed autonomous responses");
|
||||
return TextAction::Return(LoopOutcome::Failure(
|
||||
EMPTY_TOOL_COMPLETION_FAILURE.to_string(),
|
||||
));
|
||||
}
|
||||
AutonomousRecoveryAction::Continue => {}
|
||||
}
|
||||
|
||||
self.post_event(
|
||||
"message",
|
||||
serde_json::json!({
|
||||
@@ -431,6 +509,11 @@ impl LoopDelegate for ContainerDelegate {
|
||||
content: Option<String>,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
) -> Result<Option<LoopOutcome>, crate::error::Error> {
|
||||
{
|
||||
let mut recovery = self.recovery_state.lock().await;
|
||||
recovery.on_valid_tool_call();
|
||||
}
|
||||
|
||||
if let Some(ref text) = content {
|
||||
self.post_event(
|
||||
"message",
|
||||
|
||||
+84
-4
@@ -23,8 +23,8 @@ use crate::context::{ContextManager, JobState};
|
||||
use crate::error::Error;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::{
|
||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolCall,
|
||||
ToolSelection,
|
||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult,
|
||||
ResponseMetadata, ToolCall, ToolSelection,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tenant::AdminScope;
|
||||
@@ -33,6 +33,10 @@ use crate::tools::rate_limiter::RateLimitResult;
|
||||
use crate::tools::{
|
||||
ApprovalContext, ToolRegistry, autonomous_unavailable_error, prepare_tool_params, redact_params,
|
||||
};
|
||||
use crate::worker::autonomous_recovery::{
|
||||
AutonomousRecoveryAction, AutonomousRecoveryState, EMPTY_TOOL_COMPLETION_FAILURE,
|
||||
EMPTY_TOOL_COMPLETION_NUDGE, FORCE_TEXT_RECOVERY_PROMPT,
|
||||
};
|
||||
use ironclaw_common::AppEvent;
|
||||
|
||||
/// Shared dependencies for worker execution.
|
||||
@@ -391,6 +395,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
worker: self,
|
||||
rx: tokio::sync::Mutex::new(rx),
|
||||
consecutive_rate_limits: std::sync::atomic::AtomicUsize::new(0),
|
||||
recovery_state: tokio::sync::Mutex::new(AutonomousRecoveryState::default()),
|
||||
};
|
||||
|
||||
let config = AgenticLoopConfig {
|
||||
@@ -409,6 +414,9 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
self.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
|
||||
.await?;
|
||||
}
|
||||
LoopOutcome::Failure(reason) => {
|
||||
self.mark_failed(&reason).await?;
|
||||
}
|
||||
LoopOutcome::Stopped => {
|
||||
// Stop signal handled — nothing more to do
|
||||
}
|
||||
@@ -1109,6 +1117,7 @@ struct JobDelegate<'a> {
|
||||
rx: tokio::sync::Mutex<&'a mut mpsc::Receiver<WorkerMessage>>,
|
||||
/// Tracks consecutive rate-limit errors to fail fast instead of burning iterations.
|
||||
consecutive_rate_limits: std::sync::atomic::AtomicUsize,
|
||||
recovery_state: tokio::sync::Mutex<AutonomousRecoveryState>,
|
||||
}
|
||||
|
||||
impl<'a> JobDelegate<'a> {
|
||||
@@ -1159,6 +1168,7 @@ impl<'a> JobDelegate<'a> {
|
||||
result: RespondResult::Text(String::new()),
|
||||
usage: crate::llm::TokenUsage::default(),
|
||||
finish_reason: crate::llm::FinishReason::Stop,
|
||||
metadata: ResponseMetadata::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1250,8 +1260,21 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
_iteration: usize,
|
||||
) -> Option<LoopOutcome> {
|
||||
// Refresh tool definitions so newly built tools become visible
|
||||
reason_ctx.available_tools = self.worker.tools().tool_definitions().await;
|
||||
let force_text_recovery = {
|
||||
let mut recovery = self.recovery_state.lock().await;
|
||||
recovery.begin_iteration()
|
||||
};
|
||||
|
||||
if force_text_recovery {
|
||||
tracing::warn!(
|
||||
job_id = %self.worker.job_id,
|
||||
"Switching to text-only recovery after malformed tool completions"
|
||||
);
|
||||
reason_ctx.available_tools.clear();
|
||||
} else {
|
||||
// Refresh tool definitions so newly built tools become visible
|
||||
reason_ctx.available_tools = self.worker.tools().tool_definitions().await;
|
||||
}
|
||||
|
||||
// Claude 4.6 rejects assistant prefill; NEAR AI rejects any non-user-ending
|
||||
// conversation. Ensure the last message is user-role before calling the LLM.
|
||||
@@ -1285,6 +1308,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
|
||||
},
|
||||
usage: crate::llm::TokenUsage::default(),
|
||||
finish_reason: crate::llm::FinishReason::ToolUse,
|
||||
metadata: ResponseMetadata::default(),
|
||||
});
|
||||
}
|
||||
Ok(_) => {} // empty selections, fall through
|
||||
@@ -1328,8 +1352,59 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
|
||||
async fn handle_text_response(
|
||||
&self,
|
||||
text: &str,
|
||||
metadata: ResponseMetadata,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
) -> TextAction {
|
||||
let action = {
|
||||
let mut recovery = self.recovery_state.lock().await;
|
||||
recovery.on_text_response(metadata, text)
|
||||
};
|
||||
|
||||
match action {
|
||||
AutonomousRecoveryAction::ToolModeNudge => {
|
||||
tracing::warn!(
|
||||
job_id = %self.worker.job_id,
|
||||
"Malformed empty tool completion detected; retrying in tool mode"
|
||||
);
|
||||
self.worker.log_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": "Model returned an empty tool-completion response; retrying with a stronger tool-use nudge.",
|
||||
}),
|
||||
);
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::user(EMPTY_TOOL_COMPLETION_NUDGE));
|
||||
return TextAction::Continue;
|
||||
}
|
||||
AutonomousRecoveryAction::ForceTextRecovery => {
|
||||
tracing::warn!(
|
||||
job_id = %self.worker.job_id,
|
||||
"Repeated malformed tool completions detected; switching to text-only recovery"
|
||||
);
|
||||
self.worker.log_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": "Model returned repeated empty tool-completion responses; requesting a final status update without tools.",
|
||||
}),
|
||||
);
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::user(FORCE_TEXT_RECOVERY_PROMPT));
|
||||
return TextAction::Continue;
|
||||
}
|
||||
AutonomousRecoveryAction::Fail => {
|
||||
tracing::warn!(
|
||||
job_id = %self.worker.job_id,
|
||||
"Failing fast after repeated malformed autonomous responses"
|
||||
);
|
||||
return TextAction::Return(LoopOutcome::Failure(
|
||||
EMPTY_TOOL_COMPLETION_FAILURE.to_string(),
|
||||
));
|
||||
}
|
||||
AutonomousRecoveryAction::Continue => {}
|
||||
}
|
||||
|
||||
// Empty text from rate-limit backoff retry — skip processing and let the
|
||||
// loop proceed to the next iteration which will re-call the LLM.
|
||||
if text.is_empty() {
|
||||
@@ -1368,6 +1443,11 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
|
||||
content: Option<String>,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
) -> Result<Option<LoopOutcome>, crate::error::Error> {
|
||||
{
|
||||
let mut recovery = self.recovery_state.lock().await;
|
||||
recovery.on_valid_tool_call();
|
||||
}
|
||||
|
||||
if let Some(ref text) = content {
|
||||
self.worker.log_event(
|
||||
"message",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! ```
|
||||
|
||||
pub mod api;
|
||||
mod autonomous_recovery;
|
||||
pub mod claude_bridge;
|
||||
pub mod container;
|
||||
pub mod job;
|
||||
|
||||
@@ -11,9 +11,76 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use ironclaw::agent::routine::{RoutineAction, Trigger};
|
||||
use ironclaw::context::{JobContext, JobState};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::support::test_rig::TestRigBuilder;
|
||||
use crate::support::trace_llm::LlmTrace;
|
||||
use crate::support::test_rig::{TestRig, TestRigBuilder};
|
||||
use crate::support::trace_llm::{LlmTrace, RequestHint, TraceResponse, TraceStep};
|
||||
|
||||
fn text_step(content: &str) -> TraceStep {
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: content.to_string(),
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hinted_text_step(content: &str, last_user_message_contains: &str) -> TraceStep {
|
||||
TraceStep {
|
||||
request_hint: Some(RequestHint {
|
||||
last_user_message_contains: Some(last_user_message_contains.to_string()),
|
||||
min_message_count: None,
|
||||
}),
|
||||
response: TraceResponse::Text {
|
||||
content: content.to_string(),
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_job_id(response: &str) -> Uuid {
|
||||
let id = response
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("ID: "))
|
||||
.expect("job creation response should include an ID line");
|
||||
Uuid::parse_str(id).expect("job ID should be a UUID")
|
||||
}
|
||||
|
||||
async fn wait_for_job_state(rig: &TestRig, job_id: Uuid, expected: JobState) -> JobContext {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
if let Some(job) = rig
|
||||
.database()
|
||||
.get_job(job_id)
|
||||
.await
|
||||
.expect("get_job should succeed")
|
||||
&& job.state == expected
|
||||
{
|
||||
return job;
|
||||
}
|
||||
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"job {job_id} did not reach state {expected:?} before timeout"
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn requests_contain(requests: &[Vec<ironclaw::llm::ChatMessage>], needle: &str) -> bool {
|
||||
requests
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|message| message.content.contains(needle))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: time_parse_and_diff
|
||||
@@ -685,6 +752,149 @@ mod tests {
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 8a: command_job_fails_fast_on_repeated_empty_tool_completions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_job_fails_fast_on_repeated_empty_tool_completions() {
|
||||
let trace = LlmTrace::single_turn(
|
||||
"test-empty-tool-recovery-fail",
|
||||
"(worker only)",
|
||||
vec![
|
||||
text_step(""),
|
||||
text_step(""),
|
||||
hinted_text_step("", "valid arguments"),
|
||||
text_step(""),
|
||||
hinted_text_step("", "Do not call any more tools in the next reply."),
|
||||
],
|
||||
);
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("/job reproduce empty tool completion loop")
|
||||
.await;
|
||||
let create_responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
let job_id = extract_job_id(&create_responses[0].content);
|
||||
|
||||
let job = wait_for_job_state(&rig, job_id, JobState::Failed).await;
|
||||
assert_eq!(job.title, "reproduce empty tool completion loop");
|
||||
|
||||
let failure_reason = rig
|
||||
.database()
|
||||
.get_agent_job_failure_reason(job_id)
|
||||
.await
|
||||
.expect("get_agent_job_failure_reason should succeed")
|
||||
.expect("failed job should persist a failure reason");
|
||||
assert!(
|
||||
failure_reason
|
||||
.contains("repeatedly returned empty or malformed tool-completion responses"),
|
||||
"unexpected failure reason: {failure_reason}"
|
||||
);
|
||||
assert!(
|
||||
!failure_reason.contains("max iterations"),
|
||||
"failure should not surface as iteration exhaustion: {failure_reason}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rig.llm_call_count(),
|
||||
5,
|
||||
"worker should stop after the bounded recovery flow"
|
||||
);
|
||||
assert!(
|
||||
!rig.collect_metrics().await.hit_iteration_limit,
|
||||
"bounded recovery should stop before iteration-limit reporting"
|
||||
);
|
||||
|
||||
let requests = rig.captured_llm_requests();
|
||||
assert!(
|
||||
requests_contain(&requests, "call it now with valid arguments"),
|
||||
"expected targeted tool-mode recovery nudge in worker requests"
|
||||
);
|
||||
assert!(
|
||||
requests_contain(&requests, "Do not call any more tools in the next reply."),
|
||||
"expected forced text-only recovery prompt in worker requests"
|
||||
);
|
||||
|
||||
rig.clear().await;
|
||||
rig.send_message(&format!("/status {}", job_id)).await;
|
||||
let status_responses = rig.wait_for_responses(1, Duration::from_secs(5)).await;
|
||||
assert!(
|
||||
status_responses[0].content.contains("Status: Failed"),
|
||||
"unexpected status response: {:?}",
|
||||
status_responses[0].content
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 8b: command_job_text_recovery_can_complete
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_job_text_recovery_can_complete() {
|
||||
let trace = LlmTrace::single_turn(
|
||||
"test-empty-tool-recovery-success",
|
||||
"(worker only)",
|
||||
vec![
|
||||
text_step(""),
|
||||
text_step(""),
|
||||
hinted_text_step("", "valid arguments"),
|
||||
text_step(""),
|
||||
hinted_text_step(
|
||||
"The job is complete. I finished the requested work and there is nothing left to do.",
|
||||
"Do not call any more tools in the next reply.",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("/job recover after malformed tool completions")
|
||||
.await;
|
||||
let create_responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
let job_id = extract_job_id(&create_responses[0].content);
|
||||
|
||||
let job = wait_for_job_state(&rig, job_id, JobState::Completed).await;
|
||||
assert_eq!(job.title, "recover after malformed tool completions");
|
||||
|
||||
assert_eq!(
|
||||
rig.llm_call_count(),
|
||||
5,
|
||||
"worker should complete within the bounded recovery flow"
|
||||
);
|
||||
|
||||
let requests = rig.captured_llm_requests();
|
||||
assert!(
|
||||
requests_contain(&requests, "call it now with valid arguments"),
|
||||
"expected targeted tool-mode recovery nudge in worker requests"
|
||||
);
|
||||
assert!(
|
||||
requests_contain(&requests, "Do not call any more tools in the next reply."),
|
||||
"expected forced text-only recovery prompt in worker requests"
|
||||
);
|
||||
|
||||
rig.clear().await;
|
||||
rig.send_message(&format!("/status {}", job_id)).await;
|
||||
let status_responses = rig.wait_for_responses(1, Duration::from_secs(5)).await;
|
||||
assert!(
|
||||
status_responses[0].content.contains("Status: Completed"),
|
||||
"unexpected status response: {:?}",
|
||||
status_responses[0].content
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 9: job_list_cancel
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -558,7 +558,6 @@ fn gateway_state_has_multi_tenant_fields() {
|
||||
startup_time: std::time::Instant::now(),
|
||||
webhook_rate_limiter: RateLimiter::new(10, 60),
|
||||
active_config: Default::default(),
|
||||
secrets_store: None,
|
||||
};
|
||||
|
||||
assert_eq!(state.owner_id, "fallback");
|
||||
@@ -633,7 +632,6 @@ async fn start_owner_scoped_sender_server() -> (
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: Default::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let auth = MultiAuthState::multi(tokens);
|
||||
@@ -1019,7 +1017,6 @@ async fn start_multi_user_server_with_db() -> (
|
||||
startup_time: std::time::Instant::now(),
|
||||
webhook_rate_limiter: RateLimiter::new(10, 60),
|
||||
active_config: Default::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
|
||||
@@ -219,7 +219,6 @@ async fn start_test_server_with_provider(
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
||||
@@ -719,7 +718,6 @@ async fn test_no_llm_provider_returns_503() {
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
||||
|
||||
@@ -241,7 +241,6 @@ impl GatewayWorkflowHarness {
|
||||
routine_engine: Arc::clone(&routine_slot),
|
||||
startup_time: Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let mut agent = Agent::new(
|
||||
|
||||
@@ -66,7 +66,6 @@ async fn start_test_server() -> (
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
||||
|
||||
Reference in New Issue
Block a user