1use anyhow::{Context, Result};
4use serde_json::Value;
5
6use crate::cost::{estimate_tokens, ImageMetrics};
7use crate::inspector;
8use crate::inspector::MediaFormat;
9use crate::mode::{ShiftConfig, SvgMode};
10use crate::payload;
11use crate::policy;
12use crate::report::Report;
13use crate::transformer;
14
15pub fn process(payload: &Value, config: &ShiftConfig) -> Result<(Value, Report)> {
19 let mut report = Report::new();
20 report.dry_run = config.dry_run;
21
22 let provider_format = payload::detect_provider(payload);
24
25 let profile = if let Some(ref custom_path) = config.profile_path {
27 let path = std::path::Path::new(custom_path);
29
30 match path.extension().and_then(|e| e.to_str()) {
32 Some("json") => {}
33 _ => anyhow::bail!("profile path must have a .json extension"),
34 }
35
36 for component in path.components() {
38 if matches!(component, std::path::Component::ParentDir) {
39 anyhow::bail!("profile path must not contain '..' path traversal");
40 }
41 }
42
43 if path.exists() {
46 let canonical = std::fs::canonicalize(path)
47 .with_context(|| "failed to resolve profile path".to_string())?;
48 match canonical.extension().and_then(|e| e.to_str()) {
49 Some("json") => {}
50 _ => anyhow::bail!(
51 "profile path resolves to a non-JSON file (possible symlink attack)"
52 ),
53 }
54 policy::load_from_file(canonical.to_str().unwrap_or(custom_path))?
55 } else {
56 policy::load_from_file(custom_path)?
57 }
58 } else {
59 policy::load_builtin(&config.provider)?
60 };
61
62 let model_name = config
64 .model
65 .as_deref()
66 .or_else(|| payload.get("model").and_then(|m| m.as_str()));
67 let constraints = profile.constraints_for(model_name);
68
69 let images = match provider_format {
71 Some("openai") => payload::openai::extract_images_with_limits(payload, &config.limits)?,
72 Some("anthropic") => {
73 payload::anthropic::extract_images_with_limits(payload, &config.limits)?
74 }
75 _ => {
76 return Ok((payload.clone(), report));
78 }
79 };
80
81 if images.is_empty() {
82 return Ok((payload.clone(), report));
83 }
84
85 report.images_found = images.len();
86 let original_image_bytes: usize = images.iter().map(|img| img.data.len()).sum();
88 report.original_size = original_image_bytes;
89
90 let total_images = images.len();
91 let mut transformed_images: Vec<(usize, Vec<u8>, String)> = Vec::new();
92
93 for extracted in &images {
94 let meta = match inspector::image::inspect_bytes(&extracted.data) {
96 Ok(m) => m,
97 Err(e) => {
98 report.add_warning(&format!(
99 "image {}: skipped ({})",
100 extracted.global_index, e
101 ));
102 let original_mime = match &extracted.original_ref {
105 payload::ImageRef::DataUri { mime_type, .. } => mime_type.clone(),
106 payload::ImageRef::Base64 { media_type, .. } => media_type.clone(),
107 payload::ImageRef::Url(_) => "application/octet-stream".to_string(),
108 };
109 let orig_bytes = extracted.data.len();
110 let format_short = mime_to_short(&original_mime);
111 report.add_image_metrics(ImageMetrics {
113 image_index: extracted.global_index,
114 original_width: 0,
115 original_height: 0,
116 transformed_width: 0,
117 transformed_height: 0,
118 original_bytes: orig_bytes,
119 transformed_bytes: orig_bytes,
120 format_before: format_short.clone(),
121 format_after: format_short,
122 tokens_before: estimate_tokens(0, 0),
123 tokens_after: estimate_tokens(0, 0),
124 });
125 transformed_images.push((
127 extracted.global_index,
128 extracted.data.clone(),
129 original_mime,
130 ));
131 continue;
132 }
133 };
134
135 let orig_w = meta.width;
137 let orig_h = meta.height;
138 let orig_bytes = extracted.data.len();
139 let format_before = meta.format.to_string();
140
141 let actions = policy::evaluate(
143 &meta,
144 constraints,
145 config.mode,
146 extracted.global_index,
147 total_images,
148 );
149
150 if meta.format == MediaFormat::Svg {
152 let result = handle_svg(
153 &extracted.data,
154 &meta,
155 &actions,
156 config,
157 extracted.global_index,
158 &mut report,
159 )?;
160
161 let (_, ref out_data, ref out_mime) = result;
163 let (tw, th) = if out_data.is_empty() {
164 (0, 0)
166 } else if config.dry_run {
167 estimate_dims_from_actions(&actions, orig_w, orig_h)
170 } else {
171 inspector::image::inspect_bytes(out_data)
172 .map(|m| (m.width, m.height))
173 .unwrap_or((orig_w, orig_h))
174 };
175 let format_after = if config.dry_run && !out_data.is_empty() {
176 "png".to_string()
178 } else {
179 mime_to_short(out_mime)
180 };
181 report.add_image_metrics(ImageMetrics {
182 image_index: extracted.global_index,
183 original_width: orig_w,
184 original_height: orig_h,
185 transformed_width: tw,
186 transformed_height: th,
187 original_bytes: orig_bytes,
188 transformed_bytes: out_data.len(),
189 format_before: format_before.clone(),
190 format_after,
191 tokens_before: estimate_tokens(orig_w, orig_h),
192 tokens_after: estimate_tokens(tw, th),
193 });
194
195 transformed_images.push(result);
196 continue;
197 }
198
199 let mut current_data = extracted.data.clone();
201 let mut was_modified = false;
202 let mut output_mime = meta.format.mime_type().to_string();
203 let mut was_dropped = false;
204 let mut did_jpeg_resize = false;
205
206 for action in &actions {
207 match action {
208 policy::Action::Pass => {}
209 policy::Action::Drop { reason } => {
210 report.add_action(extracted.global_index, "drop", reason);
211 report.images_dropped += 1;
212 current_data = Vec::new();
213 was_modified = true;
214 was_dropped = true;
215 break;
216 }
217 policy::Action::Recompress { .. } if did_jpeg_resize => {
218 let detail = "skipped: resize already produced JPEG".to_string();
222 report.add_action(extracted.global_index, "skip_recompress", &detail);
223 }
224 _ => {
225 if !config.dry_run {
226 let new_data = transformer::transform_image(¤t_data, action)?;
227 let detail = describe_action(action, &meta);
228 report.add_action(extracted.global_index, action_name(action), &detail);
229 current_data = new_data;
230 was_modified = true;
231
232 if matches!(action, policy::Action::Resize { .. }) {
233 did_jpeg_resize =
234 inspector::detect_format(¤t_data) == MediaFormat::Jpeg;
235 }
236 } else {
237 let detail = describe_action(action, &meta);
238 report.add_action(
239 extracted.global_index,
240 &format!("would_{}", action_name(action)),
241 &detail,
242 );
243 was_modified = true;
244
245 if matches!(action, policy::Action::Resize { .. })
247 && meta.format == MediaFormat::Jpeg
248 {
249 did_jpeg_resize = true;
250 }
251 }
252
253 match action {
257 policy::Action::ConvertFormat { to } => {
258 output_mime = format!("image/{}", to);
259 }
260 policy::Action::Resize { .. } => {
261 if !config.dry_run {
262 let actual = inspector::detect_format(¤t_data);
264 output_mime = actual.mime_type().to_string();
265 } else if meta.format == MediaFormat::Jpeg {
266 output_mime = "image/jpeg".to_string();
267 } else {
268 output_mime = "image/png".to_string();
269 }
270 }
271 policy::Action::Recompress { .. } => {
272 output_mime = "image/jpeg".to_string();
273 }
274 _ => {}
275 }
276 }
277 }
278 }
279
280 if was_modified {
281 report.images_modified += 1;
282 }
283
284 let (tw, th) = if was_dropped || current_data.is_empty() {
286 (0, 0)
287 } else if was_modified && !config.dry_run {
288 inspector::image::inspect_bytes(¤t_data)
290 .map(|m| (m.width, m.height))
291 .unwrap_or((orig_w, orig_h))
292 } else {
293 estimate_dims_from_actions(&actions, orig_w, orig_h)
295 };
296
297 let format_after = mime_to_short(&output_mime);
298 report.add_image_metrics(ImageMetrics {
299 image_index: extracted.global_index,
300 original_width: orig_w,
301 original_height: orig_h,
302 transformed_width: tw,
303 transformed_height: th,
304 original_bytes: orig_bytes,
305 transformed_bytes: current_data.len(),
306 format_before,
307 format_after,
308 tokens_before: estimate_tokens(orig_w, orig_h),
309 tokens_after: estimate_tokens(tw, th),
310 });
311
312 transformed_images.push((extracted.global_index, current_data, output_mime));
313 }
314
315 let result = if config.dry_run {
317 payload.clone()
318 } else {
319 match provider_format {
320 Some("openai") => payload::openai::reconstruct(payload, &transformed_images)?,
321 Some("anthropic") => payload::anthropic::reconstruct(payload, &transformed_images)?,
322 _ => payload.clone(),
323 }
324 };
325
326 let transformed_image_bytes: usize = transformed_images
328 .iter()
329 .map(|(_, data, _)| data.len())
330 .sum();
331 report.transformed_size = transformed_image_bytes;
332
333 report.finalize_token_savings();
335
336 Ok((result, report))
337}
338
339fn mime_to_short(mime: &str) -> String {
341 mime.strip_prefix("image/").unwrap_or(mime).to_string()
342}
343
344fn estimate_dims_from_actions(actions: &[policy::Action], orig_w: u32, orig_h: u32) -> (u32, u32) {
346 for action in actions {
347 match action {
348 policy::Action::Resize {
349 target_width,
350 target_height,
351 } => return (*target_width, *target_height),
352 policy::Action::RasterizeSvg {
353 target_width,
354 target_height,
355 } => return (*target_width, *target_height),
356 policy::Action::Drop { .. } => return (0, 0),
357 _ => {}
358 }
359 }
360 (orig_w, orig_h)
361}
362
363fn handle_svg(
365 data: &[u8],
366 meta: &inspector::ImageMetadata,
367 actions: &[policy::Action],
368 config: &ShiftConfig,
369 global_index: usize,
370 report: &mut Report,
371) -> Result<(usize, Vec<u8>, String)> {
372 match config.svg_mode {
373 SvgMode::Raster => {
374 if config.dry_run {
376 let detail = format!("would rasterize {}x{} SVG to PNG", meta.width, meta.height);
377 report.add_action(global_index, "would_rasterize_svg", &detail);
378 report.images_modified += 1;
379 return Ok((global_index, data.to_vec(), "image/svg+xml".to_string()));
380 }
381
382 let (tw, th) = actions
384 .iter()
385 .find_map(|a| match a {
386 policy::Action::RasterizeSvg {
387 target_width,
388 target_height,
389 } => Some((*target_width, *target_height)),
390 _ => None,
391 })
392 .unwrap_or((meta.width.max(256), meta.height.max(256)));
393
394 let svg_text = std::str::from_utf8(data).context("SVG is not valid UTF-8")?;
395 let png_data = transformer::rasterize_svg(svg_text, tw, th)?;
396
397 report.add_action(
398 global_index,
399 "rasterize_svg",
400 &format!(
401 "SVG ({}x{}) -> PNG ({}x{})",
402 meta.width, meta.height, tw, th
403 ),
404 );
405 report.svgs_rasterized += 1;
406 report.images_modified += 1;
407
408 Ok((global_index, png_data, "image/png".to_string()))
409 }
410
411 SvgMode::Source => {
412 report.add_action(
416 global_index,
417 "svg_dropped_as_source",
418 &format!(
419 "SVG ({}x{}) removed (source mode: SVG not supported by provider)",
420 meta.width, meta.height
421 ),
422 );
423 report.images_dropped += 1;
424 report.add_warning(
425 "SVG source mode dropped an image. Consider --svg-mode raster for provider compatibility.",
426 );
427
428 Ok((global_index, Vec::new(), "text/plain".to_string()))
429 }
430
431 SvgMode::Hybrid => {
432 if config.dry_run {
434 report.add_action(
435 global_index,
436 "would_rasterize_svg_hybrid",
437 &format!(
438 "would rasterize {}x{} SVG (hybrid mode)",
439 meta.width, meta.height
440 ),
441 );
442 report.images_modified += 1;
443 return Ok((global_index, data.to_vec(), "image/svg+xml".to_string()));
444 }
445
446 let (tw, th) = actions
447 .iter()
448 .find_map(|a| match a {
449 policy::Action::RasterizeSvg {
450 target_width,
451 target_height,
452 } => Some((*target_width, *target_height)),
453 _ => None,
454 })
455 .unwrap_or((meta.width.max(256), meta.height.max(256)));
456
457 let svg_text = std::str::from_utf8(data).context("SVG is not valid UTF-8")?;
458 let png_data = transformer::rasterize_svg(svg_text, tw, th)?;
459
460 report.add_action(
461 global_index,
462 "rasterize_svg_hybrid",
463 &format!(
464 "SVG ({}x{}) -> PNG ({}x{}) + source retained",
465 meta.width, meta.height, tw, th
466 ),
467 );
468 report.svgs_rasterized += 1;
469 report.images_modified += 1;
470
471 Ok((global_index, png_data, "image/png".to_string()))
472 }
473 }
474}
475
476fn action_name(action: &policy::Action) -> &'static str {
477 match action {
478 policy::Action::Pass => "pass",
479 policy::Action::Resize { .. } => "resize",
480 policy::Action::Recompress { .. } => "recompress",
481 policy::Action::ConvertFormat { .. } => "convert",
482 policy::Action::RasterizeSvg { .. } => "rasterize_svg",
483 policy::Action::Drop { .. } => "drop",
484 }
485}
486
487fn describe_action(action: &policy::Action, meta: &inspector::ImageMetadata) -> String {
488 match action {
489 policy::Action::Pass => "no changes needed".to_string(),
490 policy::Action::Resize {
491 target_width,
492 target_height,
493 } => format!(
494 "{}x{} -> {}x{}",
495 meta.width, meta.height, target_width, target_height
496 ),
497 policy::Action::Recompress { quality } => {
498 format!("recompress at quality {}", quality)
499 }
500 policy::Action::ConvertFormat { to } => {
501 format!("{} -> {}", meta.format, to)
502 }
503 policy::Action::RasterizeSvg {
504 target_width,
505 target_height,
506 } => format!("SVG -> PNG at {}x{}", target_width, target_height),
507 policy::Action::Drop { reason } => reason.clone(),
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::mode::DriveMode;
515 use serde_json::json;
516
517 fn make_png_data_uri(width: u32, height: u32) -> String {
518 use base64::Engine;
519 let img = image::RgbaImage::new(width, height);
520 let mut buf = Vec::new();
521 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
522 image::ImageEncoder::write_image(
523 encoder,
524 img.as_raw(),
525 width,
526 height,
527 image::ExtendedColorType::Rgba8,
528 )
529 .unwrap();
530 let b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
531 format!("data:image/png;base64,{}", b64)
532 }
533
534 fn make_anthropic_png_base64(width: u32, height: u32) -> String {
535 use base64::Engine;
536 let img = image::RgbaImage::new(width, height);
537 let mut buf = Vec::new();
538 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
539 image::ImageEncoder::write_image(
540 encoder,
541 img.as_raw(),
542 width,
543 height,
544 image::ExtendedColorType::Rgba8,
545 )
546 .unwrap();
547 base64::engine::general_purpose::STANDARD.encode(&buf)
548 }
549
550 #[test]
551 fn test_text_only_passthrough() {
552 let payload = json!({
553 "model": "gpt-4o",
554 "messages": [{"role": "user", "content": "Hello"}]
555 });
556 let config = ShiftConfig::default();
557 let (result, report) = process(&payload, &config).unwrap();
558 assert_eq!(result, payload);
559 assert_eq!(report.images_found, 0);
560 assert!(!report.has_changes());
561 }
562
563 #[test]
564 fn test_small_image_passthrough() {
565 let data_uri = make_png_data_uri(640, 480);
566 let payload = json!({
567 "model": "gpt-4o",
568 "messages": [{
569 "role": "user",
570 "content": [
571 {"type": "text", "text": "What's this?"},
572 {"type": "image_url", "image_url": {"url": data_uri}}
573 ]
574 }]
575 });
576 let config = ShiftConfig::default();
577 let (_result, report) = process(&payload, &config).unwrap();
578 assert_eq!(report.images_found, 1);
579 }
580
581 #[test]
582 fn test_oversized_image_resized_openai() {
583 let data_uri = make_png_data_uri(4000, 3000);
584 let payload = json!({
585 "model": "gpt-4o",
586 "messages": [{
587 "role": "user",
588 "content": [
589 {"type": "image_url", "image_url": {"url": data_uri}}
590 ]
591 }]
592 });
593 let config = ShiftConfig {
594 provider: "openai".to_string(),
595 mode: DriveMode::Balanced,
596 ..Default::default()
597 };
598 let (_result, report) = process(&payload, &config).unwrap();
599 assert_eq!(report.images_found, 1);
600 assert!(report.has_changes());
601 assert!(report.actions.iter().any(|a| a.action == "resize"));
602 }
603
604 #[test]
605 fn test_oversized_image_resized_anthropic() {
606 let b64 = make_anthropic_png_base64(4000, 3000);
607 let payload = json!({
608 "model": "claude-sonnet-4-20250514",
609 "messages": [{
610 "role": "user",
611 "content": [{
612 "type": "image",
613 "source": {"type": "base64", "media_type": "image/png", "data": b64}
614 }]
615 }]
616 });
617 let config = ShiftConfig {
618 provider: "anthropic".to_string(),
619 mode: DriveMode::Balanced,
620 ..Default::default()
621 };
622 let (_result, report) = process(&payload, &config).unwrap();
623 assert_eq!(report.images_found, 1);
624 assert!(report.has_changes());
625 }
626
627 #[test]
628 fn test_dry_run_no_modifications() {
629 let data_uri = make_png_data_uri(4000, 3000);
630 let payload = json!({
631 "model": "gpt-4o",
632 "messages": [{
633 "role": "user",
634 "content": [
635 {"type": "image_url", "image_url": {"url": data_uri.clone()}}
636 ]
637 }]
638 });
639 let config = ShiftConfig {
640 dry_run: true,
641 ..Default::default()
642 };
643 let (result, report) = process(&payload, &config).unwrap();
644 assert_eq!(result, payload);
646 assert!(report.has_changes());
648 assert!(report.dry_run);
649 assert!(report
650 .actions
651 .iter()
652 .any(|a| a.action.starts_with("would_")));
653 }
654
655 #[test]
656 fn test_svg_rasterization_in_openai_payload() {
657 use base64::Engine;
658 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="red"/></svg>"#;
659 let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
660 let data_uri = format!("data:image/svg+xml;base64,{}", b64);
661
662 let payload = json!({
663 "model": "gpt-4o",
664 "messages": [{
665 "role": "user",
666 "content": [
667 {"type": "image_url", "image_url": {"url": data_uri}}
668 ]
669 }]
670 });
671 let config = ShiftConfig {
672 svg_mode: SvgMode::Raster,
673 ..Default::default()
674 };
675 let (_result, report) = process(&payload, &config).unwrap();
676 assert_eq!(report.svgs_rasterized, 1);
677 assert!(report.actions.iter().any(|a| a.action == "rasterize_svg"));
678 }
679
680 #[test]
681 fn test_economy_mode_aggressive() {
682 let data_uri = make_png_data_uri(1500, 1000);
684 let payload = json!({
685 "model": "gpt-4o",
686 "messages": [{
687 "role": "user",
688 "content": [
689 {"type": "image_url", "image_url": {"url": data_uri}}
690 ]
691 }]
692 });
693 let config = ShiftConfig {
694 mode: DriveMode::Economy,
695 ..Default::default()
696 };
697 let (_result, report) = process(&payload, &config).unwrap();
698 assert!(report.has_changes());
699 }
700
701 fn make_anthropic_jpeg_base64(width: u32, height: u32) -> String {
702 use base64::Engine;
703 let img = image::RgbImage::new(width, height);
704 let mut buf = Vec::new();
705 let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
706 image::ImageEncoder::write_image(
707 encoder,
708 img.as_raw(),
709 width,
710 height,
711 image::ExtendedColorType::Rgb8,
712 )
713 .unwrap();
714 base64::engine::general_purpose::STANDARD.encode(&buf)
715 }
716
717 #[test]
718 fn test_resize_preserves_jpeg_format_in_anthropic_payload() {
719 let b64 = make_anthropic_jpeg_base64(4000, 3000);
720 let payload = json!({
721 "model": "claude-sonnet-4-20250514",
722 "messages": [{
723 "role": "user",
724 "content": [{
725 "type": "image",
726 "source": {"type": "base64", "media_type": "image/jpeg", "data": b64}
727 }]
728 }]
729 });
730 let config = ShiftConfig {
731 provider: "anthropic".to_string(),
732 mode: DriveMode::Balanced,
733 ..Default::default()
734 };
735 let (result, report) = process(&payload, &config).unwrap();
736
737 assert!(report.has_changes());
739 assert!(report.actions.iter().any(|a| a.action == "resize"));
740
741 let media_type = result["messages"][0]["content"][0]["source"]["media_type"]
743 .as_str()
744 .unwrap();
745 assert_eq!(
746 media_type, "image/jpeg",
747 "resized JPEG in Anthropic payload should retain image/jpeg media_type, got {}",
748 media_type
749 );
750
751 use base64::Engine;
753 let out_b64 = result["messages"][0]["content"][0]["source"]["data"]
754 .as_str()
755 .unwrap();
756 let out_bytes = base64::engine::general_purpose::STANDARD
757 .decode(out_b64)
758 .unwrap();
759 assert_eq!(
760 crate::inspector::detect_format(&out_bytes),
761 crate::inspector::MediaFormat::Jpeg,
762 "decoded image bytes should be JPEG format"
763 );
764
765 let img_metrics = &report.image_metrics[0];
767 assert_eq!(img_metrics.format_before, "jpeg");
768 assert_eq!(
769 img_metrics.format_after, "jpeg",
770 "report should show jpeg -> jpeg, not jpeg -> png"
771 );
772 }
773
774 #[test]
775 fn test_resize_preserves_png_format_in_anthropic_payload() {
776 let b64 = make_anthropic_png_base64(4000, 3000);
777 let payload = json!({
778 "model": "claude-sonnet-4-20250514",
779 "messages": [{
780 "role": "user",
781 "content": [{
782 "type": "image",
783 "source": {"type": "base64", "media_type": "image/png", "data": b64}
784 }]
785 }]
786 });
787 let config = ShiftConfig {
788 provider: "anthropic".to_string(),
789 mode: DriveMode::Balanced,
790 ..Default::default()
791 };
792 let (result, report) = process(&payload, &config).unwrap();
793
794 assert!(report.has_changes());
795
796 let media_type = result["messages"][0]["content"][0]["source"]["media_type"]
798 .as_str()
799 .unwrap();
800 assert_eq!(media_type, "image/png");
801 }
802
803 #[test]
804 fn test_performance_mode_minimal() {
805 let data_uri = make_png_data_uri(1500, 1000);
807 let payload = json!({
808 "model": "gpt-4o",
809 "messages": [{
810 "role": "user",
811 "content": [
812 {"type": "image_url", "image_url": {"url": data_uri}}
813 ]
814 }]
815 });
816 let config = ShiftConfig {
817 mode: DriveMode::Performance,
818 ..Default::default()
819 };
820 let (_result, report) = process(&payload, &config).unwrap();
821 assert!(!report.has_changes() || report.images_modified == 0);
823 }
824}