Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1559,7 +1559,7 @@ async fn log_session_completion(
let (total_tokens, message_count) = session
.get_session()
.await
.map(|m| (m.total_tokens.unwrap_or(0), m.message_count))
.map(|m| (m.usage.total_tokens.unwrap_or(0), m.message_count))
.unwrap_or((0, 0));

tracing::info!(
Expand Down
5 changes: 4 additions & 1 deletion crates/goose-cli/src/commands/term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,10 @@ pub async fn handle_term_info() -> Result<()> {

let session_manager = SessionManager::instance();
let session = session_manager.get_session(&session_id, false).await.ok();
let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize;
let total_tokens = session
.as_ref()
.and_then(|s| s.usage.total_tokens)
.unwrap_or(0) as usize;

let config = goose::config::Config::global();
let model_name = config
Expand Down
38 changes: 23 additions & 15 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,9 +909,11 @@ impl CliSession {
.config
.session_manager
.update(&self.session_id)
.total_tokens(Some(0))
.input_tokens(Some(0))
.output_tokens(Some(0))
.usage(goose_providers::conversation::token_usage::Usage::new(
Some(0),
Some(0),
Some(0),
))
.apply()
.await
{
Expand Down Expand Up @@ -1382,9 +1384,18 @@ impl CliSession {
.await
{
Ok(session) => JsonMetadata {
total_tokens: session.accumulated_total_tokens.or(session.total_tokens),
input_tokens: session.accumulated_input_tokens.or(session.input_tokens),
output_tokens: session.accumulated_output_tokens.or(session.output_tokens),
total_tokens: session
.accumulated_usage
.total_tokens
.or(session.usage.total_tokens),
input_tokens: session
.accumulated_usage
.input_tokens
.or(session.usage.input_tokens),
output_tokens: session
.accumulated_usage
.output_tokens
.or(session.usage.output_tokens),
status: "completed".to_string(),
},
Err(_) => JsonMetadata {
Expand All @@ -1409,9 +1420,9 @@ impl CliSession {
.ok();
let (total_tokens, input_tokens, output_tokens) = match session {
Some(s) => (
s.accumulated_total_tokens.or(s.total_tokens),
s.accumulated_input_tokens.or(s.input_tokens),
s.accumulated_output_tokens.or(s.output_tokens),
s.accumulated_usage.total_tokens.or(s.usage.total_tokens),
s.accumulated_usage.input_tokens.or(s.usage.input_tokens),
s.accumulated_usage.output_tokens.or(s.usage.output_tokens),
),
None => (None, None, None),
};
Expand Down Expand Up @@ -1579,7 +1590,7 @@ impl CliSession {

pub async fn get_total_token_usage(&self) -> Result<Option<i32>> {
let metadata = self.get_session().await?;
Ok(metadata.accumulated_total_tokens)
Ok(metadata.accumulated_usage.total_tokens)
}

/// Display enhanced context usage with session totals
Expand All @@ -1599,18 +1610,15 @@ impl CliSession {

match self.get_session().await {
Ok(metadata) => {
let total_tokens = metadata.total_tokens.unwrap_or(0) as usize;
let total_tokens = metadata.usage.total_tokens.unwrap_or(0) as usize;

output::display_context_usage(total_tokens, context_limit);

if show_cost {
let input_tokens = metadata.input_tokens.unwrap_or(0) as usize;
let output_tokens = metadata.output_tokens.unwrap_or(0) as usize;
output::display_cost_usage(
&provider_name,
&model_config.model_name,
input_tokens,
output_tokens,
&metadata.usage,
);
}
}
Expand Down
35 changes: 19 additions & 16 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use goose::providers::canonical::maybe_get_canonical_model;
#[cfg(target_os = "windows")]
use goose::subprocess::SubprocessExt;
use goose::utils::safe_truncate;
use goose_providers::conversation::token_usage::Usage;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use rmcp::model::{CallToolRequestParams, JsonObject, PromptArgument};
use serde_json::Value;
Expand Down Expand Up @@ -1414,31 +1415,33 @@ pub fn display_context_usage(total_tokens: usize, context_limit: usize) {
);
}

fn estimate_cost_usd(
provider: &str,
model: &str,
input_tokens: usize,
output_tokens: usize,
) -> Option<f64> {
fn estimate_cost_usd(provider: &str, model: &str, usage: &Usage) -> Option<f64> {
let canonical_model = maybe_get_canonical_model(provider, model)?;

let input_cost_per_token = canonical_model.cost.input? / 1_000_000.0;
let output_cost_per_token = canonical_model.cost.output? / 1_000_000.0;

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)
canonical_model.cost.estimate_cost(usage)
}

/// Display cost information, if price data is available.
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) {
pub fn display_cost_usage(provider: &str, model: &str, usage: &Usage) {
if let Some(cost) = estimate_cost_usd(provider, model, usage) {
use console::style;
let input_tokens = usage.input_tokens.unwrap_or(0);
let output_tokens = usage.output_tokens.unwrap_or(0);
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
let cache_write = usage.cache_write_input_tokens.unwrap_or(0);

let cache_breakdown = match (cache_read, cache_write) {
(0, 0) => String::new(),
(read, 0) => format!(" ({} cache read)", read),
(0, write) => format!(" ({} cache write)", write),
(read, write) => format!(" ({} cache read, {} cache write)", read, write),
};

eprintln!(
"Cost: {} USD ({} tokens: in {}, out {})",
"Cost: {} USD ({} tokens: in {}{}, out {})",
style(format!("${:.4}", cost)).cyan(),
input_tokens + output_tokens,
input_tokens,
cache_breakdown,
output_tokens
);
}
Expand Down
87 changes: 87 additions & 0 deletions crates/goose-providers/src/canonical/model.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};

use crate::conversation::token_usage::Usage;

/// Modality types for model input/output
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
Expand Down Expand Up @@ -52,6 +54,30 @@ pub struct Pricing {
pub cache_write: Option<f64>,
}

impl Pricing {
pub fn estimate_cost(&self, usage: &Usage) -> Option<f64> {
let input_price = self.input?;
let output_price = self.output?;
let cache_read_price = self.cache_read.unwrap_or(input_price);
let cache_write_price = self.cache_write.unwrap_or(input_price);

let input_tokens = usage.input_tokens.unwrap_or(0).max(0) as f64;
let output_tokens = usage.output_tokens.unwrap_or(0).max(0) as f64;
let cache_read_tokens = usage.cache_read_input_tokens.unwrap_or(0).max(0) as f64;
let cache_write_tokens = usage.cache_write_input_tokens.unwrap_or(0).max(0) as f64;
let uncached_input_tokens =
(input_tokens - cache_read_tokens - cache_write_tokens).max(0.0);

Some(
(uncached_input_tokens * input_price
+ cache_read_tokens * cache_read_price
+ cache_write_tokens * cache_write_price
+ output_tokens * output_price)
/ 1_000_000.0,
)
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Limit {
/// Maximum context window size in tokens
Expand Down Expand Up @@ -130,3 +156,64 @@ pub struct CanonicalModel {
#[serde(default)]
pub limit: Limit,
}

#[cfg(test)]
mod tests {
use super::*;

fn pricing(
input: Option<f64>,
output: Option<f64>,
cache_read: Option<f64>,
cache_write: Option<f64>,
) -> Pricing {
Pricing {
input,
output,
cache_read,
cache_write,
}
}

#[test]
fn estimate_cost_prices_cache_tokens_at_cache_rates() {
let pricing = pricing(Some(5.0), Some(25.0), Some(0.5), Some(6.25));
let usage =
Usage::new(Some(10_000), Some(1_000), None).with_cache_tokens(Some(8_000), Some(1_000));

let cost = pricing.estimate_cost(&usage).unwrap();
let expected =
(1_000.0 * 5.0 + 8_000.0 * 0.5 + 1_000.0 * 6.25 + 1_000.0 * 25.0) / 1_000_000.0;
assert!((cost - expected).abs() < f64::EPSILON);
}

#[test]
fn estimate_cost_handles_missing_prices() {
let usage =
Usage::new(Some(1_000), Some(100), None).with_cache_tokens(Some(600), Some(200));

// Unpriced cache tokens fall back to the input rate.
let cost = pricing(Some(2.0), Some(10.0), None, None)
.estimate_cost(&usage)
.unwrap();
assert_eq!(cost, (1_000.0 * 2.0 + 100.0 * 10.0) / 1_000_000.0);

// Missing input or output pricing means no estimate at all.
assert!(pricing(None, Some(10.0), None, None)
.estimate_cost(&usage)
.is_none());
assert!(pricing(Some(2.0), None, None, None)
.estimate_cost(&usage)
.is_none());
}

#[test]
fn estimate_cost_clamps_cache_tokens_exceeding_input() {
let pricing = pricing(Some(5.0), Some(25.0), Some(0.5), Some(6.25));
let usage = Usage::new(Some(100), Some(10), None).with_cache_tokens(Some(150), Some(50));

let cost = pricing.estimate_cost(&usage).unwrap();
let expected = (150.0 * 0.5 + 50.0 * 6.25 + 10.0 * 25.0) / 1_000_000.0;
assert!((cost - expected).abs() < f64::EPSILON);
}
}
8 changes: 8 additions & 0 deletions crates/goose-providers/src/conversation/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1088,9 +1088,17 @@ pub struct TokenState {
pub input_tokens: i32,
pub output_tokens: i32,
pub total_tokens: i32,
#[serde(default)]
pub cache_read_tokens: i32,
#[serde(default)]
pub cache_write_tokens: i32,
pub accumulated_input_tokens: i32,
pub accumulated_output_tokens: i32,
pub accumulated_total_tokens: i32,
#[serde(default)]
pub accumulated_cache_read_tokens: i32,
#[serde(default)]
pub accumulated_cache_write_tokens: i32,
pub accumulated_cost: Option<f64>,
}

Expand Down
49 changes: 44 additions & 5 deletions crates/goose-providers/src/conversation/token_usage.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::ops::{Add, AddAssign};

use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderUsage {
Expand Down Expand Up @@ -53,8 +54,14 @@ impl ProviderUsage {
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy)]
/// `input_tokens` is the total input including cache read/write tokens;
/// the cache fields are breakdown subsets of it. Parsers for providers
/// that report cache tokens separately from input (e.g. Anthropic,
/// Bedrock) must fold them into `input_tokens`.
#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy, PartialEq, Eq, ToSchema)]
pub struct Usage {
/// All prompt tokens, including any served from or written to cache.
/// `cache_read_input_tokens` and `cache_write_input_tokens` are subsets of this.
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub total_tokens: Option<i32>,
Expand All @@ -64,12 +71,12 @@ pub struct Usage {

fn sum_optionals<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Add<Output = T> + Default,
T: Add<Output = T>,
{
match (a, b) {
(Some(x), Some(y)) => Some(x + y),
(Some(x), None) => Some(x + T::default()),
(None, Some(y)) => Some(T::default() + y),
(Some(x), None) => Some(x),
(None, Some(y)) => Some(y),
(None, None) => None,
}
}
Expand Down Expand Up @@ -107,7 +114,7 @@ impl Usage {
) -> Self {
let calculated_total = if total_tokens.is_none() {
match (input_tokens, output_tokens) {
(Some(input), Some(output)) => Some(input + output),
(Some(input), Some(output)) => Some(input.saturating_add(output)),
(Some(input), None) => Some(input),
(None, Some(output)) => Some(output),
(None, None) => None,
Expand All @@ -134,6 +141,26 @@ impl Usage {
self.cache_write_input_tokens = cache_write_input_tokens;
self
}

/// For providers whose reported `input_tokens`/`total_tokens` exclude
/// cache tokens (e.g. Anthropic, Bedrock): folds the cache breakdown in.
pub fn from_cache_exclusive_input(
input_tokens: Option<i32>,
output_tokens: Option<i32>,
total_tokens: Option<i32>,
cache_read_input_tokens: Option<i32>,
cache_write_input_tokens: Option<i32>,
) -> Self {
let cache_tokens = cache_read_input_tokens
.unwrap_or(0)
.saturating_add(cache_write_input_tokens.unwrap_or(0));
Self::new(
input_tokens.map(|v| v.saturating_add(cache_tokens)),
output_tokens,
total_tokens.map(|v| v.saturating_add(cache_tokens)),
)
.with_cache_tokens(cache_read_input_tokens, cache_write_input_tokens)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -161,6 +188,18 @@ mod tests {
Ok(())
}

#[test]
fn test_from_cache_exclusive_input_folds_cache_into_input_and_total() {
let usage =
Usage::from_cache_exclusive_input(Some(10), Some(50), Some(60), Some(5000), Some(1000));

assert_eq!(usage.input_tokens, Some(6010));
assert_eq!(usage.output_tokens, Some(50));
assert_eq!(usage.total_tokens, Some(6060));
assert_eq!(usage.cache_read_input_tokens, Some(5000));
assert_eq!(usage.cache_write_input_tokens, Some(1000));
}

#[test]
fn test_usage_addition_includes_cached_tokens() {
let usage_a =
Expand Down
Loading
Loading