Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ebfd111
feat: Add cost tracking display to chat interface
Jun 18, 2025
88cf89c
fix: Clean up debug logs and ensure cost tracker shows when available
Jun 18, 2025
1e46dfc
feat: Make cost tracker fully functional with local token estimation
Jun 18, 2025
58a2227
feat: Add backend token tracking integration to cost tracker
Jun 18, 2025
8250b29
fix: Add debugging and improve cost tracker layout
Jun 18, 2025
1edd8a2
fix: Improve cost tracker visibility and model detection
Jun 18, 2025
0eaa13b
fix: Improve cost tracker layout and tooltip messages
Jun 18, 2025
b698707
fix: Fix cost calculation logic for models with pricing
Jun 18, 2025
dc642b3
fix: Add support for claude-opus-4-20250514 model
Jun 18, 2025
0fc0b16
refactor: Create centralized cost database system
Jun 18, 2025
03648e5
fix: Use consistent 6 decimal places for cost display
Jun 18, 2025
84f9190
style: Use monospace font for cost display to prevent jumping
Jun 18, 2025
c1beab5
style: Improve bottom menu layout and vertical alignment
Jun 18, 2025
6ee03b2
style: Remove coin icon from cost tracker for cleaner display
Jun 18, 2025
d1971f2
feat: implement comprehensive pricing system with session-wide cost t…
Jun 18, 2025
7abaa53
feat: add pricing data to provider models and update generated types
Jun 18, 2025
acb355e
feat: add pricing settings UI with status and refresh functionality
Jun 18, 2025
9077126
feat: optimize memory usage for pricing data
Jun 18, 2025
8a455b9
Merge branch 'main' into feature/cost-tracking-display
baxen Jun 26, 2025
df3581e
cleanup from merge from main
baxen Jun 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions crates/goose-server/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@ use goose::scheduler_factory::SchedulerFactory;
use tower_http::cors::{Any, CorsLayer};
use tracing::info;

use goose::providers::pricing::initialize_pricing_cache;

pub async fn run() -> Result<()> {
// Initialize logging
crate::logging::setup_logging(Some("goosed"))?;

let settings = configuration::Settings::new()?;

// 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: {}. Pricing data may not be available.",
e
);
}

let secret_key =
std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string());

Expand Down
124 changes: 124 additions & 0 deletions crates/goose-server/src/routes/config_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use goose::config::{extensions::name_to_key, PermissionManager};
use goose::config::{ExtensionConfigManager, ExtensionEntry};
use goose::model::ModelConfig;
use goose::providers::base::ProviderMetadata;
use goose::providers::pricing::{get_all_pricing, get_model_pricing, refresh_pricing};
use goose::providers::providers as get_providers;
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel};
use http::{HeaderMap, StatusCode};
Expand Down Expand Up @@ -314,6 +315,128 @@ pub async fn providers(
Ok(Json(providers_response))
}

#[derive(Serialize, ToSchema)]
pub struct PricingData {
pub provider: String,
pub model: String,
pub input_token_cost: f64,
pub output_token_cost: f64,
pub currency: String,
pub context_length: Option<u32>,
}

#[derive(Serialize, ToSchema)]
pub struct PricingResponse {
pub pricing: Vec<PricingData>,
pub source: String,
}

#[derive(Deserialize, ToSchema)]
pub struct PricingQuery {
/// If true, only return pricing for configured providers. If false, return all.
pub configured_only: Option<bool>,
}

#[utoipa::path(
post,
path = "/config/pricing",
request_body = PricingQuery,
responses(
(status = 200, description = "Model pricing data retrieved successfully", body = PricingResponse)
)
)]
pub async fn get_pricing(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(query): Json<PricingQuery>,
) -> Result<Json<PricingResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;

let configured_only = query.configured_only.unwrap_or(true);

// 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;

for (provider, models) in all_pricing {
for (model, pricing) in models {
pricing_data.push(PricingData {
provider: provider.clone(),
model: model.clone(),
input_token_cost: pricing.input_cost,
output_token_cost: pricing.output_cost,
currency: "$".to_string(),
context_length: pricing.context_length,
});
}
}
} else {
// Get only configured providers' pricing
let providers_metadata = get_providers();

for metadata in providers_metadata {
// Skip unconfigured providers if filtering
if !check_provider_configured(&metadata) {
continue;
}

for model_info in &metadata.known_models {
// Try to get pricing from cache
if let Some(pricing) = get_model_pricing(&metadata.name, &model_info.name).await {
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,
currency: "$".to_string(),
context_length: pricing.context_length,
});
}
// Check if the model has embedded pricing data
else if let (Some(input_cost), Some(output_cost)) =
(model_info.input_token_cost, model_info.output_token_cost)
{
pricing_data.push(PricingData {
provider: metadata.name.clone(),
model: model_info.name.clone(),
input_token_cost: input_cost,
output_token_cost: output_cost,
currency: model_info
.currency
.clone()
.unwrap_or_else(|| "$".to_string()),
context_length: Some(model_info.context_limit as u32),
});
}
}
}
}

tracing::info!(
"Returning pricing for {} models{}",
pricing_data.len(),
if configured_only {
" (configured providers only)"
} else {
" (all cached models)"
}
);

Ok(Json(PricingResponse {
pricing: pricing_data,
source: "openrouter".to_string(),
}))
}

#[utoipa::path(
post,
path = "/config/init",
Expand Down Expand Up @@ -471,6 +594,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/config/extensions", post(add_extension))
.route("/config/extensions/{name}", delete(remove_extension))
.route("/config/providers", get(providers))
.route("/config/pricing", post(get_pricing))
.route("/config/init", post(init_config))
.route("/config/backup", post(backup_config))
.route("/config/permissions", post(upsert_permissions))
Expand Down
1 change: 1 addition & 0 deletions crates/goose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mcp-core = { path = "../mcp-core" }
anyhow = "1.0"
thiserror = "1.0"
futures = "0.3"
dirs = "5.0"
reqwest = { version = "0.12.9", features = [
"rustls-tls-native-roots",
"json",
Expand Down
12 changes: 9 additions & 3 deletions crates/goose/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use reqwest::{Client, StatusCode};
use serde_json::Value;
use std::time::Duration;

use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage};
use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata, ProviderUsage};
use super::errors::ProviderError;
use super::formats::anthropic::{create_request, get_usage, response_to_message};
use super::utils::{emit_debug_trace, get_model};
Expand Down Expand Up @@ -122,12 +122,18 @@ impl AnthropicProvider {
#[async_trait]
impl Provider for AnthropicProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
ProviderMetadata::with_models(
"anthropic",
"Anthropic",
"Claude and other models from Anthropic",
ANTHROPIC_DEFAULT_MODEL,
ANTHROPIC_KNOWN_MODELS.to_vec(),
vec![
ModelInfo::with_cost("claude-3-5-sonnet-20241022", 200000, 0.000003, 0.000015),
ModelInfo::with_cost("claude-3-5-haiku-20241022", 200000, 0.000001, 0.000005),
ModelInfo::with_cost("claude-3-opus-20240229", 200000, 0.000015, 0.000075),
ModelInfo::with_cost("claude-3-sonnet-20240229", 200000, 0.000003, 0.000015),
ModelInfo::with_cost("claude-3-haiku-20240307", 200000, 0.00000025, 0.00000125),
],
ANTHROPIC_DOC_URL,
vec![
ConfigKey::new("ANTHROPIC_API_KEY", true, true, None),
Expand Down
78 changes: 78 additions & 0 deletions crates/goose/src/providers/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,41 @@ pub struct ModelInfo {
pub name: String,
/// The maximum context length this model supports
pub context_limit: usize,
/// Cost per token for input (optional)
pub input_token_cost: Option<f64>,
/// Cost per token for output (optional)
pub output_token_cost: Option<f64>,
/// Currency for the costs (default: "$")
pub currency: Option<String>,
}

impl ModelInfo {
/// Create a new ModelInfo with just name and context limit
pub fn new(name: impl Into<String>, context_limit: usize) -> Self {
Self {
name: name.into(),
context_limit,
input_token_cost: None,
output_token_cost: None,
currency: None,
}
}

/// Create a new ModelInfo with cost information (per token)
pub fn with_cost(
name: impl Into<String>,
context_limit: usize,
input_cost: f64,
output_cost: f64,
) -> Self {
Self {
name: name.into(),
context_limit,
input_token_cost: Some(input_cost),
output_token_cost: Some(output_cost),
currency: Some("$".to_string()),
}
}
}

/// Metadata about a provider's configuration requirements and capabilities
Expand Down Expand Up @@ -74,13 +109,37 @@ impl ProviderMetadata {
.map(|&name| ModelInfo {
name: name.to_string(),
context_limit: ModelConfig::new(name.to_string()).context_limit(),
input_token_cost: None,
output_token_cost: None,
currency: None,
})
.collect(),
model_doc_link: model_doc_link.to_string(),
config_keys,
}
}

/// Create a new ProviderMetadata with ModelInfo objects that include cost data
pub fn with_models(
name: &str,
display_name: &str,
description: &str,
default_model: &str,
models: Vec<ModelInfo>,
model_doc_link: &str,
config_keys: Vec<ConfigKey>,
) -> Self {
Self {
name: name.to_string(),
display_name: display_name.to_string(),
description: description.to_string(),
default_model: default_model.to_string(),
known_models: models,
model_doc_link: model_doc_link.to_string(),
config_keys,
}
}

pub fn empty() -> Self {
Self {
name: "".to_string(),
Expand Down Expand Up @@ -313,21 +372,40 @@ mod tests {
let info = ModelInfo {
name: "test-model".to_string(),
context_limit: 1000,
input_token_cost: None,
output_token_cost: None,
currency: None,
};
assert_eq!(info.context_limit, 1000);

// Test equality
let info2 = ModelInfo {
name: "test-model".to_string(),
context_limit: 1000,
input_token_cost: None,
output_token_cost: None,
currency: None,
};
assert_eq!(info, info2);

// Test inequality
let info3 = ModelInfo {
name: "test-model".to_string(),
context_limit: 2000,
input_token_cost: None,
output_token_cost: None,
currency: None,
};
assert_ne!(info, info3);
}

#[test]
fn test_model_info_with_cost() {
let info = ModelInfo::with_cost("gpt-4o", 128000, 0.0000025, 0.00001);
assert_eq!(info.name, "gpt-4o");
assert_eq!(info.context_limit, 128000);
assert_eq!(info.input_token_cost, Some(0.0000025));
assert_eq!(info.output_token_cost, Some(0.00001));
assert_eq!(info.currency, Some("$".to_string()));
}
}
1 change: 1 addition & 0 deletions crates/goose/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod oauth;
pub mod ollama;
pub mod openai;
pub mod openrouter;
pub mod pricing;
pub mod sagemaker_tgi;
pub mod snowflake;
pub mod toolshim;
Expand Down
14 changes: 11 additions & 3 deletions crates/goose/src/providers/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;

use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::embedding::{EmbeddingCapable, EmbeddingRequest, EmbeddingResponse};
use super::errors::ProviderError;
use super::formats::openai::{create_request, get_usage, response_to_message};
Expand Down Expand Up @@ -126,12 +126,20 @@ impl OpenAiProvider {
#[async_trait]
impl Provider for OpenAiProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
ProviderMetadata::with_models(
"openai",
"OpenAI",
"GPT-4 and other OpenAI models, including OpenAI compatible ones",
OPEN_AI_DEFAULT_MODEL,
OPEN_AI_KNOWN_MODELS.to_vec(),
vec![
ModelInfo::with_cost("gpt-4o", 128000, 0.0000025, 0.00001),
ModelInfo::with_cost("gpt-4o-mini", 128000, 0.00000015, 0.0000006),
ModelInfo::with_cost("gpt-4-turbo", 128000, 0.00001, 0.00003),
ModelInfo::with_cost("gpt-3.5-turbo", 16385, 0.0000005, 0.0000015),
ModelInfo::with_cost("o1", 200000, 0.000015, 0.00006),
ModelInfo::with_cost("o3", 200000, 0.000015, 0.00006), // Using o1 pricing as placeholder
ModelInfo::with_cost("o4-mini", 128000, 0.000003, 0.000012), // Using o1-mini pricing as placeholder
],
OPEN_AI_DOC_URL,
vec![
ConfigKey::new("OPENAI_API_KEY", true, true, None),
Expand Down
Loading
Loading