返回 CodeWhale
tokens.rs
根目录 / crates / tui / src / commands / groups / debug / tokens.rs
1 //! Portable token, cost, system-prompt and context diagnostics.
2 //!
3 //! The host owns counting, route pricing, pressure classification and prompt
4 //! construction; these handlers compose only declared semantic projections.
5
6 use codewhale_command_contract::facets::{
7 CommandPresentationContext, DebugCostProjection, DebugSystemPrompt, DebugTokenProjection,
8 };
9 use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler};
10 use codewhale_command_contract::metadata::{
11 CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand,
12 };
13
14 use super::CommandResult;
15 use super::DebugAction as AppAction;
16 use crate::diagnostics_reports as reports;
17
18 pub(in crate::commands) struct TokensCmd;
19 pub(in crate::commands) struct CostCmd;
20 pub(in crate::commands) struct SystemCmd;
21 pub(in crate::commands) struct ContextCmd;
22
23 const TOKENS_INFO: ContractInfo = ContractInfo {
24 name: "tokens",
25 aliases: &[],
26 usage: "/tokens",
27 description_key: "cmd_tokens_description",
28 };
29 const COST_INFO: ContractInfo = ContractInfo {
30 name: "cost",
31 aliases: &[],
32 usage: "/cost",
33 description_key: "cmd_cost_description",
34 };
35 const SYSTEM_INFO: ContractInfo = ContractInfo {
36 name: "system",
37 aliases: &["xitong"],
38 usage: "/system",
39 description_key: "cmd_system_description",
40 };
41 const CONTEXT_INFO: ContractInfo = ContractInfo {
42 name: "context",
43 aliases: &["ctx"],
44 usage: "/context [report|json|prompt-json|summary]",
45 description_key: "cmd_context_description",
46 };
47
48 impl ContractRegisterCommand<CommandResult> for TokensCmd {
49 fn info() -> &'static ContractInfo {
50 &TOKENS_INFO
51 }
52 fn handler() -> CommandHandler<CommandResult> {
53 CommandHandler::Contextual {
54 capabilities: CommandCapabilities::DEBUG_DIAGNOSTICS
55 .union(CommandCapabilities::PRESENTATION),
56 handler: |contexts, _| tokens(contexts),
57 }
58 }
59 }
60 impl ContractRegisterCommand<CommandResult> for CostCmd {
61 fn info() -> &'static ContractInfo {
62 &COST_INFO
63 }
64 fn handler() -> CommandHandler<CommandResult> {
65 CommandHandler::Contextual {
66 capabilities: CommandCapabilities::DEBUG_DIAGNOSTICS
67 .union(CommandCapabilities::PRESENTATION),
68 handler: |contexts, _| cost(contexts),
69 }
70 }
71 }
72 impl ContractRegisterCommand<CommandResult> for SystemCmd {
73 fn info() -> &'static ContractInfo {
74 &SYSTEM_INFO
75 }
76 fn handler() -> CommandHandler<CommandResult> {
77 CommandHandler::Contextual {
78 capabilities: CommandCapabilities::DEBUG_DIAGNOSTICS,
79 handler: |contexts, _| system_prompt(contexts),
80 }
81 }
82 }
83 impl ContractRegisterCommand<CommandResult> for ContextCmd {
84 fn info() -> &'static ContractInfo {
85 &CONTEXT_INFO
86 }
87 fn handler() -> CommandHandler<CommandResult> {
88 CommandHandler::Contextual {
89 capabilities: CommandCapabilities::DEBUG_DIAGNOSTICS,
90 handler: context,
91 }
92 }
93 }
94
95 const DIAGNOSTICS_UNAVAILABLE: &str = "Command capability unavailable: debug_diagnostics";
96 const PRESENTATION_UNAVAILABLE: &str = "Command capability unavailable: presentation";
97
98 fn localized(
99 presentation: &dyn CommandPresentationContext,
100 key: &str,
101 replacements: &[(&str, &str)],
102 ) -> Result<String, String> {
103 presentation.translate(key, replacements)
104 }
105
106 fn token_count(
107 value: Option<u32>,
108 presentation: &dyn CommandPresentationContext,
109 ) -> Result<String, String> {
110 value.map_or_else(
111 || localized(presentation, "cmd_tokens_not_reported", &[]),
112 |tokens| Ok(tokens.to_string()),
113 )
114 }
115
116 fn active_context_summary(
117 usage: &DebugTokenProjection,
118 presentation: &dyn CommandPresentationContext,
119 ) -> Result<String, String> {
120 let percent = (usage.active_context_used as f64 / f64::from(usage.context_window) * 100.0)
121 .clamp(0.0, 100.0);
122 localized(
123 presentation,
124 "cmd_tokens_context_with_window",
125 &[
126 ("used", &usage.active_context_used.to_string()),
127 ("window", &usage.context_window.to_string()),
128 ("percent", &format!("{percent:.1}")),
129 ],
130 )
131 }
132
133 fn cache_summary(
134 usage: &DebugTokenProjection,
135 presentation: &dyn CommandPresentationContext,
136 ) -> Result<String, String> {
137 match (usage.cache_hit, usage.cache_miss) {
138 (Some(hit), Some(miss)) => localized(
139 presentation,
140 "cmd_tokens_cache_both",
141 &[("hit", &hit.to_string()), ("miss", &miss.to_string())],
142 ),
143 (Some(hit), None) => localized(
144 presentation,
145 "cmd_tokens_cache_hit_only",
146 &[("hit", &hit.to_string())],
147 ),
148 (None, Some(miss)) => localized(
149 presentation,
150 "cmd_tokens_cache_miss_only",
151 &[("miss", &miss.to_string())],
152 ),
153 (None, None) => localized(presentation, "cmd_tokens_not_reported", &[]),
154 }
155 }
156
157 /// Show token usage for the current session, without opening host state.
158 pub fn tokens(contexts: CommandContexts<'_>) -> CommandResult {
159 let mut parts = contexts.into_parts();
160 let Some(diagnostics) = parts.debug_diagnostics.as_deref_mut() else {
161 return CommandResult::error(DIAGNOSTICS_UNAVAILABLE);
162 };
163 let Some(presentation) = parts.presentation.as_deref_mut() else {
164 return CommandResult::error(PRESENTATION_UNAVAILABLE);
165 };
166 let usage = diagnostics.token_projection();
167 match tokens_report(&usage, presentation) {
168 Ok(report) => CommandResult::message(report),
169 Err(error) => CommandResult::error(error),
170 }
171 }
172
173 fn tokens_report(
174 usage: &DebugTokenProjection,
175 presentation: &dyn CommandPresentationContext,
176 ) -> Result<String, String> {
177 let mut report = localized(
178 presentation,
179 "cmd_tokens_report",
180 &[
181 ("active", &active_context_summary(usage, presentation)?),
182 ("input", &token_count(usage.last_input, presentation)?),
183 ("output", &token_count(usage.last_output, presentation)?),
184 ("cache", &cache_summary(usage, presentation)?),
185 ("total", &usage.total_tokens.to_string()),
186 ("cost", &cost_report_amount(&usage.cost, presentation)?),
187 ("api_messages", &usage.api_message_count.to_string()),
188 ("chat_messages", &usage.chat_message_count.to_string()),
189 ("model", &usage.model),
190 ],
191 )?;
192 report.push('\n');
193 report.push_str(&localized(
194 presentation,
195 "cmd_tokens_cache_write_total",
196 &[(
197 "write",
198 &if usage.cache_write_tokens > 0 {
199 usage.cache_write_tokens.to_string()
200 } else {
201 localized(presentation, "cmd_tokens_not_reported", &[])?
202 },
203 )],
204 )?);
205 report.push_str(&cost_coverage_report(&usage.cost, presentation)?);
206 Ok(report)
207 }
208
209 /// Show session cost with the same coverage disclaimer as `/tokens`.
210 pub fn cost(contexts: CommandContexts<'_>) -> CommandResult {
211 let mut parts = contexts.into_parts();
212 let Some(diagnostics) = parts.debug_diagnostics.as_deref_mut() else {
213 return CommandResult::error(DIAGNOSTICS_UNAVAILABLE);
214 };
215 let Some(presentation) = parts.presentation.as_deref_mut() else {
216 return CommandResult::error(PRESENTATION_UNAVAILABLE);
217 };
218 match cost_report(&diagnostics.cost_projection(), presentation) {
219 Ok(report) => CommandResult::message(report),
220 Err(error) => CommandResult::error(error),
221 }
222 }
223
224 fn cost_report(
225 cost: &DebugCostProjection,
226 presentation: &dyn CommandPresentationContext,
227 ) -> Result<String, String> {
228 let saved_legacy_subtotal = cost.legacy_coverage_unknown && cost.total > 0.0;
229 let headline = if cost.priced_turns == 0 && !saved_legacy_subtotal {
230 "cmd_cost_report_unknown"
231 } else if cost.legacy_coverage_unknown || cost.unpriced_turns > 0 {
232 "cmd_cost_report_subtotal"
233 } else {
234 "cmd_cost_report"
235 };
236 let mut report = if cost.user_declared_estimates {
237 format!(
238 "Session cost estimate (priced subtotal): {}",
239 cost_report_amount(cost, presentation)?
240 )
241 } else if headline == "cmd_cost_report_unknown" {
242 // The unknown template has no `{cost}` placeholder; the legacy
243 // renderer's no-op replacement did not require one.
244 localized(presentation, headline, &[])?
245 } else {
246 localized(
247 presentation,
248 headline,
249 &[("cost", &cost_report_amount(cost, presentation)?)],
250 )?
251 };
252 if cost.priced_turns > 0 || saved_legacy_subtotal {
253 report.push_str(&cost_breakdown_report(cost));
254 }
255 report.push_str(&cost_coverage_report(cost, presentation)?);
256 Ok(report)
257 }
258
259 fn cost_report_amount(
260 cost: &DebugCostProjection,
261 presentation: &dyn CommandPresentationContext,
262 ) -> Result<String, String> {
263 if cost.priced_turns > 0 || (cost.legacy_coverage_unknown && cost.total > 0.0) {
264 Ok(reports::format_cost_amount_precise(
265 cost.total,
266 cost.currency,
267 ))
268 } else {
269 localized(presentation, "cmd_cost_unknown_value", &[])
270 }
271 }
272
273 fn cost_breakdown_report(cost: &DebugCostProjection) -> String {
274 let format = |value| reports::format_cost_amount_precise(value, cost.currency);
275 let mut out = String::from("\n\nBreakdown (components sum to the total above):");
276 out.push_str(&format!("\n Parent turns: {}", format(cost.parent_turns)));
277 if cost.subagents > 0.0 {
278 out.push_str(&format!("\n Sub-agents: {}", format(cost.subagents)));
279 }
280 if cost.display_floor > 0.0 {
281 out.push_str(&format!(
282 "\n Reconciliation floor: {} (monotonic display guarantee, kept after a downward cost reconciliation)",
283 format(cost.display_floor)
284 ));
285 }
286 if !cost.route_amounts.is_empty() {
287 out.push_str(&format!(
288 "\n Parent-turn spend by route ({} of {} priced turns itemized):",
289 cost.itemized_turns, cost.priced_turns
290 ));
291 for route in &cost.route_amounts {
292 out.push_str(&format!("\n {}: {}", route.route, format(route.amount)));
293 }
294 if cost.itemized_turns < cost.priced_turns {
295 out.push_str(&format!(
296 "\n (earlier turns not itemized: turn telemetry keeps the last {})",
297 cost.turn_history_capacity
298 ));
299 }
300 }
301 out
302 }
303
304 fn formatted_unpriced_reasons(
305 cost: &DebugCostProjection,
306 presentation: &dyn CommandPresentationContext,
307 ) -> Result<String, String> {
308 let mut descriptions: Vec<String> = Vec::new();
309 if cost.unpriced_reason_labels.is_empty() {
310 return localized(presentation, "cost_reason_unrecorded_coverage", &[]);
311 }
312 for reason in &cost.unpriced_reason_labels {
313 // Classification comes from the host's UnpricedReason::from_label;
314 // duplicate localized explanations collapse exactly as before.
315 let description = localized(presentation, &format!("cost_reason_{reason}"), &[])?;
316 if !descriptions.contains(&description) {
317 descriptions.push(description);
318 }
319 }
320 Ok(descriptions.join(", "))
321 }
322
323 /// The identical honesty block used by both diagnostic monetary surfaces.
324 fn cost_coverage_report(
325 cost: &DebugCostProjection,
326 presentation: &dyn CommandPresentationContext,
327 ) -> Result<String, String> {
328 let mut out = String::from("\n\n");
329 if cost.user_declared_estimates {
330 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.");
331 } else {
332 out.push_str(&localized(presentation, "cmd_cost_estimate_only", &[])?);
333 }
334 out.push('\n');
335 if cost.legacy_coverage_unknown {
336 out.push_str(&localized(
337 presentation,
338 "cmd_cost_coverage_unknown_legacy",
339 &[],
340 )?);
341 } else if cost.user_declared_estimates {
342 out.push_str(&format!(
343 "Coverage: {} of {} tracked turns priced or estimated.",
344 cost.priced_turns,
345 cost.priced_turns.saturating_add(cost.unpriced_turns)
346 ));
347 } else {
348 out.push_str(&localized(
349 presentation,
350 "cmd_cost_coverage",
351 &[
352 ("priced", &cost.priced_turns.to_string()),
353 (
354 "turns",
355 &cost
356 .priced_turns
357 .saturating_add(cost.unpriced_turns)
358 .to_string(),
359 ),
360 ],
361 )?);
362 }
363 if cost.unpriced_turns > 0 {
364 out.push('\n');
365 let reasons = formatted_unpriced_reasons(cost, presentation)?;
366 if cost.user_declared_estimates {
367 out.push_str(&format!(
368 "Excluded: {} turns have incomplete prices ({}); their cost is unknown.",
369 cost.unpriced_turns, reasons
370 ));
371 } else {
372 out.push_str(&localized(
373 presentation,
374 "cmd_cost_unpriced_turns",
375 &[
376 ("unpriced", &cost.unpriced_turns.to_string()),
377 ("reasons", &reasons),
378 ],
379 )?);
380 }
381 }
382 for (key, placeholder, values) in [
383 (
384 "cmd_cost_unpriced_classes",
385 "classes",
386 &cost.unpriced_classes,
387 ),
388 (
389 "cmd_cost_pricing_provenance",
390 "sources",
391 &cost.pricing_provenances,
392 ),
393 (
394 "cmd_cost_live_pricing_downgraded",
395 "defects",
396 &cost.live_pricing_defects,
397 ),
398 (
399 "cmd_cost_live_pricing_unavailable",
400 "defects",
401 &cost.unusable_pricing_defects,
402 ),
403 ] {
404 if !values.is_empty() {
405 out.push('\n');
406 out.push_str(&localized(
407 presentation,
408 key,
409 &[(placeholder, &values.join(", "))],
410 )?);
411 }
412 }
413 if !cost.route_receipts.is_empty() {
414 out.push('\n');
415 out.push_str(&localized(presentation, "cmd_cost_routes_header", &[])?);
416 for receipt in &cost.route_receipts {
417 out.push_str("\n ");
418 out.push_str(receipt);
419 }
420 }
421 Ok(out)
422 }
423
424 /// The system command is the deliberate prompt-disclosing operation.
425 pub fn system_prompt(contexts: CommandContexts<'_>) -> CommandResult {
426 let mut parts = contexts.into_parts();
427 let Some(diagnostics) = parts.debug_diagnostics.as_deref_mut() else {
428 return CommandResult::error(DIAGNOSTICS_UNAVAILABLE);
429 };
430 let system = diagnostics.system_projection();
431 let prompt_text = match system.prompt {
432 DebugSystemPrompt::Text(text) => text,
433 DebugSystemPrompt::Blocks(blocks) => blocks.join("\n\n---\n\n"),
434 DebugSystemPrompt::None => "(no system prompt)".to_string(),
435 };
436 let display = if prompt_text.len() > 500 {
437 let truncate_at = prompt_text
438 .char_indices()
439 .take_while(|(i, _)| *i <= 500)
440 .last()
441 .map_or(0, |(i, _)| i);
442 format!(
443 "{}...\n\n(truncated, {} chars total)",
444 &prompt_text[..truncate_at],
445 prompt_text.len()
446 )
447 } else {
448 prompt_text
449 };
450 CommandResult::message(format!(
451 "System Prompt ({} mode):\n─────────────────────────────\n{}",
452 system.mode_label, display
453 ))
454 }
455
456 /// Bare `/context` emits an inspector action without invoking a report builder.
457 pub fn context(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
458 let mut parts = contexts.into_parts();
459 let Some(diagnostics) = parts.debug_diagnostics.as_deref_mut() else {
460 return CommandResult::error(DIAGNOSTICS_UNAVAILABLE);
461 };
462 let Some(subcommand) = arg.map(str::trim).filter(|arg| !arg.is_empty()) else {
463 return CommandResult::action(AppAction::OpenContextInspector);
464 };
465 match subcommand {
466 "prompt-json" | "prompt_json" | "prompt" => {
467 CommandResult::message(reports::prompt_context_json(&diagnostics.prompt_context()))
468 }
469 "report" | "json" | "summary" => {
470 let report = diagnostics.context_source_map();
471 match subcommand {
472 "report" => CommandResult::message(reports::format_context_report(&report)),
473 "json" => CommandResult::message(reports::context_report_json(&report)),
474 "summary" => CommandResult::message(reports::format_context_summary(&report)),
475 _ => unreachable!(),
476 }
477 }
478 other => CommandResult::error(format!(
479 "Unknown /context subcommand: {other}. Use report, json, prompt-json, or summary."
480 )),
481 }
482 }
483
483 lines RUST