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;
27
28use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
29use crate::{OneOrMany, completion, message};
30use serde::{Deserialize, Deserializer, Serialize, Serializer};
31use serde_json::{Map, Value};
32use tracing::{Instrument, Level, enabled, info_span};
33
34use std::convert::Infallible;
35use std::ops::Add;
36use std::str::FromStr;
37
38pub mod streaming;
39#[cfg(all(not(target_family = "wasm"), feature = "websocket"))]
40pub mod websocket;
41
42#[derive(Debug, Deserialize, Serialize, Clone)]
45pub struct CompletionRequest {
46 pub input: OneOrMany<InputItem>,
48 pub model: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub instructions: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub max_output_tokens: Option<u64>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub stream: Option<bool>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub temperature: Option<f64>,
62 #[serde(skip_serializing_if = "Option::is_none")]
65 tool_choice: Option<ToolChoice>,
66 #[serde(skip_serializing_if = "Vec::is_empty")]
69 pub tools: Vec<ResponsesToolDefinition>,
70 #[serde(flatten)]
72 pub additional_parameters: AdditionalParameters,
73}
74
75impl CompletionRequest {
76 pub fn with_structured_outputs<S>(mut self, schema_name: S, schema: serde_json::Value) -> Self
77 where
78 S: Into<String>,
79 {
80 self.additional_parameters.text = Some(TextConfig::structured_output(schema_name, schema));
81
82 self
83 }
84
85 pub fn with_reasoning(mut self, reasoning: Reasoning) -> Self {
86 self.additional_parameters.reasoning = Some(reasoning);
87
88 self
89 }
90
91 pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
95 self.tools.push(tool.into());
96 self
97 }
98
99 pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
102 where
103 I: IntoIterator<Item = Tool>,
104 Tool: Into<ResponsesToolDefinition>,
105 {
106 self.tools.extend(tools.into_iter().map(Into::into));
107 self
108 }
109}
110
111#[derive(Debug, Deserialize, Clone)]
113pub struct InputItem {
114 #[serde(skip_serializing_if = "Option::is_none")]
118 role: Option<Role>,
119 #[serde(flatten)]
121 input: InputContent,
122}
123
124impl Serialize for InputItem {
125 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
126 where
127 S: serde::Serializer,
128 {
129 let mut value = serde_json::to_value(&self.input).map_err(serde::ser::Error::custom)?;
130 let map = value.as_object_mut().ok_or_else(|| {
131 serde::ser::Error::custom("Input content must serialize to an object")
132 })?;
133
134 if let Some(role) = &self.role
135 && !map.contains_key("role")
136 {
137 map.insert(
138 "role".to_string(),
139 serde_json::to_value(role).map_err(serde::ser::Error::custom)?,
140 );
141 }
142
143 value.serialize(serializer)
144 }
145}
146
147impl InputItem {
148 pub fn system_message(content: impl Into<String>) -> Self {
149 Self {
150 role: Some(Role::System),
151 input: InputContent::Message(Message::System {
152 content: OneOrMany::one(SystemContent::InputText {
153 text: content.into(),
154 }),
155 name: None,
156 }),
157 }
158 }
159
160 pub(crate) fn system_text(&self) -> Option<String> {
161 match &self.input {
162 InputContent::Message(Message::System { content, .. }) => Some(
163 content
164 .iter()
165 .map(|item| match item {
166 SystemContent::InputText { text } => text.as_str(),
167 })
168 .collect::<Vec<_>>()
169 .join("\n"),
170 ),
171 _ => None,
172 }
173 }
174}
175
176#[derive(Debug, Deserialize, Serialize, Clone)]
178#[serde(rename_all = "lowercase")]
179pub enum Role {
180 User,
181 Assistant,
182 System,
183}
184
185#[derive(Debug, Deserialize, Serialize, Clone)]
187#[serde(tag = "type", rename_all = "snake_case")]
188pub enum InputContent {
189 Message(Message),
190 Reasoning(OpenAIReasoning),
191 FunctionCall(OutputFunctionCall),
192 FunctionCallOutput(ToolResult),
193}
194
195#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
196pub struct OpenAIReasoning {
197 id: String,
198 pub summary: Vec<ReasoningSummary>,
199 #[serde(
200 default,
201 deserialize_with = "deserialize_reasoning_text_content",
202 serialize_with = "serialize_reasoning_text_content",
203 skip_serializing_if = "Vec::is_empty"
204 )]
205 pub content: Vec<String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub encrypted_content: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub status: Option<ToolStatus>,
210}
211
212#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
213#[serde(tag = "type", rename_all = "snake_case")]
214pub enum ReasoningSummary {
215 SummaryText { text: String },
216}
217
218impl ReasoningSummary {
219 fn new(input: &str) -> Self {
220 Self::SummaryText {
221 text: input.to_string(),
222 }
223 }
224
225 pub fn text(&self) -> String {
226 let ReasoningSummary::SummaryText { text } = self;
227 text.clone()
228 }
229}
230
231fn reasoning_text_content_json(content: &[String]) -> Value {
232 Value::Array(
233 content
234 .iter()
235 .map(|text| {
236 serde_json::json!({
237 "type": "reasoning_text",
238 "text": text,
239 })
240 })
241 .collect(),
242 )
243}
244
245fn serialize_reasoning_text_content<S>(content: &[String], serializer: S) -> Result<S::Ok, S::Error>
246where
247 S: Serializer,
248{
249 reasoning_text_content_json(content).serialize(serializer)
250}
251
252fn deserialize_reasoning_text_content<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
253where
254 D: Deserializer<'de>,
255{
256 let value = Value::deserialize(deserializer)?;
257 Ok(match value {
258 Value::Array(items) => items
259 .into_iter()
260 .filter_map(|item| match item {
261 Value::Object(mut item) => item
262 .remove("text")
263 .and_then(|text| text.as_str().map(ToOwned::to_owned)),
264 Value::String(text) => Some(text),
265 _ => None,
266 })
267 .collect(),
268 Value::String(text) => vec![text],
269 _ => Vec::new(),
270 })
271}
272
273#[derive(Debug, Deserialize, Serialize, Clone)]
275pub struct ToolResult {
276 call_id: String,
278 output: String,
280 status: ToolStatus,
282}
283
284impl From<Message> for InputItem {
285 fn from(value: Message) -> Self {
286 match value {
287 Message::User { .. } => Self {
288 role: Some(Role::User),
289 input: InputContent::Message(value),
290 },
291 Message::Assistant { ref content, .. } => {
292 let role = if content
293 .iter()
294 .any(|x| matches!(x, AssistantContentType::Reasoning(_)))
295 {
296 None
297 } else {
298 Some(Role::Assistant)
299 };
300 Self {
301 role,
302 input: InputContent::Message(value),
303 }
304 }
305 Message::AssistantInput { .. } => Self {
306 role: Some(Role::Assistant),
307 input: InputContent::Message(value),
308 },
309 Message::System { .. } => Self {
310 role: Some(Role::System),
311 input: InputContent::Message(value),
312 },
313 Message::ToolResult {
314 tool_call_id,
315 output,
316 } => Self {
317 role: None,
318 input: InputContent::FunctionCallOutput(ToolResult {
319 call_id: tool_call_id,
320 output,
321 status: ToolStatus::Completed,
322 }),
323 },
324 }
325 }
326}
327
328impl TryFrom<crate::completion::Message> for Vec<InputItem> {
329 type Error = CompletionError;
330
331 fn try_from(value: crate::completion::Message) -> Result<Self, Self::Error> {
332 match value {
333 crate::completion::Message::System { content } => Ok(vec![InputItem {
334 role: Some(Role::System),
335 input: InputContent::Message(Message::System {
336 content: OneOrMany::one(content.into()),
337 name: None,
338 }),
339 }]),
340 crate::completion::Message::User { content } => {
341 let mut items = Vec::new();
342
343 for user_content in content {
344 match user_content {
345 crate::message::UserContent::Text(Text { text, .. }) => {
346 items.push(InputItem {
347 role: Some(Role::User),
348 input: InputContent::Message(Message::User {
349 content: OneOrMany::one(UserContent::InputText { text }),
350 name: None,
351 }),
352 });
353 }
354 crate::message::UserContent::ToolResult(
355 crate::completion::message::ToolResult {
356 call_id,
357 content: tool_content,
358 ..
359 },
360 ) => {
361 for tool_result_content in tool_content {
362 let crate::completion::message::ToolResultContent::Text(Text {
363 text,
364 ..
365 }) = tool_result_content
366 else {
367 return Err(CompletionError::ProviderError(
368 "The OpenAI Responses API only supports text tool results"
369 .to_string(),
370 ));
371 };
372 items.push(InputItem {
373 role: None,
374 input: InputContent::FunctionCallOutput(ToolResult {
375 call_id: require_call_id(call_id.clone(), "Tool result")?,
376 output: text,
377 status: ToolStatus::Completed,
378 }),
379 });
380 }
381 }
382 crate::message::UserContent::Document(Document {
383 data: DocumentSourceKind::FileId(file_id),
384 ..
385 }) => items.push(InputItem {
386 role: Some(Role::User),
387 input: InputContent::Message(Message::User {
388 content: OneOrMany::one(UserContent::InputFile {
389 file_id: Some(file_id),
390 file_data: None,
391 file_url: None,
392 filename: None,
393 }),
394 name: None,
395 }),
396 }),
397 crate::message::UserContent::Document(Document {
398 data,
399 media_type: Some(DocumentMediaType::PDF),
400 ..
401 }) => {
402 let (file_data, file_url) = match data {
403 DocumentSourceKind::Base64(data) => {
404 (Some(format!("data:application/pdf;base64,{data}")), None)
405 }
406 DocumentSourceKind::Url(url) => (None, Some(url)),
407 DocumentSourceKind::Raw(_) => {
408 return Err(CompletionError::RequestError(
409 "Raw file data not supported, encode as base64 first"
410 .into(),
411 ));
412 }
413 doc => {
414 return Err(CompletionError::RequestError(
415 format!("Unsupported document type: {doc}").into(),
416 ));
417 }
418 };
419
420 items.push(InputItem {
421 role: Some(Role::User),
422 input: InputContent::Message(Message::User {
423 content: OneOrMany::one(UserContent::InputFile {
424 file_id: None,
425 file_data,
426 file_url,
427 filename: Some("document.pdf".to_string()),
428 }),
429 name: None,
430 }),
431 })
432 }
433 crate::message::UserContent::Document(Document {
434 data:
435 DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text),
436 ..
437 }) => items.push(InputItem {
438 role: Some(Role::User),
439 input: InputContent::Message(Message::User {
440 content: OneOrMany::one(UserContent::InputText { text }),
441 name: None,
442 }),
443 }),
444 crate::message::UserContent::Image(crate::message::Image {
445 data,
446 media_type,
447 detail,
448 ..
449 }) => {
450 let url = match data {
451 DocumentSourceKind::Base64(data) => {
452 let media_type = if let Some(media_type) = media_type {
453 media_type.to_mime_type().to_string()
454 } else {
455 String::new()
456 };
457 format!("data:{media_type};base64,{data}")
458 }
459 DocumentSourceKind::Url(url) => url,
460 DocumentSourceKind::Raw(_) => {
461 return Err(CompletionError::RequestError(
462 "Raw file data not supported, encode as base64 first"
463 .into(),
464 ));
465 }
466 doc => {
467 return Err(CompletionError::RequestError(
468 format!("Unsupported document type: {doc}").into(),
469 ));
470 }
471 };
472 items.push(InputItem {
473 role: Some(Role::User),
474 input: InputContent::Message(Message::User {
475 content: OneOrMany::one(UserContent::InputImage {
476 image_url: url,
477 detail: detail.unwrap_or_default(),
478 }),
479 name: None,
480 }),
481 });
482 }
483 message => {
484 return Err(CompletionError::ProviderError(format!(
485 "Unsupported message: {message:?}"
486 )));
487 }
488 }
489 }
490
491 Ok(items)
492 }
493 crate::completion::Message::Assistant { id, content } => {
494 let mut reasoning_items = Vec::new();
495 let mut other_items = Vec::new();
496
497 for assistant_content in content {
498 match assistant_content {
499 crate::message::AssistantContent::Text(Text { text, .. }) => {
500 if text.is_empty() {
501 continue;
502 }
503 let message = if let Some(id) = id.clone() {
504 Message::Assistant {
505 content: OneOrMany::one(AssistantContentType::Text(
506 AssistantContent::OutputText(Text::new(text)),
507 )),
508 id,
509 name: None,
510 status: ToolStatus::Completed,
511 }
512 } else {
513 Message::AssistantInput {
514 content: text,
515 name: None,
516 }
517 };
518
519 other_items.push(InputItem {
520 role: Some(Role::Assistant),
521 input: InputContent::Message(message),
522 });
523 }
524 crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
525 id: tool_id,
526 call_id,
527 function,
528 ..
529 }) => {
530 other_items.push(InputItem {
531 role: None,
532 input: InputContent::FunctionCall(OutputFunctionCall {
533 arguments: function.arguments,
534 call_id: require_call_id(call_id, "Assistant tool call")?,
535 id: tool_id,
536 name: function.name,
537 status: ToolStatus::Completed,
538 }),
539 });
540 }
541 crate::message::AssistantContent::Reasoning(reasoning) => {
542 let openai_reasoning = openai_reasoning_from_core(&reasoning)
543 .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
544 if let Some(openai_reasoning) = openai_reasoning {
545 reasoning_items.push(InputItem {
546 role: None,
547 input: InputContent::Reasoning(openai_reasoning),
548 });
549 }
550 }
551 crate::message::AssistantContent::Image(_) => {
552 return Err(CompletionError::ProviderError(
553 "Assistant image content is not supported in OpenAI Responses API"
554 .to_string(),
555 ));
556 }
557 }
558 }
559
560 let mut items = reasoning_items;
561 items.extend(other_items);
562 Ok(items)
563 }
564 }
565 }
566}
567
568impl From<OneOrMany<String>> for Vec<ReasoningSummary> {
569 fn from(value: OneOrMany<String>) -> Self {
570 value.iter().map(|x| ReasoningSummary::new(x)).collect()
571 }
572}
573
574fn require_call_id(call_id: Option<String>, context: &str) -> Result<String, CompletionError> {
575 call_id.ok_or_else(|| {
576 CompletionError::RequestError(
577 format!("{context} `call_id` is required for OpenAI Responses API").into(),
578 )
579 })
580}
581
582fn openai_reasoning_from_core(
583 reasoning: &crate::message::Reasoning,
584) -> Result<Option<OpenAIReasoning>, MessageError> {
585 let Some(id) = reasoning.id.clone() else {
586 return Ok(None);
587 };
588
589 let mut summary = Vec::new();
590 let mut reasoning_content = Vec::new();
591 let mut encrypted_content = None;
592 for content in &reasoning.content {
593 match content {
594 crate::message::ReasoningContent::Text { text, .. } => {
595 reasoning_content.push(text.clone());
596 }
597 crate::message::ReasoningContent::Summary(text) => {
598 summary.push(ReasoningSummary::new(text));
599 }
600 crate::message::ReasoningContent::Encrypted(data)
603 | crate::message::ReasoningContent::Redacted { data } => {
604 encrypted_content.get_or_insert_with(|| data.clone());
605 }
606 }
607 }
608
609 Ok(Some(OpenAIReasoning {
610 id,
611 summary,
612 content: reasoning_content,
613 encrypted_content,
614 status: None,
615 }))
616}
617
618fn optional_reasoning_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
619where
620 D: Deserializer<'de>,
621{
622 Ok(
623 match Option::<serde_json::Value>::deserialize(deserializer)? {
624 Some(serde_json::Value::String(reasoning)) => Some(reasoning),
625 _ => None,
626 },
627 )
628}
629
630#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
632pub struct ResponsesToolDefinition {
633 #[serde(rename = "type")]
635 pub kind: String,
636 #[serde(default, skip_serializing_if = "String::is_empty")]
638 pub name: String,
639 #[serde(default, skip_serializing_if = "is_json_null")]
641 pub parameters: serde_json::Value,
642 #[serde(default, skip_serializing_if = "is_false")]
645 pub strict: bool,
646 #[serde(default, skip_serializing_if = "String::is_empty")]
648 pub description: String,
649 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
651 pub config: Map<String, Value>,
652}
653
654fn is_json_null(value: &Value) -> bool {
655 value.is_null()
656}
657
658fn is_false(value: &bool) -> bool {
659 !value
660}
661
662impl ResponsesToolDefinition {
663 pub fn function(
665 name: impl Into<String>,
666 description: impl Into<String>,
667 parameters: serde_json::Value,
668 ) -> Self {
669 Self {
670 kind: "function".to_string(),
671 name: name.into(),
672 parameters,
673 strict: false,
674 description: description.into(),
675 config: Map::new(),
676 }
677 }
678
679 pub fn strict_function(
684 name: impl Into<String>,
685 description: impl Into<String>,
686 parameters: serde_json::Value,
687 ) -> Self {
688 Self::function(name, description, parameters).with_strict()
689 }
690
691 pub fn with_strict(mut self) -> Self {
696 if self.kind == "function" {
697 super::sanitize_schema(&mut self.parameters);
698 self.strict = true;
699 }
700 self
701 }
702
703 pub fn hosted(kind: impl Into<String>) -> Self {
705 Self {
706 kind: kind.into(),
707 name: String::new(),
708 parameters: Value::Null,
709 strict: false,
710 description: String::new(),
711 config: Map::new(),
712 }
713 }
714
715 pub fn web_search() -> Self {
717 Self::hosted("web_search")
718 }
719
720 pub fn file_search() -> Self {
722 Self::hosted("file_search")
723 }
724
725 pub fn computer_use() -> Self {
727 Self::hosted("computer_use")
728 }
729
730 pub fn with_config(mut self, key: impl Into<String>, value: Value) -> Self {
732 self.config.insert(key.into(), value);
733 self
734 }
735
736 fn normalize(self) -> Self {
737 self.with_strict()
738 }
739}
740
741impl From<completion::ToolDefinition> for ResponsesToolDefinition {
742 fn from(value: completion::ToolDefinition) -> Self {
743 let completion::ToolDefinition {
744 name,
745 parameters,
746 description,
747 } = value;
748
749 Self::function(name, description, parameters)
750 }
751}
752
753#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
761#[serde(untagged)]
762pub enum ToolChoice {
763 Mode(super::completion::ToolChoice),
768 Definition(ToolChoiceDefinition),
770}
771
772#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
774#[serde(tag = "type", rename_all = "snake_case")]
775pub enum ToolChoiceDefinition {
776 Function {
778 name: String,
780 },
781 AllowedTools {
783 mode: AllowedToolsMode,
786 tools: Vec<AllowedTool>,
788 },
789}
790
791#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
794#[serde(rename_all = "snake_case")]
795pub enum AllowedToolsMode {
796 Auto,
798 Required,
800}
801
802#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
804#[serde(tag = "type", rename_all = "snake_case")]
805pub enum AllowedTool {
806 Function {
808 name: String,
810 },
811}
812
813impl TryFrom<message::ToolChoice> for ToolChoice {
814 type Error = CompletionError;
815
816 fn try_from(value: message::ToolChoice) -> Result<Self, Self::Error> {
817 let choice = match value {
818 message::ToolChoice::Auto => Self::Mode(super::completion::ToolChoice::Auto),
819 message::ToolChoice::None => Self::Mode(super::completion::ToolChoice::None),
820 message::ToolChoice::Required => Self::Mode(super::completion::ToolChoice::Required),
821 message::ToolChoice::Specific { function_names } => {
822 let mut names = function_names.into_iter();
823 let Some(first) = names.next() else {
824 return Err(CompletionError::RequestError(
825 "ToolChoice::Specific requires at least one function name".into(),
826 ));
827 };
828
829 match names.next() {
830 None => Self::Definition(ToolChoiceDefinition::Function { name: first }),
831 Some(second) => {
832 let tools = std::iter::once(first)
833 .chain(std::iter::once(second))
834 .chain(names)
835 .map(|name| AllowedTool::Function { name })
836 .collect();
837 Self::Definition(ToolChoiceDefinition::AllowedTools {
838 mode: AllowedToolsMode::Required,
839 tools,
840 })
841 }
842 }
843 }
844 };
845
846 Ok(choice)
847 }
848}
849
850#[derive(Clone, Debug, Serialize, Deserialize)]
853pub struct ResponsesUsage {
854 pub input_tokens: u64,
856 #[serde(skip_serializing_if = "Option::is_none")]
858 pub input_tokens_details: Option<InputTokensDetails>,
859 pub output_tokens: u64,
861 #[serde(skip_serializing_if = "Option::is_none")]
863 pub output_tokens_details: Option<OutputTokensDetails>,
864 pub total_tokens: u64,
866}
867
868impl ResponsesUsage {
869 pub(crate) fn new() -> Self {
871 Self {
872 input_tokens: 0,
873 input_tokens_details: Some(InputTokensDetails::new()),
874 output_tokens: 0,
875 output_tokens_details: Some(OutputTokensDetails::new()),
876 total_tokens: 0,
877 }
878 }
879}
880
881impl GetTokenUsage for ResponsesUsage {
882 fn token_usage(&self) -> crate::completion::Usage {
883 crate::completion::Usage {
884 input_tokens: self.input_tokens,
885 output_tokens: self.output_tokens,
886 total_tokens: self.total_tokens,
887 cached_input_tokens: self
888 .input_tokens_details
889 .as_ref()
890 .map(|details| details.cached_tokens)
891 .unwrap_or(0),
892 cache_creation_input_tokens: 0,
893 tool_use_prompt_tokens: 0,
894 reasoning_tokens: self
895 .output_tokens_details
896 .as_ref()
897 .map(|details| details.reasoning_tokens)
898 .unwrap_or(0),
899 }
900 }
901}
902
903impl Add for ResponsesUsage {
904 type Output = Self;
905
906 fn add(self, rhs: Self) -> Self::Output {
907 let input_tokens = self.input_tokens + rhs.input_tokens;
908 let input_tokens_details = match (self.input_tokens_details, rhs.input_tokens_details) {
909 (Some(lhs), Some(rhs)) => Some(lhs + rhs),
910 (Some(lhs), None) => Some(lhs),
911 (None, Some(rhs)) => Some(rhs),
912 (None, None) => None,
913 };
914 let output_tokens = self.output_tokens + rhs.output_tokens;
915 let output_tokens_details = match (self.output_tokens_details, rhs.output_tokens_details) {
916 (Some(lhs), Some(rhs)) => Some(lhs + rhs),
917 (Some(lhs), None) => Some(lhs),
918 (None, Some(rhs)) => Some(rhs),
919 (None, None) => None,
920 };
921 let total_tokens = self.total_tokens + rhs.total_tokens;
922 Self {
923 input_tokens,
924 input_tokens_details,
925 output_tokens,
926 output_tokens_details,
927 total_tokens,
928 }
929 }
930}
931
932#[derive(Clone, Debug, Serialize, Deserialize)]
934pub struct InputTokensDetails {
935 pub cached_tokens: u64,
937}
938
939impl InputTokensDetails {
940 pub(crate) fn new() -> Self {
941 Self { cached_tokens: 0 }
942 }
943}
944
945impl Add for InputTokensDetails {
946 type Output = Self;
947 fn add(self, rhs: Self) -> Self::Output {
948 Self {
949 cached_tokens: self.cached_tokens + rhs.cached_tokens,
950 }
951 }
952}
953
954#[derive(Clone, Debug, Serialize, Deserialize)]
956pub struct OutputTokensDetails {
957 pub reasoning_tokens: u64,
959}
960
961impl OutputTokensDetails {
962 pub(crate) fn new() -> Self {
963 Self {
964 reasoning_tokens: 0,
965 }
966 }
967}
968
969impl Add for OutputTokensDetails {
970 type Output = Self;
971 fn add(self, rhs: Self) -> Self::Output {
972 Self {
973 reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
974 }
975 }
976}
977
978#[derive(Clone, Debug, Default, Serialize, Deserialize)]
980pub struct IncompleteDetailsReason {
981 pub reason: String,
983}
984
985#[derive(Clone, Debug, Default, Serialize, Deserialize)]
987pub struct ResponseError {
988 pub code: String,
990 pub message: String,
992}
993
994#[derive(Clone, Debug, Deserialize, Serialize)]
996#[serde(rename_all = "snake_case")]
997pub enum ResponseObject {
998 Response,
999}
1000
1001#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1003#[serde(rename_all = "snake_case")]
1004pub enum ResponseStatus {
1005 InProgress,
1006 Completed,
1007 Failed,
1008 Cancelled,
1009 Queued,
1010 Incomplete,
1011}
1012
1013#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1015#[non_exhaustive]
1016pub enum SystemInstructionsPlacement {
1017 #[default]
1022 Instructions,
1023 AllInstructions,
1028 InputSystemMessages,
1033}
1034
1035pub trait ResponsesProviderExt {
1041 fn system_instructions_placement(&self) -> SystemInstructionsPlacement;
1048}
1049
1050impl TryFrom<(String, crate::completion::CompletionRequest)> for CompletionRequest {
1052 type Error = CompletionError;
1053 fn try_from(
1054 (model, request): (String, crate::completion::CompletionRequest),
1055 ) -> Result<Self, Self::Error> {
1056 Self::try_from(ResponsesRequestParams {
1057 model,
1058 request,
1059 system_instructions_placement: SystemInstructionsPlacement::default(),
1060 })
1061 }
1062}
1063
1064pub struct ResponsesRequestParams {
1067 pub model: String,
1068 pub request: crate::completion::CompletionRequest,
1069 pub system_instructions_placement: SystemInstructionsPlacement,
1070}
1071
1072impl TryFrom<ResponsesRequestParams> for CompletionRequest {
1073 type Error = CompletionError;
1074
1075 fn try_from(params: ResponsesRequestParams) -> Result<Self, Self::Error> {
1076 let ResponsesRequestParams {
1077 model,
1078 request: mut req,
1079 system_instructions_placement,
1080 } = params;
1081 let chat_history = req.chat_history_with_documents();
1082 let model = req.model.clone().unwrap_or(model);
1083 let preamble = req.preamble.take();
1084 let mut instruction_parts = Vec::new();
1085 let mut input = {
1086 let mut partial_history = vec![];
1087 partial_history.extend(chat_history);
1088
1089 let mut full_history: Vec<InputItem> = preamble
1090 .map(InputItem::system_message)
1091 .into_iter()
1092 .collect();
1093
1094 for history_item in partial_history {
1095 full_history.extend(<Vec<InputItem>>::try_from(history_item)?);
1096 }
1097
1098 full_history
1099 };
1100
1101 let mut lift_system_text = |text: String| {
1102 let text = text.trim();
1103 if !text.is_empty() {
1104 instruction_parts.push(text.to_string());
1105 }
1106 };
1107 let items_before_lift = input.len();
1108 match system_instructions_placement {
1109 SystemInstructionsPlacement::Instructions => {
1110 let leading_system_texts: Vec<String> =
1116 input.iter().map_while(InputItem::system_text).collect();
1117 if leading_system_texts.len() < input.len() {
1118 input.drain(..leading_system_texts.len());
1119 leading_system_texts
1120 .into_iter()
1121 .for_each(&mut lift_system_text);
1122 }
1123 }
1124 SystemInstructionsPlacement::AllInstructions => {
1125 let mut remaining = Vec::with_capacity(input.len());
1128 for item in input {
1129 match item.system_text() {
1130 Some(text) => lift_system_text(text),
1131 None => remaining.push(item),
1132 }
1133 }
1134 input = remaining;
1135 }
1136 SystemInstructionsPlacement::InputSystemMessages => {}
1137 }
1138 let instructions = (!instruction_parts.is_empty()).then(|| instruction_parts.join("\n\n"));
1139 let lifted_system_items = input.len() < items_before_lift;
1140
1141 let input = OneOrMany::many(input).map_err(|_| {
1142 CompletionError::RequestError(if lifted_system_items {
1143 "OpenAI Responses request input must contain at least one non-system item \
1144 (system messages were lifted into the top-level `instructions` field)"
1145 .into()
1146 } else {
1147 "OpenAI Responses request input must contain at least one item".into()
1148 })
1149 })?;
1150
1151 let mut additional_params_payload = req.additional_params.take().unwrap_or(Value::Null);
1152 let stream = match &additional_params_payload {
1153 Value::Bool(stream) => Some(*stream),
1154 Value::Object(map) => map.get("stream").and_then(Value::as_bool),
1155 _ => None,
1156 };
1157
1158 let mut additional_tools = Vec::new();
1159 if let Some(additional_params_map) = additional_params_payload.as_object_mut() {
1160 if let Some(raw_tools) = additional_params_map.remove("tools") {
1161 additional_tools = serde_json::from_value::<Vec<ResponsesToolDefinition>>(
1162 raw_tools,
1163 )
1164 .map_err(|err| {
1165 CompletionError::RequestError(
1166 format!(
1167 "Invalid OpenAI Responses tools payload in additional_params: {err}"
1168 )
1169 .into(),
1170 )
1171 })?;
1172 }
1173 additional_params_map.remove("stream");
1174 }
1175
1176 if additional_params_payload.is_boolean() {
1177 additional_params_payload = Value::Null;
1178 }
1179
1180 let mut additional_parameters = if additional_params_payload.is_null() {
1181 AdditionalParameters::default()
1183 } else {
1184 serde_json::from_value::<AdditionalParameters>(additional_params_payload).map_err(
1185 |err| {
1186 CompletionError::RequestError(
1187 format!("Invalid OpenAI Responses additional_params payload: {err}").into(),
1188 )
1189 },
1190 )?
1191 };
1192 if additional_parameters.reasoning.is_some() {
1193 let include = additional_parameters.include.get_or_insert_with(Vec::new);
1194 if !include
1195 .iter()
1196 .any(|item| matches!(item, Include::ReasoningEncryptedContent))
1197 {
1198 include.push(Include::ReasoningEncryptedContent);
1199 }
1200 }
1201
1202 if additional_parameters.text.is_none()
1204 && let Some(schema) = req.output_schema
1205 {
1206 let name = schema
1207 .as_object()
1208 .and_then(|o| o.get("title"))
1209 .and_then(|v| v.as_str())
1210 .unwrap_or("response_schema")
1211 .to_string();
1212 let mut schema_value = schema.to_value();
1213 super::sanitize_schema(&mut schema_value);
1214 additional_parameters.text = Some(TextConfig::structured_output(name, schema_value));
1215 }
1216
1217 let tool_choice = req.tool_choice.map(ToolChoice::try_from).transpose()?;
1218 let mut tools: Vec<ResponsesToolDefinition> = req
1219 .tools
1220 .into_iter()
1221 .map(ResponsesToolDefinition::from)
1222 .collect();
1223 tools.append(&mut additional_tools);
1224
1225 Ok(Self {
1226 input,
1227 model,
1228 instructions,
1229 max_output_tokens: req.max_tokens,
1230 stream,
1231 tool_choice,
1232 tools,
1233 temperature: req.temperature,
1234 additional_parameters,
1235 })
1236 }
1237}
1238
1239#[doc(hidden)]
1241#[derive(Clone)]
1242pub struct GenericResponsesCompletionModel<Ext = super::OpenAIResponsesExt, H = reqwest::Client> {
1243 pub(crate) client: crate::client::Client<Ext, H>,
1245 pub model: String,
1247 pub tools: Vec<ResponsesToolDefinition>,
1249 pub strict_tools: bool,
1252 system_instructions_placement: SystemInstructionsPlacement,
1253}
1254
1255pub type ResponsesCompletionModel<H = reqwest::Client> =
1260 GenericResponsesCompletionModel<super::OpenAIResponsesExt, H>;
1261
1262impl<Ext, H> GenericResponsesCompletionModel<Ext, H>
1263where
1264 crate::client::Client<Ext, H>: HttpClientExt + Clone + std::fmt::Debug + 'static,
1265 Ext: crate::client::Provider + ResponsesProviderExt + Clone + 'static,
1266 H: Clone + Default + std::fmt::Debug + 'static,
1267{
1268 pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
1270 let system_instructions_placement = client.ext().system_instructions_placement();
1271 Self {
1272 client,
1273 model: model.into(),
1274 tools: Vec::new(),
1275 strict_tools: false,
1276 system_instructions_placement,
1277 }
1278 }
1279
1280 pub fn with_model(client: crate::client::Client<Ext, H>, model: &str) -> Self {
1281 Self::new(client, model)
1282 }
1283
1284 pub fn with_strict_tools(mut self) -> Self {
1289 self.strict_tools = true;
1290 self
1291 }
1292
1293 pub fn with_system_instructions_placement(
1297 mut self,
1298 placement: SystemInstructionsPlacement,
1299 ) -> Self {
1300 self.system_instructions_placement = placement;
1301 self
1302 }
1303
1304 pub fn with_system_instructions_as_messages(self) -> Self {
1311 self.with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
1312 }
1313
1314 pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
1316 self.tools.push(tool.into());
1317 self
1318 }
1319
1320 pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
1322 where
1323 I: IntoIterator<Item = Tool>,
1324 Tool: Into<ResponsesToolDefinition>,
1325 {
1326 self.tools.extend(tools.into_iter().map(Into::into));
1327 self
1328 }
1329
1330 pub(crate) fn create_completion_request(
1332 &self,
1333 completion_request: crate::completion::CompletionRequest,
1334 ) -> Result<CompletionRequest, CompletionError> {
1335 let mut req = CompletionRequest::try_from(ResponsesRequestParams {
1336 model: self.model.clone(),
1337 request: completion_request,
1338 system_instructions_placement: self.system_instructions_placement,
1339 })?;
1340 req.tools.extend(self.tools.clone());
1341
1342 if self.strict_tools {
1343 req.tools = req
1344 .tools
1345 .into_iter()
1346 .map(ResponsesToolDefinition::normalize)
1347 .collect();
1348 }
1349
1350 Ok(req)
1351 }
1352}
1353
1354impl<T> GenericResponsesCompletionModel<super::OpenAIResponsesExt, T>
1355where
1356 T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
1357{
1358 pub fn completions_api(self) -> crate::providers::openai::completion::CompletionModel<T> {
1360 super::completion::CompletionModel::new(self.client.completions_api(), &self.model)
1361 }
1362}
1363
1364#[derive(Clone, Debug, Serialize, Deserialize)]
1366pub struct CompletionResponse {
1367 pub id: String,
1369 pub object: ResponseObject,
1371 pub created_at: u64,
1373 pub status: ResponseStatus,
1375 pub error: Option<ResponseError>,
1377 pub incomplete_details: Option<IncompleteDetailsReason>,
1379 pub instructions: Option<String>,
1381 pub max_output_tokens: Option<u64>,
1383 pub model: String,
1385 #[serde(
1388 default,
1389 rename = "reasoning",
1390 deserialize_with = "optional_reasoning_string",
1391 skip_serializing_if = "Option::is_none"
1392 )]
1393 pub provider_reasoning: Option<String>,
1394 pub usage: Option<ResponsesUsage>,
1396 #[serde(default)]
1398 pub output: Vec<Output>,
1399 #[serde(default)]
1401 pub tools: Vec<ResponsesToolDefinition>,
1402 #[serde(flatten)]
1404 pub additional_parameters: AdditionalParameters,
1405}
1406
1407#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1410pub struct AdditionalParameters {
1411 #[serde(skip_serializing_if = "Option::is_none")]
1413 pub background: Option<bool>,
1414 #[serde(skip_serializing_if = "Option::is_none")]
1416 pub text: Option<TextConfig>,
1417 #[serde(skip_serializing_if = "Option::is_none")]
1419 pub include: Option<Vec<Include>>,
1420 #[serde(skip_serializing_if = "Option::is_none")]
1422 pub top_p: Option<f64>,
1423 #[serde(skip_serializing_if = "Option::is_none")]
1425 pub truncation: Option<TruncationStrategy>,
1426 #[serde(skip_serializing_if = "Option::is_none")]
1428 pub user: Option<String>,
1429 #[serde(skip_serializing_if = "Option::is_none")]
1431 pub prompt_cache_key: Option<String>,
1432 #[serde(skip_serializing_if = "Option::is_none")]
1434 pub prompt_cache_retention: Option<String>,
1435 #[serde(
1437 skip_serializing_if = "Map::is_empty",
1438 default,
1439 deserialize_with = "deserialize_metadata"
1440 )]
1441 pub metadata: serde_json::Map<String, serde_json::Value>,
1442 #[serde(skip_serializing_if = "Option::is_none")]
1444 pub parallel_tool_calls: Option<bool>,
1445 #[serde(skip_serializing_if = "Option::is_none")]
1447 pub previous_response_id: Option<String>,
1448 #[serde(skip_serializing_if = "Option::is_none")]
1450 pub reasoning: Option<Reasoning>,
1451 #[serde(skip_serializing_if = "Option::is_none")]
1453 pub service_tier: Option<OpenAIServiceTier>,
1454 #[serde(skip_serializing_if = "Option::is_none")]
1456 pub store: Option<bool>,
1457}
1458
1459fn deserialize_metadata<'de, D>(
1460 deserializer: D,
1461) -> Result<serde_json::Map<String, serde_json::Value>, D::Error>
1462where
1463 D: Deserializer<'de>,
1464{
1465 Ok(
1466 Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer)?
1467 .unwrap_or_default(),
1468 )
1469}
1470
1471impl AdditionalParameters {
1472 pub fn to_json(self) -> serde_json::Value {
1473 serde_json::to_value(self).unwrap_or_else(|_| serde_json::Value::Object(Map::new()))
1474 }
1475}
1476
1477#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1481#[serde(rename_all = "snake_case")]
1482pub enum TruncationStrategy {
1483 Auto,
1484 #[default]
1485 Disabled,
1486}
1487
1488#[derive(Clone, Debug, Serialize, Deserialize)]
1491pub struct TextConfig {
1492 pub format: TextFormat,
1493}
1494
1495impl TextConfig {
1496 pub(crate) fn structured_output<S>(name: S, schema: serde_json::Value) -> Self
1497 where
1498 S: Into<String>,
1499 {
1500 Self {
1501 format: TextFormat::JsonSchema(StructuredOutputsInput {
1502 name: name.into(),
1503 schema,
1504 strict: true,
1505 }),
1506 }
1507 }
1508}
1509
1510#[derive(Clone, Debug, Serialize, Deserialize, Default)]
1513#[serde(tag = "type")]
1514#[serde(rename_all = "snake_case")]
1515pub enum TextFormat {
1516 JsonSchema(StructuredOutputsInput),
1517 #[default]
1518 Text,
1519}
1520
1521#[derive(Clone, Debug, Serialize, Deserialize)]
1523pub struct StructuredOutputsInput {
1524 pub name: String,
1526 pub schema: serde_json::Value,
1528 #[serde(default)]
1530 pub strict: bool,
1531}
1532
1533#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1535pub struct Reasoning {
1536 pub effort: Option<ReasoningEffort>,
1538 #[serde(skip_serializing_if = "Option::is_none")]
1540 pub summary: Option<ReasoningSummaryLevel>,
1541}
1542
1543impl Reasoning {
1544 pub fn new() -> Self {
1546 Self {
1547 effort: None,
1548 summary: None,
1549 }
1550 }
1551
1552 pub fn with_effort(mut self, reasoning_effort: ReasoningEffort) -> Self {
1554 self.effort = Some(reasoning_effort);
1555
1556 self
1557 }
1558
1559 pub fn with_summary_level(mut self, reasoning_summary_level: ReasoningSummaryLevel) -> Self {
1561 self.summary = Some(reasoning_summary_level);
1562
1563 self
1564 }
1565}
1566
1567#[derive(Clone, Debug, Default)]
1569pub enum OpenAIServiceTier {
1570 #[default]
1572 Auto,
1573 Default,
1575 Flex,
1577 Priority,
1579 Standard,
1581 Other(String),
1583}
1584
1585impl Serialize for OpenAIServiceTier {
1586 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1587 where
1588 S: Serializer,
1589 {
1590 serializer.serialize_str(match self {
1591 Self::Auto => "auto",
1592 Self::Default => "default",
1593 Self::Flex => "flex",
1594 Self::Priority => "priority",
1595 Self::Standard => "standard",
1596 Self::Other(value) => value,
1597 })
1598 }
1599}
1600
1601impl<'de> Deserialize<'de> for OpenAIServiceTier {
1602 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1603 where
1604 D: Deserializer<'de>,
1605 {
1606 let value = String::deserialize(deserializer)?;
1607 Ok(match value.as_str() {
1608 "auto" => Self::Auto,
1609 "default" => Self::Default,
1610 "flex" => Self::Flex,
1611 "priority" => Self::Priority,
1612 "standard" => Self::Standard,
1613 _ => Self::Other(value),
1614 })
1615 }
1616}
1617
1618#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1620#[serde(rename_all = "snake_case")]
1621pub enum ReasoningEffort {
1622 None,
1623 Minimal,
1624 Low,
1625 #[default]
1626 Medium,
1627 High,
1628 Xhigh,
1629}
1630
1631#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1633#[serde(rename_all = "snake_case")]
1634pub enum ReasoningSummaryLevel {
1635 #[default]
1636 Auto,
1637 Concise,
1638 Detailed,
1639}
1640
1641#[derive(Clone, Debug, Deserialize, Serialize)]
1644pub enum Include {
1645 #[serde(rename = "file_search_call.results")]
1646 FileSearchCallResults,
1647 #[serde(rename = "message.input_image.image_url")]
1648 MessageInputImageImageUrl,
1649 #[serde(rename = "computer_call.output.image_url")]
1650 ComputerCallOutputOutputImageUrl,
1651 #[serde(rename = "reasoning.encrypted_content")]
1652 ReasoningEncryptedContent,
1653 #[serde(rename = "code_interpreter_call.outputs")]
1654 CodeInterpreterCallOutputs,
1655}
1656
1657#[derive(Clone, Debug, PartialEq)]
1667pub enum Output {
1668 Message(OutputMessage),
1669 FunctionCall(OutputFunctionCall),
1670 Reasoning {
1671 id: String,
1672 summary: Vec<ReasoningSummary>,
1673 content: Vec<String>,
1674 encrypted_content: Option<String>,
1675 status: Option<ToolStatus>,
1676 },
1677 Unknown(Value),
1681}
1682
1683#[derive(Deserialize)]
1692struct ReasoningFields {
1693 id: String,
1694 #[serde(default)]
1695 summary: Vec<ReasoningSummary>,
1696 #[serde(default, deserialize_with = "deserialize_reasoning_text_content")]
1697 content: Vec<String>,
1698 #[serde(default)]
1699 encrypted_content: Option<String>,
1700 #[serde(default)]
1701 status: Option<ToolStatus>,
1702}
1703
1704impl From<ReasoningFields> for Output {
1705 fn from(fields: ReasoningFields) -> Self {
1706 Output::Reasoning {
1707 id: fields.id,
1708 summary: fields.summary,
1709 content: fields.content,
1710 encrypted_content: fields.encrypted_content,
1711 status: fields.status,
1712 }
1713 }
1714}
1715
1716fn tagged_output_object<T>(tag: &str, payload: &T) -> Result<Value, serde_json::Error>
1720where
1721 T: Serialize,
1722{
1723 let mut value = serde_json::to_value(payload)?;
1724 let map = value.as_object_mut().ok_or_else(|| {
1725 <serde_json::Error as serde::ser::Error>::custom(
1726 "output payload must serialize to a JSON object",
1727 )
1728 })?;
1729 map.insert("type".to_string(), Value::String(tag.to_string()));
1730 Ok(value)
1731}
1732
1733impl Serialize for Output {
1734 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1735 where
1736 S: Serializer,
1737 {
1738 let value = match self {
1744 Output::Message(message) => tagged_output_object("message", message),
1745 Output::FunctionCall(call) => tagged_output_object("function_call", call),
1746 Output::Reasoning {
1747 id,
1748 summary,
1749 content,
1750 encrypted_content,
1751 status,
1752 } => {
1753 let mut value = serde_json::json!({
1754 "type": "reasoning",
1755 "id": id,
1756 "summary": summary,
1757 "encrypted_content": encrypted_content,
1758 "status": status,
1759 });
1760 if !content.is_empty() {
1761 let map = value.as_object_mut().ok_or_else(|| {
1762 serde::ser::Error::custom("reasoning output must serialize to an object")
1763 })?;
1764 map.insert("content".to_string(), reasoning_text_content_json(content));
1765 }
1766 Ok(value)
1767 }
1768 Output::Unknown(value) => return value.serialize(serializer),
1769 };
1770 value
1771 .map_err(serde::ser::Error::custom)?
1772 .serialize(serializer)
1773 }
1774}
1775
1776impl<'de> Deserialize<'de> for Output {
1777 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1778 where
1779 D: Deserializer<'de>,
1780 {
1781 let value = Value::deserialize(deserializer)?;
1786 let Some(tag) = value.get("type").and_then(Value::as_str) else {
1787 return Ok(Output::Unknown(value));
1788 };
1789 match tag {
1790 "message" => serde_json::from_value(value)
1791 .map(Output::Message)
1792 .map_err(serde::de::Error::custom),
1793 "function_call" => serde_json::from_value(value)
1794 .map(Output::FunctionCall)
1795 .map_err(serde::de::Error::custom),
1796 "reasoning" => serde_json::from_value::<ReasoningFields>(value)
1797 .map(Output::from)
1798 .map_err(serde::de::Error::custom),
1799 _ => Ok(Output::Unknown(value)),
1800 }
1801 }
1802}
1803
1804impl From<Output> for Vec<completion::AssistantContent> {
1805 fn from(value: Output) -> Self {
1806 let res: Vec<completion::AssistantContent> = match value {
1807 Output::Message(OutputMessage { content, .. }) => content
1808 .into_iter()
1809 .map(completion::AssistantContent::from)
1810 .collect(),
1811 Output::FunctionCall(OutputFunctionCall {
1812 id,
1813 arguments,
1814 call_id,
1815 name,
1816 ..
1817 }) => vec![completion::AssistantContent::tool_call_with_call_id(
1818 id, call_id, name, arguments,
1819 )],
1820 Output::Reasoning {
1821 id,
1822 summary,
1823 content: reasoning_content,
1824 encrypted_content,
1825 ..
1826 } => {
1827 let mut content = summary
1828 .into_iter()
1829 .map(|summary| match summary {
1830 ReasoningSummary::SummaryText { text } => {
1831 message::ReasoningContent::Summary(text)
1832 }
1833 })
1834 .collect::<Vec<_>>();
1835 content.extend(reasoning_content.into_iter().map(|text| {
1836 message::ReasoningContent::Text {
1837 text,
1838 signature: None,
1839 }
1840 }));
1841 if let Some(encrypted_content) = encrypted_content {
1842 content.push(message::ReasoningContent::Encrypted(encrypted_content));
1843 }
1844 vec![completion::AssistantContent::Reasoning(
1845 message::Reasoning {
1846 id: Some(id),
1847 content,
1848 },
1849 )]
1850 }
1851 Output::Unknown(_) => Vec::new(),
1852 };
1853
1854 res
1855 }
1856}
1857
1858#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1859pub struct OutputReasoning {
1860 id: String,
1861 summary: Vec<ReasoningSummary>,
1862 status: ToolStatus,
1863}
1864
1865#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1867pub struct OutputFunctionCall {
1868 #[serde(default, skip_serializing_if = "is_not_function_call_item_id")]
1874 pub id: String,
1875 #[serde(with = "json_utils::stringified_json")]
1876 pub arguments: serde_json::Value,
1877 pub call_id: String,
1878 pub name: String,
1879 pub status: ToolStatus,
1880}
1881
1882fn is_not_function_call_item_id(id: &str) -> bool {
1885 !id.starts_with("fc_")
1886}
1887
1888#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1890#[serde(rename_all = "snake_case")]
1891pub enum ToolStatus {
1892 InProgress,
1893 Completed,
1894 Incomplete,
1895}
1896
1897#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1899pub struct OutputMessage {
1900 pub id: String,
1902 pub role: OutputRole,
1904 pub status: ResponseStatus,
1906 pub content: Vec<AssistantContent>,
1908}
1909
1910#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1912#[serde(rename_all = "snake_case")]
1913pub enum OutputRole {
1914 Assistant,
1915}
1916
1917impl<Ext, H> completion::CompletionModel for GenericResponsesCompletionModel<Ext, H>
1918where
1919 crate::client::Client<Ext, H>:
1920 HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
1921 Ext: crate::client::Provider
1922 + ResponsesProviderExt
1923 + crate::client::DebugExt
1924 + Clone
1925 + WasmCompatSend
1926 + WasmCompatSync
1927 + 'static,
1928 H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
1929{
1930 type Response = CompletionResponse;
1931 type StreamingResponse = StreamingCompletionResponse;
1932
1933 type Client = crate::client::Client<Ext, H>;
1934
1935 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1936 Self::new(client.clone(), model)
1937 }
1938
1939 fn composes_native_output_with_tools(&self) -> bool {
1943 true
1944 }
1945
1946 async fn completion(
1947 &self,
1948 completion_request: crate::completion::CompletionRequest,
1949 ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1950 let span = if tracing::Span::current().is_disabled() {
1951 info_span!(
1952 target: "rig::completions",
1953 "chat",
1954 gen_ai.operation.name = "chat",
1955 gen_ai.provider.name = tracing::field::Empty,
1956 gen_ai.request.model = tracing::field::Empty,
1957 gen_ai.response.id = tracing::field::Empty,
1958 gen_ai.response.model = tracing::field::Empty,
1959 gen_ai.usage.output_tokens = tracing::field::Empty,
1960 gen_ai.usage.input_tokens = tracing::field::Empty,
1961 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1962 gen_ai.input.messages = tracing::field::Empty,
1963 gen_ai.output.messages = tracing::field::Empty,
1964 )
1965 } else {
1966 tracing::Span::current()
1967 };
1968
1969 span.record("gen_ai.provider.name", "openai");
1970 span.record("gen_ai.request.model", &self.model);
1971 let request = self.create_completion_request(completion_request)?;
1972 let body = serde_json::to_vec(&request)?;
1973
1974 if enabled!(Level::TRACE) {
1975 tracing::trace!(
1976 target: "rig::completions",
1977 "OpenAI Responses completion request: {request}",
1978 request = serde_json::to_string_pretty(&request)?
1979 );
1980 }
1981
1982 let req = self
1983 .client
1984 .post("/responses")?
1985 .body(body)
1986 .map_err(|e| CompletionError::HttpError(e.into()))?;
1987
1988 async move {
1989 let response = self.client.send(req).await?;
1990
1991 if response.status().is_success() {
1992 let t = http_client::text(response).await?;
1993 let response = serde_json::from_str::<Self::Response>(&t)?;
1994 let span = tracing::Span::current();
1995 span.record("gen_ai.response.id", &response.id);
1996 span.record("gen_ai.response.model", &response.model);
1997 if let Some(ref usage) = response.usage {
1998 span.record("gen_ai.usage.output_tokens", usage.output_tokens);
1999 span.record("gen_ai.usage.input_tokens", usage.input_tokens);
2000 let cached_tokens = usage
2001 .input_tokens_details
2002 .as_ref()
2003 .map(|d| d.cached_tokens)
2004 .unwrap_or(0);
2005 span.record("gen_ai.usage.cache_read.input_tokens", cached_tokens);
2006 }
2007 if enabled!(Level::TRACE) {
2008 tracing::trace!(
2009 target: "rig::completions",
2010 "OpenAI Responses completion response: {response}",
2011 response = serde_json::to_string_pretty(&response)?
2012 );
2013 }
2014 response.try_into()
2015 } else {
2016 let status = response.status();
2017 let text = http_client::text(response).await?;
2018 Err(CompletionError::from_http_response(status, text))
2019 }
2020 }
2021 .instrument(span)
2022 .await
2023 }
2024
2025 async fn stream(
2026 &self,
2027 request: crate::completion::CompletionRequest,
2028 ) -> Result<
2029 crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
2030 CompletionError,
2031 > {
2032 GenericResponsesCompletionModel::stream(self, request).await
2033 }
2034}
2035
2036impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
2037 type Error = CompletionError;
2038
2039 fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
2040 let message_id = response.output.iter().find_map(|item| match item {
2042 Output::Message(msg) => Some(msg.id.clone()),
2043 _ => None,
2044 });
2045
2046 let output_content: Vec<completion::AssistantContent> = response
2047 .output
2048 .iter()
2049 .cloned()
2050 .flat_map(<Vec<completion::AssistantContent>>::from)
2051 .collect();
2052 let has_structured_reasoning = response
2053 .output
2054 .iter()
2055 .any(|item| matches!(item, Output::Reasoning { .. }));
2056 let content = response
2057 .provider_reasoning
2058 .as_ref()
2059 .filter(|reasoning| !has_structured_reasoning && !reasoning.is_empty())
2060 .map(|reasoning| {
2061 let mut content = Vec::with_capacity(output_content.len() + 1);
2062 content.push(completion::AssistantContent::Reasoning(
2063 message::Reasoning::new(reasoning),
2064 ));
2065 content.extend(output_content.clone());
2066 content
2067 })
2068 .unwrap_or(output_content);
2069
2070 let choice = OneOrMany::many(content).map_err(|_| {
2071 CompletionError::ResponseError(
2072 "Response contained no message or tool call (empty)".to_owned(),
2073 )
2074 })?;
2075
2076 let usage = response
2077 .usage
2078 .as_ref()
2079 .map(GetTokenUsage::token_usage)
2080 .unwrap_or_default();
2081
2082 Ok(completion::CompletionResponse {
2083 choice,
2084 usage,
2085 raw_response: response,
2086 message_id,
2087 })
2088 }
2089}
2090
2091#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2093#[serde(tag = "role", rename_all = "lowercase")]
2094pub enum Message {
2095 #[serde(alias = "developer")]
2096 System {
2097 #[serde(deserialize_with = "string_or_one_or_many")]
2098 content: OneOrMany<SystemContent>,
2099 #[serde(skip_serializing_if = "Option::is_none")]
2100 name: Option<String>,
2101 },
2102 User {
2103 #[serde(deserialize_with = "string_or_one_or_many")]
2104 content: OneOrMany<UserContent>,
2105 #[serde(skip_serializing_if = "Option::is_none")]
2106 name: Option<String>,
2107 },
2108 Assistant {
2109 content: OneOrMany<AssistantContentType>,
2110 #[serde(skip_serializing_if = "String::is_empty")]
2111 id: String,
2112 #[serde(skip_serializing_if = "Option::is_none")]
2113 name: Option<String>,
2114 status: ToolStatus,
2115 },
2116 #[serde(rename = "assistant", skip_deserializing)]
2117 AssistantInput {
2118 content: String,
2119 #[serde(skip_serializing_if = "Option::is_none")]
2120 name: Option<String>,
2121 },
2122 #[serde(rename = "tool")]
2123 ToolResult {
2124 tool_call_id: String,
2125 output: String,
2126 },
2127}
2128
2129#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
2131#[serde(rename_all = "lowercase")]
2132pub enum ToolResultContentType {
2133 #[default]
2134 Text,
2135}
2136
2137impl Message {
2138 pub fn system(content: &str) -> Self {
2139 Message::System {
2140 content: OneOrMany::one(content.to_owned().into()),
2141 name: None,
2142 }
2143 }
2144}
2145
2146#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2149#[serde(tag = "type", rename_all = "snake_case")]
2150pub enum AssistantContent {
2151 OutputText(Text),
2152 Refusal { refusal: String },
2153}
2154
2155impl From<AssistantContent> for completion::AssistantContent {
2156 fn from(value: AssistantContent) -> Self {
2157 match value {
2158 AssistantContent::Refusal { refusal } => {
2159 completion::AssistantContent::Text(Text::new(refusal))
2160 }
2161 AssistantContent::OutputText(Text { text, .. }) => {
2162 completion::AssistantContent::Text(Text::new(text))
2163 }
2164 }
2165 }
2166}
2167
2168#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2170#[serde(untagged)]
2171pub enum AssistantContentType {
2172 Text(AssistantContent),
2173 ToolCall(OutputFunctionCall),
2174 Reasoning(OpenAIReasoning),
2175}
2176
2177#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2180#[serde(tag = "type", rename_all = "snake_case")]
2181pub enum SystemContent {
2182 InputText { text: String },
2183}
2184
2185impl From<String> for SystemContent {
2186 fn from(s: String) -> Self {
2187 SystemContent::InputText { text: s }
2188 }
2189}
2190
2191impl std::str::FromStr for SystemContent {
2192 type Err = std::convert::Infallible;
2193
2194 fn from_str(s: &str) -> Result<Self, Self::Err> {
2195 Ok(SystemContent::InputText {
2196 text: s.to_string(),
2197 })
2198 }
2199}
2200
2201#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2203#[serde(tag = "type", rename_all = "snake_case")]
2204pub enum UserContent {
2205 InputText {
2206 text: String,
2207 },
2208 InputImage {
2209 image_url: String,
2210 #[serde(default)]
2211 detail: ImageDetail,
2212 },
2213 InputFile {
2214 #[serde(skip_serializing_if = "Option::is_none")]
2215 file_id: Option<String>,
2216 #[serde(skip_serializing_if = "Option::is_none")]
2217 file_url: Option<String>,
2218 #[serde(skip_serializing_if = "Option::is_none")]
2219 file_data: Option<String>,
2220 #[serde(skip_serializing_if = "Option::is_none")]
2221 filename: Option<String>,
2222 },
2223 Audio {
2224 input_audio: InputAudio,
2225 },
2226 #[serde(rename = "tool")]
2227 ToolResult {
2228 tool_call_id: String,
2229 output: String,
2230 },
2231}
2232
2233impl TryFrom<message::Message> for Vec<Message> {
2234 type Error = message::MessageError;
2235
2236 fn try_from(message: message::Message) -> Result<Self, Self::Error> {
2237 match message {
2238 message::Message::System { content } => Ok(vec![Message::System {
2239 content: OneOrMany::one(content.into()),
2240 name: None,
2241 }]),
2242 message::Message::User { content } => {
2243 let (tool_results, other_content): (Vec<_>, Vec<_>) = content
2244 .into_iter()
2245 .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
2246
2247 if !tool_results.is_empty() {
2250 tool_results
2251 .into_iter()
2252 .map(|content| match content {
2253 message::UserContent::ToolResult(message::ToolResult {
2254 call_id,
2255 content,
2256 ..
2257 }) => Ok::<_, message::MessageError>(Message::ToolResult {
2258 tool_call_id: call_id.ok_or_else(|| {
2259 MessageError::ConversionError(
2260 "Tool result `call_id` is required for OpenAI Responses API"
2261 .into(),
2262 )
2263 })?,
2264 output: {
2265 let res = content.first();
2266 match res {
2267 completion::message::ToolResultContent::Text(Text {
2268 text,
2269 ..
2270 }) => text,
2271 _ => return Err(MessageError::ConversionError("This API only currently supports text tool results".into()))
2272 }
2273 },
2274 }),
2275 _ => Err(MessageError::ConversionError(
2276 "expected tool result content while converting Responses API input"
2277 .into(),
2278 )),
2279 })
2280 .collect::<Result<Vec<_>, _>>()
2281 } else {
2282 let other_content = other_content
2283 .into_iter()
2284 .map(|content| match content {
2285 message::UserContent::Text(message::Text { text, .. }) => {
2286 Ok(UserContent::InputText { text })
2287 }
2288 message::UserContent::Image(message::Image {
2289 data,
2290 detail,
2291 media_type,
2292 ..
2293 }) => {
2294 let url = match data {
2295 DocumentSourceKind::Base64(data) => {
2296 let media_type = if let Some(media_type) = media_type {
2297 media_type.to_mime_type().to_string()
2298 } else {
2299 String::new()
2300 };
2301 format!("data:{media_type};base64,{data}")
2302 }
2303 DocumentSourceKind::Url(url) => url,
2304 DocumentSourceKind::Raw(_) => {
2305 return Err(MessageError::ConversionError(
2306 "Raw files not supported, encode as base64 first"
2307 .into(),
2308 ));
2309 }
2310 doc => {
2311 return Err(MessageError::ConversionError(format!(
2312 "Unsupported document type: {doc}"
2313 )));
2314 }
2315 };
2316
2317 Ok(UserContent::InputImage {
2318 image_url: url,
2319 detail: detail.unwrap_or_default(),
2320 })
2321 }
2322 message::UserContent::Document(message::Document {
2323 data: DocumentSourceKind::FileId(file_id),
2324 ..
2325 }) => Ok(UserContent::InputFile {
2326 file_id: Some(file_id),
2327 file_url: None,
2328 file_data: None,
2329 filename: None,
2330 }),
2331 message::UserContent::Document(message::Document {
2332 media_type: Some(DocumentMediaType::PDF),
2333 data,
2334 ..
2335 }) => {
2336 let (file_data, file_url, filename) = match data {
2337 DocumentSourceKind::Base64(data) => (
2338 Some(format!("data:application/pdf;base64,{data}")),
2339 None,
2340 Some("document.pdf".to_string()),
2341 ),
2342 DocumentSourceKind::Url(url) => (None, Some(url), None),
2343 DocumentSourceKind::Raw(_) => {
2344 return Err(MessageError::ConversionError(
2345 "Raw files not supported, encode as base64 first"
2346 .into(),
2347 ));
2348 }
2349 doc => {
2350 return Err(MessageError::ConversionError(format!(
2351 "Unsupported document type: {doc}"
2352 )));
2353 }
2354 };
2355
2356 Ok(UserContent::InputFile {
2357 file_id: None,
2358 file_url,
2359 file_data,
2360 filename,
2361 })
2362 }
2363 message::UserContent::Document(message::Document {
2364 data: DocumentSourceKind::Base64(text),
2365 ..
2366 }) => Ok(UserContent::InputText { text }),
2367 message::UserContent::Audio(message::Audio {
2368 data: DocumentSourceKind::Base64(data),
2369 media_type,
2370 ..
2371 }) => Ok(UserContent::Audio {
2372 input_audio: InputAudio {
2373 data,
2374 format: match media_type {
2375 Some(media_type) => media_type,
2376 None => AudioMediaType::MP3,
2377 },
2378 },
2379 }),
2380 message::UserContent::Audio(_) => Err(MessageError::ConversionError(
2381 "Audio must be base64 encoded data".into(),
2382 )),
2383 _ => Err(MessageError::ConversionError(
2384 "Unsupported user content for OpenAI Responses API".into(),
2385 )),
2386 })
2387 .collect::<Result<Vec<_>, _>>()?;
2388
2389 let other_content = OneOrMany::many(other_content).map_err(|_| {
2390 MessageError::ConversionError(
2391 "User message did not contain OpenAI Responses-compatible content"
2392 .to_string(),
2393 )
2394 })?;
2395
2396 Ok(vec![Message::User {
2397 content: other_content,
2398 name: None,
2399 }])
2400 }
2401 }
2402 message::Message::Assistant {
2403 content,
2404 id: assistant_message_id,
2405 } => {
2406 let mut messages = Vec::new();
2407
2408 for assistant_content in content {
2409 match assistant_content {
2410 crate::message::AssistantContent::Text(Text { text, .. }) => {
2411 if text.is_empty() {
2412 continue;
2413 }
2414 if let Some(id) = assistant_message_id.clone() {
2415 messages.push(Message::Assistant {
2416 id,
2417 status: ToolStatus::Completed,
2418 content: OneOrMany::one(AssistantContentType::Text(
2419 AssistantContent::OutputText(Text::new(text)),
2420 )),
2421 name: None,
2422 });
2423 } else {
2424 messages.push(Message::AssistantInput {
2425 content: text,
2426 name: None,
2427 });
2428 }
2429 }
2430 crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
2431 id: tool_id,
2432 call_id,
2433 function,
2434 ..
2435 }) => {
2436 messages.push(Message::Assistant {
2437 content: OneOrMany::one(AssistantContentType::ToolCall(
2438 OutputFunctionCall {
2439 call_id: call_id.ok_or_else(|| {
2440 MessageError::ConversionError(
2441 "Tool call `call_id` is required for OpenAI Responses API"
2442 .into(),
2443 )
2444 })?,
2445 arguments: function.arguments,
2446 id: tool_id,
2447 name: function.name,
2448 status: ToolStatus::Completed,
2449 },
2450 )),
2451 id: assistant_message_id.clone().unwrap_or_default(),
2452 name: None,
2453 status: ToolStatus::Completed,
2454 });
2455 }
2456 crate::message::AssistantContent::Reasoning(reasoning) => {
2457 if let Some(openai_reasoning) = openai_reasoning_from_core(&reasoning)?
2458 {
2459 messages.push(Message::Assistant {
2460 content: OneOrMany::one(AssistantContentType::Reasoning(
2461 openai_reasoning,
2462 )),
2463 id: assistant_message_id.clone().unwrap_or_default(),
2464 name: None,
2465 status: ToolStatus::Completed,
2466 });
2467 }
2468 }
2469 crate::message::AssistantContent::Image(_) => {
2470 return Err(MessageError::ConversionError(
2471 "Assistant image content is not supported in OpenAI Responses API"
2472 .into(),
2473 ));
2474 }
2475 }
2476 }
2477
2478 Ok(messages)
2479 }
2480 }
2481 }
2482}
2483
2484impl FromStr for UserContent {
2485 type Err = Infallible;
2486
2487 fn from_str(s: &str) -> Result<Self, Self::Err> {
2488 Ok(UserContent::InputText {
2489 text: s.to_string(),
2490 })
2491 }
2492}
2493
2494#[cfg(test)]
2495mod tests {
2496 use super::*;
2497 use crate::completion::CompletionRequestBuilder;
2498 use crate::message;
2499 use crate::test_utils::MockCompletionModel;
2500 use serde_json::json;
2501 use std::collections::HashMap;
2502
2503 fn test_document(id: &str, text: &str) -> crate::completion::Document {
2504 crate::completion::Document {
2505 id: id.to_string(),
2506 text: text.to_string(),
2507 additional_props: HashMap::new(),
2508 }
2509 }
2510
2511 fn weather_tool_definition() -> completion::ToolDefinition {
2512 completion::ToolDefinition {
2513 name: "get_weather".to_string(),
2514 description: "Get the weather".to_string(),
2515 parameters: json!({
2516 "type": "object",
2517 "properties": {
2518 "location": {"type": "string"},
2519 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2520 },
2521 "required": ["location"]
2522 }),
2523 }
2524 }
2525
2526 fn weather_tool_request() -> completion::CompletionRequest {
2527 completion::CompletionRequest {
2528 model: None,
2529 preamble: None,
2530 chat_history: crate::OneOrMany::one(message::Message::user("what's the weather?")),
2531 documents: Vec::new(),
2532 tools: vec![weather_tool_definition()],
2533 temperature: None,
2534 max_tokens: None,
2535 tool_choice: None,
2536 additional_params: None,
2537 output_schema: None,
2538 }
2539 }
2540
2541 #[test]
2542 fn responses_tool_choice_modes_serialize_as_plain_strings() {
2543 for (choice, expected) in [
2544 (message::ToolChoice::Auto, json!("auto")),
2545 (message::ToolChoice::None, json!("none")),
2546 (message::ToolChoice::Required, json!("required")),
2547 ] {
2548 let converted = ToolChoice::try_from(choice).expect("mode should convert");
2549 assert_eq!(
2550 serde_json::to_value(&converted).expect("serialize tool choice"),
2551 expected
2552 );
2553 }
2554 }
2555
2556 #[test]
2557 fn responses_tool_choice_specific_single_name_serializes_as_named_function() {
2558 let converted = ToolChoice::try_from(message::ToolChoice::Specific {
2559 function_names: vec!["get_weather".to_string()],
2560 })
2561 .expect("single specific tool should convert");
2562
2563 assert_eq!(
2564 serde_json::to_value(&converted).expect("serialize tool choice"),
2565 json!({"type": "function", "name": "get_weather"})
2566 );
2567 }
2568
2569 #[test]
2570 fn responses_tool_choice_specific_multiple_names_serialize_as_allowed_tools() {
2571 let converted = ToolChoice::try_from(message::ToolChoice::Specific {
2572 function_names: vec!["add".to_string(), "subtract".to_string()],
2573 })
2574 .expect("multiple specific tools should convert");
2575
2576 assert_eq!(
2577 serde_json::to_value(&converted).expect("serialize tool choice"),
2578 json!({
2579 "type": "allowed_tools",
2580 "mode": "required",
2581 "tools": [
2582 {"type": "function", "name": "add"},
2583 {"type": "function", "name": "subtract"}
2584 ]
2585 })
2586 );
2587 }
2588
2589 #[test]
2590 fn responses_tool_choice_specific_empty_names_error() {
2591 let converted = ToolChoice::try_from(message::ToolChoice::Specific {
2592 function_names: vec![],
2593 });
2594
2595 assert!(matches!(
2596 converted,
2597 Err(CompletionError::RequestError(error))
2598 if error.to_string().contains("at least one function name")
2599 ));
2600 }
2601
2602 #[test]
2603 fn responses_request_with_specific_tool_choice_serializes_named_function() {
2604 let mut request = weather_tool_request();
2605 request.tool_choice = Some(message::ToolChoice::Specific {
2606 function_names: vec!["get_weather".to_string()],
2607 });
2608
2609 let request =
2610 CompletionRequest::try_from(("gpt-test".to_string(), request)).expect("convert");
2611 let request_json = serde_json::to_value(&request).expect("serialize request");
2612
2613 assert_eq!(
2614 request_json.get("tool_choice"),
2615 Some(&json!({"type": "function", "name": "get_weather"}))
2616 );
2617 }
2618
2619 #[test]
2620 fn responses_function_tools_are_non_strict_by_default() {
2621 let tool = ResponsesToolDefinition::function(
2622 "get_weather",
2623 "Get the weather",
2624 weather_tool_definition().parameters,
2625 );
2626
2627 assert!(!tool.strict);
2628 assert_eq!(tool.parameters["required"], json!(["location"]));
2629 assert!(tool.parameters.get("additionalProperties").is_none());
2630
2631 let serialized = serde_json::to_value(tool).expect("tool should serialize");
2632 assert!(serialized.get("strict").is_none());
2633 }
2634
2635 #[test]
2636 fn responses_strict_function_tools_sanitize_schema() {
2637 let tool = ResponsesToolDefinition::strict_function(
2638 "get_weather",
2639 "Get the weather",
2640 weather_tool_definition().parameters,
2641 );
2642
2643 assert!(tool.strict);
2644 assert_eq!(tool.parameters["additionalProperties"], json!(false));
2645 assert_eq!(tool.parameters["required"], json!(["location", "unit"]));
2646 }
2647
2648 fn request_with_preamble(preamble: &str) -> completion::CompletionRequest {
2649 completion::CompletionRequest {
2650 model: None,
2651 preamble: Some(preamble.to_string()),
2652 chat_history: crate::OneOrMany::one(message::Message::user("Hello")),
2653 documents: Vec::new(),
2654 tools: Vec::new(),
2655 temperature: None,
2656 max_tokens: None,
2657 tool_choice: None,
2658 additional_params: None,
2659 output_schema: None,
2660 }
2661 }
2662
2663 fn system_only_request(system_text: &str) -> completion::CompletionRequest {
2664 completion::CompletionRequest {
2665 model: None,
2666 preamble: None,
2667 chat_history: crate::OneOrMany::one(completion::Message::system(system_text)),
2668 documents: Vec::new(),
2669 tools: Vec::new(),
2670 temperature: None,
2671 max_tokens: None,
2672 tool_choice: None,
2673 additional_params: None,
2674 output_schema: None,
2675 }
2676 }
2677
2678 #[test]
2679 fn responses_request_uses_top_level_instructions_for_preamble_by_default() {
2680 let req = CompletionRequest::try_from((
2681 "gpt-4o-mini".to_string(),
2682 request_with_preamble("You are concise."),
2683 ))
2684 .expect("request should convert");
2685 let serialized = serde_json::to_value(&req).expect("request should serialize");
2686 let input = serialized["input"]
2687 .as_array()
2688 .expect("input should be array");
2689
2690 assert_eq!(serialized["instructions"], json!("You are concise."));
2691 assert_eq!(input.len(), 1);
2692 assert_eq!(input[0]["role"], "user");
2693 }
2694
2695 #[test]
2696 fn responses_request_drops_whitespace_only_preamble() {
2697 let req = CompletionRequest::try_from((
2698 "gpt-4o-mini".to_string(),
2699 request_with_preamble(" \n "),
2700 ))
2701 .expect("request should convert");
2702 let serialized = serde_json::to_value(&req).expect("request should serialize");
2703 let input = serialized["input"]
2704 .as_array()
2705 .expect("input should be array");
2706
2707 assert!(
2708 serialized.get("instructions").is_none(),
2709 "a whitespace-only preamble carries no content and is dropped"
2710 );
2711 assert_eq!(input.len(), 1);
2712 assert_eq!(input[0]["role"], "user");
2713 }
2714
2715 #[test]
2716 fn responses_request_lifts_system_messages_to_top_level_instructions_by_default() {
2717 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Hello")
2718 .preamble("System one".to_string())
2719 .message(completion::Message::system("System two"))
2720 .build();
2721
2722 let req = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
2723 .expect("request should convert");
2724 let serialized = serde_json::to_value(&req).expect("request should serialize");
2725 let input = serialized["input"]
2726 .as_array()
2727 .expect("input should be array");
2728
2729 assert_eq!(
2730 serialized["instructions"],
2731 json!("System one\n\nSystem two")
2732 );
2733 assert_eq!(input.len(), 1);
2734 assert_eq!(input[0]["role"], "user");
2735 }
2736
2737 #[test]
2738 fn responses_request_with_only_system_messages_keeps_them_in_input() {
2739 let req = CompletionRequest::try_from((
2740 "gpt-4o-mini".to_string(),
2741 system_only_request("System only"),
2742 ))
2743 .expect("request conversion should succeed");
2744 let serialized = serde_json::to_value(&req).expect("request should serialize");
2745 let input = serialized["input"]
2746 .as_array()
2747 .expect("input should be array");
2748
2749 assert!(
2750 serialized.get("instructions").is_none(),
2751 "lifting a system-only history would leave input empty, so it stays in input"
2752 );
2753 assert_eq!(input.len(), 1);
2754 assert_eq!(input[0]["role"], "system");
2755 assert!(input[0].to_string().contains("System only"));
2756 }
2757
2758 #[test]
2759 fn responses_model_can_fallback_to_system_messages_in_input() {
2760 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
2761 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
2762 .with_system_instructions_as_messages();
2763
2764 let req = model
2765 .create_completion_request(request_with_preamble("You are concise."))
2766 .expect("request should convert");
2767 let serialized = serde_json::to_value(&req).expect("request should serialize");
2768 let input = serialized["input"]
2769 .as_array()
2770 .expect("input should be array");
2771
2772 assert!(serialized.get("instructions").is_none());
2773 assert_eq!(input.len(), 2);
2774 assert_eq!(input[0]["role"], "system");
2775 assert!(input[0].to_string().contains("You are concise."));
2776 assert_eq!(input[1]["role"], "user");
2777 }
2778
2779 #[test]
2780 fn responses_client_can_fallback_to_system_messages_in_input() {
2781 use crate::prelude::CompletionClient;
2782
2783 let client = crate::providers::openai::Client::new("dummy-key")
2784 .expect("client")
2785 .with_system_instructions_as_messages();
2786 let model = client.completion_model("gpt-4o-mini");
2787
2788 let req = model
2789 .create_completion_request(request_with_preamble("You are concise."))
2790 .expect("request should convert");
2791 let serialized = serde_json::to_value(&req).expect("request should serialize");
2792 let input = serialized["input"]
2793 .as_array()
2794 .expect("input should be array");
2795
2796 assert!(serialized.get("instructions").is_none());
2797 assert_eq!(input.len(), 2);
2798 assert_eq!(input[0]["role"], "system");
2799 assert!(input[0].to_string().contains("You are concise."));
2800 assert_eq!(input[1]["role"], "user");
2801 }
2802
2803 #[test]
2804 fn responses_model_can_lift_all_system_messages_via_placement() {
2805 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
2806 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
2807 .with_system_instructions_placement(SystemInstructionsPlacement::AllInstructions);
2808
2809 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "again")
2810 .preamble("System one".to_string())
2811 .message(completion::Message::user("hi"))
2812 .message(completion::Message::system("Mid-conversation instruction"))
2813 .build();
2814
2815 let req = model
2816 .create_completion_request(request)
2817 .expect("request should convert");
2818 let serialized = serde_json::to_value(&req).expect("request should serialize");
2819 let input = serialized["input"]
2820 .as_array()
2821 .expect("input should be array");
2822
2823 assert_eq!(
2824 serialized["instructions"],
2825 json!("System one\n\nMid-conversation instruction")
2826 );
2827 assert!(
2828 input.iter().all(|item| item["role"] != "system"),
2829 "AllInstructions should leave no system items in input: {input:?}"
2830 );
2831 }
2832
2833 #[test]
2834 fn responses_client_placement_survives_completions_api_round_trip() {
2835 use crate::prelude::CompletionClient;
2836
2837 let client = crate::providers::openai::Client::new("dummy-key")
2838 .expect("client")
2839 .with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
2840 .completions_api()
2841 .responses_api();
2842 let model = client.completion_model("gpt-4o-mini");
2843
2844 let req = model
2845 .create_completion_request(request_with_preamble("You are concise."))
2846 .expect("request should convert");
2847 let serialized = serde_json::to_value(&req).expect("request should serialize");
2848
2849 assert!(
2850 serialized.get("instructions").is_none(),
2851 "placement configured before completions_api() should survive responses_api()"
2852 );
2853 assert_eq!(serialized["input"][0]["role"], "system");
2854 }
2855
2856 #[test]
2857 fn all_instructions_system_only_input_reports_non_system_requirement() {
2858 let err = CompletionRequest::try_from(ResponsesRequestParams {
2859 model: "gpt-4o-mini".to_string(),
2860 request: system_only_request("System only"),
2861 system_instructions_placement: SystemInstructionsPlacement::AllInstructions,
2862 })
2863 .expect_err("system-only input should fail once every item is lifted");
2864
2865 assert!(
2866 err.to_string().contains("non-system item"),
2867 "error should explain that lifted system messages left input empty: {err}"
2868 );
2869 }
2870
2871 #[test]
2872 fn all_instructions_whitespace_only_system_input_reports_non_system_requirement() {
2873 let err = CompletionRequest::try_from(ResponsesRequestParams {
2874 model: "gpt-4o-mini".to_string(),
2875 request: system_only_request(" "),
2876 system_instructions_placement: SystemInstructionsPlacement::AllInstructions,
2877 })
2878 .expect_err("whitespace-only system input should fail once every item is lifted");
2879
2880 assert!(
2881 err.to_string().contains("non-system item"),
2882 "even when lifted system text is whitespace-only (so no `instructions` field is \
2883 produced), the error should explain that system messages were lifted: {err}"
2884 );
2885 }
2886
2887 #[test]
2888 fn responses_request_conversion_keeps_tools_non_strict_by_default() {
2889 let req = CompletionRequest::try_from(("gpt-4o-mini".to_string(), weather_tool_request()))
2890 .expect("request should convert");
2891
2892 let tool = &req.tools[0];
2893 assert!(!tool.strict);
2894 assert_eq!(tool.parameters["required"], json!(["location"]));
2895 assert!(tool.parameters.get("additionalProperties").is_none());
2896 }
2897
2898 #[test]
2899 fn responses_model_strict_tools_opt_in_sanitizes_all_function_tools() {
2900 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
2901 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
2902 .with_strict_tools()
2903 .with_tool(completion::ToolDefinition {
2904 name: "lookup".to_string(),
2905 description: "Look something up".to_string(),
2906 parameters: json!({
2907 "type": "object",
2908 "properties": {"q": {"type": "string"}}
2909 }),
2910 });
2911
2912 let mut request = weather_tool_request();
2913 request.additional_params = Some(json!({
2914 "tools": [{
2915 "type": "function",
2916 "name": "extra",
2917 "description": "An additional_params tool",
2918 "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}
2919 }]
2920 }));
2921
2922 let req = model
2923 .create_completion_request(request)
2924 .expect("request should convert");
2925
2926 assert_eq!(req.tools.len(), 3);
2927 for tool in &req.tools {
2928 assert!(tool.strict, "{} should be strict", tool.name);
2929 assert_eq!(tool.parameters["additionalProperties"], json!(false));
2930 }
2931 }
2932
2933 #[test]
2934 fn responses_model_default_preserves_all_function_tools_as_constructed() {
2935 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
2936 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
2937 .with_tool(weather_tool_definition());
2938
2939 let mut request = weather_tool_request();
2940 request.additional_params = Some(json!({
2941 "tools": [{
2942 "type": "function",
2943 "name": "extra",
2944 "description": "An additional_params tool",
2945 "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}
2946 }]
2947 }));
2948
2949 let req = model
2950 .create_completion_request(request)
2951 .expect("request should convert");
2952
2953 assert_eq!(req.tools.len(), 3);
2954 for tool in &req.tools {
2955 assert!(!tool.strict, "{} should not be strict", tool.name);
2956 assert!(tool.parameters.get("additionalProperties").is_none());
2957 }
2958 }
2959
2960 #[test]
2961 fn responses_explicit_strict_tool_stays_strict_on_default_model() {
2962 let client = crate::providers::openai::Client::new("dummy-key").expect("client");
2963 let model = ResponsesCompletionModel::new(client, "gpt-4o-mini").with_tool(
2964 ResponsesToolDefinition::strict_function(
2965 "lookup",
2966 "Look something up",
2967 json!({"type": "object", "properties": {"q": {"type": "string"}}}),
2968 ),
2969 );
2970
2971 let req = model
2972 .create_completion_request(weather_tool_request())
2973 .expect("request should convert");
2974
2975 assert!(!req.tools[0].strict);
2976 assert!(req.tools[1].strict);
2977 assert_eq!(
2978 req.tools[1].parameters["additionalProperties"],
2979 json!(false)
2980 );
2981 }
2982
2983 fn response_with_service_tier(service_tier: &str) -> Value {
2984 json!({
2985 "id": "resp_123",
2986 "object": "response",
2987 "created_at": 0,
2988 "status": "completed",
2989 "model": "gpt-5.4",
2990 "output": [],
2991 "service_tier": service_tier,
2992 })
2993 }
2994
2995 #[test]
2996 fn completion_response_deserializes_standard_service_tier() {
2997 let response: CompletionResponse =
2998 serde_json::from_value(response_with_service_tier("standard"))
2999 .expect("response should deserialize");
3000
3001 assert!(matches!(
3002 response.additional_parameters.service_tier,
3003 Some(OpenAIServiceTier::Standard)
3004 ));
3005 }
3006
3007 #[test]
3008 fn completion_response_deserializes_priority_service_tier() {
3009 let response: CompletionResponse =
3010 serde_json::from_value(response_with_service_tier("priority"))
3011 .expect("response should deserialize");
3012
3013 assert!(matches!(
3014 response.additional_parameters.service_tier,
3015 Some(OpenAIServiceTier::Priority)
3016 ));
3017 }
3018
3019 #[test]
3020 fn completion_response_preserves_unknown_service_tier() {
3021 let response: CompletionResponse =
3022 serde_json::from_value(response_with_service_tier("provider_experimental"))
3023 .expect("response should deserialize");
3024
3025 let Some(OpenAIServiceTier::Other(service_tier)) =
3026 response.additional_parameters.service_tier
3027 else {
3028 panic!("expected provider-specific service tier");
3029 };
3030
3031 assert_eq!(service_tier, "provider_experimental");
3032 }
3033
3034 #[test]
3035 fn responses_request_keeps_documents_after_lifted_system_messages() {
3036 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Prompt")
3037 .message(completion::Message::system("System prompt"))
3038 .message(completion::Message::user("Earlier user turn"))
3039 .message(completion::Message::assistant("Earlier assistant turn"))
3040 .document(test_document("doc1", "Document text."))
3041 .build();
3042
3043 let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3044 .expect("request conversion should succeed");
3045
3046 let serialized =
3047 serde_json::to_value(&responses_request).expect("request should serialize");
3048 let input = serialized["input"]
3049 .as_array()
3050 .expect("input should be an array");
3051
3052 assert_eq!(serialized["instructions"], json!("System prompt"));
3053 assert_eq!(input.len(), 4);
3054 assert_eq!(input[0]["role"], "user");
3055 assert!(
3056 input[0].to_string().contains("<file id: doc1>"),
3057 "document input should be first after system instructions are lifted: {input:?}"
3058 );
3059 assert_eq!(input[1]["role"], "user");
3060 assert!(
3061 input[1].to_string().contains("Earlier user turn"),
3062 "prior user history should follow document input: {input:?}"
3063 );
3064 assert_eq!(input[2]["role"], "assistant");
3065 assert!(
3066 input[2].to_string().contains("Earlier assistant turn"),
3067 "prior assistant history should follow prior user history: {input:?}"
3068 );
3069 assert_eq!(input[3]["role"], "user");
3070 assert!(
3071 input[3].to_string().contains("Prompt"),
3072 "prompt should remain last: {input:?}"
3073 );
3074 }
3075
3076 #[test]
3077 fn responses_direct_request_keeps_mid_conversation_system_messages_in_input() {
3078 let request = crate::completion::CompletionRequest {
3079 model: None,
3080 preamble: None,
3081 chat_history: crate::OneOrMany::many(vec![
3082 completion::Message::system("System prompt"),
3083 completion::Message::assistant("Earlier assistant turn"),
3084 completion::Message::system("Mid-conversation instruction"),
3085 completion::Message::user("Prompt"),
3086 ])
3087 .unwrap(),
3088 documents: vec![test_document("doc1", "Document text.")],
3089 tools: vec![],
3090 temperature: None,
3091 max_tokens: None,
3092 tool_choice: None,
3093 additional_params: None,
3094 output_schema: None,
3095 };
3096
3097 let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3098 .expect("request conversion should succeed");
3099
3100 let serialized =
3101 serde_json::to_value(&responses_request).expect("request should serialize");
3102 let input = serialized["input"]
3103 .as_array()
3104 .expect("input should be an array");
3105
3106 assert_eq!(
3107 serialized["instructions"],
3108 json!("System prompt"),
3109 "only the leading run of system messages should be lifted"
3110 );
3111 assert_eq!(input.len(), 4);
3112 assert_eq!(input[0]["role"], "user");
3113 assert!(
3114 input[0].to_string().contains("<file id: doc1>"),
3115 "document input should follow lifted system instructions: {input:?}"
3116 );
3117 assert_eq!(input[1]["role"], "assistant");
3118 assert_eq!(input[2]["role"], "system");
3119 assert!(
3120 input[2]
3121 .to_string()
3122 .contains("Mid-conversation instruction"),
3123 "mid-conversation system messages should keep their position: {input:?}"
3124 );
3125 assert_eq!(input[3]["role"], "user");
3126 assert_eq!(
3127 input
3128 .iter()
3129 .filter(|message| message.to_string().contains("<file id: doc1>"))
3130 .count(),
3131 1,
3132 "document input should appear exactly once: {input:?}"
3133 );
3134 }
3135
3136 #[test]
3137 fn service_tier_serializes_expected_strings() {
3138 let cases = [
3139 (OpenAIServiceTier::Auto, "auto"),
3140 (OpenAIServiceTier::Default, "default"),
3141 (OpenAIServiceTier::Flex, "flex"),
3142 (OpenAIServiceTier::Priority, "priority"),
3143 (OpenAIServiceTier::Standard, "standard"),
3144 ];
3145
3146 for (service_tier, expected) in cases {
3147 assert_eq!(
3148 serde_json::to_value(service_tier).expect("service tier should serialize"),
3149 json!(expected)
3150 );
3151 }
3152
3153 assert_eq!(
3154 serde_json::to_value(OpenAIServiceTier::Other(
3155 "provider_experimental".to_string()
3156 ))
3157 .expect("provider-specific service tier should serialize"),
3158 json!("provider_experimental")
3159 );
3160 }
3161
3162 #[test]
3163 fn responses_usage_token_usage_preserves_reasoning_tokens() {
3164 let usage = ResponsesUsage {
3165 input_tokens: 100,
3166 input_tokens_details: Some(InputTokensDetails { cached_tokens: 25 }),
3167 output_tokens: 50,
3168 output_tokens_details: Some(OutputTokensDetails {
3169 reasoning_tokens: 15,
3170 }),
3171 total_tokens: 150,
3172 };
3173
3174 let token_usage = usage.token_usage();
3175
3176 assert_eq!(token_usage.input_tokens, 100);
3177 assert_eq!(token_usage.cached_input_tokens, 25);
3178 assert_eq!(token_usage.output_tokens, 50);
3179 assert_eq!(token_usage.reasoning_tokens, 15);
3180 assert_eq!(token_usage.total_tokens, 150);
3181 }
3182
3183 #[test]
3184 fn responses_usage_deserializes_without_output_token_details() {
3185 let usage: ResponsesUsage = serde_json::from_value(json!({
3186 "input_tokens": 100,
3187 "input_tokens_details": {
3188 "cached_tokens": 25
3189 },
3190 "output_tokens": 50,
3191 "total_tokens": 150
3192 }))
3193 .expect("usage should deserialize when output token details are omitted");
3194
3195 assert!(usage.output_tokens_details.is_none());
3196
3197 let token_usage = usage.token_usage();
3198
3199 assert_eq!(token_usage.input_tokens, 100);
3200 assert_eq!(token_usage.cached_input_tokens, 25);
3201 assert_eq!(token_usage.output_tokens, 50);
3202 assert_eq!(token_usage.reasoning_tokens, 0);
3203 assert_eq!(token_usage.total_tokens, 150);
3204 }
3205
3206 #[test]
3207 fn completion_response_accepts_top_level_reasoning_string() {
3208 let response: CompletionResponse = serde_json::from_value(json!({
3209 "id": "resp_123",
3210 "object": "response",
3211 "created_at": 0,
3212 "status": "completed",
3213 "model": "Qwen/Qwen3-4B",
3214 "reasoning": "thinking through the answer",
3215 "usage": {
3216 "input_tokens": 1,
3217 "output_tokens": 2,
3218 "total_tokens": 3
3219 },
3220 "output": [{
3221 "type": "message",
3222 "id": "msg_123",
3223 "status": "completed",
3224 "role": "assistant",
3225 "content": [{
3226 "type": "output_text",
3227 "annotations": [],
3228 "text": "done"
3229 }]
3230 }],
3231 "tools": []
3232 }))
3233 .expect("mistral.rs-style reasoning string should deserialize");
3234
3235 assert_eq!(
3236 response.provider_reasoning.as_deref(),
3237 Some("thinking through the answer")
3238 );
3239
3240 let completion: completion::CompletionResponse<CompletionResponse> =
3241 response.try_into().expect("response should convert");
3242 let items = completion.choice.iter().collect::<Vec<_>>();
3243 assert!(matches!(
3244 items[0],
3245 completion::AssistantContent::Reasoning(_)
3246 ));
3247 assert!(matches!(items[1], completion::AssistantContent::Text(_)));
3248 }
3249
3250 #[test]
3251 fn completion_response_accepts_null_metadata() {
3252 let response: CompletionResponse = serde_json::from_value(json!({
3253 "id": "resp_123",
3254 "object": "response",
3255 "created_at": 0,
3256 "status": "completed",
3257 "model": "openai-compatible-model",
3258 "metadata": null,
3259 "output": [{
3260 "type": "message",
3261 "id": "msg_123",
3262 "status": "completed",
3263 "role": "assistant",
3264 "content": [{
3265 "type": "output_text",
3266 "annotations": [],
3267 "text": "done"
3268 }]
3269 }],
3270 "tools": []
3271 }))
3272 .expect("response with null metadata should deserialize");
3273
3274 assert!(response.additional_parameters.metadata.is_empty());
3275 }
3276
3277 #[test]
3278 fn completion_response_accepts_reasoning_only_response() {
3279 let response: CompletionResponse = serde_json::from_value(json!({
3280 "id": "resp_123",
3281 "object": "response",
3282 "created_at": 0,
3283 "status": "completed",
3284 "model": "Qwen/Qwen3-4B",
3285 "reasoning": "thinking only",
3286 "usage": {
3287 "input_tokens": 1,
3288 "output_tokens": 2,
3289 "total_tokens": 3
3290 },
3291 "output": [],
3292 "tools": []
3293 }))
3294 .expect("reasoning-only response should deserialize");
3295
3296 let completion: completion::CompletionResponse<CompletionResponse> = response
3297 .try_into()
3298 .expect("reasoning-only response should convert");
3299 let items = completion.choice.iter().collect::<Vec<_>>();
3300
3301 assert_eq!(items.len(), 1);
3302 assert!(matches!(
3303 items[0],
3304 completion::AssistantContent::Reasoning(_)
3305 ));
3306 }
3307
3308 #[test]
3309 fn completion_response_rejects_empty_response_without_reasoning() {
3310 let response: CompletionResponse = serde_json::from_value(json!({
3311 "id": "resp_123",
3312 "object": "response",
3313 "created_at": 0,
3314 "status": "completed",
3315 "model": "Qwen/Qwen3-4B",
3316 "output": [],
3317 "tools": []
3318 }))
3319 .expect("empty response shape should deserialize");
3320
3321 let err = completion::CompletionResponse::<CompletionResponse>::try_from(response)
3322 .expect_err("empty response without reasoning should be rejected");
3323
3324 assert!(
3325 err.to_string()
3326 .contains("Response contained no message or tool call")
3327 );
3328 }
3329
3330 #[test]
3331 fn completion_response_ignores_top_level_reasoning_object_as_text() {
3332 let response: CompletionResponse = serde_json::from_value(json!({
3333 "id": "resp_123",
3334 "object": "response",
3335 "created_at": 0,
3336 "status": "completed",
3337 "model": "Qwen/Qwen3-4B",
3338 "reasoning": {
3339 "effort": "high"
3340 },
3341 "output": [{
3342 "type": "message",
3343 "id": "msg_123",
3344 "status": "completed",
3345 "role": "assistant",
3346 "content": [{
3347 "type": "output_text",
3348 "annotations": [],
3349 "text": "done"
3350 }]
3351 }],
3352 "tools": []
3353 }))
3354 .expect("object-shaped reasoning should be tolerated");
3355
3356 assert!(response.provider_reasoning.is_none());
3357
3358 let completion: completion::CompletionResponse<CompletionResponse> =
3359 response.try_into().expect("response should convert");
3360 let items = completion.choice.iter().collect::<Vec<_>>();
3361 assert_eq!(items.len(), 1);
3362 assert!(matches!(items[0], completion::AssistantContent::Text(_)));
3363 }
3364
3365 #[test]
3366 fn completion_response_does_not_duplicate_structured_reasoning() {
3367 let response: CompletionResponse = serde_json::from_value(json!({
3368 "id": "resp_123",
3369 "object": "response",
3370 "created_at": 0,
3371 "status": "completed",
3372 "model": "gpt-5.4",
3373 "reasoning": "provider top-level text",
3374 "output": [{
3375 "type": "reasoning",
3376 "id": "rs_123",
3377 "summary": [{
3378 "type": "summary_text",
3379 "text": "structured summary"
3380 }]
3381 }, {
3382 "type": "message",
3383 "id": "msg_123",
3384 "status": "completed",
3385 "role": "assistant",
3386 "content": [{
3387 "type": "output_text",
3388 "annotations": [],
3389 "text": "done"
3390 }]
3391 }],
3392 "tools": []
3393 }))
3394 .expect("response should deserialize");
3395
3396 let completion: completion::CompletionResponse<CompletionResponse> =
3397 response.try_into().expect("response should convert");
3398 let reasoning_count = completion
3399 .choice
3400 .iter()
3401 .filter(|item| matches!(item, completion::AssistantContent::Reasoning(_)))
3402 .count();
3403
3404 assert_eq!(reasoning_count, 1);
3405 }
3406
3407 #[test]
3408 fn idless_reasoning_is_skipped_when_converting_responses_history() {
3409 let assistant = message::Message::Assistant {
3410 id: Some("msg_123".to_string()),
3411 content: OneOrMany::one(message::AssistantContent::Reasoning(
3412 message::Reasoning::new("provider reasoning"),
3413 )),
3414 };
3415
3416 let converted = Vec::<Message>::try_from(assistant)
3417 .expect("idless reasoning should degrade gracefully");
3418
3419 assert!(converted.is_empty());
3420 }
3421
3422 #[test]
3423 fn idless_reasoning_only_is_skipped_without_empty_input_item() {
3424 let assistant = completion::Message::Assistant {
3425 id: None,
3426 content: OneOrMany::one(message::AssistantContent::Reasoning(
3427 message::Reasoning::new("provider reasoning"),
3428 )),
3429 };
3430
3431 let converted = Vec::<InputItem>::try_from(assistant)
3432 .expect("idless reasoning should degrade gracefully");
3433
3434 assert!(converted.is_empty());
3435 }
3436
3437 #[test]
3438 fn idless_reasoning_plus_text_preserves_text_for_responses_history() {
3439 let assistant = message::Message::Assistant {
3440 id: Some("msg_123".to_string()),
3441 content: OneOrMany::many(vec![
3442 message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
3443 message::AssistantContent::Text(Text::new("final answer")),
3444 ])
3445 .expect("assistant content should be non-empty"),
3446 };
3447
3448 let converted =
3449 Vec::<Message>::try_from(assistant).expect("assistant history should convert");
3450
3451 assert_eq!(converted.len(), 1);
3452 let Message::Assistant { content, .. } = &converted[0] else {
3453 panic!("expected assistant message");
3454 };
3455 assert!(matches!(
3456 content.first_ref(),
3457 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
3458 ));
3459 }
3460
3461 #[test]
3462 fn completion_history_idless_reasoning_plus_text_preserves_text_input_item() {
3463 let assistant = completion::Message::Assistant {
3464 id: Some("msg_123".to_string()),
3465 content: OneOrMany::many(vec![
3466 message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
3467 message::AssistantContent::Text(Text::new("final answer")),
3468 ])
3469 .expect("assistant content should be non-empty"),
3470 };
3471
3472 let converted =
3473 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
3474
3475 assert_eq!(converted.len(), 1);
3476 assert!(matches!(converted[0].role, Some(Role::Assistant)));
3477 let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
3478 panic!("expected assistant message input item");
3479 };
3480 assert!(matches!(
3481 content.first_ref(),
3482 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
3483 ));
3484 }
3485
3486 #[test]
3487 fn assistant_text_without_idless_reasoning_replays_as_output_text() {
3488 let assistant = completion::Message::Assistant {
3489 id: Some("msg_123".to_string()),
3490 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
3491 };
3492
3493 let converted =
3494 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
3495
3496 assert_eq!(converted.len(), 1);
3497 let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
3498 panic!("expected assistant message input item");
3499 };
3500 assert!(matches!(
3501 content.first_ref(),
3502 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
3503 ));
3504 }
3505
3506 #[test]
3507 fn idless_completion_assistant_text_replays_as_easy_input_message() {
3508 let assistant = completion::Message::Assistant {
3509 id: None,
3510 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
3511 };
3512
3513 let converted =
3514 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
3515
3516 assert_eq!(converted.len(), 1);
3517 assert!(matches!(converted[0].role, Some(Role::Assistant)));
3518 let InputContent::Message(Message::AssistantInput { content, .. }) = &converted[0].input
3519 else {
3520 panic!("expected assistant input message item");
3521 };
3522 assert_eq!(content, "final answer");
3523
3524 let serialized =
3525 serde_json::to_value(&converted[0]).expect("input item should serialize to JSON");
3526 assert_eq!(serialized["type"], json!("message"));
3527 assert_eq!(serialized["role"], json!("assistant"));
3528 assert_eq!(serialized["content"], json!("final answer"));
3529 assert!(serialized.get("id").is_none());
3530 assert!(serialized.get("status").is_none());
3531 }
3532
3533 #[test]
3534 fn idless_message_assistant_text_replays_as_easy_input_message() {
3535 let assistant = message::Message::Assistant {
3536 id: None,
3537 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
3538 };
3539
3540 let converted =
3541 Vec::<Message>::try_from(assistant).expect("assistant history should convert");
3542
3543 assert_eq!(converted.len(), 1);
3544 let Message::AssistantInput { content, .. } = &converted[0] else {
3545 panic!("expected assistant input message");
3546 };
3547 assert_eq!(content, "final answer");
3548
3549 let serialized = serde_json::to_value(&converted[0])
3550 .expect("assistant message should serialize to JSON");
3551 assert_eq!(serialized["role"], json!("assistant"));
3552 assert_eq!(serialized["content"], json!("final answer"));
3553 assert!(serialized.get("id").is_none());
3554 assert!(serialized.get("status").is_none());
3555 }
3556
3557 #[test]
3558 fn structured_reasoning_with_id_still_converts_for_responses_history() {
3559 let assistant = message::Message::Assistant {
3560 id: Some("msg_123".to_string()),
3561 content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
3562 id: Some("rs_123".to_string()),
3563 content: vec![message::ReasoningContent::Summary(
3564 "structured summary".to_string(),
3565 )],
3566 })),
3567 };
3568
3569 let converted =
3570 Vec::<Message>::try_from(assistant).expect("structured reasoning should still convert");
3571
3572 assert_eq!(converted.len(), 1);
3573 let Message::Assistant { content, .. } = &converted[0] else {
3574 panic!("expected assistant message");
3575 };
3576 assert!(matches!(
3577 content.first_ref(),
3578 AssistantContentType::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
3579 ));
3580 }
3581
3582 #[test]
3583 fn structured_reasoning_with_id_still_converts_to_input_item() {
3584 let assistant = completion::Message::Assistant {
3585 id: Some("msg_123".to_string()),
3586 content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
3587 id: Some("rs_123".to_string()),
3588 content: vec![message::ReasoningContent::Summary(
3589 "structured summary".to_string(),
3590 )],
3591 })),
3592 };
3593
3594 let converted =
3595 Vec::<InputItem>::try_from(assistant).expect("structured reasoning should convert");
3596
3597 assert_eq!(converted.len(), 1);
3598 assert!(converted[0].role.is_none());
3599 assert!(matches!(
3600 &converted[0].input,
3601 InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
3602 ));
3603 }
3604
3605 #[test]
3606 fn assistant_reasoning_text_tool_call_convert_in_responses_replay_order() {
3607 let assistant = completion::Message::Assistant {
3608 id: Some("msg_123".to_string()),
3609 content: OneOrMany::many(vec![
3610 message::AssistantContent::Reasoning(message::Reasoning {
3611 id: Some("rs_123".to_string()),
3612 content: vec![message::ReasoningContent::Summary(
3613 "structured summary".to_string(),
3614 )],
3615 }),
3616 message::AssistantContent::Text(Text::new("final answer")),
3617 message::AssistantContent::tool_call_with_call_id(
3618 "fc_123",
3619 "call_123".to_string(),
3620 "lookup",
3621 json!({"query": "rig"}),
3622 ),
3623 ])
3624 .expect("assistant content should be non-empty"),
3625 };
3626
3627 let converted =
3628 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
3629
3630 assert_eq!(converted.len(), 3);
3631 assert!(converted[0].role.is_none());
3632 assert!(matches!(
3633 &converted[0].input,
3634 InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
3635 ));
3636
3637 assert!(matches!(converted[1].role, Some(Role::Assistant)));
3638 let InputContent::Message(Message::Assistant { content, id, .. }) = &converted[1].input
3639 else {
3640 panic!("expected assistant output message");
3641 };
3642 assert_eq!(id, "msg_123");
3643 assert!(matches!(
3644 content.first_ref(),
3645 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. }))
3646 if text == "final answer"
3647 ));
3648
3649 assert!(converted[2].role.is_none());
3650 let InputContent::FunctionCall(OutputFunctionCall {
3651 id, call_id, name, ..
3652 }) = &converted[2].input
3653 else {
3654 panic!("expected function call input item");
3655 };
3656 assert_eq!(id, "fc_123");
3657 assert_eq!(call_id, "call_123");
3658 assert_eq!(name, "lookup");
3659 }
3660
3661 #[test]
3662 fn mocked_second_turn_request_omits_unreplayable_reasoning() {
3663 let request = crate::completion::CompletionRequest {
3664 model: None,
3665 preamble: Some("You are concise.".to_string()),
3666 chat_history: OneOrMany::many(vec![
3667 completion::Message::User {
3668 content: OneOrMany::one(message::UserContent::Text(Text::new(
3669 "Think briefly, then answer.",
3670 ))),
3671 },
3672 completion::Message::Assistant {
3673 id: Some("msg_123".to_string()),
3674 content: OneOrMany::many(vec![
3675 message::AssistantContent::Reasoning(message::Reasoning::new(
3676 "provider reasoning",
3677 )),
3678 message::AssistantContent::Text(Text::new("final answer")),
3679 ])
3680 .expect("assistant content should be non-empty"),
3681 },
3682 completion::Message::Assistant {
3683 id: None,
3684 content: OneOrMany::many(vec![
3685 message::AssistantContent::Reasoning(message::Reasoning::new(
3686 "provider reasoning only",
3687 )),
3688 message::AssistantContent::Text(Text::new("")),
3689 ])
3690 .expect("assistant content should be non-empty"),
3691 },
3692 completion::Message::User {
3693 content: OneOrMany::one(message::UserContent::Text(Text::new(
3694 "/no_think Reply with exactly: OK",
3695 ))),
3696 },
3697 ])
3698 .expect("history should be non-empty"),
3699 documents: Vec::new(),
3700 tools: Vec::new(),
3701 temperature: None,
3702 max_tokens: Some(64),
3703 tool_choice: None,
3704 additional_params: None,
3705 output_schema: None,
3706 };
3707
3708 let request = CompletionRequest::try_from(("Qwen/Qwen3-4B".to_string(), request))
3709 .expect("request should convert");
3710 let value = serde_json::to_value(&request).expect("request should serialize");
3711 let input = value["input"]
3712 .as_array()
3713 .expect("mocked multi-turn request should serialize input as an array");
3714
3715 assert!(!input.iter().any(|item| {
3716 item.get("type") == Some(&json!("reasoning")) && item.get("id").is_none()
3717 }));
3718 assert!(!input.iter().any(|item| {
3719 item.get("role") == Some(&json!("assistant"))
3720 && item
3721 .get("content")
3722 .and_then(Value::as_array)
3723 .is_some_and(Vec::is_empty)
3724 }));
3725
3726 let assistant_items = input
3727 .iter()
3728 .filter(|item| item.get("role") == Some(&json!("assistant")))
3729 .collect::<Vec<_>>();
3730
3731 assert_eq!(assistant_items.len(), 1);
3732 assert_eq!(assistant_items[0]["content"][0]["type"], "output_text");
3733 assert_eq!(assistant_items[0]["content"][0]["text"], "final answer");
3734 }
3735
3736 #[test]
3737 fn responses_usage_add_preserves_rhs_details_when_lhs_details_are_absent() {
3738 let lhs = ResponsesUsage {
3739 input_tokens: 10,
3740 input_tokens_details: None,
3741 output_tokens: 20,
3742 output_tokens_details: None,
3743 total_tokens: 30,
3744 };
3745 let rhs = ResponsesUsage {
3746 input_tokens: 3,
3747 input_tokens_details: Some(InputTokensDetails { cached_tokens: 2 }),
3748 output_tokens: 5,
3749 output_tokens_details: Some(OutputTokensDetails {
3750 reasoning_tokens: 4,
3751 }),
3752 total_tokens: 8,
3753 };
3754
3755 let usage = lhs + rhs;
3756 let token_usage = usage.token_usage();
3757
3758 assert_eq!(token_usage.input_tokens, 13);
3759 assert_eq!(token_usage.cached_input_tokens, 2);
3760 assert_eq!(token_usage.output_tokens, 25);
3761 assert_eq!(token_usage.reasoning_tokens, 4);
3762 assert_eq!(token_usage.total_tokens, 38);
3763 }
3764
3765 #[test]
3766 fn file_id_document_serializes_as_input_file_content() {
3767 let message = message::Message::User {
3768 content: OneOrMany::one(message::UserContent::Document(message::Document {
3769 data: DocumentSourceKind::FileId("file_abc".to_string()),
3770 media_type: None,
3771 additional_params: None,
3772 })),
3773 };
3774
3775 let converted: Vec<Message> = message.try_into().expect("conversion should succeed");
3776 let Message::User { content, .. } = &converted[0] else {
3777 panic!("expected user message");
3778 };
3779
3780 let json = serde_json::to_value(content.first_ref()).expect("serialize content");
3781
3782 assert_eq!(json["type"], "input_file");
3783 assert_eq!(json["file_id"], "file_abc");
3784 assert!(json.get("file_data").is_none());
3785 assert!(json.get("file_url").is_none());
3786 }
3787
3788 #[test]
3789 fn file_id_document_serializes_as_input_item_content() {
3790 let message = completion::Message::User {
3791 content: OneOrMany::one(message::UserContent::Document(message::Document {
3792 data: DocumentSourceKind::FileId("file_abc".to_string()),
3793 media_type: None,
3794 additional_params: None,
3795 })),
3796 };
3797
3798 let converted: Vec<InputItem> = message.try_into().expect("conversion should succeed");
3799 let json = serde_json::to_value(&converted[0]).expect("serialize input item");
3800
3801 assert_eq!(json["type"], "message");
3802 assert_eq!(json["role"], "user");
3803 assert_eq!(json["content"][0]["type"], "input_file");
3804 assert_eq!(json["content"][0]["file_id"], "file_abc");
3805 assert!(json["content"][0].get("file_data").is_none());
3806 assert!(json["content"][0].get("file_url").is_none());
3807 }
3808
3809 #[tokio::test]
3810 async fn responses_completion_http_non_success_preserves_status_and_body() {
3811 use crate::client::CompletionClient;
3812 use crate::completion::CompletionModel;
3813 use crate::providers::openai::Client;
3814 use crate::test_utils::RecordingHttpClient;
3815
3816 let body = r#"{"error":{"message":"bad image","type":"invalid_request_error","code":"invalid_value"}}"#;
3817 let http_client =
3818 RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
3819 let client = Client::builder()
3820 .api_key("test-key")
3821 .http_client(http_client)
3822 .build()
3823 .expect("build client");
3824 let model = client.completion_model("gpt-4o-mini");
3825 let request = model.completion_request("hello").build();
3826
3827 let error = model
3828 .completion(request)
3829 .await
3830 .expect_err("completion should fail with non-success status");
3831
3832 assert!(matches!(error, CompletionError::HttpError(_)));
3833 assert_eq!(
3834 error.provider_response_status(),
3835 Some(http::StatusCode::BAD_REQUEST)
3836 );
3837 assert_eq!(error.provider_response_body(), Some(body));
3838 let json = error
3839 .provider_response_json()
3840 .expect("raw body should be valid JSON")
3841 .expect("parsed JSON should be present");
3842 assert_eq!(json["error"]["code"], "invalid_value");
3843 }
3844
3845 #[test]
3846 fn output_unknown_preserves_hosted_tool_payload() {
3847 let item = json!({
3848 "type": "web_search_call",
3849 "id": "ws_001",
3850 "status": "completed",
3851 "action": { "type": "search", "queries": ["rig framework"] },
3852 });
3853
3854 let output: Output =
3855 serde_json::from_value(item.clone()).expect("unknown output should deserialize");
3856
3857 let Output::Unknown(value) = output else {
3858 panic!("expected Output::Unknown for an unmodeled item type");
3859 };
3860 assert_eq!(value, item);
3861 }
3862
3863 #[test]
3864 fn output_unknown_round_trips_value_equal() {
3865 let item = json!({
3866 "type": "file_search_call",
3867 "id": "fs_007",
3868 "status": "in_progress",
3869 "queries": ["lifecycle"],
3870 });
3871
3872 let output: Output =
3873 serde_json::from_value(item.clone()).expect("unknown output should deserialize");
3874 let serialized = serde_json::to_value(&output).expect("unknown output should serialize");
3875
3876 assert_eq!(serialized, item);
3877 }
3878
3879 #[test]
3880 fn output_known_variant_with_bad_body_errors() {
3881 let malformed = json!({
3884 "type": "function_call",
3885 "id": "call_1",
3886 });
3888
3889 let result: Result<Output, _> = serde_json::from_value(malformed);
3890 assert!(result.is_err());
3891 }
3892
3893 #[test]
3894 fn completion_response_with_unknown_output_keeps_usage() {
3895 let response = json!({
3898 "id": "resp_123",
3899 "object": "response",
3900 "created_at": 0,
3901 "status": "completed",
3902 "model": "gpt-5.4",
3903 "output": [
3904 {
3905 "type": "web_search_call",
3906 "id": "ws_001",
3907 "status": "completed",
3908 },
3909 {
3910 "type": "message",
3911 "id": "msg_1",
3912 "role": "assistant",
3913 "status": "completed",
3914 "content": [ { "type": "output_text", "text": "hi", "annotations": [] } ],
3915 },
3916 ],
3917 "usage": {
3918 "input_tokens": 100,
3919 "input_tokens_details": { "cached_tokens": 25 },
3920 "output_tokens": 50,
3921 "output_tokens_details": { "reasoning_tokens": 15 },
3922 "total_tokens": 150,
3923 },
3924 });
3925
3926 let response: CompletionResponse =
3927 serde_json::from_value(response).expect("response should deserialize");
3928
3929 assert!(matches!(response.output.first(), Some(Output::Unknown(_))));
3930 let usage = response.usage.expect("usage should be present");
3931 assert_eq!(usage.total_tokens, 150);
3932 }
3933
3934 #[test]
3935 fn output_known_variant_round_trips_value_equal() {
3936 let item = json!({
3942 "type": "function_call",
3943 "id": "fc_1",
3944 "arguments": "{}",
3945 "call_id": "c1",
3946 "name": "search",
3947 "status": "completed",
3948 });
3949
3950 let output: Output =
3951 serde_json::from_value(item.clone()).expect("known output should deserialize");
3952 assert!(matches!(output, Output::FunctionCall(_)));
3953
3954 let serialized = serde_json::to_value(&output).expect("known output should serialize");
3955 assert_eq!(serialized, item);
3956 }
3957
3958 #[test]
3959 fn output_reasoning_round_trips_value_equal() {
3960 let original = Output::Reasoning {
3966 id: "reasoning_1".to_string(),
3967 summary: vec![ReasoningSummary::SummaryText {
3968 text: "weighing options".to_string(),
3969 }],
3970 content: vec!["private reasoning".to_string()],
3971 encrypted_content: Some("ENCRYPTED".to_string()),
3972 status: Some(ToolStatus::Completed),
3973 };
3974
3975 let value = serde_json::to_value(&original).expect("reasoning should serialize");
3976 let round_tripped: Output =
3977 serde_json::from_value(value).expect("reasoning should deserialize");
3978
3979 assert_eq!(round_tripped, original);
3980 }
3981
3982 #[test]
3983 fn output_reasoning_none_optionals_serialize_as_explicit_null() {
3984 let value = serde_json::to_value(Output::Reasoning {
3990 id: "reasoning_1".to_string(),
3991 summary: vec![],
3992 content: vec![],
3993 encrypted_content: None,
3994 status: None,
3995 })
3996 .expect("reasoning should serialize");
3997
3998 assert_eq!(value["type"], "reasoning");
3999 assert_eq!(value["encrypted_content"], Value::Null);
4000 assert_eq!(value["status"], Value::Null);
4001 assert!(value.get("encrypted_content").is_some());
4002 assert!(value.get("status").is_some());
4003 }
4004
4005 #[test]
4006 fn output_message_round_trips_value_equal() {
4007 let item = json!({
4011 "type": "message",
4012 "id": "msg_1",
4013 "role": "assistant",
4014 "status": "completed",
4015 "content": [ { "type": "output_text", "text": "hello", "annotations": [] } ],
4016 });
4017
4018 let output: Output =
4019 serde_json::from_value(item.clone()).expect("message item should deserialize");
4020 assert!(matches!(output, Output::Message(_)));
4021
4022 let serialized = serde_json::to_value(&output).expect("message should serialize");
4023 assert_eq!(serialized, item);
4024 }
4025
4026 #[test]
4027 fn each_known_tag_decodes_to_its_modeled_variant() {
4028 let message: Output = serde_json::from_value(json!({
4033 "type": "message", "id": "msg_1", "role": "assistant", "status": "completed",
4034 "content": [ { "type": "output_text", "text": "hi", "annotations": [] } ],
4035 }))
4036 .expect("message item should decode");
4037 assert!(matches!(message, Output::Message(_)));
4038
4039 let function_call: Output = serde_json::from_value(json!({
4040 "type": "function_call", "id": "call_1", "arguments": "{}",
4041 "call_id": "c1", "name": "f", "status": "completed",
4042 }))
4043 .expect("function_call item should decode");
4044 assert!(matches!(function_call, Output::FunctionCall(_)));
4045
4046 let reasoning: Output =
4047 serde_json::from_value(json!({ "type": "reasoning", "id": "r1", "summary": [] }))
4048 .expect("reasoning item should decode");
4049 assert!(matches!(reasoning, Output::Reasoning { .. }));
4050 }
4051
4052 #[test]
4053 fn output_without_usable_type_tag_decodes_to_unknown() {
4054 for item in [
4057 json!({ "id": "x", "note": "no type field" }),
4058 json!({ "type": 7, "id": "x" }),
4059 ] {
4060 let output: Output =
4061 serde_json::from_value(item.clone()).expect("should decode to Unknown");
4062 assert_eq!(output, Output::Unknown(item));
4063 }
4064 }
4065}