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
17pub struct ResponsesRequestPolicy {
19 pub(crate) provider: Provider,
22 always_include_reasoning: bool,
24 default_effort: Option<ReasoningEffort>,
26 text_verbosity: Option<Verbosity>,
27 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 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#[derive(Clone, Copy, PartialEq, Eq)]
84enum ReasoningFormat {
85 Encrypted,
88 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 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
211fn 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
303fn 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); 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 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 let json = serde_json::to_value(&items[0]).unwrap();
625 assert_eq!(json["role"], "user");
626 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 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 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 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}