1use super::InputAudio;
17use super::completion::ToolChoice;
18use super::responses_api::streaming::StreamingCompletionResponse;
19use crate::completion::{CompletionError, GetTokenUsage};
20use crate::http_client;
21use crate::http_client::HttpClientExt;
22use crate::json_utils;
23use crate::message::{
24 AudioMediaType, Document, DocumentMediaType, DocumentSourceKind, ImageDetail, MessageError,
25 MimeType, Text,
26};
27use crate::one_or_many::string_or_one_or_many;
28
29use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
30use crate::{OneOrMany, completion, message};
31use serde::{Deserialize, Deserializer, Serialize, Serializer};
32use serde_json::{Map, Value};
33use tracing::{Instrument, Level, enabled, info_span};
34
35use std::convert::Infallible;
36use std::ops::Add;
37use std::str::FromStr;
38
39pub mod streaming;
40#[cfg(all(not(target_family = "wasm"), feature = "websocket"))]
41pub mod websocket;
42
43#[derive(Debug, Deserialize, Serialize, Clone)]
46pub struct CompletionRequest {
47 pub input: OneOrMany<InputItem>,
49 pub model: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub instructions: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub max_output_tokens: Option<u64>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub stream: Option<bool>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub temperature: Option<f64>,
63 #[serde(skip_serializing_if = "Option::is_none")]
66 tool_choice: Option<ToolChoice>,
67 #[serde(skip_serializing_if = "Vec::is_empty")]
70 pub tools: Vec<ResponsesToolDefinition>,
71 #[serde(flatten)]
73 pub additional_parameters: AdditionalParameters,
74}
75
76impl CompletionRequest {
77 pub fn with_structured_outputs<S>(mut self, schema_name: S, schema: serde_json::Value) -> Self
78 where
79 S: Into<String>,
80 {
81 self.additional_parameters.text = Some(TextConfig::structured_output(schema_name, schema));
82
83 self
84 }
85
86 pub fn with_reasoning(mut self, reasoning: Reasoning) -> Self {
87 self.additional_parameters.reasoning = Some(reasoning);
88
89 self
90 }
91
92 pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
96 self.tools.push(tool.into());
97 self
98 }
99
100 pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
103 where
104 I: IntoIterator<Item = Tool>,
105 Tool: Into<ResponsesToolDefinition>,
106 {
107 self.tools.extend(tools.into_iter().map(Into::into));
108 self
109 }
110}
111
112#[derive(Debug, Deserialize, Clone)]
114pub struct InputItem {
115 #[serde(skip_serializing_if = "Option::is_none")]
119 role: Option<Role>,
120 #[serde(flatten)]
122 input: InputContent,
123}
124
125impl Serialize for InputItem {
126 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127 where
128 S: serde::Serializer,
129 {
130 let mut value = serde_json::to_value(&self.input).map_err(serde::ser::Error::custom)?;
131 let map = value.as_object_mut().ok_or_else(|| {
132 serde::ser::Error::custom("Input content must serialize to an object")
133 })?;
134
135 if let Some(role) = &self.role
136 && !map.contains_key("role")
137 {
138 map.insert(
139 "role".to_string(),
140 serde_json::to_value(role).map_err(serde::ser::Error::custom)?,
141 );
142 }
143
144 value.serialize(serializer)
145 }
146}
147
148impl InputItem {
149 pub fn system_message(content: impl Into<String>) -> Self {
150 Self {
151 role: Some(Role::System),
152 input: InputContent::Message(Message::System {
153 content: OneOrMany::one(SystemContent::InputText {
154 text: content.into(),
155 }),
156 name: None,
157 }),
158 }
159 }
160
161 pub(crate) fn system_text(&self) -> Option<String> {
162 match &self.input {
163 InputContent::Message(Message::System { content, .. }) => Some(
164 content
165 .iter()
166 .map(|item| match item {
167 SystemContent::InputText { text } => text.as_str(),
168 })
169 .collect::<Vec<_>>()
170 .join("\n"),
171 ),
172 _ => None,
173 }
174 }
175}
176
177#[derive(Debug, Deserialize, Serialize, Clone)]
179#[serde(rename_all = "lowercase")]
180pub enum Role {
181 User,
182 Assistant,
183 System,
184}
185
186#[derive(Debug, Deserialize, Serialize, Clone)]
188#[serde(tag = "type", rename_all = "snake_case")]
189pub enum InputContent {
190 Message(Message),
191 Reasoning(OpenAIReasoning),
192 FunctionCall(OutputFunctionCall),
193 FunctionCallOutput(ToolResult),
194}
195
196#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
197pub struct OpenAIReasoning {
198 id: String,
199 pub summary: Vec<ReasoningSummary>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub encrypted_content: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub status: Option<ToolStatus>,
204}
205
206#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
207#[serde(tag = "type", rename_all = "snake_case")]
208pub enum ReasoningSummary {
209 SummaryText { text: String },
210}
211
212impl ReasoningSummary {
213 fn new(input: &str) -> Self {
214 Self::SummaryText {
215 text: input.to_string(),
216 }
217 }
218
219 pub fn text(&self) -> String {
220 let ReasoningSummary::SummaryText { text } = self;
221 text.clone()
222 }
223}
224
225#[derive(Debug, Deserialize, Serialize, Clone)]
227pub struct ToolResult {
228 call_id: String,
230 output: String,
232 status: ToolStatus,
234}
235
236impl From<Message> for InputItem {
237 fn from(value: Message) -> Self {
238 match value {
239 Message::User { .. } => Self {
240 role: Some(Role::User),
241 input: InputContent::Message(value),
242 },
243 Message::Assistant { ref content, .. } => {
244 let role = if content
245 .iter()
246 .any(|x| matches!(x, AssistantContentType::Reasoning(_)))
247 {
248 None
249 } else {
250 Some(Role::Assistant)
251 };
252 Self {
253 role,
254 input: InputContent::Message(value),
255 }
256 }
257 Message::AssistantInput { .. } => Self {
258 role: Some(Role::Assistant),
259 input: InputContent::Message(value),
260 },
261 Message::System { .. } => Self {
262 role: Some(Role::System),
263 input: InputContent::Message(value),
264 },
265 Message::ToolResult {
266 tool_call_id,
267 output,
268 } => Self {
269 role: None,
270 input: InputContent::FunctionCallOutput(ToolResult {
271 call_id: tool_call_id,
272 output,
273 status: ToolStatus::Completed,
274 }),
275 },
276 }
277 }
278}
279
280impl TryFrom<crate::completion::Message> for Vec<InputItem> {
281 type Error = CompletionError;
282
283 fn try_from(value: crate::completion::Message) -> Result<Self, Self::Error> {
284 match value {
285 crate::completion::Message::System { content } => Ok(vec![InputItem {
286 role: Some(Role::System),
287 input: InputContent::Message(Message::System {
288 content: OneOrMany::one(content.into()),
289 name: None,
290 }),
291 }]),
292 crate::completion::Message::User { content } => {
293 let mut items = Vec::new();
294
295 for user_content in content {
296 match user_content {
297 crate::message::UserContent::Text(Text { text, .. }) => {
298 items.push(InputItem {
299 role: Some(Role::User),
300 input: InputContent::Message(Message::User {
301 content: OneOrMany::one(UserContent::InputText { text }),
302 name: None,
303 }),
304 });
305 }
306 crate::message::UserContent::ToolResult(
307 crate::completion::message::ToolResult {
308 call_id,
309 content: tool_content,
310 ..
311 },
312 ) => {
313 for tool_result_content in tool_content {
314 let crate::completion::message::ToolResultContent::Text(Text {
315 text,
316 ..
317 }) = tool_result_content
318 else {
319 return Err(CompletionError::ProviderError(
320 "This thing only supports text!".to_string(),
321 ));
322 };
323 items.push(InputItem {
325 role: None,
326 input: InputContent::FunctionCallOutput(ToolResult {
327 call_id: require_call_id(call_id.clone(), "Tool result")?,
328 output: text,
329 status: ToolStatus::Completed,
330 }),
331 });
332 }
333 }
334 crate::message::UserContent::Document(Document {
335 data: DocumentSourceKind::FileId(file_id),
336 ..
337 }) => items.push(InputItem {
338 role: Some(Role::User),
339 input: InputContent::Message(Message::User {
340 content: OneOrMany::one(UserContent::InputFile {
341 file_id: Some(file_id),
342 file_data: None,
343 file_url: None,
344 filename: None,
345 }),
346 name: None,
347 }),
348 }),
349 crate::message::UserContent::Document(Document {
350 data,
351 media_type: Some(DocumentMediaType::PDF),
352 ..
353 }) => {
354 let (file_data, file_url) = match data {
355 DocumentSourceKind::Base64(data) => {
356 (Some(format!("data:application/pdf;base64,{data}")), None)
357 }
358 DocumentSourceKind::Url(url) => (None, Some(url)),
359 DocumentSourceKind::Raw(_) => {
360 return Err(CompletionError::RequestError(
361 "Raw file data not supported, encode as base64 first"
362 .into(),
363 ));
364 }
365 doc => {
366 return Err(CompletionError::RequestError(
367 format!("Unsupported document type: {doc}").into(),
368 ));
369 }
370 };
371
372 items.push(InputItem {
373 role: Some(Role::User),
374 input: InputContent::Message(Message::User {
375 content: OneOrMany::one(UserContent::InputFile {
376 file_id: None,
377 file_data,
378 file_url,
379 filename: Some("document.pdf".to_string()),
380 }),
381 name: None,
382 }),
383 })
384 }
385 crate::message::UserContent::Document(Document {
386 data:
387 DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text),
388 ..
389 }) => items.push(InputItem {
390 role: Some(Role::User),
391 input: InputContent::Message(Message::User {
392 content: OneOrMany::one(UserContent::InputText { text }),
393 name: None,
394 }),
395 }),
396 crate::message::UserContent::Image(crate::message::Image {
397 data,
398 media_type,
399 detail,
400 ..
401 }) => {
402 let url = match data {
403 DocumentSourceKind::Base64(data) => {
404 let media_type = if let Some(media_type) = media_type {
405 media_type.to_mime_type().to_string()
406 } else {
407 String::new()
408 };
409 format!("data:{media_type};base64,{data}")
410 }
411 DocumentSourceKind::Url(url) => url,
412 DocumentSourceKind::Raw(_) => {
413 return Err(CompletionError::RequestError(
414 "Raw file data not supported, encode as base64 first"
415 .into(),
416 ));
417 }
418 doc => {
419 return Err(CompletionError::RequestError(
420 format!("Unsupported document type: {doc}").into(),
421 ));
422 }
423 };
424 items.push(InputItem {
425 role: Some(Role::User),
426 input: InputContent::Message(Message::User {
427 content: OneOrMany::one(UserContent::InputImage {
428 image_url: url,
429 detail: detail.unwrap_or_default(),
430 }),
431 name: None,
432 }),
433 });
434 }
435 message => {
436 return Err(CompletionError::ProviderError(format!(
437 "Unsupported message: {message:?}"
438 )));
439 }
440 }
441 }
442
443 Ok(items)
444 }
445 crate::completion::Message::Assistant { id, content } => {
446 let mut reasoning_items = Vec::new();
447 let mut other_items = Vec::new();
448
449 for assistant_content in content {
450 match assistant_content {
451 crate::message::AssistantContent::Text(Text { text, .. }) => {
452 if text.is_empty() {
453 continue;
454 }
455 let message = if let Some(id) = id.clone() {
456 Message::Assistant {
457 content: OneOrMany::one(AssistantContentType::Text(
458 AssistantContent::OutputText(Text::new(text)),
459 )),
460 id,
461 name: None,
462 status: ToolStatus::Completed,
463 }
464 } else {
465 Message::AssistantInput {
466 content: text,
467 name: None,
468 }
469 };
470
471 other_items.push(InputItem {
472 role: Some(Role::Assistant),
473 input: InputContent::Message(message),
474 });
475 }
476 crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
477 id: tool_id,
478 call_id,
479 function,
480 ..
481 }) => {
482 other_items.push(InputItem {
483 role: None,
484 input: InputContent::FunctionCall(OutputFunctionCall {
485 arguments: function.arguments,
486 call_id: require_call_id(call_id, "Assistant tool call")?,
487 id: tool_id,
488 name: function.name,
489 status: ToolStatus::Completed,
490 }),
491 });
492 }
493 crate::message::AssistantContent::Reasoning(reasoning) => {
494 let openai_reasoning = openai_reasoning_from_core(&reasoning)
495 .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
496 if let Some(openai_reasoning) = openai_reasoning {
497 reasoning_items.push(InputItem {
498 role: None,
499 input: InputContent::Reasoning(openai_reasoning),
500 });
501 }
502 }
503 crate::message::AssistantContent::Image(_) => {
504 return Err(CompletionError::ProviderError(
505 "Assistant image content is not supported in OpenAI Responses API"
506 .to_string(),
507 ));
508 }
509 }
510 }
511
512 let mut items = reasoning_items;
513 items.extend(other_items);
514 Ok(items)
515 }
516 }
517 }
518}
519
520impl From<OneOrMany<String>> for Vec<ReasoningSummary> {
521 fn from(value: OneOrMany<String>) -> Self {
522 value.iter().map(|x| ReasoningSummary::new(x)).collect()
523 }
524}
525
526fn require_call_id(call_id: Option<String>, context: &str) -> Result<String, CompletionError> {
527 call_id.ok_or_else(|| {
528 CompletionError::RequestError(
529 format!("{context} `call_id` is required for OpenAI Responses API").into(),
530 )
531 })
532}
533
534fn openai_reasoning_from_core(
535 reasoning: &crate::message::Reasoning,
536) -> Result<Option<OpenAIReasoning>, MessageError> {
537 let Some(id) = reasoning.id.clone() else {
538 return Ok(None);
539 };
540
541 let mut summary = Vec::new();
542 let mut encrypted_content = None;
543 for content in &reasoning.content {
544 match content {
545 crate::message::ReasoningContent::Text { text, .. }
546 | crate::message::ReasoningContent::Summary(text) => {
547 summary.push(ReasoningSummary::new(text));
548 }
549 crate::message::ReasoningContent::Encrypted(data)
552 | crate::message::ReasoningContent::Redacted { data } => {
553 encrypted_content.get_or_insert_with(|| data.clone());
554 }
555 }
556 }
557
558 Ok(Some(OpenAIReasoning {
559 id,
560 summary,
561 encrypted_content,
562 status: None,
563 }))
564}
565
566fn optional_reasoning_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
567where
568 D: Deserializer<'de>,
569{
570 Ok(
571 match Option::<serde_json::Value>::deserialize(deserializer)? {
572 Some(serde_json::Value::String(reasoning)) => Some(reasoning),
573 _ => None,
574 },
575 )
576}
577
578#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
580pub struct ResponsesToolDefinition {
581 #[serde(rename = "type")]
583 pub kind: String,
584 #[serde(default, skip_serializing_if = "String::is_empty")]
586 pub name: String,
587 #[serde(default, skip_serializing_if = "is_json_null")]
589 pub parameters: serde_json::Value,
590 #[serde(default, skip_serializing_if = "is_false")]
592 pub strict: bool,
593 #[serde(default, skip_serializing_if = "String::is_empty")]
595 pub description: String,
596 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
598 pub config: Map<String, Value>,
599}
600
601fn is_json_null(value: &Value) -> bool {
602 value.is_null()
603}
604
605fn is_false(value: &bool) -> bool {
606 !value
607}
608
609impl ResponsesToolDefinition {
610 pub fn function(
612 name: impl Into<String>,
613 description: impl Into<String>,
614 mut parameters: serde_json::Value,
615 ) -> Self {
616 super::sanitize_schema(&mut parameters);
617
618 Self {
619 kind: "function".to_string(),
620 name: name.into(),
621 parameters,
622 strict: true,
623 description: description.into(),
624 config: Map::new(),
625 }
626 }
627
628 pub fn hosted(kind: impl Into<String>) -> Self {
630 Self {
631 kind: kind.into(),
632 name: String::new(),
633 parameters: Value::Null,
634 strict: false,
635 description: String::new(),
636 config: Map::new(),
637 }
638 }
639
640 pub fn web_search() -> Self {
642 Self::hosted("web_search")
643 }
644
645 pub fn file_search() -> Self {
647 Self::hosted("file_search")
648 }
649
650 pub fn computer_use() -> Self {
652 Self::hosted("computer_use")
653 }
654
655 pub fn with_config(mut self, key: impl Into<String>, value: Value) -> Self {
657 self.config.insert(key.into(), value);
658 self
659 }
660
661 fn normalize(mut self) -> Self {
662 if self.kind == "function" {
663 super::sanitize_schema(&mut self.parameters);
664 self.strict = true;
665 }
666 self
667 }
668}
669
670impl From<completion::ToolDefinition> for ResponsesToolDefinition {
671 fn from(value: completion::ToolDefinition) -> Self {
672 let completion::ToolDefinition {
673 name,
674 parameters,
675 description,
676 } = value;
677
678 Self::function(name, description, parameters)
679 }
680}
681
682#[derive(Clone, Debug, Serialize, Deserialize)]
685pub struct ResponsesUsage {
686 pub input_tokens: u64,
688 #[serde(skip_serializing_if = "Option::is_none")]
690 pub input_tokens_details: Option<InputTokensDetails>,
691 pub output_tokens: u64,
693 #[serde(skip_serializing_if = "Option::is_none")]
695 pub output_tokens_details: Option<OutputTokensDetails>,
696 pub total_tokens: u64,
698}
699
700impl ResponsesUsage {
701 pub(crate) fn new() -> Self {
703 Self {
704 input_tokens: 0,
705 input_tokens_details: Some(InputTokensDetails::new()),
706 output_tokens: 0,
707 output_tokens_details: Some(OutputTokensDetails::new()),
708 total_tokens: 0,
709 }
710 }
711}
712
713impl GetTokenUsage for ResponsesUsage {
714 fn token_usage(&self) -> crate::completion::Usage {
715 crate::completion::Usage {
716 input_tokens: self.input_tokens,
717 output_tokens: self.output_tokens,
718 total_tokens: self.total_tokens,
719 cached_input_tokens: self
720 .input_tokens_details
721 .as_ref()
722 .map(|details| details.cached_tokens)
723 .unwrap_or(0),
724 cache_creation_input_tokens: 0,
725 tool_use_prompt_tokens: 0,
726 reasoning_tokens: self
727 .output_tokens_details
728 .as_ref()
729 .map(|details| details.reasoning_tokens)
730 .unwrap_or(0),
731 }
732 }
733}
734
735impl Add for ResponsesUsage {
736 type Output = Self;
737
738 fn add(self, rhs: Self) -> Self::Output {
739 let input_tokens = self.input_tokens + rhs.input_tokens;
740 let input_tokens_details = match (self.input_tokens_details, rhs.input_tokens_details) {
741 (Some(lhs), Some(rhs)) => Some(lhs + rhs),
742 (Some(lhs), None) => Some(lhs),
743 (None, Some(rhs)) => Some(rhs),
744 (None, None) => None,
745 };
746 let output_tokens = self.output_tokens + rhs.output_tokens;
747 let output_tokens_details = match (self.output_tokens_details, rhs.output_tokens_details) {
748 (Some(lhs), Some(rhs)) => Some(lhs + rhs),
749 (Some(lhs), None) => Some(lhs),
750 (None, Some(rhs)) => Some(rhs),
751 (None, None) => None,
752 };
753 let total_tokens = self.total_tokens + rhs.total_tokens;
754 Self {
755 input_tokens,
756 input_tokens_details,
757 output_tokens,
758 output_tokens_details,
759 total_tokens,
760 }
761 }
762}
763
764#[derive(Clone, Debug, Serialize, Deserialize)]
766pub struct InputTokensDetails {
767 pub cached_tokens: u64,
769}
770
771impl InputTokensDetails {
772 pub(crate) fn new() -> Self {
773 Self { cached_tokens: 0 }
774 }
775}
776
777impl Add for InputTokensDetails {
778 type Output = Self;
779 fn add(self, rhs: Self) -> Self::Output {
780 Self {
781 cached_tokens: self.cached_tokens + rhs.cached_tokens,
782 }
783 }
784}
785
786#[derive(Clone, Debug, Serialize, Deserialize)]
788pub struct OutputTokensDetails {
789 pub reasoning_tokens: u64,
791}
792
793impl OutputTokensDetails {
794 pub(crate) fn new() -> Self {
795 Self {
796 reasoning_tokens: 0,
797 }
798 }
799}
800
801impl Add for OutputTokensDetails {
802 type Output = Self;
803 fn add(self, rhs: Self) -> Self::Output {
804 Self {
805 reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
806 }
807 }
808}
809
810#[derive(Clone, Debug, Default, Serialize, Deserialize)]
812pub struct IncompleteDetailsReason {
813 pub reason: String,
815}
816
817#[derive(Clone, Debug, Default, Serialize, Deserialize)]
819pub struct ResponseError {
820 pub code: String,
822 pub message: String,
824}
825
826#[derive(Clone, Debug, Deserialize, Serialize)]
828#[serde(rename_all = "snake_case")]
829pub enum ResponseObject {
830 Response,
831}
832
833#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
835#[serde(rename_all = "snake_case")]
836pub enum ResponseStatus {
837 InProgress,
838 Completed,
839 Failed,
840 Cancelled,
841 Queued,
842 Incomplete,
843}
844
845impl TryFrom<(String, crate::completion::CompletionRequest)> for CompletionRequest {
847 type Error = CompletionError;
848 fn try_from(
849 (model, mut req): (String, crate::completion::CompletionRequest),
850 ) -> Result<Self, Self::Error> {
851 let chat_history = req.chat_history_with_documents();
852 let model = req.model.clone().unwrap_or(model);
853 let input = {
854 let mut partial_history = vec![];
855 partial_history.extend(chat_history);
856
857 let mut full_history: Vec<InputItem> = if let Some(content) = req.preamble {
861 vec![InputItem::system_message(content)]
862 } else {
863 Vec::new()
864 };
865
866 for history_item in partial_history {
867 full_history.extend(<Vec<InputItem>>::try_from(history_item)?);
868 }
869
870 full_history
871 };
872
873 let input = OneOrMany::many(input).map_err(|_| {
874 CompletionError::RequestError(
875 "OpenAI Responses request input must contain at least one item".into(),
876 )
877 })?;
878
879 let mut additional_params_payload = req.additional_params.take().unwrap_or(Value::Null);
880 let stream = match &additional_params_payload {
881 Value::Bool(stream) => Some(*stream),
882 Value::Object(map) => map.get("stream").and_then(Value::as_bool),
883 _ => None,
884 };
885
886 let mut additional_tools = Vec::new();
887 if let Some(additional_params_map) = additional_params_payload.as_object_mut() {
888 if let Some(raw_tools) = additional_params_map.remove("tools") {
889 additional_tools = serde_json::from_value::<Vec<ResponsesToolDefinition>>(
890 raw_tools,
891 )
892 .map_err(|err| {
893 CompletionError::RequestError(
894 format!(
895 "Invalid OpenAI Responses tools payload in additional_params: {err}"
896 )
897 .into(),
898 )
899 })?;
900 }
901 additional_params_map.remove("stream");
902 }
903
904 if additional_params_payload.is_boolean() {
905 additional_params_payload = Value::Null;
906 }
907
908 additional_tools = additional_tools
909 .into_iter()
910 .map(ResponsesToolDefinition::normalize)
911 .collect();
912
913 let mut additional_parameters = if additional_params_payload.is_null() {
914 AdditionalParameters::default()
916 } else {
917 serde_json::from_value::<AdditionalParameters>(additional_params_payload).map_err(
918 |err| {
919 CompletionError::RequestError(
920 format!("Invalid OpenAI Responses additional_params payload: {err}").into(),
921 )
922 },
923 )?
924 };
925 if additional_parameters.reasoning.is_some() {
926 let include = additional_parameters.include.get_or_insert_with(Vec::new);
927 if !include
928 .iter()
929 .any(|item| matches!(item, Include::ReasoningEncryptedContent))
930 {
931 include.push(Include::ReasoningEncryptedContent);
932 }
933 }
934
935 if additional_parameters.text.is_none()
937 && let Some(schema) = req.output_schema
938 {
939 let name = schema
940 .as_object()
941 .and_then(|o| o.get("title"))
942 .and_then(|v| v.as_str())
943 .unwrap_or("response_schema")
944 .to_string();
945 let mut schema_value = schema.to_value();
946 super::sanitize_schema(&mut schema_value);
947 additional_parameters.text = Some(TextConfig::structured_output(name, schema_value));
948 }
949
950 let tool_choice = req.tool_choice.map(ToolChoice::try_from).transpose()?;
951 let mut tools: Vec<ResponsesToolDefinition> = req
952 .tools
953 .into_iter()
954 .map(ResponsesToolDefinition::from)
955 .collect();
956 tools.append(&mut additional_tools);
957
958 Ok(Self {
959 input,
960 model,
961 instructions: None, max_output_tokens: req.max_tokens,
963 stream,
964 tool_choice,
965 tools,
966 temperature: req.temperature,
967 additional_parameters,
968 })
969 }
970}
971
972#[doc(hidden)]
974#[derive(Clone)]
975pub struct GenericResponsesCompletionModel<Ext = super::OpenAIResponsesExt, H = reqwest::Client> {
976 pub(crate) client: crate::client::Client<Ext, H>,
978 pub model: String,
980 pub tools: Vec<ResponsesToolDefinition>,
982}
983
984pub type ResponsesCompletionModel<H = reqwest::Client> =
989 GenericResponsesCompletionModel<super::OpenAIResponsesExt, H>;
990
991impl<Ext, H> GenericResponsesCompletionModel<Ext, H>
992where
993 crate::client::Client<Ext, H>: HttpClientExt + Clone + std::fmt::Debug + 'static,
994 Ext: crate::client::Provider + Clone + 'static,
995 H: Clone + Default + std::fmt::Debug + 'static,
996{
997 pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
999 Self {
1000 client,
1001 model: model.into(),
1002 tools: Vec::new(),
1003 }
1004 }
1005
1006 pub fn with_model(client: crate::client::Client<Ext, H>, model: &str) -> Self {
1007 Self {
1008 client,
1009 model: model.to_string(),
1010 tools: Vec::new(),
1011 }
1012 }
1013
1014 pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
1016 self.tools.push(tool.into());
1017 self
1018 }
1019
1020 pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
1022 where
1023 I: IntoIterator<Item = Tool>,
1024 Tool: Into<ResponsesToolDefinition>,
1025 {
1026 self.tools.extend(tools.into_iter().map(Into::into));
1027 self
1028 }
1029
1030 pub(crate) fn create_completion_request(
1032 &self,
1033 completion_request: crate::completion::CompletionRequest,
1034 ) -> Result<CompletionRequest, CompletionError> {
1035 let mut req = CompletionRequest::try_from((self.model.clone(), completion_request))?;
1036 req.tools.extend(self.tools.clone());
1037
1038 Ok(req)
1039 }
1040}
1041
1042impl<T> GenericResponsesCompletionModel<super::OpenAIResponsesExt, T>
1043where
1044 T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
1045{
1046 pub fn completions_api(self) -> crate::providers::openai::completion::CompletionModel<T> {
1048 super::completion::CompletionModel::with_model(self.client.completions_api(), &self.model)
1049 }
1050}
1051
1052#[derive(Clone, Debug, Serialize, Deserialize)]
1054pub struct CompletionResponse {
1055 pub id: String,
1057 pub object: ResponseObject,
1059 pub created_at: u64,
1061 pub status: ResponseStatus,
1063 pub error: Option<ResponseError>,
1065 pub incomplete_details: Option<IncompleteDetailsReason>,
1067 pub instructions: Option<String>,
1069 pub max_output_tokens: Option<u64>,
1071 pub model: String,
1073 #[serde(
1076 default,
1077 rename = "reasoning",
1078 deserialize_with = "optional_reasoning_string",
1079 skip_serializing_if = "Option::is_none"
1080 )]
1081 pub provider_reasoning: Option<String>,
1082 pub usage: Option<ResponsesUsage>,
1084 #[serde(default)]
1086 pub output: Vec<Output>,
1087 #[serde(default)]
1089 pub tools: Vec<ResponsesToolDefinition>,
1090 #[serde(flatten)]
1092 pub additional_parameters: AdditionalParameters,
1093}
1094
1095#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1098pub struct AdditionalParameters {
1099 #[serde(skip_serializing_if = "Option::is_none")]
1101 pub background: Option<bool>,
1102 #[serde(skip_serializing_if = "Option::is_none")]
1104 pub text: Option<TextConfig>,
1105 #[serde(skip_serializing_if = "Option::is_none")]
1107 pub include: Option<Vec<Include>>,
1108 #[serde(skip_serializing_if = "Option::is_none")]
1110 pub top_p: Option<f64>,
1111 #[serde(skip_serializing_if = "Option::is_none")]
1113 pub truncation: Option<TruncationStrategy>,
1114 #[serde(skip_serializing_if = "Option::is_none")]
1116 pub user: Option<String>,
1117 #[serde(skip_serializing_if = "Map::is_empty", default)]
1119 pub metadata: serde_json::Map<String, serde_json::Value>,
1120 #[serde(skip_serializing_if = "Option::is_none")]
1122 pub parallel_tool_calls: Option<bool>,
1123 #[serde(skip_serializing_if = "Option::is_none")]
1125 pub previous_response_id: Option<String>,
1126 #[serde(skip_serializing_if = "Option::is_none")]
1128 pub reasoning: Option<Reasoning>,
1129 #[serde(skip_serializing_if = "Option::is_none")]
1131 pub service_tier: Option<OpenAIServiceTier>,
1132 #[serde(skip_serializing_if = "Option::is_none")]
1134 pub store: Option<bool>,
1135}
1136
1137impl AdditionalParameters {
1138 pub fn to_json(self) -> serde_json::Value {
1139 serde_json::to_value(self).unwrap_or_else(|_| serde_json::Value::Object(Map::new()))
1140 }
1141}
1142
1143#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1147#[serde(rename_all = "snake_case")]
1148pub enum TruncationStrategy {
1149 Auto,
1150 #[default]
1151 Disabled,
1152}
1153
1154#[derive(Clone, Debug, Serialize, Deserialize)]
1157pub struct TextConfig {
1158 pub format: TextFormat,
1159}
1160
1161impl TextConfig {
1162 pub(crate) fn structured_output<S>(name: S, schema: serde_json::Value) -> Self
1163 where
1164 S: Into<String>,
1165 {
1166 Self {
1167 format: TextFormat::JsonSchema(StructuredOutputsInput {
1168 name: name.into(),
1169 schema,
1170 strict: true,
1171 }),
1172 }
1173 }
1174}
1175
1176#[derive(Clone, Debug, Serialize, Deserialize, Default)]
1179#[serde(tag = "type")]
1180#[serde(rename_all = "snake_case")]
1181pub enum TextFormat {
1182 JsonSchema(StructuredOutputsInput),
1183 #[default]
1184 Text,
1185}
1186
1187#[derive(Clone, Debug, Serialize, Deserialize)]
1189pub struct StructuredOutputsInput {
1190 pub name: String,
1192 pub schema: serde_json::Value,
1194 #[serde(default)]
1196 pub strict: bool,
1197}
1198
1199#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1201pub struct Reasoning {
1202 pub effort: Option<ReasoningEffort>,
1204 #[serde(skip_serializing_if = "Option::is_none")]
1206 pub summary: Option<ReasoningSummaryLevel>,
1207}
1208
1209impl Reasoning {
1210 pub fn new() -> Self {
1212 Self {
1213 effort: None,
1214 summary: None,
1215 }
1216 }
1217
1218 pub fn with_effort(mut self, reasoning_effort: ReasoningEffort) -> Self {
1220 self.effort = Some(reasoning_effort);
1221
1222 self
1223 }
1224
1225 pub fn with_summary_level(mut self, reasoning_summary_level: ReasoningSummaryLevel) -> Self {
1227 self.summary = Some(reasoning_summary_level);
1228
1229 self
1230 }
1231}
1232
1233#[derive(Clone, Debug, Default)]
1235pub enum OpenAIServiceTier {
1236 #[default]
1238 Auto,
1239 Default,
1241 Flex,
1243 Priority,
1245 Standard,
1247 Other(String),
1249}
1250
1251impl Serialize for OpenAIServiceTier {
1252 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1253 where
1254 S: Serializer,
1255 {
1256 serializer.serialize_str(match self {
1257 Self::Auto => "auto",
1258 Self::Default => "default",
1259 Self::Flex => "flex",
1260 Self::Priority => "priority",
1261 Self::Standard => "standard",
1262 Self::Other(value) => value,
1263 })
1264 }
1265}
1266
1267impl<'de> Deserialize<'de> for OpenAIServiceTier {
1268 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1269 where
1270 D: Deserializer<'de>,
1271 {
1272 let value = String::deserialize(deserializer)?;
1273 Ok(match value.as_str() {
1274 "auto" => Self::Auto,
1275 "default" => Self::Default,
1276 "flex" => Self::Flex,
1277 "priority" => Self::Priority,
1278 "standard" => Self::Standard,
1279 _ => Self::Other(value),
1280 })
1281 }
1282}
1283
1284#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1286#[serde(rename_all = "snake_case")]
1287pub enum ReasoningEffort {
1288 None,
1289 Minimal,
1290 Low,
1291 #[default]
1292 Medium,
1293 High,
1294 Xhigh,
1295}
1296
1297#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1299#[serde(rename_all = "snake_case")]
1300pub enum ReasoningSummaryLevel {
1301 #[default]
1302 Auto,
1303 Concise,
1304 Detailed,
1305}
1306
1307#[derive(Clone, Debug, Deserialize, Serialize)]
1310pub enum Include {
1311 #[serde(rename = "file_search_call.results")]
1312 FileSearchCallResults,
1313 #[serde(rename = "message.input_image.image_url")]
1314 MessageInputImageImageUrl,
1315 #[serde(rename = "computer_call.output.image_url")]
1316 ComputerCallOutputOutputImageUrl,
1317 #[serde(rename = "reasoning.encrypted_content")]
1318 ReasoningEncryptedContent,
1319 #[serde(rename = "code_interpreter_call.outputs")]
1320 CodeInterpreterCallOutputs,
1321}
1322
1323#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1325#[serde(tag = "type")]
1326#[serde(rename_all = "snake_case")]
1327pub enum Output {
1328 Message(OutputMessage),
1329 #[serde(alias = "function_call")]
1330 FunctionCall(OutputFunctionCall),
1331 Reasoning {
1332 id: String,
1333 summary: Vec<ReasoningSummary>,
1334 #[serde(default)]
1335 encrypted_content: Option<String>,
1336 #[serde(default)]
1337 status: Option<ToolStatus>,
1338 },
1339 #[serde(other)]
1344 Unknown,
1345}
1346
1347impl From<Output> for Vec<completion::AssistantContent> {
1348 fn from(value: Output) -> Self {
1349 let res: Vec<completion::AssistantContent> = match value {
1350 Output::Message(OutputMessage { content, .. }) => content
1351 .into_iter()
1352 .map(completion::AssistantContent::from)
1353 .collect(),
1354 Output::FunctionCall(OutputFunctionCall {
1355 id,
1356 arguments,
1357 call_id,
1358 name,
1359 ..
1360 }) => vec![completion::AssistantContent::tool_call_with_call_id(
1361 id, call_id, name, arguments,
1362 )],
1363 Output::Reasoning {
1364 id,
1365 summary,
1366 encrypted_content,
1367 ..
1368 } => {
1369 let mut content = summary
1370 .into_iter()
1371 .map(|summary| match summary {
1372 ReasoningSummary::SummaryText { text } => {
1373 message::ReasoningContent::Summary(text)
1374 }
1375 })
1376 .collect::<Vec<_>>();
1377 if let Some(encrypted_content) = encrypted_content {
1378 content.push(message::ReasoningContent::Encrypted(encrypted_content));
1379 }
1380 vec![completion::AssistantContent::Reasoning(
1381 message::Reasoning {
1382 id: Some(id),
1383 content,
1384 },
1385 )]
1386 }
1387 Output::Unknown => Vec::new(),
1388 };
1389
1390 res
1391 }
1392}
1393
1394#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1395pub struct OutputReasoning {
1396 id: String,
1397 summary: Vec<ReasoningSummary>,
1398 status: ToolStatus,
1399}
1400
1401#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1403pub struct OutputFunctionCall {
1404 pub id: String,
1405 #[serde(with = "json_utils::stringified_json")]
1406 pub arguments: serde_json::Value,
1407 pub call_id: String,
1408 pub name: String,
1409 pub status: ToolStatus,
1410}
1411
1412#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1414#[serde(rename_all = "snake_case")]
1415pub enum ToolStatus {
1416 InProgress,
1417 Completed,
1418 Incomplete,
1419}
1420
1421#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1423pub struct OutputMessage {
1424 pub id: String,
1426 pub role: OutputRole,
1428 pub status: ResponseStatus,
1430 pub content: Vec<AssistantContent>,
1432}
1433
1434#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1436#[serde(rename_all = "snake_case")]
1437pub enum OutputRole {
1438 Assistant,
1439}
1440
1441impl<Ext, H> completion::CompletionModel for GenericResponsesCompletionModel<Ext, H>
1442where
1443 crate::client::Client<Ext, H>:
1444 HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
1445 Ext: crate::client::Provider
1446 + crate::client::DebugExt
1447 + Clone
1448 + WasmCompatSend
1449 + WasmCompatSync
1450 + 'static,
1451 H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
1452{
1453 type Response = CompletionResponse;
1454 type StreamingResponse = StreamingCompletionResponse;
1455
1456 type Client = crate::client::Client<Ext, H>;
1457
1458 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1459 Self::new(client.clone(), model)
1460 }
1461
1462 async fn completion(
1463 &self,
1464 completion_request: crate::completion::CompletionRequest,
1465 ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1466 let span = if tracing::Span::current().is_disabled() {
1467 info_span!(
1468 target: "rig::completions",
1469 "chat",
1470 gen_ai.operation.name = "chat",
1471 gen_ai.provider.name = tracing::field::Empty,
1472 gen_ai.request.model = tracing::field::Empty,
1473 gen_ai.response.id = tracing::field::Empty,
1474 gen_ai.response.model = tracing::field::Empty,
1475 gen_ai.usage.output_tokens = tracing::field::Empty,
1476 gen_ai.usage.input_tokens = tracing::field::Empty,
1477 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1478 gen_ai.input.messages = tracing::field::Empty,
1479 gen_ai.output.messages = tracing::field::Empty,
1480 )
1481 } else {
1482 tracing::Span::current()
1483 };
1484
1485 span.record("gen_ai.provider.name", "openai");
1486 span.record("gen_ai.request.model", &self.model);
1487 let request = self.create_completion_request(completion_request)?;
1488 let body = serde_json::to_vec(&request)?;
1489
1490 if enabled!(Level::TRACE) {
1491 tracing::trace!(
1492 target: "rig::completions",
1493 "OpenAI Responses completion request: {request}",
1494 request = serde_json::to_string_pretty(&request)?
1495 );
1496 }
1497
1498 let req = self
1499 .client
1500 .post("/responses")?
1501 .body(body)
1502 .map_err(|e| CompletionError::HttpError(e.into()))?;
1503
1504 async move {
1505 let response = self.client.send(req).await?;
1506
1507 if response.status().is_success() {
1508 let t = http_client::text(response).await?;
1509 let response = serde_json::from_str::<Self::Response>(&t)?;
1510 let span = tracing::Span::current();
1511 span.record("gen_ai.response.id", &response.id);
1512 span.record("gen_ai.response.model", &response.model);
1513 if let Some(ref usage) = response.usage {
1514 span.record("gen_ai.usage.output_tokens", usage.output_tokens);
1515 span.record("gen_ai.usage.input_tokens", usage.input_tokens);
1516 let cached_tokens = usage
1517 .input_tokens_details
1518 .as_ref()
1519 .map(|d| d.cached_tokens)
1520 .unwrap_or(0);
1521 span.record("gen_ai.usage.cache_read.input_tokens", cached_tokens);
1522 }
1523 if enabled!(Level::TRACE) {
1524 tracing::trace!(
1525 target: "rig::completions",
1526 "OpenAI Responses completion response: {response}",
1527 response = serde_json::to_string_pretty(&response)?
1528 );
1529 }
1530 response.try_into()
1531 } else {
1532 let text = http_client::text(response).await?;
1533 Err(CompletionError::ProviderError(text))
1534 }
1535 }
1536 .instrument(span)
1537 .await
1538 }
1539
1540 async fn stream(
1541 &self,
1542 request: crate::completion::CompletionRequest,
1543 ) -> Result<
1544 crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
1545 CompletionError,
1546 > {
1547 GenericResponsesCompletionModel::stream(self, request).await
1548 }
1549}
1550
1551impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
1552 type Error = CompletionError;
1553
1554 fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
1555 let message_id = response.output.iter().find_map(|item| match item {
1557 Output::Message(msg) => Some(msg.id.clone()),
1558 _ => None,
1559 });
1560
1561 let output_content: Vec<completion::AssistantContent> = response
1562 .output
1563 .iter()
1564 .cloned()
1565 .flat_map(<Vec<completion::AssistantContent>>::from)
1566 .collect();
1567 let has_structured_reasoning = response
1568 .output
1569 .iter()
1570 .any(|item| matches!(item, Output::Reasoning { .. }));
1571 let content = response
1572 .provider_reasoning
1573 .as_ref()
1574 .filter(|reasoning| !has_structured_reasoning && !reasoning.is_empty())
1575 .map(|reasoning| {
1576 let mut content = Vec::with_capacity(output_content.len() + 1);
1577 content.push(completion::AssistantContent::Reasoning(
1578 message::Reasoning::new(reasoning),
1579 ));
1580 content.extend(output_content.clone());
1581 content
1582 })
1583 .unwrap_or(output_content);
1584
1585 let choice = OneOrMany::many(content).map_err(|_| {
1586 CompletionError::ResponseError(
1587 "Response contained no message or tool call (empty)".to_owned(),
1588 )
1589 })?;
1590
1591 let usage = response
1592 .usage
1593 .as_ref()
1594 .map(GetTokenUsage::token_usage)
1595 .unwrap_or_default();
1596
1597 Ok(completion::CompletionResponse {
1598 choice,
1599 usage,
1600 raw_response: response,
1601 message_id,
1602 })
1603 }
1604}
1605
1606#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1608#[serde(tag = "role", rename_all = "lowercase")]
1609pub enum Message {
1610 #[serde(alias = "developer")]
1611 System {
1612 #[serde(deserialize_with = "string_or_one_or_many")]
1613 content: OneOrMany<SystemContent>,
1614 #[serde(skip_serializing_if = "Option::is_none")]
1615 name: Option<String>,
1616 },
1617 User {
1618 #[serde(deserialize_with = "string_or_one_or_many")]
1619 content: OneOrMany<UserContent>,
1620 #[serde(skip_serializing_if = "Option::is_none")]
1621 name: Option<String>,
1622 },
1623 Assistant {
1624 content: OneOrMany<AssistantContentType>,
1625 #[serde(skip_serializing_if = "String::is_empty")]
1626 id: String,
1627 #[serde(skip_serializing_if = "Option::is_none")]
1628 name: Option<String>,
1629 status: ToolStatus,
1630 },
1631 #[serde(rename = "assistant", skip_deserializing)]
1632 AssistantInput {
1633 content: String,
1634 #[serde(skip_serializing_if = "Option::is_none")]
1635 name: Option<String>,
1636 },
1637 #[serde(rename = "tool")]
1638 ToolResult {
1639 tool_call_id: String,
1640 output: String,
1641 },
1642}
1643
1644#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
1646#[serde(rename_all = "lowercase")]
1647pub enum ToolResultContentType {
1648 #[default]
1649 Text,
1650}
1651
1652impl Message {
1653 pub fn system(content: &str) -> Self {
1654 Message::System {
1655 content: OneOrMany::one(content.to_owned().into()),
1656 name: None,
1657 }
1658 }
1659}
1660
1661#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1664#[serde(tag = "type", rename_all = "snake_case")]
1665pub enum AssistantContent {
1666 OutputText(Text),
1667 Refusal { refusal: String },
1668}
1669
1670impl From<AssistantContent> for completion::AssistantContent {
1671 fn from(value: AssistantContent) -> Self {
1672 match value {
1673 AssistantContent::Refusal { refusal } => {
1674 completion::AssistantContent::Text(Text::new(refusal))
1675 }
1676 AssistantContent::OutputText(Text { text, .. }) => {
1677 completion::AssistantContent::Text(Text::new(text))
1678 }
1679 }
1680 }
1681}
1682
1683#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1685#[serde(untagged)]
1686pub enum AssistantContentType {
1687 Text(AssistantContent),
1688 ToolCall(OutputFunctionCall),
1689 Reasoning(OpenAIReasoning),
1690}
1691
1692#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1695#[serde(tag = "type", rename_all = "snake_case")]
1696pub enum SystemContent {
1697 InputText { text: String },
1698}
1699
1700impl From<String> for SystemContent {
1701 fn from(s: String) -> Self {
1702 SystemContent::InputText { text: s }
1703 }
1704}
1705
1706impl std::str::FromStr for SystemContent {
1707 type Err = std::convert::Infallible;
1708
1709 fn from_str(s: &str) -> Result<Self, Self::Err> {
1710 Ok(SystemContent::InputText {
1711 text: s.to_string(),
1712 })
1713 }
1714}
1715
1716#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1718#[serde(tag = "type", rename_all = "snake_case")]
1719pub enum UserContent {
1720 InputText {
1721 text: String,
1722 },
1723 InputImage {
1724 image_url: String,
1725 #[serde(default)]
1726 detail: ImageDetail,
1727 },
1728 InputFile {
1729 #[serde(skip_serializing_if = "Option::is_none")]
1730 file_id: Option<String>,
1731 #[serde(skip_serializing_if = "Option::is_none")]
1732 file_url: Option<String>,
1733 #[serde(skip_serializing_if = "Option::is_none")]
1734 file_data: Option<String>,
1735 #[serde(skip_serializing_if = "Option::is_none")]
1736 filename: Option<String>,
1737 },
1738 Audio {
1739 input_audio: InputAudio,
1740 },
1741 #[serde(rename = "tool")]
1742 ToolResult {
1743 tool_call_id: String,
1744 output: String,
1745 },
1746}
1747
1748impl TryFrom<message::Message> for Vec<Message> {
1749 type Error = message::MessageError;
1750
1751 fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1752 match message {
1753 message::Message::System { content } => Ok(vec![Message::System {
1754 content: OneOrMany::one(content.into()),
1755 name: None,
1756 }]),
1757 message::Message::User { content } => {
1758 let (tool_results, other_content): (Vec<_>, Vec<_>) = content
1759 .into_iter()
1760 .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
1761
1762 if !tool_results.is_empty() {
1765 tool_results
1766 .into_iter()
1767 .map(|content| match content {
1768 message::UserContent::ToolResult(message::ToolResult {
1769 call_id,
1770 content,
1771 ..
1772 }) => Ok::<_, message::MessageError>(Message::ToolResult {
1773 tool_call_id: call_id.ok_or_else(|| {
1774 MessageError::ConversionError(
1775 "Tool result `call_id` is required for OpenAI Responses API"
1776 .into(),
1777 )
1778 })?,
1779 output: {
1780 let res = content.first();
1781 match res {
1782 completion::message::ToolResultContent::Text(Text {
1783 text,
1784 ..
1785 }) => text,
1786 _ => return Err(MessageError::ConversionError("This API only currently supports text tool results".into()))
1787 }
1788 },
1789 }),
1790 _ => Err(MessageError::ConversionError(
1791 "expected tool result content while converting Responses API input"
1792 .into(),
1793 )),
1794 })
1795 .collect::<Result<Vec<_>, _>>()
1796 } else {
1797 let other_content = other_content
1798 .into_iter()
1799 .map(|content| match content {
1800 message::UserContent::Text(message::Text { text, .. }) => {
1801 Ok(UserContent::InputText { text })
1802 }
1803 message::UserContent::Image(message::Image {
1804 data,
1805 detail,
1806 media_type,
1807 ..
1808 }) => {
1809 let url = match data {
1810 DocumentSourceKind::Base64(data) => {
1811 let media_type = if let Some(media_type) = media_type {
1812 media_type.to_mime_type().to_string()
1813 } else {
1814 String::new()
1815 };
1816 format!("data:{media_type};base64,{data}")
1817 }
1818 DocumentSourceKind::Url(url) => url,
1819 DocumentSourceKind::Raw(_) => {
1820 return Err(MessageError::ConversionError(
1821 "Raw files not supported, encode as base64 first"
1822 .into(),
1823 ));
1824 }
1825 doc => {
1826 return Err(MessageError::ConversionError(format!(
1827 "Unsupported document type: {doc}"
1828 )));
1829 }
1830 };
1831
1832 Ok(UserContent::InputImage {
1833 image_url: url,
1834 detail: detail.unwrap_or_default(),
1835 })
1836 }
1837 message::UserContent::Document(message::Document {
1838 data: DocumentSourceKind::FileId(file_id),
1839 ..
1840 }) => Ok(UserContent::InputFile {
1841 file_id: Some(file_id),
1842 file_url: None,
1843 file_data: None,
1844 filename: None,
1845 }),
1846 message::UserContent::Document(message::Document {
1847 media_type: Some(DocumentMediaType::PDF),
1848 data,
1849 ..
1850 }) => {
1851 let (file_data, file_url, filename) = match data {
1852 DocumentSourceKind::Base64(data) => (
1853 Some(format!("data:application/pdf;base64,{data}")),
1854 None,
1855 Some("document.pdf".to_string()),
1856 ),
1857 DocumentSourceKind::Url(url) => (None, Some(url), None),
1858 DocumentSourceKind::Raw(_) => {
1859 return Err(MessageError::ConversionError(
1860 "Raw files not supported, encode as base64 first"
1861 .into(),
1862 ));
1863 }
1864 doc => {
1865 return Err(MessageError::ConversionError(format!(
1866 "Unsupported document type: {doc}"
1867 )));
1868 }
1869 };
1870
1871 Ok(UserContent::InputFile {
1872 file_id: None,
1873 file_url,
1874 file_data,
1875 filename,
1876 })
1877 }
1878 message::UserContent::Document(message::Document {
1879 data: DocumentSourceKind::Base64(text),
1880 ..
1881 }) => Ok(UserContent::InputText { text }),
1882 message::UserContent::Audio(message::Audio {
1883 data: DocumentSourceKind::Base64(data),
1884 media_type,
1885 ..
1886 }) => Ok(UserContent::Audio {
1887 input_audio: InputAudio {
1888 data,
1889 format: match media_type {
1890 Some(media_type) => media_type,
1891 None => AudioMediaType::MP3,
1892 },
1893 },
1894 }),
1895 message::UserContent::Audio(_) => Err(MessageError::ConversionError(
1896 "Audio must be base64 encoded data".into(),
1897 )),
1898 _ => Err(MessageError::ConversionError(
1899 "Unsupported user content for OpenAI Responses API".into(),
1900 )),
1901 })
1902 .collect::<Result<Vec<_>, _>>()?;
1903
1904 let other_content = OneOrMany::many(other_content).map_err(|_| {
1905 MessageError::ConversionError(
1906 "User message did not contain OpenAI Responses-compatible content"
1907 .to_string(),
1908 )
1909 })?;
1910
1911 Ok(vec![Message::User {
1912 content: other_content,
1913 name: None,
1914 }])
1915 }
1916 }
1917 message::Message::Assistant {
1918 content,
1919 id: assistant_message_id,
1920 } => {
1921 let mut messages = Vec::new();
1922
1923 for assistant_content in content {
1924 match assistant_content {
1925 crate::message::AssistantContent::Text(Text { text, .. }) => {
1926 if text.is_empty() {
1927 continue;
1928 }
1929 if let Some(id) = assistant_message_id.clone() {
1930 messages.push(Message::Assistant {
1931 id,
1932 status: ToolStatus::Completed,
1933 content: OneOrMany::one(AssistantContentType::Text(
1934 AssistantContent::OutputText(Text::new(text)),
1935 )),
1936 name: None,
1937 });
1938 } else {
1939 messages.push(Message::AssistantInput {
1940 content: text,
1941 name: None,
1942 });
1943 }
1944 }
1945 crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
1946 id: tool_id,
1947 call_id,
1948 function,
1949 ..
1950 }) => {
1951 messages.push(Message::Assistant {
1952 content: OneOrMany::one(AssistantContentType::ToolCall(
1953 OutputFunctionCall {
1954 call_id: call_id.ok_or_else(|| {
1955 MessageError::ConversionError(
1956 "Tool call `call_id` is required for OpenAI Responses API"
1957 .into(),
1958 )
1959 })?,
1960 arguments: function.arguments,
1961 id: tool_id,
1962 name: function.name,
1963 status: ToolStatus::Completed,
1964 },
1965 )),
1966 id: assistant_message_id.clone().unwrap_or_default(),
1967 name: None,
1968 status: ToolStatus::Completed,
1969 });
1970 }
1971 crate::message::AssistantContent::Reasoning(reasoning) => {
1972 if let Some(openai_reasoning) = openai_reasoning_from_core(&reasoning)?
1973 {
1974 messages.push(Message::Assistant {
1975 content: OneOrMany::one(AssistantContentType::Reasoning(
1976 openai_reasoning,
1977 )),
1978 id: assistant_message_id.clone().unwrap_or_default(),
1979 name: None,
1980 status: ToolStatus::Completed,
1981 });
1982 }
1983 }
1984 crate::message::AssistantContent::Image(_) => {
1985 return Err(MessageError::ConversionError(
1986 "Assistant image content is not supported in OpenAI Responses API"
1987 .into(),
1988 ));
1989 }
1990 }
1991 }
1992
1993 Ok(messages)
1994 }
1995 }
1996 }
1997}
1998
1999impl FromStr for UserContent {
2000 type Err = Infallible;
2001
2002 fn from_str(s: &str) -> Result<Self, Self::Err> {
2003 Ok(UserContent::InputText {
2004 text: s.to_string(),
2005 })
2006 }
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011 use super::*;
2012 use crate::completion::CompletionRequestBuilder;
2013 use crate::message;
2014 use crate::test_utils::MockCompletionModel;
2015 use serde_json::json;
2016 use std::collections::HashMap;
2017
2018 fn test_document(id: &str, text: &str) -> crate::completion::Document {
2019 crate::completion::Document {
2020 id: id.to_string(),
2021 text: text.to_string(),
2022 additional_props: HashMap::new(),
2023 }
2024 }
2025
2026 fn response_with_service_tier(service_tier: &str) -> Value {
2027 json!({
2028 "id": "resp_123",
2029 "object": "response",
2030 "created_at": 0,
2031 "status": "completed",
2032 "model": "gpt-5.4",
2033 "output": [],
2034 "service_tier": service_tier,
2035 })
2036 }
2037
2038 #[test]
2039 fn completion_response_deserializes_standard_service_tier() {
2040 let response: CompletionResponse =
2041 serde_json::from_value(response_with_service_tier("standard"))
2042 .expect("response should deserialize");
2043
2044 assert!(matches!(
2045 response.additional_parameters.service_tier,
2046 Some(OpenAIServiceTier::Standard)
2047 ));
2048 }
2049
2050 #[test]
2051 fn completion_response_deserializes_priority_service_tier() {
2052 let response: CompletionResponse =
2053 serde_json::from_value(response_with_service_tier("priority"))
2054 .expect("response should deserialize");
2055
2056 assert!(matches!(
2057 response.additional_parameters.service_tier,
2058 Some(OpenAIServiceTier::Priority)
2059 ));
2060 }
2061
2062 #[test]
2063 fn completion_response_preserves_unknown_service_tier() {
2064 let response: CompletionResponse =
2065 serde_json::from_value(response_with_service_tier("provider_experimental"))
2066 .expect("response should deserialize");
2067
2068 let Some(OpenAIServiceTier::Other(service_tier)) =
2069 response.additional_parameters.service_tier
2070 else {
2071 panic!("expected provider-specific service tier");
2072 };
2073
2074 assert_eq!(service_tier, "provider_experimental");
2075 }
2076
2077 #[test]
2078 fn responses_request_keeps_documents_after_system_messages() {
2079 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Prompt")
2080 .message(completion::Message::system("System prompt"))
2081 .message(completion::Message::user("Earlier user turn"))
2082 .message(completion::Message::assistant("Earlier assistant turn"))
2083 .document(test_document("doc1", "Document text."))
2084 .build();
2085
2086 let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
2087 .expect("request conversion should succeed");
2088
2089 let serialized =
2090 serde_json::to_value(&responses_request.input).expect("input should serialize");
2091 let input = serialized.as_array().expect("input should be an array");
2092
2093 assert_eq!(input.len(), 5);
2094 assert_eq!(input[0]["role"], "system");
2095 assert_eq!(input[1]["role"], "user");
2096 assert!(
2097 input[1].to_string().contains("<file id: doc1>"),
2098 "document input should follow system input: {input:?}"
2099 );
2100 assert_eq!(input[2]["role"], "user");
2101 assert!(
2102 input[2].to_string().contains("Earlier user turn"),
2103 "prior user history should follow document input: {input:?}"
2104 );
2105 assert_eq!(input[3]["role"], "assistant");
2106 assert!(
2107 input[3].to_string().contains("Earlier assistant turn"),
2108 "prior assistant history should follow prior user history: {input:?}"
2109 );
2110 assert_eq!(input[4]["role"], "user");
2111 assert!(
2112 input[4].to_string().contains("Prompt"),
2113 "prompt should remain last: {input:?}"
2114 );
2115 }
2116
2117 #[test]
2118 fn responses_direct_request_keeps_documents_after_system_messages() {
2119 let request = crate::completion::CompletionRequest {
2120 model: None,
2121 preamble: None,
2122 chat_history: crate::OneOrMany::many(vec![
2123 completion::Message::system("System prompt"),
2124 completion::Message::assistant("Earlier assistant turn"),
2125 completion::Message::system("Mid-conversation instruction"),
2126 completion::Message::user("Prompt"),
2127 ])
2128 .unwrap(),
2129 documents: vec![test_document("doc1", "Document text.")],
2130 tools: vec![],
2131 temperature: None,
2132 max_tokens: None,
2133 tool_choice: None,
2134 additional_params: None,
2135 output_schema: None,
2136 };
2137
2138 let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
2139 .expect("request conversion should succeed");
2140
2141 let serialized =
2142 serde_json::to_value(&responses_request.input).expect("input should serialize");
2143 let input = serialized.as_array().expect("input should be an array");
2144
2145 assert_eq!(input.len(), 5);
2146 assert_eq!(input[0]["role"], "system");
2147 assert_eq!(input[1]["role"], "user");
2148 assert!(
2149 input[1].to_string().contains("<file id: doc1>"),
2150 "document input should follow leading system input: {input:?}"
2151 );
2152 assert_eq!(input[2]["role"], "assistant");
2153 assert_eq!(input[3]["role"], "system");
2154 assert_eq!(input[4]["role"], "user");
2155 assert_eq!(
2156 input
2157 .iter()
2158 .filter(|message| message.to_string().contains("<file id: doc1>"))
2159 .count(),
2160 1,
2161 "document input should appear exactly once: {input:?}"
2162 );
2163 }
2164
2165 #[test]
2166 fn service_tier_serializes_expected_strings() {
2167 let cases = [
2168 (OpenAIServiceTier::Auto, "auto"),
2169 (OpenAIServiceTier::Default, "default"),
2170 (OpenAIServiceTier::Flex, "flex"),
2171 (OpenAIServiceTier::Priority, "priority"),
2172 (OpenAIServiceTier::Standard, "standard"),
2173 ];
2174
2175 for (service_tier, expected) in cases {
2176 assert_eq!(
2177 serde_json::to_value(service_tier).expect("service tier should serialize"),
2178 json!(expected)
2179 );
2180 }
2181
2182 assert_eq!(
2183 serde_json::to_value(OpenAIServiceTier::Other(
2184 "provider_experimental".to_string()
2185 ))
2186 .expect("provider-specific service tier should serialize"),
2187 json!("provider_experimental")
2188 );
2189 }
2190
2191 #[test]
2192 fn responses_usage_token_usage_preserves_reasoning_tokens() {
2193 let usage = ResponsesUsage {
2194 input_tokens: 100,
2195 input_tokens_details: Some(InputTokensDetails { cached_tokens: 25 }),
2196 output_tokens: 50,
2197 output_tokens_details: Some(OutputTokensDetails {
2198 reasoning_tokens: 15,
2199 }),
2200 total_tokens: 150,
2201 };
2202
2203 let token_usage = usage.token_usage();
2204
2205 assert_eq!(token_usage.input_tokens, 100);
2206 assert_eq!(token_usage.cached_input_tokens, 25);
2207 assert_eq!(token_usage.output_tokens, 50);
2208 assert_eq!(token_usage.reasoning_tokens, 15);
2209 assert_eq!(token_usage.total_tokens, 150);
2210 }
2211
2212 #[test]
2213 fn responses_usage_deserializes_without_output_token_details() {
2214 let usage: ResponsesUsage = serde_json::from_value(json!({
2215 "input_tokens": 100,
2216 "input_tokens_details": {
2217 "cached_tokens": 25
2218 },
2219 "output_tokens": 50,
2220 "total_tokens": 150
2221 }))
2222 .expect("usage should deserialize when output token details are omitted");
2223
2224 assert!(usage.output_tokens_details.is_none());
2225
2226 let token_usage = usage.token_usage();
2227
2228 assert_eq!(token_usage.input_tokens, 100);
2229 assert_eq!(token_usage.cached_input_tokens, 25);
2230 assert_eq!(token_usage.output_tokens, 50);
2231 assert_eq!(token_usage.reasoning_tokens, 0);
2232 assert_eq!(token_usage.total_tokens, 150);
2233 }
2234
2235 #[test]
2236 fn completion_response_accepts_top_level_reasoning_string() {
2237 let response: CompletionResponse = serde_json::from_value(json!({
2238 "id": "resp_123",
2239 "object": "response",
2240 "created_at": 0,
2241 "status": "completed",
2242 "model": "Qwen/Qwen3-4B",
2243 "reasoning": "thinking through the answer",
2244 "usage": {
2245 "input_tokens": 1,
2246 "output_tokens": 2,
2247 "total_tokens": 3
2248 },
2249 "output": [{
2250 "type": "message",
2251 "id": "msg_123",
2252 "status": "completed",
2253 "role": "assistant",
2254 "content": [{
2255 "type": "output_text",
2256 "annotations": [],
2257 "text": "done"
2258 }]
2259 }],
2260 "tools": []
2261 }))
2262 .expect("mistral.rs-style reasoning string should deserialize");
2263
2264 assert_eq!(
2265 response.provider_reasoning.as_deref(),
2266 Some("thinking through the answer")
2267 );
2268
2269 let completion: completion::CompletionResponse<CompletionResponse> =
2270 response.try_into().expect("response should convert");
2271 let items = completion.choice.iter().collect::<Vec<_>>();
2272 assert!(matches!(
2273 items[0],
2274 completion::AssistantContent::Reasoning(_)
2275 ));
2276 assert!(matches!(items[1], completion::AssistantContent::Text(_)));
2277 }
2278
2279 #[test]
2280 fn completion_response_accepts_reasoning_only_response() {
2281 let response: CompletionResponse = serde_json::from_value(json!({
2282 "id": "resp_123",
2283 "object": "response",
2284 "created_at": 0,
2285 "status": "completed",
2286 "model": "Qwen/Qwen3-4B",
2287 "reasoning": "thinking only",
2288 "usage": {
2289 "input_tokens": 1,
2290 "output_tokens": 2,
2291 "total_tokens": 3
2292 },
2293 "output": [],
2294 "tools": []
2295 }))
2296 .expect("reasoning-only response should deserialize");
2297
2298 let completion: completion::CompletionResponse<CompletionResponse> = response
2299 .try_into()
2300 .expect("reasoning-only response should convert");
2301 let items = completion.choice.iter().collect::<Vec<_>>();
2302
2303 assert_eq!(items.len(), 1);
2304 assert!(matches!(
2305 items[0],
2306 completion::AssistantContent::Reasoning(_)
2307 ));
2308 }
2309
2310 #[test]
2311 fn completion_response_rejects_empty_response_without_reasoning() {
2312 let response: CompletionResponse = serde_json::from_value(json!({
2313 "id": "resp_123",
2314 "object": "response",
2315 "created_at": 0,
2316 "status": "completed",
2317 "model": "Qwen/Qwen3-4B",
2318 "output": [],
2319 "tools": []
2320 }))
2321 .expect("empty response shape should deserialize");
2322
2323 let err = completion::CompletionResponse::<CompletionResponse>::try_from(response)
2324 .expect_err("empty response without reasoning should be rejected");
2325
2326 assert!(
2327 err.to_string()
2328 .contains("Response contained no message or tool call")
2329 );
2330 }
2331
2332 #[test]
2333 fn completion_response_ignores_top_level_reasoning_object_as_text() {
2334 let response: CompletionResponse = serde_json::from_value(json!({
2335 "id": "resp_123",
2336 "object": "response",
2337 "created_at": 0,
2338 "status": "completed",
2339 "model": "Qwen/Qwen3-4B",
2340 "reasoning": {
2341 "effort": "high"
2342 },
2343 "output": [{
2344 "type": "message",
2345 "id": "msg_123",
2346 "status": "completed",
2347 "role": "assistant",
2348 "content": [{
2349 "type": "output_text",
2350 "annotations": [],
2351 "text": "done"
2352 }]
2353 }],
2354 "tools": []
2355 }))
2356 .expect("object-shaped reasoning should be tolerated");
2357
2358 assert!(response.provider_reasoning.is_none());
2359
2360 let completion: completion::CompletionResponse<CompletionResponse> =
2361 response.try_into().expect("response should convert");
2362 let items = completion.choice.iter().collect::<Vec<_>>();
2363 assert_eq!(items.len(), 1);
2364 assert!(matches!(items[0], completion::AssistantContent::Text(_)));
2365 }
2366
2367 #[test]
2368 fn completion_response_does_not_duplicate_structured_reasoning() {
2369 let response: CompletionResponse = serde_json::from_value(json!({
2370 "id": "resp_123",
2371 "object": "response",
2372 "created_at": 0,
2373 "status": "completed",
2374 "model": "gpt-5.4",
2375 "reasoning": "provider top-level text",
2376 "output": [{
2377 "type": "reasoning",
2378 "id": "rs_123",
2379 "summary": [{
2380 "type": "summary_text",
2381 "text": "structured summary"
2382 }]
2383 }, {
2384 "type": "message",
2385 "id": "msg_123",
2386 "status": "completed",
2387 "role": "assistant",
2388 "content": [{
2389 "type": "output_text",
2390 "annotations": [],
2391 "text": "done"
2392 }]
2393 }],
2394 "tools": []
2395 }))
2396 .expect("response should deserialize");
2397
2398 let completion: completion::CompletionResponse<CompletionResponse> =
2399 response.try_into().expect("response should convert");
2400 let reasoning_count = completion
2401 .choice
2402 .iter()
2403 .filter(|item| matches!(item, completion::AssistantContent::Reasoning(_)))
2404 .count();
2405
2406 assert_eq!(reasoning_count, 1);
2407 }
2408
2409 #[test]
2410 fn idless_reasoning_is_skipped_when_converting_responses_history() {
2411 let assistant = message::Message::Assistant {
2412 id: Some("msg_123".to_string()),
2413 content: OneOrMany::one(message::AssistantContent::Reasoning(
2414 message::Reasoning::new("provider reasoning"),
2415 )),
2416 };
2417
2418 let converted = Vec::<Message>::try_from(assistant)
2419 .expect("idless reasoning should degrade gracefully");
2420
2421 assert!(converted.is_empty());
2422 }
2423
2424 #[test]
2425 fn idless_reasoning_only_is_skipped_without_empty_input_item() {
2426 let assistant = completion::Message::Assistant {
2427 id: None,
2428 content: OneOrMany::one(message::AssistantContent::Reasoning(
2429 message::Reasoning::new("provider reasoning"),
2430 )),
2431 };
2432
2433 let converted = Vec::<InputItem>::try_from(assistant)
2434 .expect("idless reasoning should degrade gracefully");
2435
2436 assert!(converted.is_empty());
2437 }
2438
2439 #[test]
2440 fn idless_reasoning_plus_text_preserves_text_for_responses_history() {
2441 let assistant = message::Message::Assistant {
2442 id: Some("msg_123".to_string()),
2443 content: OneOrMany::many(vec![
2444 message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
2445 message::AssistantContent::Text(Text::new("final answer")),
2446 ])
2447 .expect("assistant content should be non-empty"),
2448 };
2449
2450 let converted =
2451 Vec::<Message>::try_from(assistant).expect("assistant history should convert");
2452
2453 assert_eq!(converted.len(), 1);
2454 let Message::Assistant { content, .. } = &converted[0] else {
2455 panic!("expected assistant message");
2456 };
2457 assert!(matches!(
2458 content.first_ref(),
2459 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
2460 ));
2461 }
2462
2463 #[test]
2464 fn completion_history_idless_reasoning_plus_text_preserves_text_input_item() {
2465 let assistant = completion::Message::Assistant {
2466 id: Some("msg_123".to_string()),
2467 content: OneOrMany::many(vec![
2468 message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
2469 message::AssistantContent::Text(Text::new("final answer")),
2470 ])
2471 .expect("assistant content should be non-empty"),
2472 };
2473
2474 let converted =
2475 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
2476
2477 assert_eq!(converted.len(), 1);
2478 assert!(matches!(converted[0].role, Some(Role::Assistant)));
2479 let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
2480 panic!("expected assistant message input item");
2481 };
2482 assert!(matches!(
2483 content.first_ref(),
2484 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
2485 ));
2486 }
2487
2488 #[test]
2489 fn assistant_text_without_idless_reasoning_replays_as_output_text() {
2490 let assistant = completion::Message::Assistant {
2491 id: Some("msg_123".to_string()),
2492 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
2493 };
2494
2495 let converted =
2496 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
2497
2498 assert_eq!(converted.len(), 1);
2499 let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
2500 panic!("expected assistant message input item");
2501 };
2502 assert!(matches!(
2503 content.first_ref(),
2504 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
2505 ));
2506 }
2507
2508 #[test]
2509 fn idless_completion_assistant_text_replays_as_easy_input_message() {
2510 let assistant = completion::Message::Assistant {
2511 id: None,
2512 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
2513 };
2514
2515 let converted =
2516 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
2517
2518 assert_eq!(converted.len(), 1);
2519 assert!(matches!(converted[0].role, Some(Role::Assistant)));
2520 let InputContent::Message(Message::AssistantInput { content, .. }) = &converted[0].input
2521 else {
2522 panic!("expected assistant input message item");
2523 };
2524 assert_eq!(content, "final answer");
2525
2526 let serialized =
2527 serde_json::to_value(&converted[0]).expect("input item should serialize to JSON");
2528 assert_eq!(serialized["type"], json!("message"));
2529 assert_eq!(serialized["role"], json!("assistant"));
2530 assert_eq!(serialized["content"], json!("final answer"));
2531 assert!(serialized.get("id").is_none());
2532 assert!(serialized.get("status").is_none());
2533 }
2534
2535 #[test]
2536 fn idless_message_assistant_text_replays_as_easy_input_message() {
2537 let assistant = message::Message::Assistant {
2538 id: None,
2539 content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
2540 };
2541
2542 let converted =
2543 Vec::<Message>::try_from(assistant).expect("assistant history should convert");
2544
2545 assert_eq!(converted.len(), 1);
2546 let Message::AssistantInput { content, .. } = &converted[0] else {
2547 panic!("expected assistant input message");
2548 };
2549 assert_eq!(content, "final answer");
2550
2551 let serialized = serde_json::to_value(&converted[0])
2552 .expect("assistant message should serialize to JSON");
2553 assert_eq!(serialized["role"], json!("assistant"));
2554 assert_eq!(serialized["content"], json!("final answer"));
2555 assert!(serialized.get("id").is_none());
2556 assert!(serialized.get("status").is_none());
2557 }
2558
2559 #[test]
2560 fn structured_reasoning_with_id_still_converts_for_responses_history() {
2561 let assistant = message::Message::Assistant {
2562 id: Some("msg_123".to_string()),
2563 content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
2564 id: Some("rs_123".to_string()),
2565 content: vec![message::ReasoningContent::Summary(
2566 "structured summary".to_string(),
2567 )],
2568 })),
2569 };
2570
2571 let converted =
2572 Vec::<Message>::try_from(assistant).expect("structured reasoning should still convert");
2573
2574 assert_eq!(converted.len(), 1);
2575 let Message::Assistant { content, .. } = &converted[0] else {
2576 panic!("expected assistant message");
2577 };
2578 assert!(matches!(
2579 content.first_ref(),
2580 AssistantContentType::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
2581 ));
2582 }
2583
2584 #[test]
2585 fn structured_reasoning_with_id_still_converts_to_input_item() {
2586 let assistant = completion::Message::Assistant {
2587 id: Some("msg_123".to_string()),
2588 content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
2589 id: Some("rs_123".to_string()),
2590 content: vec![message::ReasoningContent::Summary(
2591 "structured summary".to_string(),
2592 )],
2593 })),
2594 };
2595
2596 let converted =
2597 Vec::<InputItem>::try_from(assistant).expect("structured reasoning should convert");
2598
2599 assert_eq!(converted.len(), 1);
2600 assert!(converted[0].role.is_none());
2601 assert!(matches!(
2602 &converted[0].input,
2603 InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
2604 ));
2605 }
2606
2607 #[test]
2608 fn assistant_reasoning_text_tool_call_convert_in_responses_replay_order() {
2609 let assistant = completion::Message::Assistant {
2610 id: Some("msg_123".to_string()),
2611 content: OneOrMany::many(vec![
2612 message::AssistantContent::Reasoning(message::Reasoning {
2613 id: Some("rs_123".to_string()),
2614 content: vec![message::ReasoningContent::Summary(
2615 "structured summary".to_string(),
2616 )],
2617 }),
2618 message::AssistantContent::Text(Text::new("final answer")),
2619 message::AssistantContent::tool_call_with_call_id(
2620 "fc_123",
2621 "call_123".to_string(),
2622 "lookup",
2623 json!({"query": "rig"}),
2624 ),
2625 ])
2626 .expect("assistant content should be non-empty"),
2627 };
2628
2629 let converted =
2630 Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
2631
2632 assert_eq!(converted.len(), 3);
2633 assert!(converted[0].role.is_none());
2634 assert!(matches!(
2635 &converted[0].input,
2636 InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
2637 ));
2638
2639 assert!(matches!(converted[1].role, Some(Role::Assistant)));
2640 let InputContent::Message(Message::Assistant { content, id, .. }) = &converted[1].input
2641 else {
2642 panic!("expected assistant output message");
2643 };
2644 assert_eq!(id, "msg_123");
2645 assert!(matches!(
2646 content.first_ref(),
2647 AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. }))
2648 if text == "final answer"
2649 ));
2650
2651 assert!(converted[2].role.is_none());
2652 let InputContent::FunctionCall(OutputFunctionCall {
2653 id, call_id, name, ..
2654 }) = &converted[2].input
2655 else {
2656 panic!("expected function call input item");
2657 };
2658 assert_eq!(id, "fc_123");
2659 assert_eq!(call_id, "call_123");
2660 assert_eq!(name, "lookup");
2661 }
2662
2663 #[test]
2664 fn mocked_second_turn_request_omits_unreplayable_reasoning() {
2665 let request = crate::completion::CompletionRequest {
2666 model: None,
2667 preamble: Some("You are concise.".to_string()),
2668 chat_history: OneOrMany::many(vec![
2669 completion::Message::User {
2670 content: OneOrMany::one(message::UserContent::Text(Text::new(
2671 "Think briefly, then answer.",
2672 ))),
2673 },
2674 completion::Message::Assistant {
2675 id: Some("msg_123".to_string()),
2676 content: OneOrMany::many(vec![
2677 message::AssistantContent::Reasoning(message::Reasoning::new(
2678 "provider reasoning",
2679 )),
2680 message::AssistantContent::Text(Text::new("final answer")),
2681 ])
2682 .expect("assistant content should be non-empty"),
2683 },
2684 completion::Message::Assistant {
2685 id: None,
2686 content: OneOrMany::many(vec![
2687 message::AssistantContent::Reasoning(message::Reasoning::new(
2688 "provider reasoning only",
2689 )),
2690 message::AssistantContent::Text(Text::new("")),
2691 ])
2692 .expect("assistant content should be non-empty"),
2693 },
2694 completion::Message::User {
2695 content: OneOrMany::one(message::UserContent::Text(Text::new(
2696 "/no_think Reply with exactly: OK",
2697 ))),
2698 },
2699 ])
2700 .expect("history should be non-empty"),
2701 documents: Vec::new(),
2702 tools: Vec::new(),
2703 temperature: None,
2704 max_tokens: Some(64),
2705 tool_choice: None,
2706 additional_params: None,
2707 output_schema: None,
2708 };
2709
2710 let request = CompletionRequest::try_from(("Qwen/Qwen3-4B".to_string(), request))
2711 .expect("request should convert");
2712 let value = serde_json::to_value(&request).expect("request should serialize");
2713 let input = value["input"]
2714 .as_array()
2715 .expect("mocked multi-turn request should serialize input as an array");
2716
2717 assert!(!input.iter().any(|item| {
2718 item.get("type") == Some(&json!("reasoning")) && item.get("id").is_none()
2719 }));
2720 assert!(!input.iter().any(|item| {
2721 item.get("role") == Some(&json!("assistant"))
2722 && item
2723 .get("content")
2724 .and_then(Value::as_array)
2725 .is_some_and(Vec::is_empty)
2726 }));
2727
2728 let assistant_items = input
2729 .iter()
2730 .filter(|item| item.get("role") == Some(&json!("assistant")))
2731 .collect::<Vec<_>>();
2732
2733 assert_eq!(assistant_items.len(), 1);
2734 assert_eq!(assistant_items[0]["content"][0]["type"], "output_text");
2735 assert_eq!(assistant_items[0]["content"][0]["text"], "final answer");
2736 }
2737
2738 #[test]
2739 fn responses_usage_add_preserves_rhs_details_when_lhs_details_are_absent() {
2740 let lhs = ResponsesUsage {
2741 input_tokens: 10,
2742 input_tokens_details: None,
2743 output_tokens: 20,
2744 output_tokens_details: None,
2745 total_tokens: 30,
2746 };
2747 let rhs = ResponsesUsage {
2748 input_tokens: 3,
2749 input_tokens_details: Some(InputTokensDetails { cached_tokens: 2 }),
2750 output_tokens: 5,
2751 output_tokens_details: Some(OutputTokensDetails {
2752 reasoning_tokens: 4,
2753 }),
2754 total_tokens: 8,
2755 };
2756
2757 let usage = lhs + rhs;
2758 let token_usage = usage.token_usage();
2759
2760 assert_eq!(token_usage.input_tokens, 13);
2761 assert_eq!(token_usage.cached_input_tokens, 2);
2762 assert_eq!(token_usage.output_tokens, 25);
2763 assert_eq!(token_usage.reasoning_tokens, 4);
2764 assert_eq!(token_usage.total_tokens, 38);
2765 }
2766
2767 #[test]
2768 fn file_id_document_serializes_as_input_file_content() {
2769 let message = message::Message::User {
2770 content: OneOrMany::one(message::UserContent::Document(message::Document {
2771 data: DocumentSourceKind::FileId("file_abc".to_string()),
2772 media_type: None,
2773 additional_params: None,
2774 })),
2775 };
2776
2777 let converted: Vec<Message> = message.try_into().expect("conversion should succeed");
2778 let Message::User { content, .. } = &converted[0] else {
2779 panic!("expected user message");
2780 };
2781
2782 let json = serde_json::to_value(content.first_ref()).expect("serialize content");
2783
2784 assert_eq!(json["type"], "input_file");
2785 assert_eq!(json["file_id"], "file_abc");
2786 assert!(json.get("file_data").is_none());
2787 assert!(json.get("file_url").is_none());
2788 }
2789
2790 #[test]
2791 fn file_id_document_serializes_as_input_item_content() {
2792 let message = completion::Message::User {
2793 content: OneOrMany::one(message::UserContent::Document(message::Document {
2794 data: DocumentSourceKind::FileId("file_abc".to_string()),
2795 media_type: None,
2796 additional_params: None,
2797 })),
2798 };
2799
2800 let converted: Vec<InputItem> = message.try_into().expect("conversion should succeed");
2801 let json = serde_json::to_value(&converted[0]).expect("serialize input item");
2802
2803 assert_eq!(json["type"], "message");
2804 assert_eq!(json["role"], "user");
2805 assert_eq!(json["content"][0]["type"], "input_file");
2806 assert_eq!(json["content"][0]["file_id"], "file_abc");
2807 assert!(json["content"][0].get("file_data").is_none());
2808 assert!(json["content"][0].get("file_url").is_none());
2809 }
2810}