返回 CodeWhale
debug_diagnostics.rs
根目录 / crates / tui / src / commands / contract / debug_diagnostics.rs
1 //! Host-owned diagnostics projections for FEAT-029.
2 //!
3 //! Only this adapter reads App, pricing, request inspection and prepared-tool
4 //! evidence. No completed command message or rendered report crosses the facet.
5 //! The cache observation and final store are separate synchronous operations;
6 //! in particular observation never mutates the remembered inspection.
7
8 use std::collections::BTreeMap;
9 use std::time::Instant;
10
11 use codewhale_command_contract::facets::*;
12 use codewhale_models::{MessageRequest, SystemPrompt, Usage};
13
14 use super::SharedCommandHost;
15 use crate::client::{
16 CacheWarmupKey, PromptInspection, PromptLayerStability, inspect_prompt_for_request,
17 };
18 use crate::config::provider_has_balance_api;
19 use crate::context_report::project_source_map as source_map;
20 use crate::pricing::{CostCurrency, token_usage_for_pricing};
21 use crate::tool_inspection::project_snapshot as tool_snapshot;
22 use crate::tui::app::App;
23
24 pub(super) struct DebugDiagnosticsAdapter<'a> {
25 pub(super) host: SharedCommandHost<'a>,
26 }
27
28 impl CommandDebugDiagnosticsContext for DebugDiagnosticsAdapter<'_> {
29 fn balance_projection(&self) -> DebugBalanceProjection {
30 let app = self.host.app.borrow();
31 DebugBalanceProjection {
32 provider_display_name: app
33 .admitted_provider_identity()
34 .map_or("unavailable", |identity| {
35 identity
36 .compatibility()
37 .map_or(identity.key.as_str(), |row| row.label)
38 })
39 .to_string(),
40 supports_balance_api: provider_has_balance_api(app.api_provider),
41 }
42 }
43
44 fn system_projection(&self) -> DebugSystemProjection {
45 let app = self.host.app.borrow();
46 let prompt = match app.system_prompt.as_ref() {
47 None => DebugSystemPrompt::None,
48 Some(SystemPrompt::Text(text)) => DebugSystemPrompt::Text(text.clone()),
49 Some(SystemPrompt::Blocks(blocks)) => {
50 DebugSystemPrompt::Blocks(blocks.iter().map(|block| block.text.clone()).collect())
51 }
52 };
53 DebugSystemProjection {
54 mode_label: app.mode.label().to_string(),
55 prompt,
56 }
57 }
58
59 fn token_projection(&self) -> DebugTokenProjection {
60 let app = self.host.app.borrow();
61 let window = crate::route_budget::route_context_window_tokens(
62 app.api_provider,
63 app.effective_model_for_budget(),
64 app.active_route_limits,
65 );
66 let estimated = crate::compaction::estimate_input_tokens_conservative(
67 &app.api_messages,
68 app.system_prompt.as_ref(),
69 );
70 DebugTokenProjection {
71 active_context_used: estimated.min(window as usize),
72 context_window: window,
73 last_input: app.session.last_prompt_tokens,
74 last_output: app.session.last_completion_tokens,
75 cache_hit: app.session.last_prompt_cache_hit_tokens,
76 cache_miss: app.session.last_prompt_cache_miss_tokens,
77 total_tokens: u64::from(app.session.displayed_total_tokens()),
78 cache_write_tokens: u64::from(app.session.displayed_total_cache_write_tokens()),
79 api_message_count: app.api_messages.len(),
80 chat_message_count: app.history.len(),
81 model: app.model.clone(),
82 cost: cost_projection(&app),
83 }
84 }
85
86 fn cost_projection(&self) -> DebugCostProjection {
87 cost_projection(&self.host.app.borrow())
88 }
89
90 fn cache_telemetry(&self) -> DebugCacheTelemetry {
91 let app = self.host.app.borrow();
92 let currency = app.cost_display_currency(app.cost_currency);
93 let now = Instant::now();
94 let history = app
95 .session
96 .turn_cache_history
97 .iter()
98 .map(|rec| {
99 let classes = token_usage_for_pricing(&Usage {
100 input_tokens: rec.input_tokens,
101 output_tokens: rec.output_tokens,
102 prompt_cache_hit_tokens: rec.cache_hit_tokens,
103 prompt_cache_miss_tokens: rec.cache_miss_tokens,
104 prompt_cache_write_tokens: rec.cache_write_tokens,
105 reasoning_tokens: rec.reasoning_tokens,
106 reasoning_replay_tokens: rec.reasoning_replay_tokens,
107 server_tool_use: None,
108 });
109 DebugCacheTurn {
110 provider: rec.provider.map(|provider| provider.as_str().to_string()),
111 provider_identity: rec.provider_identity.clone(),
112 model: rec.model.clone(),
113 auto_model: rec.auto_model,
114 input_tokens: rec.input_tokens,
115 output_tokens: rec.output_tokens,
116 cache_hit_tokens: rec.cache_hit_tokens,
117 cache_miss_tokens: rec.cache_miss_tokens,
118 cache_write_tokens: rec.cache_write_tokens,
119 reasoning_tokens: rec.reasoning_tokens,
120 reasoning_replay_tokens: rec.reasoning_replay_tokens,
121 priced_amount: rec.cost_audit.as_ref().and_then(|audit| {
122 audit
123 .is_priced_in(currency)
124 .then(|| audit.estimate.map(|value| value.amount(currency)))
125 .flatten()
126 }),
127 unpriced_reason_key: rec.cost_audit.as_ref().and_then(|audit| {
128 audit
129 .unpriced_reason
130 .map(|reason| reason.label().to_string())
131 }),
132 unpriced_reason_sort_rank: rec
133 .cost_audit
134 .as_ref()
135 .and_then(|audit| audit.unpriced_reason.map(|reason| reason as u8)),
136 unpriced_classes: rec.cost_audit.as_ref().map_or_else(Vec::new, |audit| {
137 audit
138 .unpriced_classes
139 .iter()
140 .map(|class| class.label().to_string())
141 .collect()
142 }),
143 priced_cache_read: classes.cache_read,
144 priced_cache_miss: classes.input,
145 priced_cache_write: classes.cache_write,
146 age_seconds: now.saturating_duration_since(rec.recorded_at).as_secs(),
147 }
148 })
149 .collect();
150 DebugCacheTelemetry {
151 model: app.model.clone(),
152 session_cache_rates: crate::tui::session_metrics::cache_rates(&app),
153 history,
154 history_capacity: App::TURN_CACHE_HISTORY_CAP,
155 prefix_stability_pct: app.prefix_stability_pct,
156 prefix_checks_total: app.prefix_checks_total,
157 prefix_change_count: app.prefix_change_count,
158 prefix_drift_count: app.prefix_drift_count,
159 prefix_context_updates: app.prefix_context_updates,
160 prefix_pin_reason: app.prefix_pin_reason.clone(),
161 prefix_last_miss_reason: app.prefix_last_miss_reason.clone(),
162 last_prefix_change_desc: app.last_prefix_change_desc.clone(),
163 last_pinned_prefix_hash: app.last_pinned_prefix_hash.clone(),
164 api_message_count: app.api_messages.len(),
165 non_system_message_count: app
166 .api_messages
167 .iter()
168 .filter(|m| m.role != "system")
169 .count(),
170 }
171 }
172
173 fn context_source_map(&self) -> DebugPromptSourceMap {
174 source_map(crate::context_report::build_context_report(
175 &self.host.app.borrow(),
176 ))
177 }
178
179 fn prompt_context(&self) -> DebugPromptContext {
180 prompt_context(crate::context_report::build_prompt_context(
181 &self.host.app.borrow(),
182 ))
183 }
184
185 fn tool_snapshot(&self) -> Option<DebugToolSnapshot> {
186 self.host
187 .app
188 .borrow()
189 .session
190 .last_tool_request_snapshot
191 .as_ref()
192 .map(tool_snapshot)
193 }
194
195 fn inspect_cache(
196 &self,
197 ) -> Result<DebugCacheInspectionObservation, DebugCacheInspectionUnavailable> {
198 let app = self.host.app.borrow();
199 let (inspection, key) = observe_cache_for_app(&app)?;
200 let current_warmup_hash_short = key.hash_short();
201 let last_warmup_hash_short = app
202 .session
203 .last_warmup_key
204 .as_ref()
205 .map(CacheWarmupKey::hash_short);
206 Ok(DebugCacheInspectionObservation {
207 current: prompt_inspection(inspection),
208 previous: app
209 .session
210 .last_cache_inspection
211 .clone()
212 .map(prompt_inspection),
213 current_warmup_key: warmup_key(key),
214 last_warmup_key: app.session.last_warmup_key.clone().map(warmup_key),
215 current_warmup_hash_short,
216 last_warmup_hash_short,
217 })
218 }
219
220 fn remember_cache_inspection(&mut self, inspection: DebugPromptInspection) {
221 self.host.app.borrow_mut().session.last_cache_inspection =
222 Some(host_inspection(inspection));
223 }
224 }
225
226 /// Resolve the route and build the inspection exactly once using the existing
227 /// client authority. Both the original handler (until Phase 4 adoption) and
228 /// the diagnostics facet use this host-owned builder. This function is read-only;
229 /// the caller explicitly stores the inspected snapshot only after rendering.
230 pub(crate) fn observe_cache_for_app(
231 app: &App,
232 ) -> Result<(PromptInspection, CacheWarmupKey), DebugCacheInspectionUnavailable> {
233 let target = app
234 .cache_replay_target()
235 .ok_or(DebugCacheInspectionUnavailable::NoConcreteRoute)?;
236 let replay_base_url = target
237 .base_url
238 .as_deref()
239 .ok_or(DebugCacheInspectionUnavailable::MissingCapturedEndpoint)?;
240 let request = MessageRequest {
241 model: target.model.clone(),
242 messages: app.api_messages.as_ref().clone(),
243 max_tokens: 0,
244 system: app.system_prompt.clone(),
245 tools: app.session.last_tool_catalog.clone(),
246 tool_choice: None,
247 metadata: None,
248 thinking: None,
249 reasoning_effort: app
250 .reasoning_effort_api_value_for_replay(target.provider, replay_base_url, &target.model)
251 .map(str::to_string),
252 stream: Some(true),
253 temperature: None,
254 top_p: None,
255 };
256 let inspection = inspect_prompt_for_request(&request);
257 let key = CacheWarmupKey::from_inspection(
258 &target.provider_identity,
259 &target.model,
260 replay_base_url,
261 &inspection,
262 );
263 Ok((inspection, key))
264 }
265
266 /// Authoritative host cost components shared with the original `/cost`
267 /// command while its presentation moves to portable code in Phase 4.
268 /// The #244 high-water floor is not a separate billable amount.
269 pub(crate) struct CostComponents {
270 pub(crate) parent_turns: f64,
271 pub(crate) subagents: f64,
272 pub(crate) display_floor: f64,
273 }
274
275 impl CostComponents {
276 pub(crate) fn compute(app: &App) -> Self {
277 fn sanitize(amount: f64) -> f64 {
278 if amount.is_finite() && amount >= 0.0 {
279 amount
280 } else {
281 0.0
282 }
283 }
284 let currency = app.cost_display_currency(app.cost_currency);
285 let parent_turns = sanitize(app.session_cost_for_currency(currency));
286 let subagents = sanitize(app.subagent_cost_for_currency(currency));
287 let sum = parent_turns + subagents;
288 let current = if sum.is_finite() { sum } else { f64::MAX };
289 let total = app.displayed_session_cost_for_currency(app.cost_currency);
290 Self {
291 parent_turns,
292 subagents,
293 display_floor: (total - current).max(0.0),
294 }
295 }
296
297 #[cfg(test)]
298 pub(crate) fn sum(&self) -> f64 {
299 self.parent_turns + self.subagents + self.display_floor
300 }
301 }
302
303 fn cost_projection(app: &App) -> DebugCostProjection {
304 let currency = app.cost_display_currency(app.cost_currency);
305 let total = app.displayed_session_cost_for_currency(app.cost_currency);
306 let components = CostComponents::compute(app);
307 let (priced_turns, unpriced_turns, reasons) = match currency {
308 CostCurrency::Usd => (
309 app.session.cost_priced_turns,
310 app.session.cost_unpriced_turns,
311 &app.session.cost_unpriced_reasons,
312 ),
313 CostCurrency::Cny => (
314 app.session.cost_cny_priced_turns,
315 app.session.cost_cny_unpriced_turns,
316 &app.session.cost_cny_unpriced_reasons,
317 ),
318 };
319 let mut by_route = BTreeMap::<String, f64>::new();
320 let mut itemized_turns = 0u32;
321 for rec in &app.session.turn_cache_history {
322 let Some(audit) = rec.cost_audit.as_ref() else {
323 continue;
324 };
325 if !audit.is_priced_in(currency) {
326 continue;
327 }
328 let Some(estimate) = audit.estimate else {
329 continue;
330 };
331 let provider = rec.provider_identity.clone().unwrap_or_else(|| {
332 rec.provider.map_or_else(
333 || "unknown-provider".to_string(),
334 |provider| provider.as_str().to_string(),
335 )
336 });
337 let route = format!(
338 "{provider}/{}",
339 rec.model.as_deref().unwrap_or("unknown-model")
340 );
341 *by_route.entry(route).or_default() += estimate.amount(currency);
342 itemized_turns = itemized_turns.saturating_add(1);
343 }
344 DebugCostProjection {
345 currency: super::to_command_currency(currency),
346 total,
347 parent_turns: components.parent_turns,
348 subagents: components.subagents,
349 display_floor: components.display_floor,
350 priced_turns,
351 unpriced_turns,
352 legacy_coverage_unknown: app.session.cost_coverage_unknown_legacy,
353 user_declared_estimates: app
354 .session
355 .cost_pricing_provenances
356 .contains("user_override"),
357 itemized_turns,
358 route_amounts: by_route
359 .into_iter()
360 .map(|(route, amount)| DebugRouteCost { route, amount })
361 .collect(),
362 turn_history_capacity: App::TURN_CACHE_HISTORY_CAP,
363 unpriced_reason_labels: reasons.iter().cloned().collect(),
364 unpriced_classes: app.session.cost_unpriced_classes.iter().cloned().collect(),
365 pricing_provenances: app
366 .session
367 .cost_pricing_provenances
368 .iter()
369 .cloned()
370 .collect(),
371 live_pricing_defects: app
372 .session
373 .cost_live_pricing_defects
374 .iter()
375 .cloned()
376 .collect(),
377 unusable_pricing_defects: app
378 .session
379 .cost_live_pricing_unusable_defects
380 .iter()
381 .cloned()
382 .collect(),
383 route_receipts: app.session.cost_route_receipts.iter().cloned().collect(),
384 }
385 }
386
387 pub(crate) fn prompt_context(context: crate::context_report::PromptContext) -> DebugPromptContext {
388 DebugPromptContext {
389 schema_version: context.schema_version,
390 provider: context.provider,
391 model: context.model,
392 system_prompt_state: context.system_prompt_state.to_string(),
393 tool_catalog_state: context.tool_catalog_state.to_string(),
394 sections: context
395 .sections
396 .into_iter()
397 .map(|section| DebugPromptContextSection {
398 index: section.index,
399 block_type: section.block_type,
400 cache_control: section.cache_control.map(|control| DebugCacheControl {
401 cache_type: control.cache_type,
402 }),
403 estimated_tokens: section.estimated_tokens,
404 text: section.text,
405 })
406 .collect(),
407 tools: context
408 .tools
409 .into_iter()
410 .map(|tool| DebugPromptTool {
411 tool_type: tool.tool_type,
412 name: tool.name,
413 description: tool.description,
414 input_schema: tool.input_schema,
415 allowed_callers: tool.allowed_callers,
416 defer_loading: tool.defer_loading,
417 input_examples: tool.input_examples,
418 strict: tool.strict,
419 cache_control: tool.cache_control.map(|control| DebugCacheControl {
420 cache_type: control.cache_type,
421 }),
422 })
423 .collect(),
424 source_map: source_map(context.source_map),
425 }
426 }
427
428 fn prompt_inspection(inspection: PromptInspection) -> DebugPromptInspection {
429 DebugPromptInspection {
430 base_static_prefix_hash: inspection.base_static_prefix_hash,
431 full_request_prefix_hash: inspection.full_request_prefix_hash,
432 tool_catalog_hash: inspection.tool_catalog_hash,
433 layers: inspection
434 .layers
435 .into_iter()
436 .map(|layer| DebugPromptLayer {
437 name: layer.name,
438 stability: match layer.stability {
439 PromptLayerStability::Static => DebugPromptLayerStability::Static,
440 PromptLayerStability::History => DebugPromptLayerStability::History,
441 PromptLayerStability::Dynamic => DebugPromptLayerStability::Dynamic,
442 },
443 char_len: layer.char_len,
444 byte_len: layer.byte_len,
445 token_estimate: layer.token_estimate,
446 sha256: layer.sha256,
447 tool_result: layer.tool_result.map(|result| DebugToolResultInspection {
448 original_chars: result.original_chars,
449 sent_chars: result.sent_chars,
450 truncated: result.truncated,
451 deduplicated: result.deduplicated,
452 }),
453 turn_meta: layer.turn_meta.map(|meta| DebugTurnMetaInspection {
454 original_chars: meta.original_chars,
455 sent_chars: meta.sent_chars,
456 deduplicated: meta.deduplicated,
457 sha256: meta.sha256,
458 }),
459 })
460 .collect(),
461 }
462 }
463
464 fn host_inspection(inspection: DebugPromptInspection) -> PromptInspection {
465 use crate::client::{PromptLayerInspection, ToolResultInspection, TurnMetaInspection};
466 PromptInspection {
467 base_static_prefix_hash: inspection.base_static_prefix_hash,
468 full_request_prefix_hash: inspection.full_request_prefix_hash,
469 tool_catalog_hash: inspection.tool_catalog_hash,
470 layers: inspection
471 .layers
472 .into_iter()
473 .map(|layer| PromptLayerInspection {
474 name: layer.name,
475 stability: match layer.stability {
476 DebugPromptLayerStability::Static => PromptLayerStability::Static,
477 DebugPromptLayerStability::History => PromptLayerStability::History,
478 DebugPromptLayerStability::Dynamic => PromptLayerStability::Dynamic,
479 },
480 char_len: layer.char_len,
481 byte_len: layer.byte_len,
482 token_estimate: layer.token_estimate,
483 sha256: layer.sha256,
484 tool_result: layer.tool_result.map(|result| ToolResultInspection {
485 original_chars: result.original_chars,
486 sent_chars: result.sent_chars,
487 truncated: result.truncated,
488 deduplicated: result.deduplicated,
489 }),
490 turn_meta: layer.turn_meta.map(|meta| TurnMetaInspection {
491 original_chars: meta.original_chars,
492 sent_chars: meta.sent_chars,
493 deduplicated: meta.deduplicated,
494 sha256: meta.sha256,
495 }),
496 })
497 .collect(),
498 }
499 }
500
501 pub(crate) fn warmup_key(key: CacheWarmupKey) -> DebugWarmupKey {
502 DebugWarmupKey {
503 provider: key.provider,
504 model: key.model,
505 base_url: key.base_url,
506 static_prefix_hash: key.static_prefix_hash,
507 tool_catalog_hash: key.tool_catalog_hash,
508 project_pack_hash: key.project_pack_hash,
509 skills_hash: key.skills_hash,
510 }
511 }
512
512 lines RUST