返回 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::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
587 lines RUST