mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
refactor: simplify config resolution and consolidate main.rs init (#287)
* refactor: simplify config resolution and consolidate main.rs init into AppBuilder - Add parse_bool_env() and parse_string_env() helpers to eliminate repetitive 5-line optional_env/parse/map_err/unwrap_or boilerplate across 12 config files - Add EmbeddingsConfig::create_provider() to centralize embeddings construction (fixes hardcoded 1536 dimensions and missing Ollama provider in app.rs) - Extract init_cli_tracing(), setup_wasm_channels(), start_tunnel(), run_memory_command(), run_worker(), run_claude_bridge() from main.rs - Replace ~600 lines of inline init in main.rs with AppBuilder::build_all() - Expose catalog_entries from AppComponents for gateway registry entries - Net reduction: ~738 lines across 15 files Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: propagate dev_loaded_tool_names from AppBuilder and add parse_option_env helper Address PR review feedback: - Capture dev_loaded_tool_names from WASM loading in init_extensions() and expose via AppComponents so bootstrap_hooks receives the actual dev tool names instead of an empty slice (fixes silent hook skip) - Add parse_option_env<T>() helper for Option<T> config fields, simplifying max_cost_per_day_cents and max_actions_per_hour in agent.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: fetch real NEAR AI pricing and unify cost calculation path CostGuard was independently looking up pricing via costs::model_cost(), falling back to GPT-4o default rates when NEAR AI model names didn't match the static table — causing ~3x cost overestimates in logs. - Add pricing map to NearAiChatProvider that fetches real rates from /v1/model/list at startup (background, non-blocking) - Update cost_per_token() to check fetched pricing first, then static table, then default - Add cost_per_token parameter to CostGuard::record_llm_call() so the dispatcher passes provider-sourced rates directly Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: update default NEAR AI model to GLM-latest Replace fireworks llama4-maverick-instruct-basic with zai-org/GLM-latest as the default model in config and setup wizard. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: align wizard default model name with config Change "zai/GLM-latest" to "zai-org/GLM-latest" in wizard.rs to match the default in config/llm.rs. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
91b602790a
commit
c3ce26278a
Generated
+9
-9
@@ -4975,15 +4975,6 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yml"
|
||||
version = "0.0.12"
|
||||
@@ -4999,6 +4990,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
|
||||
+18
-11
@@ -151,14 +151,19 @@ impl CostGuard {
|
||||
/// Record a completed LLM action: its token costs and the action timestamp.
|
||||
///
|
||||
/// Call this AFTER an LLM call completes so that costs are tracked.
|
||||
///
|
||||
/// When `cost_per_token` is `Some`, those rates are used directly (provider-
|
||||
/// sourced pricing). When `None`, falls back to the static `costs::model_cost`
|
||||
/// lookup table, then `costs::default_cost`.
|
||||
pub async fn record_llm_call(
|
||||
&self,
|
||||
model: &str,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
cost_per_token: Option<(Decimal, Decimal)>,
|
||||
) -> Decimal {
|
||||
let (input_rate, output_rate) =
|
||||
costs::model_cost(model).unwrap_or_else(costs::default_cost);
|
||||
let (input_rate, output_rate) = cost_per_token
|
||||
.unwrap_or_else(|| costs::model_cost(model).unwrap_or_else(costs::default_cost));
|
||||
let cost =
|
||||
input_rate * Decimal::from(input_tokens) + output_rate * Decimal::from(output_tokens);
|
||||
|
||||
@@ -261,7 +266,9 @@ mod tests {
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
|
||||
// Record a big call, still allowed
|
||||
guard.record_llm_call("gpt-4o", 100_000, 100_000).await;
|
||||
guard
|
||||
.record_llm_call("gpt-4o", 100_000, 100_000, None)
|
||||
.await;
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
}
|
||||
|
||||
@@ -278,7 +285,7 @@ mod tests {
|
||||
// Record a call that costs more than $0.01
|
||||
// gpt-4o: input=$0.0000025/tok, output=$0.00001/tok
|
||||
// 10000 input + 10000 output = $0.025 + $0.10 = $0.125
|
||||
guard.record_llm_call("gpt-4o", 10_000, 10_000).await;
|
||||
guard.record_llm_call("gpt-4o", 10_000, 10_000, None).await;
|
||||
|
||||
// Now should be blocked
|
||||
let result = guard.check_allowed().await;
|
||||
@@ -301,7 +308,7 @@ mod tests {
|
||||
// First 3 actions allowed
|
||||
for _ in 0..3 {
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
guard.record_llm_call("gpt-4o", 10, 10).await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
}
|
||||
|
||||
// 4th should be blocked
|
||||
@@ -322,7 +329,7 @@ mod tests {
|
||||
|
||||
assert_eq!(guard.daily_spend().await, Decimal::ZERO);
|
||||
|
||||
let cost = guard.record_llm_call("gpt-4o", 1000, 500).await;
|
||||
let cost = guard.record_llm_call("gpt-4o", 1000, 500, None).await;
|
||||
assert!(cost > Decimal::ZERO);
|
||||
assert_eq!(guard.daily_spend().await, cost);
|
||||
}
|
||||
@@ -333,8 +340,8 @@ mod tests {
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 0);
|
||||
|
||||
guard.record_llm_call("gpt-4o", 10, 10).await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10).await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 2);
|
||||
}
|
||||
@@ -371,10 +378,10 @@ mod tests {
|
||||
assert!(guard.model_usage().await.is_empty());
|
||||
|
||||
// Record calls for two different models
|
||||
guard.record_llm_call("gpt-4o", 1000, 500).await;
|
||||
guard.record_llm_call("gpt-4o", 2000, 1000).await;
|
||||
guard.record_llm_call("gpt-4o", 1000, 500, None).await;
|
||||
guard.record_llm_call("gpt-4o", 2000, 1000, None).await;
|
||||
guard
|
||||
.record_llm_call("claude-3-5-sonnet-20241022", 500, 200)
|
||||
.record_llm_call("claude-3-5-sonnet-20241022", 500, 200, None)
|
||||
.await;
|
||||
|
||||
let usage = guard.model_usage().await;
|
||||
|
||||
@@ -222,6 +222,7 @@ impl Agent {
|
||||
&model_name,
|
||||
output.usage.input_tokens,
|
||||
output.usage.output_tokens,
|
||||
Some(self.llm().cost_per_token()),
|
||||
)
|
||||
.await;
|
||||
tracing::debug!(
|
||||
|
||||
+59
-49
@@ -22,6 +22,7 @@ use crate::skills::SkillRegistry;
|
||||
use crate::skills::catalog::SkillCatalog;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::mcp::McpSessionManager;
|
||||
use crate::tools::wasm::SharedCredentialRegistry;
|
||||
use crate::tools::wasm::WasmToolRuntime;
|
||||
use crate::workspace::{EmbeddingProvider, Workspace};
|
||||
|
||||
@@ -48,6 +49,8 @@ pub struct AppComponents {
|
||||
pub skill_catalog: Option<Arc<SkillCatalog>>,
|
||||
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
||||
pub session: Arc<SessionManager>,
|
||||
pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
|
||||
pub dev_loaded_tool_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Options that control optional init phases.
|
||||
@@ -313,54 +316,41 @@ impl AppBuilder {
|
||||
),
|
||||
anyhow::Error,
|
||||
> {
|
||||
use crate::workspace::{NearAiEmbeddings, OpenAiEmbeddings};
|
||||
|
||||
let safety = Arc::new(SafetyLayer::new(&self.config.safety));
|
||||
tracing::info!("Safety layer initialized");
|
||||
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
// Initialize tool registry with credential injection support
|
||||
let credential_registry = Arc::new(SharedCredentialRegistry::new());
|
||||
let tools = if let Some(ref ss) = self.secrets_store {
|
||||
Arc::new(
|
||||
ToolRegistry::new()
|
||||
.with_credentials(Arc::clone(&credential_registry), Arc::clone(ss)),
|
||||
)
|
||||
} else {
|
||||
Arc::new(ToolRegistry::new())
|
||||
};
|
||||
tools.register_builtin_tools();
|
||||
|
||||
// Create embeddings provider if configured
|
||||
let embeddings: Option<Arc<dyn EmbeddingProvider>> = if self.config.embeddings.enabled {
|
||||
match self.config.embeddings.provider.as_str() {
|
||||
"nearai" => {
|
||||
tracing::info!(
|
||||
"Embeddings enabled via NEAR AI (model: {})",
|
||||
self.config.embeddings.model
|
||||
);
|
||||
Some(Arc::new(
|
||||
NearAiEmbeddings::new(
|
||||
&self.config.llm.nearai.base_url,
|
||||
self.session.clone(),
|
||||
)
|
||||
.with_model(&self.config.embeddings.model, 1536),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
if let Some(api_key) = self.config.embeddings.openai_api_key() {
|
||||
tracing::info!(
|
||||
"Embeddings enabled via OpenAI (model: {})",
|
||||
self.config.embeddings.model
|
||||
);
|
||||
Some(Arc::new(OpenAiEmbeddings::with_model(
|
||||
api_key,
|
||||
&self.config.embeddings.model,
|
||||
match self.config.embeddings.model.as_str() {
|
||||
"text-embedding-3-large" => 3072,
|
||||
_ => 1536,
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
tracing::warn!("Embeddings configured but OPENAI_API_KEY not set");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("Embeddings disabled (set OPENAI_API_KEY or EMBEDDING_ENABLED=true)");
|
||||
None
|
||||
};
|
||||
// Create embeddings provider using the unified method
|
||||
let embeddings = self
|
||||
.config
|
||||
.embeddings
|
||||
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
|
||||
|
||||
// Warn if libSQL backend is used with non-1536 embedding dimension.
|
||||
if self.config.database.backend == crate::config::DatabaseBackend::LibSql
|
||||
&& self.config.embeddings.enabled
|
||||
&& self.config.embeddings.dimension != 1536
|
||||
{
|
||||
tracing::warn!(
|
||||
configured_dimension = self.config.embeddings.dimension,
|
||||
"Embedding dimension {} is not 1536. The libSQL schema uses \
|
||||
F32_BLOB(1536) which requires exactly 1536 dimensions. \
|
||||
Embedding storage will fail. Use PostgreSQL or set \
|
||||
EMBEDDING_DIMENSION=1536.",
|
||||
self.config.embeddings.dimension
|
||||
);
|
||||
}
|
||||
|
||||
// Register memory tools if database is available
|
||||
let workspace = if let Some(ref db) = self.db {
|
||||
@@ -402,6 +392,8 @@ impl AppBuilder {
|
||||
Arc<McpSessionManager>,
|
||||
Option<Arc<WasmToolRuntime>>,
|
||||
Option<Arc<ExtensionManager>>,
|
||||
Vec<crate::extensions::RegistryEntry>,
|
||||
Vec<String>,
|
||||
),
|
||||
anyhow::Error,
|
||||
> {
|
||||
@@ -431,6 +423,8 @@ impl AppBuilder {
|
||||
let tools = Arc::clone(tools);
|
||||
let wasm_config = self.config.wasm.clone();
|
||||
async move {
|
||||
let mut dev_loaded_tool_names: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ref runtime) = wasm_tool_runtime {
|
||||
let mut loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools));
|
||||
if let Some(ref secrets) = secrets_store {
|
||||
@@ -461,10 +455,11 @@ impl AppBuilder {
|
||||
|
||||
match load_dev_tools(&loader, &wasm_config.tools_dir).await {
|
||||
Ok(results) => {
|
||||
if !results.loaded.is_empty() {
|
||||
dev_loaded_tool_names.extend(results.loaded.iter().cloned());
|
||||
if !dev_loaded_tool_names.is_empty() {
|
||||
tracing::info!(
|
||||
"Loaded {} dev WASM tools from build artifacts",
|
||||
results.loaded.len()
|
||||
dev_loaded_tool_names.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -473,6 +468,8 @@ impl AppBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dev_loaded_tool_names
|
||||
}
|
||||
};
|
||||
|
||||
@@ -577,7 +574,7 @@ impl AppBuilder {
|
||||
}
|
||||
};
|
||||
|
||||
tokio::join!(wasm_tools_future, mcp_servers_future);
|
||||
let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future);
|
||||
|
||||
// Load registry catalog entries for extension discovery
|
||||
let catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
|
||||
@@ -640,7 +637,13 @@ impl AppBuilder {
|
||||
tools.register_dev_tools();
|
||||
}
|
||||
|
||||
Ok((mcp_session_manager, wasm_tool_runtime, extension_manager))
|
||||
Ok((
|
||||
mcp_session_manager,
|
||||
wasm_tool_runtime,
|
||||
extension_manager,
|
||||
catalog_entries,
|
||||
dev_loaded_tool_names,
|
||||
))
|
||||
}
|
||||
|
||||
/// Run all init phases in order and return the assembled components.
|
||||
@@ -654,8 +657,13 @@ impl AppBuilder {
|
||||
// Create hook registry early so runtime extension activation can register hooks.
|
||||
let hooks = Arc::new(HookRegistry::new());
|
||||
|
||||
let (mcp_session_manager, wasm_tool_runtime, extension_manager) =
|
||||
self.init_extensions(&tools, &hooks).await?;
|
||||
let (
|
||||
mcp_session_manager,
|
||||
wasm_tool_runtime,
|
||||
extension_manager,
|
||||
catalog_entries,
|
||||
dev_loaded_tool_names,
|
||||
) = self.init_extensions(&tools, &hooks).await?;
|
||||
|
||||
// Seed workspace and backfill embeddings
|
||||
if let Some(ref ws) = workspace {
|
||||
@@ -730,6 +738,8 @@ impl AppBuilder {
|
||||
skill_catalog,
|
||||
cost_guard,
|
||||
session: self.session,
|
||||
catalog_entries,
|
||||
dev_loaded_tool_names,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+38
-104
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -32,109 +32,43 @@ pub struct AgentConfig {
|
||||
impl AgentConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()),
|
||||
max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "AGENT_MAX_PARALLEL_JOBS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.max_parallel_jobs as usize),
|
||||
job_timeout: Duration::from_secs(
|
||||
optional_env("AGENT_JOB_TIMEOUT_SECS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "AGENT_JOB_TIMEOUT_SECS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.job_timeout_secs),
|
||||
),
|
||||
stuck_threshold: Duration::from_secs(
|
||||
optional_env("AGENT_STUCK_THRESHOLD_SECS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "AGENT_STUCK_THRESHOLD_SECS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.stuck_threshold_secs),
|
||||
),
|
||||
repair_check_interval: Duration::from_secs(
|
||||
optional_env("SELF_REPAIR_CHECK_INTERVAL_SECS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "SELF_REPAIR_CHECK_INTERVAL_SECS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.repair_check_interval_secs),
|
||||
),
|
||||
max_repair_attempts: optional_env("SELF_REPAIR_MAX_ATTEMPTS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "SELF_REPAIR_MAX_ATTEMPTS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.max_repair_attempts),
|
||||
use_planning: optional_env("AGENT_USE_PLANNING")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "AGENT_USE_PLANNING".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.use_planning),
|
||||
session_idle_timeout: Duration::from_secs(
|
||||
optional_env("SESSION_IDLE_TIMEOUT_SECS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "SESSION_IDLE_TIMEOUT_SECS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.session_idle_timeout_secs),
|
||||
),
|
||||
allow_local_tools: optional_env("ALLOW_LOCAL_TOOLS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "ALLOW_LOCAL_TOOLS".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(false),
|
||||
max_cost_per_day_cents: optional_env("MAX_COST_PER_DAY_CENTS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "MAX_COST_PER_DAY_CENTS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?,
|
||||
max_actions_per_hour: optional_env("MAX_ACTIONS_PER_HOUR")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "MAX_ACTIONS_PER_HOUR".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?,
|
||||
max_tool_iterations: optional_env("AGENT_MAX_TOOL_ITERATIONS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "AGENT_MAX_TOOL_ITERATIONS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.max_tool_iterations),
|
||||
auto_approve_tools: optional_env("AGENT_AUTO_APPROVE_TOOLS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "AGENT_AUTO_APPROVE_TOOLS".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.agent.auto_approve_tools),
|
||||
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(parse_optional_env(
|
||||
"AGENT_JOB_TIMEOUT_SECS",
|
||||
settings.agent.job_timeout_secs,
|
||||
)?),
|
||||
stuck_threshold: Duration::from_secs(parse_optional_env(
|
||||
"AGENT_STUCK_THRESHOLD_SECS",
|
||||
settings.agent.stuck_threshold_secs,
|
||||
)?),
|
||||
repair_check_interval: Duration::from_secs(parse_optional_env(
|
||||
"SELF_REPAIR_CHECK_INTERVAL_SECS",
|
||||
settings.agent.repair_check_interval_secs,
|
||||
)?),
|
||||
max_repair_attempts: parse_optional_env(
|
||||
"SELF_REPAIR_MAX_ATTEMPTS",
|
||||
settings.agent.max_repair_attempts,
|
||||
)?,
|
||||
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_tool_iterations: parse_optional_env(
|
||||
"AGENT_MAX_TOOL_ITERATIONS",
|
||||
settings.agent.max_tool_iterations,
|
||||
)?,
|
||||
auto_approve_tools: parse_bool_env(
|
||||
"AGENT_AUTO_APPROVE_TOOLS",
|
||||
settings.agent.auto_approve_tools,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
-17
@@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Builder mode configuration.
|
||||
@@ -34,25 +34,11 @@ impl Default for BuilderModeConfig {
|
||||
impl BuilderModeConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: optional_env("BUILDER_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "BUILDER_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
enabled: parse_bool_env("BUILDER_ENABLED", true)?,
|
||||
build_dir: optional_env("BUILDER_DIR")?.map(PathBuf::from),
|
||||
max_iterations: parse_optional_env("BUILDER_MAX_ITERATIONS", 20)?,
|
||||
timeout_secs: parse_optional_env("BUILDER_TIMEOUT_SECS", 600)?,
|
||||
auto_register: optional_env("BUILDER_AUTO_REGISTER")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "BUILDER_AUTO_REGISTER".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
auto_register: parse_bool_env("BUILDER_AUTO_REGISTER", true)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+7
-30
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -48,14 +48,7 @@ impl ChannelsConfig {
|
||||
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
|
||||
Some(HttpConfig {
|
||||
host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()),
|
||||
port: optional_env("HTTP_PORT")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "HTTP_PORT".to_string(),
|
||||
message: format!("must be a valid port number: {e}"),
|
||||
})?
|
||||
.unwrap_or(8080),
|
||||
port: parse_optional_env("HTTP_PORT", 8080)?,
|
||||
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from),
|
||||
user_id: optional_env("HTTP_USER_ID")?.unwrap_or_else(|| "http".to_string()),
|
||||
})
|
||||
@@ -63,20 +56,11 @@ impl ChannelsConfig {
|
||||
None
|
||||
};
|
||||
|
||||
let gateway = if optional_env("GATEWAY_ENABLED")?
|
||||
.map(|s| s.to_lowercase() == "true" || s == "1")
|
||||
.unwrap_or(true)
|
||||
{
|
||||
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", true)?;
|
||||
let gateway = if gateway_enabled {
|
||||
Some(GatewayConfig {
|
||||
host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
port: optional_env("GATEWAY_PORT")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "GATEWAY_PORT".to_string(),
|
||||
message: format!("must be a valid port number: {e}"),
|
||||
})?
|
||||
.unwrap_or(3000),
|
||||
port: parse_optional_env("GATEWAY_PORT", 3000)?,
|
||||
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?,
|
||||
user_id: optional_env("GATEWAY_USER_ID")?.unwrap_or_else(|| "default".to_string()),
|
||||
})
|
||||
@@ -97,18 +81,11 @@ impl ChannelsConfig {
|
||||
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
wasm_channels_enabled: optional_env("WASM_CHANNELS_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "WASM_CHANNELS_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
|
||||
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
.map_err(|e: std::num::ParseIntError| ConfigError::InvalidValue {
|
||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||
message: format!("must be an integer: {e}"),
|
||||
})?
|
||||
|
||||
+67
-17
@@ -1,8 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::SessionManager;
|
||||
use crate::settings::Settings;
|
||||
use crate::workspace::EmbeddingProvider;
|
||||
|
||||
/// Embeddings provider configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -65,23 +69,10 @@ impl EmbeddingsConfig {
|
||||
.or_else(|| settings.ollama_base_url.clone())
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
|
||||
let dimension = optional_env("EMBEDDING_DIMENSION")?
|
||||
.map(|s| s.parse::<usize>())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "EMBEDDING_DIMENSION".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or_else(|| default_dimension_for_model(&model));
|
||||
let dimension =
|
||||
parse_optional_env("EMBEDDING_DIMENSION", default_dimension_for_model(&model))?;
|
||||
|
||||
let enabled = optional_env("EMBEDDING_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "EMBEDDING_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.embeddings.enabled);
|
||||
let enabled = parse_bool_env("EMBEDDING_ENABLED", settings.embeddings.enabled)?;
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
@@ -97,6 +88,65 @@ impl EmbeddingsConfig {
|
||||
pub fn openai_api_key(&self) -> Option<&str> {
|
||||
self.openai_api_key.as_ref().map(|s| s.expose_secret())
|
||||
}
|
||||
|
||||
/// Create the appropriate embedding provider based on configuration.
|
||||
///
|
||||
/// Returns `None` if embeddings are disabled or the required credentials
|
||||
/// are missing. The `nearai_base_url` and `session` are needed only for
|
||||
/// the NEAR AI provider but must be passed unconditionally.
|
||||
pub fn create_provider(
|
||||
&self,
|
||||
nearai_base_url: &str,
|
||||
session: Arc<SessionManager>,
|
||||
) -> Option<Arc<dyn EmbeddingProvider>> {
|
||||
if !self.enabled {
|
||||
tracing::info!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
|
||||
return None;
|
||||
}
|
||||
|
||||
match self.provider.as_str() {
|
||||
"nearai" => {
|
||||
tracing::info!(
|
||||
"Embeddings enabled via NEAR AI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
);
|
||||
Some(Arc::new(
|
||||
crate::workspace::NearAiEmbeddings::new(nearai_base_url, session)
|
||||
.with_model(&self.model, self.dimension),
|
||||
))
|
||||
}
|
||||
"ollama" => {
|
||||
tracing::info!(
|
||||
"Embeddings enabled via Ollama (model: {}, url: {}, dim: {})",
|
||||
self.model,
|
||||
self.ollama_base_url,
|
||||
self.dimension,
|
||||
);
|
||||
Some(Arc::new(
|
||||
crate::workspace::OllamaEmbeddings::new(&self.ollama_base_url)
|
||||
.with_model(&self.model, self.dimension),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
if let Some(api_key) = self.openai_api_key() {
|
||||
tracing::info!(
|
||||
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
);
|
||||
Some(Arc::new(crate::workspace::OpenAiEmbeddings::with_model(
|
||||
api_key,
|
||||
&self.model,
|
||||
self.dimension,
|
||||
)))
|
||||
} else {
|
||||
tracing::warn!("Embeddings configured but OPENAI_API_KEY not set");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+6
-17
@@ -1,4 +1,4 @@
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -29,22 +29,11 @@ impl Default for HeartbeatConfig {
|
||||
impl HeartbeatConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: optional_env("HEARTBEAT_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.heartbeat.enabled),
|
||||
interval_secs: optional_env("HEARTBEAT_INTERVAL_SECS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_INTERVAL_SECS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.heartbeat.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: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
|
||||
.or_else(|| settings.heartbeat.notify_channel.clone()),
|
||||
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
||||
|
||||
@@ -47,3 +47,45 @@ where
|
||||
.transpose()
|
||||
.map(|opt| opt.unwrap_or(default))
|
||||
}
|
||||
|
||||
/// Parse a boolean from an env var with a default.
|
||||
///
|
||||
/// Accepts "true"/"1" as true, "false"/"0" as false.
|
||||
pub(crate) fn parse_bool_env(key: &str, default: bool) -> Result<bool, ConfigError> {
|
||||
match optional_env(key)? {
|
||||
Some(s) => match s.to_lowercase().as_str() {
|
||||
"true" | "1" => Ok(true),
|
||||
"false" | "0" => Ok(false),
|
||||
_ => Err(ConfigError::InvalidValue {
|
||||
key: key.to_string(),
|
||||
message: format!("must be 'true' or 'false', got '{s}'"),
|
||||
}),
|
||||
},
|
||||
None => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an env var into `Option<T>` — returns `None` when unset,
|
||||
/// `Some(parsed)` when set to a valid value.
|
||||
pub(crate) fn parse_option_env<T>(key: &str) -> Result<Option<T>, ConfigError>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
optional_env(key)?
|
||||
.map(|s| {
|
||||
s.parse().map_err(|e| ConfigError::InvalidValue {
|
||||
key: key.to_string(),
|
||||
message: format!("{e}"),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Parse a string from an env var with a default.
|
||||
pub(crate) fn parse_string_env(
|
||||
key: &str,
|
||||
default: impl Into<String>,
|
||||
) -> Result<String, ConfigError> {
|
||||
Ok(optional_env(key)?.unwrap_or_else(|| default.into()))
|
||||
}
|
||||
|
||||
+4
-25
@@ -1,4 +1,4 @@
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Memory hygiene configuration.
|
||||
@@ -28,30 +28,9 @@ impl Default for HygieneConfig {
|
||||
impl HygieneConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: optional_env("MEMORY_HYGIENE_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "MEMORY_HYGIENE_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
retention_days: optional_env("MEMORY_HYGIENE_RETENTION_DAYS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "MEMORY_HYGIENE_RETENTION_DAYS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(30),
|
||||
cadence_hours: optional_env("MEMORY_HYGIENE_CADENCE_HOURS")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "MEMORY_HYGIENE_CADENCE_HOURS".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.unwrap_or(12),
|
||||
enabled: parse_bool_env("MEMORY_HYGIENE_ENABLED", true)?,
|
||||
retention_days: parse_optional_env("MEMORY_HYGIENE_RETENTION_DAYS", 30)?,
|
||||
cadence_hours: parse_optional_env("MEMORY_HYGIENE_CADENCE_HOURS", 12)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -206,10 +206,7 @@ impl LlmConfig {
|
||||
let nearai = NearAiConfig {
|
||||
model: optional_env("NEARAI_MODEL")?
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| {
|
||||
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
|
||||
.to_string()
|
||||
}),
|
||||
.unwrap_or_else(|| "zai-org/GLM-latest".to_string()),
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
||||
if nearai_api_key.is_some() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Routines configuration.
|
||||
@@ -31,14 +31,7 @@ impl Default for RoutineConfig {
|
||||
impl RoutineConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: optional_env("ROUTINES_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "ROUTINES_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
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)?,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Safety configuration.
|
||||
@@ -12,14 +12,7 @@ impl SafetyConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", 100_000)?,
|
||||
injection_check_enabled: optional_env("SAFETY_INJECTION_CHECK_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "SAFETY_INJECTION_CHECK_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
injection_check_enabled: parse_bool_env("SAFETY_INJECTION_CHECK_ENABLED", true)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+7
-29
@@ -1,4 +1,4 @@
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, parse_string_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Docker sandbox configuration.
|
||||
@@ -44,28 +44,13 @@ impl SandboxModeConfig {
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Self {
|
||||
enabled: optional_env("SANDBOX_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "SANDBOX_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
policy: optional_env("SANDBOX_POLICY")?.unwrap_or_else(|| "readonly".to_string()),
|
||||
enabled: parse_bool_env("SANDBOX_ENABLED", true)?,
|
||||
policy: parse_string_env("SANDBOX_POLICY", "readonly")?,
|
||||
timeout_secs: parse_optional_env("SANDBOX_TIMEOUT_SECS", 120)?,
|
||||
memory_limit_mb: parse_optional_env("SANDBOX_MEMORY_LIMIT_MB", 2048)?,
|
||||
cpu_shares: parse_optional_env("SANDBOX_CPU_SHARES", 1024)?,
|
||||
image: optional_env("SANDBOX_IMAGE")?
|
||||
.unwrap_or_else(|| "ironclaw-worker:latest".to_string()),
|
||||
auto_pull_image: optional_env("SANDBOX_AUTO_PULL")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "SANDBOX_AUTO_PULL".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
image: parse_string_env("SANDBOX_IMAGE", "ironclaw-worker:latest")?,
|
||||
auto_pull_image: parse_bool_env("SANDBOX_AUTO_PULL", true)?,
|
||||
extra_allowed_domains: extra_domains,
|
||||
})
|
||||
}
|
||||
@@ -221,18 +206,11 @@ impl ClaudeCodeConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
let defaults = Self::default();
|
||||
Ok(Self {
|
||||
enabled: optional_env("CLAUDE_CODE_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "CLAUDE_CODE_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(defaults.enabled),
|
||||
enabled: parse_bool_env("CLAUDE_CODE_ENABLED", defaults.enabled)?,
|
||||
config_dir: optional_env("CLAUDE_CONFIG_DIR")?
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or(defaults.config_dir),
|
||||
model: optional_env("CLAUDE_CODE_MODEL")?.unwrap_or(defaults.model),
|
||||
model: parse_string_env("CLAUDE_CODE_MODEL", defaults.model)?,
|
||||
max_turns: parse_optional_env("CLAUDE_CODE_MAX_TURNS", defaults.max_turns)?,
|
||||
memory_limit_mb: parse_optional_env(
|
||||
"CLAUDE_CODE_MEMORY_LIMIT_MB",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// Skills system configuration.
|
||||
@@ -38,14 +38,7 @@ fn default_skills_dir() -> PathBuf {
|
||||
impl SkillsConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: optional_env("SKILLS_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "SKILLS_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(false),
|
||||
enabled: parse_bool_env("SKILLS_ENABLED", false)?,
|
||||
local_dir: optional_env("SKILLS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_skills_dir),
|
||||
|
||||
+3
-17
@@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// WASM sandbox configuration.
|
||||
@@ -48,14 +48,7 @@ fn default_tools_dir() -> PathBuf {
|
||||
impl WasmConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: optional_env("WASM_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "WASM_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
enabled: parse_bool_env("WASM_ENABLED", true)?,
|
||||
tools_dir: optional_env("WASM_TOOLS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_tools_dir),
|
||||
@@ -65,14 +58,7 @@ impl WasmConfig {
|
||||
)?,
|
||||
default_timeout_secs: parse_optional_env("WASM_DEFAULT_TIMEOUT_SECS", 60)?,
|
||||
default_fuel_limit: parse_optional_env("WASM_DEFAULT_FUEL_LIMIT", 10_000_000)?,
|
||||
cache_compiled: optional_env("WASM_CACHE_COMPILED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "WASM_CACHE_COMPILED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
cache_compiled: parse_bool_env("WASM_CACHE_COMPILED", true)?,
|
||||
cache_dir: optional_env("WASM_CACHE_DIR")?.map(PathBuf::from),
|
||||
})
|
||||
}
|
||||
|
||||
+263
-6
@@ -6,12 +6,13 @@
|
||||
//! - **Session token auth**: Otherwise, uses `SessionManager` for Bearer session token
|
||||
//! with automatic renewal on 401 errors
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use rust_decimal::prelude::MathematicalOps;
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -21,7 +22,7 @@ use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
||||
ToolCompletionRequest, ToolCompletionResponse,
|
||||
};
|
||||
use crate::llm::session::SessionManager;
|
||||
use crate::llm::{costs, session::SessionManager};
|
||||
|
||||
/// Information about an available model from NEAR AI API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -42,6 +43,9 @@ pub struct NearAiChatProvider {
|
||||
session: Arc<SessionManager>,
|
||||
active_model: std::sync::RwLock<String>,
|
||||
flatten_tool_messages: bool,
|
||||
/// Per-model pricing fetched from the NEAR AI `/v1/model/list` endpoint.
|
||||
/// Maps model ID → (input_cost_per_token, output_cost_per_token).
|
||||
pricing: Arc<std::sync::RwLock<HashMap<String, (Decimal, Decimal)>>>,
|
||||
}
|
||||
|
||||
impl NearAiChatProvider {
|
||||
@@ -72,13 +76,49 @@ impl NearAiChatProvider {
|
||||
})?;
|
||||
|
||||
let active_model = std::sync::RwLock::new(config.model.clone());
|
||||
Ok(Self {
|
||||
let pricing = Arc::new(std::sync::RwLock::new(HashMap::new()));
|
||||
|
||||
let provider = Self {
|
||||
client,
|
||||
config,
|
||||
session,
|
||||
active_model,
|
||||
flatten_tool_messages,
|
||||
})
|
||||
pricing,
|
||||
};
|
||||
|
||||
// Fire-and-forget background pricing fetch — don't block startup.
|
||||
// Only spawns when a tokio runtime is active (skipped in sync tests).
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
let client = provider.client.clone();
|
||||
let base_url = provider.config.base_url.clone();
|
||||
let api_key = provider.config.api_key.clone();
|
||||
let session = provider.session.clone();
|
||||
let pricing = provider.pricing.clone();
|
||||
|
||||
handle.spawn(async move {
|
||||
match fetch_pricing(&client, &base_url, api_key.as_ref(), &session).await {
|
||||
Ok(map) if !map.is_empty() => {
|
||||
tracing::info!("Loaded NEAR AI pricing for {} model(s)", map.len());
|
||||
match pricing.write() {
|
||||
Ok(mut guard) => *guard = map,
|
||||
Err(poisoned) => *poisoned.into_inner() = map,
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::debug!("NEAR AI pricing endpoint returned no pricing data");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"Could not fetch NEAR AI pricing (will use fallback): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
@@ -500,8 +540,14 @@ impl LlmProvider for NearAiChatProvider {
|
||||
}
|
||||
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||
// Default costs - could be model-specific in the future
|
||||
(dec!(0.000003), dec!(0.000015))
|
||||
let model = self.active_model_name();
|
||||
// Try fetched pricing first, then static lookup table, then default
|
||||
if let Ok(guard) = self.pricing.read()
|
||||
&& let Some(&rates) = guard.get(&model)
|
||||
{
|
||||
return rates;
|
||||
}
|
||||
costs::model_cost(&model).unwrap_or_else(costs::default_cost)
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
||||
@@ -562,6 +608,143 @@ struct ChatCompletionMessage {
|
||||
tool_calls: Option<Vec<ChatCompletionToolCall>>,
|
||||
}
|
||||
|
||||
// -- Pricing fetch types and logic -----------------------------------------
|
||||
|
||||
/// Cost amount from the NEAR AI `/v1/model/list` response.
|
||||
///
|
||||
/// Real cost per token = `amount * 10^(-scale)`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelCost {
|
||||
amount: f64,
|
||||
#[serde(default)]
|
||||
scale: i32,
|
||||
}
|
||||
|
||||
/// A single model entry from the pricing response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PricingModelEntry {
|
||||
#[serde(default, alias = "modelId", alias = "model_id")]
|
||||
model_id: Option<String>,
|
||||
#[serde(default, alias = "inputCostPerToken")]
|
||||
input_cost_per_token: Option<ModelCost>,
|
||||
#[serde(default, alias = "outputCostPerToken")]
|
||||
output_cost_per_token: Option<ModelCost>,
|
||||
#[serde(default)]
|
||||
metadata: Option<PricingMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PricingMetadata {
|
||||
#[serde(default)]
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// Wrapper for the `/v1/model/list` response body.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PricingResponse {
|
||||
#[serde(default)]
|
||||
models: Option<Vec<PricingModelEntry>>,
|
||||
#[serde(default)]
|
||||
data: Option<Vec<PricingModelEntry>>,
|
||||
}
|
||||
|
||||
/// Convert a `ModelCost` to a `Decimal` per-token price.
|
||||
fn model_cost_to_decimal(mc: &ModelCost) -> Option<Decimal> {
|
||||
if mc.amount == 0.0 {
|
||||
return Some(Decimal::ZERO);
|
||||
}
|
||||
// amount * 10^(-scale)
|
||||
let base = Decimal::try_from(mc.amount).ok()?;
|
||||
let factor = Decimal::TEN.checked_powi(-i64::from(mc.scale))?;
|
||||
base.checked_mul(factor)
|
||||
}
|
||||
|
||||
/// Fetch pricing from the NEAR AI `/v1/model/list` endpoint.
|
||||
///
|
||||
/// Returns a map of model_id → (input_cost_per_token, output_cost_per_token).
|
||||
/// Errors are non-fatal; callers should fall back to the static lookup table.
|
||||
async fn fetch_pricing(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&secrecy::SecretString>,
|
||||
session: &SessionManager,
|
||||
) -> Result<HashMap<String, (Decimal, Decimal)>, LlmError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{}/model/list", base)
|
||||
} else {
|
||||
format!("{}/v1/model/list", base)
|
||||
};
|
||||
|
||||
let token = if let Some(key) = api_key {
|
||||
key.expose_secret().to_string()
|
||||
} else {
|
||||
let tok = session.get_token().await?;
|
||||
tok.expose_secret().to_string()
|
||||
};
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "nearai_chat".to_string(),
|
||||
reason: format!("Failed to fetch pricing: {}", e),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(LlmError::RequestFailed {
|
||||
provider: "nearai_chat".to_string(),
|
||||
reason: format!("Pricing endpoint returned HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
let body = response.text().await.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "nearai_chat".to_string(),
|
||||
reason: format!("Failed to read pricing response: {}", e),
|
||||
})?;
|
||||
|
||||
// Parse as {models: [...]} or {data: [...]} or direct array
|
||||
let entries: Vec<PricingModelEntry> =
|
||||
if let Ok(resp) = serde_json::from_str::<PricingResponse>(&body) {
|
||||
resp.models.or(resp.data).unwrap_or_default()
|
||||
} else if let Ok(arr) = serde_json::from_str::<Vec<PricingModelEntry>>(&body) {
|
||||
arr
|
||||
} else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for entry in &entries {
|
||||
let (Some(input_mc), Some(output_mc)) =
|
||||
(&entry.input_cost_per_token, &entry.output_cost_per_token)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (Some(input), Some(output)) = (
|
||||
model_cost_to_decimal(input_mc),
|
||||
model_cost_to_decimal(output_mc),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Insert under the primary model_id
|
||||
if let Some(ref id) = entry.model_id {
|
||||
map.insert(id.clone(), (input, output));
|
||||
}
|
||||
// Also insert under any aliases
|
||||
if let Some(ref meta) = entry.metadata {
|
||||
for alias in &meta.aliases {
|
||||
map.insert(alias.clone(), (input, output));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Rewrite tool-call / tool-result messages into plain assistant/user text.
|
||||
///
|
||||
/// NEAR AI cloud-api does not support the OpenAI multi-turn tool-calling
|
||||
@@ -748,6 +931,7 @@ fn parse_usage(usage: Option<&ChatCompletionUsage>) -> (u32, u32) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::session::SessionConfig;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
fn test_nearai_config(base_url: &str) -> NearAiConfig {
|
||||
NearAiConfig {
|
||||
@@ -991,4 +1175,77 @@ mod tests {
|
||||
assert!(text.starts_with("Let me check that."));
|
||||
assert!(text.contains("[Called tool `search`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_cost_to_decimal_basic() {
|
||||
// amount=3, scale=6 → 3 * 10^-6 = 0.000003
|
||||
let mc = ModelCost {
|
||||
amount: 3.0,
|
||||
scale: 6,
|
||||
};
|
||||
let result = model_cost_to_decimal(&mc).unwrap();
|
||||
assert_eq!(result, dec!(0.000003));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_cost_to_decimal_zero() {
|
||||
let mc = ModelCost {
|
||||
amount: 0.0,
|
||||
scale: 6,
|
||||
};
|
||||
assert_eq!(model_cost_to_decimal(&mc), Some(Decimal::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_cost_to_decimal_larger_scale() {
|
||||
// amount=85, scale=8 → 85 * 10^-8 = 0.00000085
|
||||
let mc = ModelCost {
|
||||
amount: 85.0,
|
||||
scale: 8,
|
||||
};
|
||||
let result = model_cost_to_decimal(&mc).unwrap();
|
||||
assert_eq!(result, dec!(0.00000085));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_per_token_uses_pricing_map() {
|
||||
let cfg = test_nearai_config("http://127.0.0.1:8318");
|
||||
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
|
||||
|
||||
// Inject pricing directly
|
||||
{
|
||||
let mut guard = provider.pricing.write().unwrap();
|
||||
guard.insert("test-model".to_string(), (dec!(0.000001), dec!(0.000005)));
|
||||
}
|
||||
|
||||
let (input, output) = provider.cost_per_token();
|
||||
assert_eq!(input, dec!(0.000001));
|
||||
assert_eq!(output, dec!(0.000005));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_per_token_falls_back_to_static() {
|
||||
let mut cfg = test_nearai_config("http://127.0.0.1:8318");
|
||||
cfg.model = "gpt-4o".to_string();
|
||||
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
|
||||
|
||||
// No pricing in map, should fall back to static costs::model_cost
|
||||
let (input, output) = provider.cost_per_token();
|
||||
let (expected_in, expected_out) = costs::model_cost("gpt-4o").unwrap();
|
||||
assert_eq!(input, expected_in);
|
||||
assert_eq!(output, expected_out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_per_token_falls_back_to_default() {
|
||||
let mut cfg = test_nearai_config("http://127.0.0.1:8318");
|
||||
cfg.model = "some-unknown-nearai-model".to_string();
|
||||
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
|
||||
|
||||
// No pricing in map, not in static table, should use default_cost
|
||||
let (input, output) = provider.cost_per_token();
|
||||
let (default_in, default_out) = costs::default_cost();
|
||||
assert_eq!(input, default_in);
|
||||
assert_eq!(output, default_out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,7 +614,7 @@ Respond with a JSON plan in this format:
|
||||
let group_section = self.build_group_section();
|
||||
|
||||
format!(
|
||||
r#"You are NEAR AI Agent, an autonomous assistant.
|
||||
r#"You are IronClaw Agent, a secure autonomous assistant.
|
||||
|
||||
## Response Format — CRITICAL
|
||||
|
||||
|
||||
+467
-1094
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1084,9 +1084,8 @@ impl SetupWizard {
|
||||
let fetched = self.fetch_nearai_models().await;
|
||||
let default_models: Vec<(String, String)> = vec![
|
||||
(
|
||||
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
|
||||
.into(),
|
||||
"Llama 4 Maverick (default, fast)".into(),
|
||||
"zai-org/GLM-latest".into(),
|
||||
"GLM Latest (default, fast)".into(),
|
||||
),
|
||||
(
|
||||
"anthropic::claude-sonnet-4-20250514".into(),
|
||||
|
||||
Reference in New Issue
Block a user