Skip to main content

llm/providers/openai_responses/
mappers.rs

1use async_openai::types::responses::{
2    CreateResponse, EasyInputContent, EasyInputMessage, FunctionCallOutput, FunctionCallOutputItemParam, FunctionTool,
3    FunctionToolCall, ImageDetail, IncludeEnum, InputContent, InputImageContent, InputItem, InputParam,
4    InputTextContent, Item, MessageType, Reasoning, ReasoningItem, ReasoningItemContent, ReasoningSummary,
5    ReasoningTextContent, ResponseTextParam, Role, TextResponseFormatConfiguration, Tool, Verbosity,
6};
7
8use schemars::Schema;
9
10use crate::catalog::Provider;
11use crate::providers::openai_compatible::PromptCacheKeySource;
12use crate::tool_schema::normalize_for_xiaomi;
13use crate::{
14    ChatMessage, ContentBlock, Context, LlmError, LlmModel, ReasoningEffort, Result, ToolCallRequest, ToolDefinition,
15};
16
17/// The per-provider decisions that shape an otherwise identical Responses request.
18pub struct ResponsesRequestPolicy {
19    /// Provider that owns the model — decides whose encrypted reasoning may be
20    /// replayed from earlier turns.
21    pub(crate) provider: Provider,
22    /// Send `reasoning` even when no effort was requested.
23    always_include_reasoning: bool,
24    /// Effort applied when the caller did not request one.
25    default_effort: Option<ReasoningEffort>,
26    text_verbosity: Option<Verbosity>,
27    /// `strict` sent on every function tool. The Responses API defaults this to
28    /// `true`, which rejects tool schemas that omit `additionalProperties`, so
29    /// `None` and `Some(false)` are not interchangeable.
30    tool_strict: Option<bool>,
31    parallel_tool_calls: Option<bool>,
32    tool_schema_transform: Option<fn(&mut Schema)>,
33    reasoning_format: ReasoningFormat,
34    prompt_cache_key: PromptCacheKeySource,
35}
36
37impl ResponsesRequestPolicy {
38    pub const OPENAI: Self = Self {
39        provider: Provider::Openai,
40        always_include_reasoning: false,
41        default_effort: None,
42        text_verbosity: None,
43        tool_strict: Some(false),
44        parallel_tool_calls: None,
45        tool_schema_transform: None,
46        reasoning_format: ReasoningFormat::Encrypted,
47        prompt_cache_key: PromptCacheKeySource::Prefix,
48    };
49
50    pub const XIAOMI: Self = Self {
51        provider: Provider::Xiaomi,
52        tool_schema_transform: Some(normalize_for_xiaomi),
53        reasoning_format: ReasoningFormat::PlainText,
54        prompt_cache_key: PromptCacheKeySource::Omit,
55        ..Self::OPENAI
56    };
57
58    #[cfg(feature = "codex")]
59    pub const CODEX: Self = Self {
60        provider: Provider::Codex,
61        always_include_reasoning: true,
62        default_effort: Some(ReasoningEffort::Medium),
63        text_verbosity: Some(Verbosity::Medium),
64        tool_strict: None,
65        parallel_tool_calls: Some(true),
66        ..Self::OPENAI
67    };
68
69    #[cfg(feature = "bedrock")]
70    pub const MANTLE: Self =
71        Self { provider: Provider::Bedrock, always_include_reasoning: true, tool_strict: None, ..Self::OPENAI };
72
73    /// Effort to send, if any — an explicit request beats the provider default.
74    fn effort(&self, context: &Context) -> Option<ReasoningEffort> {
75        match context.reasoning_effort() {
76            ReasoningEffort::Default => self.default_effort,
77            effort => Some(effort),
78        }
79    }
80}
81
82/// How a provider exposes reasoning and expects it back on later turns.
83#[derive(Clone, Copy, PartialEq, Eq)]
84enum ReasoningFormat {
85    /// Opaque encrypted items replayed only to the model that produced them,
86    /// alongside human-readable summaries.
87    Encrypted,
88    /// Raw reasoning text, streamed and replayed as-is with no summaries.
89    PlainText,
90}
91
92pub(crate) fn build_typed_request(
93    model: &str,
94    context: &Context,
95    policy: &ResponsesRequestPolicy,
96) -> Result<CreateResponse> {
97    let identity: Option<LlmModel> = format!("{}:{model}", policy.provider.parser_name()).parse().ok();
98    crate::provider::validate_reasoning(context, identity.as_ref())?;
99    let context = context.filter_encrypted_reasoning(identity.as_ref());
100    let encrypted = policy.reasoning_format == ReasoningFormat::Encrypted;
101    let (instructions, input) = map_messages(context.messages(), policy.reasoning_format)?;
102    let tools = if context.tools().is_empty() { None } else { Some(map_tools(context.tools(), policy)?) };
103    let settings = context.model_settings();
104    let reasoning = (policy.always_include_reasoning || policy.effort(&context).is_some()).then_some(Reasoning {
105        effort: None,
106        summary: (encrypted && context.reasoning_effort() != ReasoningEffort::Disabled)
107            .then_some(ReasoningSummary::Auto),
108        mode: None,
109        context: None,
110    });
111
112    let text = policy.text_verbosity.clone().map(|verbosity| ResponseTextParam {
113        format: TextResponseFormatConfiguration::Text,
114        verbosity: Some(verbosity),
115    });
116
117    Ok(CreateResponse {
118        model: Some(model.to_string()),
119        input: InputParam::Items(input),
120        instructions,
121        tools,
122        parallel_tool_calls: policy.parallel_tool_calls,
123        stream: Some(true),
124        store: Some(false),
125        max_output_tokens: settings.max_tokens,
126        temperature: settings.temperature,
127        top_p: settings.top_p,
128        reasoning,
129        include: encrypted.then_some(vec![IncludeEnum::ReasoningEncryptedContent]),
130        text,
131        prompt_cache_key: policy.prompt_cache_key.resolve(&context).map(String::from),
132        ..Default::default()
133    })
134}
135
136pub(crate) fn build_wire_request(
137    model: &str,
138    context: &Context,
139    policy: &ResponsesRequestPolicy,
140) -> Result<serde_json::Value> {
141    let effort = policy.effort(context);
142    let mut body = serde_json::to_value(build_typed_request(model, context, policy)?)?;
143
144    // async-openai's `ReasoningEffort` cannot express `max`, so the effort is
145    // written onto the serialized body instead of the typed request. Safe because
146    // `build_typed_request` emits `reasoning` whenever `policy.effort` is set.
147    if let Some(effort) = effort {
148        let wire = match effort {
149            ReasoningEffort::Default => return Ok(body),
150            ReasoningEffort::Disabled if policy.provider == Provider::Codex => "disabled",
151            ReasoningEffort::Disabled => "none",
152            ReasoningEffort::Minimal => "minimal",
153            ReasoningEffort::Low => "low",
154            ReasoningEffort::Medium => "medium",
155            ReasoningEffort::High => "high",
156            ReasoningEffort::Xhigh => "xhigh",
157            ReasoningEffort::Max => "max",
158        };
159        body["reasoning"]["effort"] = wire.into();
160    }
161    Ok(body)
162}
163
164pub(crate) fn map_user_content_for_responses(parts: &[ContentBlock]) -> Result<EasyInputContent> {
165    let mut items = Vec::with_capacity(parts.len());
166    for part in parts {
167        match part {
168            ContentBlock::Text { text } => {
169                items.push(InputContent::InputText(InputTextContent {
170                    text: text.clone(),
171                    prompt_cache_breakpoint: None,
172                }));
173            }
174            ContentBlock::Image { .. } => {
175                items.push(InputContent::InputImage(InputImageContent {
176                    detail: ImageDetail::Auto,
177                    file_id: None,
178                    image_url: Some(part.as_data_uri().expect("image content always has a data URI")),
179                    prompt_cache_breakpoint: None,
180                }));
181            }
182            ContentBlock::Audio { .. } => {
183                return Err(LlmError::UnsupportedContent("OpenAI Responses does not support audio input".into()));
184            }
185        }
186    }
187    Ok(EasyInputContent::ContentList(items))
188}
189
190impl From<FunctionToolCall> for ToolCallRequest {
191    fn from(call: FunctionToolCall) -> Self {
192        ToolCallRequest { id: call.call_id, name: call.name, arguments: call.arguments }
193    }
194}
195
196impl From<&ToolCallRequest> for FunctionToolCall {
197    fn from(call: &ToolCallRequest) -> Self {
198        FunctionToolCall {
199            call_id: call.id.clone(),
200            name: call.name.clone(),
201            arguments: call.arguments.clone(),
202            namespace: None,
203            id: None,
204            status: None,
205            caller: None,
206            r#async: None,
207        }
208    }
209}
210
211/// Map internal `ChatMessage`s to Responses API input items.
212///
213/// Returns `(system_prompt, input_items)` — the system prompt is extracted
214/// separately since the Responses API carries it as `instructions`.
215fn map_messages(
216    messages: &[ChatMessage],
217    reasoning_format: ReasoningFormat,
218) -> Result<(Option<String>, Vec<InputItem>)> {
219    let mut system_prompt = None;
220    let mut items = Vec::new();
221
222    for msg in messages {
223        match msg {
224            ChatMessage::System { content, .. } => {
225                system_prompt = Some(content.clone());
226            }
227            ChatMessage::User { content, .. } => {
228                items.push(InputItem::EasyMessage(EasyInputMessage {
229                    r#type: MessageType::Message,
230                    role: Role::User,
231                    content: map_user_content_for_responses(content)?,
232                    phase: None,
233                }));
234            }
235            ChatMessage::Assistant { content, tool_calls, reasoning, .. } => {
236                if !content.is_empty() {
237                    items.push(easy_message(Role::Assistant, content.clone()));
238                }
239                if reasoning_format == ReasoningFormat::PlainText
240                    && let Some(text) = &reasoning.summary_text
241                {
242                    items.push(InputItem::Item(Item::Reasoning(ReasoningItem {
243                        id: None,
244                        summary: vec![],
245                        encrypted_content: None,
246                        content: Some(vec![ReasoningItemContent::ReasoningText(ReasoningTextContent {
247                            text: text.clone(),
248                        })]),
249                        status: None,
250                    })));
251                }
252                if reasoning_format == ReasoningFormat::Encrypted
253                    && let Some(encrypted) = &reasoning.encrypted_content
254                {
255                    items.push(InputItem::Item(Item::Reasoning(ReasoningItem {
256                        id: Some(encrypted.id.clone()),
257                        summary: vec![],
258                        encrypted_content: Some(encrypted.content.clone()),
259                        content: None,
260                        status: None,
261                    })));
262                }
263                for tc in tool_calls {
264                    items.push(InputItem::Item(Item::FunctionCall(tc.into())));
265                }
266            }
267            ChatMessage::ToolCallResult(result) => match result {
268                Ok(r) => {
269                    items.push(InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
270                        call_id: Some(r.id.clone()),
271                        output: FunctionCallOutput::Text(r.result.clone()),
272                        id: None,
273                        status: None,
274                        name: None,
275                        namespace: None,
276                        caller: None,
277                    })));
278                }
279                Err(e) => {
280                    items.push(InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
281                        call_id: Some(e.id.clone()),
282                        output: FunctionCallOutput::Text(format!("Error: {}", e.error)),
283                        id: None,
284                        status: None,
285                        name: None,
286                        namespace: None,
287                        caller: None,
288                    })));
289                }
290            },
291            ChatMessage::Error { message, .. } => {
292                items.push(easy_message(Role::User, format!("[Error: {message}]")));
293            }
294            ChatMessage::Summary { content, .. } => {
295                items.push(easy_message(Role::User, format!("[Summary of previous conversation]\n{content}")));
296            }
297        }
298    }
299
300    Ok((system_prompt, items))
301}
302
303/// Map internal `ToolDefinition`s to async-openai `Tool` types.
304fn map_tools(tools: &[ToolDefinition], policy: &ResponsesRequestPolicy) -> Result<Vec<Tool>> {
305    tools
306        .iter()
307        .map(|tool| {
308            let parameters = match policy.tool_schema_transform {
309                Some(transform) => {
310                    let mut schema = Schema::try_from(tool.parameters.clone()).map_err(|error| {
311                        LlmError::ToolParameterParsing { tool_name: tool.name.clone(), error: error.to_string() }
312                    })?;
313                    transform(&mut schema);
314                    schema.into()
315                }
316                None => tool.parameters.clone(),
317            };
318            Ok(Tool::Function(FunctionTool {
319                name: tool.name.clone(),
320                description: Some(tool.description.clone()),
321                parameters: Some(parameters),
322                strict: policy.tool_strict,
323                defer_loading: None,
324                r#async: None,
325                output_schema: None,
326                allowed_callers: None,
327            }))
328        })
329        .collect()
330}
331
332fn easy_message(role: Role, content: String) -> InputItem {
333    InputItem::EasyMessage(EasyInputMessage { role, content: EasyInputContent::Text(content), ..Default::default() })
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::types::IsoString;
340    use crate::{
341        AssistantReasoning, ContentBlock, EncryptedReasoningContent, LlmError, MessageId, ToolCallError,
342        ToolCallRequest, ToolCallResult,
343    };
344
345    fn openai_request(model: &str, context: &Context) -> Result<CreateResponse> {
346        build_typed_request(model, context, &ResponsesRequestPolicy::OPENAI)
347    }
348
349    fn openai_body(model: &str, context: &Context) -> serde_json::Value {
350        build_wire_request(model, context, &ResponsesRequestPolicy::OPENAI).unwrap()
351    }
352
353    #[test]
354    fn build_request_maps_a_simple_user_message() {
355        let context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
356
357        let request = openai_request("gpt-4.1", &context).unwrap();
358        assert_eq!(request.model, Some("gpt-4.1".to_string()));
359        assert!(request.instructions.is_none());
360        assert!(request.tools.is_none());
361        assert!(request.reasoning.is_none());
362
363        let json = serde_json::to_value(&request).unwrap();
364        assert_eq!(json["input"][0]["role"], "user");
365        assert_eq!(json["input"][0]["content"][0]["text"], "Hello");
366    }
367
368    #[test]
369    fn build_request_lifts_the_system_prompt_into_instructions() {
370        let context = Context::new(vec![ChatMessage::system("You are helpful."), ChatMessage::user("Hi")], vec![]);
371
372        let request = openai_request("gpt-4.1", &context).unwrap();
373        assert_eq!(request.instructions, Some("You are helpful.".to_string()));
374
375        let json = serde_json::to_value(&request).unwrap();
376        let items = json["input"].as_array().unwrap();
377        assert_eq!(items.len(), 1);
378        assert_eq!(items[0]["role"], "user");
379    }
380
381    #[test]
382    fn build_request_maps_tool_calls_and_results() {
383        let context = Context::new(
384            vec![
385                ChatMessage::user("Search for rust"),
386                ChatMessage::Assistant {
387                    message_id: crate::MessageId::new(),
388                    content: String::new(),
389                    reasoning: AssistantReasoning::default(),
390                    timestamp: IsoString::now(),
391                    tool_calls: vec![ToolCallRequest {
392                        id: "call_1".to_string(),
393                        name: "search".to_string(),
394                        arguments: r#"{"q":"rust"}"#.to_string(),
395                    }],
396                },
397                ChatMessage::ToolCallResult(Ok(ToolCallResult {
398                    id: "call_1".to_string(),
399                    name: "search".to_string(),
400                    arguments: r#"{"q":"rust"}"#.to_string(),
401                    result: "Found results".to_string(),
402                })),
403            ],
404            vec![ToolDefinition::new("search", "Search", serde_json::json!({ "type": "object" }))],
405        );
406
407        let request = openai_request("gpt-4.1", &context).unwrap();
408        let json = serde_json::to_value(&request).unwrap();
409
410        let items = json["input"].as_array().unwrap();
411        assert_eq!(items[0]["role"], "user");
412        assert_eq!(items[1]["type"], "function_call");
413        assert_eq!(items[1]["call_id"], "call_1");
414        assert_eq!(items[2]["type"], "function_call_output");
415        assert_eq!(items[2]["call_id"], "call_1");
416        assert_eq!(items[2]["output"], "Found results");
417
418        let tools = serde_json::to_value(&request.tools).unwrap();
419        assert_eq!(tools[0]["type"], "function");
420        assert_eq!(tools[0]["name"], "search");
421    }
422
423    #[test]
424    fn build_request_applies_model_settings() {
425        let mut context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
426        context.set_model_settings(crate::ModelSettings {
427            temperature: Some(0.0),
428            top_p: Some(0.5),
429            max_tokens: Some(128),
430        });
431
432        let request = openai_request("gpt-4.1", &context).unwrap();
433        assert_eq!(request.temperature, Some(0.0));
434        assert_eq!(request.top_p, Some(0.5));
435        assert_eq!(request.max_output_tokens, Some(128));
436    }
437
438    #[test]
439    fn build_request_rejects_audio_content() {
440        let context = Context::new(
441            vec![ChatMessage::User {
442                message_id: MessageId::new(),
443                content: vec![ContentBlock::Audio { data: "YXVkaW8=".to_string(), mime_type: "audio/wav".to_string() }],
444                timestamp: IsoString::now(),
445            }],
446            vec![],
447        );
448
449        assert!(matches!(openai_request("gpt-4.1", &context), Err(LlmError::UnsupportedContent(_))));
450    }
451
452    #[test]
453    fn wire_request_carries_every_reasoning_effort() {
454        for effort in ReasoningEffort::all().iter().filter(|effort| effort.is_enabled()) {
455            let mut context = Context::new(vec![ChatMessage::user("Think")], vec![]);
456            context.set_reasoning_effort(*effort);
457
458            let body = openai_body("gpt-5.6", &context);
459            assert_eq!(body["reasoning"]["effort"], effort.as_str());
460            assert_eq!(body["reasoning"]["summary"], "auto");
461        }
462    }
463
464    #[test]
465    fn wire_request_omits_reasoning_without_an_effort() {
466        let context = Context::new(vec![ChatMessage::user("Hi")], vec![]);
467
468        assert!(openai_body("gpt-4.1", &context)["reasoning"].is_null());
469    }
470
471    #[cfg(feature = "codex")]
472    #[test]
473    fn a_provider_default_effort_always_ships_with_a_reasoning_object() {
474        let context = Context::new(vec![ChatMessage::user("Hi")], vec![]);
475
476        let body = build_wire_request("gpt-5.5", &context, &ResponsesRequestPolicy::CODEX).unwrap();
477
478        assert_eq!(body["reasoning"]["effort"], "medium");
479        assert_eq!(body["reasoning"]["summary"], "auto");
480    }
481
482    #[cfg(feature = "codex")]
483    #[test]
484    fn tool_strict_is_sent_verbatim_per_policy() {
485        let tools = vec![ToolDefinition::new("read_file", "Read a file", serde_json::json!({ "type": "object" }))];
486
487        let openai = serde_json::to_value(map_tools(&tools, &ResponsesRequestPolicy::OPENAI).unwrap()).unwrap();
488        let codex = serde_json::to_value(map_tools(&tools, &ResponsesRequestPolicy::CODEX).unwrap()).unwrap();
489
490        assert_eq!(openai[0]["strict"], false);
491        assert!(codex[0].get("strict").is_none(), "{codex}");
492    }
493
494    #[test]
495    fn map_messages_extracts_system_prompt() {
496        let messages = vec![ChatMessage::system("You are helpful"), ChatMessage::user("Hello")];
497
498        let (system, items) = map_messages(&messages, ReasoningFormat::Encrypted).unwrap();
499        assert_eq!(system, Some("You are helpful".to_string()));
500        assert_eq!(items.len(), 1);
501    }
502
503    #[test]
504    fn map_messages_handles_multi_turn_with_tool_calls() {
505        let messages = vec![
506            ChatMessage::user("Read foo.rs"),
507            ChatMessage::Assistant {
508                message_id: MessageId::new(),
509                content: "I'll read that file.".to_string(),
510                reasoning: AssistantReasoning::default(),
511                timestamp: IsoString::now(),
512                tool_calls: vec![ToolCallRequest {
513                    id: "call_1".to_string(),
514                    name: "read_file".to_string(),
515                    arguments: r#"{"path":"foo.rs"}"#.to_string(),
516                }],
517            },
518            ChatMessage::ToolCallResult(Ok(ToolCallResult {
519                id: "call_1".to_string(),
520                name: "read_file".to_string(),
521                arguments: r#"{"path":"foo.rs"}"#.to_string(),
522                result: "fn main() {}".to_string(),
523            })),
524            ChatMessage::Assistant {
525                message_id: MessageId::new(),
526                content: "Here's the file content.".to_string(),
527                reasoning: AssistantReasoning::default(),
528                timestamp: IsoString::now(),
529                tool_calls: vec![],
530            },
531        ];
532
533        let (system, items) = map_messages(&messages, ReasoningFormat::Encrypted).unwrap();
534        assert!(system.is_none());
535        assert_eq!(items.len(), 5); // user + assistant msg + function_call + function_call_output + assistant msg
536
537        // Verify the function_call item
538        let fc = &items[2];
539        if let InputItem::Item(Item::FunctionCall(call)) = fc {
540            assert_eq!(call.call_id, "call_1");
541            assert_eq!(call.name, "read_file");
542            assert_eq!(call.arguments, r#"{"path":"foo.rs"}"#);
543        } else {
544            panic!("Expected FunctionCall, got {fc:?}");
545        }
546
547        // Verify the function_call_output item
548        let fco = &items[3];
549        if let InputItem::Item(Item::FunctionCallOutput(out)) = fco {
550            assert_eq!(out.call_id.as_deref(), Some("call_1"));
551            assert!(matches!(&out.output, FunctionCallOutput::Text(t) if t == "fn main() {}"));
552        } else {
553            panic!("Expected FunctionCallOutput, got {fco:?}");
554        }
555    }
556
557    #[test]
558    fn map_messages_handles_tool_errors() {
559        let messages = vec![ChatMessage::ToolCallResult(Err(ToolCallError {
560            id: "call_2".to_string(),
561            name: "bash".to_string(),
562            arguments: Some("{}".to_string()),
563            error: "command failed".to_string(),
564        }))];
565
566        let (_, items) = map_messages(&messages, ReasoningFormat::Encrypted).unwrap();
567        assert_eq!(items.len(), 1);
568        if let InputItem::Item(Item::FunctionCallOutput(out)) = &items[0] {
569            assert!(matches!(&out.output, FunctionCallOutput::Text(t) if t.contains("Error: command failed")));
570        } else {
571            panic!("Expected FunctionCallOutput");
572        }
573    }
574
575    #[test]
576    fn map_messages_handles_summary() {
577        let messages = vec![ChatMessage::Summary {
578            message_id: MessageId::new(),
579            content: "User asked about Rust.".to_string(),
580            timestamp: IsoString::now(),
581            messages_compacted: 5,
582        }];
583
584        let (_, items) = map_messages(&messages, ReasoningFormat::Encrypted).unwrap();
585        assert_eq!(items.len(), 1);
586        if let InputItem::EasyMessage(msg) = &items[0] {
587            assert_eq!(msg.role, Role::User);
588            if let EasyInputContent::Text(text) = &msg.content {
589                assert!(text.contains("Summary"));
590                assert!(text.contains("Rust"));
591            } else {
592                panic!("Expected Text content");
593            }
594        } else {
595            panic!("Expected EasyMessage");
596        }
597    }
598
599    #[test]
600    fn map_messages_serialization_shape() {
601        let messages = vec![
602            ChatMessage::user("Hello"),
603            ChatMessage::Assistant {
604                message_id: MessageId::new(),
605                content: "Hi".to_string(),
606                reasoning: AssistantReasoning::default(),
607                timestamp: IsoString::now(),
608                tool_calls: vec![ToolCallRequest {
609                    id: "tc_1".to_string(),
610                    name: "bash".to_string(),
611                    arguments: "{}".to_string(),
612                }],
613            },
614            ChatMessage::ToolCallResult(Ok(ToolCallResult {
615                id: "tc_1".to_string(),
616                name: "bash".to_string(),
617                arguments: "{}".to_string(),
618                result: "ok".to_string(),
619            })),
620        ];
621
622        let (_, items) = map_messages(&messages, ReasoningFormat::Encrypted).unwrap();
623        // EasyMessage items serialize with "type": "message"
624        let json = serde_json::to_value(&items[0]).unwrap();
625        assert_eq!(json["role"], "user");
626        // FunctionCall items serialize with "type": "function_call"
627        let json = serde_json::to_value(&items[2]).unwrap();
628        assert_eq!(json["type"], "function_call");
629        assert_eq!(json["call_id"], "tc_1");
630        // FunctionCallOutput items serialize with "type": "function_call_output"
631        let json = serde_json::to_value(&items[3]).unwrap();
632        assert_eq!(json["type"], "function_call_output");
633        assert_eq!(json["call_id"], "tc_1");
634    }
635
636    #[test]
637    fn map_tools_produces_function_type() {
638        let tools = vec![ToolDefinition::new(
639            "read_file",
640            "Read a file from disk",
641            serde_json::from_str(r#"{"type": "object", "properties": {"path": {"type": "string"}}}"#).unwrap(),
642        )];
643
644        let mapped = map_tools(&tools, &ResponsesRequestPolicy::OPENAI).unwrap();
645        assert_eq!(mapped.len(), 1);
646        if let Tool::Function(f) = &mapped[0] {
647            assert_eq!(f.name, "read_file");
648            assert_eq!(f.description.as_deref(), Some("Read a file from disk"));
649            assert_eq!(f.parameters.as_ref().unwrap()["properties"]["path"]["type"], "string");
650        } else {
651            panic!("Expected Tool::Function");
652        }
653    }
654
655    #[test]
656    fn map_messages_includes_encrypted_reasoning_item() {
657        let messages = vec![ChatMessage::Assistant {
658            message_id: MessageId::new(),
659            content: "thinking done".to_string(),
660            reasoning: AssistantReasoning::from_parts(
661                "summary".to_string(),
662                Some(EncryptedReasoningContent {
663                    id: "r_1".to_string(),
664                    model: crate::LlmModel::Ollama("test".to_string()),
665                    content: "encrypted-blob".to_string(),
666                }),
667            ),
668            timestamp: IsoString::now(),
669            tool_calls: vec![],
670        }];
671
672        let (_, items) = map_messages(&messages, ReasoningFormat::Encrypted).unwrap();
673        // Should have: easy_message (text) + reasoning item = 2
674        assert_eq!(items.len(), 2);
675
676        let reasoning_item = &items[1];
677        if let InputItem::Item(Item::Reasoning(r)) = reasoning_item {
678            assert_eq!(r.encrypted_content.as_deref(), Some("encrypted-blob"));
679        } else {
680            panic!("Expected Item::Reasoning, got {reasoning_item:?}");
681        }
682    }
683
684    #[test]
685    fn map_messages_skips_reasoning_item_without_encrypted_content() {
686        let messages = vec![ChatMessage::Assistant {
687            message_id: MessageId::new(),
688            content: "no encrypted".to_string(),
689            reasoning: AssistantReasoning::from_parts("just a summary".to_string(), None),
690            timestamp: IsoString::now(),
691            tool_calls: vec![],
692        }];
693
694        let (_, items) = map_messages(&messages, ReasoningFormat::Encrypted).unwrap();
695        // Only the text message, no reasoning item
696        assert_eq!(items.len(), 1);
697        assert!(matches!(&items[0], InputItem::EasyMessage(_)));
698    }
699
700    #[test]
701    fn map_messages_with_audio_errors() {
702        let messages = vec![ChatMessage::User {
703            message_id: MessageId::new(),
704            content: vec![ContentBlock::Audio { data: "YXVkaW8=".to_string(), mime_type: "audio/wav".to_string() }],
705            timestamp: IsoString::now(),
706        }];
707
708        assert!(matches!(map_messages(&messages, ReasoningFormat::Encrypted), Err(LlmError::UnsupportedContent(_))));
709    }
710}