Skip to main content

bamboo_llm/protocol/
openai.rs

1//! OpenAI protocol conversion implementation.
2
3use crate::api::models::{
4    ChatMessage as OpenAIChatMessage, Content as OpenAIContent, ContentPart as OpenAIContentPart,
5    Role as OpenAIRole, Tool, ToolCall as OpenAIToolCall,
6};
7use crate::models::ContentPart;
8use crate::protocol::{FromProvider, ProtocolResult, ToProvider};
9use bamboo_domain::{FunctionCall, ToolCall};
10use bamboo_domain::{FunctionSchema, ToolSchema};
11use bamboo_domain::{Message, MessagePart, MessagePhase, Role};
12
13/// OpenAI protocol converter.
14pub struct OpenAIProtocol;
15
16// ============================================================================
17// OpenAI → Internal (FromProvider)
18// ============================================================================
19
20impl FromProvider<OpenAIChatMessage> for Message {
21    fn from_provider(msg: OpenAIChatMessage) -> ProtocolResult<Self> {
22        let role = convert_openai_role_to_internal(&msg.role);
23
24        let (content, content_parts) = match msg.content {
25            OpenAIContent::Text(text) => (text, None),
26            OpenAIContent::Parts(parts) => {
27                // Preserve parts (including images) while also producing a text-only projection.
28                let text = parts
29                    .iter()
30                    .filter_map(|part| match part {
31                        OpenAIContentPart::Text { text } => Some(text.as_str()),
32                        OpenAIContentPart::ImageUrl { .. } => None,
33                    })
34                    .collect::<Vec<_>>()
35                    .join("");
36                let message_parts: Vec<MessagePart> = parts.into_iter().map(Into::into).collect();
37                (text, Some(message_parts))
38            }
39        };
40
41        let tool_calls = msg
42            .tool_calls
43            .map(|calls| calls.into_iter().map(ToolCall::from_provider).collect())
44            .transpose()?;
45        let phase = match msg.phase.as_deref() {
46            Some("commentary") => Some(MessagePhase::Commentary),
47            Some("final_answer") => Some(MessagePhase::FinalAnswer),
48            _ => None,
49        };
50
51        Ok(Message {
52            id: String::new(), // Will be generated if needed
53            role,
54            content,
55            reasoning: None,
56            reasoning_signature: None,
57            content_parts,
58            image_ocr: None,
59            phase,
60            tool_calls,
61            tool_call_id: msg.tool_call_id,
62            tool_success: None,
63            compressed: false,
64            compressed_by_event_id: None,
65            never_compress: false,
66            compression_level: 0,
67            created_at: chrono::Utc::now(),
68            metadata: None,
69        })
70    }
71}
72
73impl FromProvider<OpenAIToolCall> for ToolCall {
74    fn from_provider(tc: OpenAIToolCall) -> ProtocolResult<Self> {
75        Ok(ToolCall {
76            id: tc.id,
77            tool_type: tc.tool_type,
78            function: FunctionCall {
79                name: tc.function.name,
80                arguments: tc.function.arguments,
81            },
82        })
83    }
84}
85
86impl FromProvider<Tool> for ToolSchema {
87    fn from_provider(tool: Tool) -> ProtocolResult<Self> {
88        Ok(ToolSchema {
89            schema_type: tool.tool_type,
90            function: FunctionSchema {
91                name: tool.function.name,
92                description: tool.function.description.unwrap_or_default(),
93                parameters: tool.function.parameters,
94            },
95        })
96    }
97}
98
99// ============================================================================
100// Internal → OpenAI (ToProvider)
101// ============================================================================
102
103impl ToProvider<OpenAIChatMessage> for Message {
104    fn to_provider(&self) -> ProtocolResult<OpenAIChatMessage> {
105        let role = convert_internal_role_to_openai(&self.role);
106
107        let content = match self.content_parts.as_ref() {
108            Some(parts) => {
109                OpenAIContent::Parts(parts.iter().cloned().map(ContentPart::from).collect())
110            }
111            None => OpenAIContent::Text(self.content.clone()),
112        };
113
114        let tool_calls = self
115            .tool_calls
116            .as_ref()
117            .map(|calls| calls.iter().map(|tc| tc.to_provider()).collect())
118            .transpose()?;
119
120        Ok(OpenAIChatMessage {
121            role,
122            content,
123            phase: self.phase.as_ref().map(|phase| phase.as_str().to_string()),
124            tool_calls,
125            tool_call_id: self.tool_call_id.clone(),
126        })
127    }
128}
129
130impl ToProvider<OpenAIToolCall> for ToolCall {
131    fn to_provider(&self) -> ProtocolResult<OpenAIToolCall> {
132        Ok(OpenAIToolCall {
133            id: self.id.clone(),
134            tool_type: self.tool_type.clone(),
135            function: crate::api::models::FunctionCall {
136                name: self.function.name.clone(),
137                arguments: self.function.arguments.clone(),
138            },
139        })
140    }
141}
142
143impl ToProvider<Tool> for ToolSchema {
144    fn to_provider(&self) -> ProtocolResult<Tool> {
145        Ok(Tool {
146            tool_type: self.schema_type.clone(),
147            function: crate::api::models::FunctionDefinition {
148                name: self.function.name.clone(),
149                description: Some(self.function.description.clone()),
150                parameters: self.function.parameters.clone(),
151            },
152        })
153    }
154}
155
156// ============================================================================
157// Helper functions
158// ============================================================================
159
160fn convert_openai_role_to_internal(role: &OpenAIRole) -> Role {
161    match role {
162        OpenAIRole::System => Role::System,
163        OpenAIRole::User => Role::User,
164        OpenAIRole::Assistant => Role::Assistant,
165        OpenAIRole::Tool => Role::Tool,
166    }
167}
168
169fn convert_internal_role_to_openai(role: &Role) -> OpenAIRole {
170    match role {
171        Role::System => OpenAIRole::System,
172        Role::User => OpenAIRole::User,
173        Role::Assistant => OpenAIRole::Assistant,
174        Role::Tool => OpenAIRole::Tool,
175    }
176}
177
178// ============================================================================
179// Extension trait for ergonomic conversion
180// ============================================================================
181
182/// Extension trait for converting types with .into_internal() and .to_openai() (test-only)
183#[cfg(test)]
184pub trait OpenAIExt: Sized {
185    fn into_internal(self) -> ProtocolResult<Message>;
186    fn to_openai(&self) -> ProtocolResult<OpenAIChatMessage>;
187}
188
189#[cfg(test)]
190impl OpenAIExt for OpenAIChatMessage {
191    fn into_internal(self) -> ProtocolResult<Message> {
192        Message::from_provider(self)
193    }
194
195    fn to_openai(&self) -> ProtocolResult<OpenAIChatMessage> {
196        Ok(self.clone())
197    }
198}
199
200#[cfg(test)]
201impl OpenAIExt for Message {
202    fn into_internal(self) -> ProtocolResult<Message> {
203        Ok(self)
204    }
205
206    fn to_openai(&self) -> ProtocolResult<OpenAIChatMessage> {
207        self.to_provider()
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::api::models::{FunctionCall as OpenAIFunctionCall, Role as OpenAIRole};
215    use bamboo_domain::FunctionCall;
216    use bamboo_domain::Role;
217
218    #[test]
219    fn test_openai_to_internal_simple_message() {
220        let openai_msg = OpenAIChatMessage {
221            role: OpenAIRole::User,
222            content: OpenAIContent::Text("Hello".to_string()),
223            phase: None,
224            tool_calls: None,
225            tool_call_id: None,
226        };
227
228        let internal_msg: Message = openai_msg.into_internal().unwrap();
229
230        assert_eq!(internal_msg.role, Role::User);
231        assert_eq!(internal_msg.content, "Hello");
232        assert!(internal_msg.tool_calls.is_none());
233    }
234
235    #[test]
236    fn test_internal_to_openai_simple_message() {
237        let internal_msg = Message::user("Hello");
238
239        let openai_msg: OpenAIChatMessage = internal_msg.to_openai().unwrap();
240
241        assert_eq!(openai_msg.role, OpenAIRole::User);
242        assert!(matches!(openai_msg.content, OpenAIContent::Text(ref t) if t == "Hello"));
243        assert!(openai_msg.tool_calls.is_none());
244    }
245
246    #[test]
247    fn test_openai_to_internal_with_tool_call() {
248        let openai_msg = OpenAIChatMessage {
249            role: OpenAIRole::Assistant,
250            content: OpenAIContent::Text(String::new()),
251            phase: None,
252            tool_calls: Some(vec![OpenAIToolCall {
253                id: "call_1".to_string(),
254                tool_type: "function".to_string(),
255                function: OpenAIFunctionCall {
256                    name: "search".to_string(),
257                    arguments: r#"{"q":"test"}"#.to_string(),
258                },
259            }]),
260            tool_call_id: None,
261        };
262
263        let internal_msg: Message = Message::from_provider(openai_msg).unwrap();
264
265        assert_eq!(internal_msg.role, Role::Assistant);
266        assert!(internal_msg.tool_calls.is_some());
267        let tool_calls = internal_msg.tool_calls.unwrap();
268        assert_eq!(tool_calls.len(), 1);
269        assert_eq!(tool_calls[0].id, "call_1");
270        assert_eq!(tool_calls[0].function.name, "search");
271    }
272
273    #[test]
274    fn test_internal_to_openai_with_tool_call() {
275        let tool_call = ToolCall {
276            id: "call_1".to_string(),
277            tool_type: "function".to_string(),
278            function: FunctionCall {
279                name: "search".to_string(),
280                arguments: r#"{"q":"test"}"#.to_string(),
281            },
282        };
283
284        let internal_msg = Message::assistant("", Some(vec![tool_call]));
285
286        let openai_msg: OpenAIChatMessage = internal_msg.to_provider().unwrap();
287
288        assert_eq!(openai_msg.role, OpenAIRole::Assistant);
289        assert!(openai_msg.tool_calls.is_some());
290        let tool_calls = openai_msg.tool_calls.unwrap();
291        assert_eq!(tool_calls.len(), 1);
292        assert_eq!(tool_calls[0].id, "call_1");
293        assert_eq!(tool_calls[0].function.name, "search");
294        assert_eq!(tool_calls[0].function.arguments, r#"{"q":"test"}"#);
295    }
296
297    #[test]
298    fn test_roundtrip_conversion() {
299        let original = Message::user("Hello, world!");
300
301        // Internal → OpenAI
302        let openai_msg: OpenAIChatMessage = original.to_provider().unwrap();
303
304        // OpenAI → Internal
305        let roundtrip: Message = Message::from_provider(openai_msg).unwrap();
306
307        assert_eq!(roundtrip.role, original.role);
308        assert_eq!(roundtrip.content, original.content);
309    }
310
311    #[test]
312    fn test_tool_schema_conversion() {
313        let openai_tool = Tool {
314            tool_type: "function".to_string(),
315            function: crate::api::models::FunctionDefinition {
316                name: "search".to_string(),
317                description: Some("Search the web".to_string()),
318                parameters: serde_json::json!({
319                    "type": "object",
320                    "properties": {
321                        "q": { "type": "string" }
322                    }
323                }),
324            },
325        };
326
327        // OpenAI → Internal
328        let internal_schema: ToolSchema = ToolSchema::from_provider(openai_tool.clone()).unwrap();
329        assert_eq!(internal_schema.function.name, "search");
330
331        // Internal → OpenAI
332        let roundtrip: Tool = internal_schema.to_provider().unwrap();
333        assert_eq!(roundtrip.function.name, "search");
334        assert_eq!(
335            roundtrip.function.description,
336            Some("Search the web".to_string())
337        );
338    }
339
340    #[test]
341    fn test_extension_trait() {
342        let openai_msg = OpenAIChatMessage {
343            role: OpenAIRole::User,
344            content: OpenAIContent::Text("Test".to_string()),
345            phase: None,
346            tool_calls: None,
347            tool_call_id: None,
348        };
349
350        // Using extension trait
351        let internal = openai_msg.into_internal().unwrap();
352        assert_eq!(internal.content, "Test");
353    }
354}