bamboo_server/
request_hooks.rs1use bamboo_infrastructure::api::models::{
8 ChatCompletionRequest, ChatMessage, Content, ContentPart,
9};
10use bamboo_infrastructure::Config;
11
12#[derive(Debug, thiserror::Error)]
13pub enum HookError {
14 #[error("Invalid hook configuration: {0}")]
15 InvalidConfig(String),
16 #[error("Request not supported: {0}")]
17 Unsupported(String),
18}
19
20pub fn apply_openai_preflight_hooks(
21 config: &Config,
22 _model: &str,
23 request: &mut ChatCompletionRequest,
24) -> Result<(), HookError> {
25 apply_image_fallback_hook(config, &mut request.messages)
26}
27
28pub fn apply_openai_preflight_hooks_to_messages(
29 config: &Config,
30 messages: &mut [ChatMessage],
31) -> Result<(), HookError> {
32 apply_image_fallback_hook(config, messages)
33}
34
35fn apply_image_fallback_hook(
36 config: &Config,
37 messages: &mut [ChatMessage],
38) -> Result<(), HookError> {
39 let hook_cfg = &config.hooks.image_fallback;
40 if !hook_cfg.enabled {
41 return Ok(());
42 }
43
44 let mode = hook_cfg.mode.trim().to_ascii_lowercase();
45 if mode != "placeholder" && mode != "error" {
46 return Err(HookError::InvalidConfig(format!(
47 "hooks.image_fallback.mode must be 'placeholder' or 'error' (got '{mode}')"
48 )));
49 }
50
51 let mut images_seen = 0usize;
52
53 for msg in messages.iter_mut() {
54 let Content::Parts(parts) = &msg.content else {
55 continue;
56 };
57
58 let mut out_text = String::new();
59 for part in parts.iter() {
60 match part {
61 ContentPart::Text { text } => out_text.push_str(text),
62 ContentPart::ImageUrl { image_url } => {
63 images_seen += 1;
64 if mode == "error" {
65 continue;
67 }
68
69 let summary = summarize_image_url(&image_url.url);
70 out_text.push_str("\n[Image omitted: ");
71 out_text.push_str(&summary);
72 out_text.push_str("]\n");
73 }
74 }
75 }
76
77 if mode == "placeholder" {
78 msg.content = Content::Text(out_text);
79 }
80 }
81
82 if images_seen > 0 && mode == "error" {
83 return Err(HookError::Unsupported(format!(
84 "This server does not currently support image inputs (found {images_seen} image part(s)). Configure hooks.image_fallback.mode='placeholder' to degrade gracefully."
85 )));
86 }
87
88 Ok(())
89}
90
91fn summarize_image_url(url: &str) -> String {
92 let trimmed = url.trim();
93 if trimmed.starts_with("data:") {
94 let mut mime = "unknown".to_string();
97 if let Some(semi_idx) = trimmed.find(';') {
98 let header = &trimmed["data:".len()..semi_idx];
99 if !header.trim().is_empty() {
100 mime = header.trim().to_string();
101 }
102 }
103
104 let approx_bytes = trimmed
105 .split_once(',')
106 .map(|(_, data)| {
107 let len = data.trim().len();
108 (len.saturating_mul(3)) / 4
110 })
111 .unwrap_or(0);
112
113 return format!("{mime} (~{approx_bytes} bytes)");
114 }
115
116 const MAX: usize = 120;
118 if trimmed.len() <= MAX {
119 trimmed.to_string()
120 } else {
121 format!("{}...", &trimmed[..MAX])
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use bamboo_infrastructure::api::models::{ImageUrl, Role};
129 use tempfile::TempDir;
130
131 fn base_config(mode: &str) -> Config {
132 let dir = TempDir::new().expect("tempdir");
133 let mut cfg = Config::from_data_dir(Some(dir.path().to_path_buf()));
134 cfg.hooks.image_fallback.enabled = true;
135 cfg.hooks.image_fallback.mode = mode.to_string();
136 cfg
137 }
138
139 #[test]
140 fn image_fallback_placeholder_rewrites_images_to_text_without_leaking_data() {
141 let cfg = base_config("placeholder");
142
143 let mut messages = vec![ChatMessage {
144 role: Role::User,
145 content: Content::Parts(vec![
146 ContentPart::Text {
147 text: "What is in this image?".to_string(),
148 },
149 ContentPart::ImageUrl {
150 image_url: ImageUrl {
151 url: "data:image/png;base64,AAAABBBBCCCC".to_string(),
152 detail: None,
153 },
154 },
155 ]),
156 phase: None,
157 tool_calls: None,
158 tool_call_id: None,
159 }];
160
161 apply_openai_preflight_hooks_to_messages(&cfg, &mut messages).expect("hook ok");
162
163 match &messages[0].content {
164 Content::Text(text) => {
165 assert!(text.contains("Image omitted: image/png"), "summary present");
166 assert!(!text.contains("AAAABBBBCCCC"), "base64 not leaked");
167 }
168 _ => panic!("expected Content::Text after rewrite"),
169 }
170 }
171
172 #[test]
173 fn image_fallback_error_rejects_requests_with_images() {
174 let cfg = base_config("error");
175
176 let mut messages = vec![ChatMessage {
177 role: Role::User,
178 content: Content::Parts(vec![ContentPart::ImageUrl {
179 image_url: ImageUrl {
180 url: "https://example.com/image.png".to_string(),
181 detail: None,
182 },
183 }]),
184 phase: None,
185 tool_calls: None,
186 tool_call_id: None,
187 }];
188
189 let err =
190 apply_openai_preflight_hooks_to_messages(&cfg, &mut messages).expect_err("should err");
191 assert!(err
192 .to_string()
193 .contains("does not currently support image inputs"));
194 }
195
196 #[test]
197 fn image_fallback_invalid_mode_errors() {
198 let cfg = base_config("wat");
199 let mut messages = Vec::new();
200 let err =
201 apply_openai_preflight_hooks_to_messages(&cfg, &mut messages).expect_err("should err");
202 assert!(matches!(err, HookError::InvalidConfig(_)));
203 }
204}