mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +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
+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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user