feat: Added full cost tracking to Coyote and added it to the default right prompt
This commit is contained in:
7 files changed
+237
-25
No files matched your search
+91
-4
@@ -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<f64> {
|
||||
fn term(tokens: Option<u64>, price: Option<f64>) -> Option<f64> {
|
||||
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<f64>,
|
||||
output_price: Option<f64>,
|
||||
cache_read_price: Option<f64>,
|
||||
cache_write_price: Option<f64>,
|
||||
) -> 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"}});
|
||||
|
||||
@@ -195,6 +195,22 @@ impl Model {
|
||||
self.data.max_output_tokens
|
||||
}
|
||||
|
||||
pub fn input_price(&self) -> Option<f64> {
|
||||
self.data.input_price
|
||||
}
|
||||
|
||||
pub fn output_price(&self) -> Option<f64> {
|
||||
self.data.output_price
|
||||
}
|
||||
|
||||
pub fn cache_read_price(&self) -> Option<f64> {
|
||||
self.data.cache_read_price
|
||||
}
|
||||
|
||||
pub fn cache_write_price(&self) -> Option<f64> {
|
||||
self.data.cache_write_price
|
||||
}
|
||||
|
||||
pub fn supports_function_calling(&self) -> bool {
|
||||
self.data.supports_function_calling
|
||||
}
|
||||
|
||||
+1
-1
@@ -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<Option<String>> = OnceLock::new();
|
||||
|
||||
|
||||
@@ -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<TokenUsage>,
|
||||
pub last_cost: Option<f64>,
|
||||
|
||||
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<TokenUsage>) {
|
||||
pub fn record_token_usage(&mut self, usage: Option<TokenUsage>, 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 {
|
||||
|
||||
@@ -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<String>,
|
||||
#[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<String> = 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();
|
||||
|
||||
Reference in new issue
Block a user