1use super::InputAudio;
17use super::responses_api::streaming::StreamingCompletionResponse;
18use crate::completion::{CompletionError, GetTokenUsage};
19use crate::http_client;
20use crate::http_client::HttpClientExt;
21use crate::json_utils;
22use crate::message::{
23 AudioMediaType, Document, DocumentMediaType, DocumentSourceKind, ImageDetail, MessageError,
24 MimeType, Text,
25};
26use crate::one_or_many::string_or_one_or_many;
27use crate::telemetry::{CompletionOperation, CompletionSpanBuilder};
28
29use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
30use crate::{OneOrMany, completion, message};
31use serde::{Deserialize, Deserializer, Serialize, Serializer};
32use serde_json::{Map, Value};
33use tracing::{Instrument, Level, enabled};
34
35use std::convert::Infallible;
36use std::ops::Add;
37use std::str::FromStr;
38
39pub mod streaming;
40#[cfg(all(not(target_family = "wasm"), feature = "websocket"))]
41pub mod websocket;
42
43#[derive(Debug, Deserialize, Serialize, Clone)]
46pub struct CompletionRequest {
47 pub input: OneOrMany<InputItem>,
49 pub model: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub instructions: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub max_output_tokens: Option<u64>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub stream: Option<bool>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub temperature: Option<f64>,
63 #[serde(skip_serializing_if = "Option::is_none")]
66 tool_choice: Option<ToolChoice>,
67 #[serde(skip_serializing_if = "Vec::is_empty")]
70 pub tools: Vec<ResponsesToolDefinition>,
71 #[serde(flatten)]
73 pub additional_parameters: AdditionalParameters,
74}
75
76impl CompletionRequest {
77 pub fn with_structured_outputs<S>(mut self, schema_name: S, schema: serde_json::Value) -> Self
78 where
79 S: Into<String>,
80 {
81 self.additional_parameters.text = Some(TextConfig::structured_output(schema_name, schema));
82
83 self
84 }
85
86 pub fn with_reasoning(mut self, reasoning: Reasoning) -> Self {
87 self.additional_parameters.reasoning = Some(reasoning);
88
89 self
90 }
91
92 pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
96 self.tools.push(tool.into());
97 self
98 }
99
100 pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
103 where
104 I: IntoIterator<Item = Tool>,
105 Tool: Into<ResponsesToolDefinition>,
106 {
107 self.tools.extend(tools.into_iter().map(Into::into));
108 self
109 }
110}
111
112#[derive(Debug, Deserialize, Clone)]
114pub struct InputItem {
115 #[serde(skip_serializing_if = "Option::is_none")]
119 role: Option<Role>,
120 #[serde(flatten)]
122 input: InputContent,
123}
124
125impl Serialize for InputItem {
126 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127 where
128 S: serde::Serializer,
129 {
130 let mut value = serde_json::to_value(&self.input).map_err(serde::ser::Error::custom)?;
131 let map = value.as_object_mut().ok_or_else(|| {
132 serde::ser::Error::custom("Input content must serialize to an object")
133 })?;
134
135 if let Some(role) = &self.role
136 && !map.contains_key("role")
137 {
138 map.insert(
139 "role".to_string(),
140 serde_json::to_value(role).map_err(serde::ser::Error::custom)?,
141 );
142 }
143
144 value.serialize(serializer)
145 }
146}
147
148impl InputItem {
149 pub fn system_message(content: impl Into<String>) -> Self {
150 Self {
151 role: Some(Role::System),
152 input: InputContent::Message(Message::System {
153 content: OneOrMany::one(SystemContent::InputText {
154 text: content.into(),
155 }),
156 name: None,
157 }),
158 }
159 }
160
161 pub(crate) fn system_text(&self) -> Option<String> {
162 match &self.input {
163 InputContent::Message(Message::System { content, .. }) => Some(
164 content
165 .iter()
166 .map(|item| match item {
167 SystemContent::InputText { text } => text.as_str(),
168 })
169 .collect::<Vec<_>>()
170 .join("\n"),
171 ),
172 _ => None,
173 }
174 }
175}
176
177#[derive(Debug, Deserialize, Serialize, Clone)]
179#[serde(rename_all = "lowercase")]
180pub enum Role {
181 User,
182 Assistant,
183 System,
184}
185
186#[derive(Debug, Deserialize, Serialize, Clone)]
188#[serde(tag = "type", rename_all = "snake_case")]
189pub enum InputContent {
190 Message(Message),
191 Reasoning(OpenAIReasoning),
192 FunctionCall(OutputFunctionCall),
193 FunctionCallOutput(ToolResult),
194}
195
196#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
197pub struct OpenAIReasoning {
198 id: String,
199 pub summary: Vec<ReasoningSummary>,
200 #[serde(
201 default,
202 deserialize_with = "deserialize_reasoning_text_content",
203 serialize_with = "serialize_reasoning_text_content",
204 skip_serializing_if = "Vec::is_empty"
205 )]
206 pub content: Vec<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub encrypted_content: Option<String>,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub status: Option<ToolStatus>,
211}
212
213#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum ReasoningSummary {
216 SummaryText { text: String },
217}
218
219impl ReasoningSummary {
220 fn new(input: &str) -> Self {
221 Self::SummaryText {
222 text: input.to_string(),
223 }
224 }
225
226 pub fn text(&self) -> String {
227 let ReasoningSummary::SummaryText { text } = self;
228 text.clone()
229 }
230}
231
232fn reasoning_text_content_json(content: &[String]) -> Value {
233 Value::Array(
234 content
235 .iter()
236 .map(|text| {
237 serde_json::json!({
238 "type": "reasoning_text",
239 "text": text,
240 })
241 })
242 .collect(),
243 )
244}
245
246fn serialize_reasoning_text_content<S>(content: &[String], serializer: S) -> Result<S::Ok, S::Error>
247where
248 S: Serializer,
249{
250 reasoning_text_content_json(content).serialize(serializer)
251}
252
253fn deserialize_reasoning_text_content<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
254where
255 D: Deserializer<'de>,
256{
257 let value = Value::deserialize(deserializer)?;
258 Ok(match value {
259 Value::Array(items) => items
260 .into_iter()
261 .filter_map(|item| match item {
262 Value::Object(mut item) => item
263 .remove("text")
264 .and_then(|text| text.as_str().map(ToOwned::to_owned)),
265 Value::String(text) => Some(text),
266 _ => None,
267 })
268 .collect(),
269 Value::String(text) => vec![text],
270 _ => Vec::new(),
271 })
272}
273
274#[derive(Debug, Deserialize, Serialize, Clone)]
276pub struct ToolResult {
277 call_id: String,
279 output: ToolResultOutput,
281 status: ToolStatus,
283}
284
285#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
288#[serde(untagged)]
289pub enum ToolResultOutput {
290 Text(String),
292 Content(Vec<ToolResultOutputContent>),
294}
295
296#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
298#[serde(tag = "type", rename_all = "snake_case")]
299pub enum ToolResultOutputContent {
300 InputText {
302 text: String,
304 },
305 InputImage {
307 #[serde(skip_serializing_if = "Option::is_none")]
309 image_url: Option<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
313 file_id: Option<String>,
314 #[serde(default)]
316 detail: ImageDetail,
317 },
318}
319
320fn responses_tool_result_output(
321 content: OneOrMany<message::ToolResultContent>,
322) -> Result<ToolResultOutput, MessageError> {
323 let mut rich_output = Vec::new();
324
325 for content in content {
326 match content {
327 message::ToolResultContent::Text(Text { text, .. }) => {
328 rich_output.push(ToolResultOutputContent::InputText { text });
329 }
330 message::ToolResultContent::Json { value } => {
331 rich_output.push(ToolResultOutputContent::InputText {
332 text: value.to_string(),
333 });
334 }
335 message::ToolResultContent::Image(message::Image {
336 data,
337 media_type,
338 detail,
339 ..
340 }) => {
341 let (image_url, file_id) = match data {
342 DocumentSourceKind::Base64(data) => {
343 let media_type = media_type.ok_or_else(|| {
344 MessageError::ConversionError(
345 "A media type is required for base64 tool-result images".into(),
346 )
347 })?;
348 (
349 Some(format!(
350 "data:{media_type};base64,{data}",
351 media_type = media_type.to_mime_type()
352 )),
353 None,
354 )
355 }
356 DocumentSourceKind::Url(url) => (Some(url), None),
357 DocumentSourceKind::FileId(file_id) => (None, Some(file_id)),
358 unsupported => {
359 return Err(MessageError::ConversionError(format!(
360 "Unsupported tool-result image source: {unsupported}"
361 )));
362 }
363 };
364 rich_output.push(ToolResultOutputContent::InputImage {
365 image_url,
366 file_id,
367 detail: detail.unwrap_or_default(),
368 });
369 }
370 }
371 }
372
373 match rich_output.as_slice() {
374 [ToolResultOutputContent::InputText { text }] => Ok(ToolResultOutput::Text(text.clone())),
375
376 _ => Ok(ToolResultOutput::Content(rich_output)),
377 }
378}
379
380impl From<Message> for InputItem {
381 fn from(value: Message) -> Self {
382 match value {
383 Message::User { .. } => Self {
384 role: Some(Role::User),
385 input: InputContent::Message(value),
386 },
387 Message::Assistant { ref content, .. } => {
388 let role = if content
389 .iter()
390 .any(|x| matches!(x, AssistantContentType::Reasoning(_)))
391 {
392 None
393 } else {
394 Some(Role::Assistant)
395 };
396 Self {
397 role,
398 input: InputContent::Message(value),
399 }
400 }
401 Message::AssistantInput { .. } => Self {
402 role: Some(Role::Assistant),
403 input: InputContent::Message(value),
404 },
405 Message::System { .. } => Self {
406 role: Some(Role::System),
407 input: InputContent::Message(value),
408 },
409 Message::ToolResult {
410 tool_call_id,
411 output,
412 } => Self {
413 role: None,
414 input: InputContent::FunctionCallOutput(ToolResult {
415 call_id: tool_call_id,
416 output,
417 status: ToolStatus::Completed,
418 }),
419 },
420 }
421 }
422}
423
424impl TryFrom<crate::completion::Message> for Vec<InputItem> {
425 type Error = CompletionError;
426
427 fn try_from(value: crate::completion::Message) -> Result<Self, Self::Error> {
428 match value {
429 crate::completion::Message::System { content } => Ok(vec![InputItem {
430 role: Some(Role::System),
431 input: InputContent::Message(Message::System {
432 content: OneOrMany::one(content.into()),
433 name: None,
434 }),
435 }]),
436 crate::completion::Message::User { content } => {
437 let mut items = Vec::new();
438
439 for user_content in content {
440 match user_content {
441 crate::message::UserContent::Text(Text { text, .. }) => {
442 items.push(InputItem {
443 role: Some(Role::User),
444 input: InputContent::Message(Message::User {
445 content: OneOrMany::one(UserContent::InputText { text }),
446 name: None,
447 }),
448 });
449 }
450 crate::message::UserContent::ToolResult(
451 crate::completion::message::ToolResult {
452 call_id,
453 content: tool_content,
454 ..
455 },
456 ) => {
457 let output =
458 responses_tool_result_output(tool_content).map_err(|error| {
459 CompletionError::ProviderError(error.to_string())
460 })?;
461 items.push(InputItem {
462 role: None,
463 input: InputContent::FunctionCallOutput(ToolResult {
464 call_id: require_call_id(call_id, "Tool result")?,
465 output,
466 status: ToolStatus::Completed,
467 }),
468 });
469 }
470 crate::message::UserContent::Document(Document {
471 data: DocumentSourceKind::FileId(file_id),
472 ..
473 }) => items.push(InputItem {
474 role: Some(Role::User),
475 input: InputContent::Message(Message::User {
476 content: OneOrMany::one(UserContent::InputFile {
477 file_id: Some(file_id),
478 file_data: None,
479 file_url: None,
480 filename: None,
481 }),
482 name: None,
483 }),
484 }),
485 crate::message::UserContent::Document(Document {
486 data,
487 media_type: Some(DocumentMediaType::PDF),
488 ..
489 }) => {
490 let (file_data, file_url, filename) = match data {
491 DocumentSourceKind::Base64(data) => (
492 Some(format!("data:application/pdf;base64,{data}")),
493 None,
494 Some("document.pdf".to_string()),
495 ),
496 DocumentSourceKind::Url(url) => (None, Some(url), None),
497 DocumentSourceKind::Raw(_) => {
498 return Err(CompletionError::RequestError(
499 "Raw file data not supported, encode as base64 first"
500 .into(),
501 ));
502 }
503 doc => {
504 return Err(CompletionError::RequestError(
505 format!("Unsupported document type: {doc}").into(),
506 ));
507 }
508 };
509
510 items.push(InputItem {
511 role: Some(Role::User),
512 input: InputContent::Message(Message::User {
513 content: OneOrMany::one(UserContent::InputFile {
514 file_id: None,
515 file_data,
516 file_url,
517 filename,
518 }),
519 name: None,
520 }),
521 })
522 }
523 crate::message::UserContent::Document(Document {
524 data:
525 DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text),
526 ..
527 }) => items.push(InputItem {
528 role: Some(Role::User),
529 input: InputContent::Message(Message::User {
530 content: OneOrMany::one(UserContent::InputText { text }),
531 name: None,
532 }),
533 }),
534 crate::message::UserContent::Image(crate::message::Image {
535 data,
536 media_type,
537 detail,
538 ..
539 }) => {
540 let url = match data {
541 DocumentSourceKind::Base64(data) => {
542 let media_type = if let Some(media_type) = media_type {
543 media_type.to_mime_type().to_string()
544 } else {
545 String::new()
546 };
547 format!("data:{media_type};base64,{data}")
548 }
549 DocumentSourceKind::Url(url) => url,
550 DocumentSourceKind::Raw(_) => {
551 return Err(CompletionError::RequestError(
552 "Raw file data not supported, encode as base64 first"
553 .into(),
554 ));
555 }
556 doc => {
557 return Err(CompletionError::RequestError(
558 format!("Unsupported document type: {doc}").into(),
559 ));
560 }
561 };
562 items.push(InputItem {
563 role: Some(Role::User),
564 input: InputContent::Message(Message::User {
565 content: OneOrMany::one(UserContent::InputImage {
566 image_url: url,
567 detail: detail.unwrap_or_default(),
568 }),
569 name: None,
570 }),
571 });
572 }
573 message => {
574 return Err(CompletionError::ProviderError(format!(
575 "Unsupported message: {message:?}"
576 )));
577 }
578 }
579 }
580
581 Ok(items)
582 }
583 crate::completion::Message::Assistant { id, content } => {
584 let mut reasoning_items = Vec::new();
585 let mut other_items = Vec::new();
586
587 for assistant_content in content {
588 match assistant_content {
589 crate::message::AssistantContent::Text(Text { text, .. }) => {
590 if text.is_empty() {
591 continue;
592 }
593 let message = if let Some(id) = id.clone() {
594 Message::Assistant {
595 content: OneOrMany::one(AssistantContentType::Text(
596 AssistantContent::OutputText(Text::new(text)),
597 )),
598 id,
599 name: None,
600 status: ToolStatus::Completed,
601 }
602 } else {
603 Message::AssistantInput {
604 content: text,
605 name: None,
606 }
607 };
608
609 other_items.push(InputItem {
610 role: Some(Role::Assistant),
611 input: InputContent::Message(message),
612 });
613 }
614 crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
615 id: tool_id,
616 call_id,
617 function,
618 ..
619 }) => {
620 other_items.push(InputItem {
621 role: None,
622 input: InputContent::FunctionCall(OutputFunctionCall {
623 arguments: function.arguments,
624 call_id: require_call_id(call_id, "Assistant tool call")?,
625 id: tool_id,
626 name: function.name,
627 status: ToolStatus::Completed,
628 }),
629 });
630 }
631 crate::message::AssistantContent::Reasoning(reasoning) => {
632 let openai_reasoning = openai_reasoning_from_core(&reasoning)
633 .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
634 if let Some(openai_reasoning) = openai_reasoning {
635 reasoning_items.push(InputItem {
636 role: None,
637 input: InputContent::Reasoning(openai_reasoning),
638 });
639 }
640 }
641 crate::message::AssistantContent::Image(_) => {
642 return Err(CompletionError::ProviderError(
643 "Assistant image content is not supported in OpenAI Responses API"
644 .to_string(),
645 ));
646 }
647 }
648 }
649
650 let mut items = reasoning_items;
651 items.extend(other_items);
652 Ok(items)
653 }
654 }
655 }
656}
657
658impl From<OneOrMany<String>> for Vec<ReasoningSummary> {
659 fn from(value: OneOrMany<String>) -> Self {
660 value.iter().map(|x| ReasoningSummary::new(x)).collect()
661 }
662}
663
664fn require_call_id(call_id: Option<String>, context: &str) -> Result<String, CompletionError> {
665 call_id.ok_or_else(|| {
666 CompletionError::RequestError(
667 format!("{context} `call_id` is required for OpenAI Responses API").into(),
668 )
669 })
670}
671
672fn openai_reasoning_from_core(
673 reasoning: &crate::message::Reasoning,
674) -> Result<Option<OpenAIReasoning>, MessageError> {
675 let Some(id) = reasoning.id.clone() else {
676 return Ok(None);
677 };
678
679 let mut summary = Vec::new();
680 let mut reasoning_content = Vec::new();
681 let mut encrypted_content = None;
682 for content in &reasoning.content {
683 match content {
684 crate::message::ReasoningContent::Text { text, .. } => {
685 reasoning_content.push(text.clone());
686 }
687 crate::message::ReasoningContent::Summary(text) => {
688 summary.push(ReasoningSummary::new(text));
689 }
690 crate::message::ReasoningContent::Encrypted(data)
693 | crate::message::ReasoningContent::Redacted { data } => {
694 encrypted_content.get_or_insert_with(|| data.clone());
695 }
696 }
697 }
698
699 Ok(Some(OpenAIReasoning {
700 id,
701 summary,
702 content: reasoning_content,
703 encrypted_content,
704 status: None,
705 }))
706}
707
708#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
710pub struct ResponsesToolDefinition {
711 #[serde(rename = "type")]
713 pub kind: String,
714 #[serde(default, skip_serializing_if = "String::is_empty")]
716 pub name: String,
717 #[serde(default, skip_serializing_if = "is_json_null")]
719 pub parameters: serde_json::Value,
720 #[serde(
723 default,
724 skip_serializing_if = "is_false",
725 deserialize_with = "json_utils::null_or_default"
726 )]
727 pub strict: bool,
728 #[serde(default, skip_serializing_if = "String::is_empty")]
730 pub description: String,
731 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
733 pub config: Map<String, Value>,
734}
735
736fn is_json_null(value: &Value) -> bool {
737 value.is_null()
738}
739
740fn is_false(value: &bool) -> bool {
741 !value
742}
743
744impl ResponsesToolDefinition {
745 pub fn function(
747 name: impl Into<String>,
748 description: impl Into<String>,
749 parameters: serde_json::Value,
750 ) -> Self {
751 Self {
752 kind: "function".to_string(),
753 name: name.into(),
754 parameters,
755 strict: false,
756 description: description.into(),
757 config: Map::new(),
758 }
759 }
760
761 pub fn strict_function(
766 name: impl Into<String>,
767 description: impl Into<String>,
768 parameters: serde_json::Value,
769 ) -> Self {
770 Self::function(name, description, parameters).with_strict()
771 }
772
773 pub fn with_strict(mut self) -> Self {
778 if self.kind == "function" {
779 super::sanitize_schema(&mut self.parameters);
780 self.strict = true;
781 }
782 self
783 }
784
785 pub fn hosted(kind: impl Into<String>) -> Self {
787 Self {
788 kind: kind.into(),
789 name: String::new(),
790 parameters: Value::Null,
791 strict: false,
792 description: String::new(),
793 config: Map::new(),
794 }
795 }
796
797 pub fn web_search() -> Self {
799 Self::hosted("web_search")
800 }
801
802 pub fn file_search() -> Self {
804 Self::hosted("file_search")
805 }
806
807 pub fn computer_use() -> Self {
809 Self::hosted("computer_use")
810 }
811
812 pub fn with_config(mut self, key: impl Into<String>, value: Value) -> Self {
814 self.config.insert(key.into(), value);
815 self
816 }
817
818 fn normalize(self) -> Self {
819 self.with_strict()
820 }
821}
822
823impl From<completion::ToolDefinition> for ResponsesToolDefinition {
824 fn from(value: completion::ToolDefinition) -> Self {
825 let completion::ToolDefinition {
826 name,
827 parameters,
828 description,
829 } = value;
830
831 Self::function(name, description, parameters)
832 }
833}
834
835#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
843#[serde(untagged)]
844pub enum ToolChoice {
845 Mode(super::completion::ToolChoice),
850 Definition(ToolChoiceDefinition),
852}
853
854#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
856#[serde(tag = "type", rename_all = "snake_case")]
857pub enum ToolChoiceDefinition {
858 Function {
860 name: String,
862 },
863 AllowedTools {
865 mode: AllowedToolsMode,
868 tools: Vec<AllowedTool>,
870 },
871}
872
873#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
876#[serde(rename_all = "snake_case")]
877pub enum AllowedToolsMode {
878 Auto,
880 Required,
882}
883
884#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
886#[serde(tag = "type", rename_all = "snake_case")]
887pub enum AllowedTool {
888 Function {
890 name: String,
892 },
893}
894
895impl TryFrom<message::ToolChoice> for ToolChoice {
896 type Error = CompletionError;
897
898 fn try_from(value: message::ToolChoice) -> Result<Self, Self::Error> {
899 let choice = match value {
900 message::ToolChoice::Auto => Self::Mode(super::completion::ToolChoice::Auto),
901 message::ToolChoice::None => Self::Mode(super::completion::ToolChoice::None),
902 message::ToolChoice::Required => Self::Mode(super::completion::ToolChoice::Required),
903 message::ToolChoice::Specific { function_names } => {
904 let mut names = function_names.into_iter();
905 let Some(first) = names.next() else {
906 return Err(CompletionError::RequestError(
907 "ToolChoice::Specific requires at least one function name".into(),
908 ));
909 };
910
911 match names.next() {
912 None => Self::Definition(ToolChoiceDefinition::Function { name: first }),
913 Some(second) => {
914 let tools = std::iter::once(first)
915 .chain(std::iter::once(second))
916 .chain(names)
917 .map(|name| AllowedTool::Function { name })
918 .collect();
919 Self::Definition(ToolChoiceDefinition::AllowedTools {
920 mode: AllowedToolsMode::Required,
921 tools,
922 })
923 }
924 }
925 }
926 };
927
928 Ok(choice)
929 }
930}
931
932#[derive(Clone, Debug, Serialize, Deserialize)]
935pub struct ResponsesUsage {
936 pub input_tokens: u64,
938 #[serde(skip_serializing_if = "Option::is_none")]
940 pub input_tokens_details: Option<InputTokensDetails>,
941 pub output_tokens: u64,
943 #[serde(skip_serializing_if = "Option::is_none")]
945 pub output_tokens_details: Option<OutputTokensDetails>,
946 pub total_tokens: u64,
948}
949
950impl ResponsesUsage {
951 pub(crate) fn new() -> Self {
953 Self {
954 input_tokens: 0,
955 input_tokens_details: Some(InputTokensDetails::new()),
956 output_tokens: 0,
957 output_tokens_details: Some(OutputTokensDetails::new()),
958 total_tokens: 0,
959 }
960 }
961}
962
963impl GetTokenUsage for ResponsesUsage {
964 fn token_usage(&self) -> crate::completion::Usage {
965 crate::completion::Usage {
966 input_tokens: self.input_tokens,
967 output_tokens: self.output_tokens,
968 total_tokens: self.total_tokens,
969 cached_input_tokens: self
970 .input_tokens_details
971 .as_ref()
972 .map(|details| details.cached_tokens)
973 .unwrap_or(0),
974 cache_creation_input_tokens: 0,
975 tool_use_prompt_tokens: 0,
976 reasoning_tokens: self
977 .output_tokens_details
978 .as_ref()
979 .map(|details| details.reasoning_tokens)
980 .unwrap_or(0),
981 }
982 }
983}
984
985impl Add for ResponsesUsage {
986 type Output = Self;
987
988 fn add(self, rhs: Self) -> Self::Output {
989 let input_tokens = self.input_tokens + rhs.input_tokens;
990 let input_tokens_details = match (self.input_tokens_details, rhs.input_tokens_details) {
991 (Some(lhs), Some(rhs)) => Some(lhs + rhs),
992 (Some(lhs), None) => Some(lhs),
993 (None, Some(rhs)) => Some(rhs),
994 (None, None) => None,
995 };
996 let output_tokens = self.output_tokens + rhs.output_tokens;
997 let output_tokens_details = match (self.output_tokens_details, rhs.output_tokens_details) {
998 (Some(lhs), Some(rhs)) => Some(lhs + rhs),
999 (Some(lhs), None) => Some(lhs),
1000 (None, Some(rhs)) => Some(rhs),
1001 (None, None) => None,
1002 };
1003 let total_tokens = self.total_tokens + rhs.total_tokens;
1004 Self {
1005 input_tokens,
1006 input_tokens_details,
1007 output_tokens,
1008 output_tokens_details,
1009 total_tokens,
1010 }
1011 }
1012}
1013
1014#[derive(Clone, Debug, Serialize, Deserialize)]
1016pub struct InputTokensDetails {
1017 pub cached_tokens: u64,
1019}
1020
1021impl InputTokensDetails {
1022 pub(crate) fn new() -> Self {
1023 Self { cached_tokens: 0 }
1024 }
1025}
1026
1027impl Add for InputTokensDetails {
1028 type Output = Self;
1029 fn add(self, rhs: Self) -> Self::Output {
1030 Self {
1031 cached_tokens: self.cached_tokens + rhs.cached_tokens,
1032 }
1033 }
1034}
1035
1036#[derive(Clone, Debug, Serialize, Deserialize)]
1038pub struct OutputTokensDetails {
1039 pub reasoning_tokens: u64,
1041}
1042
1043impl OutputTokensDetails {
1044 pub(crate) fn new() -> Self {
1045 Self {
1046 reasoning_tokens: 0,
1047 }
1048 }
1049}
1050
1051impl Add for OutputTokensDetails {
1052 type Output = Self;
1053 fn add(self, rhs: Self) -> Self::Output {
1054 Self {
1055 reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
1056 }
1057 }
1058}
1059
1060#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1062pub struct IncompleteDetailsReason {
1063 pub reason: String,
1065}
1066
1067#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1069pub struct ResponseError {
1070 pub code: String,
1072 pub message: String,
1074}
1075
1076#[derive(Clone, Debug, Deserialize, Serialize)]
1078#[serde(rename_all = "snake_case")]
1079pub enum ResponseObject {
1080 Response,
1081}
1082
1083#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1085#[serde(rename_all = "snake_case")]
1086pub enum ResponseStatus {
1087 InProgress,
1088 Completed,
1089 Failed,
1090 Cancelled,
1091 Queued,
1092 Incomplete,
1093}
1094
1095#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1097#[non_exhaustive]
1098pub enum SystemInstructionsPlacement {
1099 #[default]
1104 Instructions,
1105 AllInstructions,
1110 InputSystemMessages,
1115}
1116
1117pub trait ResponsesProviderExt {
1123 fn system_instructions_placement(&self) -> SystemInstructionsPlacement;
1130}
1131
1132impl TryFrom<(String, crate::completion::CompletionRequest)> for CompletionRequest {
1134 type Error = CompletionError;
1135 fn try_from(
1136 (model, request): (String, crate::completion::CompletionRequest),
1137 ) -> Result<Self, Self::Error> {
1138 Self::try_from(ResponsesRequestParams {
1139 model,
1140 request,
1141 system_instructions_placement: SystemInstructionsPlacement::default(),
1142 })
1143 }
1144}
1145
1146pub struct ResponsesRequestParams {
1149 pub model: String,
1150 pub request: crate::completion::CompletionRequest,
1151 pub system_instructions_placement: SystemInstructionsPlacement,
1152}
1153
1154impl TryFrom<ResponsesRequestParams> for CompletionRequest {
1155 type Error = CompletionError;
1156
1157 fn try_from(params: ResponsesRequestParams) -> Result<Self, Self::Error> {
1158 let ResponsesRequestParams {
1159 model,
1160 request: mut req,
1161 system_instructions_placement,
1162 } = params;
1163 let chat_history = req.chat_history_with_documents();
1164 let model = req.model.clone().unwrap_or(model);
1165 let preamble = req.preamble.take();
1166 let mut instruction_parts = Vec::new();
1167 let mut input = {
1168 let mut partial_history = vec![];
1169 partial_history.extend(chat_history);
1170
1171 let mut full_history: Vec<InputItem> = preamble
1172 .map(InputItem::system_message)
1173 .into_iter()
1174 .collect();
1175
1176 for history_item in partial_history {
1177 full_history.extend(<Vec<InputItem>>::try_from(history_item)?);
1178 }
1179
1180 full_history
1181 };
1182
1183 let mut lift_system_text = |text: String| {
1184 let text = text.trim();
1185 if !text.is_empty() {
1186 instruction_parts.push(text.to_string());
1187 }
1188 };
1189 let items_before_lift = input.len();
1190 match system_instructions_placement {
1191 SystemInstructionsPlacement::Instructions => {
1192 let leading_system_texts: Vec<String> =
1198 input.iter().map_while(InputItem::system_text).collect();
1199 if leading_system_texts.len() < input.len() {
1200 input.drain(..leading_system_texts.len());
1201 leading_system_texts
1202 .into_iter()
1203 .for_each(&mut lift_system_text);
1204 }
1205 }
1206 SystemInstructionsPlacement::AllInstructions => {
1207 let mut remaining = Vec::with_capacity(input.len());
1210 for item in input {
1211 match item.system_text() {
1212 Some(text) => lift_system_text(text),
1213 None => remaining.push(item),
1214 }
1215 }
1216 input = remaining;
1217 }
1218 SystemInstructionsPlacement::InputSystemMessages => {}
1219 }
1220 let instructions = (!instruction_parts.is_empty()).then(|| instruction_parts.join("\n\n"));
1221 let lifted_system_items = input.len() < items_before_lift;
1222
1223 let input = OneOrMany::many(input).map_err(|_| {
1224 CompletionError::RequestError(if lifted_system_items {
1225 "OpenAI Responses request input must contain at least one non-system item \
1226 (system messages were lifted into the top-level `instructions` field)"
1227 .into()
1228 } else {
1229 "OpenAI Responses request input must contain at least one item".into()
1230 })
1231 })?;
1232
1233 let mut additional_params_payload = req.additional_params.take().unwrap_or(Value::Null);
1234 let stream = match &additional_params_payload {
1235 Value::Bool(stream) => Some(*stream),
1236 Value::Object(map) => map.get("stream").and_then(Value::as_bool),
1237 _ => None,
1238 };
1239
1240 let mut additional_tools = Vec::new();
1241 if let Some(additional_params_map) = additional_params_payload.as_object_mut() {
1242 if let Some(raw_tools) = additional_params_map.remove("tools") {
1243 additional_tools = serde_json::from_value::<Vec<ResponsesToolDefinition>>(
1244 raw_tools,
1245 )
1246 .map_err(|err| {
1247 CompletionError::RequestError(
1248 format!(
1249 "Invalid OpenAI Responses tools payload in additional_params: {err}"
1250 )
1251 .into(),
1252 )
1253 })?;
1254 }
1255 additional_params_map.remove("stream");
1256 }
1257
1258 if additional_params_payload.is_boolean() {
1259 additional_params_payload = Value::Null;
1260 }
1261
1262 let mut additional_parameters = if additional_params_payload.is_null() {
1263 AdditionalParameters::default()
1265 } else {
1266 serde_json::from_value::<AdditionalParameters>(additional_params_payload).map_err(
1267 |err| {
1268 CompletionError::RequestError(
1269 format!("Invalid OpenAI Responses additional_params payload: {err}").into(),
1270 )
1271 },
1272 )?
1273 };
1274 if additional_parameters.reasoning.is_some() {
1275 let include = additional_parameters.include.get_or_insert_with(Vec::new);
1276 if !include
1277 .iter()
1278 .any(|item| matches!(item, Include::ReasoningEncryptedContent))
1279 {
1280 include.push(Include::ReasoningEncryptedContent);
1281 }
1282 }
1283
1284 if additional_parameters.text.is_none()
1286 && let Some(schema) = req.output_schema
1287 {
1288 let name = schema
1289 .as_object()
1290 .and_then(|o| o.get("title"))
1291 .and_then(|v| v.as_str())
1292 .unwrap_or("response_schema")
1293 .to_string();
1294 let mut schema_value = schema.to_value();
1295 super::sanitize_schema(&mut schema_value);
1296 additional_parameters.text = Some(TextConfig::structured_output(name, schema_value));
1297 }
1298
1299 let tool_choice = req.tool_choice.map(ToolChoice::try_from).transpose()?;
1300 let mut tools: Vec<ResponsesToolDefinition> = req
1301 .tools
1302 .into_iter()
1303 .map(ResponsesToolDefinition::from)
1304 .collect();
1305 tools.append(&mut additional_tools);
1306
1307 Ok(Self {
1308 input,
1309 model,
1310 instructions,
1311 max_output_tokens: req.max_tokens,
1312 stream,
1313 tool_choice,
1314 tools,
1315 temperature: req.temperature,
1316 additional_parameters,
1317 })
1318 }
1319}
1320
1321#[doc(hidden)]
1323#[derive(Clone)]
1324pub struct GenericResponsesCompletionModel<Ext = super::OpenAIResponsesExt, H = reqwest::Client> {
1325 pub(crate) client: crate::client::Client<Ext, H>,
1327 pub model: String,
1329 pub tools: Vec<ResponsesToolDefinition>,
1331 pub strict_tools: bool,
1334 system_instructions_placement: SystemInstructionsPlacement,
1335}
1336
1337pub type ResponsesCompletionModel<H = reqwest::Client> =
1342 GenericResponsesCompletionModel<super::OpenAIResponsesExt, H>;
1343
1344impl<Ext, H> GenericResponsesCompletionModel<Ext, H>
1345where
1346 crate::client::Client<Ext, H>: HttpClientExt + Clone + std::fmt::Debug + 'static,
1347 Ext: crate::client::Provider + ResponsesProviderExt + Clone + 'static,
1348 H: Clone + Default + std::fmt::Debug + 'static,
1349{
1350 pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
1352 let system_instructions_placement = client.ext().system_instructions_placement();
1353 Self {
1354 client,
1355 model: model.into(),
1356 tools: Vec::new(),
1357 strict_tools: false,
1358 system_instructions_placement,
1359 }
1360 }
1361
1362 pub fn with_model(client: crate::client::Client<Ext, H>, model: &str) -> Self {
1363 Self::new(client, model)
1364 }
1365
1366 pub fn with_strict_tools(mut self) -> Self {
1371 self.strict_tools = true;
1372 self
1373 }
1374
1375 pub fn with_system_instructions_placement(
1379 mut self,
1380 placement: SystemInstructionsPlacement,
1381 ) -> Self {
1382 self.system_instructions_placement = placement;
1383 self
1384 }
1385
1386 pub fn with_system_instructions_as_messages(self) -> Self {
1393 self.with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
1394 }
1395
1396 pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
1398 self.tools.push(tool.into());
1399 self
1400 }
1401
1402 pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
1404 where
1405 I: IntoIterator<Item = Tool>,
1406 Tool: Into<ResponsesToolDefinition>,
1407 {
1408 self.tools.extend(tools.into_iter().map(Into::into));
1409 self
1410 }
1411
1412 pub(crate) fn create_completion_request(
1414 &self,
1415 completion_request: crate::completion::CompletionRequest,
1416 ) -> Result<CompletionRequest, CompletionError> {
1417 let mut req = CompletionRequest::try_from(ResponsesRequestParams {
1418 model: self.model.clone(),
1419 request: completion_request,
1420 system_instructions_placement: self.system_instructions_placement,
1421 })?;
1422 req.tools.extend(self.tools.clone());
1423
1424 if self.strict_tools {
1425 req.tools = req
1426 .tools
1427 .into_iter()
1428 .map(ResponsesToolDefinition::normalize)
1429 .collect();
1430 }
1431
1432 Ok(req)
1433 }
1434}
1435
1436impl<T> GenericResponsesCompletionModel<super::OpenAIResponsesExt, T>
1437where
1438 T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
1439{
1440 pub fn completions_api(self) -> crate::providers::openai::completion::CompletionModel<T> {
1442 super::completion::CompletionModel::new(self.client.completions_api(), &self.model)
1443 }
1444}
1445
1446#[derive(Clone, Debug)]
1448pub struct CompletionResponse {
1449 pub id: String,
1451 pub object: ResponseObject,
1453 pub created_at: u64,
1455 pub status: ResponseStatus,
1457 pub error: Option<ResponseError>,
1459 pub incomplete_details: Option<IncompleteDetailsReason>,
1461 pub instructions: Option<String>,
1463 pub max_output_tokens: Option<u64>,
1465 pub model: String,
1467 pub provider_reasoning: Option<String>,
1470 pub reasoning_metadata: Option<Map<String, Value>>,
1479 pub reasoning_context: Option<String>,
1485 pub usage: Option<ResponsesUsage>,
1487 pub output: Vec<Output>,
1489 pub tools: Vec<ResponsesToolDefinition>,
1491 pub additional_parameters: AdditionalParameters,
1493}
1494
1495#[derive(Serialize)]
1496#[serde(untagged)]
1497enum CompletionResponseReasoningRef<'a> {
1498 Text(&'a str),
1499 Metadata(&'a Map<String, Value>),
1500 Context { context: &'a str },
1501}
1502
1503#[derive(Serialize)]
1504struct CompletionResponseWireRef<'a> {
1505 id: &'a str,
1506 object: &'a ResponseObject,
1507 created_at: u64,
1508 status: &'a ResponseStatus,
1509 error: &'a Option<ResponseError>,
1510 incomplete_details: &'a Option<IncompleteDetailsReason>,
1511 instructions: &'a Option<String>,
1512 max_output_tokens: &'a Option<u64>,
1513 model: &'a str,
1514 #[serde(skip_serializing_if = "Option::is_none")]
1515 reasoning: Option<CompletionResponseReasoningRef<'a>>,
1516 usage: &'a Option<ResponsesUsage>,
1517 output: &'a Vec<Output>,
1518 tools: &'a Vec<ResponsesToolDefinition>,
1519 #[serde(flatten)]
1520 additional_parameters: &'a AdditionalParameters,
1521}
1522
1523#[derive(Deserialize)]
1524struct CompletionResponseWire {
1525 id: String,
1526 object: ResponseObject,
1527 created_at: u64,
1528 status: ResponseStatus,
1529 error: Option<ResponseError>,
1530 incomplete_details: Option<IncompleteDetailsReason>,
1531 instructions: Option<String>,
1532 max_output_tokens: Option<u64>,
1533 model: String,
1534 #[serde(default)]
1535 reasoning: Option<Value>,
1536 usage: Option<ResponsesUsage>,
1537 #[serde(default)]
1538 output: Vec<Output>,
1539 #[serde(default)]
1540 tools: Vec<ResponsesToolDefinition>,
1541 #[serde(flatten)]
1542 additional_parameters: AdditionalParameters,
1543}
1544
1545impl Serialize for CompletionResponse {
1546 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1547 where
1548 S: Serializer,
1549 {
1550 let mut additional_parameters = self.additional_parameters.clone();
1555 additional_parameters.reasoning = None;
1556
1557 let reasoning = self
1558 .provider_reasoning
1559 .as_deref()
1560 .map(CompletionResponseReasoningRef::Text)
1561 .or_else(|| {
1562 self.reasoning_metadata
1563 .as_ref()
1564 .map(CompletionResponseReasoningRef::Metadata)
1565 })
1566 .or_else(|| {
1567 self.reasoning_context
1568 .as_deref()
1569 .map(|context| CompletionResponseReasoningRef::Context { context })
1570 });
1571
1572 CompletionResponseWireRef {
1573 id: &self.id,
1574 object: &self.object,
1575 created_at: self.created_at,
1576 status: &self.status,
1577 error: &self.error,
1578 incomplete_details: &self.incomplete_details,
1579 instructions: &self.instructions,
1580 max_output_tokens: &self.max_output_tokens,
1581 model: &self.model,
1582 reasoning,
1583 usage: &self.usage,
1584 output: &self.output,
1585 tools: &self.tools,
1586 additional_parameters: &additional_parameters,
1587 }
1588 .serialize(serializer)
1589 }
1590}
1591
1592impl<'de> Deserialize<'de> for CompletionResponse {
1593 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1594 where
1595 D: Deserializer<'de>,
1596 {
1597 let response = CompletionResponseWire::deserialize(deserializer)?;
1598 let (provider_reasoning, reasoning_metadata) = match response.reasoning {
1599 Some(Value::String(reasoning)) => (Some(reasoning), None),
1600 Some(Value::Object(metadata)) => (None, Some(metadata)),
1601 _ => (None, None),
1605 };
1606 let reasoning_context = reasoning_metadata
1607 .as_ref()
1608 .and_then(|reasoning| reasoning.get("context"))
1609 .and_then(Value::as_str)
1610 .map(ToOwned::to_owned);
1611
1612 Ok(Self {
1613 id: response.id,
1614 object: response.object,
1615 created_at: response.created_at,
1616 status: response.status,
1617 error: response.error,
1618 incomplete_details: response.incomplete_details,
1619 instructions: response.instructions,
1620 max_output_tokens: response.max_output_tokens,
1621 model: response.model,
1622 provider_reasoning,
1623 reasoning_metadata,
1624 reasoning_context,
1625 usage: response.usage,
1626 output: response.output,
1627 tools: response.tools,
1628 additional_parameters: response.additional_parameters,
1629 })
1630 }
1631}
1632
1633#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1636pub struct AdditionalParameters {
1637 #[serde(skip_serializing_if = "Option::is_none")]
1639 pub background: Option<bool>,
1640 #[serde(skip_serializing_if = "Option::is_none")]
1642 pub text: Option<TextConfig>,
1643 #[serde(skip_serializing_if = "Option::is_none")]
1645 pub include: Option<Vec<Include>>,
1646 #[serde(skip_serializing_if = "Option::is_none")]
1648 pub top_p: Option<f64>,
1649 #[serde(skip_serializing_if = "Option::is_none")]
1651 pub truncation: Option<TruncationStrategy>,
1652 #[serde(skip_serializing_if = "Option::is_none")]
1654 pub user: Option<String>,
1655 #[serde(skip_serializing_if = "Option::is_none")]
1657 pub prompt_cache_key: Option<String>,
1658 #[serde(skip_serializing_if = "Option::is_none")]
1660 pub prompt_cache_retention: Option<String>,
1661 #[serde(
1663 skip_serializing_if = "Map::is_empty",
1664 default,
1665 deserialize_with = "deserialize_metadata"
1666 )]
1667 pub metadata: serde_json::Map<String, serde_json::Value>,
1668 #[serde(skip_serializing_if = "Option::is_none")]
1670 pub parallel_tool_calls: Option<bool>,
1671 #[serde(skip_serializing_if = "Option::is_none")]
1673 pub previous_response_id: Option<String>,
1674 #[serde(skip_serializing_if = "Option::is_none")]
1676 pub reasoning: Option<Reasoning>,
1677 #[serde(skip_serializing_if = "Option::is_none")]
1679 pub service_tier: Option<OpenAIServiceTier>,
1680 #[serde(skip_serializing_if = "Option::is_none")]
1682 pub store: Option<bool>,
1683}
1684
1685fn deserialize_metadata<'de, D>(
1686 deserializer: D,
1687) -> Result<serde_json::Map<String, serde_json::Value>, D::Error>
1688where
1689 D: Deserializer<'de>,
1690{
1691 Ok(
1692 Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer)?
1693 .unwrap_or_default(),
1694 )
1695}
1696
1697impl AdditionalParameters {
1698 pub fn to_json(self) -> serde_json::Value {
1699 serde_json::to_value(self).unwrap_or_else(|_| serde_json::Value::Object(Map::new()))
1700 }
1701}
1702
1703#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1707#[serde(rename_all = "snake_case")]
1708pub enum TruncationStrategy {
1709 Auto,
1710 #[default]
1711 Disabled,
1712}
1713
1714#[derive(Clone, Debug, Serialize, Deserialize)]
1717pub struct TextConfig {
1718 pub format: TextFormat,
1719}
1720
1721impl TextConfig {
1722 pub(crate) fn structured_output<S>(name: S, schema: serde_json::Value) -> Self
1723 where
1724 S: Into<String>,
1725 {
1726 Self {
1727 format: TextFormat::JsonSchema(StructuredOutputsInput {
1728 name: name.into(),
1729 schema,
1730 strict: true,
1731 }),
1732 }
1733 }
1734}
1735
1736#[derive(Clone, Debug, Serialize, Deserialize, Default)]
1739#[serde(tag = "type")]
1740#[serde(rename_all = "snake_case")]
1741pub enum TextFormat {
1742 JsonSchema(StructuredOutputsInput),
1743 #[default]
1744 Text,
1745}
1746
1747#[derive(Clone, Debug, Serialize, Deserialize)]
1749pub struct StructuredOutputsInput {
1750 pub name: String,
1752 pub schema: serde_json::Value,
1754 #[serde(default)]
1756 pub strict: bool,
1757}
1758
1759#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1774pub struct Reasoning {
1775 #[serde(skip_serializing_if = "Option::is_none")]
1777 pub effort: Option<ReasoningEffort>,
1778 #[serde(skip_serializing_if = "Option::is_none")]
1780 pub summary: Option<ReasoningSummaryLevel>,
1781 #[serde(skip_serializing_if = "Option::is_none")]
1784 pub mode: Option<ReasoningMode>,
1785 #[serde(skip_serializing_if = "Option::is_none")]
1788 pub context: Option<ReasoningContext>,
1789}
1790
1791impl Reasoning {
1792 pub fn new() -> Self {
1794 Self::default()
1795 }
1796
1797 pub fn with_effort(mut self, reasoning_effort: ReasoningEffort) -> Self {
1799 self.effort = Some(reasoning_effort);
1800
1801 self
1802 }
1803
1804 pub fn with_summary_level(mut self, reasoning_summary_level: ReasoningSummaryLevel) -> Self {
1806 self.summary = Some(reasoning_summary_level);
1807
1808 self
1809 }
1810
1811 pub fn with_mode(mut self, reasoning_mode: ReasoningMode) -> Self {
1813 self.mode = Some(reasoning_mode);
1814
1815 self
1816 }
1817
1818 pub fn with_context(mut self, reasoning_context: ReasoningContext) -> Self {
1820 self.context = Some(reasoning_context);
1821
1822 self
1823 }
1824}
1825
1826#[derive(Clone, Debug, Default)]
1828pub enum OpenAIServiceTier {
1829 #[default]
1831 Auto,
1832 Default,
1834 Flex,
1836 Priority,
1838 Standard,
1840 Other(String),
1842}
1843
1844impl Serialize for OpenAIServiceTier {
1845 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1846 where
1847 S: Serializer,
1848 {
1849 serializer.serialize_str(match self {
1850 Self::Auto => "auto",
1851 Self::Default => "default",
1852 Self::Flex => "flex",
1853 Self::Priority => "priority",
1854 Self::Standard => "standard",
1855 Self::Other(value) => value,
1856 })
1857 }
1858}
1859
1860impl<'de> Deserialize<'de> for OpenAIServiceTier {
1861 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1862 where
1863 D: Deserializer<'de>,
1864 {
1865 let value = String::deserialize(deserializer)?;
1866 Ok(match value.as_str() {
1867 "auto" => Self::Auto,
1868 "default" => Self::Default,
1869 "flex" => Self::Flex,
1870 "priority" => Self::Priority,
1871 "standard" => Self::Standard,
1872 _ => Self::Other(value),
1873 })
1874 }
1875}
1876
1877#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1879#[serde(rename_all = "snake_case")]
1880pub enum ReasoningEffort {
1881 None,
1882 Minimal,
1883 Low,
1884 #[default]
1885 Medium,
1886 High,
1887 Xhigh,
1888 Max,
1890}
1891
1892#[derive(Clone, Debug, Serialize, Deserialize)]
1897#[serde(rename_all = "snake_case")]
1898pub enum ReasoningMode {
1899 Pro,
1901}
1902
1903#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1906#[serde(rename_all = "snake_case")]
1907pub enum ReasoningContext {
1908 #[default]
1910 Auto,
1911 AllTurns,
1913 CurrentTurn,
1915}
1916
1917#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1919#[serde(rename_all = "snake_case")]
1920pub enum ReasoningSummaryLevel {
1921 #[default]
1922 Auto,
1923 Concise,
1924 Detailed,
1925}
1926
1927#[derive(Clone, Debug, Deserialize, Serialize)]
1930pub enum Include {
1931 #[serde(rename = "file_search_call.results")]
1932 FileSearchCallResults,
1933 #[serde(rename = "message.input_image.image_url")]
1934 MessageInputImageImageUrl,
1935 #[serde(rename = "computer_call.output.image_url")]
1936 ComputerCallOutputOutputImageUrl,
1937 #[serde(rename = "reasoning.encrypted_content")]
1938 ReasoningEncryptedContent,
1939 #[serde(rename = "code_interpreter_call.outputs")]
1940 CodeInterpreterCallOutputs,
1941}
1942
1943#[derive(Clone, Debug, PartialEq)]
1953pub enum Output {
1954 Message(OutputMessage),
1955 FunctionCall(OutputFunctionCall),
1956 Reasoning {
1957 id: String,
1958 summary: Vec<ReasoningSummary>,
1959 content: Vec<String>,
1960 encrypted_content: Option<String>,
1961 status: Option<ToolStatus>,
1962 },
1963 Unknown(Value),
1967}
1968
1969#[derive(Deserialize)]
1978struct ReasoningFields {
1979 id: String,
1980 #[serde(default)]
1981 summary: Vec<ReasoningSummary>,
1982 #[serde(default, deserialize_with = "deserialize_reasoning_text_content")]
1983 content: Vec<String>,
1984 #[serde(default)]
1985 encrypted_content: Option<String>,
1986 #[serde(default)]
1987 status: Option<ToolStatus>,
1988}
1989
1990impl From<ReasoningFields> for Output {
1991 fn from(fields: ReasoningFields) -> Self {
1992 Output::Reasoning {
1993 id: fields.id,
1994 summary: fields.summary,
1995 content: fields.content,
1996 encrypted_content: fields.encrypted_content,
1997 status: fields.status,
1998 }
1999 }
2000}
2001
2002fn tagged_output_object<T>(tag: &str, payload: &T) -> Result<Value, serde_json::Error>
2006where
2007 T: Serialize,
2008{
2009 let mut value = serde_json::to_value(payload)?;
2010 let map = value.as_object_mut().ok_or_else(|| {
2011 <serde_json::Error as serde::ser::Error>::custom(
2012 "output payload must serialize to a JSON object",
2013 )
2014 })?;
2015 map.insert("type".to_string(), Value::String(tag.to_string()));
2016 Ok(value)
2017}
2018
2019impl Serialize for Output {
2020 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2021 where
2022 S: Serializer,
2023 {
2024 let value = match self {
2030 Output::Message(message) => tagged_output_object("message", message),
2031 Output::FunctionCall(call) => tagged_output_object("function_call", call),
2032 Output::Reasoning {
2033 id,
2034 summary,
2035 content,
2036 encrypted_content,
2037 status,
2038 } => {
2039 let mut value = serde_json::json!({
2040 "type": "reasoning",
2041 "id": id,
2042 "summary": summary,
2043 "encrypted_content": encrypted_content,
2044 "status": status,
2045 });
2046 if !content.is_empty() {
2047 let map = value.as_object_mut().ok_or_else(|| {
2048 serde::ser::Error::custom("reasoning output must serialize to an object")
2049 })?;
2050 map.insert("content".to_string(), reasoning_text_content_json(content));
2051 }
2052 Ok(value)
2053 }
2054 Output::Unknown(value) => return value.serialize(serializer),
2055 };
2056 value
2057 .map_err(serde::ser::Error::custom)?
2058 .serialize(serializer)
2059 }
2060}
2061
2062impl<'de> Deserialize<'de> for Output {
2063 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2064 where
2065 D: Deserializer<'de>,
2066 {
2067 let value = Value::deserialize(deserializer)?;
2072 let Some(tag) = value.get("type").and_then(Value::as_str) else {
2073 return Ok(Output::Unknown(value));
2074 };
2075 match tag {
2076 "message" => serde_json::from_value(value)
2077 .map(Output::Message)
2078 .map_err(serde::de::Error::custom),
2079 "function_call" => serde_json::from_value(value)
2080 .map(Output::FunctionCall)
2081 .map_err(serde::de::Error::custom),
2082 "reasoning" => serde_json::from_value::<ReasoningFields>(value)
2083 .map(Output::from)
2084 .map_err(serde::de::Error::custom),
2085 _ => Ok(Output::Unknown(value)),
2086 }
2087 }
2088}
2089
2090impl From<Output> for Vec<completion::AssistantContent> {
2091 fn from(value: Output) -> Self {
2092 let res: Vec<completion::AssistantContent> = match value {
2093 Output::Message(OutputMessage { content, .. }) => content
2094 .into_iter()
2095 .map(completion::AssistantContent::from)
2096 .collect(),
2097 Output::FunctionCall(OutputFunctionCall {
2098 id,
2099 arguments,
2100 call_id,
2101 name,
2102 ..
2103 }) => vec![completion::AssistantContent::tool_call_with_call_id(
2104 id, call_id, name, arguments,
2105 )],
2106 Output::Reasoning {
2107 id,
2108 summary,
2109 content: reasoning_content,
2110 encrypted_content,
2111 ..
2112 } => {
2113 let mut content = summary
2114 .into_iter()
2115 .map(|summary| match summary {
2116 ReasoningSummary::SummaryText { text } => {
2117 message::ReasoningContent::Summary(text)
2118 }
2119 })
2120 .collect::<Vec<_>>();
2121 content.extend(reasoning_content.into_iter().map(|text| {
2122 message::ReasoningContent::Text {
2123 text,
2124 signature: None,
2125 }
2126 }));
2127 if let Some(encrypted_content) =
2128 encrypted_content.filter(|content| !content.is_empty())
2129 {
2130 content.push(message::ReasoningContent::Encrypted(encrypted_content));
2131 }
2132 vec![completion::AssistantContent::Reasoning(
2133 message::Reasoning {
2134 id: Some(id),
2135 content,
2136 },
2137 )]
2138 }
2139 Output::Unknown(_) => Vec::new(),
2140 };
2141
2142 res
2143 }
2144}
2145
2146#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2147pub struct OutputReasoning {
2148 id: String,
2149 summary: Vec<ReasoningSummary>,
2150 status: ToolStatus,
2151}
2152
2153#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2155pub struct OutputFunctionCall {
2156 #[serde(default, skip_serializing_if = "is_not_function_call_item_id")]
2162 pub id: String,
2163 #[serde(with = "json_utils::stringified_json")]
2164 pub arguments: serde_json::Value,
2165 pub call_id: String,
2166 pub name: String,
2167 pub status: ToolStatus,
2168}
2169
2170fn is_not_function_call_item_id(id: &str) -> bool {
2173 !id.starts_with("fc_")
2174}
2175
2176#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2178#[serde(rename_all = "snake_case")]
2179pub enum ToolStatus {
2180 InProgress,
2181 Completed,
2182 Incomplete,
2183}
2184
2185#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2187pub struct OutputMessage {
2188 pub id: String,
2190 pub role: OutputRole,
2192 pub status: ResponseStatus,
2194 pub content: Vec<AssistantContent>,
2196}
2197
2198#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2200#[serde(rename_all = "snake_case")]
2201pub enum OutputRole {
2202 Assistant,
2203}
2204
2205impl<Ext, H> completion::CompletionModel for GenericResponsesCompletionModel<Ext, H>
2206where
2207 crate::client::Client<Ext, H>:
2208 HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
2209 Ext: crate::client::Provider
2210 + ResponsesProviderExt
2211 + crate::client::DebugExt
2212 + Clone
2213 + WasmCompatSend
2214 + WasmCompatSync
2215 + 'static,
2216 H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
2217{
2218 type Response = CompletionResponse;
2219 type StreamingResponse = StreamingCompletionResponse;
2220
2221 type Client = crate::client::Client<Ext, H>;
2222
2223 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
2224 Self::new(client.clone(), model)
2225 }
2226
2227 fn composes_native_output_with_tools(&self) -> bool {
2231 true
2232 }
2233
2234 async fn completion(
2235 &self,
2236 completion_request: crate::completion::CompletionRequest,
2237 ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
2238 let system_instructions = completion_request.preamble.clone();
2239 let record_telemetry_content = completion_request.record_telemetry_content;
2240 let request = self.create_completion_request(completion_request)?;
2241 let span = CompletionSpanBuilder::new("openai", &request.model, CompletionOperation::Chat)
2242 .system_instructions(system_instructions.as_deref(), record_telemetry_content)
2243 .build();
2244 let body = serde_json::to_vec(&request)?;
2245
2246 if enabled!(Level::TRACE) {
2247 tracing::trace!(
2248 target: "rig::completions",
2249 "OpenAI Responses completion request: {request}",
2250 request = serde_json::to_string_pretty(&request)?
2251 );
2252 }
2253
2254 let req = self
2255 .client
2256 .post("/responses")?
2257 .body(body)
2258 .map_err(|e| CompletionError::HttpError(e.into()))?;
2259
2260 async move {
2261 let response = self.client.send(req).await?;
2262
2263 if response.status().is_success() {
2264 let t = http_client::text(response).await?;
2265 let response = serde_json::from_str::<Self::Response>(&t)?;
2266 let span = tracing::Span::current();
2267 span.record("gen_ai.response.id", &response.id);
2268 span.record("gen_ai.response.model", &response.model);
2269 if let Some(ref usage) = response.usage {
2270 span.record("gen_ai.usage.output_tokens", usage.output_tokens);
2271 span.record("gen_ai.usage.input_tokens", usage.input_tokens);
2272 let cached_tokens = usage
2273 .input_tokens_details
2274 .as_ref()
2275 .map(|d| d.cached_tokens)
2276 .unwrap_or(0);
2277 span.record("gen_ai.usage.cache_read.input_tokens", cached_tokens);
2278 }
2279 if enabled!(Level::TRACE) {
2280 tracing::trace!(
2281 target: "rig::completions",
2282 "OpenAI Responses completion response: {response}",
2283 response = serde_json::to_string_pretty(&response)?
2284 );
2285 }
2286 response.try_into()
2287 } else {
2288 let status = response.status();
2289 let text = http_client::text(response).await?;
2290 Err(CompletionError::from_http_response(status, text))
2291 }
2292 }
2293 .instrument(span)
2294 .await
2295 }
2296
2297 async fn stream(
2298 &self,
2299 request: crate::completion::CompletionRequest,
2300 ) -> Result<
2301 crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
2302 CompletionError,
2303 > {
2304 GenericResponsesCompletionModel::stream(self, request).await
2305 }
2306}
2307
2308impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
2309 type Error = CompletionError;
2310
2311 fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
2312 let message_id = response.output.iter().find_map(|item| match item {
2314 Output::Message(msg) => Some(msg.id.clone()),
2315 _ => None,
2316 });
2317
2318 let output_content: Vec<completion::AssistantContent> = response
2319 .output
2320 .iter()
2321 .cloned()
2322 .flat_map(<Vec<completion::AssistantContent>>::from)
2323 .collect();
2324 let has_structured_reasoning = response
2325 .output
2326 .iter()
2327 .any(|item| matches!(item, Output::Reasoning { .. }));
2328 let content = response
2329 .provider_reasoning
2330 .as_ref()
2331 .filter(|reasoning| !has_structured_reasoning && !reasoning.is_empty())
2332 .map(|reasoning| {
2333 let mut content = Vec::with_capacity(output_content.len() + 1);
2334 content.push(completion::AssistantContent::Reasoning(
2335 message::Reasoning::new(reasoning),
2336 ));
2337 content.extend(output_content.clone());
2338 content
2339 })
2340 .unwrap_or(output_content);
2341
2342 let choice = OneOrMany::many(content).map_err(|_| {
2343 CompletionError::ResponseError(
2344 "Response contained no message or tool call (empty)".to_owned(),
2345 )
2346 })?;
2347
2348 let usage = response
2349 .usage
2350 .as_ref()
2351 .map(GetTokenUsage::token_usage)
2352 .unwrap_or_default();
2353
2354 Ok(completion::CompletionResponse {
2355 choice,
2356 usage,
2357 raw_response: response,
2358 message_id,
2359 })
2360 }
2361}
2362
2363#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2365#[serde(tag = "role", rename_all = "lowercase")]
2366pub enum Message {
2367 #[serde(alias = "developer")]
2368 System {
2369 #[serde(deserialize_with = "string_or_one_or_many")]
2370 content: OneOrMany<SystemContent>,
2371 #[serde(skip_serializing_if = "Option::is_none")]
2372 name: Option<String>,
2373 },
2374 User {
2375 #[serde(deserialize_with = "string_or_one_or_many")]
2376 content: OneOrMany<UserContent>,
2377 #[serde(skip_serializing_if = "Option::is_none")]
2378 name: Option<String>,
2379 },
2380 Assistant {
2381 content: OneOrMany<AssistantContentType>,
2382 #[serde(skip_serializing_if = "String::is_empty")]
2383 id: String,
2384 #[serde(skip_serializing_if = "Option::is_none")]
2385 name: Option<String>,
2386 status: ToolStatus,
2387 },
2388 #[serde(rename = "assistant", skip_deserializing)]
2389 AssistantInput {
2390 content: String,
2391 #[serde(skip_serializing_if = "Option::is_none")]
2392 name: Option<String>,
2393 },
2394 #[serde(rename = "tool")]
2395 ToolResult {
2396 tool_call_id: String,
2397 output: ToolResultOutput,
2398 },
2399}
2400
2401#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
2403#[serde(rename_all = "lowercase")]
2404pub enum ToolResultContentType {
2405 #[default]
2406 Text,
2407}
2408
2409impl Message {
2410 pub fn system(content: &str) -> Self {
2411 Message::System {
2412 content: OneOrMany::one(content.to_owned().into()),
2413 name: None,
2414 }
2415 }
2416}
2417
2418#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2421#[serde(tag = "type", rename_all = "snake_case")]
2422pub enum AssistantContent {
2423 OutputText(Text),
2424 Refusal { refusal: String },
2425}
2426
2427impl From<AssistantContent> for completion::AssistantContent {
2428 fn from(value: AssistantContent) -> Self {
2429 match value {
2430 AssistantContent::Refusal { refusal } => {
2431 completion::AssistantContent::Text(Text::new(refusal))
2432 }
2433 AssistantContent::OutputText(Text { text, .. }) => {
2434 completion::AssistantContent::Text(Text::new(text))
2435 }
2436 }
2437 }
2438}
2439
2440#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2442#[serde(untagged)]
2443pub enum AssistantContentType {
2444 Text(AssistantContent),
2445 ToolCall(OutputFunctionCall),
2446 Reasoning(OpenAIReasoning),
2447}
2448
2449#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2452#[serde(tag = "type", rename_all = "snake_case")]
2453pub enum SystemContent {
2454 InputText { text: String },
2455}
2456
2457impl From<String> for SystemContent {
2458 fn from(s: String) -> Self {
2459 SystemContent::InputText { text: s }
2460 }
2461}
2462
2463impl std::str::FromStr for SystemContent {
2464 type Err = std::convert::Infallible;
2465
2466 fn from_str(s: &str) -> Result<Self, Self::Err> {
2467 Ok(SystemContent::InputText {
2468 text: s.to_string(),
2469 })
2470 }
2471}
2472
2473#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2475#[serde(tag = "type", rename_all = "snake_case")]
2476pub enum UserContent {
2477 InputText {
2478 text: String,
2479 },
2480 InputImage {
2481 image_url: String,
2482 #[serde(default)]
2483 detail: ImageDetail,
2484 },
2485 InputFile {
2486 #[serde(skip_serializing_if = "Option::is_none")]
2487 file_id: Option<String>,
2488 #[serde(skip_serializing_if = "Option::is_none")]
2489 file_url: Option<String>,
2490 #[serde(skip_serializing_if = "Option::is_none")]
2491 file_data: Option<String>,
2492 #[serde(skip_serializing_if = "Option::is_none")]
2493 filename: Option<String>,
2494 },
2495 Audio {
2496 input_audio: InputAudio,
2497 },
2498 #[serde(rename = "tool")]
2499 ToolResult {
2500 tool_call_id: String,
2501 output: String,
2502 },
2503}
2504
2505fn flush_responses_user_content(
2506 messages: &mut Vec<Message>,
2507 pending: &mut Vec<UserContent>,
2508) -> Result<(), MessageError> {
2509 if pending.is_empty() {
2510 return Ok(());
2511 }
2512
2513 let content = OneOrMany::many(std::mem::take(pending)).map_err(|_| {
2514 MessageError::ConversionError(
2515 "User message did not contain OpenAI Responses-compatible content".to_string(),
2516 )
2517 })?;
2518 messages.push(Message::User {
2519 content,
2520 name: None,
2521 });
2522 Ok(())
2523}
2524
2525fn responses_user_content(content: message::UserContent) -> Result<UserContent, MessageError> {
2526 match content {
2527 message::UserContent::Text(message::Text { text, .. }) => {
2528 Ok(UserContent::InputText { text })
2529 }
2530 message::UserContent::Image(message::Image {
2531 data,
2532 detail,
2533 media_type,
2534 ..
2535 }) => {
2536 let url = match data {
2537 DocumentSourceKind::Base64(data) => {
2538 let media_type = media_type
2539 .map(|media_type| media_type.to_mime_type().to_string())
2540 .unwrap_or_default();
2541 format!("data:{media_type};base64,{data}")
2542 }
2543 DocumentSourceKind::Url(url) => url,
2544 DocumentSourceKind::Raw(_) => {
2545 return Err(MessageError::ConversionError(
2546 "Raw files not supported, encode as base64 first".into(),
2547 ));
2548 }
2549 doc => {
2550 return Err(MessageError::ConversionError(format!(
2551 "Unsupported document type: {doc}"
2552 )));
2553 }
2554 };
2555
2556 Ok(UserContent::InputImage {
2557 image_url: url,
2558 detail: detail.unwrap_or_default(),
2559 })
2560 }
2561 message::UserContent::Document(message::Document {
2562 data: DocumentSourceKind::FileId(file_id),
2563 ..
2564 }) => Ok(UserContent::InputFile {
2565 file_id: Some(file_id),
2566 file_url: None,
2567 file_data: None,
2568 filename: None,
2569 }),
2570 message::UserContent::Document(message::Document {
2571 media_type: Some(DocumentMediaType::PDF),
2572 data,
2573 ..
2574 }) => {
2575 let (file_data, file_url, filename) = match data {
2576 DocumentSourceKind::Base64(data) => (
2577 Some(format!("data:application/pdf;base64,{data}")),
2578 None,
2579 Some("document.pdf".to_string()),
2580 ),
2581 DocumentSourceKind::Url(url) => (None, Some(url), None),
2582 DocumentSourceKind::Raw(_) => {
2583 return Err(MessageError::ConversionError(
2584 "Raw files not supported, encode as base64 first".into(),
2585 ));
2586 }
2587 doc => {
2588 return Err(MessageError::ConversionError(format!(
2589 "Unsupported document type: {doc}"
2590 )));
2591 }
2592 };
2593
2594 Ok(UserContent::InputFile {
2595 file_id: None,
2596 file_url,
2597 file_data,
2598 filename,
2599 })
2600 }
2601 message::UserContent::Document(message::Document {
2602 data: DocumentSourceKind::Base64(text),
2603 ..
2604 }) => Ok(UserContent::InputText { text }),
2605 message::UserContent::Audio(message::Audio {
2606 data: DocumentSourceKind::Base64(data),
2607 media_type,
2608 ..
2609 }) => Ok(UserContent::Audio {
2610 input_audio: InputAudio {
2611 data,
2612 format: media_type.unwrap_or(AudioMediaType::MP3),
2613 },
2614 }),
2615 message::UserContent::Audio(_) => Err(MessageError::ConversionError(
2616 "Audio must be base64 encoded data".into(),
2617 )),
2618 _ => Err(MessageError::ConversionError(
2619 "Unsupported user content for OpenAI Responses API".into(),
2620 )),
2621 }
2622}
2623
2624fn responses_tool_result(tool_result: message::ToolResult) -> Result<Message, MessageError> {
2625 let output = responses_tool_result_output(tool_result.content)?;
2626
2627 Ok(Message::ToolResult {
2628 tool_call_id: tool_result.call_id.ok_or_else(|| {
2629 MessageError::ConversionError(
2630 "Tool result `call_id` is required for OpenAI Responses API".into(),
2631 )
2632 })?,
2633 output,
2634 })
2635}
2636
2637impl TryFrom<message::Message> for Vec<Message> {
2638 type Error = message::MessageError;
2639
2640 fn try_from(message: message::Message) -> Result<Self, Self::Error> {
2641 match message {
2642 message::Message::System { content } => Ok(vec![Message::System {
2643 content: OneOrMany::one(content.into()),
2644 name: None,
2645 }]),
2646 message::Message::User { content } => {
2647 let mut messages = Vec::new();
2648 let mut pending = Vec::new();
2649
2650 for content in content {
2651 match content {
2652 message::UserContent::ToolResult(tool_result) => {
2653 flush_responses_user_content(&mut messages, &mut pending)?;
2654 messages.push(responses_tool_result(tool_result)?);
2655 }
2656 content => pending.push(responses_user_content(content)?),
2657 }
2658 }
2659
2660 flush_responses_user_content(&mut messages, &mut pending)?;
2661 Ok(messages)
2662 }
2663 message::Message::Assistant {
2664 content,
2665 id: assistant_message_id,
2666 } => {
2667 let mut messages = Vec::new();
2668
2669 for assistant_content in content {
2670 match assistant_content {
2671 crate::message::AssistantContent::Text(Text { text, .. }) => {
2672 if text.is_empty() {
2673 continue;
2674 }
2675 if let Some(id) = assistant_message_id.clone() {
2676 messages.push(Message::Assistant {
2677 id,
2678 status: ToolStatus::Completed,
2679 content: OneOrMany::one(AssistantContentType::Text(
2680 AssistantContent::OutputText(Text::new(text)),
2681 )),
2682 name: None,
2683 });
2684 } else {
2685 messages.push(Message::AssistantInput {
2686 content: text,
2687 name: None,
2688 });
2689 }
2690 }
2691 crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
2692 id: tool_id,
2693 call_id,
2694 function,
2695 ..
2696 }) => {
2697 messages.push(Message::Assistant {
2698 content: OneOrMany::one(AssistantContentType::ToolCall(
2699 OutputFunctionCall {
2700 call_id: call_id.ok_or_else(|| {
2701 MessageError::ConversionError(
2702 "Tool call `call_id` is required for OpenAI Responses API"
2703 .into(),
2704 )
2705 })?,
2706 arguments: function.arguments,
2707 id: tool_id,
2708 name: function.name,
2709 status: ToolStatus::Completed,
2710 },
2711 )),
2712 id: assistant_message_id.clone().unwrap_or_default(),
2713 name: None,
2714 status: ToolStatus::Completed,
2715 });
2716 }
2717 crate::message::AssistantContent::Reasoning(reasoning) => {
2718 if let Some(openai_reasoning) = openai_reasoning_from_core(&reasoning)?
2719 {
2720 messages.push(Message::Assistant {
2721 content: OneOrMany::one(AssistantContentType::Reasoning(
2722 openai_reasoning,
2723 )),
2724 id: assistant_message_id.clone().unwrap_or_default(),
2725 name: None,
2726 status: ToolStatus::Completed,
2727 });
2728 }
2729 }
2730 crate::message::AssistantContent::Image(_) => {
2731 return Err(MessageError::ConversionError(
2732 "Assistant image content is not supported in OpenAI Responses API"
2733 .into(),
2734 ));
2735 }
2736 }
2737 }
2738
2739 Ok(messages)
2740 }
2741 }
2742 }
2743}
2744
2745impl FromStr for UserContent {
2746 type Err = Infallible;
2747
2748 fn from_str(s: &str) -> Result<Self, Self::Err> {
2749 Ok(UserContent::InputText {
2750 text: s.to_string(),
2751 })
2752 }
2753}
2754
2755#[cfg(test)]
2756mod tests {
2757 use super::*;
2758 use crate::completion::CompletionRequestBuilder;
2759 use crate::message;
2760 use crate::test_utils::MockCompletionModel;
2761 use serde_json::json;
2762 use std::collections::HashMap;
2763
2764 fn test_document(id: &str, text: &str) -> crate::completion::Document {
2765 crate::completion::Document {
2766 id: id.to_string(),
2767 text: text.to_string(),
2768 additional_props: HashMap::new(),
2769 }
2770 }
2771
2772 fn weather_tool_definition() -> completion::ToolDefinition {
2773 completion::ToolDefinition {
2774 name: "get_weather".to_string(),
2775 description: "Get the weather".to_string(),
2776 parameters: json!({
2777 "type": "object",
2778 "properties": {
2779 "location": {"type": "string"},
2780 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2781 },
2782 "required": ["location"]
2783 }),
2784 }
2785 }
2786
2787 fn rig_tool_result(content: message::ToolResultContent) -> message::Message {
2788 message::Message::User {
2789 content: OneOrMany::one(message::UserContent::ToolResult(message::ToolResult {
2790 id: "result-id".to_string(),
2791 call_id: Some("call-id".to_string()),
2792 content: OneOrMany::one(content),
2793 })),
2794 }
2795 }
2796
2797 #[test]
2798 fn mixed_user_content_preserves_order_around_tool_results() {
2799 let input = message::Message::User {
2800 content: OneOrMany::many(vec![
2801 message::UserContent::text("before"),
2802 message::UserContent::tool_result_with_call_id(
2803 "result-id",
2804 "call-id".to_string(),
2805 OneOrMany::one(message::ToolResultContent::text("tool output")),
2806 ),
2807 message::UserContent::text("after"),
2808 ])
2809 .expect("mixed content should be non-empty"),
2810 };
2811
2812 let messages = Vec::<Message>::try_from(input).expect("message conversion");
2813
2814 assert!(matches!(
2815 messages.as_slice(),
2816 [
2817 Message::User { content: before, .. },
2818 Message::ToolResult { tool_call_id, .. },
2819 Message::User { content: after, .. },
2820 ] if matches!(before.first(), UserContent::InputText { text } if text == "before")
2821 && tool_call_id == "call-id"
2822 && matches!(after.first(), UserContent::InputText { text } if text == "after")
2823 ));
2824 }
2825
2826 #[test]
2827 fn tool_result_literal_text_and_structured_json_render_without_reparsing() {
2828 let cases = [
2829 (
2830 message::ToolResultContent::text(r#"{"status":"ok"}"#),
2831 r#"{"status":"ok"}"#.to_string(),
2832 ),
2833 (
2834 message::ToolResultContent::json(json!({ "status": "ok" })),
2835 r#"{"status":"ok"}"#.to_string(),
2836 ),
2837 ];
2838
2839 for (content, expected) in cases {
2840 let input = rig_tool_result(content);
2841
2842 let messages: Vec<Message> = input.clone().try_into().expect("message conversion");
2843 assert!(matches!(
2844 messages.as_slice(),
2845 [Message::ToolResult {
2846 output: ToolResultOutput::Text(output),
2847 ..
2848 }] if output == &expected
2849 ));
2850
2851 let items: Vec<InputItem> = input.try_into().expect("input item conversion");
2852 assert!(matches!(
2853 items.as_slice(),
2854 [InputItem {
2855 input: InputContent::FunctionCallOutput(ToolResult {
2856 output: ToolResultOutput::Text(output),
2857 ..
2858 }),
2859 ..
2860 }] if output == &expected
2861 ));
2862 }
2863 }
2864
2865 #[test]
2866 fn multiple_text_tool_result_blocks_preserve_order_as_rich_function_output() {
2867 let content = OneOrMany::many(vec![
2868 message::ToolResultContent::text("first"),
2869 message::ToolResultContent::text("second"),
2870 ])
2871 .expect("multiple tool-result blocks should be non-empty");
2872
2873 let input = message::Message::User {
2874 content: OneOrMany::one(message::UserContent::ToolResult(message::ToolResult {
2875 id: "result-id".to_string(),
2876 call_id: Some("call-id".to_string()),
2877 content,
2878 })),
2879 };
2880
2881 let expected = ToolResultOutput::Content(vec![
2882 ToolResultOutputContent::InputText {
2883 text: "first".to_string(),
2884 },
2885 ToolResultOutputContent::InputText {
2886 text: "second".to_string(),
2887 },
2888 ]);
2889
2890 let messages: Vec<Message> = input.clone().try_into().expect("message conversion");
2891
2892 match messages.as_slice() {
2893 [Message::ToolResult { output, .. }] => {
2894 assert_eq!(output, &expected);
2895 }
2896 other => panic!("expected one tool result, got {other:?}"),
2897 }
2898
2899 let items: Vec<InputItem> = input.try_into().expect("input item conversion");
2900
2901 match items.as_slice() {
2902 [
2903 InputItem {
2904 input: InputContent::FunctionCallOutput(ToolResult { output, .. }),
2905 ..
2906 },
2907 ] => {
2908 assert_eq!(output, &expected);
2909 }
2910 other => panic!("expected one function-call output, got {other:?}"),
2911 }
2912
2913 let wire = serde_json::to_value(&items[0]).expect("input item should serialize");
2914
2915 assert_eq!(
2916 wire,
2917 json!({
2918 "type": "function_call_output",
2919 "call_id": "call-id",
2920 "output": [
2921 {
2922 "type": "input_text",
2923 "text": "first"
2924 },
2925 {
2926 "type": "input_text",
2927 "text": "second"
2928 }
2929 ],
2930 "status": "completed"
2931 })
2932 );
2933 }
2934
2935 #[test]
2936 fn multiple_text_and_json_tool_result_blocks_preserve_boundaries() {
2937 let content = OneOrMany::many(vec![
2938 message::ToolResultContent::text("before"),
2939 message::ToolResultContent::json(json!({
2940 "status": "ok"
2941 })),
2942 message::ToolResultContent::text("after"),
2943 ])
2944 .expect("multiple tool-result blocks should be non-empty");
2945
2946 let output =
2947 responses_tool_result_output(content).expect("tool-result conversion should succeed");
2948
2949 assert_eq!(
2950 output,
2951 ToolResultOutput::Content(vec![
2952 ToolResultOutputContent::InputText {
2953 text: "before".to_string(),
2954 },
2955 ToolResultOutputContent::InputText {
2956 text: r#"{"status":"ok"}"#.to_string(),
2957 },
2958 ToolResultOutputContent::InputText {
2959 text: "after".to_string(),
2960 },
2961 ])
2962 );
2963 }
2964
2965 #[test]
2966 fn tool_result_images_and_text_preserve_order_as_rich_function_output() {
2967 let content = OneOrMany::many(vec![
2968 message::ToolResultContent::text("before"),
2969 message::ToolResultContent::image_base64(
2970 "aW1hZ2U=",
2971 Some(message::ImageMediaType::PNG),
2972 None,
2973 ),
2974 message::ToolResultContent::json(json!({ "after": true })),
2975 ])
2976 .expect("mixed tool output is non-empty");
2977 let input = message::Message::User {
2978 content: OneOrMany::one(message::UserContent::ToolResult(message::ToolResult {
2979 id: "result-id".to_string(),
2980 call_id: Some("call-id".to_string()),
2981 content,
2982 })),
2983 };
2984
2985 let assert_output = |output: &ToolResultOutput| {
2986 assert!(matches!(
2987 output,
2988 ToolResultOutput::Content(content)
2989 if matches!(content.as_slice(), [
2990 ToolResultOutputContent::InputText { text: before },
2991 ToolResultOutputContent::InputImage { image_url, .. },
2992 ToolResultOutputContent::InputText { text: after },
2993 ] if before == "before"
2994 && image_url.as_deref() == Some("data:image/png;base64,aW1hZ2U=")
2995 && after == r#"{"after":true}"#)
2996 ));
2997 };
2998
2999 let messages: Vec<Message> = input.clone().try_into().expect("message conversion");
3000 match messages.as_slice() {
3001 [Message::ToolResult { output, .. }] => assert_output(output),
3002 other => panic!("expected one rich tool result, got {other:?}"),
3003 }
3004
3005 let items: Vec<InputItem> = input.try_into().expect("input item conversion");
3006 match items.as_slice() {
3007 [
3008 InputItem {
3009 input: InputContent::FunctionCallOutput(ToolResult { output, .. }),
3010 ..
3011 },
3012 ] => assert_output(output),
3013 other => panic!("expected one rich function output, got {other:?}"),
3014 }
3015 }
3016
3017 #[test]
3018 fn tool_result_file_id_image_uses_the_native_wire_field() {
3019 let input = rig_tool_result(message::ToolResultContent::Image(message::Image {
3020 data: message::DocumentSourceKind::FileId("file-image-123".to_string()),
3021 media_type: None,
3022 detail: None,
3023 additional_params: None,
3024 }));
3025
3026 let items: Vec<InputItem> = input.try_into().expect("input item conversion");
3027 let wire = serde_json::to_value(&items[0]).expect("serialize input item");
3028 assert_eq!(
3029 wire,
3030 json!({
3031 "type": "function_call_output",
3032 "call_id": "call-id",
3033 "output": [{
3034 "type": "input_image",
3035 "file_id": "file-image-123",
3036 "detail": "auto"
3037 }],
3038 "status": "completed"
3039 })
3040 );
3041 }
3042
3043 fn weather_tool_request() -> completion::CompletionRequest {
3044 completion::CompletionRequest {
3045 model: None,
3046 preamble: None,
3047 chat_history: crate::OneOrMany::one(message::Message::user("what's the weather?")),
3048 documents: Vec::new(),
3049 tools: vec![weather_tool_definition()],
3050 temperature: None,
3051 max_tokens: None,
3052 tool_choice: None,
3053 additional_params: None,
3054 output_schema: None,
3055 record_telemetry_content: false,
3056 }
3057 }
3058
3059 #[test]
3060 fn responses_tool_choice_modes_serialize_as_plain_strings() {
3061 for (choice, expected) in [
3062 (message::ToolChoice::Auto, json!("auto")),
3063 (message::ToolChoice::None, json!("none")),
3064 (message::ToolChoice::Required, json!("required")),
3065 ] {
3066 let converted = ToolChoice::try_from(choice).expect("mode should convert");
3067 assert_eq!(
3068 serde_json::to_value(&converted).expect("serialize tool choice"),
3069 expected
3070 );
3071 }
3072 }
3073
3074 #[test]
3075 fn responses_tool_choice_specific_single_name_serializes_as_named_function() {
3076 let converted = ToolChoice::try_from(message::ToolChoice::Specific {
3077 function_names: vec!["get_weather".to_string()],
3078 })
3079 .expect("single specific tool should convert");
3080
3081 assert_eq!(
3082 serde_json::to_value(&converted).expect("serialize tool choice"),
3083 json!({"type": "function", "name": "get_weather"})
3084 );
3085 }
3086
3087 #[test]
3088 fn responses_tool_choice_specific_multiple_names_serialize_as_allowed_tools() {
3089 let converted = ToolChoice::try_from(message::ToolChoice::Specific {
3090 function_names: vec!["add".to_string(), "subtract".to_string()],
3091 })
3092 .expect("multiple specific tools should convert");
3093
3094 assert_eq!(
3095 serde_json::to_value(&converted).expect("serialize tool choice"),
3096 json!({
3097 "type": "allowed_tools",
3098 "mode": "required",
3099 "tools": [
3100 {"type": "function", "name": "add"},
3101 {"type": "function", "name": "subtract"}
3102 ]
3103 })
3104 );
3105 }
3106
3107 #[test]
3108 fn responses_tool_choice_specific_empty_names_error() {
3109 let converted = ToolChoice::try_from(message::ToolChoice::Specific {
3110 function_names: vec![],
3111 });
3112
3113 assert!(matches!(
3114 converted,
3115 Err(CompletionError::RequestError(error))
3116 if error.to_string().contains("at least one function name")
3117 ));
3118 }
3119
3120 #[test]
3121 fn responses_request_with_specific_tool_choice_serializes_named_function() {
3122 let mut request = weather_tool_request();
3123 request.tool_choice = Some(message::ToolChoice::Specific {
3124 function_names: vec!["get_weather".to_string()],
3125 });
3126
3127 let request =
3128 CompletionRequest::try_from(("gpt-test".to_string(), request)).expect("convert");
3129 let request_json = serde_json::to_value(&request).expect("serialize request");
3130
3131 assert_eq!(
3132 request_json.get("tool_choice"),
3133 Some(&json!({"type": "function", "name": "get_weather"}))
3134 );
3135 }
3136
3137 #[test]
3138 fn responses_function_tools_are_non_strict_by_default() {
3139 let tool = ResponsesToolDefinition::function(
3140 "get_weather",
3141 "Get the weather",
3142 weather_tool_definition().parameters,
3143 );
3144
3145 assert!(!tool.strict);
3146 assert_eq!(tool.parameters["required"], json!(["location"]));
3147 assert!(tool.parameters.get("additionalProperties").is_none());
3148
3149 let serialized = serde_json::to_value(tool).expect("tool should serialize");
3150 assert!(serialized.get("strict").is_none());
3151 }
3152
3153 #[test]
3154 fn responses_tool_definitions_accept_nullable_strict() {
3155 let cases = [
3156 (
3157 json!({
3158 "type": "function",
3159 "name": "get_weather",
3160 "parameters": {}
3161 }),
3162 false,
3163 ),
3164 (
3165 json!({
3166 "type": "function",
3167 "name": "get_weather",
3168 "parameters": {},
3169 "strict": null
3170 }),
3171 false,
3172 ),
3173 (
3174 json!({
3175 "type": "function",
3176 "name": "get_weather",
3177 "parameters": {},
3178 "strict": false
3179 }),
3180 false,
3181 ),
3182 (
3183 json!({
3184 "type": "function",
3185 "name": "get_weather",
3186 "parameters": {},
3187 "strict": true
3188 }),
3189 true,
3190 ),
3191 ];
3192
3193 for (value, expected) in cases {
3194 let tool: ResponsesToolDefinition =
3195 serde_json::from_value(value).expect("tool definition should deserialize");
3196 assert_eq!(tool.strict, expected);
3197 }
3198 }
3199
3200 #[test]
3201 fn responses_strict_function_tools_sanitize_schema() {
3202 let tool = ResponsesToolDefinition::strict_function(
3203 "get_weather",
3204 "Get the weather",
3205 weather_tool_definition().parameters,
3206 );
3207
3208 assert!(tool.strict);
3209 assert_eq!(tool.parameters["additionalProperties"], json!(false));
3210 assert_eq!(tool.parameters["required"], json!(["location", "unit"]));
3211 }
3212
3213 fn request_with_preamble(preamble: &str) -> completion::CompletionRequest {
3214 completion::CompletionRequest {
3215 model: None,
3216 preamble: Some(preamble.to_string()),
3217 chat_history: crate::OneOrMany::one(message::Message::user("Hello")),
3218 documents: Vec::new(),
3219 tools: Vec::new(),
3220 temperature: None,
3221 max_tokens: None,
3222 tool_choice: None,
3223 additional_params: None,
3224 output_schema: None,
3225 record_telemetry_content: false,
3226 }
3227 }
3228
3229 fn system_only_request(system_text: &str) -> completion::CompletionRequest {
3230 completion::CompletionRequest {
3231 model: None,
3232 preamble: None,
3233 chat_history: crate::OneOrMany::one(completion::Message::system(system_text)),
3234 documents: Vec::new(),
3235 tools: Vec::new(),
3236 temperature: None,
3237 max_tokens: None,
3238 tool_choice: None,
3239 additional_params: None,
3240 output_schema: None,
3241 record_telemetry_content: false,
3242 }
3243 }
3244
3245 #[test]
3246 fn responses_request_uses_top_level_instructions_for_preamble_by_default() {
3247 let req = CompletionRequest::try_from((
3248 "gpt-4o-mini".to_string(),
3249 request_with_preamble("You are concise."),
3250 ))
3251 .expect("request should convert");
3252 let serialized = serde_json::to_value(&req).expect("request should serialize");
3253 let input = serialized["input"]
3254 .as_array()
3255 .expect("input should be array");
3256
3257 assert_eq!(serialized["instructions"], json!("You are concise."));
3258 assert_eq!(input.len(), 1);
3259 assert_eq!(input[0]["role"], "user");
3260 }
3261
3262 #[test]
3263 fn responses_request_drops_whitespace_only_preamble() {
3264 let req = CompletionRequest::try_from((
3265 "gpt-4o-mini".to_string(),
3266 request_with_preamble(" \n "),
3267 ))
3268 .expect("request should convert");
3269 let serialized = serde_json::to_value(&req).expect("request should serialize");
3270 let input = serialized["input"]
3271 .as_array()
3272 .expect("input should be array");
3273
3274 assert!(
3275 serialized.get("instructions").is_none(),
3276 "a whitespace-only preamble carries no content and is dropped"
3277 );
3278 assert_eq!(input.len(), 1);
3279 assert_eq!(input[0]["role"], "user");
3280 }
3281
3282 #[test]
3283 fn responses_request_lifts_system_messages_to_top_level_instructions_by_default() {
3284 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Hello")
3285 .preamble("System one".to_string())
3286 .message(completion::Message::system("System two"))
3287 .build();
3288
3289 let req = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3290 .expect("request should convert");
3291 let serialized = serde_json::to_value(&req).expect("request should serialize");
3292 let input = serialized["input"]
3293 .as_array()
3294 .expect("input should be array");
3295
3296 assert_eq!(
3297 serialized["instructions"],
3298 json!("System one\n\nSystem two")
3299 );
3300 assert_eq!(input.len(), 1);
3301 assert_eq!(input[0]["role"], "user");
3302 }
3303
3304 #[test]
3305 fn responses_request_with_only_system_messages_keeps_them_in_input() {
3306 let req = CompletionRequest::try_from((
3307 "gpt-4o-mini".to_string(),
3308 system_only_request("System only"),
3309 ))
3310 .expect("request conversion should succeed");
3311 let serialized = serde_json::to_value(&req).expect("request should serialize");
3312 let input = serialized["input"]
3313 .as_array()
3314 .expect("input should be array");
3315
3316 assert!(
3317 serialized.get("instructions").is_none(),
3318 "lifting a system-only history would leave input empty, so it stays in input"
3319 );
3320 assert_eq!(input.len(), 1);
3321 assert_eq!(input[0]["role"], "system");
3322 assert!(input[0].to_string().contains("System only"));
3323 }
3324
3325 #[test]
3326 fn responses_model_can_fallback_to_system_messages_in_input() {
3327 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3328 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3329 .with_system_instructions_as_messages();
3330
3331 let req = model
3332 .create_completion_request(request_with_preamble("You are concise."))
3333 .expect("request should convert");
3334 let serialized = serde_json::to_value(&req).expect("request should serialize");
3335 let input = serialized["input"]
3336 .as_array()
3337 .expect("input should be array");
3338
3339 assert!(serialized.get("instructions").is_none());
3340 assert_eq!(input.len(), 2);
3341 assert_eq!(input[0]["role"], "system");
3342 assert!(input[0].to_string().contains("You are concise."));
3343 assert_eq!(input[1]["role"], "user");
3344 }
3345
3346 #[test]
3347 fn responses_client_can_fallback_to_system_messages_in_input() {
3348 use crate::prelude::CompletionClient;
3349
3350 let client = crate::providers::openai::Client::new("dummy-key")
3351 .expect("client")
3352 .with_system_instructions_as_messages();
3353 let model = client.completion_model("gpt-4o-mini");
3354
3355 let req = model
3356 .create_completion_request(request_with_preamble("You are concise."))
3357 .expect("request should convert");
3358 let serialized = serde_json::to_value(&req).expect("request should serialize");
3359 let input = serialized["input"]
3360 .as_array()
3361 .expect("input should be array");
3362
3363 assert!(serialized.get("instructions").is_none());
3364 assert_eq!(input.len(), 2);
3365 assert_eq!(input[0]["role"], "system");
3366 assert!(input[0].to_string().contains("You are concise."));
3367 assert_eq!(input[1]["role"], "user");
3368 }
3369
3370 #[test]
3371 fn responses_model_can_lift_all_system_messages_via_placement() {
3372 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3373 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3374 .with_system_instructions_placement(SystemInstructionsPlacement::AllInstructions);
3375
3376 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "again")
3377 .preamble("System one".to_string())
3378 .message(completion::Message::user("hi"))
3379 .message(completion::Message::system("Mid-conversation instruction"))
3380 .build();
3381
3382 let req = model
3383 .create_completion_request(request)
3384 .expect("request should convert");
3385 let serialized = serde_json::to_value(&req).expect("request should serialize");
3386 let input = serialized["input"]
3387 .as_array()
3388 .expect("input should be array");
3389
3390 assert_eq!(
3391 serialized["instructions"],
3392 json!("System one\n\nMid-conversation instruction")
3393 );
3394 assert!(
3395 input.iter().all(|item| item["role"] != "system"),
3396 "AllInstructions should leave no system items in input: {input:?}"
3397 );
3398 }
3399
3400 #[test]
3401 fn responses_client_placement_survives_completions_api_round_trip() {
3402 use crate::prelude::CompletionClient;
3403
3404 let client = crate::providers::openai::Client::new("dummy-key")
3405 .expect("client")
3406 .with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
3407 .completions_api()
3408 .responses_api();
3409 let model = client.completion_model("gpt-4o-mini");
3410
3411 let req = model
3412 .create_completion_request(request_with_preamble("You are concise."))
3413 .expect("request should convert");
3414 let serialized = serde_json::to_value(&req).expect("request should serialize");
3415
3416 assert!(
3417 serialized.get("instructions").is_none(),
3418 "placement configured before completions_api() should survive responses_api()"
3419 );
3420 assert_eq!(serialized["input"][0]["role"], "system");
3421 }
3422
3423 #[test]
3424 fn all_instructions_system_only_input_reports_non_system_requirement() {
3425 let err = CompletionRequest::try_from(ResponsesRequestParams {
3426 model: "gpt-4o-mini".to_string(),
3427 request: system_only_request("System only"),
3428 system_instructions_placement: SystemInstructionsPlacement::AllInstructions,
3429 })
3430 .expect_err("system-only input should fail once every item is lifted");
3431
3432 assert!(
3433 err.to_string().contains("non-system item"),
3434 "error should explain that lifted system messages left input empty: {err}"
3435 );
3436 }
3437
3438 #[test]
3439 fn all_instructions_whitespace_only_system_input_reports_non_system_requirement() {
3440 let err = CompletionRequest::try_from(ResponsesRequestParams {
3441 model: "gpt-4o-mini".to_string(),
3442 request: system_only_request(" "),
3443 system_instructions_placement: SystemInstructionsPlacement::AllInstructions,
3444 })
3445 .expect_err("whitespace-only system input should fail once every item is lifted");
3446
3447 assert!(
3448 err.to_string().contains("non-system item"),
3449 "even when lifted system text is whitespace-only (so no `instructions` field is \
3450 produced), the error should explain that system messages were lifted: {err}"
3451 );
3452 }
3453
3454 #[test]
3455 fn responses_request_conversion_keeps_tools_non_strict_by_default() {
3456 let req = CompletionRequest::try_from(("gpt-4o-mini".to_string(), weather_tool_request()))
3457 .expect("request should convert");
3458
3459 let tool = &req.tools[0];
3460 assert!(!tool.strict);
3461 assert_eq!(tool.parameters["required"], json!(["location"]));
3462 assert!(tool.parameters.get("additionalProperties").is_none());
3463 }
3464
3465 #[test]
3466 fn responses_model_strict_tools_opt_in_sanitizes_all_function_tools() {
3467 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3468 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3469 .with_strict_tools()
3470 .with_tool(completion::ToolDefinition {
3471 name: "lookup".to_string(),
3472 description: "Look something up".to_string(),
3473 parameters: json!({
3474 "type": "object",
3475 "properties": {"q": {"type": "string"}}
3476 }),
3477 });
3478
3479 let mut request = weather_tool_request();
3480 request.additional_params = Some(json!({
3481 "tools": [{
3482 "type": "function",
3483 "name": "extra",
3484 "description": "An additional_params tool",
3485 "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}
3486 }]
3487 }));
3488
3489 let req = model
3490 .create_completion_request(request)
3491 .expect("request should convert");
3492
3493 assert_eq!(req.tools.len(), 3);
3494 for tool in &req.tools {
3495 assert!(tool.strict, "{} should be strict", tool.name);
3496 assert_eq!(tool.parameters["additionalProperties"], json!(false));
3497 }
3498 }
3499
3500 #[test]
3501 fn responses_model_default_preserves_all_function_tools_as_constructed() {
3502 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3503 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3504 .with_tool(weather_tool_definition());
3505
3506 let mut request = weather_tool_request();
3507 request.additional_params = Some(json!({
3508 "tools": [{
3509 "type": "function",
3510 "name": "extra",
3511 "description": "An additional_params tool",
3512 "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}
3513 }]
3514 }));
3515
3516 let req = model
3517 .create_completion_request(request)
3518 .expect("request should convert");
3519
3520 assert_eq!(req.tools.len(), 3);
3521 for tool in &req.tools {
3522 assert!(!tool.strict, "{} should not be strict", tool.name);
3523 assert!(tool.parameters.get("additionalProperties").is_none());
3524 }
3525 }
3526
3527 #[test]
3528 fn responses_explicit_strict_tool_stays_strict_on_default_model() {
3529 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3530 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini").with_tool(
3531 ResponsesToolDefinition::strict_function(
3532 "lookup",
3533 "Look something up",
3534 json!({"type": "object", "properties": {"q": {"type": "string"}}}),
3535 ),
3536 );
3537
3538 let req = model
3539 .create_completion_request(weather_tool_request())
3540 .expect("request should convert");
3541
3542 assert!(!req.tools[0].strict);
3543 assert!(req.tools[1].strict);
3544 assert_eq!(
3545 req.tools[1].parameters["additionalProperties"],
3546 json!(false)
3547 );
3548 }
3549
3550 fn response_with_service_tier(service_tier: &str) -> Value {
3551 json!({
3552 "id": "resp_123",
3553 "object": "response",
3554 "created_at": 0,
3555 "status": "completed",
3556 "model": "gpt-5.4",
3557 "output": [],
3558 "service_tier": service_tier,
3559 })
3560 }
3561
3562 #[test]
3563 fn completion_response_deserializes_standard_service_tier() {
3564 let response: CompletionResponse =
3565 serde_json::from_value(response_with_service_tier("standard"))
3566 .expect("response should deserialize");
3567
3568 assert!(matches!(
3569 response.additional_parameters.service_tier,
3570 Some(OpenAIServiceTier::Standard)
3571 ));
3572 }
3573
3574 #[test]
3575 fn completion_response_deserializes_priority_service_tier() {
3576 let response: CompletionResponse =
3577 serde_json::from_value(response_with_service_tier("priority"))
3578 .expect("response should deserialize");
3579
3580 assert!(matches!(
3581 response.additional_parameters.service_tier,
3582 Some(OpenAIServiceTier::Priority)
3583 ));
3584 }
3585
3586 #[test]
3587 fn completion_response_preserves_unknown_service_tier() {
3588 let response: CompletionResponse =
3589 serde_json::from_value(response_with_service_tier("provider_experimental"))
3590 .expect("response should deserialize");
3591
3592 let Some(OpenAIServiceTier::Other(service_tier)) =
3593 response.additional_parameters.service_tier
3594 else {
3595 panic!("expected provider-specific service tier");
3596 };
3597
3598 assert_eq!(service_tier, "provider_experimental");
3599 }
3600
3601 #[test]
3602 fn responses_request_keeps_documents_after_lifted_system_messages() {
3603 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Prompt")
3604 .message(completion::Message::system("System prompt"))
3605 .message(completion::Message::user("Earlier user turn"))
3606 .message(completion::Message::assistant("Earlier assistant turn"))
3607 .document(test_document("doc1", "Document text."))
3608 .build();
3609
3610 let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3611 .expect("request conversion should succeed");
3612
3613 let serialized =
3614 serde_json::to_value(&responses_request).expect("request should serialize");
3615 let input = serialized["input"]
3616 .as_array()
3617 .expect("input should be an array");
3618
3619 assert_eq!(serialized["instructions"], json!("System prompt"));
3620 assert_eq!(input.len(), 4);
3621 assert_eq!(input[0]["role"], "user");
3622 assert!(
3623 input[0].to_string().contains("<file id: doc1>"),
3624 "document input should be first after system instructions are lifted: {input:?}"
3625 );
3626 assert_eq!(input[1]["role"], "user");
3627 assert!(
3628 input[1].to_string().contains("Earlier user turn"),
3629 "prior user history should follow document input: {input:?}"
3630 );
3631 assert_eq!(input[2]["role"], "assistant");
3632 assert!(
3633 input[2].to_string().contains("Earlier assistant turn"),
3634 "prior assistant history should follow prior user history: {input:?}"
3635 );
3636 assert_eq!(input[3]["role"], "user");
3637 assert!(
3638 input[3].to_string().contains("Prompt"),
3639 "prompt should remain last: {input:?}"
3640 );
3641 }
3642
3643 #[test]
3644 fn responses_direct_request_keeps_mid_conversation_system_messages_in_input() {
3645 let request = crate::completion::CompletionRequest {
3646 model: None,
3647 preamble: None,
3648 chat_history: crate::OneOrMany::many(vec![
3649 completion::Message::system("System prompt"),
3650 completion::Message::assistant("Earlier assistant turn"),
3651 completion::Message::system("Mid-conversation instruction"),
3652 completion::Message::user("Prompt"),
3653 ])
3654 .unwrap(),
3655 documents: vec![test_document("doc1", "Document text.")],
3656 tools: vec![],
3657 temperature: None,
3658 max_tokens: None,
3659 tool_choice: None,
3660 additional_params: None,
3661 output_schema: None,
3662 record_telemetry_content: false,
3663 };
3664
3665 let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3666 .expect("request conversion should succeed");
3667
3668 let serialized =
3669 serde_json::to_value(&responses_request).expect("request should serialize");
3670 let input = serialized["input"]
3671 .as_array()
3672 .expect("input should be an array");
3673
3674 assert_eq!(
3675 serialized["instructions"],
3676 json!("System prompt"),
3677 "only the leading run of system messages should be lifted"
3678 );
3679 assert_eq!(input.len(), 4);
3680 assert_eq!(input[0]["role"], "user");
3681 assert!(
3682 input[0].to_string().contains("<file id: doc1>"),
3683 "document input should follow lifted system instructions: {input:?}"
3684 );
3685 assert_eq!(input[1]["role"], "assistant");
3686 assert_eq!(input[2]["role"], "system");
3687 assert!(
3688 input[2]
3689 .to_string()
3690 .contains("Mid-conversation instruction"),
3691 "mid-conversation system messages should keep their position: {input:?}"
3692 );
3693 assert_eq!(input[3]["role"], "user");
3694 assert_eq!(
3695 input
3696 .iter()
3697 .filter(|message| message.to_string().contains("<file id: doc1>"))
3698 .count(),
3699 1,
3700 "document input should appear exactly once: {input:?}"
3701 );
3702 }
3703
3704 #[test]
3705 fn service_tier_serializes_expected_strings() {
3706 let cases = [
3707 (OpenAIServiceTier::Auto, "auto"),
3708 (OpenAIServiceTier::Default, "default"),
3709 (OpenAIServiceTier::Flex, "flex"),
3710 (OpenAIServiceTier::Priority, "priority"),
3711 (OpenAIServiceTier::Standard, "standard"),
3712 ];
3713
3714 for (service_tier, expected) in cases {
3715 assert_eq!(
3716 serde_json::to_value(service_tier).expect("service tier should serialize"),
3717 json!(expected)
3718 );
3719 }
3720
3721 assert_eq!(
3722 serde_json::to_value(OpenAIServiceTier::Other(
3723 "provider_experimental".to_string()
3724 ))
3725 .expect("provider-specific service tier should serialize"),
3726 json!("provider_experimental")
3727 );
3728 }
3729
3730 #[test]
3731 fn responses_usage_token_usage_preserves_reasoning_tokens() {
3732 let usage = ResponsesUsage {
3733 input_tokens: 100,
3734 input_tokens_details: Some(InputTokensDetails { cached_tokens: 25 }),
3735 output_tokens: 50,
3736 output_tokens_details: Some(OutputTokensDetails {
3737 reasoning_tokens: 15,
3738 }),
3739 total_tokens: 150,
3740 };
3741
3742 let token_usage = usage.token_usage();
3743
3744 assert_eq!(token_usage.input_tokens, 100);
3745 assert_eq!(token_usage.cached_input_tokens, 25);
3746 assert_eq!(token_usage.output_tokens, 50);
3747 assert_eq!(token_usage.reasoning_tokens, 15);
3748 assert_eq!(token_usage.total_tokens, 150);
3749 }
3750
3751 #[test]
3752 fn responses_usage_deserializes_without_output_token_details() {
3753 let usage: ResponsesUsage = serde_json::from_value(json!({
3754 "input_tokens": 100,
3755 "input_tokens_details": {
3756 "cached_tokens": 25
3757 },
3758 "output_tokens": 50,
3759 "total_tokens": 150
3760 }))
3761 .expect("usage should deserialize when output token details are omitted");
3762
3763 assert!(usage.output_tokens_details.is_none());
3764
3765 let token_usage = usage.token_usage();
3766
3767 assert_eq!(token_usage.input_tokens, 100);
3768 assert_eq!(token_usage.cached_input_tokens, 25);
3769 assert_eq!(token_usage.output_tokens, 50);
3770 assert_eq!(token_usage.reasoning_tokens, 0);
3771 assert_eq!(token_usage.total_tokens, 150);
3772 }
3773
3774 #[test]
3775 fn completion_response_accepts_top_level_reasoning_string() {
3776 let response: CompletionResponse = serde_json::from_value(json!({
3777 "id": "resp_123",
3778 "object": "response",
3779 "created_at": 0,
3780 "status": "completed",
3781 "model": "Qwen/Qwen3-4B",
3782 "reasoning": "thinking through the answer",
3783 "usage": {
3784 "input_tokens": 1,
3785 "output_tokens": 2,
3786 "total_tokens": 3
3787 },
3788 "output": [{
3789 "type": "message",
3790 "id": "msg_123",
3791 "status": "completed",
3792 "role": "assistant",
3793 "content": [{
3794 "type": "output_text",
3795 "annotations": [],
3796 "text": "done"
3797 }]
3798 }],
3799 "tools": []
3800 }))
3801 .expect("mistral.rs-style reasoning string should deserialize");
3802
3803 assert_eq!(
3804 response.provider_reasoning.as_deref(),
3805 Some("thinking through the answer")
3806 );
3807 assert_eq!(response.reasoning_metadata, None);
3808 assert_eq!(response.reasoning_context, None);
3809 assert_eq!(
3810 serde_json::to_value(&response).expect("response should serialize")["reasoning"],
3811 json!("thinking through the answer")
3812 );
3813
3814 let completion: completion::CompletionResponse<CompletionResponse> =
3815 response.try_into().expect("response should convert");
3816 let items = completion.choice.iter().collect::<Vec<_>>();
3817 assert!(matches!(
3818 items[0],
3819 completion::AssistantContent::Reasoning(_)
3820 ));
3821 assert!(matches!(items[1], completion::AssistantContent::Text(_)));
3822 }
3823
3824 #[test]
3825 fn completion_response_accepts_null_metadata() {
3826 let response: CompletionResponse = serde_json::from_value(json!({
3827 "id": "resp_123",
3828 "object": "response",
3829 "created_at": 0,
3830 "status": "completed",
3831 "model": "openai-compatible-model",
3832 "metadata": null,
3833 "output": [{
3834 "type": "message",
3835 "id": "msg_123",
3836 "status": "completed",
3837 "role": "assistant",
3838 "content": [{
3839 "type": "output_text",
3840 "annotations": [],
3841 "text": "done"
3842 }]
3843 }],
3844 "tools": []
3845 }))
3846 .expect("response with null metadata should deserialize");
3847
3848 assert!(response.additional_parameters.metadata.is_empty());
3849 }
3850
3851 #[test]
3852 fn completion_response_accepts_reasoning_only_response() {
3853 let response: CompletionResponse = serde_json::from_value(json!({
3854 "id": "resp_123",
3855 "object": "response",
3856 "created_at": 0,
3857 "status": "completed",
3858 "model": "Qwen/Qwen3-4B",
3859 "reasoning": "thinking only",
3860 "usage": {
3861 "input_tokens": 1,
3862 "output_tokens": 2,
3863 "total_tokens": 3
3864 },
3865 "output": [],
3866 "tools": []
3867 }))
3868 .expect("reasoning-only response should deserialize");
3869
3870 let completion: completion::CompletionResponse<CompletionResponse> = response
3871 .try_into()
3872 .expect("reasoning-only response should convert");
3873 let items = completion.choice.iter().collect::<Vec<_>>();
3874
3875 assert_eq!(items.len(), 1);
3876 assert!(matches!(
3877 items[0],
3878 completion::AssistantContent::Reasoning(_)
3879 ));
3880 }
3881
3882 #[test]
3883 fn completion_response_rejects_empty_response_without_reasoning() {
3884 let response: CompletionResponse = serde_json::from_value(json!({
3885 "id": "resp_123",
3886 "object": "response",
3887 "created_at": 0,
3888 "status": "completed",
3889 "model": "Qwen/Qwen3-4B",
3890 "output": [],
3891 "tools": []
3892 }))
3893 .expect("empty response shape should deserialize");
3894
3895 let err = completion::CompletionResponse::<CompletionResponse>::try_from(response)
3896 .expect_err("empty response without reasoning should be rejected");
3897
3898 assert!(
3899 err.to_string()
3900 .contains("Response contained no message or tool call")
3901 );
3902 }
3903
3904 #[test]
3905 fn completion_response_preserves_context_without_treating_config_as_text() {
3906 let response: CompletionResponse = serde_json::from_value(json!({
3907 "id": "resp_123",
3908 "object": "response",
3909 "created_at": 0,
3910 "status": "completed",
3911 "model": "Qwen/Qwen3-4B",
3912 "reasoning": {
3913 "context": "all_turns",
3914 "effort": "high",
3915 "mode": "standard",
3916 "summary": null
3917 },
3918 "output": [{
3919 "type": "message",
3920 "id": "msg_123",
3921 "status": "completed",
3922 "role": "assistant",
3923 "content": [{
3924 "type": "output_text",
3925 "annotations": [],
3926 "text": "done"
3927 }]
3928 }],
3929 "tools": []
3930 }))
3931 .expect("object-shaped reasoning should be tolerated");
3932
3933 assert!(response.provider_reasoning.is_none());
3934 assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
3935 assert_eq!(
3936 response.reasoning_metadata.as_ref(),
3937 json!({
3938 "context": "all_turns",
3939 "effort": "high",
3940 "mode": "standard",
3941 "summary": null
3942 })
3943 .as_object()
3944 );
3945 assert_eq!(
3946 serde_json::to_value(&response).expect("response should serialize")["reasoning"],
3947 json!({
3948 "context": "all_turns",
3949 "effort": "high",
3950 "mode": "standard",
3951 "summary": null
3952 })
3953 );
3954
3955 let completion: completion::CompletionResponse<CompletionResponse> =
3956 response.try_into().expect("response should convert");
3957 let items = completion.choice.iter().collect::<Vec<_>>();
3958 assert_eq!(items.len(), 1);
3959 assert!(matches!(items[0], completion::AssistantContent::Text(_)));
3960 }
3961
3962 #[test]
3963 fn completion_response_preserves_unknown_reasoning_metadata_and_nulls() {
3964 let metadata = json!({
3965 "context": "future_context",
3966 "effort": "ultra",
3967 "summary": null,
3968 "future_control": { "depth": 3 }
3969 });
3970 let response: CompletionResponse = serde_json::from_value(json!({
3971 "id": "resp_123",
3972 "object": "response",
3973 "created_at": 0,
3974 "status": "completed",
3975 "model": "gpt-future",
3976 "reasoning": metadata.clone(),
3977 "output": [],
3978 "tools": []
3979 }))
3980 .expect("unknown reasoning metadata should deserialize");
3981
3982 assert_eq!(
3983 response.reasoning_context.as_deref(),
3984 Some("future_context")
3985 );
3986 assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
3987 assert_eq!(
3988 serde_json::to_value(&response).expect("response should serialize")["reasoning"],
3989 metadata
3990 );
3991 }
3992
3993 #[test]
3994 fn completion_response_ignores_unsupported_reasoning_shapes() {
3995 for reasoning in [Value::Null, json!(["unexpected"]), json!(42), json!(true)] {
3996 let response: CompletionResponse = serde_json::from_value(json!({
3997 "id": "resp_123",
3998 "object": "response",
3999 "created_at": 0,
4000 "status": "completed",
4001 "model": "openai-compatible-model",
4002 "reasoning": reasoning,
4003 "output": [],
4004 "tools": []
4005 }))
4006 .expect("unsupported reasoning shapes should remain non-fatal");
4007
4008 assert_eq!(response.provider_reasoning, None);
4009 assert_eq!(response.reasoning_metadata, None);
4010 assert_eq!(response.reasoning_context, None);
4011 let serialized = serde_json::to_value(&response).expect("response should serialize");
4012 assert!(
4013 !serialized
4014 .as_object()
4015 .expect("response should serialize as an object")
4016 .contains_key("reasoning")
4017 );
4018 }
4019 }
4020
4021 #[test]
4022 fn completion_response_reasoning_serialization_precedence_is_stable() {
4023 let mut response: CompletionResponse = serde_json::from_value(json!({
4024 "id": "resp_123",
4025 "object": "response",
4026 "created_at": 0,
4027 "status": "completed",
4028 "model": "gpt-5.6",
4029 "reasoning": { "context": "all_turns", "effort": "max" },
4030 "output": [],
4031 "tools": []
4032 }))
4033 .expect("reasoning metadata should deserialize");
4034
4035 response.reasoning_context = Some("current_turn".to_owned());
4036 response.additional_parameters.reasoning =
4037 Some(Reasoning::new().with_effort(ReasoningEffort::Low));
4038 let serialized = serde_json::to_string(&response).expect("response should serialize");
4039 assert_eq!(serialized.matches("\"reasoning\":").count(), 1);
4040 assert_eq!(
4041 serde_json::to_value(&response).expect("response should serialize")["reasoning"],
4042 json!({ "context": "all_turns", "effort": "max" })
4043 );
4044
4045 let metadata = response.reasoning_metadata.take();
4046 assert_eq!(
4047 serde_json::to_value(&response).expect("response should serialize")["reasoning"],
4048 json!({ "context": "current_turn" })
4049 );
4050
4051 response.reasoning_metadata = metadata;
4052 response.provider_reasoning = Some("compatible-provider text".to_owned());
4053 assert_eq!(
4054 serde_json::to_value(&response).expect("response should serialize")["reasoning"],
4055 json!("compatible-provider text")
4056 );
4057 }
4058
4059 fn request_with_reasoning_params(reasoning: Value) -> CompletionRequest {
4060 let mut request = request_with_preamble("You are concise.");
4061 request.additional_params = Some(json!({ "reasoning": reasoning }));
4062
4063 CompletionRequest::try_from(("gpt-5.6".to_string(), request))
4064 .expect("request with reasoning params should convert")
4065 }
4066
4067 #[test]
4068 fn reasoning_effort_max_survives_request_conversion() {
4069 let request = request_with_reasoning_params(json!({ "effort": "max" }));
4070 let serialized = serde_json::to_value(&request).expect("request should serialize");
4071
4072 assert_eq!(serialized["reasoning"], json!({ "effort": "max" }));
4073 }
4074
4075 #[test]
4076 fn reasoning_mode_pro_composes_with_independent_effort() {
4077 let request = request_with_reasoning_params(json!({ "effort": "high", "mode": "pro" }));
4078 let serialized = serde_json::to_value(&request).expect("request should serialize");
4079
4080 assert_eq!(
4081 serialized["reasoning"],
4082 json!({ "effort": "high", "mode": "pro" })
4083 );
4084 }
4085
4086 #[test]
4087 fn reasoning_context_values_survive_request_conversion() {
4088 for (context, wire_value) in [
4089 (ReasoningContext::Auto, "auto"),
4090 (ReasoningContext::AllTurns, "all_turns"),
4091 (ReasoningContext::CurrentTurn, "current_turn"),
4092 ] {
4093 let typed = serde_json::to_value(Reasoning::new().with_context(context))
4094 .expect("typed reasoning should serialize");
4095 assert_eq!(typed, json!({ "context": wire_value }));
4096
4097 let request = request_with_reasoning_params(json!({ "context": wire_value }));
4098 let serialized = serde_json::to_value(&request).expect("request should serialize");
4099 assert_eq!(serialized["reasoning"], json!({ "context": wire_value }));
4100 }
4101 }
4102
4103 #[test]
4104 fn reasoning_omits_unset_optional_fields() {
4105 let reasoning = serde_json::to_value(Reasoning::new().with_mode(ReasoningMode::Pro))
4106 .expect("reasoning should serialize");
4107
4108 assert_eq!(reasoning, json!({ "mode": "pro" }));
4109
4110 let reasoning = serde_json::to_value(
4111 Reasoning::new()
4112 .with_effort(ReasoningEffort::Max)
4113 .with_mode(ReasoningMode::Pro)
4114 .with_context(ReasoningContext::CurrentTurn)
4115 .with_summary_level(ReasoningSummaryLevel::Detailed),
4116 )
4117 .expect("reasoning should serialize");
4118
4119 assert_eq!(
4120 reasoning,
4121 json!({
4122 "effort": "max",
4123 "mode": "pro",
4124 "context": "current_turn",
4125 "summary": "detailed"
4126 })
4127 );
4128 }
4129
4130 #[test]
4131 fn completion_response_does_not_duplicate_structured_reasoning() {
4132 let response: CompletionResponse = serde_json::from_value(json!({
4133 "id": "resp_123",
4134 "object": "response",
4135 "created_at": 0,
4136 "status": "completed",
4137 "model": "gpt-5.4",
4138 "reasoning": "provider top-level text",
4139 "output": [{
4140 "type": "reasoning",
4141 "id": "rs_123",
4142 "summary": [{
4143 "type": "summary_text",
4144 "text": "structured summary"
4145 }]
4146 }, {
4147 "type": "message",
4148 "id": "msg_123",
4149 "status": "completed",
4150 "role": "assistant",
4151 "content": [{
4152 "type": "output_text",
4153 "annotations": [],
4154 "text": "done"
4155 }]
4156 }],
4157 "tools": []
4158 }))
4159 .expect("response should deserialize");
4160
4161 let completion: completion::CompletionResponse<CompletionResponse> =
4162 response.try_into().expect("response should convert");
4163 let reasoning_count = completion
4164 .choice
4165 .iter()
4166 .filter(|item| matches!(item, completion::AssistantContent::Reasoning(_)))
4167 .count();
4168
4169 assert_eq!(reasoning_count, 1);
4170 }
4171
4172 #[test]
4173 fn idless_reasoning_is_skipped_when_converting_responses_history() {
4174 let assistant = message::Message::Assistant {
4175 id: Some("msg_123".to_string()),
4176 content: OneOrMany::one(message::AssistantContent::Reasoning(
4177 message::Reasoning::new("provider reasoning"),
4178 )),
4179 };
4180
4181 let converted = Vec::<Message>::try_from(assistant)
4182 .expect("idless reasoning should degrade gracefully");
4183
4184 assert!(converted.is_empty());
4185 }
4186
4187 #[test]
4188 fn idless_reasoning_only_is_skipped_without_empty_input_item() {
4189 let assistant = completion::Message::Assistant {
4190 id: None,
4191 content: OneOrMany::one(message::AssistantContent::Reasoning(
4192 message::Reasoning::new("provider reasoning"),
4193 )),
4194 };
4195
4196 let converted = Vec::<InputItem>::try_from(assistant)
4197 .expect("idless reasoning should degrade gracefully");
4198
4199 assert!(converted.is_empty());
4200 }
4201
4202 #[test]
4203 fn idless_reasoning_plus_text_preserves_text_for_responses_history() {
4204 let assistant = message::Message::Assistant {
4205 id: Some("msg_123".to_string()),
4206 content: OneOrMany::many(vec![
4207 message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
4208 message::AssistantContent::Text(Text::new("final answer")),
4209 ])
4210 .expect("assistant content should be non-empty"),
4211 };
4212
4213 let converted =
4214 Vec::<Message>::try_from(assistant).expect("assistant history should convert");
4215
4216 assert_eq!(converted.len(), 1);
4217 let Message::Assistant { content, .. } = &converted[0] else {
4218 panic!("expected assistant message");
4219 };
4220 assert!(matches!(
4221 content.first_ref(),
4222 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
4223 ));
4224 }
4225
4226 #[test]
4227 fn completion_history_idless_reasoning_plus_text_preserves_text_input_item() {
4228 let assistant = completion::Message::Assistant {
4229 id: Some("msg_123".to_string()),
4230 content: OneOrMany::many(vec![
4231 message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
4232 message::AssistantContent::Text(Text::new("final answer")),
4233 ])
4234 .expect("assistant content should be non-empty"),
4235 };
4236
4237 let converted =
4238 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4239
4240 assert_eq!(converted.len(), 1);
4241 assert!(matches!(converted[0].role, Some(Role::Assistant)));
4242 let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
4243 panic!("expected assistant message input item");
4244 };
4245 assert!(matches!(
4246 content.first_ref(),
4247 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
4248 ));
4249 }
4250
4251 #[test]
4252 fn assistant_text_without_idless_reasoning_replays_as_output_text() {
4253 let assistant = completion::Message::Assistant {
4254 id: Some("msg_123".to_string()),
4255 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
4256 };
4257
4258 let converted =
4259 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4260
4261 assert_eq!(converted.len(), 1);
4262 let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
4263 panic!("expected assistant message input item");
4264 };
4265 assert!(matches!(
4266 content.first_ref(),
4267 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
4268 ));
4269 }
4270
4271 #[test]
4272 fn idless_completion_assistant_text_replays_as_easy_input_message() {
4273 let assistant = completion::Message::Assistant {
4274 id: None,
4275 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
4276 };
4277
4278 let converted =
4279 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4280
4281 assert_eq!(converted.len(), 1);
4282 assert!(matches!(converted[0].role, Some(Role::Assistant)));
4283 let InputContent::Message(Message::AssistantInput { content, .. }) = &converted[0].input
4284 else {
4285 panic!("expected assistant input message item");
4286 };
4287 assert_eq!(content, "final answer");
4288
4289 let serialized =
4290 serde_json::to_value(&converted[0]).expect("input item should serialize to JSON");
4291 assert_eq!(serialized["type"], json!("message"));
4292 assert_eq!(serialized["role"], json!("assistant"));
4293 assert_eq!(serialized["content"], json!("final answer"));
4294 assert!(serialized.get("id").is_none());
4295 assert!(serialized.get("status").is_none());
4296 }
4297
4298 #[test]
4299 fn idless_message_assistant_text_replays_as_easy_input_message() {
4300 let assistant = message::Message::Assistant {
4301 id: None,
4302 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
4303 };
4304
4305 let converted =
4306 Vec::<Message>::try_from(assistant).expect("assistant history should convert");
4307
4308 assert_eq!(converted.len(), 1);
4309 let Message::AssistantInput { content, .. } = &converted[0] else {
4310 panic!("expected assistant input message");
4311 };
4312 assert_eq!(content, "final answer");
4313
4314 let serialized = serde_json::to_value(&converted[0])
4315 .expect("assistant message should serialize to JSON");
4316 assert_eq!(serialized["role"], json!("assistant"));
4317 assert_eq!(serialized["content"], json!("final answer"));
4318 assert!(serialized.get("id").is_none());
4319 assert!(serialized.get("status").is_none());
4320 }
4321
4322 #[test]
4323 fn structured_reasoning_with_id_still_converts_for_responses_history() {
4324 let assistant = message::Message::Assistant {
4325 id: Some("msg_123".to_string()),
4326 content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
4327 id: Some("rs_123".to_string()),
4328 content: vec![message::ReasoningContent::Summary(
4329 "structured summary".to_string(),
4330 )],
4331 })),
4332 };
4333
4334 let converted =
4335 Vec::<Message>::try_from(assistant).expect("structured reasoning should still convert");
4336
4337 assert_eq!(converted.len(), 1);
4338 let Message::Assistant { content, .. } = &converted[0] else {
4339 panic!("expected assistant message");
4340 };
4341 assert!(matches!(
4342 content.first_ref(),
4343 AssistantContentType::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
4344 ));
4345 }
4346
4347 #[test]
4348 fn structured_reasoning_with_id_still_converts_to_input_item() {
4349 let assistant = completion::Message::Assistant {
4350 id: Some("msg_123".to_string()),
4351 content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
4352 id: Some("rs_123".to_string()),
4353 content: vec![message::ReasoningContent::Summary(
4354 "structured summary".to_string(),
4355 )],
4356 })),
4357 };
4358
4359 let converted =
4360 Vec::<InputItem>::try_from(assistant).expect("structured reasoning should convert");
4361
4362 assert_eq!(converted.len(), 1);
4363 assert!(converted[0].role.is_none());
4364 assert!(matches!(
4365 &converted[0].input,
4366 InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
4367 ));
4368 }
4369
4370 #[test]
4371 fn assistant_reasoning_text_tool_call_convert_in_responses_replay_order() {
4372 let assistant = completion::Message::Assistant {
4373 id: Some("msg_123".to_string()),
4374 content: OneOrMany::many(vec![
4375 message::AssistantContent::Reasoning(message::Reasoning {
4376 id: Some("rs_123".to_string()),
4377 content: vec![message::ReasoningContent::Summary(
4378 "structured summary".to_string(),
4379 )],
4380 }),
4381 message::AssistantContent::Text(Text::new("final answer")),
4382 message::AssistantContent::tool_call_with_call_id(
4383 "fc_123",
4384 "call_123".to_string(),
4385 "lookup",
4386 json!({"query": "rig"}),
4387 ),
4388 ])
4389 .expect("assistant content should be non-empty"),
4390 };
4391
4392 let converted =
4393 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4394
4395 assert_eq!(converted.len(), 3);
4396 assert!(converted[0].role.is_none());
4397 assert!(matches!(
4398 &converted[0].input,
4399 InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
4400 ));
4401
4402 assert!(matches!(converted[1].role, Some(Role::Assistant)));
4403 let InputContent::Message(Message::Assistant { content, id, .. }) = &converted[1].input
4404 else {
4405 panic!("expected assistant output message");
4406 };
4407 assert_eq!(id, "msg_123");
4408 assert!(matches!(
4409 content.first_ref(),
4410 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. }))
4411 if text == "final answer"
4412 ));
4413
4414 assert!(converted[2].role.is_none());
4415 let InputContent::FunctionCall(OutputFunctionCall {
4416 id, call_id, name, ..
4417 }) = &converted[2].input
4418 else {
4419 panic!("expected function call input item");
4420 };
4421 assert_eq!(id, "fc_123");
4422 assert_eq!(call_id, "call_123");
4423 assert_eq!(name, "lookup");
4424 }
4425
4426 #[test]
4427 fn mocked_second_turn_request_omits_unreplayable_reasoning() {
4428 let request = crate::completion::CompletionRequest {
4429 model: None,
4430 preamble: Some("You are concise.".to_string()),
4431 chat_history: OneOrMany::many(vec![
4432 completion::Message::User {
4433 content: OneOrMany::one(message::UserContent::Text(Text::new(
4434 "Think briefly, then answer.",
4435 ))),
4436 },
4437 completion::Message::Assistant {
4438 id: Some("msg_123".to_string()),
4439 content: OneOrMany::many(vec![
4440 message::AssistantContent::Reasoning(message::Reasoning::new(
4441 "provider reasoning",
4442 )),
4443 message::AssistantContent::Text(Text::new("final answer")),
4444 ])
4445 .expect("assistant content should be non-empty"),
4446 },
4447 completion::Message::Assistant {
4448 id: None,
4449 content: OneOrMany::many(vec![
4450 message::AssistantContent::Reasoning(message::Reasoning::new(
4451 "provider reasoning only",
4452 )),
4453 message::AssistantContent::Text(Text::new("")),
4454 ])
4455 .expect("assistant content should be non-empty"),
4456 },
4457 completion::Message::User {
4458 content: OneOrMany::one(message::UserContent::Text(Text::new(
4459 "/no_think Reply with exactly: OK",
4460 ))),
4461 },
4462 ])
4463 .expect("history should be non-empty"),
4464 documents: Vec::new(),
4465 tools: Vec::new(),
4466 temperature: None,
4467 max_tokens: Some(64),
4468 tool_choice: None,
4469 additional_params: None,
4470 output_schema: None,
4471 record_telemetry_content: false,
4472 };
4473
4474 let request = CompletionRequest::try_from(("Qwen/Qwen3-4B".to_string(), request))
4475 .expect("request should convert");
4476 let value = serde_json::to_value(&request).expect("request should serialize");
4477 let input = value["input"]
4478 .as_array()
4479 .expect("mocked multi-turn request should serialize input as an array");
4480
4481 assert!(!input.iter().any(|item| {
4482 item.get("type") == Some(&json!("reasoning")) && item.get("id").is_none()
4483 }));
4484 assert!(!input.iter().any(|item| {
4485 item.get("role") == Some(&json!("assistant"))
4486 && item
4487 .get("content")
4488 .and_then(Value::as_array)
4489 .is_some_and(Vec::is_empty)
4490 }));
4491
4492 let assistant_items = input
4493 .iter()
4494 .filter(|item| item.get("role") == Some(&json!("assistant")))
4495 .collect::<Vec<_>>();
4496
4497 assert_eq!(assistant_items.len(), 1);
4498 assert_eq!(assistant_items[0]["content"][0]["type"], "output_text");
4499 assert_eq!(assistant_items[0]["content"][0]["text"], "final answer");
4500 }
4501
4502 #[test]
4503 fn responses_usage_add_preserves_rhs_details_when_lhs_details_are_absent() {
4504 let lhs = ResponsesUsage {
4505 input_tokens: 10,
4506 input_tokens_details: None,
4507 output_tokens: 20,
4508 output_tokens_details: None,
4509 total_tokens: 30,
4510 };
4511 let rhs = ResponsesUsage {
4512 input_tokens: 3,
4513 input_tokens_details: Some(InputTokensDetails { cached_tokens: 2 }),
4514 output_tokens: 5,
4515 output_tokens_details: Some(OutputTokensDetails {
4516 reasoning_tokens: 4,
4517 }),
4518 total_tokens: 8,
4519 };
4520
4521 let usage = lhs + rhs;
4522 let token_usage = usage.token_usage();
4523
4524 assert_eq!(token_usage.input_tokens, 13);
4525 assert_eq!(token_usage.cached_input_tokens, 2);
4526 assert_eq!(token_usage.output_tokens, 25);
4527 assert_eq!(token_usage.reasoning_tokens, 4);
4528 assert_eq!(token_usage.total_tokens, 38);
4529 }
4530
4531 #[test]
4532 fn file_id_document_serializes_as_input_file_content() {
4533 let message = message::Message::User {
4534 content: OneOrMany::one(message::UserContent::Document(message::Document {
4535 data: DocumentSourceKind::FileId("file_abc".to_string()),
4536 media_type: None,
4537 additional_params: None,
4538 })),
4539 };
4540
4541 let converted: Vec<Message> = message.try_into().expect("conversion should succeed");
4542 let Message::User { content, .. } = &converted[0] else {
4543 panic!("expected user message");
4544 };
4545
4546 let json = serde_json::to_value(content.first_ref()).expect("serialize content");
4547
4548 assert_eq!(json["type"], "input_file");
4549 assert_eq!(json["file_id"], "file_abc");
4550 assert!(json.get("file_data").is_none());
4551 assert!(json.get("file_url").is_none());
4552 }
4553
4554 #[test]
4555 fn file_id_document_serializes_as_input_item_content() {
4556 let message = completion::Message::User {
4557 content: OneOrMany::one(message::UserContent::Document(message::Document {
4558 data: DocumentSourceKind::FileId("file_abc".to_string()),
4559 media_type: None,
4560 additional_params: None,
4561 })),
4562 };
4563
4564 let converted: Vec<InputItem> = message.try_into().expect("conversion should succeed");
4565 let json = serde_json::to_value(&converted[0]).expect("serialize input item");
4566
4567 assert_eq!(json["type"], "message");
4568 assert_eq!(json["role"], "user");
4569 assert_eq!(json["content"][0]["type"], "input_file");
4570 assert_eq!(json["content"][0]["file_id"], "file_abc");
4571 assert!(json["content"][0].get("file_data").is_none());
4572 assert!(json["content"][0].get("file_url").is_none());
4573 }
4574
4575 #[tokio::test]
4576 async fn responses_completion_http_non_success_preserves_status_and_body() {
4577 use crate::client::CompletionClient;
4578 use crate::completion::CompletionModel;
4579 use crate::providers::openai::Client;
4580 use crate::test_utils::RecordingHttpClient;
4581
4582 let body = r#"{"error":{"message":"bad image","type":"invalid_request_error","code":"invalid_value"}}"#;
4583 let http_client =
4584 RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
4585 let client = Client::builder()
4586 .api_key("test-key")
4587 .http_client(http_client)
4588 .build()
4589 .expect("build client");
4590 let model = client.completion_model("gpt-4o-mini");
4591 let request = model.completion_request("hello").build();
4592
4593 let error = model
4594 .completion(request)
4595 .await
4596 .expect_err("completion should fail with non-success status");
4597
4598 assert!(matches!(error, CompletionError::HttpError(_)));
4599 assert_eq!(
4600 error.provider_response_status(),
4601 Some(http::StatusCode::BAD_REQUEST)
4602 );
4603 assert_eq!(error.provider_response_body(), Some(body));
4604 let json = error
4605 .provider_response_json()
4606 .expect("raw body should be valid JSON")
4607 .expect("parsed JSON should be present");
4608 assert_eq!(json["error"]["code"], "invalid_value");
4609 }
4610
4611 #[test]
4612 fn output_unknown_preserves_hosted_tool_payload() {
4613 let item = json!({
4614 "type": "web_search_call",
4615 "id": "ws_001",
4616 "status": "completed",
4617 "action": { "type": "search", "queries": ["rig framework"] },
4618 });
4619
4620 let output: Output =
4621 serde_json::from_value(item.clone()).expect("unknown output should deserialize");
4622
4623 let Output::Unknown(value) = output else {
4624 panic!("expected Output::Unknown for an unmodeled item type");
4625 };
4626 assert_eq!(value, item);
4627 }
4628
4629 #[test]
4630 fn output_unknown_round_trips_value_equal() {
4631 let item = json!({
4632 "type": "file_search_call",
4633 "id": "fs_007",
4634 "status": "in_progress",
4635 "queries": ["lifecycle"],
4636 });
4637
4638 let output: Output =
4639 serde_json::from_value(item.clone()).expect("unknown output should deserialize");
4640 let serialized = serde_json::to_value(&output).expect("unknown output should serialize");
4641
4642 assert_eq!(serialized, item);
4643 }
4644
4645 #[test]
4646 fn output_known_variant_with_bad_body_errors() {
4647 let malformed = json!({
4650 "type": "function_call",
4651 "id": "call_1",
4652 });
4654
4655 let result: Result<Output, _> = serde_json::from_value(malformed);
4656 assert!(result.is_err());
4657 }
4658
4659 #[test]
4660 fn completion_response_with_unknown_output_keeps_usage() {
4661 let response = json!({
4664 "id": "resp_123",
4665 "object": "response",
4666 "created_at": 0,
4667 "status": "completed",
4668 "model": "gpt-5.4",
4669 "output": [
4670 {
4671 "type": "web_search_call",
4672 "id": "ws_001",
4673 "status": "completed",
4674 },
4675 {
4676 "type": "message",
4677 "id": "msg_1",
4678 "role": "assistant",
4679 "status": "completed",
4680 "content": [ { "type": "output_text", "text": "hi", "annotations": [] } ],
4681 },
4682 ],
4683 "usage": {
4684 "input_tokens": 100,
4685 "input_tokens_details": { "cached_tokens": 25 },
4686 "output_tokens": 50,
4687 "output_tokens_details": { "reasoning_tokens": 15 },
4688 "total_tokens": 150,
4689 },
4690 });
4691
4692 let response: CompletionResponse =
4693 serde_json::from_value(response).expect("response should deserialize");
4694
4695 assert!(matches!(response.output.first(), Some(Output::Unknown(_))));
4696 let usage = response.usage.expect("usage should be present");
4697 assert_eq!(usage.total_tokens, 150);
4698 }
4699
4700 #[test]
4701 fn output_known_variant_round_trips_value_equal() {
4702 let item = json!({
4708 "type": "function_call",
4709 "id": "fc_1",
4710 "arguments": "{}",
4711 "call_id": "c1",
4712 "name": "search",
4713 "status": "completed",
4714 });
4715
4716 let output: Output =
4717 serde_json::from_value(item.clone()).expect("known output should deserialize");
4718 assert!(matches!(output, Output::FunctionCall(_)));
4719
4720 let serialized = serde_json::to_value(&output).expect("known output should serialize");
4721 assert_eq!(serialized, item);
4722 }
4723
4724 #[test]
4725 fn output_reasoning_round_trips_value_equal() {
4726 let original = Output::Reasoning {
4732 id: "reasoning_1".to_string(),
4733 summary: vec![ReasoningSummary::SummaryText {
4734 text: "weighing options".to_string(),
4735 }],
4736 content: vec!["private reasoning".to_string()],
4737 encrypted_content: Some("ENCRYPTED".to_string()),
4738 status: Some(ToolStatus::Completed),
4739 };
4740
4741 let value = serde_json::to_value(&original).expect("reasoning should serialize");
4742 let round_tripped: Output =
4743 serde_json::from_value(value).expect("reasoning should deserialize");
4744
4745 assert_eq!(round_tripped, original);
4746 }
4747
4748 #[test]
4749 fn output_reasoning_conversion_omits_empty_encrypted_content() {
4750 let output = Output::Reasoning {
4751 id: "reasoning_1".to_string(),
4752 summary: vec![],
4753 content: vec!["visible reasoning".to_string()],
4754 encrypted_content: Some(String::new()),
4755 status: Some(ToolStatus::Completed),
4756 };
4757
4758 let converted = Vec::<completion::AssistantContent>::from(output);
4759
4760 assert_eq!(converted.len(), 1);
4761 let completion::AssistantContent::Reasoning(reasoning) = &converted[0] else {
4762 panic!("expected reasoning output");
4763 };
4764 assert_eq!(reasoning.id.as_deref(), Some("reasoning_1"));
4765 assert_eq!(reasoning.content.len(), 1);
4766 assert!(matches!(
4767 reasoning.content.first(),
4768 Some(message::ReasoningContent::Text { text, .. })
4769 if text == "visible reasoning"
4770 ));
4771 }
4772
4773 #[test]
4774 fn output_reasoning_conversion_preserves_non_empty_encrypted_content() {
4775 let output = Output::Reasoning {
4776 id: "reasoning_1".to_string(),
4777 summary: vec![],
4778 content: vec![],
4779 encrypted_content: Some("ciphertext".to_string()),
4780 status: Some(ToolStatus::Completed),
4781 };
4782
4783 let converted = Vec::<completion::AssistantContent>::from(output);
4784
4785 assert_eq!(converted.len(), 1);
4786 let completion::AssistantContent::Reasoning(reasoning) = &converted[0] else {
4787 panic!("expected reasoning output");
4788 };
4789 assert_eq!(
4790 reasoning.content,
4791 vec![message::ReasoningContent::Encrypted(
4792 "ciphertext".to_string()
4793 )]
4794 );
4795 }
4796
4797 #[test]
4798 fn output_reasoning_none_optionals_serialize_as_explicit_null() {
4799 let value = serde_json::to_value(Output::Reasoning {
4805 id: "reasoning_1".to_string(),
4806 summary: vec![],
4807 content: vec![],
4808 encrypted_content: None,
4809 status: None,
4810 })
4811 .expect("reasoning should serialize");
4812
4813 assert_eq!(value["type"], "reasoning");
4814 assert_eq!(value["encrypted_content"], Value::Null);
4815 assert_eq!(value["status"], Value::Null);
4816 assert!(value.get("encrypted_content").is_some());
4817 assert!(value.get("status").is_some());
4818 }
4819
4820 #[test]
4821 fn output_message_round_trips_value_equal() {
4822 let item = json!({
4826 "type": "message",
4827 "id": "msg_1",
4828 "role": "assistant",
4829 "status": "completed",
4830 "content": [ { "type": "output_text", "text": "hello", "annotations": [] } ],
4831 });
4832
4833 let output: Output =
4834 serde_json::from_value(item.clone()).expect("message item should deserialize");
4835 assert!(matches!(output, Output::Message(_)));
4836
4837 let serialized = serde_json::to_value(&output).expect("message should serialize");
4838 assert_eq!(serialized, item);
4839 }
4840
4841 #[test]
4842 fn each_known_tag_decodes_to_its_modeled_variant() {
4843 let message: Output = serde_json::from_value(json!({
4848 "type": "message", "id": "msg_1", "role": "assistant", "status": "completed",
4849 "content": [ { "type": "output_text", "text": "hi", "annotations": [] } ],
4850 }))
4851 .expect("message item should decode");
4852 assert!(matches!(message, Output::Message(_)));
4853
4854 let function_call: Output = serde_json::from_value(json!({
4855 "type": "function_call", "id": "call_1", "arguments": "{}",
4856 "call_id": "c1", "name": "f", "status": "completed",
4857 }))
4858 .expect("function_call item should decode");
4859 assert!(matches!(function_call, Output::FunctionCall(_)));
4860
4861 let reasoning: Output =
4862 serde_json::from_value(json!({ "type": "reasoning", "id": "r1", "summary": [] }))
4863 .expect("reasoning item should decode");
4864 assert!(matches!(reasoning, Output::Reasoning { .. }));
4865 }
4866
4867 #[test]
4868 fn output_without_usable_type_tag_decodes_to_unknown() {
4869 for item in [
4872 json!({ "id": "x", "note": "no type field" }),
4873 json!({ "type": 7, "id": "x" }),
4874 ] {
4875 let output: Output =
4876 serde_json::from_value(item.clone()).expect("should decode to Unknown");
4877 assert_eq!(output, Output::Unknown(item));
4878 }
4879 }
4880
4881 const PDF_URL: &str = "https://example.com/resume.pdf";
4894
4895 fn url_pdf_message() -> message::Message {
4896 message::Message::User {
4897 content: OneOrMany::one(message::UserContent::document_url(
4898 PDF_URL,
4899 Some(message::DocumentMediaType::PDF),
4900 )),
4901 }
4902 }
4903
4904 fn find_input_files(value: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
4906 match value {
4907 serde_json::Value::Object(map) => {
4908 if map.get("type").and_then(|t| t.as_str()) == Some("input_file") {
4909 out.push(value.clone());
4910 }
4911 map.values().for_each(|v| find_input_files(v, out));
4912 }
4913 serde_json::Value::Array(items) => {
4914 items.iter().for_each(|v| find_input_files(v, out));
4915 }
4916 _ => {}
4917 }
4918 }
4919
4920 fn sole_input_file(value: &serde_json::Value) -> serde_json::Value {
4921 let mut found = Vec::new();
4922 find_input_files(value, &mut found);
4923 assert_eq!(
4924 found.len(),
4925 1,
4926 "expected exactly one input_file item in {value:#}"
4927 );
4928 found.pop().unwrap()
4929 }
4930
4931 fn assert_url_only_input_file(input_file: &serde_json::Value) {
4932 assert_eq!(
4933 input_file.get("file_url").and_then(|v| v.as_str()),
4934 Some(PDF_URL),
4935 "URL PDF should carry file_url: {input_file:#}"
4936 );
4937 assert_eq!(
4938 input_file.get("filename"),
4939 None,
4940 "filename must be absent for URL PDFs (issue #1429): {input_file:#}"
4941 );
4942 assert_eq!(
4943 input_file.get("file_data"),
4944 None,
4945 "file_data must be absent for URL PDFs: {input_file:#}"
4946 );
4947 }
4948
4949 #[test]
4950 fn url_pdf_via_input_item_path_omits_filename() {
4951 let items = Vec::<InputItem>::try_from(url_pdf_message())
4952 .expect("URL PDF should convert to input items");
4953 let json = serde_json::to_value(&items).expect("input items should serialize");
4954 assert_url_only_input_file(&sole_input_file(&json));
4955 }
4956
4957 #[test]
4958 fn url_pdf_in_full_completion_request_omits_filename() {
4959 let core_request = crate::completion::CompletionRequest {
4960 model: None,
4961 preamble: None,
4962 chat_history: OneOrMany::one(url_pdf_message()),
4963 documents: Vec::new(),
4964 tools: Vec::new(),
4965 temperature: None,
4966 max_tokens: None,
4967 tool_choice: None,
4968 additional_params: None,
4969 output_schema: None,
4970 record_telemetry_content: false,
4971 };
4972
4973 let request = CompletionRequest::try_from(("gpt-4o".to_string(), core_request))
4974 .expect("request should convert");
4975 let json = serde_json::to_value(&request).expect("request should serialize");
4976 assert_url_only_input_file(&sole_input_file(&json));
4977 }
4978
4979 #[test]
4980 fn url_pdf_via_vec_message_path_omits_filename() {
4981 let messages = Vec::<Message>::try_from(url_pdf_message())
4982 .expect("URL PDF should convert to messages");
4983 let json = serde_json::to_value(&messages).expect("messages should serialize");
4984 assert_url_only_input_file(&sole_input_file(&json));
4985 }
4986
4987 #[test]
4988 fn base64_pdf_via_input_item_path_keeps_filename() {
4989 let input = message::Message::User {
4990 content: OneOrMany::one(message::UserContent::Document(message::Document {
4991 data: DocumentSourceKind::base64("dGVzdA=="),
4992 media_type: Some(message::DocumentMediaType::PDF),
4993 additional_params: None,
4994 })),
4995 };
4996
4997 let items =
4998 Vec::<InputItem>::try_from(input).expect("base64 PDF should convert to input items");
4999 let json = serde_json::to_value(&items).expect("input items should serialize");
5000 let input_file = sole_input_file(&json);
5001
5002 assert_eq!(
5003 input_file.get("file_data").and_then(|v| v.as_str()),
5004 Some("data:application/pdf;base64,dGVzdA=="),
5005 "base64 PDF should carry file_data: {input_file:#}"
5006 );
5007 assert_eq!(
5008 input_file.get("filename").and_then(|v| v.as_str()),
5009 Some("document.pdf"),
5010 "base64 PDF should keep the default filename: {input_file:#}"
5011 );
5012 assert_eq!(
5013 input_file.get("file_url"),
5014 None,
5015 "base64 PDF should not carry file_url: {input_file:#}"
5016 );
5017 }
5018}