Skip to main content

bamboo_server/
request_hooks.rs

1//! Request preflight hooks.
2//!
3//! These hooks run before we convert/forward OpenAI-compatible requests into Bamboo's
4//! internal message format. They allow us to inspect and rewrite requests (e.g. image
5//! fallback handling) in a single, easy-to-extend place.
6
7use 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                        // Defer returning until after we count all images, so we can include a helpful message.
66                        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        // data:<mime>;base64,<data...>
95        // Keep summary stable and avoid ever echoing base64 content.
96        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                // Base64 is ~4/3 expansion.
109                (len.saturating_mul(3)) / 4
110            })
111            .unwrap_or(0);
112
113        return format!("{mime} (~{approx_bytes} bytes)");
114    }
115
116    // For normal URLs, truncate to keep logs/responses compact.
117    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}