From 4d28872a9a9262bc0ee75d4254db8a2c03fe1a12 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 15 Dec 2025 17:14:55 -0500 Subject: [PATCH 01/10] first pass --- crates/goose-cli/src/session/mod.rs | 15 +- crates/goose-cli/src/session/output.rs | 19 +- crates/goose-server/src/commands/agent.rs | 9 - .../src/routes/config_management.rs | 37 +- crates/goose/src/providers/canonical/mod.rs | 28 ++ .../goose/src/providers/canonical/registry.rs | 42 ++ crates/goose/src/providers/mod.rs | 1 - crates/goose/src/providers/pricing.rs | 408 ------------------ 8 files changed, 95 insertions(+), 464 deletions(-) delete mode 100644 crates/goose/src/providers/pricing.rs diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 6c206a9a74b4..807e3d925e55 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -33,7 +33,6 @@ use goose::agents::extension::{Envs, ExtensionConfig, PLATFORM_EXTENSIONS}; use goose::agents::types::RetryConfig; use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGERS}; use goose::config::{Config, GooseMode}; -use goose::providers::pricing::initialize_pricing_cache; use goose::session::SessionManager; use input::InputResult; use rmcp::model::PromptMessage; @@ -1406,17 +1405,6 @@ impl CliSession { // Do not get costing information if show cost is disabled // This will prevent the API call to openrouter.ai - // This is useful if for cases where openrouter.ai may be blocked by corporate firewalls - if show_cost { - // Initialize pricing cache on startup - tracing::info!("Initializing pricing cache..."); - if let Err(e) = initialize_pricing_cache().await { - tracing::warn!( - "Failed to initialize pricing cache: {e}. Pricing data may not be available." - ); - } - } - match self.get_session().await { Ok(metadata) => { let total_tokens = metadata.total_tokens.unwrap_or(0) as usize; @@ -1431,8 +1419,7 @@ impl CliSession { &model_config.model_name, input_tokens, output_tokens, - ) - .await; + ); } } Err(_) => { diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 04808ee345bf..9dc07ac64c38 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -5,8 +5,7 @@ use goose::config::Config; use goose::conversation::message::{ ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse, }; -use goose::providers::pricing::get_model_pricing; -use goose::providers::pricing::parse_model_id; +use goose::providers::canonical::{get_model_pricing, parse_model_id}; use goose::utils::safe_truncate; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use regex::Regex; @@ -818,7 +817,7 @@ fn normalize_model_name(model: &str) -> String { result } -async fn estimate_cost_usd( +fn estimate_cost_usd( provider: &str, model: &str, input_tokens: usize, @@ -836,14 +835,14 @@ async fn estimate_cost_usd( None => (provider, model), }; - // Use the pricing module's get_model_pricing which handles model name mapping internally + // Use canonical model pricing which handles model name mapping internally let cleaned_model = normalize_model_name(model_to_use); - let pricing_info = get_model_pricing(provider_to_use, &cleaned_model).await; + let pricing_info = get_model_pricing(provider_to_use, &cleaned_model); match pricing_info { - Some(pricing) => { - let input_cost = pricing.input_cost * input_tokens as f64; - let output_cost = pricing.output_cost * output_tokens as f64; + Some((input_cost_per_token, output_cost_per_token, _context_length)) => { + let input_cost = input_cost_per_token * input_tokens as f64; + let output_cost = output_cost_per_token * output_tokens as f64; Some(input_cost + output_cost) } None => None, @@ -851,13 +850,13 @@ async fn estimate_cost_usd( } /// Display cost information, if price data is available. -pub async fn display_cost_usage( +pub fn display_cost_usage( provider: &str, model: &str, input_tokens: usize, output_tokens: usize, ) { - if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens).await { + if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens) { use console::style; eprintln!( "Cost: {} USD ({} tokens: in {}, out {})", diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 950640987916..a68d56eeae6d 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -6,8 +6,6 @@ use goose_server::auth::check_token; use tower_http::cors::{Any, CorsLayer}; use tracing::info; -use goose::providers::pricing::initialize_pricing_cache; - // Graceful shutdown signal #[cfg(unix)] async fn shutdown_signal() { @@ -32,13 +30,6 @@ pub async fn run() -> Result<()> { let settings = configuration::Settings::new()?; - if let Err(e) = initialize_pricing_cache().await { - tracing::warn!( - "Failed to initialize pricing cache: {}. Pricing data may not be available.", - e - ); - } - let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string()); diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index c70a1bb9db01..f62f153fae04 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -13,10 +13,8 @@ use goose::config::{Config, ConfigError}; use goose::model::ModelConfig; use goose::providers::auto_detect::detect_provider_from_api_key; use goose::providers::base::{ProviderMetadata, ProviderType}; +use goose::providers::canonical::{get_all_pricing, get_model_pricing, parse_model_id}; use goose::providers::create_with_default_model; -use goose::providers::pricing::{ - get_all_pricing, get_model_pricing, parse_model_id, refresh_pricing, -}; use goose::providers::providers as get_providers; use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands}; use http::StatusCode; @@ -479,28 +477,21 @@ pub async fn get_pricing( ) -> Result, StatusCode> { let configured_only = query.configured_only; - // If refresh requested (configured_only = false), refresh the cache - if !configured_only { - if let Err(e) = refresh_pricing().await { - tracing::error!("Failed to refresh pricing data: {}", e); - } - } - let mut pricing_data = Vec::new(); if !configured_only { - // Get ALL pricing data from the cache - let all_pricing = get_all_pricing().await; + // Get ALL pricing data from canonical models + let all_pricing = get_all_pricing(); for (provider, models) in all_pricing { - for (model, pricing) in models { + for (model, (input_cost, output_cost, context_length)) in models { pricing_data.push(PricingData { provider: provider.clone(), model: model.clone(), - input_token_cost: pricing.input_cost, - output_token_cost: pricing.output_cost, + input_token_cost: input_cost, + output_token_cost: output_cost, currency: "$".to_string(), - context_length: pricing.context_length, + context_length, }); } } @@ -526,15 +517,17 @@ pub async fn get_pricing( (metadata.name.clone(), model_info.name.clone()) }; - // Only get pricing from OpenRouter cache - if let Some(pricing) = get_model_pricing(&lookup_provider, &lookup_model).await { + // Get pricing from canonical models + if let Some((input_cost, output_cost, context_length)) = + get_model_pricing(&lookup_provider, &lookup_model) + { pricing_data.push(PricingData { provider: metadata.name.clone(), model: model_info.name.clone(), - input_token_cost: pricing.input_cost, - output_token_cost: pricing.output_cost, + input_token_cost: input_cost, + output_token_cost: output_cost, currency: "$".to_string(), - context_length: pricing.context_length, + context_length, }); } // No fallback to hardcoded prices @@ -554,7 +547,7 @@ pub async fn get_pricing( Ok(Json(PricingResponse { pricing: pricing_data, - source: "openrouter".to_string(), + source: "canonical".to_string(), })) } diff --git a/crates/goose/src/providers/canonical/mod.rs b/crates/goose/src/providers/canonical/mod.rs index 1aac285ca516..48cecb58b687 100644 --- a/crates/goose/src/providers/canonical/mod.rs +++ b/crates/goose/src/providers/canonical/mod.rs @@ -6,6 +6,8 @@ pub use model::{CanonicalModel, Pricing}; pub use name_builder::{canonical_name, map_to_canonical_model, strip_version_suffix}; pub use registry::CanonicalModelRegistry; +use std::collections::HashMap; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ModelMapping { pub provider_model: String, @@ -20,3 +22,29 @@ impl ModelMapping { } } } + +/// Get pricing for a specific provider/model combination +/// Returns (input_cost_per_token, output_cost_per_token, context_length) if found +pub fn get_model_pricing(provider: &str, model: &str) -> Option<(f64, f64, Option)> { + let registry = CanonicalModelRegistry::bundled().ok()?; + registry.get_model_pricing(provider, model) +} + +/// Get all pricing data organized by provider +/// Returns HashMap> +pub fn get_all_pricing() -> HashMap)>> { + CanonicalModelRegistry::bundled() + .map(|registry| registry.get_all_pricing()) + .unwrap_or_default() +} + +/// Parse OpenRouter-style model ID into (provider, model) components +/// e.g., "anthropic/claude-sonnet-4-20250514" -> ("anthropic", "claude-sonnet-4-20250514") +pub fn parse_model_id(model_id: &str) -> Option<(String, String)> { + let parts: Vec<&str> = model_id.splitn(2, '/').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].to_string())) + } else { + None + } +} diff --git a/crates/goose/src/providers/canonical/registry.rs b/crates/goose/src/providers/canonical/registry.rs index b601bfca1158..549bb6cce2e6 100644 --- a/crates/goose/src/providers/canonical/registry.rs +++ b/crates/goose/src/providers/canonical/registry.rs @@ -83,6 +83,48 @@ impl CanonicalModelRegistry { pub fn contains(&self, name: &str) -> bool { self.models.contains_key(name) } + + /// Get pricing for a specific provider/model combination + /// Returns (input_cost, output_cost, context_length) if found + pub fn get_model_pricing( + &self, + provider: &str, + model: &str, + ) -> Option<(f64, f64, Option)> { + // Try to map to canonical model first + let canonical_id = super::map_to_canonical_model(provider, model, self)?; + let model = self.get(&canonical_id)?; + + let prompt = model.pricing.prompt?; + let completion = model.pricing.completion?; + let context_length = model.context_length as u32; + + Some((prompt, completion, Some(context_length))) + } + + /// Get all pricing data organized by provider + /// Returns HashMap> + pub fn get_all_pricing(&self) -> HashMap)>> { + let mut result: HashMap)>> = + HashMap::new(); + + for model in self.models.values() { + // Parse canonical ID to get provider and model name + if let Some((provider, model_name)) = model.id.split_once('/') { + if let (Some(prompt), Some(completion)) = + (model.pricing.prompt, model.pricing.completion) + { + let provider_models = result.entry(provider.to_string()).or_default(); + provider_models.insert( + model_name.to_string(), + (prompt, completion, Some(model.context_length as u32)), + ); + } + } + } + + result + } } impl Default for CanonicalModelRegistry { diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 3c3e64344d7f..04b5a491a608 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -24,7 +24,6 @@ pub mod oauth; pub mod ollama; pub mod openai; pub mod openrouter; -pub mod pricing; pub mod provider_registry; pub mod provider_test; mod retry; diff --git a/crates/goose/src/providers/pricing.rs b/crates/goose/src/providers/pricing.rs deleted file mode 100644 index 50593b43cc8f..000000000000 --- a/crates/goose/src/providers/pricing.rs +++ /dev/null @@ -1,408 +0,0 @@ -use anyhow::{anyhow, Result}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::RwLock; - -/// Disk cache configuration -const CACHE_FILE_NAME: &str = "pricing_cache.json"; -const CACHE_TTL_DAYS: u64 = 7; // Cache for 7 days - -/// Get the cache directory path -fn get_cache_dir() -> Result { - let cache_dir = if let Ok(goose_dir) = std::env::var("GOOSE_CACHE_DIR") { - PathBuf::from(goose_dir) - } else { - dirs::cache_dir() - .ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))? - .join("goose") - }; - Ok(cache_dir) -} - -/// Cached pricing data structure for disk storage -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CachedPricingData { - /// Nested HashMap: provider -> model -> pricing info - pub pricing: HashMap>, - /// Unix timestamp when data was fetched - pub fetched_at: u64, -} - -/// Simplified pricing info for efficient storage -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PricingInfo { - pub input_cost: f64, // Cost per token - pub output_cost: f64, // Cost per token - pub context_length: Option, -} - -/// Cache for OpenRouter pricing data with disk persistence -pub struct PricingCache { - /// In-memory cache - memory_cache: Arc>>, -} - -impl PricingCache { - pub fn new() -> Self { - Self { - memory_cache: Arc::new(RwLock::new(None)), - } - } - - /// Load pricing from disk cache - async fn load_from_disk(&self) -> Result> { - let cache_path = get_cache_dir()?.join(CACHE_FILE_NAME); - - if !cache_path.exists() { - return Ok(None); - } - - match tokio::fs::read(&cache_path).await { - Ok(data) => { - match serde_json::from_slice::(&data) { - Ok(cached) => { - // Check if cache is still valid - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let age_days = (now - cached.fetched_at) / (24 * 60 * 60); - - if age_days < CACHE_TTL_DAYS { - Ok(Some(cached)) - } else { - Ok(None) - } - } - Err(e) => { - tracing::warn!("Failed to parse pricing cache: {}", e); - Ok(None) - } - } - } - Err(e) => { - tracing::warn!("Failed to read pricing cache: {}", e); - Ok(None) - } - } - } - - /// Save pricing data to disk - async fn save_to_disk(&self, data: &CachedPricingData) -> Result<()> { - let cache_dir = get_cache_dir()?; - tokio::fs::create_dir_all(&cache_dir).await?; - - let cache_path = cache_dir.join(CACHE_FILE_NAME); - let json_data = serde_json::to_vec_pretty(data)?; - tokio::fs::write(&cache_path, json_data).await?; - Ok(()) - } - - /// Get pricing for a specific model - pub async fn get_model_pricing(&self, provider: &str, model: &str) -> Option { - // Try memory cache first - { - let cache = self.memory_cache.read().await; - if let Some(cached) = &*cache { - return cached - .pricing - .get(&provider.to_lowercase()) - .and_then(|models| models.get(model)) - .cloned(); - } - } - - // Try loading from disk - if let Ok(Some(disk_cache)) = self.load_from_disk().await { - // Update memory cache - { - let mut cache = self.memory_cache.write().await; - *cache = Some(disk_cache.clone()); - } - - return disk_cache - .pricing - .get(&provider.to_lowercase()) - .and_then(|models| models.get(model)) - .cloned(); - } - - None - } - - /// Force refresh pricing data from OpenRouter - pub async fn refresh(&self) -> Result<()> { - let pricing = fetch_openrouter_pricing_internal().await?; - - // Convert to our efficient structure - let mut structured_pricing: HashMap> = HashMap::new(); - - for (model_id, model) in pricing { - if let Some((provider, model_name)) = parse_model_id(&model_id) { - if let (Some(input_cost), Some(output_cost)) = ( - convert_pricing(&model.pricing.prompt), - convert_pricing(&model.pricing.completion), - ) { - let provider_lower = provider.to_lowercase(); - let provider_models = structured_pricing.entry(provider_lower).or_default(); - - provider_models.insert( - model_name, - PricingInfo { - input_cost, - output_cost, - context_length: model.context_length, - }, - ); - } - } - } - - let cached_data = CachedPricingData { - pricing: structured_pricing, - fetched_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), - }; - - self.save_to_disk(&cached_data).await?; - - { - let mut cache = self.memory_cache.write().await; - *cache = Some(cached_data); - } - - Ok(()) - } - - /// Initialize cache (load from disk or fetch if needed) - pub async fn initialize(&self) -> Result<()> { - // Try loading from disk first - if let Ok(Some(cached)) = self.load_from_disk().await { - { - let mut cache = self.memory_cache.write().await; - *cache = Some(cached); - } - - return Ok(()); - } - - self.refresh().await - } -} - -impl Default for PricingCache { - fn default() -> Self { - Self::new() - } -} - -// Global cache instance -lazy_static::lazy_static! { - static ref PRICING_CACHE: PricingCache = PricingCache::new(); -} - -fn create_http_client() -> Result { - Client::builder() - .timeout(Duration::from_secs(30)) - .pool_idle_timeout(Duration::from_secs(90)) - .pool_max_idle_per_host(10) - .build() - .map_err(|e| anyhow!(e)) -} - -/// OpenRouter model pricing information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenRouterModel { - pub id: String, - pub name: String, - pub pricing: OpenRouterPricing, - pub context_length: Option, - pub architecture: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenRouterPricing { - pub prompt: String, // Cost per token for input (in USD) - pub completion: String, // Cost per token for output (in USD) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Architecture { - pub modality: String, - pub tokenizer: String, - pub instruct_type: Option, -} - -/// Response from OpenRouter models endpoint -#[derive(Debug, Deserialize)] -pub struct OpenRouterModelsResponse { - pub data: Vec, -} - -/// Internal function to fetch pricing data -async fn fetch_openrouter_pricing_internal() -> Result> { - let client = create_http_client()?; - let response = client - .get("https://openrouter.ai/api/v1/models") - .send() - .await?; - - if !response.status().is_success() { - anyhow::bail!( - "Failed to fetch OpenRouter models: HTTP {}", - response.status() - ); - } - - let models_response: OpenRouterModelsResponse = response.json().await?; - - // Create a map for easy lookup - let mut pricing_map = HashMap::new(); - for model in models_response.data { - pricing_map.insert(model.id.clone(), model); - } - - Ok(pricing_map) -} - -/// Initialize pricing cache on startup -pub async fn initialize_pricing_cache() -> Result<()> { - PRICING_CACHE.initialize().await -} - -/// Get pricing for a specific model -pub async fn get_model_pricing(provider: &str, model: &str) -> Option { - PRICING_CACHE.get_model_pricing(provider, model).await -} - -/// Force refresh pricing data -pub async fn refresh_pricing() -> Result<()> { - PRICING_CACHE.refresh().await -} - -/// Get all cached pricing data -pub async fn get_all_pricing() -> HashMap> { - let cache = PRICING_CACHE.memory_cache.read().await; - if let Some(cached) = &*cache { - cached.pricing.clone() - } else { - // Try loading from disk - if let Ok(Some(disk_cache)) = PRICING_CACHE.load_from_disk().await { - // Update memory cache - drop(cache); - let mut write_cache = PRICING_CACHE.memory_cache.write().await; - *write_cache = Some(disk_cache.clone()); - disk_cache.pricing - } else { - HashMap::new() - } - } -} - -/// Convert OpenRouter model ID to provider/model format -/// e.g., "anthropic/claude-sonnet-4-20250514" -> ("anthropic", "claude-sonnet-4-20250514") -pub fn parse_model_id(model_id: &str) -> Option<(String, String)> { - let parts: Vec<&str> = model_id.splitn(2, '/').collect(); - if parts.len() == 2 { - // Normalize provider names to match our internal naming - let provider = match parts[0] { - "openai" => "openai", - "anthropic" => "anthropic", - "google" => "google", - "meta-llama" => "ollama", // Meta models often run via Ollama - "mistralai" => "mistral", - "cohere" => "cohere", - "perplexity" => "perplexity", - "deepseek" => "deepseek", - "groq" => "groq", - "nvidia" => "nvidia", - "microsoft" => "azure", - "replicate" => "replicate", - "huggingface" => "huggingface", - _ => parts[0], - }; - Some((provider.to_string(), parts[1].to_string())) - } else { - None - } -} - -/// Convert OpenRouter pricing to cost per token (already in that format) -pub fn convert_pricing(price_str: &str) -> Option { - // OpenRouter prices are already in USD per token - price_str.parse::().ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_model_id() { - assert_eq!( - parse_model_id("anthropic/claude-sonnet-4-20250514"), - Some(( - "anthropic".to_string(), - "claude-sonnet-4-20250514".to_string() - )) - ); - assert_eq!( - parse_model_id("openai/gpt-4"), - Some(("openai".to_string(), "gpt-4".to_string())) - ); - assert_eq!(parse_model_id("invalid-format"), None); - - // Test the specific model causing issues - assert_eq!( - parse_model_id("anthropic/claude-sonnet-4-20250514"), - Some(( - "anthropic".to_string(), - "claude-sonnet-4-20250514".to_string() - )) - ); - } - - #[test] - fn test_convert_pricing() { - assert_eq!(convert_pricing("0.000003"), Some(0.000003)); - assert_eq!(convert_pricing("0.015"), Some(0.015)); - assert_eq!(convert_pricing("invalid"), None); - } - - #[tokio::test] - async fn test_claude_sonnet_4_pricing_lookup() { - // Initialize the cache to load from disk - if let Err(e) = initialize_pricing_cache().await { - println!("Failed to initialize pricing cache: {}", e); - return; - } - - // Test lookup for the specific model (use the name that actually exists in cache) - let pricing = get_model_pricing("anthropic", "claude-sonnet-4").await; - - println!( - "Pricing lookup result for anthropic/claude-sonnet-4: {:?}", - pricing - ); - - // Should find pricing data - if let Some(pricing_info) = pricing { - assert!(pricing_info.input_cost > 0.0); - assert!(pricing_info.output_cost > 0.0); - println!( - "Found pricing: input={}, output={}", - pricing_info.input_cost, pricing_info.output_cost - ); - } else { - // Print debug info - let all_pricing = get_all_pricing().await; - if let Some(anthropic_models) = all_pricing.get("anthropic") { - println!("Available anthropic models in cache:"); - for model_name in anthropic_models.keys() { - println!(" {}", model_name); - } - } - panic!("Expected to find pricing for anthropic/claude-sonnet-4"); - } - } -} From f4b8859f22fee3e63dfa5ba3c4deede4f74dfe4b Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 15 Dec 2025 17:32:36 -0500 Subject: [PATCH 02/10] cleanup more dead code --- crates/goose-cli/src/session/output.rs | 48 ++++---------- .../src/routes/config_management.rs | 63 +++++++++++-------- crates/goose/src/providers/canonical/mod.rs | 17 ----- .../goose/src/providers/canonical/registry.rs | 42 ------------- 4 files changed, 48 insertions(+), 122 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 9dc07ac64c38..7813d06a8fc0 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -5,10 +5,9 @@ use goose::config::Config; use goose::conversation::message::{ ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse, }; -use goose::providers::canonical::{get_model_pricing, parse_model_id}; +use goose::providers::canonical::{map_to_canonical_model, parse_model_id, CanonicalModelRegistry}; use goose::utils::safe_truncate; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use regex::Regex; use rmcp::model::{CallToolRequestParam, JsonObject, PromptArgument}; use serde_json::Value; use std::cell::RefCell; @@ -794,29 +793,6 @@ pub fn display_context_usage(total_tokens: usize, context_limit: usize) { ); } -fn normalize_model_name(model: &str) -> String { - let mut result = model.to_string(); - - // Remove "-latest" suffix - if result.ends_with("-latest") { - result = result.strip_suffix("-latest").unwrap().to_string(); - } - - // Remove date-like suffixes: -YYYYMMDD - let re_date = Regex::new(r"-\d{8}$").unwrap(); - if re_date.is_match(&result) { - result = re_date.replace(&result, "").to_string(); - } - - // Convert version numbers like -3-7- to -3.7- (e.g., claude-3-7-sonnet -> claude-3.7-sonnet) - let re_version = Regex::new(r"-(\d+)-(\d+)-").unwrap(); - if re_version.is_match(&result) { - result = re_version.replace(&result, "-$1.$2-").to_string(); - } - - result -} - fn estimate_cost_usd( provider: &str, model: &str, @@ -835,18 +811,18 @@ fn estimate_cost_usd( None => (provider, model), }; - // Use canonical model pricing which handles model name mapping internally - let cleaned_model = normalize_model_name(model_to_use); - let pricing_info = get_model_pricing(provider_to_use, &cleaned_model); + // Get canonical model which handles model name mapping internally + let registry = CanonicalModelRegistry::bundled().ok()?; + let canonical_id = map_to_canonical_model(provider_to_use, model_to_use, registry)?; + let canonical_model = registry.get(&canonical_id)?; - match pricing_info { - Some((input_cost_per_token, output_cost_per_token, _context_length)) => { - let input_cost = input_cost_per_token * input_tokens as f64; - let output_cost = output_cost_per_token * output_tokens as f64; - Some(input_cost + output_cost) - } - None => None, - } + // Calculate cost from canonical model pricing + let input_cost_per_token = canonical_model.pricing.prompt?; + let output_cost_per_token = canonical_model.pricing.completion?; + + let input_cost = input_cost_per_token * input_tokens as f64; + let output_cost = output_cost_per_token * output_tokens as f64; + Some(input_cost + output_cost) } /// Display cost information, if price data is available. diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index f62f153fae04..913de830a436 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -13,7 +13,7 @@ use goose::config::{Config, ConfigError}; use goose::model::ModelConfig; use goose::providers::auto_detect::detect_provider_from_api_key; use goose::providers::base::{ProviderMetadata, ProviderType}; -use goose::providers::canonical::{get_all_pricing, get_model_pricing, parse_model_id}; +use goose::providers::canonical::{map_to_canonical_model, parse_model_id, CanonicalModelRegistry}; use goose::providers::create_with_default_model; use goose::providers::providers as get_providers; use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands}; @@ -477,22 +477,28 @@ pub async fn get_pricing( ) -> Result, StatusCode> { let configured_only = query.configured_only; + let registry = CanonicalModelRegistry::bundled() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut pricing_data = Vec::new(); if !configured_only { // Get ALL pricing data from canonical models - let all_pricing = get_all_pricing(); - - for (provider, models) in all_pricing { - for (model, (input_cost, output_cost, context_length)) in models { - pricing_data.push(PricingData { - provider: provider.clone(), - model: model.clone(), - input_token_cost: input_cost, - output_token_cost: output_cost, - currency: "$".to_string(), - context_length, - }); + for canonical_model in registry.all_models() { + // Parse canonical ID to get provider and model name + if let Some((provider, model_name)) = canonical_model.id.split_once('/') { + if let (Some(input_cost), Some(output_cost)) = + (canonical_model.pricing.prompt, canonical_model.pricing.completion) + { + pricing_data.push(PricingData { + provider: provider.to_string(), + model: model_name.to_string(), + input_token_cost: input_cost, + output_token_cost: output_cost, + currency: "$".to_string(), + context_length: Some(canonical_model.context_length as u32), + }); + } } } } else { @@ -517,20 +523,23 @@ pub async fn get_pricing( (metadata.name.clone(), model_info.name.clone()) }; - // Get pricing from canonical models - if let Some((input_cost, output_cost, context_length)) = - get_model_pricing(&lookup_provider, &lookup_model) - { - pricing_data.push(PricingData { - provider: metadata.name.clone(), - model: model_info.name.clone(), - input_token_cost: input_cost, - output_token_cost: output_cost, - currency: "$".to_string(), - context_length, - }); + // Get canonical model which handles model name mapping + if let Some(canonical_id) = map_to_canonical_model(&lookup_provider, &lookup_model, registry) { + if let Some(canonical_model) = registry.get(&canonical_id) { + if let (Some(input_cost), Some(output_cost)) = + (canonical_model.pricing.prompt, canonical_model.pricing.completion) + { + pricing_data.push(PricingData { + provider: metadata.name.clone(), + model: model_info.name.clone(), + input_token_cost: input_cost, + output_token_cost: output_cost, + currency: "$".to_string(), + context_length: Some(canonical_model.context_length as u32), + }); + } + } } - // No fallback to hardcoded prices } } } @@ -541,7 +550,7 @@ pub async fn get_pricing( if configured_only { " (configured providers only)" } else { - " (all cached models)" + " (all canonical models)" } ); diff --git a/crates/goose/src/providers/canonical/mod.rs b/crates/goose/src/providers/canonical/mod.rs index 48cecb58b687..8520af48c3f7 100644 --- a/crates/goose/src/providers/canonical/mod.rs +++ b/crates/goose/src/providers/canonical/mod.rs @@ -6,8 +6,6 @@ pub use model::{CanonicalModel, Pricing}; pub use name_builder::{canonical_name, map_to_canonical_model, strip_version_suffix}; pub use registry::CanonicalModelRegistry; -use std::collections::HashMap; - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ModelMapping { pub provider_model: String, @@ -23,21 +21,6 @@ impl ModelMapping { } } -/// Get pricing for a specific provider/model combination -/// Returns (input_cost_per_token, output_cost_per_token, context_length) if found -pub fn get_model_pricing(provider: &str, model: &str) -> Option<(f64, f64, Option)> { - let registry = CanonicalModelRegistry::bundled().ok()?; - registry.get_model_pricing(provider, model) -} - -/// Get all pricing data organized by provider -/// Returns HashMap> -pub fn get_all_pricing() -> HashMap)>> { - CanonicalModelRegistry::bundled() - .map(|registry| registry.get_all_pricing()) - .unwrap_or_default() -} - /// Parse OpenRouter-style model ID into (provider, model) components /// e.g., "anthropic/claude-sonnet-4-20250514" -> ("anthropic", "claude-sonnet-4-20250514") pub fn parse_model_id(model_id: &str) -> Option<(String, String)> { diff --git a/crates/goose/src/providers/canonical/registry.rs b/crates/goose/src/providers/canonical/registry.rs index 549bb6cce2e6..b601bfca1158 100644 --- a/crates/goose/src/providers/canonical/registry.rs +++ b/crates/goose/src/providers/canonical/registry.rs @@ -83,48 +83,6 @@ impl CanonicalModelRegistry { pub fn contains(&self, name: &str) -> bool { self.models.contains_key(name) } - - /// Get pricing for a specific provider/model combination - /// Returns (input_cost, output_cost, context_length) if found - pub fn get_model_pricing( - &self, - provider: &str, - model: &str, - ) -> Option<(f64, f64, Option)> { - // Try to map to canonical model first - let canonical_id = super::map_to_canonical_model(provider, model, self)?; - let model = self.get(&canonical_id)?; - - let prompt = model.pricing.prompt?; - let completion = model.pricing.completion?; - let context_length = model.context_length as u32; - - Some((prompt, completion, Some(context_length))) - } - - /// Get all pricing data organized by provider - /// Returns HashMap> - pub fn get_all_pricing(&self) -> HashMap)>> { - let mut result: HashMap)>> = - HashMap::new(); - - for model in self.models.values() { - // Parse canonical ID to get provider and model name - if let Some((provider, model_name)) = model.id.split_once('/') { - if let (Some(prompt), Some(completion)) = - (model.pricing.prompt, model.pricing.completion) - { - let provider_models = result.entry(provider.to_string()).or_default(); - provider_models.insert( - model_name.to_string(), - (prompt, completion, Some(model.context_length as u32)), - ); - } - } - } - - result - } } impl Default for CanonicalModelRegistry { From 96cb5ec128a757d65903c6aed9a275aa632b2bb8 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 15 Dec 2025 17:45:33 -0500 Subject: [PATCH 03/10] further cleanup --- crates/goose-cli/src/session/output.rs | 23 ++------- .../src/routes/config_management.rs | 50 ++++++------------- crates/goose/src/providers/canonical/mod.rs | 22 ++++++++ 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 7813d06a8fc0..01cc95b395a4 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -5,7 +5,7 @@ use goose::config::Config; use goose::conversation::message::{ ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse, }; -use goose::providers::canonical::{map_to_canonical_model, parse_model_id, CanonicalModelRegistry}; +use goose::providers::canonical::maybe_get_canonical_model; use goose::utils::safe_truncate; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use rmcp::model::{CallToolRequestParam, JsonObject, PromptArgument}; @@ -799,24 +799,11 @@ fn estimate_cost_usd( input_tokens: usize, output_tokens: usize, ) -> Option { - // For OpenRouter, parse the model name to extract real provider/model - let openrouter_data = if provider == "openrouter" { - parse_model_id(model) - } else { - None - }; - - let (provider_to_use, model_to_use) = match &openrouter_data { - Some((real_provider, real_model)) => (real_provider.as_str(), real_model.as_str()), - None => (provider, model), - }; - - // Get canonical model which handles model name mapping internally - let registry = CanonicalModelRegistry::bundled().ok()?; - let canonical_id = map_to_canonical_model(provider_to_use, model_to_use, registry)?; - let canonical_model = registry.get(&canonical_id)?; + // Try to get canonical model - returns None if model not found in registry + // For OpenRouter, model is already in "provider/model" format and map_to_canonical_model handles it + let canonical_model = maybe_get_canonical_model(provider, model)?; - // Calculate cost from canonical model pricing + // Extract pricing from canonical model (all canonical models have pricing) let input_cost_per_token = canonical_model.pricing.prompt?; let output_cost_per_token = canonical_model.pricing.completion?; diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 913de830a436..e1de40114bd2 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -13,7 +13,7 @@ use goose::config::{Config, ConfigError}; use goose::model::ModelConfig; use goose::providers::auto_detect::detect_provider_from_api_key; use goose::providers::base::{ProviderMetadata, ProviderType}; -use goose::providers::canonical::{map_to_canonical_model, parse_model_id, CanonicalModelRegistry}; +use goose::providers::canonical::{all_canonical_models, maybe_get_canonical_model}; use goose::providers::create_with_default_model; use goose::providers::providers as get_providers; use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands}; @@ -477,14 +477,11 @@ pub async fn get_pricing( ) -> Result, StatusCode> { let configured_only = query.configured_only; - let registry = CanonicalModelRegistry::bundled() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let mut pricing_data = Vec::new(); if !configured_only { // Get ALL pricing data from canonical models - for canonical_model in registry.all_models() { + for canonical_model in all_canonical_models() { // Parse canonical ID to get provider and model name if let Some((provider, model_name)) = canonical_model.id.split_once('/') { if let (Some(input_cost), Some(output_cost)) = @@ -509,35 +506,20 @@ pub async fn get_pricing( } for model_info in &metadata.known_models { - // Handle OpenRouter models specially - they store full provider/model names - let (lookup_provider, lookup_model) = if metadata.name == "openrouter" { - // For OpenRouter, parse the model name to extract real provider/model - if let Some((provider, model)) = parse_model_id(&model_info.name) { - (provider, model) - } else { - // Fallback if parsing fails - (metadata.name.clone(), model_info.name.clone()) - } - } else { - // For other providers, use names as-is - (metadata.name.clone(), model_info.name.clone()) - }; - - // Get canonical model which handles model name mapping - if let Some(canonical_id) = map_to_canonical_model(&lookup_provider, &lookup_model, registry) { - if let Some(canonical_model) = registry.get(&canonical_id) { - if let (Some(input_cost), Some(output_cost)) = - (canonical_model.pricing.prompt, canonical_model.pricing.completion) - { - pricing_data.push(PricingData { - provider: metadata.name.clone(), - model: model_info.name.clone(), - input_token_cost: input_cost, - output_token_cost: output_cost, - currency: "$".to_string(), - context_length: Some(canonical_model.context_length as u32), - }); - } + // Try to get canonical model - returns None if model not found + // For OpenRouter, model is already in "provider/model" format and map_to_canonical_model handles it + if let Some(canonical_model) = maybe_get_canonical_model(&metadata.name, &model_info.name) { + if let (Some(input_cost), Some(output_cost)) = + (canonical_model.pricing.prompt, canonical_model.pricing.completion) + { + pricing_data.push(PricingData { + provider: metadata.name.clone(), + model: model_info.name.clone(), + input_token_cost: input_cost, + output_token_cost: output_cost, + currency: "$".to_string(), + context_length: Some(canonical_model.context_length as u32), + }); } } } diff --git a/crates/goose/src/providers/canonical/mod.rs b/crates/goose/src/providers/canonical/mod.rs index 8520af48c3f7..989e21295e97 100644 --- a/crates/goose/src/providers/canonical/mod.rs +++ b/crates/goose/src/providers/canonical/mod.rs @@ -31,3 +31,25 @@ pub fn parse_model_id(model_id: &str) -> Option<(String, String)> { None } } + +/// Get a canonical model for a given provider and model name +/// Returns None if the model cannot be found or if the registry cannot be loaded +pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option { + let registry = CanonicalModelRegistry::bundled().ok()?; + let canonical_id = map_to_canonical_model(provider, model, registry)?; + registry.get(&canonical_id).cloned() +} + +/// Get all canonical models from the bundled registry +/// Returns an empty vector if the registry cannot be loaded +pub fn all_canonical_models() -> Vec { + CanonicalModelRegistry::bundled() + .map(|registry| { + registry + .all_models() + .into_iter() + .cloned() + .collect() + }) + .unwrap_or_default() +} From 8234a5f586da6b0483c4f11118ea8f5e84479529 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 15 Dec 2025 18:06:17 -0500 Subject: [PATCH 04/10] massive UI pricing code dleetion --- .../src/routes/config_management.rs | 76 ++----- .../components/bottom_menu/CostTracker.tsx | 96 ++------ .../settings/app/AppSettingsSection.tsx | 134 +----------- ui/desktop/src/hooks/useAgent.ts | 9 +- ui/desktop/src/hooks/useCostTracking.ts | 77 ++++--- ui/desktop/src/utils/costDatabase.ts | 207 ------------------ ui/desktop/src/utils/pricing.ts | 60 +++++ 7 files changed, 135 insertions(+), 524 deletions(-) delete mode 100644 ui/desktop/src/utils/costDatabase.ts create mode 100644 ui/desktop/src/utils/pricing.ts diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index e1de40114bd2..c7562157104b 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -13,7 +13,7 @@ use goose::config::{Config, ConfigError}; use goose::model::ModelConfig; use goose::providers::auto_detect::detect_provider_from_api_key; use goose::providers::base::{ProviderMetadata, ProviderType}; -use goose::providers::canonical::{all_canonical_models, maybe_get_canonical_model}; +use goose::providers::canonical::maybe_get_canonical_model; use goose::providers::create_with_default_model; use goose::providers::providers as get_providers; use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands}; @@ -461,7 +461,8 @@ pub struct PricingResponse { #[derive(Deserialize, ToSchema)] pub struct PricingQuery { - pub configured_only: bool, + pub provider: String, + pub model: String, } #[utoipa::path( @@ -475,67 +476,26 @@ pub struct PricingQuery { pub async fn get_pricing( Json(query): Json, ) -> Result, StatusCode> { - let configured_only = query.configured_only; + // Try to get canonical model for the specific provider/model + let canonical_model = maybe_get_canonical_model(&query.provider, &query.model) + .ok_or(StatusCode::NOT_FOUND)?; let mut pricing_data = Vec::new(); - if !configured_only { - // Get ALL pricing data from canonical models - for canonical_model in all_canonical_models() { - // Parse canonical ID to get provider and model name - if let Some((provider, model_name)) = canonical_model.id.split_once('/') { - if let (Some(input_cost), Some(output_cost)) = - (canonical_model.pricing.prompt, canonical_model.pricing.completion) - { - pricing_data.push(PricingData { - provider: provider.to_string(), - model: model_name.to_string(), - input_token_cost: input_cost, - output_token_cost: output_cost, - currency: "$".to_string(), - context_length: Some(canonical_model.context_length as u32), - }); - } - } - } - } else { - for (metadata, provider_type) in get_providers().await { - // Skip unconfigured providers if filtering - if !check_provider_configured(&metadata, provider_type) { - continue; - } - - for model_info in &metadata.known_models { - // Try to get canonical model - returns None if model not found - // For OpenRouter, model is already in "provider/model" format and map_to_canonical_model handles it - if let Some(canonical_model) = maybe_get_canonical_model(&metadata.name, &model_info.name) { - if let (Some(input_cost), Some(output_cost)) = - (canonical_model.pricing.prompt, canonical_model.pricing.completion) - { - pricing_data.push(PricingData { - provider: metadata.name.clone(), - model: model_info.name.clone(), - input_token_cost: input_cost, - output_token_cost: output_cost, - currency: "$".to_string(), - context_length: Some(canonical_model.context_length as u32), - }); - } - } - } - } + // Extract pricing from canonical model + if let (Some(input_cost), Some(output_cost)) = + (canonical_model.pricing.prompt, canonical_model.pricing.completion) + { + pricing_data.push(PricingData { + provider: query.provider.clone(), + model: query.model.clone(), + input_token_cost: input_cost, + output_token_cost: output_cost, + currency: "$".to_string(), + context_length: Some(canonical_model.context_length as u32), + }); } - tracing::debug!( - "Returning pricing for {} models{}", - pricing_data.len(), - if configured_only { - " (configured providers only)" - } else { - " (all canonical models)" - } - ); - Ok(Json(PricingResponse { pricing: pricing_data, source: "canonical".to_string(), diff --git a/ui/desktop/src/components/bottom_menu/CostTracker.tsx b/ui/desktop/src/components/bottom_menu/CostTracker.tsx index 54cf6620e94b..98ecd2462fe3 100644 --- a/ui/desktop/src/components/bottom_menu/CostTracker.tsx +++ b/ui/desktop/src/components/bottom_menu/CostTracker.tsx @@ -1,14 +1,8 @@ import { useState, useEffect } from 'react'; import { useModelAndProvider } from '../ModelAndProviderContext'; -import { useConfig } from '../ConfigContext'; import { CoinIcon } from '../icons'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'; -import { - getCostForModel, - initializeCostDatabase, - updateAllModelCosts, - fetchAndCachePricing, -} from '../../utils/costDatabase'; +import { fetchModelPricing, ModelCostInfo } from '../../utils/pricing'; interface CostTrackerProps { inputTokens?: number; @@ -24,18 +18,10 @@ interface CostTrackerProps { export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: CostTrackerProps) { const { currentModel, currentProvider } = useModelAndProvider(); - const { getProviders } = useConfig(); - const [costInfo, setCostInfo] = useState<{ - input_token_cost?: number; - output_token_cost?: number; - currency?: string; - } | null>(null); + const [costInfo, setCostInfo] = useState(null); const [isLoading, setIsLoading] = useState(true); const [showPricing, setShowPricing] = useState(true); const [pricingFailed, setPricingFailed] = useState(false); - const [modelNotFound, setModelNotFound] = useState(false); - const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false); - const [initialLoadComplete, setInitialLoadComplete] = useState(false); // Check if pricing is enabled useEffect(() => { @@ -44,33 +30,11 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: setShowPricing(stored !== 'false'); }; - // Check on mount checkPricingSetting(); - - // Listen for storage changes window.addEventListener('storage', checkPricingSetting); return () => window.removeEventListener('storage', checkPricingSetting); }, []); - // Set initial load complete after a short delay - useEffect(() => { - const timer = setTimeout(() => { - setInitialLoadComplete(true); - }, 3000); // Give 3 seconds for initial load - - return () => window.clearTimeout(timer); - }, []); - - // Debug log props removed - - // Initialize cost database on mount - useEffect(() => { - initializeCostDatabase(); - - // Update costs for all models in background - updateAllModelCosts().catch(() => {}); - }, [getProviders]); - useEffect(() => { const loadCostInfo = async () => { if (!currentModel || !currentProvider) { @@ -78,49 +42,20 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: return; } + setIsLoading(true); try { - // First check sync cache - let costData = getCostForModel(currentProvider, currentModel); - + const costData = await fetchModelPricing(currentProvider, currentModel); if (costData) { - // We have cached data setCostInfo(costData); setPricingFailed(false); - setModelNotFound(false); - setIsLoading(false); - setHasAttemptedFetch(true); } else { - // Need to fetch from backend - setIsLoading(true); - const result = await fetchAndCachePricing(currentProvider, currentModel); - setHasAttemptedFetch(true); - - if (result && result.costInfo) { - setCostInfo(result.costInfo); - setPricingFailed(false); - setModelNotFound(false); - } else if (result && result.error === 'model_not_found') { - // Model not found in pricing database, but API call succeeded - setModelNotFound(true); - setPricingFailed(false); - } else { - // API call failed or other error - const freeProviders = ['ollama', 'local', 'localhost']; - if (!freeProviders.includes(currentProvider.toLowerCase())) { - setPricingFailed(true); - setModelNotFound(false); - } - } - setIsLoading(false); - } - } catch { - setHasAttemptedFetch(true); - // Only set pricing failed if we're not dealing with a known free provider - const freeProviders = ['ollama', 'local', 'localhost']; - if (!freeProviders.includes(currentProvider.toLowerCase())) { setPricingFailed(true); - setModelNotFound(false); + setCostInfo(null); } + } catch { + setPricingFailed(true); + setCostInfo(null); + } finally { setIsLoading(false); } }; @@ -221,10 +156,9 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: // Otherwise show as unavailable const getUnavailableTooltip = () => { - if (pricingFailed && hasAttemptedFetch && initialLoadComplete) { - return `Pricing data unavailable - OpenRouter connection failed. Click refresh in settings to retry.`; + if (pricingFailed) { + return `Pricing data unavailable for ${currentModel}`; } - // If we reach here, it must be modelNotFound (since we only get here after attempting fetch) return `Cost data not available for ${currentModel} (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`; }; @@ -249,12 +183,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: // Build tooltip content const getTooltipContent = (): string => { // Handle error states first - if (pricingFailed && hasAttemptedFetch && initialLoadComplete) { - return `Pricing data unavailable - OpenRouter connection failed. Click refresh in settings to retry.`; - } - - if (modelNotFound && hasAttemptedFetch && initialLoadComplete) { - return `Pricing not available for ${currentProvider}/${currentModel}. This model may not be supported by the pricing service.`; + if (pricingFailed) { + return `Pricing data unavailable for ${currentProvider}/${currentModel}`; } // Handle session costs diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 200ab861edd3..103afbc549f1 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -1,13 +1,12 @@ import { useState, useEffect, useRef } from 'react'; import { Switch } from '../../ui/switch'; import { Button } from '../../ui/button'; -import { Settings, RefreshCw, ExternalLink } from 'lucide-react'; +import { Settings } from 'lucide-react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog'; import UpdateSection from './UpdateSection'; import TunnelSection from '../tunnel/TunnelSection'; import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates'; -import { getApiUrl } from '../../../config'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import ThemeSelector from '../../GooseSidebar/ThemeSelector'; import BlockLogoBlack from './icons/block-lockup_black.png'; @@ -25,9 +24,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [isMacOS, setIsMacOS] = useState(false); const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false); - const [pricingStatus, setPricingStatus] = useState<'loading' | 'success' | 'error'>('loading'); - const [lastFetchTime, setLastFetchTime] = useState(null); - const [isRefreshing, setIsRefreshing] = useState(false); const [showPricing, setShowPricing] = useState(true); const [isDarkMode, setIsDarkMode] = useState(false); const updateSectionRef = useRef(null); @@ -65,71 +61,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti setShowPricing(stored !== 'false'); }, []); - // Check pricing status on mount - useEffect(() => { - checkPricingStatus(); - }, []); - - const checkPricingStatus = async () => { - try { - const apiUrl = getApiUrl('/config/pricing'); - const secretKey = await window.electron.getSecretKey(); - - const headers: HeadersInit = { 'Content-Type': 'application/json' }; - if (secretKey) { - headers['X-Secret-Key'] = secretKey; - } - - const response = await fetch(apiUrl, { - method: 'POST', - headers, - body: JSON.stringify({ configured_only: true }), - }); - - if (response.ok) { - await response.json(); - setPricingStatus('success'); - setLastFetchTime(new Date()); - } else { - setPricingStatus('error'); - } - } catch { - setPricingStatus('error'); - } - }; - - const handleRefreshPricing = async () => { - setIsRefreshing(true); - try { - const apiUrl = getApiUrl('/config/pricing'); - const secretKey = await window.electron.getSecretKey(); - - const headers: HeadersInit = { 'Content-Type': 'application/json' }; - if (secretKey) { - headers['X-Secret-Key'] = secretKey; - } - - const response = await fetch(apiUrl, { - method: 'POST', - headers, - body: JSON.stringify({ configured_only: false }), - }); - - if (response.ok) { - setPricingStatus('success'); - setLastFetchTime(new Date()); - // Trigger a reload of the cost database - window.dispatchEvent(new CustomEvent('pricing-updated')); - } else { - setPricingStatus('error'); - } - } catch { - setPricingStatus('error'); - } finally { - setIsRefreshing(false); - } - }; - // Handle scrolling to update section useEffect(() => { if (scrollToSection === 'update' && updateSectionRef.current) { @@ -321,69 +252,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti )} - {/* Pricing Status - only show if cost tracking is enabled */} - {COST_TRACKING_ENABLED && showPricing && ( - <> -
- Pricing Source: - - OpenRouter Docs - - -
- -
- Status: -
- - {pricingStatus === 'success' - ? '✓ Connected' - : pricingStatus === 'error' - ? '✗ Failed' - : '... Checking'} - - -
-
- - {lastFetchTime && ( -
- Last updated: - {lastFetchTime.toLocaleTimeString()} -
- )} - - {pricingStatus === 'error' && ( -

- Unable to fetch pricing data. Costs will not be displayed. -

- )} - - )} diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 37fea45d973c..069a50174ab1 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -2,7 +2,6 @@ import { useCallback, useRef, useState } from 'react'; import { useConfig } from '../components/ConfigContext'; import { ChatType } from '../types/chat'; import { initializeSystem } from '../utils/providerUtils'; -import { initializeCostDatabase } from '../utils/costDatabase'; import { backupConfig, initConfig, @@ -235,13 +234,7 @@ export function useAgent(): UseAgentReturn { recipe: recipeForInit, }); - if (COST_TRACKING_ENABLED) { - try { - await initializeCostDatabase(); - } catch (error) { - console.error('Failed to initialize cost database:', error); - } - } + // Cost tracking enabled - no initialization needed const recipe = initContext.recipe || agentSession.recipe; const conversation = agentSession.conversation || []; diff --git a/ui/desktop/src/hooks/useCostTracking.ts b/ui/desktop/src/hooks/useCostTracking.ts index 4e8b390b23a4..974c8ee8eb36 100644 --- a/ui/desktop/src/hooks/useCostTracking.ts +++ b/ui/desktop/src/hooks/useCostTracking.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useModelAndProvider } from '../components/ModelAndProviderContext'; -import { getCostForModel } from '../utils/costDatabase'; +import { fetchModelPricing } from '../utils/pricing'; import { Session } from '../api'; interface UseCostTrackingProps { @@ -32,46 +32,53 @@ export const useCostTracking = ({ // Handle model changes and accumulate costs useEffect(() => { - if ( - prevModelRef.current !== undefined && - prevProviderRef.current !== undefined && - (prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider) - ) { - // Model/provider has changed, save the costs for the previous model - const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`; + const handleModelChange = async () => { + if ( + prevModelRef.current !== undefined && + prevProviderRef.current !== undefined && + (prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider) + ) { + // Model/provider has changed, save the costs for the previous model + const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`; - // Get pricing info for the previous model - const prevCostInfo = getCostForModel(prevProviderRef.current, prevModelRef.current); + // Get pricing info for the previous model + const prevCostInfo = await fetchModelPricing( + prevProviderRef.current, + prevModelRef.current + ); - if (prevCostInfo) { - const prevInputCost = - (sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0); - const prevOutputCost = - (sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0); - const prevTotalCost = prevInputCost + prevOutputCost; + if (prevCostInfo) { + const prevInputCost = + (sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0); + const prevOutputCost = + (sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0); + const prevTotalCost = prevInputCost + prevOutputCost; - // Save the accumulated costs for this model - setSessionCosts((prev) => ({ - ...prev, - [prevKey]: { - inputTokens: sessionInputTokens || localInputTokens, - outputTokens: sessionOutputTokens || localOutputTokens, - totalCost: prevTotalCost, - }, - })); + // Save the accumulated costs for this model + setSessionCosts((prev) => ({ + ...prev, + [prevKey]: { + inputTokens: sessionInputTokens || localInputTokens, + outputTokens: sessionOutputTokens || localOutputTokens, + totalCost: prevTotalCost, + }, + })); + } + + console.log( + 'Model changed from', + `${prevProviderRef.current}/${prevModelRef.current}`, + 'to', + `${currentProvider}/${currentModel}`, + '- saved costs and restored session token counters' + ); } - console.log( - 'Model changed from', - `${prevProviderRef.current}/${prevModelRef.current}`, - 'to', - `${currentProvider}/${currentModel}`, - '- saved costs and restored session token counters' - ); - } + prevModelRef.current = currentModel || undefined; + prevProviderRef.current = currentProvider || undefined; + }; - prevModelRef.current = currentModel || undefined; - prevProviderRef.current = currentProvider || undefined; + handleModelChange(); }, [ currentModel, currentProvider, diff --git a/ui/desktop/src/utils/costDatabase.ts b/ui/desktop/src/utils/costDatabase.ts deleted file mode 100644 index 5b3bde1de0fe..000000000000 --- a/ui/desktop/src/utils/costDatabase.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { getApiUrl } from '../config'; -import { safeJsonParse } from './conversionUtils'; - -export interface ModelCostInfo { - input_token_cost: number; // Cost per token for input (in USD) - output_token_cost: number; // Cost per token for output (in USD) - currency: string; // Currency symbol -} - -// In-memory cache for current session only -const sessionPricingCache = new Map(); - -/** - * Fetch pricing data from backend for specific provider/model - */ -async function fetchPricingForModel( - provider: string, - model: string -): Promise { - // For OpenRouter models, we need to use the parsed provider and model for the API lookup - let lookupProvider = provider; - let lookupModel = model; - - if (provider.toLowerCase() === 'openrouter') { - const parsed = parseOpenRouterModel(model); - if (parsed) { - lookupProvider = parsed[0]; - lookupModel = parsed[1]; - } - } - - const apiUrl = getApiUrl('/config/pricing'); - const secretKey = await window.electron.getSecretKey(); - - const headers: HeadersInit = { 'Content-Type': 'application/json' }; - if (secretKey) { - headers['X-Secret-Key'] = secretKey; - } - - const response = await fetch(apiUrl, { - method: 'POST', - headers, - body: JSON.stringify({ configured_only: false }), - }); - - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}`); - } - - const data = await safeJsonParse<{ - pricing: Array<{ - provider: string; - model: string; - input_token_cost: number; - output_token_cost: number; - currency: string; - }>; - }>(response, 'Failed to parse pricing data'); - - // Find the specific model pricing using the lookup provider/model - const pricing = data.pricing?.find( - (p: { - provider: string; - model: string; - input_token_cost: number; - output_token_cost: number; - currency: string; - }) => { - const providerMatch = p.provider.toLowerCase() === lookupProvider.toLowerCase(); - - // More flexible model matching - handle versioned models - let modelMatch = p.model === lookupModel; - - // If exact match fails, try matching without version suffix - if (!modelMatch && lookupModel.includes('-20')) { - // Remove date suffix like -20241022 - const modelWithoutDate = lookupModel.replace(/-20\d{6}$/, ''); - modelMatch = p.model === modelWithoutDate; - - // Also try with dots instead of dashes (claude-3-5-sonnet vs claude-3.5-sonnet) - if (!modelMatch) { - const modelWithDots = modelWithoutDate.replace(/-(\d)-/g, '.$1.'); - modelMatch = p.model === modelWithDots; - } - } - - return providerMatch && modelMatch; - } - ); - - if (pricing) { - return { - input_token_cost: pricing.input_token_cost, - output_token_cost: pricing.output_token_cost, - currency: pricing.currency || '$', - }; - } - - // API call succeeded but model not found in pricing data - return null; -} - -/** - * Initialize the cost database - no-op since we fetch on demand now - */ -export async function initializeCostDatabase(): Promise { - // Clear session cache on init - sessionPricingCache.clear(); -} - -/** - * Update model costs from providers - no-op since we fetch on demand - */ -export async function updateAllModelCosts(): Promise { - // No-op - we fetch on demand now -} - -/** - * Parse OpenRouter model ID to extract provider and model - * e.g., "anthropic/claude-sonnet-4" -> ["anthropic", "claude-sonnet-4"] - */ -function parseOpenRouterModel(modelId: string): [string, string] | null { - const parts = modelId.split('/'); - if (parts.length === 2) { - return [parts[0], parts[1]]; - } - return null; -} - -/** - * Get cost information for a specific model with session caching - */ -export function getCostForModel(provider: string, model: string): ModelCostInfo | null { - const cacheKey = `${provider}/${model}`; - - // Check session cache first - if (sessionPricingCache.has(cacheKey)) { - return sessionPricingCache.get(cacheKey) || null; - } - - // For OpenRouter models, also check if we have cached data under the parsed provider/model - if (provider.toLowerCase() === 'openrouter') { - const parsed = parseOpenRouterModel(model); - if (parsed) { - const [parsedProvider, parsedModel] = parsed; - const parsedCacheKey = `${parsedProvider}/${parsedModel}`; - if (sessionPricingCache.has(parsedCacheKey)) { - const cachedData = sessionPricingCache.get(parsedCacheKey) || null; - // Also cache it under the original OpenRouter key for future lookups - sessionPricingCache.set(cacheKey, cachedData); - return cachedData; - } - } - } - - // For local/free providers, return zero cost immediately - const freeProviders = ['ollama', 'local', 'localhost']; - if (freeProviders.includes(provider.toLowerCase())) { - const zeroCost = { - input_token_cost: 0, - output_token_cost: 0, - currency: '$', - }; - sessionPricingCache.set(cacheKey, zeroCost); - return zeroCost; - } - - // Need to fetch - return null and let component handle async fetch - return null; -} - -/** - * Fetch and cache pricing for a model - */ -export async function fetchAndCachePricing( - provider: string, - model: string -): Promise<{ costInfo: ModelCostInfo | null; error?: string } | null> { - try { - const cacheKey = `${provider}/${model}`; - const costInfo = await fetchPricingForModel(provider, model); - - // Cache the result in session cache under the original key - sessionPricingCache.set(cacheKey, costInfo); - - // For OpenRouter models, also cache under the parsed provider/model key - // This helps with cross-referencing between frontend requests and backend responses - if (provider.toLowerCase() === 'openrouter') { - const parsed = parseOpenRouterModel(model); - if (parsed) { - const [parsedProvider, parsedModel] = parsed; - const parsedCacheKey = `${parsedProvider}/${parsedModel}`; - sessionPricingCache.set(parsedCacheKey, costInfo); - } - } - - if (costInfo) { - return { costInfo }; - } else { - // Model not found in pricing data - return { costInfo: null, error: 'model_not_found' }; - } - } catch { - // This is a real API/network error - return null; - } -} diff --git a/ui/desktop/src/utils/pricing.ts b/ui/desktop/src/utils/pricing.ts new file mode 100644 index 000000000000..f9f1f1e2133b --- /dev/null +++ b/ui/desktop/src/utils/pricing.ts @@ -0,0 +1,60 @@ +import { getApiUrl } from '../config'; + +export interface ModelCostInfo { + input_token_cost: number; + output_token_cost: number; + currency: string; +} + +/** + * Fetch pricing for a specific provider/model from the backend + */ +export async function fetchModelPricing( + provider: string, + model: string +): Promise { + // For local/free providers, return zero cost immediately + const freeProviders = ['ollama', 'local', 'localhost']; + if (freeProviders.includes(provider.toLowerCase())) { + return { + input_token_cost: 0, + output_token_cost: 0, + currency: '$', + }; + } + + try { + const apiUrl = getApiUrl('/config/pricing'); + const secretKey = await window.electron.getSecretKey(); + + const headers: HeadersInit = { 'Content-Type': 'application/json' }; + if (secretKey) { + headers['X-Secret-Key'] = secretKey; + } + + const response = await fetch(apiUrl, { + method: 'POST', + headers, + body: JSON.stringify({ provider, model }), + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + const pricing = data.pricing?.[0]; + + if (pricing) { + return { + input_token_cost: pricing.input_token_cost, + output_token_cost: pricing.output_token_cost, + currency: pricing.currency || '$', + }; + } + + return null; + } catch { + return null; + } +} From 3aee1c6f7e1cbb18cbc4f2104ead02a39168cee5 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 15 Dec 2025 18:06:29 -0500 Subject: [PATCH 05/10] more dl --- ui/desktop/src/hooks/useAgent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 069a50174ab1..dcc88c93aef3 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -12,7 +12,6 @@ import { startAgent, validateConfig, } from '../api'; -import { COST_TRACKING_ENABLED } from '../updates'; export enum AgentState { UNINITIALIZED = 'uninitialized', From 77bd96ee59dc903e91728d3f65dfe0d634c92847 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 16 Dec 2025 13:43:18 -0500 Subject: [PATCH 06/10] fmt --- crates/goose-cli/src/session/output.rs | 7 +------ crates/goose-server/src/routes/config_management.rs | 11 ++++++----- crates/goose/src/providers/canonical/mod.rs | 8 +------- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 01cc95b395a4..e18bb3046d63 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -813,12 +813,7 @@ fn estimate_cost_usd( } /// Display cost information, if price data is available. -pub fn display_cost_usage( - provider: &str, - model: &str, - input_tokens: usize, - output_tokens: usize, -) { +pub fn display_cost_usage(provider: &str, model: &str, input_tokens: usize, output_tokens: usize) { if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens) { use console::style; eprintln!( diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index c7562157104b..0a265238bd6d 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -477,15 +477,16 @@ pub async fn get_pricing( Json(query): Json, ) -> Result, StatusCode> { // Try to get canonical model for the specific provider/model - let canonical_model = maybe_get_canonical_model(&query.provider, &query.model) - .ok_or(StatusCode::NOT_FOUND)?; + let canonical_model = + maybe_get_canonical_model(&query.provider, &query.model).ok_or(StatusCode::NOT_FOUND)?; let mut pricing_data = Vec::new(); // Extract pricing from canonical model - if let (Some(input_cost), Some(output_cost)) = - (canonical_model.pricing.prompt, canonical_model.pricing.completion) - { + if let (Some(input_cost), Some(output_cost)) = ( + canonical_model.pricing.prompt, + canonical_model.pricing.completion, + ) { pricing_data.push(PricingData { provider: query.provider.clone(), model: query.model.clone(), diff --git a/crates/goose/src/providers/canonical/mod.rs b/crates/goose/src/providers/canonical/mod.rs index 989e21295e97..6f6d8e279ba5 100644 --- a/crates/goose/src/providers/canonical/mod.rs +++ b/crates/goose/src/providers/canonical/mod.rs @@ -44,12 +44,6 @@ pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option Vec { CanonicalModelRegistry::bundled() - .map(|registry| { - registry - .all_models() - .into_iter() - .cloned() - .collect() - }) + .map(|registry| registry.all_models().into_iter().cloned().collect()) .unwrap_or_default() } From 3584478ca8955655fae894c2e3692819e388a589 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 16 Dec 2025 14:33:43 -0500 Subject: [PATCH 07/10] Cleanup noisy comments --- crates/goose-cli/src/session/mod.rs | 2 -- crates/goose-cli/src/session/output.rs | 3 --- .../src/routes/config_management.rs | 2 -- crates/goose/src/providers/canonical/mod.rs | 21 ------------------- ui/desktop/src/hooks/useAgent.ts | 2 -- 5 files changed, 30 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 807e3d925e55..e21368334442 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1403,8 +1403,6 @@ impl CliSession { .get_goose_provider() .unwrap_or_else(|_| "unknown".to_string()); - // Do not get costing information if show cost is disabled - // This will prevent the API call to openrouter.ai match self.get_session().await { Ok(metadata) => { let total_tokens = metadata.total_tokens.unwrap_or(0) as usize; diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index e18bb3046d63..9243eff96bd3 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -799,11 +799,8 @@ fn estimate_cost_usd( input_tokens: usize, output_tokens: usize, ) -> Option { - // Try to get canonical model - returns None if model not found in registry - // For OpenRouter, model is already in "provider/model" format and map_to_canonical_model handles it let canonical_model = maybe_get_canonical_model(provider, model)?; - // Extract pricing from canonical model (all canonical models have pricing) let input_cost_per_token = canonical_model.pricing.prompt?; let output_cost_per_token = canonical_model.pricing.completion?; diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 0a265238bd6d..c91ef80ebf97 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -476,13 +476,11 @@ pub struct PricingQuery { pub async fn get_pricing( Json(query): Json, ) -> Result, StatusCode> { - // Try to get canonical model for the specific provider/model let canonical_model = maybe_get_canonical_model(&query.provider, &query.model).ok_or(StatusCode::NOT_FOUND)?; let mut pricing_data = Vec::new(); - // Extract pricing from canonical model if let (Some(input_cost), Some(output_cost)) = ( canonical_model.pricing.prompt, canonical_model.pricing.completion, diff --git a/crates/goose/src/providers/canonical/mod.rs b/crates/goose/src/providers/canonical/mod.rs index 6f6d8e279ba5..b7d0e560d4b2 100644 --- a/crates/goose/src/providers/canonical/mod.rs +++ b/crates/goose/src/providers/canonical/mod.rs @@ -21,29 +21,8 @@ impl ModelMapping { } } -/// Parse OpenRouter-style model ID into (provider, model) components -/// e.g., "anthropic/claude-sonnet-4-20250514" -> ("anthropic", "claude-sonnet-4-20250514") -pub fn parse_model_id(model_id: &str) -> Option<(String, String)> { - let parts: Vec<&str> = model_id.splitn(2, '/').collect(); - if parts.len() == 2 { - Some((parts[0].to_string(), parts[1].to_string())) - } else { - None - } -} - -/// Get a canonical model for a given provider and model name -/// Returns None if the model cannot be found or if the registry cannot be loaded pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option { let registry = CanonicalModelRegistry::bundled().ok()?; let canonical_id = map_to_canonical_model(provider, model, registry)?; registry.get(&canonical_id).cloned() } - -/// Get all canonical models from the bundled registry -/// Returns an empty vector if the registry cannot be loaded -pub fn all_canonical_models() -> Vec { - CanonicalModelRegistry::bundled() - .map(|registry| registry.all_models().into_iter().cloned().collect()) - .unwrap_or_default() -} diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index dcc88c93aef3..ec9608e810b4 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -233,8 +233,6 @@ export function useAgent(): UseAgentReturn { recipe: recipeForInit, }); - // Cost tracking enabled - no initialization needed - const recipe = initContext.recipe || agentSession.recipe; const conversation = agentSession.conversation || []; // If we're loading a recipe from initContext (new recipe load), start with empty messages From dec46a8e7a53b3a897d67744f2c4902b95c5b1f5 Mon Sep 17 00:00:00 2001 From: David Katz Date: Wed, 17 Dec 2025 14:27:52 -0500 Subject: [PATCH 08/10] use openapi --- crates/goose-server/src/openapi.rs | 4 ++ ui/desktop/openapi.json | 98 ++++++++++++++++++++++++++++++ ui/desktop/src/api/sdk.gen.ts | 11 +++- ui/desktop/src/api/types.gen.ts | 35 +++++++++++ ui/desktop/src/utils/pricing.ts | 27 +++----- 5 files changed, 157 insertions(+), 18 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index e576c87d6a7e..751947b019c5 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -351,6 +351,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::remove_custom_provider, super::routes::config_management::check_provider, super::routes::config_management::set_config_provider, + super::routes::config_management::get_pricing, super::routes::agent::start_agent, super::routes::agent::resume_agent, super::routes::agent::get_tools, @@ -417,6 +418,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::UpdateCustomProviderRequest, super::routes::config_management::CheckProviderRequest, super::routes::config_management::SetProviderRequest, + super::routes::config_management::PricingQuery, + super::routes::config_management::PricingResponse, + super::routes::config_management::PricingData, super::routes::action_required::ConfirmToolActionRequest, super::routes::reply::ChatRequest, super::routes::session::ImportSessionRequest, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 82be74e71b76..6bc6dfbc4d94 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -854,6 +854,36 @@ } } }, + "/config/pricing": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_pricing", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PricingQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Model pricing data retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PricingResponse" + } + } + } + } + } + } + }, "/config/providers": { "get": { "tags": [ @@ -4223,6 +4253,74 @@ "never_allow" ] }, + "PricingData": { + "type": "object", + "required": [ + "provider", + "model", + "input_token_cost", + "output_token_cost", + "currency" + ], + "properties": { + "context_length": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "currency": { + "type": "string" + }, + "input_token_cost": { + "type": "number", + "format": "double" + }, + "model": { + "type": "string" + }, + "output_token_cost": { + "type": "number", + "format": "double" + }, + "provider": { + "type": "string" + } + } + }, + "PricingQuery": { + "type": "object", + "required": [ + "provider", + "model" + ], + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "PricingResponse": { + "type": "object", + "required": [ + "pricing", + "source" + ], + "properties": { + "pricing": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PricingData" + } + }, + "source": { + "type": "string" + } + } + }, "PrincipalType": { "type": "string", "enum": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 0ec5ba6b74cc..0aaa6fb4579b 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -178,6 +178,15 @@ export const upsertPermissions = (options: } }); +export const getPricing = (options: Options) => (options.client ?? client).post({ + url: '/config/pricing', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const providers = (options?: Options) => (options?.client ?? client).get({ url: '/config/providers', ...options }); export const getProviderModels = (options: Options) => (options.client ?? client).get({ url: '/config/providers/{name}/models', ...options }); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index b19a9b8c6c3c..0166872c13bf 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -527,6 +527,25 @@ export type ParseRecipeResponse = { */ export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow'; +export type PricingData = { + context_length?: number | null; + currency: string; + input_token_cost: number; + model: string; + output_token_cost: number; + provider: string; +}; + +export type PricingQuery = { + model: string; + provider: string; +}; + +export type PricingResponse = { + pricing: Array; + source: string; +}; + export type PrincipalType = 'Extension' | 'Tool'; export type ProviderDetails = { @@ -1718,6 +1737,22 @@ export type UpsertPermissionsResponses = { export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses]; +export type GetPricingData = { + body: PricingQuery; + path?: never; + query?: never; + url: '/config/pricing'; +}; + +export type GetPricingResponses = { + /** + * Model pricing data retrieved successfully + */ + 200: PricingResponse; +}; + +export type GetPricingResponse = GetPricingResponses[keyof GetPricingResponses]; + export type ProvidersData = { body?: never; path?: never; diff --git a/ui/desktop/src/utils/pricing.ts b/ui/desktop/src/utils/pricing.ts index f9f1f1e2133b..e91e2d2892c6 100644 --- a/ui/desktop/src/utils/pricing.ts +++ b/ui/desktop/src/utils/pricing.ts @@ -1,9 +1,10 @@ -import { getApiUrl } from '../config'; +import { getPricing, PricingData } from '../api'; export interface ModelCostInfo { input_token_cost: number; output_token_cost: number; currency: string; + context_length?: number; } /** @@ -20,36 +21,28 @@ export async function fetchModelPricing( input_token_cost: 0, output_token_cost: 0, currency: '$', + context_length: undefined, }; } try { - const apiUrl = getApiUrl('/config/pricing'); - const secretKey = await window.electron.getSecretKey(); - - const headers: HeadersInit = { 'Content-Type': 'application/json' }; - if (secretKey) { - headers['X-Secret-Key'] = secretKey; - } - - const response = await fetch(apiUrl, { - method: 'POST', - headers, - body: JSON.stringify({ provider, model }), + const response = await getPricing({ + body: { provider, model }, + throwOnError: false, }); - if (!response.ok) { + if (!response.data) { return null; } - const data = await response.json(); - const pricing = data.pricing?.[0]; + const pricing: PricingData | undefined = response.data.pricing?.[0]; if (pricing) { return { input_token_cost: pricing.input_token_cost, output_token_cost: pricing.output_token_cost, - currency: pricing.currency || '$', + currency: pricing.currency, + context_length: pricing.context_length ?? undefined, }; } From b084dda397e5d0b30a3118b119e5d42ab06487f7 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 18 Dec 2025 11:08:24 -0500 Subject: [PATCH 09/10] rm modelcost info --- .../components/bottom_menu/CostTracker.tsx | 5 ++-- ui/desktop/src/utils/pricing.ts | 26 ++++--------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/ui/desktop/src/components/bottom_menu/CostTracker.tsx b/ui/desktop/src/components/bottom_menu/CostTracker.tsx index 98ecd2462fe3..fc930e934be6 100644 --- a/ui/desktop/src/components/bottom_menu/CostTracker.tsx +++ b/ui/desktop/src/components/bottom_menu/CostTracker.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'; import { useModelAndProvider } from '../ModelAndProviderContext'; import { CoinIcon } from '../icons'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'; -import { fetchModelPricing, ModelCostInfo } from '../../utils/pricing'; +import { fetchModelPricing } from '../../utils/pricing'; +import { PricingData } from '../../api'; interface CostTrackerProps { inputTokens?: number; @@ -18,7 +19,7 @@ interface CostTrackerProps { export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: CostTrackerProps) { const { currentModel, currentProvider } = useModelAndProvider(); - const [costInfo, setCostInfo] = useState(null); + const [costInfo, setCostInfo] = useState(null); const [isLoading, setIsLoading] = useState(true); const [showPricing, setShowPricing] = useState(true); const [pricingFailed, setPricingFailed] = useState(false); diff --git a/ui/desktop/src/utils/pricing.ts b/ui/desktop/src/utils/pricing.ts index e91e2d2892c6..84ceb40c9ba8 100644 --- a/ui/desktop/src/utils/pricing.ts +++ b/ui/desktop/src/utils/pricing.ts @@ -1,27 +1,22 @@ import { getPricing, PricingData } from '../api'; -export interface ModelCostInfo { - input_token_cost: number; - output_token_cost: number; - currency: string; - context_length?: number; -} - /** * Fetch pricing for a specific provider/model from the backend */ export async function fetchModelPricing( provider: string, model: string -): Promise { +): Promise { // For local/free providers, return zero cost immediately const freeProviders = ['ollama', 'local', 'localhost']; if (freeProviders.includes(provider.toLowerCase())) { return { + provider, + model, input_token_cost: 0, output_token_cost: 0, currency: '$', - context_length: undefined, + context_length: null, }; } @@ -35,18 +30,7 @@ export async function fetchModelPricing( return null; } - const pricing: PricingData | undefined = response.data.pricing?.[0]; - - if (pricing) { - return { - input_token_cost: pricing.input_token_cost, - output_token_cost: pricing.output_token_cost, - currency: pricing.currency, - context_length: pricing.context_length ?? undefined, - }; - } - - return null; + return response.data.pricing?.[0] ?? null; } catch { return null; } From df6313d623dcfa9bdfbb5276238ca405803f382f Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 18 Dec 2025 11:19:08 -0500 Subject: [PATCH 10/10] rm free provider check --- ui/desktop/src/utils/pricing.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/ui/desktop/src/utils/pricing.ts b/ui/desktop/src/utils/pricing.ts index 84ceb40c9ba8..509b92409851 100644 --- a/ui/desktop/src/utils/pricing.ts +++ b/ui/desktop/src/utils/pricing.ts @@ -7,19 +7,6 @@ export async function fetchModelPricing( provider: string, model: string ): Promise { - // For local/free providers, return zero cost immediately - const freeProviders = ['ollama', 'local', 'localhost']; - if (freeProviders.includes(provider.toLowerCase())) { - return { - provider, - model, - input_token_cost: 0, - output_token_cost: 0, - currency: '$', - context_length: null, - }; - } - try { const response = await getPricing({ body: { provider, model },