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
@@ -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
@@ -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
@@ -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"}});
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user