From 58b0dbd8122077e54c4f8057795051a294ac5cd6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 14 Sep 2026 15:24:48 -0600 Subject: [PATCH] feat: Added full cost tracking to Coyote and added it to the default right prompt --- assets/config-template.yaml | 3 +- config.example.yaml | 3 +- src/client/common.rs | 95 ++++++++++++++++++++++++++++++-- src/client/model.rs | 16 ++++++ src/config/mod.rs | 2 +- src/config/request_context.rs | 100 ++++++++++++++++++++++++++++------ src/config/session.rs | 43 +++++++++++++++ 7 files changed, 237 insertions(+), 25 deletions(-) diff --git a/assets/config-template.yaml b/assets/config-template.yaml index 7e566f3..a024886 100644 --- a/assets/config-template.yaml +++ b/assets/config-template.yaml @@ -240,10 +240,11 @@ theme: null # null = the built-in dark theme; set to `light left_prompt: null # If null, uses the built-in default: # '{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ' right_prompt: null # If null, uses the built-in default: - # '{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?last_cache_read_tokens ⚡{last_cache_read_tokens} }{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}' + # '{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?last_cache_read_tokens ⚡{last_cache_read_tokens} }{?total_cost ${total_cost} }{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}' # Token-usage variables (set only when the provider reports usage, so guard them with {?var ...}): # last_input_tokens, last_output_tokens, last_cache_read_tokens, last_cache_creation_tokens (most recent API call) # total_input_tokens, total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens (session cumulative) + # last_cost, total_cost (USD, formatted like 0.0142; set only when nonzero and every billed token type has a price) # ---- Miscellaneous ---- user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/ diff --git a/config.example.yaml b/config.example.yaml index c48dd58..c53c3fb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -39,10 +39,11 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons # Token-usage variables (set only when the provider reports usage, so guard them with {?var ...}): # last_input_tokens, last_output_tokens, last_cache_read_tokens, last_cache_creation_tokens (most recent API call) # total_input_tokens, total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens (session cumulative) +# last_cost, total_cost (USD, formatted like 0.0142; set only when nonzero and every billed token type has a price) left_prompt: '{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ' right_prompt: - '{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?last_cache_read_tokens ⚡{last_cache_read_tokens} }{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}' + '{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?last_cache_read_tokens ⚡{last_cache_read_tokens} }{?total_cost ${total_cost} }{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}' # ---- Vault ---- # See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault. diff --git a/src/client/common.rs b/src/client/common.rs index e0c1965..7a223cb 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -402,6 +402,27 @@ impl TokenUsage { other.cache_read_input_tokens, ); } + + /// USD cost of this usage at the given model's prices (USD per 1M + /// tokens). The four buckets are disjoint on both Anthropic direct and + /// Bedrock Converse (`input_tokens` excludes cache tokens), so the terms + /// sum without adjustment. Strict on missing prices: any nonzero + /// component without a price yields `None` rather than a silent + /// undercount. All-zero usage is also `None` — nothing billed, nothing + /// to show. + pub fn cost_usd(&self, model: &Model) -> Option { + fn term(tokens: Option, price: Option) -> Option { + match tokens.unwrap_or(0) { + 0 => Some(0.0), + tokens => Some(tokens as f64 * price? / 1_000_000.0), + } + } + let total = term(self.input_tokens, model.input_price())? + + term(self.output_tokens, model.output_price())? + + term(self.cache_read_input_tokens, model.cache_read_price())? + + term(self.cache_creation_input_tokens, model.cache_write_price())?; + (total > 0.0).then_some(total) + } } #[derive(Debug)] @@ -559,7 +580,7 @@ pub async fn call_chat_completions( ctx.app.config.print_markdown(&text)?; } } - ctx.record_token_usage(usage); + ctx.record_token_usage(usage, client.model()); finish_completion(ctx, text, tool_calls, thinking).await } Err(err) => Err(err), @@ -591,7 +612,7 @@ pub async fn call_chat_completions_streaming( // call's input tokens (reported in message_start), so abort and error // paths must still count toward the session totals. let (text, tool_calls, thinking, usage) = handler.take(); - ctx.record_token_usage(usage); + ctx.record_token_usage(usage, client.model()); if aborted_ctrld { bail!("Aborted."); @@ -665,7 +686,7 @@ pub async fn call_chat_completions_streaming_quiet( thinking, usage, } = output; - ctx.record_token_usage(usage); + ctx.record_token_usage(usage, client.model()); if abort_signal.aborted() { bail!("Aborted."); } @@ -681,7 +702,7 @@ pub async fn call_chat_completions_streaming_quiet( let send_ret = client.chat_completions_streaming(input, &mut handler).await; let (text, tool_calls, thinking, usage) = handler.take(); - ctx.record_token_usage(usage); + ctx.record_token_usage(usage, client.model()); if abort_signal.aborted() { bail!("Aborted."); @@ -1000,6 +1021,72 @@ mod tests { assert_eq!(total.input_tokens, Some(u64::MAX)); } + fn priced_model( + input_price: Option, + output_price: Option, + cache_read_price: Option, + cache_write_price: Option, + ) -> Model { + let mut data = ModelData::new("test"); + data.input_price = input_price; + data.output_price = output_price; + data.cache_read_price = cache_read_price; + data.cache_write_price = cache_write_price; + Model::from_config("provider", &[data]).remove(0) + } + + #[test] + fn test_token_usage_cost_usd_sums_all_four_components() { + let usage = TokenUsage { + input_tokens: Some(1_000_000), + output_tokens: Some(500_000), + cache_creation_input_tokens: Some(200_000), + cache_read_input_tokens: Some(2_000_000), + }; + let model = priced_model(Some(3.0), Some(15.0), Some(0.3), Some(3.75)); + // 3.0 + 7.5 + 0.6 + 0.75 + let cost = usage.cost_usd(&model).unwrap(); + assert!((cost - 11.85).abs() < 1e-9); + } + + #[test] + fn test_token_usage_cost_usd_is_none_when_a_billed_component_lacks_a_price() { + let usage = TokenUsage { + input_tokens: Some(100), + output_tokens: Some(10), + cache_creation_input_tokens: None, + cache_read_input_tokens: Some(50), + }; + let model = priced_model(Some(3.0), Some(15.0), None, Some(3.75)); + assert_eq!(usage.cost_usd(&model), None); + } + + #[test] + fn test_token_usage_cost_usd_ignores_missing_prices_for_unbilled_components() { + let usage = TokenUsage { + input_tokens: Some(1_000_000), + output_tokens: Some(1_000_000), + cache_creation_input_tokens: Some(0), + cache_read_input_tokens: None, + }; + let model = priced_model(Some(3.0), Some(15.0), None, None); + let cost = usage.cost_usd(&model).unwrap(); + assert!((cost - 18.0).abs() < 1e-9); + } + + #[test] + fn test_token_usage_cost_usd_is_none_for_empty_or_all_zero_usage() { + let model = priced_model(Some(3.0), Some(15.0), Some(0.3), Some(3.75)); + assert_eq!(TokenUsage::default().cost_usd(&model), None); + let zero = TokenUsage { + input_tokens: Some(0), + output_tokens: Some(0), + cache_creation_input_tokens: Some(0), + cache_read_input_tokens: Some(0), + }; + assert_eq!(zero.cost_usd(&model), None); + } + #[test] fn test_catch_error_display_json_with_type() { let data = json!({"error": {"type": "invalid_request_error", "message": "Bad request"}}); diff --git a/src/client/model.rs b/src/client/model.rs index a51f1c0..1ccae1a 100644 --- a/src/client/model.rs +++ b/src/client/model.rs @@ -195,6 +195,22 @@ impl Model { self.data.max_output_tokens } + pub fn input_price(&self) -> Option { + self.data.input_price + } + + pub fn output_price(&self) -> Option { + self.data.output_price + } + + pub fn cache_read_price(&self) -> Option { + self.data.cache_read_price + } + + pub fn cache_write_price(&self) -> Option { + self.data.cache_write_price + } + pub fn supports_function_calling(&self) -> bool { self.data.supports_function_calling } diff --git a/src/config/mod.rs b/src/config/mod.rs index b13aa06..be9242c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -218,7 +218,7 @@ const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a const LEFT_PROMPT: &str = "{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} "; // `{?var}` treats "0" as falsy, so a reported-but-zero cache read intentionally hides the ⚡ segment. -const RIGHT_PROMPT: &str = "{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?last_cache_read_tokens ⚡{last_cache_read_tokens} }{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}"; +const RIGHT_PROMPT: &str = "{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?last_cache_read_tokens ⚡{last_cache_read_tokens} }{?total_cost ${total_cost} }{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}"; static EDITOR: OnceLock> = OnceLock::new(); diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 9d3c129..0579609 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -311,6 +311,7 @@ pub struct RequestContext { /// Usage reported by the most recent API call; reset on every completion /// so providers that report nothing never show stale numbers. pub last_token_usage: Option, + pub last_cost: Option, pub tool_scope: ToolScope, @@ -372,6 +373,7 @@ impl RequestContext { agent: None, last_message: None, last_token_usage: None, + last_cost: None, tool_scope: ToolScope::default(), declared_function_names: Default::default(), node_job_scope: None, @@ -437,6 +439,7 @@ impl RequestContext { agent: None, last_message: None, last_token_usage: None, + last_cost: None, tool_scope: ToolScope { functions, mcp_runtime, @@ -499,6 +502,7 @@ impl RequestContext { agent: self.agent.clone(), last_message: self.last_message.clone(), last_token_usage: self.last_token_usage.clone(), + last_cost: self.last_cost, tool_scope: self.tool_scope.clone(), declared_function_names: self.declared_function_names.clone(), node_job_scope: None, @@ -549,6 +553,7 @@ impl RequestContext { agent: None, last_message: None, last_token_usage: None, + last_cost: None, tool_scope: ToolScope { functions: Functions::default(), mcp_runtime: McpRuntime::default(), @@ -2236,14 +2241,19 @@ impl RequestContext { } } - pub fn record_token_usage(&mut self, usage: Option) { + pub fn record_token_usage(&mut self, usage: Option, model: &Model) { + let cost = usage.as_ref().and_then(|u| u.cost_usd(model)); if let Some(usage) = &usage { debug!("token-usage: {usage:?}"); if let Some(session) = self.session.as_mut() { session.accumulate_token_usage(usage); + if let Some(cost) = cost { + session.accumulate_cost(cost); + } } } self.last_token_usage = usage; + self.last_cost = cost; } pub fn generate_prompt_context(&self, app: &AppConfig) -> HashMap<&str, String> { @@ -2311,6 +2321,12 @@ impl RequestContext { if let Some(value) = usage.cache_creation_input_tokens { output.insert("last_cache_creation_tokens", value.to_string()); } + if let Some(value) = self.last_cost { + let rendered = format!("{value:.4}"); + if rendered != "0.0000" { + output.insert("last_cost", rendered); + } + } } if let Some(session) = &self.session { output.insert("session", session.name().to_string()); @@ -2335,6 +2351,10 @@ impl RequestContext { if let Some(value) = usage.cache_creation_input_tokens { output.insert("total_cache_creation_tokens", value.to_string()); } + let rendered = format!("{:.4}", session.cost()); + if rendered != "0.0000" { + output.insert("total_cost", rendered); + } } if let Some(rag) = &self.rag { output.insert("rag", rag.name().to_string()); @@ -5343,6 +5363,7 @@ fn fork_base_name(name: &str) -> &str { mod tests { use super::super::mcp_factory::McpFactory; use super::*; + use crate::client::ModelData; use crate::config::AppState; use crate::config::agent::AgentConfig; use crate::config::bundles::BundleStore; @@ -5460,22 +5481,37 @@ mod tests { RequestContext::new(default_app_state(), WorkingMode::Cmd) } + fn priced_model() -> Model { + let mut data = ModelData::new("test"); + data.input_price = Some(10.0); + data.output_price = Some(100.0); + data.cache_read_price = Some(1.0); + data.cache_write_price = Some(20.0); + Model::from_config("provider", &[data]).remove(0) + } + #[test] fn generate_prompt_context_emits_token_usage_vars() { let mut ctx = create_test_ctx(); ctx.session = Some(Session::default()); - ctx.record_token_usage(Some(TokenUsage { - input_tokens: Some(120), - output_tokens: Some(8), - cache_creation_input_tokens: None, - cache_read_input_tokens: Some(100), - })); - ctx.record_token_usage(Some(TokenUsage { - input_tokens: Some(30), - output_tokens: Some(2), - cache_creation_input_tokens: Some(40), - cache_read_input_tokens: None, - })); + ctx.record_token_usage( + Some(TokenUsage { + input_tokens: Some(120), + output_tokens: Some(8), + cache_creation_input_tokens: None, + cache_read_input_tokens: Some(100), + }), + &priced_model(), + ); + ctx.record_token_usage( + Some(TokenUsage { + input_tokens: Some(30), + output_tokens: Some(2), + cache_creation_input_tokens: Some(40), + cache_read_input_tokens: None, + }), + &priced_model(), + ); let app = ctx.app.config.clone(); let vars = ctx.generate_prompt_context(&app); @@ -5509,21 +5545,49 @@ mod tests { vars.get("total_cache_creation_tokens").map(String::as_str), Some("40") ); + // 30×10 + 2×100 + 40×20 per 1M = 0.0013; first call adds 0.0021. + assert_eq!(vars.get("last_cost").map(String::as_str), Some("0.0013")); + assert_eq!(vars.get("total_cost").map(String::as_str), Some("0.0034")); } #[test] fn generate_prompt_context_omits_token_usage_vars_when_provider_reports_none() { let mut ctx = create_test_ctx(); - ctx.record_token_usage(Some(TokenUsage { - input_tokens: Some(1), - ..Default::default() - })); - ctx.record_token_usage(None); + ctx.record_token_usage( + Some(TokenUsage { + input_tokens: Some(1), + ..Default::default() + }), + &priced_model(), + ); + ctx.record_token_usage(None, &priced_model()); let app = ctx.app.config.clone(); let vars = ctx.generate_prompt_context(&app); assert!(!vars.contains_key("last_input_tokens")); assert!(!vars.contains_key("total_input_tokens")); + assert!(!vars.contains_key("last_cost")); + assert!(!vars.contains_key("total_cost")); + } + + #[test] + fn generate_prompt_context_omits_costs_that_render_as_zero() { + let mut ctx = create_test_ctx(); + ctx.session = Some(Session::default()); + // 1 input token at $10/1M is 0.00001, which formats to "0.0000". + ctx.record_token_usage( + Some(TokenUsage { + input_tokens: Some(1), + ..Default::default() + }), + &priced_model(), + ); + + let app = ctx.app.config.clone(); + let vars = ctx.generate_prompt_context(&app); + assert_eq!(vars.get("last_input_tokens").map(String::as_str), Some("1")); + assert!(!vars.contains_key("last_cost")); + assert!(!vars.contains_key("total_cost")); } fn test_decl(name: &str) -> FunctionDeclaration { diff --git a/src/config/session.rs b/src/config/session.rs index dc85b9f..6f541b9 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -24,6 +24,10 @@ pub const INTERRUPTED_RESPONSE_TEXT: &str = "[Response interrupted due to error] /// instead of thrashing every turn. const COMPRESSION_MIN_RECLAIM_DIVISOR: usize = 5; +fn cost_is_zero(v: &f64) -> bool { + *v == 0.0 +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Session { #[serde(rename(serialize = "model", deserialize = "model"))] @@ -101,6 +105,8 @@ pub struct Session { auto_continue_paused: Option, #[serde(default, skip_serializing_if = "TokenUsage::is_empty")] token_usage: TokenUsage, + #[serde(default, skip_serializing_if = "cost_is_zero")] + cost: f64, #[serde(skip)] model: Model, @@ -276,6 +282,15 @@ impl Session { self.dirty = true; } + pub fn cost(&self) -> f64 { + self.cost + } + + pub fn accumulate_cost(&mut self, cost: f64) { + self.cost += cost; + self.dirty = true; + } + pub fn has_user_messages(&self) -> bool { self.messages.iter().any(|v| v.role.is_user()) } @@ -357,6 +372,9 @@ impl Session { if let Some(value) = self.token_usage.cache_creation_input_tokens { data["cache_creation_tokens"] = value.into(); } + if self.cost != 0.0 { + data["total_cost"] = ((self.cost * 1e6).round() / 1e6).into(); + } data["messages"] = json!(self.messages); let output = serde_yaml::to_string(&data) @@ -467,6 +485,9 @@ impl Session { if let Some(value) = self.token_usage.cache_creation_input_tokens { items.push(("cache_creation_tokens", value.to_string())); } + if self.cost != 0.0 { + items.push(("total_cost", format!("${:.4}", self.cost))); + } let mut lines: Vec = items .iter() @@ -1212,6 +1233,28 @@ mod tests { assert!(!yaml.contains("token_usage")); } + #[test] + fn session_cost_accumulates_and_survives_yaml_round_trip() { + let mut session = Session::default(); + assert_eq!(session.cost(), 0.0); + + session.accumulate_cost(0.0025); + session.accumulate_cost(0.001); + assert!((session.cost() - 0.0035).abs() < 1e-12); + assert!(session.dirty()); + + let yaml = serde_yaml::to_string(&session).unwrap(); + let reloaded: Session = serde_yaml::from_str(&yaml).unwrap(); + assert!((reloaded.cost() - 0.0035).abs() < 1e-12); + } + + #[test] + fn session_zero_cost_is_not_serialized() { + let session = Session::default(); + let yaml = serde_yaml::to_string(&session).unwrap(); + assert!(!yaml.contains("cost")); + } + #[test] fn session_export_includes_real_usage_only_when_reported() { let empty = Session::default().export().unwrap();