1pub mod trace;
11
12use std::borrow::Cow;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU32, Ordering};
16
17use base64::Engine as _;
18use zeph_llm::provider::{ChatResponse, Message, MessagePart, Role, ToolDefinition};
19
20use crate::redact::{redact_binary_blobs, scrub_content};
21
22pub use zeph_config::DumpFormat;
23
24#[derive(Clone)]
26pub struct DebugDumper {
27 dir: PathBuf,
28 counter: Arc<AtomicU32>,
29 format: DumpFormat,
30 include_raw_images: bool,
31}
32
33pub struct RequestDebugDump<'a> {
34 pub model_name: &'a str,
35 pub messages: &'a [Message],
36 pub tools: &'a [ToolDefinition],
37 pub provider_request: serde_json::Value,
38 pub memcot_state: Option<&'a str>,
43}
44
45impl DebugDumper {
46 pub fn new(base_dir: &Path, format: DumpFormat) -> std::io::Result<Self> {
52 let ts = std::time::SystemTime::now()
53 .duration_since(std::time::UNIX_EPOCH)
54 .map_or(0, |d| d.as_secs());
55 let dir = base_dir.join(ts.to_string());
56 std::fs::create_dir_all(&dir)?;
57 tracing::info!(path = %dir.display(), format = ?format, "debug dump directory created");
58 Ok(Self {
59 dir,
60 counter: Arc::new(AtomicU32::new(0)),
61 format,
62 include_raw_images: false,
63 })
64 }
65
66 #[must_use]
74 pub fn with_include_raw_images(mut self, include: bool) -> Self {
75 if include {
76 tracing::warn!(
77 "debug dumps: include_raw_images=true — image payloads will be written to disk unredacted"
78 );
79 }
80 self.include_raw_images = include;
81 self
82 }
83
84 #[must_use]
86 pub fn dir(&self) -> &Path {
87 &self.dir
88 }
89
90 #[must_use]
95 pub fn is_trace_format(&self) -> bool {
96 self.format == DumpFormat::Trace
97 }
98
99 fn next_id(&self) -> u32 {
100 self.counter.fetch_add(1, Ordering::Relaxed)
101 }
102
103 fn write(&self, filename: &str, content: &[u8]) {
113 let path = self.dir.join(filename);
114 let content = content.to_vec();
115 tokio::task::spawn_blocking(move || {
126 if let Err(e) = zeph_common::fs_secure::atomic_write_private(&path, &content) {
127 tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
128 }
129 });
130 }
131
132 fn write_sync(&self, filename: &str, content: &[u8]) {
136 let path = self.dir.join(filename);
137 if let Err(e) = zeph_common::fs_secure::atomic_write_private(&path, content) {
138 tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
139 }
140 }
141
142 #[must_use]
147 pub fn dump_request(&self, request: &RequestDebugDump<'_>) -> u32 {
148 let id = self.next_id();
149 if self.format == DumpFormat::Trace {
151 return id;
152 }
153 let json = match self.format {
154 DumpFormat::Raw => raw_dump(request, self.include_raw_images),
155 DumpFormat::Trace => unreachable!("handled above"),
156 _ => json_dump(request, self.include_raw_images),
157 };
158 self.write(&format!("{id:04}-request.json"), json.as_bytes());
159 id
160 }
161
162 pub fn dump_response(&self, id: u32, response: &str) {
165 if self.format == DumpFormat::Trace {
166 return;
167 }
168 let redacted = scrub_content(response);
169 let redacted = redact_binary_blobs(&redacted);
170 self.write(&format!("{id:04}-response.txt"), redacted.as_bytes());
171 }
172
173 pub(crate) fn chat_response_dump_text(response: &ChatResponse) -> String {
179 match response {
180 ChatResponse::Text(t) => t.clone(),
181 ChatResponse::ToolUse {
182 text, tool_calls, ..
183 } => {
184 let calls = serde_json::to_string_pretty(tool_calls).unwrap_or_default();
185 format!(
186 "{}\n\n---TOOL_CALLS---\n{calls}",
187 text.as_deref().unwrap_or("")
188 )
189 }
190 _ => String::new(),
191 }
192 }
193
194 pub fn dump_tool_output(&self, tool_name: &str, output: &str) {
197 if self.format == DumpFormat::Trace {
198 return;
199 }
200 let id = self.next_id();
201 let safe_name = sanitize_dump_name(tool_name);
202 let redacted = scrub_content(output);
203 let redacted = redact_binary_blobs(&redacted);
204 self.write(
205 &format!("{id:04}-tool-{safe_name}.txt"),
206 redacted.as_bytes(),
207 );
208 }
209
210 #[cfg(test)]
213 pub(crate) fn dump_pruning_scores(&self, scores: &[zeph_agent_context::BlockScore]) {
214 if self.format == DumpFormat::Trace {
215 return;
216 }
217 let id = self.next_id();
218 let payload: Vec<serde_json::Value> = scores
219 .iter()
220 .map(|s| {
221 serde_json::json!({
222 "msg_index": s.msg_index,
223 "relevance": s.relevance,
224 "redundancy": s.redundancy,
225 "mig": s.mig,
226 })
227 })
228 .collect();
229 match serde_json::to_string_pretty(&serde_json::json!({ "scores": payload })) {
230 Ok(json) => self.write_sync(&format!("{id:04}-pruning-scores.json"), json.as_bytes()),
231 Err(e) => tracing::warn!("dump_pruning_scores: serialize failed: {e}"),
232 }
233 }
234
235 pub(crate) fn dump_anchored_summary(
240 &self,
241 summary: &zeph_memory::AnchoredSummary,
242 fallback: bool,
243 token_counter: &zeph_memory::TokenCounter,
244 ) {
245 if self.format == DumpFormat::Trace {
246 return;
247 }
248 let id = self.next_id();
249 let section_completeness = serde_json::json!({
250 "session_intent": !summary.session_intent.trim().is_empty(),
251 "files_modified": !summary.files_modified.is_empty(),
252 "decisions_made": !summary.decisions_made.is_empty(),
253 "open_questions": !summary.open_questions.is_empty(),
254 "next_steps": !summary.next_steps.is_empty(),
255 });
256 let total_items = summary.files_modified.len()
257 + summary.decisions_made.len()
258 + summary.open_questions.len()
259 + summary.next_steps.len();
260 let markdown = summary.to_markdown();
261 let token_estimate = token_counter.count_tokens(&markdown);
262 let payload = serde_json::json!({
263 "summary": summary,
264 "section_completeness": section_completeness,
265 "total_items": total_items,
266 "token_estimate": token_estimate,
267 "fallback": fallback,
268 });
269 match serde_json::to_string_pretty(&payload) {
270 Ok(json) => self.write_sync(&format!("{id:04}-anchored-summary.json"), json.as_bytes()),
271 Err(e) => tracing::warn!("dump_anchored_summary: serialize failed: {e}"),
272 }
273 }
274
275 pub(crate) fn dump_compaction_probe(&self, result: &zeph_memory::CompactionProbeResult) {
278 if self.format == DumpFormat::Trace {
279 return;
280 }
281 let id = self.next_id();
282 let questions: Vec<serde_json::Value> = result
283 .questions
284 .iter()
285 .zip(
286 result
287 .answers
288 .iter()
289 .chain(std::iter::repeat(&String::new())),
290 )
291 .zip(
292 result
293 .per_question_scores
294 .iter()
295 .chain(std::iter::repeat(&0.0_f32)),
296 )
297 .map(|((q, a), &s)| {
298 serde_json::json!({
299 "question": scrub_content(&q.question),
300 "expected": scrub_content(&q.expected_answer),
301 "actual": scrub_content(a),
302 "score": s,
303 "category": format!("{:?}", q.category),
304 })
305 })
306 .collect();
307 let category_scores: Vec<serde_json::Value> = result
308 .category_scores
309 .iter()
310 .map(|cs| {
311 serde_json::json!({
312 "category": format!("{:?}", cs.category),
313 "score": cs.score,
314 "probes_run": cs.probes_run,
315 })
316 })
317 .collect();
318 let payload = serde_json::json!({
319 "score": result.score,
320 "category_scores": category_scores,
321 "threshold": result.threshold,
322 "hard_fail_threshold": result.hard_fail_threshold,
323 "verdict": format!("{:?}", result.verdict),
324 "model": result.model,
325 "duration_ms": result.duration_ms,
326 "questions": questions,
327 });
328 match serde_json::to_string_pretty(&payload) {
329 Ok(json) => {
330 self.write_sync(&format!("{id:04}-compaction-probe.json"), json.as_bytes());
331 }
332 Err(e) => tracing::warn!("dump_compaction_probe: serialize failed: {e}"),
333 }
334 }
335
336 pub fn dump_focus_knowledge(&self, knowledge: &str) {
339 if self.format == DumpFormat::Trace {
340 return;
341 }
342 let id = self.next_id();
343 let redacted = scrub_content(knowledge);
344 let redacted = redact_binary_blobs(&redacted);
345 self.write(&format!("{id:04}-focus-knowledge.txt"), redacted.as_bytes());
346 }
347
348 pub(crate) fn dump_sidequest_eviction(
351 &self,
352 cursors: &[crate::agent::sidequest::ToolOutputCursor],
353 evicted_indices: &[usize],
354 freed_tokens: usize,
355 ) {
356 if self.format == DumpFormat::Trace {
357 return;
358 }
359 let id = self.next_id();
360 let cursor_info: Vec<serde_json::Value> = cursors
361 .iter()
362 .enumerate()
363 .map(|(i, c)| {
364 serde_json::json!({
365 "cursor_id": i,
366 "msg_index": c.msg_index,
367 "part_index": c.part_index,
368 "tool_name": c.tool_name,
369 "token_count": c.token_count,
370 "evicted": evicted_indices.contains(&i),
371 })
372 })
373 .collect();
374 let payload = serde_json::json!({
375 "cursors": cursor_info,
376 "evicted_indices": evicted_indices,
377 "freed_tokens": freed_tokens,
378 });
379 match serde_json::to_string_pretty(&payload) {
380 Ok(json) => {
381 self.write_sync(&format!("{id:04}-sidequest-eviction.json"), json.as_bytes());
382 }
383 Err(e) => tracing::warn!("dump_sidequest_eviction: serialize failed: {e}"),
384 }
385 }
386
387 #[cfg(test)]
392 pub(crate) fn dump_subgoal_registry(&self, registry: &zeph_agent_context::SubgoalRegistry) {
393 if self.format == DumpFormat::Trace {
394 return;
395 }
396 let id = self.next_id();
397 let mut output = String::from("=== Subgoal Registry ===\n");
398 if registry.subgoals.is_empty() {
399 output.push_str("(no subgoals tracked yet)\n");
400 } else {
401 for sg in ®istry.subgoals {
402 let state_str = match sg.state {
403 zeph_agent_context::SubgoalState::Active => "Active ",
404 zeph_agent_context::SubgoalState::Completed => "Completed",
405 _ => "Unknown ",
406 };
407 let _ = std::fmt::write(
408 &mut output,
409 format_args!(
410 "[{}] {state_str}: \"{}\" (msgs {}-{})\n",
411 sg.id.0, sg.description, sg.start_msg_index, sg.end_msg_index,
412 ),
413 );
414 }
415 }
416 self.write_sync(&format!("{id:04}-subgoal-registry.txt"), output.as_bytes());
417 }
418
419 pub fn dump_tool_error(&self, tool_name: &str, error: &zeph_tools::ToolError) {
422 if self.format == DumpFormat::Trace {
423 return;
424 }
425 let id = self.next_id();
426 let safe_name = sanitize_dump_name(tool_name);
427 let error_text = error.to_string();
428 let error_text = scrub_content(&error_text);
429 let error_text = redact_binary_blobs(&error_text);
430 let payload = serde_json::json!({
431 "tool": tool_name,
432 "error": error_text,
433 "kind": error.kind().to_string(),
434 });
435 match serde_json::to_string_pretty(&payload) {
436 Ok(json) => {
437 self.write(
438 &format!("{id:04}-tool-error-{safe_name}.json"),
439 json.as_bytes(),
440 );
441 }
442 Err(e) => {
443 tracing::warn!("dump_tool_error: failed to serialize error payload: {e}");
444 }
445 }
446 }
447}
448
449impl zeph_llm::debug_dump::DebugDumpSink for DebugDumper {
453 fn is_trace_format(&self) -> bool {
454 self.is_trace_format()
455 }
456
457 fn dump_request(
458 &self,
459 model_name: &str,
460 messages: &[Message],
461 tools: &[ToolDefinition],
462 provider_request: serde_json::Value,
463 ) -> u32 {
464 DebugDumper::dump_request(
465 self,
466 &RequestDebugDump {
467 model_name,
468 messages,
469 tools,
470 provider_request,
471 memcot_state: None,
475 },
476 )
477 }
478
479 fn dump_response(&self, id: u32, response: &ChatResponse) {
480 DebugDumper::dump_response(self, id, &DebugDumper::chat_response_dump_text(response));
481 }
482}
483
484pub struct PiiScrubbingDumpSink {
491 inner: DebugDumper,
492 pii_filter: zeph_sanitizer::pii::PiiFilter,
493}
494
495impl PiiScrubbingDumpSink {
496 #[must_use]
499 pub fn new(inner: DebugDumper, pii_filter: zeph_sanitizer::pii::PiiFilter) -> Self {
500 Self { inner, pii_filter }
501 }
502}
503
504impl zeph_llm::debug_dump::DebugDumpSink for PiiScrubbingDumpSink {
505 fn is_trace_format(&self) -> bool {
506 self.inner.is_trace_format()
507 }
508
509 fn dump_request(
510 &self,
511 model_name: &str,
512 messages: &[Message],
513 tools: &[ToolDefinition],
514 provider_request: serde_json::Value,
515 ) -> u32 {
516 <DebugDumper as zeph_llm::debug_dump::DebugDumpSink>::dump_request(
517 &self.inner,
518 model_name,
519 messages,
520 tools,
521 provider_request,
522 )
523 }
524
525 fn dump_response(&self, id: u32, response: &ChatResponse) {
526 let raw = DebugDumper::chat_response_dump_text(response);
527 let text = if self.pii_filter.is_enabled() {
528 self.pii_filter.scrub(&raw).into_owned()
529 } else {
530 raw
531 };
532 self.inner.dump_response(id, &text);
533 }
534}
535
536fn json_dump(request: &RequestDebugDump<'_>, include_raw_images: bool) -> String {
537 let mut payload = serde_json::json!({
538 "model": extract_model(&request.provider_request, request.model_name),
539 "max_tokens": extract_max_tokens(&request.provider_request),
540 "messages": serde_json::to_value(request.messages)
541 .unwrap_or(serde_json::Value::Array(vec![])),
542 "tools": extract_tools(&request.provider_request, request.tools),
543 "temperature": request
544 .provider_request
545 .get("temperature")
546 .cloned()
547 .unwrap_or(serde_json::Value::Null),
548 "cache_control": request
549 .provider_request
550 .get("cache_control")
551 .cloned()
552 .unwrap_or(serde_json::Value::Null),
553 "memcot_state": request.memcot_state,
554 });
555 redact_dump_tree(&mut payload, include_raw_images);
556 serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
557}
558
559fn raw_dump(request: &RequestDebugDump<'_>, include_raw_images: bool) -> String {
560 let mut payload = if request.provider_request.is_object() {
561 request.provider_request.clone()
562 } else {
563 serde_json::json!({})
564 };
565 if let Some(obj) = payload.as_object_mut() {
566 obj.entry("model")
567 .or_insert_with(|| extract_model(&request.provider_request, request.model_name));
568 obj.entry("max_tokens")
569 .or_insert_with(|| extract_max_tokens(&request.provider_request));
570 obj.entry("tools")
571 .or_insert_with(|| extract_tools(&request.provider_request, request.tools));
572 obj.entry("temperature").or_insert_with(|| {
573 request
574 .provider_request
575 .get("temperature")
576 .cloned()
577 .unwrap_or(serde_json::Value::Null)
578 });
579 obj.entry("cache_control").or_insert_with(|| {
580 request
581 .provider_request
582 .get("cache_control")
583 .cloned()
584 .unwrap_or(serde_json::Value::Null)
585 });
586 obj.insert(
587 "memcot_state".to_owned(),
588 match request.memcot_state {
589 Some(s) => serde_json::Value::String(s.to_owned()),
590 None => serde_json::Value::Null,
591 },
592 );
593 if !obj.contains_key("messages") && !obj.contains_key("system") {
594 let generic = messages_to_api_value(request.messages);
595 if let Some(generic_obj) = generic.as_object() {
596 for (key, value) in generic_obj {
597 obj.insert(key.clone(), value.clone());
598 }
599 }
600 }
601 }
602 redact_dump_tree(&mut payload, include_raw_images);
603 serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
604}
605
606const MIN_IMAGE_BASE64_LEN: usize = 64;
610
611fn redact_dump_tree(value: &mut serde_json::Value, include_raw_images: bool) {
652 match value {
653 serde_json::Value::Object(map) => {
654 let is_image_kind =
655 map.get("kind").and_then(serde_json::Value::as_str) == Some("image");
656 let is_image_type =
657 map.get("type").and_then(serde_json::Value::as_str) == Some("image");
658
659 let mut exempt_keys: Vec<&'static str> = Vec::new();
664
665 if is_image_kind {
666 redact_image_kind_field(map, include_raw_images);
667 exempt_keys.push("data");
671 }
672 if is_image_type && redact_image_type_source_field(map, include_raw_images) {
673 exempt_keys.push("source");
674 }
675 if redact_image_url_field(map, include_raw_images) {
676 exempt_keys.push("image_url");
677 }
678 if redact_inline_data_field(map, include_raw_images) {
679 exempt_keys.push("inlineData");
680 }
681 if redact_images_array_field(map, include_raw_images) {
682 exempt_keys.push("images");
683 }
684
685 for (key, v) in map.iter_mut() {
686 if exempt_keys.contains(&key.as_str()) {
687 continue;
688 }
689 redact_dump_tree(v, include_raw_images);
690 }
691 }
692 serde_json::Value::Array(items) => {
693 for v in items.iter_mut() {
694 redact_dump_tree(v, include_raw_images);
695 }
696 }
697 serde_json::Value::String(s) => {
698 let scrubbed = scrub_content(s);
699 match redact_binary_blobs(scrubbed.as_ref()) {
700 Cow::Borrowed(_) => {
701 if let Cow::Owned(owned) = scrubbed {
702 *s = owned;
703 }
704 }
705 Cow::Owned(owned) => *s = owned,
706 }
707 }
708 serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
709 }
710}
711
712fn redact_image_kind_field(
716 map: &mut serde_json::Map<String, serde_json::Value>,
717 include_raw_images: bool,
718) {
719 if !include_raw_images {
720 redact_base64_field(map, "data", "mime_type");
721 }
722}
723
724fn redact_image_type_source_field(
728 map: &mut serde_json::Map<String, serde_json::Value>,
729 include_raw_images: bool,
730) -> bool {
731 let Some(source) = map
732 .get_mut("source")
733 .and_then(serde_json::Value::as_object_mut)
734 else {
735 return false;
736 };
737 if !include_raw_images {
738 redact_base64_field(source, "data", "media_type");
739 }
740 redact_object_fields_except(source, "data", include_raw_images);
741 true
742}
743
744fn redact_image_url_field(
748 map: &mut serde_json::Map<String, serde_json::Value>,
749 include_raw_images: bool,
750) -> bool {
751 let Some(image_url) = map
752 .get_mut("image_url")
753 .and_then(serde_json::Value::as_object_mut)
754 else {
755 return false;
756 };
757 let is_data_url = image_url
758 .get("url")
759 .and_then(serde_json::Value::as_str)
760 .is_some_and(|u| u.starts_with("data:"));
761 if is_data_url {
762 if !include_raw_images
763 && let Some(marker) = image_url
764 .get("url")
765 .and_then(serde_json::Value::as_str)
766 .and_then(redact_data_url)
767 {
768 image_url.insert("url".to_owned(), serde_json::Value::String(marker));
769 }
770 } else if let Some(url) = image_url.get_mut("url") {
772 redact_dump_tree(url, include_raw_images);
776 }
777 redact_object_fields_except(image_url, "url", include_raw_images);
778 true
779}
780
781fn redact_inline_data_field(
784 map: &mut serde_json::Map<String, serde_json::Value>,
785 include_raw_images: bool,
786) -> bool {
787 let Some(inline) = map
788 .get_mut("inlineData")
789 .and_then(serde_json::Value::as_object_mut)
790 else {
791 return false;
792 };
793 if !include_raw_images {
794 redact_base64_field(inline, "data", "mimeType");
795 }
796 redact_object_fields_except(inline, "data", include_raw_images);
797 true
798}
799
800fn redact_images_array_field(
804 map: &mut serde_json::Map<String, serde_json::Value>,
805 include_raw_images: bool,
806) -> bool {
807 let Some(images) = map
808 .get_mut("images")
809 .and_then(serde_json::Value::as_array_mut)
810 else {
811 return false;
812 };
813 for img in images.iter_mut() {
814 let is_recognized = img
815 .as_str()
816 .is_some_and(|s| s.len() >= MIN_IMAGE_BASE64_LEN);
817 if is_recognized {
818 if !include_raw_images && let Some(encoded) = img.as_str() {
819 *img = serde_json::Value::String(image_marker_from_base64("image", encoded));
820 }
821 } else {
823 redact_dump_tree(img, include_raw_images);
826 }
827 }
828 true
829}
830
831fn redact_object_fields_except(
838 obj: &mut serde_json::Map<String, serde_json::Value>,
839 skip_key: &str,
840 include_raw_images: bool,
841) {
842 for (k, v) in obj.iter_mut() {
843 if k == skip_key {
844 continue;
845 }
846 redact_dump_tree(v, include_raw_images);
847 }
848}
849
850fn redact_base64_field(
851 obj: &mut serde_json::Map<String, serde_json::Value>,
852 data_key: &str,
853 mime_key: &str,
854) {
855 let mime_type = obj
856 .get(mime_key)
857 .and_then(serde_json::Value::as_str)
858 .unwrap_or("unknown")
859 .to_owned();
860 if let Some(marker) = obj
861 .get(data_key)
862 .and_then(serde_json::Value::as_str)
863 .map(|encoded| image_marker_from_base64(&mime_type, encoded))
864 {
865 obj.insert(data_key.to_owned(), serde_json::Value::String(marker));
866 }
867}
868
869fn redact_data_url(url: &str) -> Option<String> {
870 let rest = url.strip_prefix("data:")?;
871 let (mime_type, encoded) = rest.split_once(";base64,")?;
872 Some(image_marker_from_base64(mime_type, encoded))
873}
874
875fn image_marker_from_base64(mime_type: &str, encoded: &str) -> String {
876 base64::engine::general_purpose::STANDARD
877 .decode(encoded)
878 .map_or_else(
879 |_| {
880 format!(
881 "<redacted image: {mime_type}, undecodable base64 ({} chars)>",
882 encoded.len()
883 )
884 },
885 |bytes| image_marker(mime_type, &bytes),
886 )
887}
888
889fn image_marker(mime_type: &str, data: &[u8]) -> String {
893 let hash = blake3::hash(data).to_hex();
894 format!(
895 "<redacted image: {mime_type}, {} bytes, blake3:{}>",
896 data.len(),
897 &hash[..16]
898 )
899}
900
901fn extract_model(payload: &serde_json::Value, fallback: &str) -> serde_json::Value {
902 payload
903 .get("model")
904 .cloned()
905 .unwrap_or_else(|| serde_json::json!(fallback))
906}
907
908fn extract_max_tokens(payload: &serde_json::Value) -> serde_json::Value {
909 payload
910 .get("max_tokens")
911 .cloned()
912 .or_else(|| payload.get("max_completion_tokens").cloned())
913 .unwrap_or(serde_json::Value::Null)
914}
915
916fn extract_tools(payload: &serde_json::Value, fallback: &[ToolDefinition]) -> serde_json::Value {
917 payload.get("tools").cloned().unwrap_or_else(|| {
918 serde_json::to_value(fallback).unwrap_or(serde_json::Value::Array(vec![]))
919 })
920}
921
922fn sanitize_dump_name(name: &str) -> String {
923 name.chars()
924 .map(|c| {
925 if c.is_alphanumeric() || c == '-' {
926 c
927 } else {
928 '_'
929 }
930 })
931 .collect()
932}
933
934fn messages_to_api_value(messages: &[Message]) -> serde_json::Value {
938 let system: String = messages
939 .iter()
940 .filter(|m| m.metadata.visibility.is_agent_visible() && m.role == Role::System)
941 .map(zeph_llm::provider::Message::to_llm_content)
942 .collect::<Vec<_>>()
943 .join("\n\n");
944
945 let chat: Vec<serde_json::Value> = messages
946 .iter()
947 .filter(|m| m.metadata.visibility.is_agent_visible() && m.role != Role::System)
948 .filter_map(|m| {
949 let role = match m.role {
950 Role::User => "user",
951 Role::Assistant => "assistant",
952 Role::System | _ => return None,
953 };
954 let is_assistant = m.role == Role::Assistant;
955 let has_structured = m.parts.iter().any(|p| {
956 matches!(
957 p,
958 MessagePart::ToolUse { .. }
959 | MessagePart::ToolResult { .. }
960 | MessagePart::Image(_)
961 | MessagePart::ThinkingBlock { .. }
962 | MessagePart::RedactedThinkingBlock { .. }
963 )
964 });
965 let content: serde_json::Value = if !has_structured || m.parts.is_empty() {
966 let text = m.to_llm_content();
967 if text.trim().is_empty() {
968 return None;
969 }
970 serde_json::json!(text)
971 } else {
972 let blocks: Vec<serde_json::Value> = m
973 .parts
974 .iter()
975 .filter_map(|p| part_to_block(p, is_assistant))
976 .collect();
977 if blocks.is_empty() {
978 return None;
979 }
980 serde_json::Value::Array(blocks)
981 };
982 Some(serde_json::json!({ "role": role, "content": content }))
983 })
984 .collect();
985
986 serde_json::json!({ "system": system, "messages": chat })
987}
988
989fn part_to_block(part: &MessagePart, is_assistant: bool) -> Option<serde_json::Value> {
990 match part {
991 MessagePart::Text { text }
992 | MessagePart::Recall { text }
993 | MessagePart::CodeContext { text }
994 | MessagePart::Summary { text }
995 | MessagePart::CrossSession { text } => {
996 if text.trim().is_empty() {
997 None
998 } else {
999 Some(serde_json::json!({ "type": "text", "text": text }))
1000 }
1001 }
1002 MessagePart::ToolOutput {
1003 tool_name,
1004 body,
1005 compacted_at,
1006 } => {
1007 let text = if compacted_at.is_some() {
1008 if body.is_empty() {
1009 format!("[tool output: {tool_name}] (pruned)")
1010 } else {
1011 format!("[tool output: {tool_name}] {body}")
1012 }
1013 } else {
1014 format!("[tool output: {tool_name}]\n{body}")
1015 };
1016 Some(serde_json::json!({ "type": "text", "text": text }))
1017 }
1018 MessagePart::ToolUse { id, name, input } if is_assistant => {
1019 Some(serde_json::json!({ "type": "tool_use", "id": id, "name": name, "input": input }))
1020 }
1021 MessagePart::ToolUse { name, input, .. } => Some(
1022 serde_json::json!({ "type": "text", "text": format!("[tool_use: {name}] {input}") }),
1023 ),
1024 MessagePart::ToolResult {
1025 tool_use_id,
1026 content,
1027 is_error,
1028 } if !is_assistant => Some(
1029 serde_json::json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error }),
1030 ),
1031 MessagePart::ToolResult { content, .. } => {
1032 if content.trim().is_empty() {
1033 None
1034 } else {
1035 Some(serde_json::json!({ "type": "text", "text": content }))
1036 }
1037 }
1038 MessagePart::ThinkingBlock {
1039 thinking,
1040 signature,
1041 } if is_assistant => Some(
1042 serde_json::json!({ "type": "thinking", "thinking": thinking, "signature": signature }),
1043 ),
1044 MessagePart::RedactedThinkingBlock { data } if is_assistant => {
1045 Some(serde_json::json!({ "type": "redacted_thinking", "data": data }))
1046 }
1047 MessagePart::ThinkingBlock { .. }
1048 | MessagePart::RedactedThinkingBlock { .. }
1049 | MessagePart::Compaction { .. }
1050 if !is_assistant =>
1051 {
1052 None
1053 }
1054 MessagePart::Compaction { summary } => {
1055 Some(serde_json::json!({ "type": "compaction", "summary": summary }))
1056 }
1057 MessagePart::Image(img) => Some(serde_json::json!({
1058 "type": "image",
1059 "source": {
1060 "type": "base64",
1061 "media_type": img.mime_type,
1062 "data": base64::engine::general_purpose::STANDARD.encode(&img.data),
1066 },
1067 })),
1068 _ => None,
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use tempfile::tempdir;
1076
1077 #[test]
1078 fn dump_format_from_str_valid() {
1079 assert_eq!("json".parse::<DumpFormat>().unwrap(), DumpFormat::Json);
1080 assert_eq!("raw".parse::<DumpFormat>().unwrap(), DumpFormat::Raw);
1081 assert_eq!("trace".parse::<DumpFormat>().unwrap(), DumpFormat::Trace);
1082 }
1083
1084 #[test]
1085 fn dump_format_from_str_invalid_returns_error() {
1086 let err = "binary".parse::<DumpFormat>().unwrap_err();
1087 assert!(
1088 err.contains("unknown dump format"),
1089 "error must mention unknown dump format: {err}"
1090 );
1091 }
1092
1093 fn sample_messages() -> Vec<Message> {
1094 vec![
1095 Message::from_legacy(Role::System, "system prompt"),
1096 Message::from_legacy(Role::User, "hello"),
1097 ]
1098 }
1099
1100 fn sample_tools() -> Vec<ToolDefinition> {
1101 vec![ToolDefinition {
1102 name: "read_file".into(),
1103 description: "Read a file".into(),
1104 parameters: serde_json::json!({
1105 "type": "object",
1106 "properties": { "path": { "type": "string" } },
1107 }),
1108 output_schema: None,
1109 }]
1110 }
1111
1112 const SAMPLE_IMAGE_BYTES: &[u8] = b"not-a-real-png-but-long-enough-to-look-like-image-bytes";
1113 const SAMPLE_IMAGE_MIME: &str = "image/png";
1114
1115 fn sample_image_messages() -> Vec<Message> {
1116 vec![
1117 Message::from_legacy(Role::System, "system prompt"),
1118 Message::from_parts(
1119 Role::User,
1120 vec![
1121 MessagePart::Text {
1122 text: "describe this".to_owned(),
1123 },
1124 MessagePart::Image(Box::new(zeph_llm::provider::ImageData {
1125 data: SAMPLE_IMAGE_BYTES.to_vec(),
1126 mime_type: SAMPLE_IMAGE_MIME.to_owned(),
1127 })),
1128 ],
1129 ),
1130 ]
1131 }
1132
1133 fn sample_image_base64() -> String {
1134 base64::engine::general_purpose::STANDARD.encode(SAMPLE_IMAGE_BYTES)
1135 }
1136
1137 async fn read_request_dump(dir: &Path) -> serde_json::Value {
1141 let session = std::fs::read_dir(dir)
1142 .unwrap()
1143 .next()
1144 .unwrap()
1145 .unwrap()
1146 .path();
1147 let path = session.join("0000-request.json");
1148 for _ in 0..200 {
1149 if let Ok(content) = std::fs::read_to_string(&path) {
1150 return serde_json::from_str(&content).unwrap();
1151 }
1152 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1153 }
1154 panic!(
1155 "debug dump file not written within timeout: {}",
1156 path.display()
1157 );
1158 }
1159
1160 async fn read_dump_file(dir: &Path, filename: &str) -> String {
1165 let session = std::fs::read_dir(dir)
1166 .unwrap()
1167 .next()
1168 .unwrap()
1169 .unwrap()
1170 .path();
1171 let path = session.join(filename);
1172 for _ in 0..200 {
1173 if let Ok(content) = std::fs::read_to_string(&path)
1174 && !content.is_empty()
1175 {
1176 return content;
1177 }
1178 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1179 }
1180 panic!(
1181 "debug dump file not written within timeout: {}",
1182 path.display()
1183 );
1184 }
1185
1186 #[tokio::test]
1187 async fn json_dump_request_includes_request_metadata() {
1188 let dir = tempdir().unwrap();
1189 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1190 let messages = sample_messages();
1191 let tools = sample_tools();
1192
1193 let _ = dumper.dump_request(&RequestDebugDump {
1194 model_name: "claude-sonnet-test",
1195 messages: &messages,
1196 tools: &tools,
1197 provider_request: serde_json::json!({
1198 "model": "claude-sonnet-test",
1199 "max_tokens": 4096,
1200 "tools": [{ "name": "read_file" }],
1201 "temperature": 0.7,
1202 "cache_control": { "type": "ephemeral" }
1203 }),
1204 memcot_state: None,
1205 });
1206
1207 let payload = read_request_dump(dir.path()).await;
1208 assert_eq!(payload["model"], "claude-sonnet-test");
1209 assert_eq!(payload["max_tokens"], 4096);
1210 assert_eq!(payload["tools"][0]["name"], "read_file");
1211 assert_eq!(payload["temperature"], 0.7);
1212 assert_eq!(payload["cache_control"]["type"], "ephemeral");
1213 assert_eq!(payload["messages"][1]["content"], "hello");
1214 }
1215
1216 #[tokio::test]
1217 async fn raw_dump_request_includes_request_metadata() {
1218 let dir = tempdir().unwrap();
1219 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1220 let messages = sample_messages();
1221 let tools = sample_tools();
1222
1223 let _ = dumper.dump_request(&RequestDebugDump {
1224 model_name: "gpt-5-mini",
1225 messages: &messages,
1226 tools: &tools,
1227 provider_request: serde_json::json!({
1228 "model": "gpt-5-mini",
1229 "max_completion_tokens": 2048,
1230 "messages": [{ "role": "user", "content": "hello" }],
1231 "tools": [{ "type": "function", "function": { "name": "read_file" } }],
1232 "temperature": 0.3,
1233 "cache_control": null
1234 }),
1235 memcot_state: None,
1236 });
1237
1238 let payload = read_request_dump(dir.path()).await;
1239 assert_eq!(payload["model"], "gpt-5-mini");
1240 assert_eq!(payload["max_tokens"], 2048);
1241 assert_eq!(payload["tools"][0]["function"]["name"], "read_file");
1242 assert_eq!(payload["temperature"], 0.3);
1243 assert_eq!(payload["messages"][0]["content"], "hello");
1244 }
1245
1246 #[tokio::test]
1249 async fn json_dump_request_redacts_binary_blob_in_message_history() {
1250 let dir = tempdir().unwrap();
1251 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1252 let blob = "A".repeat(300);
1253 let messages = vec![
1254 Message::from_legacy(Role::System, "system prompt"),
1255 Message::from_legacy(Role::User, format!("tool output: {blob}")),
1256 ];
1257 let tools = sample_tools();
1258
1259 let _ = dumper.dump_request(&RequestDebugDump {
1260 model_name: "test-model",
1261 messages: &messages,
1262 tools: &tools,
1263 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1264 memcot_state: None,
1265 });
1266
1267 let payload = read_request_dump(dir.path()).await;
1268 let content = payload["messages"][1]["content"].as_str().unwrap();
1269 assert!(
1270 content.contains("<redacted possible binary data:"),
1271 "message history must carry the redaction marker: {content}"
1272 );
1273 assert!(
1274 !content.contains(&blob),
1275 "raw blob must not reappear in dump_request: {content}"
1276 );
1277 }
1278
1279 #[tokio::test]
1282 async fn raw_dump_request_redacts_secrets_in_provider_request_messages() {
1283 let dir = tempdir().unwrap();
1284 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1285 let messages = sample_messages();
1286 let tools = sample_tools();
1287
1288 let _ = dumper.dump_request(&RequestDebugDump {
1289 model_name: "gpt-5-mini",
1290 messages: &messages,
1291 tools: &tools,
1292 provider_request: serde_json::json!({
1293 "model": "gpt-5-mini",
1294 "messages": [{ "role": "user", "content": "use key sk-abc123def456 please" }],
1295 }),
1296 memcot_state: None,
1297 });
1298
1299 let payload = read_request_dump(dir.path()).await;
1300 let content = payload["messages"][0]["content"].as_str().unwrap();
1301 assert!(
1302 content.contains("[REDACTED]"),
1303 "secret must be redacted: {content}"
1304 );
1305 assert!(
1306 !content.contains("sk-abc123def456"),
1307 "raw secret must not appear: {content}"
1308 );
1309 }
1310
1311 #[tokio::test]
1315 async fn dump_tool_output_redacts_binary_blob_before_writing() {
1316 let dir = tempdir().unwrap();
1317 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1318 let blob = "A".repeat(300);
1319 dumper.dump_tool_output("ocr_tool", &format!("output: {blob}"));
1320
1321 let content = read_dump_file(dir.path(), "0000-tool-ocr_tool.txt").await;
1322 assert!(
1323 content.contains("<redacted possible binary data:"),
1324 "tool output must carry the redaction marker: {content}"
1325 );
1326 assert!(
1327 !content.contains(&blob),
1328 "raw blob must not reach disk: {content}"
1329 );
1330 }
1331
1332 #[tokio::test]
1334 async fn dump_response_redacts_secret_before_writing() {
1335 let dir = tempdir().unwrap();
1336 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1337 dumper.dump_response(0, "here is the key: sk-abc123def456");
1338
1339 let content = read_dump_file(dir.path(), "0000-response.txt").await;
1340 assert!(
1341 content.contains("[REDACTED]"),
1342 "secret must be redacted: {content}"
1343 );
1344 assert!(
1345 !content.contains("sk-abc123def456"),
1346 "raw secret must not reach disk: {content}"
1347 );
1348 }
1349
1350 #[tokio::test]
1354 async fn pii_scrubbing_dump_sink_scrubs_pii_when_filter_enabled() {
1355 use zeph_llm::debug_dump::DebugDumpSink as _;
1356 use zeph_llm::provider::ChatResponse;
1357 use zeph_sanitizer::pii::{PiiFilter, PiiFilterConfig};
1358
1359 let dir = tempdir().unwrap();
1360 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1361 let pii_filter = PiiFilter::new(PiiFilterConfig {
1362 enabled: true,
1363 ..Default::default()
1364 });
1365 let sink = PiiScrubbingDumpSink::new(dumper, pii_filter);
1366
1367 sink.dump_response(
1368 0,
1369 &ChatResponse::Text("contact me at someone@example.com".to_owned()),
1370 );
1371
1372 let content = read_dump_file(dir.path(), "0000-response.txt").await;
1373 assert!(
1374 content.contains("[PII:email]"),
1375 "email must be scrubbed by the PiiFilter layer: {content}"
1376 );
1377 assert!(
1378 !content.contains("someone@example.com"),
1379 "raw email must not reach disk: {content}"
1380 );
1381 }
1382
1383 #[tokio::test]
1387 async fn pii_scrubbing_dump_sink_skips_pii_scrub_when_filter_disabled() {
1388 use zeph_llm::debug_dump::DebugDumpSink as _;
1389 use zeph_llm::provider::ChatResponse;
1390 use zeph_sanitizer::pii::{PiiFilter, PiiFilterConfig};
1391
1392 let dir = tempdir().unwrap();
1393 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1394 let pii_filter = PiiFilter::new(PiiFilterConfig {
1395 enabled: false,
1396 ..Default::default()
1397 });
1398 let sink = PiiScrubbingDumpSink::new(dumper, pii_filter);
1399
1400 sink.dump_response(
1401 0,
1402 &ChatResponse::Text("contact me at someone@example.com".to_owned()),
1403 );
1404
1405 let content = read_dump_file(dir.path(), "0000-response.txt").await;
1406 assert!(
1407 content.contains("someone@example.com"),
1408 "disabled PiiFilter must not scrub email: {content}"
1409 );
1410 }
1411
1412 #[tokio::test]
1414 async fn dump_tool_error_redacts_binary_blob_before_writing() {
1415 let dir = tempdir().unwrap();
1416 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1417 let blob = "B".repeat(300);
1418 let error = zeph_tools::ToolError::InvalidParams {
1419 message: format!("failed on input: {blob}"),
1420 };
1421 dumper.dump_tool_error("vision_tool", &error);
1422
1423 let content = read_dump_file(dir.path(), "0000-tool-error-vision_tool.json").await;
1424 assert!(
1425 content.contains("<redacted possible binary data:"),
1426 "tool error must carry the redaction marker: {content}"
1427 );
1428 assert!(
1429 !content.contains(&blob),
1430 "raw blob must not reach disk: {content}"
1431 );
1432 }
1433
1434 #[tokio::test]
1436 async fn dump_focus_knowledge_redacts_secret_before_writing() {
1437 let dir = tempdir().unwrap();
1438 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1439 dumper.dump_focus_knowledge("summary mentions key sk-abc123def456 from the scrape");
1440
1441 let content = read_dump_file(dir.path(), "0000-focus-knowledge.txt").await;
1442 assert!(
1443 content.contains("[REDACTED]"),
1444 "secret must be redacted: {content}"
1445 );
1446 assert!(
1447 !content.contains("sk-abc123def456"),
1448 "raw secret must not reach disk: {content}"
1449 );
1450 }
1451
1452 #[tokio::test]
1453 async fn memcot_state_written_to_dump_when_present() {
1454 for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1455 let dir = tempdir().unwrap();
1456 let dumper = DebugDumper::new(dir.path(), fmt).unwrap();
1457 let messages = sample_messages();
1458 let tools = sample_tools();
1459
1460 let _ = dumper.dump_request(&RequestDebugDump {
1461 model_name: "test-model",
1462 messages: &messages,
1463 tools: &tools,
1464 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1465 memcot_state: Some("Rust uses LLVM; user is refactoring the parser"),
1466 });
1467
1468 let payload = read_request_dump(dir.path()).await;
1469 assert_eq!(
1470 payload["memcot_state"], "Rust uses LLVM; user is refactoring the parser",
1471 "memcot_state must appear in {fmt:?} dump"
1472 );
1473 }
1474 }
1475
1476 #[tokio::test]
1477 async fn memcot_state_null_when_absent() {
1478 let dir = tempdir().unwrap();
1479 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1480 let messages = sample_messages();
1481 let tools = sample_tools();
1482
1483 let _ = dumper.dump_request(&RequestDebugDump {
1484 model_name: "test-model",
1485 messages: &messages,
1486 tools: &tools,
1487 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1488 memcot_state: None,
1489 });
1490
1491 let payload = read_request_dump(dir.path()).await;
1492 assert!(
1493 payload["memcot_state"].is_null(),
1494 "memcot_state must be null when None"
1495 );
1496 }
1497
1498 #[tokio::test]
1507 async fn dump_request_smoke_returns_promptly_and_write_still_lands() {
1508 let dir = tempdir().unwrap();
1509 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1510 let messages = sample_messages();
1511 let tools = sample_tools();
1512
1513 let start = std::time::Instant::now();
1514 let _ = dumper.dump_request(&RequestDebugDump {
1515 model_name: "test-model",
1516 messages: &messages,
1517 tools: &tools,
1518 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1519 memcot_state: None,
1520 });
1521 assert!(
1523 start.elapsed() < std::time::Duration::from_millis(50),
1524 "dump_request must return without waiting on the blocking write"
1525 );
1526
1527 let _ = read_request_dump(dir.path()).await;
1529 }
1530
1531 #[tokio::test]
1534 async fn json_dump_redacts_image_bytes_by_default() {
1535 let dir = tempdir().unwrap();
1536 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1537 let messages = sample_image_messages();
1538 let tools = sample_tools();
1539
1540 let _ = dumper.dump_request(&RequestDebugDump {
1541 model_name: "test-model",
1542 messages: &messages,
1543 tools: &tools,
1544 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1545 memcot_state: None,
1546 });
1547
1548 let payload = read_request_dump(dir.path()).await;
1549 let dumped_json = payload.to_string();
1550 assert!(
1551 !dumped_json.contains(&sample_image_base64()),
1552 "json dump must not contain raw base64 image bytes by default"
1553 );
1554 let image_part = &payload["messages"][1]["parts"][1];
1555 assert_eq!(image_part["mime_type"], SAMPLE_IMAGE_MIME);
1556 let marker = image_part["data"].as_str().unwrap();
1557 assert!(
1558 marker.starts_with("<redacted image: image/png,"),
1559 "unexpected marker: {marker}"
1560 );
1561 assert_eq!(payload["messages"][1]["parts"][0]["text"], "describe this");
1563 }
1564
1565 #[tokio::test]
1566 async fn raw_dump_redacts_image_bytes_via_fallback_content_blocks() {
1567 let dir = tempdir().unwrap();
1568 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1569 let messages = sample_image_messages();
1570 let tools = sample_tools();
1571
1572 let _ = dumper.dump_request(&RequestDebugDump {
1575 model_name: "claude-sonnet-test",
1576 messages: &messages,
1577 tools: &tools,
1578 provider_request: serde_json::json!({ "model": "claude-sonnet-test" }),
1579 memcot_state: None,
1580 });
1581
1582 let payload = read_request_dump(dir.path()).await;
1583 let dumped_json = payload.to_string();
1584 assert!(
1585 !dumped_json.contains(&sample_image_base64()),
1586 "raw dump fallback content blocks must not contain raw base64 image bytes by default"
1587 );
1588 let image_block = &payload["messages"][0]["content"][1];
1589 assert_eq!(image_block["type"], "image");
1590 assert_eq!(image_block["source"]["media_type"], SAMPLE_IMAGE_MIME);
1591 let marker = image_block["source"]["data"].as_str().unwrap();
1592 assert!(
1593 marker.starts_with("<redacted image: image/png,"),
1594 "unexpected marker: {marker}"
1595 );
1596 }
1597
1598 #[tokio::test]
1599 async fn raw_dump_redacts_openai_style_image_url_in_provider_request() {
1600 let dir = tempdir().unwrap();
1601 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1602 let messages = sample_messages();
1603 let tools = sample_tools();
1604 let data_url = format!("data:{SAMPLE_IMAGE_MIME};base64,{}", sample_image_base64());
1605
1606 let _ = dumper.dump_request(&RequestDebugDump {
1609 model_name: "gpt-5-mini",
1610 messages: &messages,
1611 tools: &tools,
1612 provider_request: serde_json::json!({
1613 "model": "gpt-5-mini",
1614 "messages": [{
1615 "role": "user",
1616 "content": [
1617 { "type": "text", "text": "describe this" },
1618 { "type": "image_url", "image_url": { "url": data_url } },
1619 ],
1620 }],
1621 }),
1622 memcot_state: None,
1623 });
1624
1625 let payload = read_request_dump(dir.path()).await;
1626 let dumped_json = payload.to_string();
1627 assert!(
1628 !dumped_json.contains(&sample_image_base64()),
1629 "raw dump of a real provider_request must not contain raw base64 image bytes by default"
1630 );
1631 let url = payload["messages"][0]["content"][1]["image_url"]["url"]
1632 .as_str()
1633 .unwrap();
1634 assert!(
1635 url.starts_with("<redacted image: image/png,"),
1636 "unexpected marker: {url}"
1637 );
1638 }
1639
1640 #[tokio::test]
1641 async fn raw_dump_redacts_gemini_and_ollama_style_provider_request_shapes() {
1642 let dir = tempdir().unwrap();
1643 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1644 let messages = sample_messages();
1645 let tools = sample_tools();
1646 let b64 = sample_image_base64();
1647
1648 let _ = dumper.dump_request(&RequestDebugDump {
1649 model_name: "test-model",
1650 messages: &messages,
1651 tools: &tools,
1652 provider_request: serde_json::json!({
1653 "model": "test-model",
1654 "contents": [{
1656 "role": "user",
1657 "parts": [{ "inlineData": { "mimeType": SAMPLE_IMAGE_MIME, "data": b64 } }],
1658 }],
1659 "messages": [{ "role": "user", "content": "hi", "images": [b64] }],
1661 }),
1662 memcot_state: None,
1663 });
1664
1665 let payload = read_request_dump(dir.path()).await;
1666 let dumped_json = payload.to_string();
1667 assert!(
1668 !dumped_json.contains(&sample_image_base64()),
1669 "raw dump must redact both Gemini inlineData and Ollama images-array shapes"
1670 );
1671 let gemini_data = payload["contents"][0]["parts"][0]["inlineData"]["data"]
1672 .as_str()
1673 .unwrap();
1674 assert!(gemini_data.starts_with("<redacted image:"));
1675 let ollama_image = payload["messages"][0]["images"][0].as_str().unwrap();
1676 assert!(ollama_image.starts_with("<redacted image:"));
1677 }
1678
1679 #[tokio::test]
1680 async fn include_raw_images_opt_in_preserves_full_bytes() {
1681 for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1682 let dir = tempdir().unwrap();
1683 let dumper = DebugDumper::new(dir.path(), fmt)
1684 .unwrap()
1685 .with_include_raw_images(true);
1686 let messages = sample_image_messages();
1687 let tools = sample_tools();
1688
1689 let _ = dumper.dump_request(&RequestDebugDump {
1690 model_name: "test-model",
1691 messages: &messages,
1692 tools: &tools,
1693 provider_request: serde_json::json!({ "model": "test-model" }),
1694 memcot_state: None,
1695 });
1696
1697 let payload = read_request_dump(dir.path()).await;
1698 assert!(
1699 payload.to_string().contains(&sample_image_base64()),
1700 "with include_raw_images=true, {fmt:?} dump must contain the full base64 image bytes"
1701 );
1702 }
1703 }
1704
1705 #[tokio::test]
1711 async fn include_raw_images_survives_binary_blob_heuristic_for_large_images() {
1712 let large_bytes: Vec<u8> = std::iter::repeat(0..=255u8).flatten().take(300).collect();
1713 let large_b64 = base64::engine::general_purpose::STANDARD.encode(&large_bytes);
1714 assert!(
1715 large_b64.len() > 200,
1716 "test setup sanity check: encoded image must exceed redact_binary_blobs's 200-char threshold"
1717 );
1718 let messages = vec![
1719 Message::from_legacy(Role::System, "system prompt"),
1720 Message::from_parts(
1721 Role::User,
1722 vec![
1723 MessagePart::Text {
1724 text: "describe this".to_owned(),
1725 },
1726 MessagePart::Image(Box::new(zeph_llm::provider::ImageData {
1727 data: large_bytes,
1728 mime_type: "image/png".to_owned(),
1729 })),
1730 ],
1731 ),
1732 ];
1733 let tools = sample_tools();
1734
1735 for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1736 let dir = tempdir().unwrap();
1737 let dumper = DebugDumper::new(dir.path(), fmt)
1738 .unwrap()
1739 .with_include_raw_images(true);
1740
1741 let _ = dumper.dump_request(&RequestDebugDump {
1742 model_name: "test-model",
1743 messages: &messages,
1744 tools: &tools,
1745 provider_request: serde_json::json!({ "model": "test-model" }),
1746 memcot_state: None,
1747 });
1748
1749 let payload = read_request_dump(dir.path()).await;
1750 assert!(
1751 payload.to_string().contains(&large_b64),
1752 "{fmt:?}: include_raw_images=true must preserve full image bytes even though \
1753 they exceed redact_binary_blobs's 200-char threshold"
1754 );
1755 }
1756 }
1757
1758 #[tokio::test]
1765 async fn include_raw_images_does_not_widen_scope_to_non_image_blobs() {
1766 let blob = "A".repeat(300);
1767 let messages = vec![
1768 Message::from_legacy(Role::System, "system prompt"),
1769 Message::from_legacy(Role::User, format!("tool output: {blob}")),
1770 ];
1771 let tools = sample_tools();
1772
1773 for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1774 let dir = tempdir().unwrap();
1775 let dumper = DebugDumper::new(dir.path(), fmt)
1776 .unwrap()
1777 .with_include_raw_images(true);
1778
1779 let _ = dumper.dump_request(&RequestDebugDump {
1780 model_name: "test-model",
1781 messages: &messages,
1782 tools: &tools,
1783 provider_request: serde_json::json!({ "model": "test-model" }),
1784 memcot_state: None,
1785 });
1786
1787 let payload = read_request_dump(dir.path()).await;
1788 let dumped_json = payload.to_string();
1789 assert!(
1790 dumped_json.contains("<redacted possible binary data:"),
1791 "{fmt:?}: include_raw_images=true must not suppress redact_binary_blobs for \
1792 non-image freeform text: {dumped_json}"
1793 );
1794 assert!(
1795 !dumped_json.contains(&blob),
1796 "{fmt:?}: raw non-image blob must not survive even with include_raw_images=true: \
1797 {dumped_json}"
1798 );
1799 }
1800 }
1801
1802 #[tokio::test]
1808 async fn image_url_with_external_url_still_gets_secret_scrubbed() {
1809 let dir = tempdir().unwrap();
1810 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1811 let messages = sample_messages();
1812 let tools = sample_tools();
1813
1814 let _ = dumper.dump_request(&RequestDebugDump {
1815 model_name: "gpt-5-mini",
1816 messages: &messages,
1817 tools: &tools,
1818 provider_request: serde_json::json!({
1819 "model": "gpt-5-mini",
1820 "messages": [{
1821 "role": "user",
1822 "content": [
1823 { "type": "text", "text": "describe this" },
1824 {
1825 "type": "image_url",
1826 "image_url": { "url": "https://user:s3cr3t-key@cdn.example.com/photo.png" },
1827 },
1828 ],
1829 }],
1830 }),
1831 memcot_state: None,
1832 });
1833
1834 let payload = read_request_dump(dir.path()).await;
1835 let url = payload["messages"][0]["content"][1]["image_url"]["url"]
1836 .as_str()
1837 .unwrap();
1838 assert!(
1839 url.contains("[REDACTED]"),
1840 "external image_url.url with embedded credentials must still be scrubbed: {url}"
1841 );
1842 assert!(
1843 !url.contains("s3cr3t-key"),
1844 "raw credential must not survive: {url}"
1845 );
1846 }
1847
1848 #[tokio::test]
1853 async fn image_type_source_without_data_key_still_gets_secret_scrubbed() {
1854 let dir = tempdir().unwrap();
1855 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1856 let messages = sample_messages();
1857 let tools = sample_tools();
1858
1859 let _ = dumper.dump_request(&RequestDebugDump {
1860 model_name: "claude-sonnet-test",
1861 messages: &messages,
1862 tools: &tools,
1863 provider_request: serde_json::json!({
1864 "model": "claude-sonnet-test",
1865 "messages": [{
1866 "role": "user",
1867 "content": [{
1868 "type": "image",
1869 "source": {
1870 "type": "url",
1871 "url": "https://user:s3cr3t-key@cdn.example.com/photo.png",
1872 },
1873 }],
1874 }],
1875 }),
1876 memcot_state: None,
1877 });
1878
1879 let payload = read_request_dump(dir.path()).await;
1880 let url = payload["messages"][0]["content"][0]["source"]["url"]
1881 .as_str()
1882 .unwrap();
1883 assert!(
1884 url.contains("[REDACTED]"),
1885 "source object without a data key must still get secret-scrubbed: {url}"
1886 );
1887 assert!(
1888 !url.contains("s3cr3t-key"),
1889 "raw credential must not survive: {url}"
1890 );
1891 }
1892
1893 #[tokio::test]
1902 async fn raw_dump_redacts_real_provider_debug_request_json_for_every_provider() {
1903 use zeph_llm::provider::LlmProvider;
1904
1905 let messages = sample_image_messages();
1906 let b64 = sample_image_base64();
1907
1908 let claude = zeph_llm::claude::ClaudeProvider::new(
1909 "test-key".to_owned(),
1910 "claude-sonnet-test".to_owned(),
1911 4096,
1912 );
1913 let openai = zeph_llm::openai::OpenAiProvider::new(zeph_llm::openai::OpenAiConfig {
1914 api_key: "test-key".to_owned(),
1915 base_url: "https://api.openai.com/v1".to_owned(),
1916 model: "gpt-5-mini".to_owned(),
1917 max_tokens: 4096,
1918 embedding_model: None,
1919 reasoning_effort: None,
1920 context_window: None,
1921 completion_tokens_param: None,
1922 vision: None,
1923 });
1924 let gemini = zeph_llm::gemini::GeminiProvider::new(
1925 "test-key".to_owned(),
1926 "gemini-2.5-flash".to_owned(),
1927 4096,
1928 );
1929 let ollama = zeph_llm::ollama::OllamaProvider::new(
1930 "http://localhost:11434",
1931 "llava".to_owned(),
1932 "nomic-embed-text".to_owned(),
1933 );
1934
1935 let providers: Vec<(&str, serde_json::Value)> = vec![
1936 ("claude", claude.debug_request_json(&messages, &[], false)),
1937 ("openai", openai.debug_request_json(&messages, &[], false)),
1938 ("gemini", gemini.debug_request_json(&messages, &[], false)),
1939 ("ollama", ollama.debug_request_json(&messages, &[], false)),
1940 ];
1941
1942 for (name, provider_request) in providers {
1943 assert!(
1944 provider_request.to_string().contains(&b64),
1945 "test setup sanity check: {name}'s debug_request_json must actually contain \
1946 the raw base64 image bytes before redaction (otherwise this test proves \
1947 nothing)"
1948 );
1949
1950 let dir = tempdir().unwrap();
1951 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1952 let _ = dumper.dump_request(&RequestDebugDump {
1953 model_name: name,
1954 messages: &messages,
1955 tools: &[],
1956 provider_request,
1957 memcot_state: None,
1958 });
1959
1960 let payload = read_request_dump(dir.path()).await;
1961 assert!(
1962 !payload.to_string().contains(&b64),
1963 "{name}'s real debug_request_json output must not contain raw base64 image \
1964 bytes after passing through the real dump_request/raw_dump redaction path"
1965 );
1966 }
1967 }
1968
1969 #[test]
1970 fn redact_dump_tree_leaves_clean_non_image_values_untouched() {
1971 let mut value = serde_json::json!({
1972 "model": "test-model",
1973 "messages": [{
1974 "role": "user",
1975 "content": [
1976 { "type": "text", "text": "hello world" },
1977 { "type": "tool_use", "id": "t1", "name": "read_file", "input": { "path": "a.rs" } },
1978 { "type": "tool_result", "tool_use_id": "t1", "content": "file contents", "is_error": false },
1979 ],
1980 }],
1981 "temperature": 0.5,
1982 });
1983 let before = value.clone();
1984 redact_dump_tree(&mut value, false);
1985 assert_eq!(
1986 value, before,
1987 "JSON with no secrets/blobs/image data must be unchanged by redaction"
1988 );
1989 }
1990}