feat: Added full cost tracking to Coyote and added it to the default right prompt

This commit is contained in:
Dark-Alex-17 committed 2026-09-14 15:24:48 -06:00
1 parent bd5c0a7cdd
commit 58b0dbd812
7 files changed
+237 -25

No files matched your search

+2 -1
View File
@@ -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: 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} ' # '{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: 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 ...}): # 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) # 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) # 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 ---- # ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version> user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
+2 -1
View File
@@ -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 ...}): # 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) # 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) # 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: left_prompt:
'{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ' '{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
right_prompt: 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 ---- # ---- Vault ----
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault. # See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault.
+91 -4
View File
@@ -402,6 +402,27 @@ impl TokenUsage {
other.cache_read_input_tokens, 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)] #[derive(Debug)]
@@ -559,7 +580,7 @@ pub async fn call_chat_completions(
ctx.app.config.print_markdown(&text)?; 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 finish_completion(ctx, text, tool_calls, thinking).await
} }
Err(err) => Err(err), 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 // call's input tokens (reported in message_start), so abort and error
// paths must still count toward the session totals. // paths must still count toward the session totals.
let (text, tool_calls, thinking, usage) = handler.take(); let (text, tool_calls, thinking, usage) = handler.take();
ctx.record_token_usage(usage); ctx.record_token_usage(usage, client.model());
if aborted_ctrld { if aborted_ctrld {
bail!("Aborted."); bail!("Aborted.");
@@ -665,7 +686,7 @@ pub async fn call_chat_completions_streaming_quiet(
thinking, thinking,
usage, usage,
} = output; } = output;
ctx.record_token_usage(usage); ctx.record_token_usage(usage, client.model());
if abort_signal.aborted() { if abort_signal.aborted() {
bail!("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 send_ret = client.chat_completions_streaming(input, &mut handler).await;
let (text, tool_calls, thinking, usage) = handler.take(); let (text, tool_calls, thinking, usage) = handler.take();
ctx.record_token_usage(usage); ctx.record_token_usage(usage, client.model());
if abort_signal.aborted() { if abort_signal.aborted() {
bail!("Aborted."); bail!("Aborted.");
@@ -1000,6 +1021,72 @@ mod tests {
assert_eq!(total.input_tokens, Some(u64::MAX)); 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] #[test]
fn test_catch_error_display_json_with_type() { fn test_catch_error_display_json_with_type() {
let data = json!({"error": {"type": "invalid_request_error", "message": "Bad request"}}); let data = json!({"error": {"type": "invalid_request_error", "message": "Bad request"}});
+16
View File
@@ -195,6 +195,22 @@ impl Model {
self.data.max_output_tokens 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 { pub fn supports_function_calling(&self) -> bool {
self.data.supports_function_calling self.data.supports_function_calling
} }
+1 -1
View File
@@ -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} "; 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. // `{?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(); static EDITOR: OnceLock<Option<String>> = OnceLock::new();
+82 -18
View File
@@ -311,6 +311,7 @@ pub struct RequestContext {
/// Usage reported by the most recent API call; reset on every completion /// Usage reported by the most recent API call; reset on every completion
/// so providers that report nothing never show stale numbers. /// so providers that report nothing never show stale numbers.
pub last_token_usage: Option<TokenUsage>, pub last_token_usage: Option<TokenUsage>,
pub last_cost: Option<f64>,
pub tool_scope: ToolScope, pub tool_scope: ToolScope,
@@ -372,6 +373,7 @@ impl RequestContext {
agent: None, agent: None,
last_message: None, last_message: None,
last_token_usage: None, last_token_usage: None,
last_cost: None,
tool_scope: ToolScope::default(), tool_scope: ToolScope::default(),
declared_function_names: Default::default(), declared_function_names: Default::default(),
node_job_scope: None, node_job_scope: None,
@@ -437,6 +439,7 @@ impl RequestContext {
agent: None, agent: None,
last_message: None, last_message: None,
last_token_usage: None, last_token_usage: None,
last_cost: None,
tool_scope: ToolScope { tool_scope: ToolScope {
functions, functions,
mcp_runtime, mcp_runtime,
@@ -499,6 +502,7 @@ impl RequestContext {
agent: self.agent.clone(), agent: self.agent.clone(),
last_message: self.last_message.clone(), last_message: self.last_message.clone(),
last_token_usage: self.last_token_usage.clone(), last_token_usage: self.last_token_usage.clone(),
last_cost: self.last_cost,
tool_scope: self.tool_scope.clone(), tool_scope: self.tool_scope.clone(),
declared_function_names: self.declared_function_names.clone(), declared_function_names: self.declared_function_names.clone(),
node_job_scope: None, node_job_scope: None,
@@ -549,6 +553,7 @@ impl RequestContext {
agent: None, agent: None,
last_message: None, last_message: None,
last_token_usage: None, last_token_usage: None,
last_cost: None,
tool_scope: ToolScope { tool_scope: ToolScope {
functions: Functions::default(), functions: Functions::default(),
mcp_runtime: McpRuntime::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 { if let Some(usage) = &usage {
debug!("token-usage: {usage:?}"); debug!("token-usage: {usage:?}");
if let Some(session) = self.session.as_mut() { if let Some(session) = self.session.as_mut() {
session.accumulate_token_usage(usage); session.accumulate_token_usage(usage);
if let Some(cost) = cost {
session.accumulate_cost(cost);
}
} }
} }
self.last_token_usage = usage; self.last_token_usage = usage;
self.last_cost = cost;
} }
pub fn generate_prompt_context(&self, app: &AppConfig) -> HashMap<&str, String> { 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 { if let Some(value) = usage.cache_creation_input_tokens {
output.insert("last_cache_creation_tokens", value.to_string()); 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 { if let Some(session) = &self.session {
output.insert("session", session.name().to_string()); output.insert("session", session.name().to_string());
@@ -2335,6 +2351,10 @@ impl RequestContext {
if let Some(value) = usage.cache_creation_input_tokens { if let Some(value) = usage.cache_creation_input_tokens {
output.insert("total_cache_creation_tokens", value.to_string()); 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 { if let Some(rag) = &self.rag {
output.insert("rag", rag.name().to_string()); output.insert("rag", rag.name().to_string());
@@ -5343,6 +5363,7 @@ fn fork_base_name(name: &str) -> &str {
mod tests { mod tests {
use super::super::mcp_factory::McpFactory; use super::super::mcp_factory::McpFactory;
use super::*; use super::*;
use crate::client::ModelData;
use crate::config::AppState; use crate::config::AppState;
use crate::config::agent::AgentConfig; use crate::config::agent::AgentConfig;
use crate::config::bundles::BundleStore; use crate::config::bundles::BundleStore;
@@ -5460,22 +5481,37 @@ mod tests {
RequestContext::new(default_app_state(), WorkingMode::Cmd) 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] #[test]
fn generate_prompt_context_emits_token_usage_vars() { fn generate_prompt_context_emits_token_usage_vars() {
let mut ctx = create_test_ctx(); let mut ctx = create_test_ctx();
ctx.session = Some(Session::default()); ctx.session = Some(Session::default());
ctx.record_token_usage(Some(TokenUsage { ctx.record_token_usage(
input_tokens: Some(120), Some(TokenUsage {
output_tokens: Some(8), input_tokens: Some(120),
cache_creation_input_tokens: None, output_tokens: Some(8),
cache_read_input_tokens: Some(100), cache_creation_input_tokens: None,
})); cache_read_input_tokens: Some(100),
ctx.record_token_usage(Some(TokenUsage { }),
input_tokens: Some(30), &priced_model(),
output_tokens: Some(2), );
cache_creation_input_tokens: Some(40), ctx.record_token_usage(
cache_read_input_tokens: None, 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 app = ctx.app.config.clone();
let vars = ctx.generate_prompt_context(&app); let vars = ctx.generate_prompt_context(&app);
@@ -5509,21 +5545,49 @@ mod tests {
vars.get("total_cache_creation_tokens").map(String::as_str), vars.get("total_cache_creation_tokens").map(String::as_str),
Some("40") 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] #[test]
fn generate_prompt_context_omits_token_usage_vars_when_provider_reports_none() { fn generate_prompt_context_omits_token_usage_vars_when_provider_reports_none() {
let mut ctx = create_test_ctx(); let mut ctx = create_test_ctx();
ctx.record_token_usage(Some(TokenUsage { ctx.record_token_usage(
input_tokens: Some(1), Some(TokenUsage {
..Default::default() input_tokens: Some(1),
})); ..Default::default()
ctx.record_token_usage(None); }),
&priced_model(),
);
ctx.record_token_usage(None, &priced_model());
let app = ctx.app.config.clone(); let app = ctx.app.config.clone();
let vars = ctx.generate_prompt_context(&app); let vars = ctx.generate_prompt_context(&app);
assert!(!vars.contains_key("last_input_tokens")); assert!(!vars.contains_key("last_input_tokens"));
assert!(!vars.contains_key("total_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 { fn test_decl(name: &str) -> FunctionDeclaration {
+43
View File
@@ -24,6 +24,10 @@ pub const INTERRUPTED_RESPONSE_TEXT: &str = "[Response interrupted due to error]
/// instead of thrashing every turn. /// instead of thrashing every turn.
const COMPRESSION_MIN_RECLAIM_DIVISOR: usize = 5; const COMPRESSION_MIN_RECLAIM_DIVISOR: usize = 5;
fn cost_is_zero(v: &f64) -> bool {
*v == 0.0
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Session { pub struct Session {
#[serde(rename(serialize = "model", deserialize = "model"))] #[serde(rename(serialize = "model", deserialize = "model"))]
@@ -101,6 +105,8 @@ pub struct Session {
auto_continue_paused: Option<String>, auto_continue_paused: Option<String>,
#[serde(default, skip_serializing_if = "TokenUsage::is_empty")] #[serde(default, skip_serializing_if = "TokenUsage::is_empty")]
token_usage: TokenUsage, token_usage: TokenUsage,
#[serde(default, skip_serializing_if = "cost_is_zero")]
cost: f64,
#[serde(skip)] #[serde(skip)]
model: Model, model: Model,
@@ -276,6 +282,15 @@ impl Session {
self.dirty = true; 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 { pub fn has_user_messages(&self) -> bool {
self.messages.iter().any(|v| v.role.is_user()) 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 { if let Some(value) = self.token_usage.cache_creation_input_tokens {
data["cache_creation_tokens"] = value.into(); 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); data["messages"] = json!(self.messages);
let output = serde_yaml::to_string(&data) let output = serde_yaml::to_string(&data)
@@ -467,6 +485,9 @@ impl Session {
if let Some(value) = self.token_usage.cache_creation_input_tokens { if let Some(value) = self.token_usage.cache_creation_input_tokens {
items.push(("cache_creation_tokens", value.to_string())); 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 let mut lines: Vec<String> = items
.iter() .iter()
@@ -1212,6 +1233,28 @@ mod tests {
assert!(!yaml.contains("token_usage")); 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] #[test]
fn session_export_includes_real_usage_only_when_reported() { fn session_export_includes_real_usage_only_when_reported() {
let empty = Session::default().export().unwrap(); let empty = Session::default().export().unwrap();