1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::completion::{self, CompletionError};
12use crate::message::{Message as RigMessage, MimeType, ReasoningContent};
13use crate::providers::openai::responses_api::ReasoningSummary;
14
15#[derive(Debug, Serialize, Deserialize)]
16struct CompletionRequest {
17 model: String,
18 input: Vec<Message>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 temperature: Option<f64>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 max_output_tokens: Option<u64>,
23 #[serde(skip_serializing_if = "Vec::is_empty")]
24 tools: Vec<Value>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 tool_choice: Option<crate::providers::openai::responses_api::ToolChoice>,
27 #[serde(flatten, skip_serializing_if = "Option::is_none")]
28 additional_params: Option<Value>,
29}
30
31fn normalize_strict_tool(mut tool: Value) -> Value {
32 if tool.get("type").and_then(Value::as_str) == Some("function") {
33 if let Some(parameters) = tool.get_mut("parameters") {
34 crate::providers::openai::sanitize_schema(parameters);
35 }
36 if let Some(tool) = tool.as_object_mut() {
37 tool.insert("strict".to_string(), Value::Bool(true));
38 }
39 }
40 tool
41}
42
43pub(crate) fn create_completion_request(
44 model: String,
45 req: crate::completion::CompletionRequest,
46 default_tools: &[crate::providers::openai::responses_api::ResponsesToolDefinition],
47 strict_tools: bool,
48 stream: bool,
49) -> Result<(String, Value), CompletionError> {
50 let chat_history = req.chat_history_with_documents();
51 if req.output_schema.is_some() {
52 tracing::warn!("Structured outputs currently not supported for xAI");
53 }
54 let model = req.model.clone().unwrap_or(model);
55 let mut input = req
56 .preamble
57 .as_ref()
58 .map_or_else(Vec::new, |p| vec![Message::system(p)]);
59 for message in chat_history {
60 input.extend(Vec::<Message>::try_from(message)?);
61 }
62 let input = crate::message::require_non_empty(input, || {
63 CompletionError::RequestError(
64 "no message in the chat history converted to xAI input \
65 (id-less reasoning-only content has no xAI representation)"
66 .into(),
67 )
68 })?;
69
70 let mut additional_params = req.additional_params.unwrap_or(Value::Null);
71 let mut additional_tools = if let Some(map) = additional_params.as_object_mut()
72 && let Some(raw_tools) = map.remove("tools")
73 {
74 serde_json::from_value::<Vec<Value>>(raw_tools).map_err(|err| {
75 CompletionError::RequestError(
76 format!("Invalid xAI `additional_params.tools` payload: {err}").into(),
77 )
78 })?
79 } else {
80 Vec::new()
81 };
82 let mut tools = req
83 .tools
84 .into_iter()
85 .map(ToolDefinition::from)
86 .map(serde_json::to_value)
87 .collect::<Result<Vec<_>, _>>()?;
88 tools.append(&mut additional_tools);
89 tools.extend(
90 default_tools
91 .iter()
92 .map(serde_json::to_value)
93 .collect::<Result<Vec<_>, _>>()?,
94 );
95 if strict_tools {
96 tools = tools.into_iter().map(normalize_strict_tool).collect();
97 }
98 if stream {
99 if additional_params.is_null() {
100 additional_params = serde_json::json!({});
101 }
102 crate::json_utils::merge_inplace(
103 &mut additional_params,
104 serde_json::json!({"stream": true}),
105 );
106 }
107
108 let request = CompletionRequest {
109 model: model.clone(),
110 input,
111 temperature: req.temperature,
112 max_output_tokens: req.max_tokens,
113 tools,
114 tool_choice: req
115 .tool_choice
116 .map(crate::providers::openai::responses_api::ToolChoice::try_from)
117 .transpose()?,
118 additional_params: (!additional_params.is_null()).then_some(additional_params),
119 };
120 Ok((model, serde_json::to_value(request)?))
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "snake_case")]
130#[allow(clippy::enum_variant_names)]
131pub enum Message {
132 Message { role: Role, content: Content },
134 FunctionCall {
136 call_id: String,
137 name: String,
138 arguments: String,
139 },
140 FunctionCallOutput { call_id: String, output: String },
142 Reasoning {
144 id: String,
145 summary: Vec<ReasoningSummary>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 encrypted_content: Option<String>,
148 },
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum Role {
154 System,
155 User,
156 Assistant,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(untagged)]
161pub enum Content {
162 Text(String),
163 Array(Vec<ContentItem>),
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(tag = "type")]
169pub enum ContentItem {
170 #[serde(rename = "input_text")]
171 Text { text: String },
172 #[serde(rename = "input_image")]
173 Image {
174 image_url: String,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 detail: Option<String>,
177 },
178 #[serde(rename = "input_file")]
179 File {
180 #[serde(skip_serializing_if = "Option::is_none")]
181 file_url: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 file_data: Option<String>,
184 },
185}
186
187impl Message {
188 pub fn system(content: impl Into<String>) -> Self {
189 Self::Message {
190 role: Role::System,
191 content: Content::Text(content.into()),
192 }
193 }
194
195 pub fn user(content: impl Into<String>) -> Self {
196 Self::Message {
197 role: Role::User,
198 content: Content::Text(content.into()),
199 }
200 }
201
202 pub fn user_with_content(content: Vec<ContentItem>) -> Self {
203 Self::Message {
204 role: Role::User,
205 content: Content::Array(content),
206 }
207 }
208
209 pub fn assistant(content: impl Into<String>) -> Self {
210 Self::Message {
211 role: Role::Assistant,
212 content: Content::Text(content.into()),
213 }
214 }
215
216 pub fn function_call(call_id: String, name: String, arguments: String) -> Self {
217 Self::FunctionCall {
218 call_id,
219 name,
220 arguments,
221 }
222 }
223
224 pub fn function_call_output(call_id: String, output: String) -> Self {
225 Self::FunctionCallOutput { call_id, output }
226 }
227
228 pub fn reasoning(
229 id: String,
230 summary: Vec<ReasoningSummary>,
231 encrypted_content: Option<String>,
232 ) -> Self {
233 Self::Reasoning {
234 id,
235 summary,
236 encrypted_content,
237 }
238 }
239}
240
241impl TryFrom<RigMessage> for Vec<Message> {
242 type Error = CompletionError;
243
244 fn try_from(msg: RigMessage) -> Result<Self, Self::Error> {
245 use crate::message::{
246 AssistantContent, Document, DocumentSourceKind, Image as RigImage, Text,
247 ToolResultContent, UserContent,
248 };
249
250 fn image_item(img: RigImage) -> Result<ContentItem, CompletionError> {
251 let url = match img.data {
252 DocumentSourceKind::Url(u) => u,
253 DocumentSourceKind::Base64(data) => {
254 let mime = img
255 .media_type
256 .map(|m| m.to_mime_type())
257 .unwrap_or("image/png");
258 format!("data:{mime};base64,{data}")
259 }
260 _ => {
261 return Err(CompletionError::RequestError(
262 "xAI does not support raw image data; use base64 or URL".into(),
263 ));
264 }
265 };
266 Ok(ContentItem::Image {
267 image_url: url,
268 detail: img.detail.map(|d| format!("{d:?}").to_lowercase()),
269 })
270 }
271
272 fn document_item(doc: Document) -> Result<ContentItem, CompletionError> {
273 let (file_data, file_url) = match doc.data {
274 DocumentSourceKind::Url(url) => (None, Some(url)),
275 DocumentSourceKind::Base64(data) => {
276 let mime = doc
277 .media_type
278 .map(|m| m.to_mime_type())
279 .unwrap_or("application/pdf");
280 (Some(format!("data:{mime};base64,{data}")), None)
281 }
282 DocumentSourceKind::String(text) => {
283 return Ok(ContentItem::Text { text });
285 }
286 _ => {
287 return Err(CompletionError::RequestError(
288 "xAI does not support raw document data; use base64 or URL".into(),
289 ));
290 }
291 };
292 Ok(ContentItem::File {
293 file_url,
294 file_data,
295 })
296 }
297
298 fn reasoning_item(
299 reasoning: crate::message::Reasoning,
300 ) -> Result<Option<Message>, CompletionError> {
301 let crate::message::Reasoning { id, content } = reasoning;
302 let Some(id) = id else {
309 tracing::warn!(
310 "xAI: dropping id-less reasoning item from request input \
311 (cross-provider replay; xAI reasoning requires a wire id)"
312 );
313 return Ok(None);
314 };
315 let mut encrypted_content = None;
316 let mut summary = Vec::new();
317 for reasoning_content in content {
318 match reasoning_content {
319 ReasoningContent::Text { text, .. } | ReasoningContent::Summary(text) => {
320 summary.push(ReasoningSummary::SummaryText { text });
321 }
322 ReasoningContent::Redacted { data } | ReasoningContent::Encrypted(data) => {
325 if encrypted_content.is_some() {
326 tracing::warn!(
327 "xAI: dropping additional encrypted/redacted reasoning block \
328 (API only supports one encrypted_content per item)"
329 );
330 }
331 encrypted_content.get_or_insert(data);
332 }
333 }
334 }
335
336 Ok(Some(Message::reasoning(id, summary, encrypted_content)))
337 }
338
339 match msg {
340 RigMessage::System { content } => Ok(vec![Message::system(content)]),
341 RigMessage::User { content } => {
342 let mut items = Vec::new();
343 let mut text_parts = Vec::new();
344 let mut content_items = Vec::new();
345 let mut has_images = false;
346
347 for c in content {
348 match c {
349 UserContent::Text(Text { text, .. }) => text_parts.push(text),
350 UserContent::Image(img) => {
351 has_images = true;
352 content_items.push(image_item(img)?);
353 }
354 UserContent::ToolResult(tr) => {
355 if has_images {
357 let mut msg_items: Vec<_> = text_parts
358 .drain(..)
359 .map(|t| ContentItem::Text { text: t })
360 .collect();
361 msg_items.append(&mut content_items);
362 if !msg_items.is_empty() {
363 items.push(Message::user_with_content(msg_items));
364 }
365 } else if !text_parts.is_empty() {
366 items.push(Message::user(text_parts.join("\n")));
367 text_parts.clear();
368 }
369 has_images = false;
370
371 let call_id = tr.wire_call_id().to_owned();
374 let output = tr
376 .content
377 .into_iter()
378 .map(|tc| match tc {
379 ToolResultContent::Text(t) => Ok(t.text),
380 ToolResultContent::Json { value } => Ok(value.to_string()),
381 ToolResultContent::Image(_) => {
382 Err(CompletionError::RequestError(
383 "xAI does not support images in tool results".into(),
384 ))
385 }
386 })
387 .collect::<Result<Vec<_>, _>>()?
388 .join("\n");
389 items.push(Message::function_call_output(call_id, output));
390 }
391 UserContent::Document(doc) => {
392 has_images = true; content_items.push(document_item(doc)?);
394 }
395 UserContent::Audio(_) => {
396 return Err(CompletionError::RequestError(
397 "xAI does not support audio".into(),
398 ));
399 }
400 UserContent::Video(_) => {
401 return Err(CompletionError::RequestError(
402 "xAI does not support video".into(),
403 ));
404 }
405 }
406 }
407
408 if has_images {
410 let mut msg_items: Vec<_> = text_parts
411 .into_iter()
412 .map(|t| ContentItem::Text { text: t })
413 .collect();
414 msg_items.append(&mut content_items);
415 if !msg_items.is_empty() {
416 items.push(Message::user_with_content(msg_items));
417 }
418 } else if !text_parts.is_empty() {
419 items.push(Message::user(text_parts.join("\n")));
420 }
421
422 Ok(items)
423 }
424 RigMessage::Assistant { content, .. } => {
425 let mut items = Vec::new();
426 let mut text_parts = Vec::new();
427 let flush_assistant_text =
428 |items: &mut Vec<Message>, text_parts: &mut Vec<String>| {
429 if !text_parts.is_empty() {
430 items.push(Message::assistant(text_parts.join("\n")));
431 text_parts.clear();
432 }
433 };
434
435 for c in content {
436 match c {
437 AssistantContent::Text(t) => text_parts.push(t.text),
438 AssistantContent::ToolCall(tc) => {
439 flush_assistant_text(&mut items, &mut text_parts);
440 let call_id = tc.wire_call_id().to_owned();
441 items.push(Message::function_call(
442 call_id,
443 tc.function.name,
444 tc.function.arguments.to_string(),
445 ));
446 }
447 AssistantContent::Reasoning(r) => {
448 flush_assistant_text(&mut items, &mut text_parts);
449 if let Some(item) = reasoning_item(r)? {
450 items.push(item);
451 }
452 }
453 AssistantContent::Image(_) => {
454 return Err(CompletionError::RequestError(
455 "xAI does not support images in assistant content".into(),
456 ));
457 }
458 }
459 }
460
461 if !text_parts.is_empty() {
463 items.push(Message::assistant(text_parts.join("\n")));
464 }
465
466 Ok(items)
467 }
468 }
469 }
470}
471
472#[derive(Clone, Debug, Deserialize, Serialize)]
473pub struct ToolDefinition {
474 pub r#type: String,
475 #[serde(flatten)]
476 pub function: completion::ToolDefinition,
477}
478
479impl From<completion::ToolDefinition> for ToolDefinition {
480 fn from(tool: completion::ToolDefinition) -> Self {
481 Self {
482 r#type: "function".to_string(),
483 function: tool,
484 }
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::{Content, Message, Role, create_completion_request};
491 use crate::completion::{CompletionRequest, CompletionRequestBuilder, Document};
492 use crate::message::{
493 AssistantContent, Message as RigMessage, Reasoning, ReasoningContent, ToolChoice,
494 ToolResultContent, UserContent,
495 };
496 use crate::providers::openai::responses_api::ReasoningSummary;
497 use crate::test_utils::MockCompletionModel;
498
499 fn request_value(request: CompletionRequest) -> serde_json::Value {
500 create_completion_request("grok-4-0709".to_string(), request, &[], false, false)
501 .expect("request conversion should succeed")
502 .1
503 }
504
505 #[test]
506 fn xai_request_includes_normalized_documents() {
507 let request = CompletionRequestBuilder::new(
508 MockCompletionModel::default(),
509 "What does glarb-glarb mean?",
510 )
511 .document(Document {
512 id: "doc_1".to_string(),
513 text: "Definition of glarb-glarb: an ancient tool.".to_string(),
514 additional_props: Default::default(),
515 })
516 .build();
517
518 let serialized = request_value(request);
519 let input = serialized["input"]
520 .as_array()
521 .expect("xAI request input should be an array");
522
523 assert!(
524 input
525 .iter()
526 .any(|message| message.to_string().contains("glarb-glarb")),
527 "normalized documents should be forwarded into xAI input"
528 );
529 }
530
531 #[test]
532 fn xai_direct_request_keeps_documents_after_system_messages() {
533 let request = CompletionRequest {
534 model: None,
535 preamble: None,
536 chat_history: vec![
537 RigMessage::system("System prompt"),
538 RigMessage::assistant("Earlier assistant turn"),
539 RigMessage::system("Mid-conversation instruction"),
540 RigMessage::user("What is glarb-glarb?"),
541 ],
542 documents: vec![Document {
543 id: "doc_1".to_string(),
544 text: "Definition of glarb-glarb: an ancient tool.".to_string(),
545 additional_props: Default::default(),
546 }],
547 tools: vec![],
548 temperature: None,
549 max_tokens: None,
550 tool_choice: None,
551 additional_params: None,
552 output_schema: None,
553 record_telemetry_content: false,
554 };
555
556 let serialized = request_value(request);
557 let input = serialized["input"]
558 .as_array()
559 .expect("xAI request input should be an array");
560
561 assert_eq!(input.len(), 5);
562 assert_eq!(input[0]["role"], "system");
563 assert_eq!(input[1]["role"], "user");
564 assert!(input[1].to_string().contains("<file id: doc_1>"));
565 assert_eq!(input[2]["role"], "assistant");
566 assert_eq!(input[3]["role"], "system");
567 assert_eq!(input[4]["role"], "user");
568 assert_eq!(
569 input
570 .iter()
571 .filter(|message| message.to_string().contains("<file id: doc_1>"))
572 .count(),
573 1,
574 "document input should appear exactly once: {input:?}"
575 );
576 }
577
578 #[test]
579 fn xai_request_uses_responses_tool_choice_for_specific_tool() {
580 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Use a tool.")
581 .tool(crate::completion::ToolDefinition {
582 name: "alpha".to_string(),
583 description: "Alpha tool".to_string(),
584 parameters: serde_json::json!({
585 "type": "object",
586 "properties": {},
587 "required": []
588 }),
589 })
590 .tool(crate::completion::ToolDefinition {
591 name: "beta".to_string(),
592 description: "Beta tool".to_string(),
593 parameters: serde_json::json!({
594 "type": "object",
595 "properties": {},
596 "required": []
597 }),
598 })
599 .tool_choice(ToolChoice::Specific {
600 function_names: vec!["beta".to_string()],
601 })
602 .build();
603
604 let serialized = request_value(request);
605 assert_eq!(
606 serialized["tool_choice"],
607 serde_json::json!({"type": "function", "name": "beta"})
608 );
609 }
610
611 #[test]
612 fn xai_stream_request_sets_stream_without_additional_params() {
613 let request =
614 CompletionRequestBuilder::new(MockCompletionModel::default(), "hello").build();
615 let (_, serialized) =
616 create_completion_request("grok-4-0709".to_string(), request, &[], false, true)
617 .expect("streaming request conversion should succeed");
618
619 assert_eq!(serialized["stream"], true);
620 }
621
622 #[test]
623 fn xai_strict_mode_normalizes_function_tools_from_every_source() {
624 let mut request =
625 CompletionRequestBuilder::new(MockCompletionModel::default(), "Use one of the tools.")
626 .tool(crate::completion::ToolDefinition {
627 name: "request_tool".to_string(),
628 description: "A request tool".to_string(),
629 parameters: serde_json::json!({
630 "type": "object",
631 "properties": {"request": {"type": "string"}}
632 }),
633 })
634 .build();
635 request.additional_params = Some(serde_json::json!({
636 "tools": [
637 {
638 "type": "function",
639 "name": "additional_tool",
640 "description": "An additional_params tool",
641 "parameters": {
642 "type": "object",
643 "properties": {"additional": {"type": "string"}}
644 }
645 },
646 {"type": "web_search"}
647 ]
648 }));
649 let default_tools = [
650 crate::providers::openai::responses_api::ResponsesToolDefinition::function(
651 "default_tool",
652 "A model-level default tool",
653 serde_json::json!({
654 "type": "object",
655 "properties": {"default": {"type": "string"}}
656 }),
657 ),
658 ];
659
660 let (_, serialized) = create_completion_request(
661 "grok-4-0709".to_string(),
662 request,
663 &default_tools,
664 true,
665 false,
666 )
667 .expect("request conversion should succeed");
668 let tools = serialized["tools"]
669 .as_array()
670 .expect("tools should be an array");
671
672 assert_eq!(tools.len(), 4);
673 for tool in tools.iter().filter(|tool| tool["type"] == "function") {
674 assert_eq!(tool["strict"], true);
675 assert_eq!(tool["parameters"]["additionalProperties"], false);
676 assert_eq!(
677 tool["parameters"]["required"]
678 .as_array()
679 .expect("strict object schema should require every property")
680 .len(),
681 1
682 );
683 }
684 assert_eq!(tools[2], serde_json::json!({"type": "web_search"}));
685 }
686
687 #[test]
688 fn mixed_user_content_preserves_order_without_duplicate_text() {
689 let message = RigMessage::User {
690 content: vec![
691 UserContent::text("before"),
692 UserContent::tool_result_with_call_id(
693 "result-id",
694 "call-id".to_string(),
695 "tool",
696 vec![ToolResultContent::json(serde_json::json!({ "ok": true }))],
697 ),
698 UserContent::text("after"),
699 ],
700 };
701
702 let messages = Vec::<Message>::try_from(message).expect("mixed content should convert");
703 assert_eq!(messages.len(), 3);
704 assert!(matches!(
705 &messages[0],
706 Message::Message {
707 role: Role::User,
708 content: Content::Text(text),
709 } if text == "before"
710 ));
711 assert!(matches!(
712 &messages[1],
713 Message::FunctionCallOutput { call_id, output }
714 if call_id == "call-id" && output == r#"{"ok":true}"#
715 ));
716 assert!(matches!(
717 &messages[2],
718 Message::Message {
719 role: Role::User,
720 content: Content::Text(text),
721 } if text == "after"
722 ));
723 }
724
725 #[test]
726 fn assistant_redacted_reasoning_is_serialized_as_encrypted_content() {
727 let reasoning = Reasoning {
728 id: Some("rs_1".to_string()),
729 content: vec![ReasoningContent::Redacted {
730 data: "opaque-redacted".to_string(),
731 }],
732 };
733 let message = RigMessage::Assistant {
734 id: Some("assistant_1".to_string()),
735 content: vec![AssistantContent::Reasoning(reasoning)],
736 };
737
738 let items = Vec::<Message>::try_from(message).expect("convert assistant message");
739 assert_eq!(items.len(), 1);
740 assert!(matches!(
741 items.first(),
742 Some(Message::Reasoning {
743 id,
744 summary,
745 encrypted_content: Some(encrypted_content),
746 }) if id == "rs_1" && summary.is_empty() && encrypted_content == "opaque-redacted"
747 ));
748 }
749
750 #[test]
751 fn assistant_redacted_reasoning_does_not_leak_into_summary_text() {
752 let reasoning = Reasoning {
753 id: Some("rs_2".to_string()),
754 content: vec![
755 ReasoningContent::Text {
756 text: "explain".to_string(),
757 signature: None,
758 },
759 ReasoningContent::Redacted {
760 data: "opaque-redacted".to_string(),
761 },
762 ],
763 };
764 let message = RigMessage::Assistant {
765 id: Some("assistant_2".to_string()),
766 content: vec![AssistantContent::Reasoning(reasoning)],
767 };
768
769 let items = Vec::<Message>::try_from(message).expect("convert assistant message");
770 let Some(Message::Reasoning {
771 summary,
772 encrypted_content,
773 ..
774 }) = items.first()
775 else {
776 panic!("Expected reasoning item");
777 };
778
779 assert_eq!(
780 summary,
781 &vec![ReasoningSummary::SummaryText {
782 text: "explain".to_string()
783 }]
784 );
785 assert_eq!(encrypted_content.as_deref(), Some("opaque-redacted"));
786 }
787
788 #[test]
789 fn assistant_empty_reasoning_content_roundtrips_without_error() {
790 let reasoning = Reasoning {
791 id: Some("rs_empty".to_string()),
792 content: vec![],
793 };
794 let message = RigMessage::Assistant {
795 id: Some("assistant_2b".to_string()),
796 content: vec![AssistantContent::Reasoning(reasoning)],
797 };
798
799 let items = Vec::<Message>::try_from(message).expect("convert assistant message");
800 assert_eq!(items.len(), 1);
801 assert!(matches!(
802 items.first(),
803 Some(Message::Reasoning {
804 id,
805 summary,
806 encrypted_content,
807 }) if id == "rs_empty" && summary.is_empty() && encrypted_content.is_none()
808 ));
809 }
810
811 #[test]
812 fn assistant_reasoning_without_id_is_dropped_from_request_input() {
813 let message = RigMessage::Assistant {
819 id: Some("assistant_no_reasoning_id".to_string()),
820 content: vec![AssistantContent::Reasoning(Reasoning::new("thinking"))],
821 };
822
823 let converted = Vec::<Message>::try_from(message).expect("conversion must not fail");
824 assert!(
825 converted
826 .iter()
827 .all(|item| !matches!(item, Message::Reasoning { .. })),
828 "an id-less reasoning item must not reach the request: {converted:?}"
829 );
830 }
831
832 #[test]
833 fn serialized_message_type_tags_are_snake_case() {
834 let function_call = Message::function_call(
835 "call_1".to_string(),
836 "tool_name".to_string(),
837 "{\"arg\":1}".to_string(),
838 );
839 let user_message = Message::user("hello");
840
841 let function_call_json =
842 serde_json::to_value(function_call).expect("serialize function_call");
843 let user_message_json = serde_json::to_value(user_message).expect("serialize message");
844
845 assert_eq!(
846 function_call_json
847 .get("type")
848 .and_then(|value| value.as_str()),
849 Some("function_call")
850 );
851 assert_eq!(
852 user_message_json
853 .get("type")
854 .and_then(|value| value.as_str()),
855 Some("message")
856 );
857 }
858
859 #[test]
860 fn user_tool_result_without_call_id_replays_the_minted_handle() {
861 let message = RigMessage::tool_result("", "tool_1", "result payload");
864
865 let converted = Vec::<Message>::try_from(message).expect("id-less tool results convert");
866 assert!(matches!(
867 converted.as_slice(),
868 [Message::FunctionCallOutput { call_id, output }]
869 if !call_id.is_empty() && output == "result payload"
870 ));
871 }
872
873 #[test]
874 fn assistant_tool_call_without_call_id_replays_the_minted_handle() {
875 let message = RigMessage::Assistant {
878 id: Some("assistant_3".to_string()),
879 content: vec![AssistantContent::tool_call(
880 "",
881 "my_tool",
882 serde_json::json!({"arg":"value"}),
883 )],
884 };
885
886 let converted = Vec::<Message>::try_from(message).expect("id-less tool calls convert");
887 assert!(matches!(
888 converted.as_slice(),
889 [Message::FunctionCall { call_id, name, .. }]
890 if !call_id.is_empty() && name == "my_tool"
891 ));
892 }
893}