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:
Illia Polosukhin
2026-02-22 02:54:31 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 91b602790a
commit c3ce26278a
21 changed files with 1004 additions and 1460 deletions
Generated
+9 -9
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")?
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+2 -9
View File
@@ -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)?,
+2 -9
View File
@@ -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
View File
@@ -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",
+2 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -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(),