返回 CodeWhale
tokens.rs
根目录 / crates / tui / src / commands / groups / debug / tokens.rs
1 //! Token/cost introspection and context commands.
2
3 use crate::compaction::estimate_input_tokens_conservative;
4 use crate::tui::app::{App, AppAction};
5 use codewhale_localization::{Locale, MessageId, tr};
6 use codewhale_models::SystemPrompt;
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.displayed_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.displayed_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 /// the recorded pricing sources; 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 = if has_declared_estimates(app) {
120 // Like the diagnostic breakdown below, state the actual accounting
121 // basis without the legacy templates' published-rate assertion.
122 format!(
123 "Session cost estimate (priced subtotal): {}",
124 cost_report_amount(app, locale)
125 )
126 } else {
127 tr(locale, headline).replace("{cost}", &cost_report_amount(app, locale))
128 };
129 if priced > 0 || has_saved_legacy_subtotal {
130 report.push_str(&cost_breakdown_report(app));
131 }
132 report.push_str(&cost_coverage_report(app, locale));
133 CommandResult::message(report)
134 }
135
136 fn cost_report_amount(app: &App, locale: Locale) -> String {
137 let (priced, _) = cost_coverage_counts(app);
138 let total = app.displayed_session_cost_for_currency(app.cost_currency);
139 if priced > 0 || (app.session.cost_coverage_unknown_legacy && total > 0.0) {
140 app.format_cost_amount_precise(total)
141 } else {
142 tr(locale, MessageId::CmdCostUnknownValue).to_string()
143 }
144 }
145
146 /// The `/cost` headline decomposed into the exact terms it is computed from.
147 ///
148 /// The headline is `max(parent turns + sub-agents, display high-water)` in the
149 /// display currency (the #244 monotonic guarantee). Those are its only inputs,
150 /// so the three components below always sum back to it — asserted by test, so
151 /// the breakdown can never drift from the number above it (#4939).
152 struct CostComponents {
153 /// Accumulated parent-turn spend.
154 parent_turns: f64,
155 /// Accumulated sub-agent/background spend.
156 subagents: f64,
157 /// Amount by which the monotonic display floor exceeds the live
158 /// accumulators after a downward reconciliation (#244). Zero whenever the
159 /// live sum is the headline.
160 display_floor: f64,
161 }
162
163 impl CostComponents {
164 fn compute(app: &App) -> Self {
165 // Each term is sanitized exactly the way the accumulator fold
166 // sanitizes it, so `current` here is bitwise the `current` inside
167 // `displayed_session_cost_for_currency` and the floor is exact.
168 fn sanitize(amount: f64) -> f64 {
169 if amount.is_finite() && amount >= 0.0 {
170 amount
171 } else {
172 0.0
173 }
174 }
175 let currency = app.cost_display_currency(app.cost_currency);
176 let parent_turns = sanitize(app.session_cost_for_currency(currency));
177 let subagents = sanitize(app.subagent_cost_for_currency(currency));
178 let current = {
179 let sum = parent_turns + subagents;
180 if sum.is_finite() { sum } else { f64::MAX }
181 };
182 let headline = app.displayed_session_cost_for_currency(app.cost_currency);
183 Self {
184 parent_turns,
185 subagents,
186 display_floor: (headline - current).max(0.0),
187 }
188 }
189
190 /// The recomposed headline. Test-only: production renders the components
191 /// and the headline from the same state, and the tests assert this sum
192 /// equals the displayed headline exactly.
193 #[cfg(test)]
194 fn sum(&self) -> f64 {
195 self.parent_turns + self.subagents + self.display_floor
196 }
197 }
198
199 /// Append the headline decomposition: the accumulator components the headline
200 /// is computed from, then parent-turn spend attributed per route from the
201 /// audited turn-telemetry ring.
202 ///
203 /// Diagnostic composition detail like `/context report`, so plain English
204 /// rather than a localized template.
205 fn cost_breakdown_report(app: &App) -> String {
206 let components = CostComponents::compute(app);
207 let mut out = String::from("\n\nBreakdown (components sum to the total above):");
208 out.push_str(&format!(
209 "\n Parent turns: {}",
210 app.format_cost_amount_precise(components.parent_turns)
211 ));
212 if components.subagents > 0.0 {
213 out.push_str(&format!(
214 "\n Sub-agents: {}",
215 app.format_cost_amount_precise(components.subagents)
216 ));
217 }
218 if components.display_floor > 0.0 {
219 out.push_str(&format!(
220 "\n Reconciliation floor: {} (monotonic display guarantee, kept after a downward cost reconciliation)",
221 app.format_cost_amount_precise(components.display_floor)
222 ));
223 }
224
225 // Per-route attribution from the per-turn audits that fed the total. The
226 // telemetry ring is bounded, so coverage is stated instead of implied:
227 // itemized turns out of all priced turns, never a claim of completeness.
228 let currency = app.cost_display_currency(app.cost_currency);
229 let mut by_route: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();
230 let mut itemized: u32 = 0;
231 for record in &app.session.turn_cache_history {
232 let Some(audit) = record.cost_audit.as_ref() else {
233 continue;
234 };
235 if !audit.is_priced_in(currency) {
236 continue;
237 }
238 let Some(estimate) = audit.estimate else {
239 continue;
240 };
241 let provider = record.provider_identity.clone().unwrap_or_else(|| {
242 record.provider.map_or_else(
243 || "unknown-provider".to_string(),
244 |p| p.as_str().to_string(),
245 )
246 });
247 let model = record.model.as_deref().unwrap_or("unknown-model");
248 *by_route.entry(format!("{provider}/{model}")).or_insert(0.0) += estimate.amount(currency);
249 itemized = itemized.saturating_add(1);
250 }
251 if !by_route.is_empty() {
252 let (priced, _) = cost_coverage_counts(app);
253 out.push_str(&format!(
254 "\n Parent-turn spend by route ({itemized} of {priced} priced turns itemized):"
255 ));
256 for (route, amount) in &by_route {
257 out.push_str(&format!(
258 "\n {route}: {}",
259 app.format_cost_amount_precise(*amount)
260 ));
261 }
262 if itemized < priced {
263 out.push_str(&format!(
264 "\n (earlier turns not itemized: turn telemetry keeps the last {})",
265 App::TURN_CACHE_HISTORY_CAP
266 ));
267 }
268 }
269 out
270 }
271
272 fn joined(values: &std::collections::BTreeSet<String>) -> String {
273 values
274 .iter()
275 .map(String::as_str)
276 .collect::<Vec<_>>()
277 .join(", ")
278 }
279
280 fn has_declared_estimates(app: &App) -> bool {
281 app.session
282 .cost_pricing_provenances
283 .contains("user_override")
284 }
285
286 /// The honesty block appended to `/cost` and `/tokens`: what the estimate covers
287 /// and what it cannot.
288 ///
289 /// Both surfaces render the same block from the same session counters, so they
290 /// cannot disagree about completeness (#4318).
291 pub(crate) fn cost_coverage_report(app: &App, locale: Locale) -> String {
292 let (priced, unpriced) = cost_coverage_counts(app);
293 let mut out = String::from("\n\n");
294 let declared = has_declared_estimates(app);
295 if declared {
296 out.push_str("Includes user-declared, unverified price estimates calculated from recorded usage. These amounts do not establish provider prices, billing mode, or an invoice.");
297 } else {
298 out.push_str(&tr(locale, MessageId::CmdCostEstimateOnly));
299 }
300 out.push('\n');
301 if app.session.cost_coverage_unknown_legacy {
302 // A restored pre-coverage session has real money and no evidence of what
303 // it covers. Saying "0 of 0 priced" here would assert the total is
304 // complete, so the unknown state is stated instead.
305 out.push_str(&tr(locale, MessageId::CmdCostCoverageUnknownLegacy));
306 } else if declared {
307 out.push_str(&format!(
308 "Coverage: {priced} of {} tracked turns priced or estimated.",
309 priced.saturating_add(unpriced)
310 ));
311 } else {
312 out.push_str(
313 &tr(locale, MessageId::CmdCostCoverage)
314 .replace("{priced}", &priced.to_string())
315 .replace("{turns}", &(priced.saturating_add(unpriced)).to_string()),
316 );
317 }
318 if unpriced > 0 {
319 let reasons = match app.cost_display_currency(app.cost_currency) {
320 crate::pricing::CostCurrency::Usd => &app.session.cost_unpriced_reasons,
321 crate::pricing::CostCurrency::Cny => &app.session.cost_cny_unpriced_reasons,
322 };
323 out.push('\n');
324 let excluded = if declared {
325 "Excluded: {unpriced} turns have incomplete prices ({reasons}); their cost is unknown."
326 .to_string()
327 } else {
328 tr(locale, MessageId::CmdCostUnpricedTurns).to_string()
329 };
330 out.push_str(
331 &excluded
332 .replace("{unpriced}", &unpriced.to_string())
333 .replace(
334 "{reasons}",
335 &crate::route_billing::format_unpriced_reasons(
336 &reasons
337 .iter()
338 .map(|reason| crate::pricing::UnpricedReason::from_label(reason))
339 .collect::<Vec<_>>(),
340 locale,
341 ),
342 ),
343 );
344 }
345 if !app.session.cost_unpriced_classes.is_empty() {
346 out.push('\n');
347 out.push_str(
348 &tr(locale, MessageId::CmdCostUnpricedClasses)
349 .replace("{classes}", &joined(&app.session.cost_unpriced_classes)),
350 );
351 }
352 if !app.session.cost_pricing_provenances.is_empty() {
353 out.push('\n');
354 out.push_str(
355 &tr(locale, MessageId::CmdCostPricingProvenance)
356 .replace("{sources}", &joined(&app.session.cost_pricing_provenances)),
357 );
358 }
359 if !app.session.cost_live_pricing_defects.is_empty() {
360 out.push('\n');
361 out.push_str(
362 &tr(locale, MessageId::CmdCostLivePricingDowngraded)
363 .replace("{defects}", &joined(&app.session.cost_live_pricing_defects)),
364 );
365 }
366 if !app.session.cost_live_pricing_unusable_defects.is_empty() {
367 out.push('\n');
368 out.push_str(
369 &tr(locale, MessageId::CmdCostLivePricingUnavailable).replace(
370 "{defects}",
371 &joined(&app.session.cost_live_pricing_unusable_defects),
372 ),
373 );
374 }
375 if !app.session.cost_route_receipts.is_empty() {
376 out.push('\n');
377 out.push_str(&tr(locale, MessageId::CmdCostRoutesHeader));
378 for receipt in &app.session.cost_route_receipts {
379 out.push_str("\n ");
380 out.push_str(receipt);
381 }
382 }
383 out
384 }
385
386 fn cost_coverage_counts(app: &App) -> (u32, u32) {
387 match app.cost_display_currency(app.cost_currency) {
388 crate::pricing::CostCurrency::Usd => (
389 app.session.cost_priced_turns,
390 app.session.cost_unpriced_turns,
391 ),
392 crate::pricing::CostCurrency::Cny => (
393 app.session.cost_cny_priced_turns,
394 app.session.cost_cny_unpriced_turns,
395 ),
396 }
397 }
398
399 /// Show current system prompt
400 pub fn system_prompt(app: &mut App) -> CommandResult {
401 let prompt_text = match &app.system_prompt {
402 Some(SystemPrompt::Text(text)) => text.clone(),
403 Some(SystemPrompt::Blocks(blocks)) => blocks
404 .iter()
405 .map(|b| b.text.clone())
406 .collect::<Vec<_>>()
407 .join("\n\n---\n\n"),
408 None => "(no system prompt)".to_string(),
409 };
410
411 // Truncate if too long
412 let display = if prompt_text.len() > 500 {
413 // Find a valid UTF-8 char boundary at or before byte 500
414 let truncate_at = prompt_text
415 .char_indices()
416 .take_while(|(i, _)| *i <= 500)
417 .last()
418 .map_or(0, |(i, _)| i);
419 format!(
420 "{}...\n\n(truncated, {} chars total)",
421 &prompt_text[..truncate_at],
422 prompt_text.len()
423 )
424 } else {
425 prompt_text
426 };
427
428 CommandResult::message(format!(
429 "System Prompt ({} mode):\n─────────────────────────────\n{}",
430 app.mode.label(),
431 display
432 ))
433 }
434
435 /// Show context window usage.
436 ///
437 /// `/context` keeps opening the interactive inspector. `/context report`,
438 /// `/context json`, `/context prompt-json`, and `/context summary` expose the diagnostic source map
439 /// from #3143 without replacing the inspector surface.
440 pub fn context(app: &mut App, arg: Option<&str>) -> CommandResult {
441 let Some(subcommand) = arg.map(str::trim).filter(|arg| !arg.is_empty()) else {
442 return CommandResult::action(AppAction::OpenContextInspector);
443 };
444
445 match subcommand {
446 "prompt-json" | "prompt_json" | "prompt" => {
447 let context = crate::context_report::build_prompt_context(app);
448 CommandResult::message(crate::context_report::prompt_context_json(&context))
449 }
450 "report" | "json" | "summary" => {
451 let report = crate::context_report::build_context_report(app);
452 match subcommand {
453 "report" => {
454 CommandResult::message(crate::context_report::format_context_report(&report))
455 }
456 "json" => {
457 CommandResult::message(crate::context_report::context_report_json(&report))
458 }
459 "summary" => {
460 CommandResult::message(crate::context_report::format_context_summary(&report))
461 }
462 _ => unreachable!(),
463 }
464 }
465 other => CommandResult::error(format!(
466 "Unknown /context subcommand: {other}. Use report, json, prompt-json, or summary."
467 )),
468 }
469 }
470
471 #[cfg(test)]
472 mod cost_breakdown_tests {
473 use super::*;
474 use crate::config::Config;
475 use crate::pricing::{CostCurrency, CostEstimate, TurnCostAudit};
476 use crate::tui::app::{TuiOptions, TurnCacheRecord};
477 use std::path::PathBuf;
478 use std::time::Instant;
479
480 fn test_app() -> App {
481 let options = TuiOptions {
482 skills_dir: PathBuf::from("/tmp/test-skills"),
483 ..crate::test_support::test_tui_options(PathBuf::from("/tmp/test-workspace"))
484 };
485 let mut app = App::new(options, &Config::default());
486 app.ui_locale = codewhale_localization::Locale::En;
487 app.cost_currency = CostCurrency::Usd;
488 app.api_provider = crate::config::ApiProvider::Deepseek;
489 app
490 }
491
492 fn priced_audit(estimate: CostEstimate) -> TurnCostAudit {
493 TurnCostAudit {
494 estimate: Some(estimate),
495 provenance: None,
496 unpriced_classes: Vec::new(),
497 unpriced_reason: None,
498 live_pricing_defect: None,
499 usd_priced: true,
500 cny_priced: estimate.cny > 0.0,
501 }
502 }
503
504 fn turn_record(model: &str, audit: TurnCostAudit) -> TurnCacheRecord {
505 TurnCacheRecord {
506 provider: Some(crate::config::ApiProvider::Deepseek),
507 provider_identity: None,
508 model: Some(model.to_string()),
509 auto_model: false,
510 input_tokens: 100,
511 output_tokens: 10,
512 cache_hit_tokens: None,
513 cache_miss_tokens: None,
514 cache_write_tokens: None,
515 reasoning_tokens: None,
516 cost_audit: Some(audit),
517 reasoning_replay_tokens: None,
518 recorded_at: Instant::now(),
519 }
520 }
521
522 #[test]
523 fn configured_model_cost_copy_does_not_claim_published_rates() {
524 let _env = crate::test_support::lock_test_env();
525 let mut app = test_app();
526 app.session
527 .cost_pricing_provenances
528 .insert("user_override".into());
529 app.session.cost_priced_turns = 1;
530 let report = cost_coverage_report(&app, Locale::En);
531 assert!(report.contains("user-declared, unverified price estimates"));
532 assert!(!report.contains("published rates"));
533 assert!(!report.contains("money-metered"));
534 }
535
536 /// The decomposition's terms are exactly the headline's inputs, so their
537 /// sum reproduces the headline — including when the #244 monotonic floor,
538 /// not the live accumulators, is the number on display (#4939).
539 #[test]
540 fn cost_breakdown_components_sum_to_headline() {
541 let mut app = test_app();
542 app.session.cost_priced_turns = 2;
543 app.accrue_session_cost_estimate(CostEstimate {
544 usd: 0.05,
545 cny: 0.0,
546 });
547 app.accrue_subagent_cost_estimate(CostEstimate {
548 usd: 0.02,
549 cny: 0.0,
550 });
551
552 // Live sum is the headline: no floor component.
553 let components = CostComponents::compute(&app);
554 assert_eq!(components.parent_turns, 0.05);
555 assert_eq!(components.subagents, 0.02);
556 assert_eq!(components.display_floor, 0.0);
557 assert_eq!(
558 components.sum(),
559 app.displayed_session_cost_for_currency(CostCurrency::Usd),
560 "components must sum to the /cost headline"
561 );
562
563 // After a downward reconciliation the high-water is the headline; the
564 // difference surfaces as an explicit floor component, and the sum still
565 // reproduces the headline exactly.
566 app.session.displayed_cost_high_water = 0.10;
567 let components = CostComponents::compute(&app);
568 assert!(components.display_floor > 0.0);
569 assert_eq!(
570 components.sum(),
571 app.displayed_session_cost_for_currency(CostCurrency::Usd),
572 "floor component must absorb exactly the high-water excess"
573 );
574
575 let msg = cost(&mut app).message.expect("cost report");
576 assert!(msg.contains("Breakdown"), "{msg}");
577 assert!(msg.contains("Parent turns: $0.0500"), "{msg}");
578 assert!(msg.contains("Sub-agents: $0.0200"), "{msg}");
579 assert!(msg.contains("Reconciliation floor:"), "{msg}");
580 }
581
582 /// Per-route attribution comes from the same `TurnCostAudit`s that fed the
583 /// total, in the display currency; with every priced turn itemized, the
584 /// route amounts account for the whole parent component. CNY amounts are
585 /// the audits' provider-published CNY figures — never an FX projection of
586 /// the USD column (#4939).
587 #[test]
588 fn cost_breakdown_itemizes_routes_from_turn_audits() {
589 let mut app = test_app();
590 app.cost_currency = CostCurrency::Cny;
591 let turns = [
592 CostEstimate {
593 usd: 0.01,
594 cny: 0.07,
595 },
596 CostEstimate {
597 usd: 0.02,
598 cny: 0.14,
599 },
600 ];
601 for estimate in turns {
602 let audit = priced_audit(estimate);
603 app.record_turn_cost_audit(&audit);
604 app.accrue_session_cost_estimate(estimate);
605 app.push_turn_cache_record(turn_record("deepseek-chat", audit));
606 }
607
608 let components = CostComponents::compute(&app);
609 assert_eq!(
610 components.sum(),
611 app.displayed_session_cost_for_currency(CostCurrency::Cny),
612 "CNY components must sum to the CNY headline"
613 );
614
615 let msg = cost(&mut app).message.expect("cost report");
616 assert!(
617 msg.contains("Parent-turn spend by route (2 of 2 priced turns itemized):"),
618 "{msg}"
619 );
620 // 0.07 + 0.14 accumulated in ring order equals the parent component's
621 // accumulation, so the route line shows the whole parent spend.
622 let route_amount = app.format_cost_amount_precise(components.parent_turns);
623 assert!(
624 msg.contains(&format!("deepseek/deepseek-chat: {route_amount}")),
625 "{msg}"
626 );
627 assert!(msg.contains("¥"), "CNY display must use CNY symbol: {msg}");
628 }
629
630 /// An unpriced headline renders no breakdown: decomposing a number that is
631 /// not being shown would fabricate amounts the report just declined to
632 /// claim.
633 #[test]
634 fn cost_breakdown_absent_when_headline_unknown() {
635 let mut app = test_app();
636 let msg = cost(&mut app).message.expect("cost report");
637 assert!(!msg.contains("Breakdown"), "{msg}");
638 assert!(!msg.contains("Parent turns:"), "{msg}");
639 }
640 }
641
641 lines RUST