| 1 | //! Token/cost introspection and context commands. |
| 2 | |
| 3 | use crate::compaction::estimate_input_tokens_conservative; |
| 4 | use crate::localization::{Locale, MessageId, tr}; |
| 5 | use crate::models::SystemPrompt; |
| 6 | use crate::tui::app::{App, AppAction}; |
| 7 | |
| 8 | use super::CommandResult; |
| 9 | |
| 10 | fn token_count(value: Option<u32>, locale: Locale) -> String { |
| 11 | value.map_or_else( |
| 12 | || tr(locale, MessageId::CmdTokensNotReported).to_string(), |
| 13 | |tokens| tokens.to_string(), |
| 14 | ) |
| 15 | } |
| 16 | |
| 17 | fn active_context_summary(app: &App, locale: Locale) -> String { |
| 18 | let estimated = |
| 19 | estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref()); |
| 20 | let window = crate::route_budget::route_context_window_tokens( |
| 21 | app.api_provider, |
| 22 | app.effective_model_for_budget(), |
| 23 | app.active_route_limits, |
| 24 | ); |
| 25 | let used = estimated.min(window as usize); |
| 26 | let percent = (used as f64 / f64::from(window) * 100.0).clamp(0.0, 100.0); |
| 27 | tr(locale, MessageId::CmdTokensContextWithWindow) |
| 28 | .replace("{used}", &used.to_string()) |
| 29 | .replace("{window}", &window.to_string()) |
| 30 | .replace("{percent}", &format!("{percent:.1}")) |
| 31 | } |
| 32 | |
| 33 | fn cache_summary(app: &App, locale: Locale) -> String { |
| 34 | match ( |
| 35 | app.session.last_prompt_cache_hit_tokens, |
| 36 | app.session.last_prompt_cache_miss_tokens, |
| 37 | ) { |
| 38 | (Some(hit), Some(miss)) => tr(locale, MessageId::CmdTokensCacheBoth) |
| 39 | .replace("{hit}", &hit.to_string()) |
| 40 | .replace("{miss}", &miss.to_string()), |
| 41 | (Some(hit), None) => { |
| 42 | tr(locale, MessageId::CmdTokensCacheHitOnly).replace("{hit}", &hit.to_string()) |
| 43 | } |
| 44 | (None, Some(miss)) => { |
| 45 | tr(locale, MessageId::CmdTokensCacheMissOnly).replace("{miss}", &miss.to_string()) |
| 46 | } |
| 47 | (None, None) => tr(locale, MessageId::CmdTokensNotReported).to_string(), |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// Show token usage for session |
| 52 | pub fn tokens(app: &mut App) -> CommandResult { |
| 53 | let locale = app.ui_locale; |
| 54 | let message_count = app.api_messages.len(); |
| 55 | let chat_count = app.history.len(); |
| 56 | |
| 57 | let mut report = tr(locale, MessageId::CmdTokensReport) |
| 58 | .replace("{active}", &active_context_summary(app, locale)) |
| 59 | .replace( |
| 60 | "{input}", |
| 61 | &token_count(app.session.last_prompt_tokens, locale), |
| 62 | ) |
| 63 | .replace( |
| 64 | "{output}", |
| 65 | &token_count(app.session.last_completion_tokens, locale), |
| 66 | ) |
| 67 | .replace("{cache}", &cache_summary(app, locale)) |
| 68 | .replace("{total}", &app.session.total_tokens.to_string()) |
| 69 | .replace("{cost}", &cost_report_amount(app, locale)) |
| 70 | .replace("{api_messages}", &message_count.to_string()) |
| 71 | .replace("{chat_messages}", &chat_count.to_string()) |
| 72 | .replace("{model}", &app.model); |
| 73 | // `/tokens` quotes the same cost figure as `/cost`, so it carries the same |
| 74 | // estimate disclaimer and the same coverage state. Two surfaces showing one |
| 75 | // number must not disagree about how complete that number is (#4318). |
| 76 | report.push_str(&cache_write_summary(app, locale)); |
| 77 | report.push_str(&cost_coverage_report(app, locale)); |
| 78 | CommandResult::message(report) |
| 79 | } |
| 80 | |
| 81 | /// Session cache-write total, reported as its own class with a pointer to |
| 82 | /// `/cache` for the per-turn breakdown. |
| 83 | /// |
| 84 | /// Cache-write is billed at a premium on the providers that publish one, so it |
| 85 | /// is neither folded into input nor hidden: `/tokens` shows the total and says |
| 86 | /// where the detail lives. |
| 87 | fn cache_write_summary(app: &App, locale: Locale) -> String { |
| 88 | let write = app.session.total_cache_write_tokens; |
| 89 | let mut out = String::from("\n"); |
| 90 | out.push_str(&tr(locale, MessageId::CmdTokensCacheWriteTotal).replace( |
| 91 | "{write}", |
| 92 | &if write > 0 { |
| 93 | write.to_string() |
| 94 | } else { |
| 95 | tr(locale, MessageId::CmdTokensNotReported).to_string() |
| 96 | }, |
| 97 | )); |
| 98 | out |
| 99 | } |
| 100 | |
| 101 | /// Show session cost breakdown. |
| 102 | /// |
| 103 | /// The figure is an **estimate** computed from provider-reported usage and |
| 104 | /// published rates; it is never an invoice. Turns whose route produced no |
| 105 | /// authoritative price are missing from it entirely, so the coverage of the |
| 106 | /// number is reported alongside it rather than left implicit (#4318). |
| 107 | pub fn cost(app: &mut App) -> CommandResult { |
| 108 | let locale = app.ui_locale; |
| 109 | let (priced, unpriced) = cost_coverage_counts(app); |
| 110 | let has_saved_legacy_subtotal = app.session.cost_coverage_unknown_legacy |
| 111 | && app.displayed_session_cost_for_currency(app.cost_currency) > 0.0; |
| 112 | let headline = if priced == 0 && !has_saved_legacy_subtotal { |
| 113 | MessageId::CmdCostReportUnknown |
| 114 | } else if app.session.cost_coverage_unknown_legacy || unpriced > 0 { |
| 115 | MessageId::CmdCostReportSubtotal |
| 116 | } else { |
| 117 | MessageId::CmdCostReport |
| 118 | }; |
| 119 | let mut report = tr(locale, headline).replace("{cost}", &cost_report_amount(app, locale)); |
| 120 | if priced > 0 || has_saved_legacy_subtotal { |
| 121 | report.push_str(&cost_breakdown_report(app)); |
| 122 | } |
| 123 | report.push_str(&cost_coverage_report(app, locale)); |
| 124 | CommandResult::message(report) |
| 125 | } |
| 126 | |
| 127 | fn cost_report_amount(app: &App, locale: Locale) -> String { |
| 128 | let (priced, _) = cost_coverage_counts(app); |
| 129 | let total = app.displayed_session_cost_for_currency(app.cost_currency); |
| 130 | if priced > 0 || (app.session.cost_coverage_unknown_legacy && total > 0.0) { |
| 131 | app.format_cost_amount_precise(total) |
| 132 | } else { |
| 133 | tr(locale, MessageId::CmdCostUnknownValue).to_string() |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /// The `/cost` headline decomposed into the exact terms it is computed from. |
| 138 | /// |
| 139 | /// The headline is `max(parent turns + sub-agents, display high-water)` in the |
| 140 | /// display currency (the #244 monotonic guarantee). Those are its only inputs, |
| 141 | /// so the three components below always sum back to it — asserted by test, so |
| 142 | /// the breakdown can never drift from the number above it (#4939). |
| 143 | struct CostComponents { |
| 144 | /// Accumulated parent-turn spend. |
| 145 | parent_turns: f64, |
| 146 | /// Accumulated sub-agent/background spend. |
| 147 | subagents: f64, |
| 148 | /// Amount by which the monotonic display floor exceeds the live |
| 149 | /// accumulators after a downward reconciliation (#244). Zero whenever the |
| 150 | /// live sum is the headline. |
| 151 | display_floor: f64, |
| 152 | } |
| 153 | |
| 154 | impl CostComponents { |
| 155 | fn compute(app: &App) -> Self { |
| 156 | // Each term is sanitized exactly the way the accumulator fold |
| 157 | // sanitizes it, so `current` here is bitwise the `current` inside |
| 158 | // `displayed_session_cost_for_currency` and the floor is exact. |
| 159 | fn sanitize(amount: f64) -> f64 { |
| 160 | if amount.is_finite() && amount >= 0.0 { |
| 161 | amount |
| 162 | } else { |
| 163 | 0.0 |
| 164 | } |
| 165 | } |
| 166 | let currency = app.cost_display_currency(app.cost_currency); |
| 167 | let parent_turns = sanitize(app.session_cost_for_currency(currency)); |
| 168 | let subagents = sanitize(app.subagent_cost_for_currency(currency)); |
| 169 | let current = { |
| 170 | let sum = parent_turns + subagents; |
| 171 | if sum.is_finite() { sum } else { f64::MAX } |
| 172 | }; |
| 173 | let headline = app.displayed_session_cost_for_currency(app.cost_currency); |
| 174 | Self { |
| 175 | parent_turns, |
| 176 | subagents, |
| 177 | display_floor: (headline - current).max(0.0), |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | /// The recomposed headline. Test-only: production renders the components |
| 182 | /// and the headline from the same state, and the tests assert this sum |
| 183 | /// equals the displayed headline exactly. |
| 184 | #[cfg(test)] |
| 185 | fn sum(&self) -> f64 { |
| 186 | self.parent_turns + self.subagents + self.display_floor |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /// Append the headline decomposition: the accumulator components the headline |
| 191 | /// is computed from, then parent-turn spend attributed per route from the |
| 192 | /// audited turn-telemetry ring. |
| 193 | /// |
| 194 | /// Diagnostic composition detail like `/context report`, so plain English |
| 195 | /// rather than a localized template. |
| 196 | fn cost_breakdown_report(app: &App) -> String { |
| 197 | let components = CostComponents::compute(app); |
| 198 | let mut out = String::from("\n\nBreakdown (components sum to the total above):"); |
| 199 | out.push_str(&format!( |
| 200 | "\n Parent turns: {}", |
| 201 | app.format_cost_amount_precise(components.parent_turns) |
| 202 | )); |
| 203 | if components.subagents > 0.0 { |
| 204 | out.push_str(&format!( |
| 205 | "\n Sub-agents: {}", |
| 206 | app.format_cost_amount_precise(components.subagents) |
| 207 | )); |
| 208 | } |
| 209 | if components.display_floor > 0.0 { |
| 210 | out.push_str(&format!( |
| 211 | "\n Reconciliation floor: {} (monotonic display guarantee, kept after a downward cost reconciliation)", |
| 212 | app.format_cost_amount_precise(components.display_floor) |
| 213 | )); |
| 214 | } |
| 215 | |
| 216 | // Per-route attribution from the per-turn audits that fed the total. The |
| 217 | // telemetry ring is bounded, so coverage is stated instead of implied: |
| 218 | // itemized turns out of all priced turns, never a claim of completeness. |
| 219 | let currency = app.cost_display_currency(app.cost_currency); |
| 220 | let mut by_route: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new(); |
| 221 | let mut itemized: u32 = 0; |
| 222 | for record in &app.session.turn_cache_history { |
| 223 | let Some(audit) = record.cost_audit.as_ref() else { |
| 224 | continue; |
| 225 | }; |
| 226 | if !audit.is_priced_in(currency) { |
| 227 | continue; |
| 228 | } |
| 229 | let Some(estimate) = audit.estimate else { |
| 230 | continue; |
| 231 | }; |
| 232 | let provider = record.provider_identity.clone().unwrap_or_else(|| { |
| 233 | record.provider.map_or_else( |
| 234 | || "unknown-provider".to_string(), |
| 235 | |p| p.as_str().to_string(), |
| 236 | ) |
| 237 | }); |
| 238 | let model = record.model.as_deref().unwrap_or("unknown-model"); |
| 239 | *by_route.entry(format!("{provider}/{model}")).or_insert(0.0) += estimate.amount(currency); |
| 240 | itemized = itemized.saturating_add(1); |
| 241 | } |
| 242 | if !by_route.is_empty() { |
| 243 | let (priced, _) = cost_coverage_counts(app); |
| 244 | out.push_str(&format!( |
| 245 | "\n Parent-turn spend by route ({itemized} of {priced} priced turns itemized):" |
| 246 | )); |
| 247 | for (route, amount) in &by_route { |
| 248 | out.push_str(&format!( |
| 249 | "\n {route}: {}", |
| 250 | app.format_cost_amount_precise(*amount) |
| 251 | )); |
| 252 | } |
| 253 | if itemized < priced { |
| 254 | out.push_str(&format!( |
| 255 | "\n (earlier turns not itemized: turn telemetry keeps the last {})", |
| 256 | App::TURN_CACHE_HISTORY_CAP |
| 257 | )); |
| 258 | } |
| 259 | } |
| 260 | out |
| 261 | } |
| 262 | |
| 263 | fn joined(values: &std::collections::BTreeSet<String>) -> String { |
| 264 | values |
| 265 | .iter() |
| 266 | .map(String::as_str) |
| 267 | .collect::<Vec<_>>() |
| 268 | .join(", ") |
| 269 | } |
| 270 | |
| 271 | /// The honesty block appended to `/cost` and `/tokens`: what the estimate covers |
| 272 | /// and what it cannot. |
| 273 | /// |
| 274 | /// Both surfaces render the same block from the same session counters, so they |
| 275 | /// cannot disagree about completeness (#4318). |
| 276 | pub(crate) fn cost_coverage_report(app: &App, locale: Locale) -> String { |
| 277 | let (priced, unpriced) = cost_coverage_counts(app); |
| 278 | let mut out = String::from("\n\n"); |
| 279 | out.push_str(&tr(locale, MessageId::CmdCostEstimateOnly)); |
| 280 | out.push('\n'); |
| 281 | if app.session.cost_coverage_unknown_legacy { |
| 282 | // A restored pre-coverage session has real money and no evidence of what |
| 283 | // it covers. Saying "0 of 0 priced" here would assert the total is |
| 284 | // complete, so the unknown state is stated instead. |
| 285 | out.push_str(&tr(locale, MessageId::CmdCostCoverageUnknownLegacy)); |
| 286 | } else { |
| 287 | out.push_str( |
| 288 | &tr(locale, MessageId::CmdCostCoverage) |
| 289 | .replace("{priced}", &priced.to_string()) |
| 290 | .replace("{turns}", &(priced.saturating_add(unpriced)).to_string()), |
| 291 | ); |
| 292 | } |
| 293 | if unpriced > 0 { |
| 294 | let reasons = match app.cost_display_currency(app.cost_currency) { |
| 295 | crate::pricing::CostCurrency::Usd => &app.session.cost_unpriced_reasons, |
| 296 | crate::pricing::CostCurrency::Cny => &app.session.cost_cny_unpriced_reasons, |
| 297 | }; |
| 298 | out.push('\n'); |
| 299 | out.push_str( |
| 300 | &tr(locale, MessageId::CmdCostUnpricedTurns) |
| 301 | .replace("{unpriced}", &unpriced.to_string()) |
| 302 | .replace("{reasons}", &joined(reasons)), |
| 303 | ); |
| 304 | } |
| 305 | if !app.session.cost_unpriced_classes.is_empty() { |
| 306 | out.push('\n'); |
| 307 | out.push_str( |
| 308 | &tr(locale, MessageId::CmdCostUnpricedClasses) |
| 309 | .replace("{classes}", &joined(&app.session.cost_unpriced_classes)), |
| 310 | ); |
| 311 | } |
| 312 | if !app.session.cost_pricing_provenances.is_empty() { |
| 313 | out.push('\n'); |
| 314 | out.push_str( |
| 315 | &tr(locale, MessageId::CmdCostPricingProvenance) |
| 316 | .replace("{sources}", &joined(&app.session.cost_pricing_provenances)), |
| 317 | ); |
| 318 | } |
| 319 | if !app.session.cost_live_pricing_defects.is_empty() { |
| 320 | out.push('\n'); |
| 321 | out.push_str( |
| 322 | &tr(locale, MessageId::CmdCostLivePricingDowngraded) |
| 323 | .replace("{defects}", &joined(&app.session.cost_live_pricing_defects)), |
| 324 | ); |
| 325 | } |
| 326 | if !app.session.cost_live_pricing_unusable_defects.is_empty() { |
| 327 | out.push('\n'); |
| 328 | out.push_str( |
| 329 | &tr(locale, MessageId::CmdCostLivePricingUnavailable).replace( |
| 330 | "{defects}", |
| 331 | &joined(&app.session.cost_live_pricing_unusable_defects), |
| 332 | ), |
| 333 | ); |
| 334 | } |
| 335 | if !app.session.cost_route_receipts.is_empty() { |
| 336 | out.push('\n'); |
| 337 | out.push_str(&tr(locale, MessageId::CmdCostRoutesHeader)); |
| 338 | for receipt in &app.session.cost_route_receipts { |
| 339 | out.push_str("\n "); |
| 340 | out.push_str(receipt); |
| 341 | } |
| 342 | } |
| 343 | out |
| 344 | } |
| 345 | |
| 346 | fn cost_coverage_counts(app: &App) -> (u32, u32) { |
| 347 | match app.cost_display_currency(app.cost_currency) { |
| 348 | crate::pricing::CostCurrency::Usd => ( |
| 349 | app.session.cost_priced_turns, |
| 350 | app.session.cost_unpriced_turns, |
| 351 | ), |
| 352 | crate::pricing::CostCurrency::Cny => ( |
| 353 | app.session.cost_cny_priced_turns, |
| 354 | app.session.cost_cny_unpriced_turns, |
| 355 | ), |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | /// Show current system prompt |
| 360 | pub fn system_prompt(app: &mut App) -> CommandResult { |
| 361 | let prompt_text = match &app.system_prompt { |
| 362 | Some(SystemPrompt::Text(text)) => text.clone(), |
| 363 | Some(SystemPrompt::Blocks(blocks)) => blocks |
| 364 | .iter() |
| 365 | .map(|b| b.text.clone()) |
| 366 | .collect::<Vec<_>>() |
| 367 | .join("\n\n---\n\n"), |
| 368 | None => "(no system prompt)".to_string(), |
| 369 | }; |
| 370 | |
| 371 | // Truncate if too long |
| 372 | let display = if prompt_text.len() > 500 { |
| 373 | // Find a valid UTF-8 char boundary at or before byte 500 |
| 374 | let truncate_at = prompt_text |
| 375 | .char_indices() |
| 376 | .take_while(|(i, _)| *i <= 500) |
| 377 | .last() |
| 378 | .map_or(0, |(i, _)| i); |
| 379 | format!( |
| 380 | "{}...\n\n(truncated, {} chars total)", |
| 381 | &prompt_text[..truncate_at], |
| 382 | prompt_text.len() |
| 383 | ) |
| 384 | } else { |
| 385 | prompt_text |
| 386 | }; |
| 387 | |
| 388 | CommandResult::message(format!( |
| 389 | "System Prompt ({} mode):\n─────────────────────────────\n{}", |
| 390 | app.mode.label(), |
| 391 | display |
| 392 | )) |
| 393 | } |
| 394 | |
| 395 | /// Show context window usage. |
| 396 | /// |
| 397 | /// `/context` keeps opening the interactive inspector. `/context report`, |
| 398 | /// `/context json`, `/context prompt-json`, and `/context summary` expose the diagnostic source map |
| 399 | /// from #3143 without replacing the inspector surface. |
| 400 | pub fn context(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 401 | let Some(subcommand) = arg.map(str::trim).filter(|arg| !arg.is_empty()) else { |
| 402 | return CommandResult::action(AppAction::OpenContextInspector); |
| 403 | }; |
| 404 | |
| 405 | match subcommand { |
| 406 | "prompt-json" | "prompt_json" | "prompt" => { |
| 407 | let context = crate::context_report::build_prompt_context(app); |
| 408 | CommandResult::message(crate::context_report::prompt_context_json(&context)) |
| 409 | } |
| 410 | "report" | "json" | "summary" => { |
| 411 | let report = crate::context_report::build_context_report(app); |
| 412 | match subcommand { |
| 413 | "report" => { |
| 414 | CommandResult::message(crate::context_report::format_context_report(&report)) |
| 415 | } |
| 416 | "json" => { |
| 417 | CommandResult::message(crate::context_report::context_report_json(&report)) |
| 418 | } |
| 419 | "summary" => { |
| 420 | CommandResult::message(crate::context_report::format_context_summary(&report)) |
| 421 | } |
| 422 | _ => unreachable!(), |
| 423 | } |
| 424 | } |
| 425 | other => CommandResult::error(format!( |
| 426 | "Unknown /context subcommand: {other}. Use report, json, prompt-json, or summary." |
| 427 | )), |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | #[cfg(test)] |
| 432 | mod cost_breakdown_tests { |
| 433 | use super::*; |
| 434 | use crate::config::Config; |
| 435 | use crate::pricing::{CostCurrency, CostEstimate, TurnCostAudit}; |
| 436 | use crate::tui::app::{TuiOptions, TurnCacheRecord}; |
| 437 | use std::path::PathBuf; |
| 438 | use std::time::Instant; |
| 439 | |
| 440 | fn test_app() -> App { |
| 441 | let options = TuiOptions { |
| 442 | skills_dir: PathBuf::from("/tmp/test-skills"), |
| 443 | ..crate::test_support::test_tui_options(PathBuf::from("/tmp/test-workspace")) |
| 444 | }; |
| 445 | let mut app = App::new(options, &Config::default()); |
| 446 | app.ui_locale = crate::localization::Locale::En; |
| 447 | app.cost_currency = CostCurrency::Usd; |
| 448 | app.api_provider = crate::config::ApiProvider::Deepseek; |
| 449 | app |
| 450 | } |
| 451 | |
| 452 | fn priced_audit(estimate: CostEstimate) -> TurnCostAudit { |
| 453 | TurnCostAudit { |
| 454 | estimate: Some(estimate), |
| 455 | provenance: None, |
| 456 | unpriced_classes: Vec::new(), |
| 457 | unpriced_reason: None, |
| 458 | live_pricing_defect: None, |
| 459 | usd_priced: true, |
| 460 | cny_priced: estimate.cny > 0.0, |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | fn turn_record(model: &str, audit: TurnCostAudit) -> TurnCacheRecord { |
| 465 | TurnCacheRecord { |
| 466 | provider: Some(crate::config::ApiProvider::Deepseek), |
| 467 | provider_identity: None, |
| 468 | model: Some(model.to_string()), |
| 469 | auto_model: false, |
| 470 | input_tokens: 100, |
| 471 | output_tokens: 10, |
| 472 | cache_hit_tokens: None, |
| 473 | cache_miss_tokens: None, |
| 474 | cache_write_tokens: None, |
| 475 | reasoning_tokens: None, |
| 476 | cost_audit: Some(audit), |
| 477 | reasoning_replay_tokens: None, |
| 478 | recorded_at: Instant::now(), |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | /// The decomposition's terms are exactly the headline's inputs, so their |
| 483 | /// sum reproduces the headline — including when the #244 monotonic floor, |
| 484 | /// not the live accumulators, is the number on display (#4939). |
| 485 | #[test] |
| 486 | fn cost_breakdown_components_sum_to_headline() { |
| 487 | let mut app = test_app(); |
| 488 | app.session.cost_priced_turns = 2; |
| 489 | app.accrue_session_cost_estimate(CostEstimate { |
| 490 | usd: 0.05, |
| 491 | cny: 0.0, |
| 492 | }); |
| 493 | app.accrue_subagent_cost_estimate(CostEstimate { |
| 494 | usd: 0.02, |
| 495 | cny: 0.0, |
| 496 | }); |
| 497 | |
| 498 | // Live sum is the headline: no floor component. |
| 499 | let components = CostComponents::compute(&app); |
| 500 | assert_eq!(components.parent_turns, 0.05); |
| 501 | assert_eq!(components.subagents, 0.02); |
| 502 | assert_eq!(components.display_floor, 0.0); |
| 503 | assert_eq!( |
| 504 | components.sum(), |
| 505 | app.displayed_session_cost_for_currency(CostCurrency::Usd), |
| 506 | "components must sum to the /cost headline" |
| 507 | ); |
| 508 | |
| 509 | // After a downward reconciliation the high-water is the headline; the |
| 510 | // difference surfaces as an explicit floor component, and the sum still |
| 511 | // reproduces the headline exactly. |
| 512 | app.session.displayed_cost_high_water = 0.10; |
| 513 | let components = CostComponents::compute(&app); |
| 514 | assert!(components.display_floor > 0.0); |
| 515 | assert_eq!( |
| 516 | components.sum(), |
| 517 | app.displayed_session_cost_for_currency(CostCurrency::Usd), |
| 518 | "floor component must absorb exactly the high-water excess" |
| 519 | ); |
| 520 | |
| 521 | let msg = cost(&mut app).message.expect("cost report"); |
| 522 | assert!(msg.contains("Breakdown"), "{msg}"); |
| 523 | assert!(msg.contains("Parent turns: $0.0500"), "{msg}"); |
| 524 | assert!(msg.contains("Sub-agents: $0.0200"), "{msg}"); |
| 525 | assert!(msg.contains("Reconciliation floor:"), "{msg}"); |
| 526 | } |
| 527 | |
| 528 | /// Per-route attribution comes from the same `TurnCostAudit`s that fed the |
| 529 | /// total, in the display currency; with every priced turn itemized, the |
| 530 | /// route amounts account for the whole parent component. CNY amounts are |
| 531 | /// the audits' provider-published CNY figures — never an FX projection of |
| 532 | /// the USD column (#4939). |
| 533 | #[test] |
| 534 | fn cost_breakdown_itemizes_routes_from_turn_audits() { |
| 535 | let mut app = test_app(); |
| 536 | app.cost_currency = CostCurrency::Cny; |
| 537 | let turns = [ |
| 538 | CostEstimate { |
| 539 | usd: 0.01, |
| 540 | cny: 0.07, |
| 541 | }, |
| 542 | CostEstimate { |
| 543 | usd: 0.02, |
| 544 | cny: 0.14, |
| 545 | }, |
| 546 | ]; |
| 547 | for estimate in turns { |
| 548 | let audit = priced_audit(estimate); |
| 549 | app.record_turn_cost_audit(&audit); |
| 550 | app.accrue_session_cost_estimate(estimate); |
| 551 | app.push_turn_cache_record(turn_record("deepseek-chat", audit)); |
| 552 | } |
| 553 | |
| 554 | let components = CostComponents::compute(&app); |
| 555 | assert_eq!( |
| 556 | components.sum(), |
| 557 | app.displayed_session_cost_for_currency(CostCurrency::Cny), |
| 558 | "CNY components must sum to the CNY headline" |
| 559 | ); |
| 560 | |
| 561 | let msg = cost(&mut app).message.expect("cost report"); |
| 562 | assert!( |
| 563 | msg.contains("Parent-turn spend by route (2 of 2 priced turns itemized):"), |
| 564 | "{msg}" |
| 565 | ); |
| 566 | // 0.07 + 0.14 accumulated in ring order equals the parent component's |
| 567 | // accumulation, so the route line shows the whole parent spend. |
| 568 | let route_amount = app.format_cost_amount_precise(components.parent_turns); |
| 569 | assert!( |
| 570 | msg.contains(&format!("deepseek/deepseek-chat: {route_amount}")), |
| 571 | "{msg}" |
| 572 | ); |
| 573 | assert!(msg.contains("¥"), "CNY display must use CNY symbol: {msg}"); |
| 574 | } |
| 575 | |
| 576 | /// An unpriced headline renders no breakdown: decomposing a number that is |
| 577 | /// not being shown would fabricate amounts the report just declined to |
| 578 | /// claim. |
| 579 | #[test] |
| 580 | fn cost_breakdown_absent_when_headline_unknown() { |
| 581 | let mut app = test_app(); |
| 582 | let msg = cost(&mut app).message.expect("cost report"); |
| 583 | assert!(!msg.contains("Breakdown"), "{msg}"); |
| 584 | assert!(!msg.contains("Parent turns:"), "{msg}"); |
| 585 | } |
| 586 | } |
| 587 |