1use super::client::ApiResponse;
6use crate::completion::NormalizeCompletionResponse;
7use crate::completion::{CompletionError, CompletionRequest as CoreCompletionRequest};
8use crate::http_client::HttpClientExt;
9use crate::json_utils::string_or_vec;
10use crate::message::{AudioMediaType, DocumentSourceKind, ImageDetail, MimeType};
11use crate::providers::internal::completion_send::send_completion;
12use crate::telemetry::{
13 CompletionOperation, CompletionSpanBuilder, ProviderResponseExt, SpanCombinator,
14};
15use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
16use crate::{completion, json_utils, message};
17use serde::{Deserialize, Serialize, Serializer};
18use std::convert::Infallible;
19use std::fmt;
20use tracing::Instrument;
21
22use std::str::FromStr;
23
24pub mod streaming;
25
26fn serialize_user_content<S>(content: &[UserContent], serializer: S) -> Result<S::Ok, S::Error>
29where
30 S: Serializer,
31{
32 if content.len() == 1
33 && let Some(UserContent::Text { text, .. }) = content.first()
34 {
35 return serializer.serialize_str(text);
36 }
37 content.serialize(serializer)
38}
39
40pub const GPT_5_6: &str = "gpt-5.6";
42
43pub const GPT_5_6_SOL: &str = "gpt-5.6-sol";
45
46pub const GPT_5_6_TERRA: &str = "gpt-5.6-terra";
48
49pub const GPT_5_6_LUNA: &str = "gpt-5.6-luna";
51
52pub const GPT_5_5: &str = "gpt-5.5";
54
55pub const GPT_5_2: &str = "gpt-5.2";
57
58pub const GPT_5_1: &str = "gpt-5.1";
60
61pub const GPT_5: &str = "gpt-5";
63pub const GPT_5_MINI: &str = "gpt-5-mini";
65pub const GPT_5_NANO: &str = "gpt-5-nano";
67
68pub const GPT_4_5_PREVIEW: &str = "gpt-4.5-preview";
70pub const GPT_4_5_PREVIEW_2025_02_27: &str = "gpt-4.5-preview-2025-02-27";
72pub const GPT_4O_2024_11_20: &str = "gpt-4o-2024-11-20";
74pub const GPT_4O: &str = "gpt-4o";
76pub const GPT_4O_MINI: &str = "gpt-4o-mini";
78pub const GPT_4O_2024_05_13: &str = "gpt-4o-2024-05-13";
80pub const GPT_4_TURBO: &str = "gpt-4-turbo";
82pub const GPT_4_TURBO_2024_04_09: &str = "gpt-4-turbo-2024-04-09";
84pub const GPT_4_TURBO_PREVIEW: &str = "gpt-4-turbo-preview";
86pub const GPT_4_0125_PREVIEW: &str = "gpt-4-0125-preview";
88pub const GPT_4_1106_PREVIEW: &str = "gpt-4-1106-preview";
90pub const GPT_4_VISION_PREVIEW: &str = "gpt-4-vision-preview";
92pub const GPT_4_1106_VISION_PREVIEW: &str = "gpt-4-1106-vision-preview";
94pub const GPT_4: &str = "gpt-4";
96pub const GPT_4_0613: &str = "gpt-4-0613";
98pub const GPT_4_32K: &str = "gpt-4-32k";
100pub const GPT_4_32K_0613: &str = "gpt-4-32k-0613";
102
103pub const O4_MINI_2025_04_16: &str = "o4-mini-2025-04-16";
105pub const O4_MINI: &str = "o4-mini";
107pub const O3: &str = "o3";
109pub const O3_MINI: &str = "o3-mini";
111pub const O3_MINI_2025_01_31: &str = "o3-mini-2025-01-31";
113pub const O1_PRO: &str = "o1-pro";
115pub const O1: &str = "o1";
117pub const O1_2024_12_17: &str = "o1-2024-12-17";
119pub const O1_PREVIEW: &str = "o1-preview";
121pub const O1_PREVIEW_2024_09_12: &str = "o1-preview-2024-09-12";
123pub const O1_MINI: &str = "o1-mini";
125pub const O1_MINI_2024_09_12: &str = "o1-mini-2024-09-12";
127
128pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
130pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
132pub const GPT_4_1_2025_04_14: &str = "gpt-4.1-2025-04-14";
134pub const GPT_4_1: &str = "gpt-4.1";
136
137#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
138#[serde(tag = "role", rename_all = "lowercase")]
139pub enum Message {
140 #[serde(alias = "developer")]
141 System {
142 #[serde(deserialize_with = "string_or_vec")]
143 content: Vec<SystemContent>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 name: Option<String>,
146 },
147 User {
148 #[serde(
149 deserialize_with = "string_or_vec",
150 serialize_with = "serialize_user_content"
151 )]
152 content: Vec<UserContent>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 name: Option<String>,
155 },
156 #[serde(alias = "model")]
159 Assistant {
160 #[serde(
161 default,
162 deserialize_with = "json_utils::string_or_vec",
163 skip_serializing_if = "Vec::is_empty",
164 serialize_with = "serialize_assistant_content_vec"
165 )]
166 content: Vec<AssistantContent>,
167 #[serde(
172 skip_serializing_if = "Option::is_none",
173 rename = "reasoning_content",
174 alias = "reasoning"
175 )]
176 reasoning: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 refusal: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 audio: Option<AudioAssistant>,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 name: Option<String>,
183 #[serde(
184 default,
185 deserialize_with = "json_utils::null_or_default",
186 skip_serializing_if = "Vec::is_empty"
187 )]
188 tool_calls: Vec<ToolCall>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 reasoning_details: Vec<ReasoningDetails>,
194 #[serde(default, skip_serializing)]
198 images: Vec<ResponseImage>,
199 },
200 #[serde(rename = "tool")]
201 ToolResult {
202 tool_call_id: String,
203 content: ToolResultContentValue,
204 },
205}
206
207impl Message {
208 pub fn system(content: &str) -> Self {
209 Message::System {
210 content: vec![content.to_owned().into()],
211 name: None,
212 }
213 }
214}
215
216fn history_contains_tool_result(messages: &[Message]) -> bool {
217 messages
218 .iter()
219 .any(|message| matches!(message, Message::ToolResult { .. }))
220}
221
222#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
223pub struct AudioAssistant {
224 pub id: String,
225}
226
227#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
233#[serde(tag = "type", rename_all = "snake_case")]
234pub enum ReasoningDetails {
235 #[serde(rename = "reasoning.summary")]
236 Summary {
237 id: Option<String>,
238 format: Option<String>,
239 index: Option<usize>,
240 summary: String,
241 },
242 #[serde(rename = "reasoning.encrypted")]
243 Encrypted {
244 id: Option<String>,
245 format: Option<String>,
246 index: Option<usize>,
247 data: String,
248 },
249 #[serde(rename = "reasoning.text")]
250 Text {
251 id: Option<String>,
252 format: Option<String>,
253 index: Option<usize>,
254 text: Option<String>,
255 signature: Option<String>,
256 },
257}
258
259#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
264pub struct ResponseImage {
265 pub image_url: ImageUrl,
266}
267
268#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
269pub struct SystemContent {
270 #[serde(default)]
271 pub r#type: SystemContentType,
272 pub text: String,
273}
274
275#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
276#[serde(rename_all = "lowercase")]
277pub enum SystemContentType {
278 #[default]
279 Text,
280}
281
282#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
283#[serde(tag = "type", rename_all = "lowercase")]
284pub enum AssistantContent {
285 Text { text: String },
286 Refusal { refusal: String },
287}
288
289impl From<AssistantContent> for completion::AssistantContent {
290 fn from(value: AssistantContent) -> Self {
291 match value {
292 AssistantContent::Text { text, .. } => completion::AssistantContent::text(text),
293 AssistantContent::Refusal { refusal } => completion::AssistantContent::text(refusal),
294 }
295 }
296}
297
298#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
299#[serde(tag = "type", rename_all = "lowercase")]
300pub enum UserContent {
301 Text {
302 text: String,
303 },
304 #[serde(rename = "image_url")]
305 Image {
306 image_url: ImageUrl,
307 },
308 #[serde(rename = "input_audio", alias = "audio")]
311 Audio {
312 input_audio: InputAudio,
313 },
314 File {
320 file: FileData,
321 },
322 #[serde(rename = "video_url")]
325 Video {
326 video_url: VideoUrl,
327 },
328}
329
330#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
331pub struct ImageUrl {
332 pub url: String,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub detail: Option<ImageDetail>,
337}
338
339#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
344pub struct VideoUrl {
345 pub url: String,
346}
347
348#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
349pub struct InputAudio {
350 pub data: String,
351 pub format: AudioMediaType,
352}
353
354#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
360pub struct FileData {
361 #[serde(skip_serializing_if = "Option::is_none")]
364 pub file_data: Option<String>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub file_id: Option<String>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub filename: Option<String>,
371}
372
373#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
374pub struct ToolResultContent {
375 #[serde(default)]
376 r#type: ToolResultContentType,
377 pub text: String,
378}
379
380#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
381#[serde(rename_all = "lowercase")]
382pub enum ToolResultContentType {
383 #[default]
384 Text,
385}
386
387impl FromStr for ToolResultContent {
388 type Err = Infallible;
389
390 fn from_str(s: &str) -> Result<Self, Self::Err> {
391 Ok(s.to_owned().into())
392 }
393}
394
395impl From<String> for ToolResultContent {
396 fn from(s: String) -> Self {
397 ToolResultContent {
398 r#type: ToolResultContentType::default(),
399 text: s,
400 }
401 }
402}
403
404#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
405#[serde(untagged)]
406pub enum ToolResultContentValue {
407 Array(Vec<ToolResultContent>),
408 String(String),
409}
410
411impl ToolResultContentValue {
412 pub fn from_string(s: String, use_array_format: bool) -> Self {
413 if use_array_format {
414 ToolResultContentValue::Array(vec![ToolResultContent::from(s)])
415 } else {
416 ToolResultContentValue::String(s)
417 }
418 }
419
420 pub fn as_text(&self) -> String {
421 match self {
422 ToolResultContentValue::Array(arr) => arr
423 .iter()
424 .map(|c| c.text.clone())
425 .collect::<Vec<_>>()
426 .join("\n"),
427 ToolResultContentValue::String(s) => s.clone(),
428 }
429 }
430
431 pub fn to_array(&self) -> Self {
432 match self {
433 ToolResultContentValue::Array(_) => self.clone(),
434 ToolResultContentValue::String(s) => {
435 ToolResultContentValue::Array(vec![ToolResultContent::from(s.clone())])
436 }
437 }
438 }
439}
440
441#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
442pub struct ToolCall {
443 pub id: String,
444 #[serde(default)]
445 pub r#type: ToolType,
446 pub function: Function,
447}
448
449#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
450#[serde(rename_all = "lowercase")]
451pub enum ToolType {
452 #[default]
453 Function,
454}
455
456#[derive(Debug, Deserialize, Serialize, Clone)]
458pub struct FunctionDefinition {
459 pub name: String,
460 pub description: String,
461 pub parameters: serde_json::Value,
462 #[serde(skip_serializing_if = "Option::is_none")]
463 pub strict: Option<bool>,
464}
465
466#[derive(Debug, Deserialize, Serialize, Clone)]
467pub struct ToolDefinition {
468 pub r#type: String,
469 pub function: FunctionDefinition,
470}
471
472impl From<completion::ToolDefinition> for ToolDefinition {
473 fn from(tool: completion::ToolDefinition) -> Self {
474 Self {
475 r#type: "function".into(),
476 function: FunctionDefinition {
477 name: tool.name,
478 description: tool.description,
479 parameters: tool.parameters,
480 strict: None,
481 },
482 }
483 }
484}
485
486impl ToolDefinition {
487 pub fn with_strict(mut self) -> Self {
490 self.function.strict = Some(true);
491 super::sanitize_schema(&mut self.function.parameters);
492 self
493 }
494}
495
496#[derive(Default, Clone, Debug, PartialEq)]
497pub enum ToolChoice {
498 #[default]
499 Auto,
500 None,
501 Required,
502 Function {
505 name: String,
506 },
507}
508
509#[derive(Deserialize, Serialize)]
510struct ToolChoiceFunctionName {
511 name: String,
512}
513
514#[derive(Deserialize, Serialize)]
515#[serde(tag = "type", rename_all = "snake_case")]
516enum ToolChoiceFunctionRepr {
517 Function { function: ToolChoiceFunctionName },
518}
519
520impl Serialize for ToolChoice {
521 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
522 match self {
523 Self::Auto => serializer.serialize_str("auto"),
524 Self::None => serializer.serialize_str("none"),
525 Self::Required => serializer.serialize_str("required"),
526 Self::Function { name } => ToolChoiceFunctionRepr::Function {
527 function: ToolChoiceFunctionName { name: name.clone() },
528 }
529 .serialize(serializer),
530 }
531 }
532}
533
534impl<'de> Deserialize<'de> for ToolChoice {
535 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
536 #[derive(Deserialize)]
537 #[serde(untagged)]
538 enum Repr {
539 Mode(String),
540 Function(ToolChoiceFunctionRepr),
541 }
542
543 match Repr::deserialize(deserializer)? {
544 Repr::Mode(mode) => match mode.as_str() {
545 "auto" => Ok(Self::Auto),
546 "none" => Ok(Self::None),
547 "required" => Ok(Self::Required),
548 other => Err(serde::de::Error::custom(format!(
549 "unknown tool_choice mode {other:?}"
550 ))),
551 },
552 Repr::Function(ToolChoiceFunctionRepr::Function {
553 function: ToolChoiceFunctionName { name },
554 }) => Ok(Self::Function { name }),
555 }
556 }
557}
558
559impl ToolChoice {
560 pub fn function(name: impl Into<String>) -> Self {
562 Self::Function { name: name.into() }
563 }
564}
565
566impl TryFrom<crate::message::ToolChoice> for ToolChoice {
567 type Error = CompletionError;
568 fn try_from(value: crate::message::ToolChoice) -> Result<Self, Self::Error> {
569 let res = match value {
570 message::ToolChoice::Specific { function_names } => {
571 let [name] = function_names.as_slice() else {
572 return Err(CompletionError::ProviderError(
573 "Provider only supports forcing exactly one specific tool".to_string(),
574 ));
575 };
576 Self::function(name)
577 }
578 message::ToolChoice::Auto => Self::Auto,
579 message::ToolChoice::None => Self::None,
580 message::ToolChoice::Required => Self::Required,
581 };
582
583 Ok(res)
584 }
585}
586
587#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
588pub struct Function {
589 pub name: String,
590 #[serde(
591 serialize_with = "json_utils::stringified_json::serialize",
592 deserialize_with = "json_utils::stringified_json::deserialize_maybe_stringified"
593 )]
594 pub arguments: serde_json::Value,
595}
596
597impl TryFrom<message::ToolResult> for Message {
598 type Error = message::MessageError;
599
600 fn try_from(value: message::ToolResult) -> Result<Self, Self::Error> {
601 let tool_call_id = value.wire_call_id().to_owned();
606 let parts = value
607 .content
608 .into_iter()
609 .map(|content| match content {
610 message::ToolResultContent::Text(message::Text { text, .. }) => Ok(ToolResultContent::from(text)),
611 message::ToolResultContent::Json { value } => Ok(ToolResultContent::from(value.to_string())),
612 message::ToolResultContent::Image(_) => Err(message::MessageError::ConversionError(
613 "OpenAI Chat Completions does not support images in tool results. Tool results must be text."
614 .into(),
615 )),
616 })
617 .collect::<Result<Vec<_>, _>>()?;
618
619 let content = match parts.as_slice() {
620 [part] => ToolResultContentValue::String(part.text.clone()),
621 _ => ToolResultContentValue::Array(parts),
622 };
623
624 Ok(Message::ToolResult {
625 tool_call_id,
626 content,
627 })
628 }
629}
630
631impl TryFrom<message::UserContent> for UserContent {
632 type Error = message::MessageError;
633
634 fn try_from(value: message::UserContent) -> Result<Self, Self::Error> {
635 match value {
636 message::UserContent::Text(message::Text { text, .. }) => Ok(UserContent::Text { text }),
637 message::UserContent::Image(message::Image {
638 data,
639 detail,
640 media_type,
641 ..
642 }) => match data {
643 DocumentSourceKind::Url(url) => Ok(UserContent::Image {
644 image_url: ImageUrl {
645 url,
646 detail: Some(detail.unwrap_or_default()),
649 },
650 }),
651 DocumentSourceKind::Base64(data) => {
652 let url = format!(
653 "data:{};base64,{}",
654 media_type.map(|i| i.to_mime_type()).ok_or(
655 message::MessageError::ConversionError(
656 "OpenAI Image URI must have media type".into()
657 )
658 )?,
659 data
660 );
661
662 let detail = Some(detail.unwrap_or_default());
663
664 Ok(UserContent::Image {
665 image_url: ImageUrl { url, detail },
666 })
667 }
668 DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
669 "Raw files not supported, encode as base64 first".into(),
670 )),
671 DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
672 "File IDs are not supported for images".into(),
673 )),
674 DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
675 "Document has no body".into(),
676 )),
677 doc => Err(message::MessageError::ConversionError(format!(
678 "Unsupported document type: {doc:?}"
679 ))),
680 },
681 message::UserContent::Document(message::Document {
682 data: DocumentSourceKind::FileId(file_id),
683 ..
684 }) => Ok(UserContent::File {
685 file: FileData {
686 file_data: None,
687 file_id: Some(file_id),
688 filename: None,
689 },
690 }),
691 message::UserContent::Document(message::Document {
692 data,
693 media_type: Some(message::DocumentMediaType::PDF),
694 ..
695 }) => match data {
696 DocumentSourceKind::Base64(b64) => Ok(UserContent::File {
697 file: FileData {
698 file_data: Some(format!("data:application/pdf;base64,{b64}")),
699 file_id: None,
700 filename: Some("document.pdf".to_string()),
701 },
702 }),
703 DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
704 "OpenAI chat completions does not accept URL files; use the Responses API or pass base64-encoded bytes".into(),
705 )),
706 DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
707 "Raw files not supported, encode as base64 first".into(),
708 )),
709 DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
710 "PDF documents must be base64-encoded, not raw strings".into(),
711 )),
712 DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
713 "File ID documents should be converted without media type constraints".into(),
714 )),
715 DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
716 "Document has no body".into(),
717 )),
718 },
719 message::UserContent::Document(message::Document { data, .. }) => {
720 if let DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text) = data {
721 Ok(UserContent::Text { text })
722 } else {
723 Err(message::MessageError::ConversionError(
724 "Documents must be base64 or a string".into(),
725 ))
726 }
727 }
728 message::UserContent::Audio(message::Audio {
729 data, media_type, ..
730 }) => match data {
731 DocumentSourceKind::Base64(data) => Ok(UserContent::Audio {
732 input_audio: InputAudio {
733 data,
734 format: match media_type {
735 Some(media_type) => media_type,
736 None => AudioMediaType::MP3,
737 },
738 },
739 }),
740 DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
741 "URLs are not supported for audio".into(),
742 )),
743 DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
744 "Raw files are not supported for audio".into(),
745 )),
746 DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
747 "File IDs are not supported for audio".into(),
748 )),
749 DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
750 "Audio has no body".into(),
751 )),
752 audio => Err(message::MessageError::ConversionError(format!(
753 "Unsupported audio type: {audio:?}"
754 ))),
755 },
756 message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
757 "Tool result is in unsupported format".into(),
758 )),
759 message::UserContent::Video(message::Video {
760 data, media_type, ..
761 }) => {
762 let url = match data {
763 DocumentSourceKind::Url(url) => url,
764 DocumentSourceKind::Base64(data) => {
765 let mime = media_type
766 .ok_or_else(|| {
767 message::MessageError::ConversionError(
768 "Video media type required for base64 encoding".into(),
769 )
770 })?
771 .to_mime_type();
772 format!("data:{mime};base64,{data}")
773 }
774 DocumentSourceKind::Raw(_) => {
775 return Err(message::MessageError::ConversionError(
776 "Raw bytes not supported for video, encode as base64 first".into(),
777 ));
778 }
779 DocumentSourceKind::FileId(_) => {
780 return Err(message::MessageError::ConversionError(
781 "File IDs are not supported for video".into(),
782 ));
783 }
784 DocumentSourceKind::String(_) => {
785 return Err(message::MessageError::ConversionError(
786 "String source not supported for video".into(),
787 ));
788 }
789 DocumentSourceKind::Unknown => {
790 return Err(message::MessageError::ConversionError(
791 "Video has no data".into(),
792 ));
793 }
794 };
795 Ok(UserContent::Video {
796 video_url: VideoUrl { url },
797 })
798 }
799 }
800 }
801}
802
803pub fn user_content_to_messages(
811 value: Vec<message::UserContent>,
812) -> Result<Vec<Message>, message::MessageError> {
813 fn flush_user_content(messages: &mut Vec<Message>, pending: &mut Vec<UserContent>) {
814 if pending.is_empty() {
819 return;
820 }
821
822 messages.push(Message::User {
823 content: std::mem::take(pending),
824 name: None,
825 });
826 }
827
828 let mut messages = Vec::new();
829 let mut pending = Vec::new();
830
831 for content in value {
832 match content {
833 message::UserContent::ToolResult(tool_result) => {
834 flush_user_content(&mut messages, &mut pending);
835 messages.push(tool_result.try_into()?);
836 }
837 content => pending.push(content.try_into()?),
838 }
839 }
840
841 flush_user_content(&mut messages, &mut pending);
842 Ok(messages)
843}
844
845pub fn assistant_content_to_messages(
850 value: Vec<message::AssistantContent>,
851) -> Result<Vec<Message>, message::MessageError> {
852 let mut text_content = Vec::new();
853 let mut tool_calls = Vec::new();
854 let mut reasoning_parts: Vec<String> = Vec::new();
858
859 for content in value {
860 match content {
861 message::AssistantContent::Text(text) => text_content.push(text),
862 message::AssistantContent::ToolCall(tool_call) => tool_calls.push(tool_call),
863 message::AssistantContent::Reasoning(reasoning) => {
864 let display = reasoning.display_text();
865 if !display.is_empty() {
866 reasoning_parts.push(display);
867 }
868 }
869 message::AssistantContent::Image(_) => {
870 return Err(message::MessageError::ConversionError(
871 "OpenAI assistant messages do not support image content in chat completions"
872 .into(),
873 ));
874 }
875 }
876 }
877
878 if text_content.is_empty() && tool_calls.is_empty() {
879 return Ok(vec![]);
880 }
881
882 Ok(vec![Message::Assistant {
883 content: text_content
884 .into_iter()
885 .map(|content| content.text.into())
886 .collect::<Vec<_>>(),
887 reasoning: if reasoning_parts.is_empty() {
888 None
889 } else {
890 Some(reasoning_parts.join("\n"))
891 },
892 refusal: None,
893 audio: None,
894 name: None,
895 tool_calls: tool_calls
896 .into_iter()
897 .map(|tool_call| tool_call.into())
898 .collect::<Vec<_>>(),
899 reasoning_details: Vec::new(),
900 images: Vec::new(),
901 }])
902}
903
904impl TryFrom<message::Message> for Vec<Message> {
905 type Error = message::MessageError;
906
907 fn try_from(message: message::Message) -> Result<Self, Self::Error> {
908 match message {
909 message::Message::System { content } => Ok(vec![Message::system(&content)]),
910 message::Message::User { content } => user_content_to_messages(content),
911 message::Message::Assistant { content, .. } => assistant_content_to_messages(content),
912 }
913 }
914}
915
916impl From<message::ToolCall> for ToolCall {
917 fn from(tool_call: message::ToolCall) -> Self {
918 Self {
919 id: tool_call.wire_call_id().to_owned(),
924 r#type: ToolType::default(),
925 function: Function {
926 name: tool_call.function.name,
927 arguments: tool_call.function.arguments,
928 },
929 }
930 }
931}
932
933impl From<ToolCall> for message::ToolCall {
934 fn from(tool_call: ToolCall) -> Self {
935 message::ToolCall::from_wire(
936 tool_call.id,
937 message::ToolFunction {
938 name: tool_call.function.name,
939 arguments: tool_call.function.arguments,
940 },
941 )
942 }
943}
944
945impl TryFrom<Message> for message::Message {
946 type Error = message::MessageError;
947
948 fn try_from(message: Message) -> Result<Self, Self::Error> {
949 Ok(match message {
950 Message::User { content, .. } => message::Message::User {
951 content: content.into_iter().map(|content| content.into()).collect(),
952 },
953 Message::Assistant {
954 content,
955 tool_calls,
956 reasoning,
957 refusal,
958 ..
959 } => {
960 let mut assistant_content = Vec::new();
961
962 if let Some(reasoning) = reasoning
963 && !reasoning.is_empty()
964 {
965 assistant_content.push(message::AssistantContent::reasoning(reasoning));
966 }
967
968 if let Some(refusal) = assistant_refusal_fallback(&content, refusal.as_deref()) {
974 assistant_content.push(message::AssistantContent::text(refusal));
975 } else {
976 assistant_content.extend(content.into_iter().map(|content| match content {
977 AssistantContent::Text { text, .. } => {
978 message::AssistantContent::text(text)
979 }
980 AssistantContent::Refusal { refusal } => {
981 message::AssistantContent::text(refusal)
982 }
983 }));
984 }
985
986 assistant_content.extend(
987 tool_calls
988 .into_iter()
989 .map(|tool_call| Ok(message::AssistantContent::ToolCall(tool_call.into())))
990 .collect::<Result<Vec<_>, _>>()?,
991 );
992
993 message::Message::Assistant {
994 id: None,
995 content: crate::message::require_non_empty(assistant_content, || {
996 message::MessageError::ConversionError(
997 "Neither `content` nor `tool_calls` was provided to the Message"
998 .to_owned(),
999 )
1000 })?,
1001 }
1002 }
1003
1004 Message::ToolResult {
1005 tool_call_id,
1006 content,
1007 } => message::Message::User {
1008 content: vec![message::UserContent::tool_result_from_wire(
1011 tool_call_id,
1012 "",
1013 vec![message::ToolResultContent::text(content.as_text())],
1014 )],
1015 },
1016
1017 Message::System { content, .. } => message::Message::User {
1020 content: content
1021 .into_iter()
1022 .map(|content| message::UserContent::text(content.text))
1023 .collect(),
1024 },
1025 })
1026 }
1027}
1028
1029impl From<UserContent> for message::UserContent {
1030 fn from(content: UserContent) -> Self {
1031 match content {
1032 UserContent::Text { text, .. } => message::UserContent::text(text),
1033 UserContent::Image { image_url } => {
1034 message::UserContent::image_url(image_url.url, None, image_url.detail)
1035 }
1036 UserContent::Audio { input_audio } => {
1037 message::UserContent::audio(input_audio.data, Some(input_audio.format))
1038 }
1039 UserContent::File {
1040 file: FileData {
1041 file_data, file_id, ..
1042 },
1043 } => match file_data {
1044 Some(data_url) => {
1045 let kind = match data_url.strip_prefix("data:application/pdf;base64,") {
1046 Some(b64) => DocumentSourceKind::Base64(b64.to_string()),
1047 None => DocumentSourceKind::String(data_url),
1048 };
1049 message::UserContent::Document(message::Document {
1050 data: kind,
1051 media_type: Some(message::DocumentMediaType::PDF),
1052 additional_params: None,
1053 })
1054 }
1055 None => match file_id {
1056 Some(id) => message::UserContent::Document(message::Document {
1057 data: DocumentSourceKind::FileId(id),
1058 media_type: None,
1059 additional_params: None,
1060 }),
1061 None => message::UserContent::text(String::new()),
1062 },
1063 },
1064 UserContent::Video { video_url } => {
1065 let decomposed = video_url
1066 .url
1067 .strip_prefix("data:")
1068 .and_then(|rest| rest.split_once(";base64,"))
1069 .and_then(|(mime, data)| {
1070 crate::message::VideoMediaType::from_mime_type(mime)
1075 .map(|media_type| (media_type, data))
1076 });
1077 match decomposed {
1078 Some((media_type, data)) => message::UserContent::video(data, Some(media_type)),
1079 None => message::UserContent::video_url(video_url.url, None),
1080 }
1081 }
1082 }
1083 }
1084}
1085
1086impl From<String> for UserContent {
1087 fn from(s: String) -> Self {
1088 UserContent::Text { text: s }
1089 }
1090}
1091
1092impl From<&str> for UserContent {
1093 fn from(s: &str) -> Self {
1094 s.to_owned().into()
1095 }
1096}
1097
1098impl FromStr for UserContent {
1099 type Err = Infallible;
1100
1101 fn from_str(s: &str) -> Result<Self, Self::Err> {
1102 Ok(s.to_owned().into())
1103 }
1104}
1105
1106impl From<String> for AssistantContent {
1107 fn from(s: String) -> Self {
1108 AssistantContent::Text { text: s }
1109 }
1110}
1111
1112impl FromStr for AssistantContent {
1113 type Err = Infallible;
1114
1115 fn from_str(s: &str) -> Result<Self, Self::Err> {
1116 Ok(s.to_owned().into())
1117 }
1118}
1119impl From<String> for SystemContent {
1120 fn from(s: String) -> Self {
1121 SystemContent {
1122 r#type: SystemContentType::default(),
1123 text: s,
1124 }
1125 }
1126}
1127
1128impl FromStr for SystemContent {
1129 type Err = Infallible;
1130
1131 fn from_str(s: &str) -> Result<Self, Self::Err> {
1132 Ok(s.to_owned().into())
1133 }
1134}
1135
1136#[derive(Clone, Debug, Deserialize, Serialize)]
1137pub struct CompletionResponse {
1138 pub id: String,
1139 #[serde(default, deserialize_with = "json_utils::null_or_default")]
1143 pub object: String,
1144 #[serde(default, deserialize_with = "json_utils::null_or_default")]
1145 pub created: u64,
1146 pub model: String,
1147 pub system_fingerprint: Option<String>,
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub service_tier: Option<String>,
1151 #[serde(
1152 deserialize_with = "crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls"
1153 )]
1154 pub choices: Vec<Choice>,
1155 pub usage: Option<Usage>,
1156}
1157
1158impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
1165 fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
1166 use crate::providers::internal::openai_chat_completions_compatible as compat;
1167
1168 let usage = self
1169 .usage
1170 .as_ref()
1171 .map(crate::completion::Usage::from)
1172 .unwrap_or_default();
1173 compat::normalize_openai_response(
1174 provider,
1175 &self.choices,
1176 Some(self.id.as_str()),
1177 Some(self.model.as_str()),
1178 usage,
1179 |choice| choice.finish_reason.as_str(),
1180 |choice| match &choice.message {
1181 Message::Assistant {
1182 content: wire_content,
1183 tool_calls,
1184 reasoning,
1185 refusal,
1186 ..
1187 } => {
1188 let mut content = wire_content
1189 .iter()
1190 .filter_map(|c| {
1191 let s = match c {
1192 AssistantContent::Text { text, .. } => text,
1193 AssistantContent::Refusal { refusal } => refusal,
1194 };
1195 if s.is_empty() {
1196 None
1197 } else {
1198 Some(completion::AssistantContent::text(s))
1199 }
1200 })
1201 .collect::<Vec<_>>();
1202
1203 if let Some(refusal) =
1204 assistant_refusal_fallback(wire_content, refusal.as_deref())
1205 {
1206 content.push(completion::AssistantContent::text(refusal));
1207 }
1208
1209 if let Some(reasoning) = reasoning {
1210 content.push(completion::AssistantContent::reasoning(reasoning));
1214 }
1215
1216 content.extend(tool_calls.iter().map(|call| {
1217 completion::AssistantContent::tool_call(
1218 &call.id,
1219 &call.function.name,
1220 call.function.arguments.clone(),
1221 )
1222 }));
1223 Some(content)
1224 }
1225 _ => None,
1226 },
1227 )
1228 }
1229}
1230
1231impl ProviderResponseExt for CompletionResponse {
1232 type Usage = Usage;
1233
1234 fn get_response_id(&self) -> Option<String> {
1235 Some(self.id.to_owned())
1236 }
1237
1238 fn get_response_model_name(&self) -> Option<String> {
1239 Some(self.model.to_owned())
1240 }
1241
1242 fn get_text_response(&self) -> Option<String> {
1243 let response = self
1244 .choices
1245 .iter()
1246 .filter_map(|choice| assistant_message_text_response(&choice.message))
1247 .collect::<Vec<_>>()
1248 .join("\n");
1249
1250 if response.is_empty() {
1251 None
1252 } else {
1253 Some(response)
1254 }
1255 }
1256
1257 fn get_usage(&self) -> Option<Self::Usage> {
1258 self.usage.clone()
1259 }
1260}
1261
1262pub(crate) fn assistant_refusal_fallback<'a>(
1291 content: &[AssistantContent],
1292 refusal: Option<&'a str>,
1293) -> Option<&'a str> {
1294 let has_text = content.iter().any(|part| {
1295 !match part {
1296 AssistantContent::Text { text } => text,
1297 AssistantContent::Refusal { refusal } => refusal,
1298 }
1299 .is_empty()
1300 });
1301
1302 refusal.filter(|refusal| !has_text && !refusal.is_empty())
1303}
1304
1305pub(crate) fn assistant_message_text_response(message: &Message) -> Option<String> {
1306 let Message::Assistant {
1307 content, refusal, ..
1308 } = message
1309 else {
1310 return None;
1311 };
1312
1313 let mut segments = content
1314 .iter()
1315 .filter_map(|content| match content {
1316 AssistantContent::Text { text, .. } => (!text.is_empty()).then(|| text.clone()),
1317 AssistantContent::Refusal { refusal } => (!refusal.is_empty()).then(|| refusal.clone()),
1318 })
1319 .collect::<Vec<_>>();
1320
1321 if let Some(refusal) = assistant_refusal_fallback(content, refusal.as_deref()) {
1322 segments.push(refusal.to_owned());
1323 }
1324
1325 if segments.is_empty() {
1326 None
1327 } else {
1328 Some(segments.join("\n"))
1329 }
1330}
1331
1332#[derive(Clone, Debug, Serialize, Deserialize)]
1333pub struct Choice {
1334 #[serde(default, deserialize_with = "json_utils::null_or_default")]
1338 pub index: usize,
1339 pub message: Message,
1340 pub logprobs: Option<serde_json::Value>,
1341 #[serde(default, deserialize_with = "json_utils::null_or_default")]
1342 pub finish_reason: String,
1343}
1344
1345#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1346pub struct PromptTokensDetails {
1347 #[serde(default)]
1349 pub cached_tokens: usize,
1350}
1351
1352#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1353pub struct CompletionTokensDetails {
1354 #[serde(default)]
1356 pub reasoning_tokens: usize,
1357}
1358
1359#[derive(Clone, Debug, Deserialize, Serialize)]
1360pub struct Usage {
1361 pub prompt_tokens: usize,
1362 #[serde(default, skip_serializing_if = "Option::is_none")]
1363 pub completion_tokens: Option<usize>,
1364 pub total_tokens: usize,
1365 #[serde(skip_serializing_if = "Option::is_none")]
1366 pub prompt_tokens_details: Option<PromptTokensDetails>,
1367 #[serde(default, skip_serializing_if = "Option::is_none")]
1368 pub completion_tokens_details: Option<CompletionTokensDetails>,
1369 #[serde(default, skip_serializing_if = "Option::is_none")]
1370 pub queue_time: Option<f64>,
1371 #[serde(default, skip_serializing_if = "Option::is_none")]
1372 pub prompt_time: Option<f64>,
1373 #[serde(default, skip_serializing_if = "Option::is_none")]
1374 pub completion_time: Option<f64>,
1375 #[serde(default, skip_serializing_if = "Option::is_none")]
1376 pub total_time: Option<f64>,
1377}
1378
1379impl Usage {
1380 pub fn new() -> Self {
1381 Self {
1382 prompt_tokens: 0,
1383 completion_tokens: None,
1384 total_tokens: 0,
1385 prompt_tokens_details: None,
1386 completion_tokens_details: None,
1387 queue_time: None,
1388 prompt_time: None,
1389 completion_time: None,
1390 total_time: None,
1391 }
1392 }
1393}
1394
1395impl Default for Usage {
1396 fn default() -> Self {
1397 Self::new()
1398 }
1399}
1400
1401impl fmt::Display for Usage {
1402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1403 let Usage {
1404 prompt_tokens,
1405 total_tokens,
1406 ..
1407 } = self;
1408 write!(
1409 f,
1410 "Prompt tokens: {prompt_tokens} Total tokens: {total_tokens}"
1411 )
1412 }
1413}
1414
1415impl From<&Usage> for crate::completion::Usage {
1416 fn from(value: &Usage) -> crate::completion::Usage {
1417 value.to_normalized()
1418 }
1419}
1420
1421impl From<Usage> for crate::completion::Usage {
1422 fn from(value: Usage) -> crate::completion::Usage {
1423 value.to_normalized()
1424 }
1425}
1426
1427impl Usage {
1428 pub fn to_normalized(&self) -> crate::completion::Usage {
1430 let mut usage = crate::providers::internal::completion_usage(
1431 self.prompt_tokens as u64,
1432 self.completion_tokens
1433 .unwrap_or_else(|| self.total_tokens.saturating_sub(self.prompt_tokens))
1434 as u64,
1435 self.total_tokens as u64,
1436 self.prompt_tokens_details
1437 .as_ref()
1438 .map(|d| d.cached_tokens as u64)
1439 .unwrap_or(0),
1440 );
1441 usage.reasoning_tokens = self
1442 .completion_tokens_details
1443 .as_ref()
1444 .map(|d| d.reasoning_tokens as u64)
1445 .unwrap_or(0);
1446 usage
1447 }
1448}
1449
1450#[derive(Debug, Clone, Copy, Default)]
1453pub struct CompletionModelOptions {
1454 pub strict_tools: bool,
1456 pub tool_result_array_content: bool,
1458 pub prompt_caching: bool,
1460}
1461
1462pub trait OpenAICompatibleProvider: crate::client::Provider {
1475 const PROVIDER_NAME: &'static str;
1477
1478 const REQUEST_ID_HEADER: Option<&'static str> = None;
1483
1484 const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = false;
1489
1490 const SUPPORTS_TOOLS: bool = true;
1495
1496 const SUPPORTS_RESPONSE_FORMAT: bool = true;
1500
1501 const STREAM_INCLUDE_USAGE: bool = true;
1506
1507 fn map_streaming_finish_reason(
1514 &self,
1515 finish_reason: Option<&str>,
1516 _native_finish_reason: Option<&str>,
1517 ) -> Option<crate::completion::FinishReason> {
1518 finish_reason
1519 .filter(|reason| !reason.is_empty())
1520 .map(crate::providers::internal::openai_chat_completions_compatible::map_openai_finish_reason)
1521 }
1522
1523 fn requires_modern_output_cap(&self, model: &str) -> bool {
1546 let _ = model;
1547 false
1548 }
1549
1550 type StreamingUsage: Clone
1555 + Default
1556 + Into<crate::completion::Usage>
1557 + Serialize
1558 + serde::de::DeserializeOwned
1559 + Unpin
1560 + WasmCompatSend
1561 + WasmCompatSync
1562 + 'static;
1563
1564 type Response: serde::de::DeserializeOwned
1570 + Serialize
1571 + crate::telemetry::ProviderResponseExt<Usage: Into<crate::completion::Usage>>
1572 + crate::completion::NormalizeCompletionResponse
1573 + WasmCompatSend
1574 + WasmCompatSync;
1575
1576 fn completion_path(&self, model: &str) -> String {
1583 let _ = model;
1584 "/chat/completions".to_string()
1585 }
1586
1587 fn build_completion_request(
1592 &self,
1593 model: String,
1594 request: CoreCompletionRequest,
1595 options: CompletionModelOptions,
1596 ) -> Result<CompletionRequest, CompletionError> {
1597 CompletionRequest::try_from(OpenAIRequestParams {
1598 model,
1599 request,
1600 strict_tools: options.strict_tools,
1601 tool_result_array_content: options.tool_result_array_content,
1602 supports_response_format: Self::SUPPORTS_RESPONSE_FORMAT,
1603 supports_tools: Self::SUPPORTS_TOOLS,
1604 })
1605 }
1606
1607 fn prepare_request(&self, request: &mut CompletionRequest) -> Result<(), CompletionError> {
1611 let _ = request;
1612 Ok(())
1613 }
1614
1615 fn finalize_request_body(&self, body: &mut serde_json::Value) -> Result<(), CompletionError> {
1620 let _ = body;
1621 Ok(())
1622 }
1623
1624 fn finalize_request_body_with_options(
1628 &self,
1629 body: &mut serde_json::Value,
1630 options: CompletionModelOptions,
1631 ) -> Result<(), CompletionError> {
1632 let _ = options;
1633 self.finalize_request_body(body)
1634 }
1635
1636 fn streaming_detail_reasoning(
1646 &self,
1647 detail: &serde_json::Value,
1648 ) -> Option<(
1649 crate::streaming::StreamPartId,
1650 Option<crate::streaming::WireId>,
1651 crate::message::ReasoningContent,
1652 )> {
1653 let _ = detail;
1654 None
1655 }
1656
1657 fn streaming_reasoning_signature(&self, detail: &serde_json::Value) -> Option<String> {
1661 let _ = detail;
1662 None
1663 }
1664
1665 fn decorate_streaming_tool_call(
1673 &self,
1674 detail: &serde_json::Value,
1675 ) -> Option<crate::streaming::ToolCallDecoration> {
1676 let _ = detail;
1677 None
1678 }
1679}
1680
1681impl OpenAICompatibleProvider for super::OpenAICompletionsExt {
1682 const PROVIDER_NAME: &'static str = "openai";
1683 const REQUEST_ID_HEADER: Option<&'static str> = Some("x-request-id");
1684
1685 type StreamingUsage = Usage;
1686 type Response = CompletionResponse;
1687
1688 fn requires_modern_output_cap(&self, model: &str) -> bool {
1689 is_openai_reasoning_model(model)
1690 }
1691}
1692
1693pub(crate) fn is_openai_reasoning_model(model: &str) -> bool {
1704 fn is_numbered_gpt_family(model: &str, lowest: u32) -> bool {
1714 model
1715 .strip_prefix("gpt-")
1716 .and_then(|rest| rest.split(['.', '-']).next())
1717 .filter(|major| major.len() == 1)
1718 .and_then(|major| major.parse::<u32>().ok())
1719 .is_some_and(|major| major >= lowest)
1720 }
1721
1722 fn is_o_series(model: &str) -> bool {
1724 let mut chars = model.chars();
1725 chars.next() == Some('o')
1726 && chars.next().is_some_and(|digit| digit.is_ascii_digit())
1727 && chars
1728 .next()
1729 .is_none_or(|next| next == '-' || next.is_ascii_digit())
1730 }
1731
1732 is_numbered_gpt_family(model, 5) || is_o_series(model)
1733}
1734
1735pub(crate) fn request_body(
1742 request: &CompletionRequest,
1743 modern_output_cap: bool,
1744) -> Result<serde_json::Value, CompletionError> {
1745 let mut body = serde_json::to_value(request)?;
1746
1747 if modern_output_cap
1748 && let Some(object) = body.as_object_mut()
1749 && let Some(max_tokens) = object.remove("max_tokens")
1750 {
1751 object.entry("max_completion_tokens").or_insert(max_tokens);
1756 }
1757
1758 Ok(body)
1759}
1760
1761#[derive(Clone)]
1765pub struct GenericCompletionModel<Ext = super::OpenAICompletionsExt, H = reqwest::Client> {
1766 pub(crate) client: crate::client::Client<Ext, H>,
1767 pub model: String,
1768 pub(crate) strict_tools: bool,
1769 pub(crate) tool_result_array_content: bool,
1770 pub(crate) prompt_caching: bool,
1771}
1772
1773pub type CompletionModel<H = reqwest::Client> =
1778 GenericCompletionModel<super::OpenAICompletionsExt, H>;
1779
1780impl<Ext, H> GenericCompletionModel<Ext, H>
1781where
1782 crate::client::Client<Ext, H>: std::fmt::Debug + Clone + 'static,
1783 Ext: crate::client::Provider + Clone + 'static,
1784{
1785 pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
1786 Self {
1787 client,
1788 model: model.into(),
1789 strict_tools: false,
1790 tool_result_array_content: false,
1791 prompt_caching: false,
1792 }
1793 }
1794
1795 pub fn with_strict_tools(mut self) -> Self {
1804 self.strict_tools = true;
1805 self
1806 }
1807
1808 pub fn with_tool_result_array_content(mut self) -> Self {
1809 self.tool_result_array_content = true;
1810 self
1811 }
1812}
1813
1814#[derive(Debug, Serialize, Deserialize, Clone)]
1815pub struct CompletionRequest {
1816 pub model: String,
1817 pub messages: Vec<Message>,
1818 #[serde(skip_serializing_if = "Vec::is_empty")]
1819 pub tools: Vec<ToolDefinition>,
1820 #[serde(skip_serializing_if = "Option::is_none")]
1821 pub tool_choice: Option<ToolChoice>,
1822 #[serde(skip_serializing_if = "Option::is_none")]
1823 pub temperature: Option<f64>,
1824 #[serde(skip_serializing_if = "Option::is_none")]
1825 pub max_tokens: Option<u64>,
1826 #[serde(flatten)]
1827 pub additional_params: Option<serde_json::Value>,
1828}
1829
1830pub(crate) fn flatten_text_content_parts(
1836 content: &mut serde_json::Value,
1837 separator: &str,
1838 only_if_all_text: bool,
1839) {
1840 fn part_text(part: &serde_json::Value) -> Option<&str> {
1844 part.get("text")
1845 .and_then(serde_json::Value::as_str)
1846 .or_else(|| part.get("refusal").and_then(serde_json::Value::as_str))
1847 }
1848
1849 let Some(parts) = content.as_array() else {
1850 return;
1851 };
1852 if only_if_all_text && !parts.iter().all(|part| part_text(part).is_some()) {
1853 return;
1854 }
1855 let mut flattened = String::new();
1856 for text in parts.iter().filter_map(part_text) {
1857 if !flattened.is_empty() {
1858 flattened.push_str(separator);
1859 }
1860 flattened.push_str(text);
1861 }
1862 *content = serde_json::Value::String(flattened);
1863}
1864
1865pub(crate) fn joined_text_parts(parts: &[serde_json::Value]) -> String {
1867 parts
1868 .iter()
1869 .filter_map(|part| {
1870 (part.get("type").and_then(serde_json::Value::as_str) == Some("text"))
1871 .then(|| part.get("text").and_then(serde_json::Value::as_str))
1872 .flatten()
1873 })
1874 .collect::<Vec<_>>()
1875 .join("")
1876}
1877
1878pub(crate) fn sanitize_plain_text_history(
1889 messages: &mut Vec<serde_json::Value>,
1890 flatten: Option<(&str, bool)>,
1891 strip_names: bool,
1892 merge_same_role: bool,
1893) {
1894 messages
1895 .retain(|message| message.get("role").and_then(serde_json::Value::as_str) != Some("tool"));
1896
1897 for message in messages.iter_mut() {
1898 let Some(object) = message.as_object_mut() else {
1899 continue;
1900 };
1901 if object.get("role").and_then(serde_json::Value::as_str) == Some("assistant") {
1902 object.remove("tool_calls");
1903 object.remove("reasoning_content");
1904 }
1905 if strip_names {
1906 object.remove("name");
1907 }
1908 if let Some((separator, only_if_all_text)) = flatten
1909 && let Some(content) = object.get_mut("content")
1910 {
1911 flatten_text_content_parts(content, separator, only_if_all_text);
1912 }
1913 }
1914
1915 messages.retain(|message| {
1916 if message.get("role").and_then(serde_json::Value::as_str) != Some("assistant") {
1917 return true;
1918 }
1919 match message.get("content") {
1920 Some(serde_json::Value::String(text)) => !text.is_empty(),
1921 Some(serde_json::Value::Null) | None => false,
1922 Some(_) => true,
1923 }
1924 });
1925
1926 if !merge_same_role {
1927 return;
1928 }
1929
1930 let mut merged: Vec<serde_json::Value> = Vec::with_capacity(messages.len());
1931 for message in std::mem::take(messages) {
1932 let merged_text = if let Some(role) = message
1933 .get("role")
1934 .and_then(serde_json::Value::as_str)
1935 .filter(|role| matches!(*role, "assistant" | "user"))
1936 && let Some(previous) = merged.last()
1937 && previous.get("role").and_then(serde_json::Value::as_str) == Some(role)
1938 && let Some(previous_text) = previous.get("content").and_then(serde_json::Value::as_str)
1939 && let Some(text) = message.get("content").and_then(serde_json::Value::as_str)
1940 {
1941 Some(format!("{previous_text}\n{text}"))
1942 } else {
1943 None
1944 };
1945
1946 if let Some(text) = merged_text
1947 && let Some(previous) = merged.last_mut().and_then(serde_json::Value::as_object_mut)
1948 {
1949 previous.insert("content".to_string(), serde_json::Value::String(text));
1950 continue;
1951 }
1952 merged.push(message);
1953 }
1954 *messages = merged;
1955}
1956
1957pub struct OpenAIRequestParams {
1958 pub model: String,
1959 pub request: CoreCompletionRequest,
1960 pub strict_tools: bool,
1961 pub tool_result_array_content: bool,
1962 pub supports_response_format: bool,
1965 pub supports_tools: bool,
1968}
1969
1970impl TryFrom<OpenAIRequestParams> for CompletionRequest {
1971 type Error = CompletionError;
1972
1973 fn try_from(params: OpenAIRequestParams) -> Result<Self, Self::Error> {
1974 let OpenAIRequestParams {
1975 model,
1976 request: req,
1977 strict_tools,
1978 tool_result_array_content,
1979 supports_response_format,
1980 supports_tools,
1981 } = params;
1982 let chat_history = req.chat_history_with_documents();
1983
1984 let CoreCompletionRequest {
1985 model: request_model,
1986 preamble,
1987 chat_history: _,
1988 tools,
1989 temperature,
1990 max_tokens,
1991 additional_params,
1992 tool_choice,
1993 output_schema,
1994 ..
1995 } = req;
1996
1997 let mut partial_history = Vec::new();
1998 partial_history.extend(chat_history);
1999
2000 let mut full_history: Vec<Message> =
2001 preamble.map_or_else(Vec::new, |preamble| vec![Message::system(&preamble)]);
2002
2003 full_history.extend(
2004 partial_history
2005 .into_iter()
2006 .map(message::Message::try_into)
2007 .collect::<Result<Vec<Vec<Message>>, _>>()?
2008 .into_iter()
2009 .flatten()
2010 .collect::<Vec<_>>(),
2011 );
2012
2013 if full_history.is_empty() {
2014 return Err(CompletionError::RequestError(
2015 std::io::Error::new(
2016 std::io::ErrorKind::InvalidInput,
2017 "OpenAI Chat Completions request has no provider-compatible messages after conversion",
2018 )
2019 .into(),
2020 ));
2021 }
2022
2023 for msg in &mut full_history {
2024 if let Message::ToolResult { content, .. } = msg {
2025 let normalized = if tool_result_array_content {
2026 content.to_array()
2027 } else {
2028 ToolResultContentValue::String(content.as_text())
2029 };
2030
2031 *content = normalized;
2032 }
2033 }
2034
2035 let history_has_tool_result = history_contains_tool_result(&full_history);
2036
2037 let (mut tools, tool_choice) = if supports_tools {
2038 let tool_choice = tool_choice.map(ToolChoice::try_from).transpose()?;
2039 let tools: Vec<ToolDefinition> = tools
2040 .into_iter()
2041 .map(|tool| {
2042 let def = ToolDefinition::from(tool);
2043 if strict_tools { def.with_strict() } else { def }
2044 })
2045 .collect();
2046 (tools, tool_choice)
2047 } else {
2048 if !tools.is_empty() {
2049 tracing::warn!("Tool use is not supported by this provider; tools will be ignored");
2050 }
2051 if tool_choice.is_some() {
2052 tracing::warn!("Tool choice is not supported by this provider and will be ignored");
2053 }
2054 (Vec::new(), None)
2055 };
2056
2057 let mut additional_params = additional_params;
2066 if supports_tools
2067 && let Some(map) = additional_params
2068 .as_mut()
2069 .and_then(serde_json::Value::as_object_mut)
2070 && let Some(raw_tools) = map.remove("tools")
2071 {
2072 let raw_tools =
2073 serde_json::from_value::<Vec<serde_json::Value>>(raw_tools).map_err(|err| {
2074 CompletionError::RequestError(
2075 format!(
2076 "Invalid OpenAI Chat Completions `additional_params.tools` payload: {err}"
2077 )
2078 .into(),
2079 )
2080 })?;
2081 let mut remaining = Vec::new();
2082 for raw_tool in raw_tools {
2083 let is_function_tool =
2084 raw_tool.get("type").and_then(serde_json::Value::as_str) == Some("function");
2085 if is_function_tool {
2086 let tool =
2087 serde_json::from_value::<ToolDefinition>(raw_tool).map_err(|err| {
2088 CompletionError::RequestError(
2089 format!(
2090 "Invalid function tool in OpenAI Chat Completions \
2091 `additional_params.tools`: {err}"
2092 )
2093 .into(),
2094 )
2095 })?;
2096 tools.push(tool);
2097 } else {
2098 remaining.push(raw_tool);
2099 }
2100 }
2101 if !remaining.is_empty() {
2102 map.insert("tools".to_string(), serde_json::Value::Array(remaining));
2103 }
2104 }
2105
2106 if output_schema.is_some() && !supports_response_format {
2107 tracing::warn!(
2108 "Structured outputs are not supported by this provider; ignoring output_schema"
2109 );
2110 }
2111
2112 let should_apply_response_format = output_schema.is_some()
2116 && supports_response_format
2117 && (tools.is_empty() || history_has_tool_result);
2118
2119 let additional_params = if let Some(schema) = output_schema
2121 && should_apply_response_format
2122 {
2123 let (name, schema_value) = super::structured_output_schema(schema);
2124 let response_format = serde_json::json!({
2125 "response_format": {
2126 "type": "json_schema",
2127 "json_schema": {
2128 "name": name,
2129 "strict": true,
2130 "schema": schema_value
2131 }
2132 }
2133 });
2134 Some(match additional_params {
2135 Some(existing) => json_utils::merge(existing, response_format),
2136 None => response_format,
2137 })
2138 } else {
2139 additional_params
2140 };
2141
2142 let res = Self {
2143 model: request_model.unwrap_or(model),
2144 messages: full_history,
2145 tools,
2146 tool_choice,
2147 temperature,
2148 max_tokens,
2149 additional_params,
2150 };
2151
2152 Ok(res)
2153 }
2154}
2155
2156impl TryFrom<(String, CoreCompletionRequest)> for CompletionRequest {
2157 type Error = CompletionError;
2158
2159 fn try_from((model, req): (String, CoreCompletionRequest)) -> Result<Self, Self::Error> {
2160 CompletionRequest::try_from(OpenAIRequestParams {
2161 model,
2162 request: req,
2163 strict_tools: false,
2164 tool_result_array_content: false,
2165 supports_response_format: true,
2166 supports_tools: true,
2167 })
2168 }
2169}
2170
2171impl<Ext, H> GenericCompletionModel<Ext, H>
2172where
2173 Ext: OpenAICompatibleProvider,
2174{
2175 pub(crate) fn sends_modern_output_cap(&self, model: &str) -> bool {
2183 self.client.ext().requires_modern_output_cap(model)
2184 }
2185}
2186
2187impl<Ext, H> GenericCompletionModel<Ext, H>
2188where
2189 crate::client::Client<Ext, H>:
2190 HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
2191 Ext: crate::client::Provider
2192 + OpenAICompatibleProvider
2193 + crate::client::DebugExt
2194 + Clone
2195 + WasmCompatSend
2196 + WasmCompatSync
2197 + 'static,
2198 H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
2199{
2200 pub async fn raw_completion(
2213 &self,
2214 completion_request: CoreCompletionRequest,
2215 ) -> Result<Ext::Response, CompletionError> {
2216 self.raw_completion_with_request_id(completion_request)
2217 .await
2218 .map(|(response, _)| response)
2219 }
2220
2221 pub async fn raw_completion_with_request_id(
2235 &self,
2236 completion_request: CoreCompletionRequest,
2237 ) -> Result<(Ext::Response, Option<String>), CompletionError> {
2238 let system_instructions = completion_request.preamble.clone();
2239 let record_telemetry_content = completion_request.record_telemetry_content;
2240 let options = CompletionModelOptions {
2241 strict_tools: self.strict_tools,
2242 tool_result_array_content: self.tool_result_array_content,
2243 prompt_caching: self.prompt_caching,
2244 };
2245 let mut request = self.client.ext().build_completion_request(
2246 self.model.to_owned(),
2247 completion_request,
2248 options,
2249 )?;
2250 self.client.ext().prepare_request(&mut request)?;
2251 let span = CompletionSpanBuilder::new(
2252 Ext::PROVIDER_NAME,
2253 &request.model,
2254 CompletionOperation::Chat,
2255 )
2256 .system_instructions(system_instructions.as_deref(), record_telemetry_content)
2257 .build();
2258
2259 let modern_output_cap = self.sends_modern_output_cap(&request.model);
2260 let mut request_body = request_body(&request, modern_output_cap)?;
2261 self.client
2262 .ext()
2263 .finalize_request_body_with_options(&mut request_body, options)?;
2264 crate::providers::internal::trace_json(
2265 crate::providers::internal::LogTarget::Completions,
2266 "OpenAI Chat Completions completion request",
2267 &request_body,
2268 );
2269
2270 let body = serde_json::to_vec(&request_body)?;
2271 let path = self.client.ext().completion_path(&self.model);
2274
2275 let req = self
2276 .client
2277 .post(&path)?
2278 .body(body)
2279 .map_err(|e| CompletionError::HttpError(e.into()))?;
2280
2281 send_completion::<_, ApiResponse<Ext::Response>, _>(
2282 &self.client,
2283 req,
2284 "OpenAI Chat Completions completion",
2285 Ext::REQUEST_ID_HEADER,
2286 |response| {
2287 let span = tracing::Span::current();
2288 span.record_response_metadata(response);
2289 let usage = response.get_usage().map(Into::into).unwrap_or_default();
2290 span.record_token_usage(&usage);
2291 },
2292 )
2293 .instrument(span)
2294 .await
2295 }
2296}
2297
2298impl<Ext, H> completion::CompletionModel for GenericCompletionModel<Ext, H>
2299where
2300 crate::client::Client<Ext, H>:
2301 HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
2302 Ext: crate::client::Provider
2303 + OpenAICompatibleProvider
2304 + crate::client::DebugExt
2305 + Clone
2306 + WasmCompatSend
2307 + WasmCompatSync
2308 + 'static,
2309 H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
2310{
2311 fn capabilities(&self) -> completion::ProviderCapabilities {
2319 completion::ProviderCapabilities::default()
2324 .with_native_output_tool_composition(Ext::SUPPORTS_RESPONSE_FORMAT)
2325 }
2326
2327 async fn completion(
2328 &self,
2329 completion_request: CoreCompletionRequest,
2330 ) -> Result<completion::CompletionResponse, CompletionError> {
2331 let (response, provider_request_id) = self
2333 .raw_completion_with_request_id(completion_request)
2334 .await?;
2335 let captured = serde_json::to_value(&response)?;
2336 Ok(response
2337 .normalize(Ext::PROVIDER_NAME)?
2338 .with_optional_provider_request_id(provider_request_id)
2339 .with_raw(captured))
2340 }
2341
2342 async fn stream(
2343 &self,
2344 request: CoreCompletionRequest,
2345 ) -> Result<crate::streaming::StreamingCompletionResponse, CompletionError> {
2346 GenericCompletionModel::stream(self, request).await
2347 }
2348}
2349
2350impl<Ext, H> crate::client::ConstructCompletionModel<crate::client::Client<Ext, H>>
2351 for GenericCompletionModel<Ext, H>
2352where
2353 crate::client::Client<Ext, H>: std::fmt::Debug + Clone + 'static,
2354 Ext: crate::client::Provider + Clone + 'static,
2355{
2356 fn construct(client: &crate::client::Client<Ext, H>, model: String) -> Self {
2357 Self::new(client.clone(), model)
2358 }
2359}
2360
2361fn serialize_assistant_content_vec<S>(
2362 value: &[AssistantContent],
2363 serializer: S,
2364) -> Result<S::Ok, S::Error>
2365where
2366 S: Serializer,
2367{
2368 if value.is_empty() {
2369 serializer.serialize_str("")
2370 } else {
2371 value.serialize(serializer)
2372 }
2373}
2374
2375#[cfg(test)]
2376mod tests {
2377 #[test]
2384 fn completion_response_tolerates_null_or_missing_envelope_metadata() {
2385 let json = r#"{
2386 "id": "chatcmpl-1",
2387 "object": null,
2388 "created": null,
2389 "model": "some-model",
2390 "choices": [{
2391 "index": null,
2392 "message": { "role": "assistant", "content": "hi" },
2393 "finish_reason": null
2394 }]
2395 }"#;
2396 let response: super::CompletionResponse =
2397 serde_json::from_str(json).expect("null envelope metadata should deserialize");
2398 assert_eq!(response.object, "");
2399 assert_eq!(response.created, 0);
2400 assert_eq!(response.choices[0].index, 0);
2401 assert_eq!(response.choices[0].finish_reason, "");
2402 }
2403
2404 #[test]
2413 fn minted_tool_ids_replay_as_a_consistent_pair() {
2414 let assistant = crate::message::Message::Assistant {
2415 id: None,
2416 content: vec![crate::message::AssistantContent::tool_call(
2417 "tool-0",
2418 "get_weather",
2419 serde_json::json!({"city": "Tokyo"}),
2420 )],
2421 };
2422 let tool_result = crate::message::Message::User {
2423 content: vec![crate::message::UserContent::tool_result(
2424 "tool-0",
2425 "get_weather",
2426 vec![crate::message::ToolResultContent::text("22C")],
2427 )],
2428 };
2429
2430 let assistant_wire: Vec<super::Message> = assistant.try_into().expect("assistant converts");
2431 let result_wire: Vec<super::Message> =
2432 tool_result.try_into().expect("tool result converts");
2433
2434 let call_id = assistant_wire
2435 .iter()
2436 .find_map(|message| match message {
2437 super::Message::Assistant { tool_calls, .. } => {
2438 tool_calls.first().map(|call| call.id.clone())
2439 }
2440 _ => None,
2441 })
2442 .expect("assistant message carries the tool call");
2443 let result_id = result_wire
2444 .iter()
2445 .find_map(|message| match message {
2446 super::Message::ToolResult { tool_call_id, .. } => Some(tool_call_id.clone()),
2447 _ => None,
2448 })
2449 .expect("tool result message present");
2450
2451 assert_eq!(call_id, "tool-0");
2452 assert_eq!(
2453 result_id, call_id,
2454 "the minted pair must be self-consistent"
2455 );
2456 }
2457
2458 use super::*;
2459 use crate::completion::CompletionRequestBuilder;
2460 use crate::telemetry::ProviderResponseExt;
2461 use crate::test_utils::MockCompletionModel;
2462 use serde_json::{Value, json};
2463 use std::collections::HashMap;
2464
2465 fn test_document(id: &str, text: &str) -> crate::completion::Document {
2466 crate::completion::Document {
2467 id: id.to_string(),
2468 text: text.to_string(),
2469 additional_props: HashMap::new(),
2470 }
2471 }
2472
2473 fn request_with_multi_block_tool_result() -> CoreCompletionRequest {
2474 let tool_result = message::ToolResult {
2475 call: message::ToolCallId::new_or_mint("call-id"),
2476 provider: message::ProviderCallId::new("call-id"),
2477 name: "tool".to_string(),
2478 content: vec![
2479 message::ToolResultContent::text("first"),
2480 message::ToolResultContent::text("second"),
2481 ],
2482 };
2483
2484 CoreCompletionRequest {
2485 model: None,
2486 preamble: None,
2487 chat_history: vec![message::Message::User {
2488 content: vec![message::UserContent::ToolResult(tool_result)],
2489 }],
2490 documents: vec![],
2491 tools: vec![],
2492 temperature: None,
2493 max_tokens: None,
2494 tool_choice: None,
2495 additional_params: None,
2496 output_schema: None,
2497 record_telemetry_content: false,
2498 }
2499 }
2500
2501 #[test]
2502 fn mixed_user_content_preserves_order_around_tool_results() {
2503 let content = vec![
2504 message::UserContent::text("before"),
2505 message::UserContent::tool_result_with_call_id(
2506 "result-id",
2507 "call-id".to_string(),
2508 "tool",
2509 vec![message::ToolResultContent::text("tool output")],
2510 ),
2511 message::UserContent::text("after"),
2512 ];
2513
2514 let messages = user_content_to_messages(content).expect("message conversion");
2515
2516 assert!(matches!(
2517 messages.as_slice(),
2518 [
2519 Message::User { content: before, .. },
2520 Message::ToolResult { tool_call_id, .. },
2521 Message::User { content: after, .. },
2522 ] if matches!(before.first(), Some(UserContent::Text { text }) if text == "before")
2523 && tool_call_id == "call-id"
2524 && matches!(after.first(), Some(UserContent::Text { text }) if text == "after")
2525 ));
2526 }
2527
2528 #[test]
2529 fn video_data_uri_with_unrecognized_mime_round_trips_as_url() {
2530 let original = "data:video/quicktime;base64,AAAA";
2531 let openai_content = UserContent::Video {
2532 video_url: VideoUrl {
2533 url: original.to_string(),
2534 },
2535 };
2536
2537 let rig_content: message::UserContent = openai_content.into();
2538 assert!(matches!(
2540 &rig_content,
2541 message::UserContent::Video(video)
2542 if matches!(&video.data, message::DocumentSourceKind::Url(url) if url == original)
2543 ));
2544
2545 let back = UserContent::try_from(rig_content).expect("video should convert back");
2546 assert!(matches!(
2547 back,
2548 UserContent::Video { video_url } if video_url.url == original
2549 ));
2550 }
2551
2552 #[test]
2553 fn video_data_uri_with_known_mime_decomposes_to_base64() {
2554 let openai_content = UserContent::Video {
2555 video_url: VideoUrl {
2556 url: "data:video/mp4;base64,AAAA".to_string(),
2557 },
2558 };
2559
2560 let rig_content: message::UserContent = openai_content.into();
2561 assert!(matches!(
2562 &rig_content,
2563 message::UserContent::Video(video)
2564 if video.media_type == Some(crate::message::VideoMediaType::MP4)
2565 && matches!(&video.data, message::DocumentSourceKind::Base64(data) if data == "AAAA")
2566 ));
2567 }
2568
2569 #[test]
2570 fn sanitize_plain_text_history_strips_tool_exchange_and_keeps_alternation() {
2571 let mut messages = vec![
2572 serde_json::json!({"role": "user", "content": "Look up the label."}),
2573 serde_json::json!({"role": "assistant", "tool_calls": [
2574 {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
2575 ]}),
2576 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "crimson"}),
2577 serde_json::json!({
2578 "role": "assistant",
2579 "content": [{"type": "text", "text": "The label is crimson."}],
2580 "reasoning_content": "thinking"
2581 }),
2582 serde_json::json!({"role": "user", "content": "Thanks!"}),
2583 ];
2584
2585 sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2586
2587 let roles = messages
2588 .iter()
2589 .map(|m| m["role"].as_str().unwrap_or_default())
2590 .collect::<Vec<_>>();
2591 assert_eq!(roles, ["user", "assistant", "user"]);
2594 assert_eq!(messages[1]["content"], "The label is crimson.");
2595 assert!(messages[1].get("reasoning_content").is_none());
2596 assert!(messages[1].get("tool_calls").is_none());
2597 }
2598
2599 #[test]
2600 fn sanitize_plain_text_history_merges_consecutive_user_messages() {
2601 let mut messages = vec![
2605 serde_json::json!({"role": "user", "content": "Look it up."}),
2606 serde_json::json!({"role": "assistant", "tool_calls": [
2607 {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
2608 ]}),
2609 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "crimson"}),
2610 serde_json::json!({"role": "user", "content": "Ask again."}),
2611 ];
2612
2613 sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2614
2615 assert_eq!(messages.len(), 1);
2616 assert_eq!(messages[0]["role"], "user");
2617 assert_eq!(messages[0]["content"], "Look it up.\nAsk again.");
2618 }
2619
2620 #[test]
2621 fn flatten_text_content_parts_treats_refusals_as_text() {
2622 let mut content = serde_json::json!([
2623 {"type": "text", "text": "Partly:"},
2624 {"type": "refusal", "refusal": "I cannot help with that."}
2625 ]);
2626
2627 flatten_text_content_parts(&mut content, "\n", true);
2628
2629 assert_eq!(content, "Partly:\nI cannot help with that.");
2630 }
2631
2632 #[test]
2633 fn sanitize_plain_text_history_merges_consecutive_assistant_messages() {
2634 let mut messages = vec![
2635 serde_json::json!({"role": "assistant", "content": "First."}),
2636 serde_json::json!({"role": "tool", "tool_call_id": "c", "content": "x"}),
2637 serde_json::json!({"role": "assistant", "content": "Second."}),
2638 ];
2639
2640 sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2641
2642 assert_eq!(messages.len(), 1);
2643 assert_eq!(messages[0]["content"], "First.\nSecond.");
2644 }
2645
2646 #[test]
2647 fn tool_result_array_content_preserves_multiple_text_blocks() {
2648 let request = CompletionRequest::try_from(OpenAIRequestParams {
2649 model: "gpt-4o-mini".to_string(),
2650 request: request_with_multi_block_tool_result(),
2651 strict_tools: false,
2652 tool_result_array_content: true,
2653 supports_response_format: true,
2654 supports_tools: true,
2655 })
2656 .expect("request conversion should succeed");
2657
2658 let wire = serde_json::to_value(&request.messages).expect("messages should serialize");
2659
2660 assert_eq!(
2661 wire,
2662 serde_json::json!([
2663 {
2664 "role": "tool",
2665 "tool_call_id": "call-id",
2666 "content": [
2667 {
2668 "type": "text",
2669 "text": "first"
2670 },
2671 {
2672 "type": "text",
2673 "text": "second"
2674 }
2675 ]
2676 }
2677 ])
2678 );
2679 }
2680
2681 #[test]
2682 fn tool_result_string_content_flattens_multiple_text_blocks() {
2683 let request = CompletionRequest::try_from(OpenAIRequestParams {
2684 model: "gpt-4o-mini".to_string(),
2685 request: request_with_multi_block_tool_result(),
2686 strict_tools: false,
2687 tool_result_array_content: false,
2688 supports_response_format: true,
2689 supports_tools: true,
2690 })
2691 .expect("request conversion should succeed");
2692
2693 let wire = serde_json::to_value(&request.messages).expect("messages should serialize");
2694
2695 assert_eq!(
2696 wire,
2697 serde_json::json!([
2698 {
2699 "role": "tool",
2700 "tool_call_id": "call-id",
2701 "content": "first\nsecond"
2702 }
2703 ])
2704 );
2705 }
2706
2707 #[test]
2708 fn multiple_tool_result_blocks_convert_to_distinct_content_parts() {
2709 let result = message::ToolResult {
2710 call: message::ToolCallId::new_or_mint("call-id"),
2711 name: "tool".to_string(),
2712 provider: message::ProviderCallId::new("call-id"),
2713 content: vec![
2714 message::ToolResultContent::text("first"),
2715 message::ToolResultContent::json(serde_json::json!({
2716 "status": "ok"
2717 })),
2718 message::ToolResultContent::text("second"),
2719 ],
2720 };
2721
2722 let converted = Message::try_from(result).expect("tool result should convert");
2723
2724 assert_eq!(
2725 converted,
2726 Message::ToolResult {
2727 tool_call_id: "call-id".to_string(),
2728 content: ToolResultContentValue::Array(vec![
2729 ToolResultContent::from("first".to_string()),
2730 ToolResultContent::from(r#"{"status":"ok"}"#.to_string()),
2731 ToolResultContent::from("second".to_string()),
2732 ]),
2733 }
2734 );
2735 }
2736
2737 #[test]
2738 fn test_openai_request_uses_request_model_override() {
2739 let request = crate::completion::CompletionRequest {
2740 model: Some("gpt-4.1".to_string()),
2741 preamble: None,
2742 chat_history: vec!["Hello".into()],
2743 documents: vec![],
2744 tools: vec![],
2745 temperature: None,
2746 max_tokens: None,
2747 tool_choice: None,
2748 additional_params: None,
2749 output_schema: None,
2750 record_telemetry_content: false,
2751 };
2752
2753 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2754 model: "gpt-4o-mini".to_string(),
2755 request,
2756 strict_tools: false,
2757 tool_result_array_content: false,
2758 supports_response_format: true,
2759 supports_tools: true,
2760 })
2761 .expect("request conversion should succeed");
2762 let serialized =
2763 serde_json::to_value(openai_request).expect("serialization should succeed");
2764
2765 assert_eq!(serialized["model"], "gpt-4.1");
2766 }
2767
2768 #[test]
2769 fn test_openai_request_uses_default_model_when_override_unset() {
2770 let request = crate::completion::CompletionRequest {
2771 model: None,
2772 preamble: None,
2773 chat_history: vec!["Hello".into()],
2774 documents: vec![],
2775 tools: vec![],
2776 temperature: None,
2777 max_tokens: None,
2778 tool_choice: None,
2779 additional_params: None,
2780 output_schema: None,
2781 record_telemetry_content: false,
2782 };
2783
2784 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2785 model: "gpt-4o-mini".to_string(),
2786 request,
2787 strict_tools: false,
2788 tool_result_array_content: false,
2789 supports_response_format: true,
2790 supports_tools: true,
2791 })
2792 .expect("request conversion should succeed");
2793 let serialized =
2794 serde_json::to_value(openai_request).expect("serialization should succeed");
2795
2796 assert_eq!(serialized["model"], "gpt-4o-mini");
2797 }
2798
2799 #[test]
2800 fn openai_chat_request_keeps_documents_after_system_messages() {
2801 let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Prompt")
2802 .message(crate::completion::Message::system("System prompt"))
2803 .message(crate::completion::Message::user("Earlier user turn"))
2804 .message(crate::completion::Message::assistant(
2805 "Earlier assistant turn",
2806 ))
2807 .document(test_document("doc1", "Document text."))
2808 .build();
2809
2810 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2811 model: "gpt-4o-mini".to_string(),
2812 request,
2813 strict_tools: false,
2814 tool_result_array_content: false,
2815 supports_response_format: true,
2816 supports_tools: true,
2817 })
2818 .expect("request conversion should succeed");
2819
2820 let serialized =
2821 serde_json::to_value(&openai_request.messages).expect("messages should serialize");
2822 let messages = serialized.as_array().expect("messages should be an array");
2823
2824 assert_eq!(messages.len(), 5);
2825 assert_eq!(messages[0]["role"], "system");
2826 assert_eq!(messages[1]["role"], "user");
2827 assert!(
2828 messages[1].to_string().contains("<file id: doc1>"),
2829 "document message should follow system message: {messages:?}"
2830 );
2831 assert_eq!(messages[2]["role"], "user");
2832 assert!(
2833 messages[2].to_string().contains("Earlier user turn"),
2834 "prior user history should follow document message: {messages:?}"
2835 );
2836 assert_eq!(messages[3]["role"], "assistant");
2837 assert!(
2838 messages[3].to_string().contains("Earlier assistant turn"),
2839 "prior assistant history should follow prior user history: {messages:?}"
2840 );
2841 assert_eq!(messages[4]["role"], "user");
2842 assert!(
2843 messages[4].to_string().contains("Prompt"),
2844 "prompt should remain last: {messages:?}"
2845 );
2846 }
2847
2848 #[test]
2849 fn openai_chat_direct_request_keeps_documents_after_system_messages() {
2850 let request = CoreCompletionRequest {
2851 model: None,
2852 preamble: None,
2853 chat_history: vec![
2854 crate::completion::Message::system("System prompt"),
2855 crate::completion::Message::assistant("Earlier assistant turn"),
2856 crate::completion::Message::system("Mid-conversation instruction"),
2857 crate::completion::Message::user("Prompt"),
2858 ],
2859 documents: vec![test_document("doc1", "Document text.")],
2860 tools: vec![],
2861 temperature: None,
2862 max_tokens: None,
2863 tool_choice: None,
2864 additional_params: None,
2865 output_schema: None,
2866 record_telemetry_content: false,
2867 };
2868
2869 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2870 model: "gpt-4o-mini".to_string(),
2871 request,
2872 strict_tools: false,
2873 tool_result_array_content: false,
2874 supports_response_format: true,
2875 supports_tools: true,
2876 })
2877 .expect("request conversion should succeed");
2878
2879 let serialized =
2880 serde_json::to_value(&openai_request.messages).expect("messages should serialize");
2881 let messages = serialized.as_array().expect("messages should be an array");
2882
2883 assert_eq!(messages.len(), 5);
2884 assert_eq!(messages[0]["role"], "system");
2885 assert_eq!(messages[1]["role"], "user");
2886 assert!(
2887 messages[1].to_string().contains("<file id: doc1>"),
2888 "document message should follow leading system messages: {messages:?}"
2889 );
2890 assert_eq!(messages[2]["role"], "assistant");
2891 assert_eq!(messages[3]["role"], "system");
2892 assert_eq!(messages[4]["role"], "user");
2893 assert_eq!(
2894 messages
2895 .iter()
2896 .filter(|message| message.to_string().contains("<file id: doc1>"))
2897 .count(),
2898 1,
2899 "document message should appear exactly once: {messages:?}"
2900 );
2901 }
2902
2903 #[test]
2904 fn assistant_reasoning_alone_is_dropped() {
2905 let assistant_content = vec![message::AssistantContent::reasoning("hidden")];
2906
2907 let converted: Vec<Message> =
2908 assistant_content_to_messages(assistant_content).expect("conversion should work");
2909
2910 assert!(converted.is_empty());
2911 }
2912
2913 #[test]
2918 fn assistant_reasoning_is_attached_to_tool_call_message() {
2919 let assistant_content = vec![
2920 message::AssistantContent::reasoning("hidden"),
2921 message::AssistantContent::text("visible"),
2922 message::AssistantContent::tool_call(
2923 "call_1",
2924 "subtract",
2925 serde_json::json!({"x": 2, "y": 1}),
2926 ),
2927 ];
2928
2929 let converted: Vec<Message> =
2930 assistant_content_to_messages(assistant_content).expect("conversion should work");
2931 assert_eq!(converted.len(), 1);
2932
2933 match &converted[0] {
2934 Message::Assistant {
2935 content,
2936 tool_calls,
2937 reasoning,
2938 ..
2939 } => {
2940 assert_eq!(
2941 content,
2942 &vec![AssistantContent::Text {
2943 text: "visible".to_string()
2944 }]
2945 );
2946 assert_eq!(tool_calls.len(), 1);
2947 assert_eq!(tool_calls[0].id, "call_1");
2948 assert_eq!(tool_calls[0].function.name, "subtract");
2949 assert_eq!(
2950 tool_calls[0].function.arguments,
2951 serde_json::json!({"x": 2, "y": 1})
2952 );
2953 assert_eq!(reasoning.as_deref(), Some("hidden"));
2954 }
2955 _ => panic!("expected assistant message"),
2956 }
2957
2958 let json = serde_json::to_value(&converted[0]).expect("serialize");
2959 assert_eq!(json["reasoning_content"], "hidden");
2960 }
2961
2962 #[test]
2963 fn assistant_reasoning_roundtrips_back_to_rig_message() {
2964 let assistant = Message::Assistant {
2965 content: vec![AssistantContent::Text {
2966 text: "visible".to_string(),
2967 }],
2968 reasoning: Some("hidden".to_string()),
2969 refusal: None,
2970 audio: None,
2971 name: None,
2972 tool_calls: vec![],
2973 reasoning_details: vec![],
2974 images: vec![],
2975 };
2976
2977 let rig_msg: message::Message = assistant.try_into().expect("convert back");
2978
2979 let message::Message::Assistant { content, .. } = rig_msg else {
2980 panic!("expected assistant");
2981 };
2982
2983 let items: Vec<_> = content.into_iter().collect();
2984 assert_eq!(items.len(), 2);
2985 assert!(matches!(items[0], message::AssistantContent::Reasoning(_)));
2986 assert!(matches!(items[1], message::AssistantContent::Text(_)));
2987 }
2988
2989 #[test]
2990 fn provider_response_text_response_reads_assistant_multipart_output() {
2991 let response = CompletionResponse {
2992 id: "resp_123".to_owned(),
2993 object: "chat.completion".to_owned(),
2994 created: 0,
2995 model: GPT_4O.to_owned(),
2996 system_fingerprint: None,
2997 service_tier: None,
2998 choices: vec![Choice {
2999 index: 0,
3000 message: Message::Assistant {
3001 content: vec![
3002 AssistantContent::Text {
3003 text: "first".to_owned(),
3004 },
3005 AssistantContent::Refusal {
3006 refusal: "second".to_owned(),
3007 },
3008 AssistantContent::Text {
3009 text: "third".to_owned(),
3010 },
3011 ],
3012 reasoning: Some("hidden".to_owned()),
3013 refusal: None,
3014 audio: None,
3015 name: None,
3016 tool_calls: vec![],
3017 reasoning_details: vec![],
3018 images: vec![],
3019 },
3020 logprobs: None,
3021 finish_reason: "stop".to_owned(),
3022 }],
3023 usage: None,
3024 };
3025
3026 assert_eq!(
3027 response.get_text_response(),
3028 Some("first\nsecond\nthird".to_owned())
3029 );
3030 }
3031
3032 #[test]
3033 fn raw_completion_response_retains_service_tier() {
3034 let response: CompletionResponse = serde_json::from_value(json!({
3035 "id": "chatcmpl-tier",
3036 "object": "chat.completion",
3037 "created": 0,
3038 "model": GPT_4O,
3039 "system_fingerprint": "fp_test",
3040 "service_tier": "priority",
3041 "choices": [{
3042 "index": 0,
3043 "message": {"role": "assistant", "content": "ok"},
3044 "finish_reason": "stop"
3045 }]
3046 }))
3047 .expect("live Chat Completions metadata should deserialize");
3048
3049 assert_eq!(response.service_tier.as_deref(), Some("priority"));
3050 }
3051
3052 #[test]
3053 fn provider_response_text_response_falls_back_to_assistant_refusal_field() {
3054 let response = CompletionResponse {
3055 id: "resp_123".to_owned(),
3056 object: "chat.completion".to_owned(),
3057 created: 0,
3058 model: GPT_4O.to_owned(),
3059 system_fingerprint: None,
3060 service_tier: None,
3061 choices: vec![Choice {
3062 index: 0,
3063 message: Message::Assistant {
3064 content: vec![],
3065 reasoning: None,
3066 refusal: Some("blocked".to_owned()),
3067 audio: None,
3068 name: None,
3069 tool_calls: vec![],
3070 reasoning_details: vec![],
3071 images: vec![],
3072 },
3073 logprobs: None,
3074 finish_reason: "stop".to_owned(),
3075 }],
3076 usage: None,
3077 };
3078
3079 assert_eq!(response.get_text_response(), Some("blocked".to_owned()));
3080 }
3081
3082 fn refusal_response(body: Value) -> CompletionResponse {
3085 serde_json::from_value(json!({
3086 "id": "chatcmpl-refusal",
3087 "object": "chat.completion",
3088 "created": 0,
3089 "model": GPT_4O,
3090 "choices": [{ "index": 0, "message": body, "finish_reason": "stop" }],
3091 }))
3092 .expect("the refusal wire shape must deserialize")
3093 }
3094
3095 fn normalized_text(response: CompletionResponse) -> Vec<completion::AssistantContent> {
3096 use crate::completion::NormalizeCompletionResponse;
3097
3098 response
3099 .normalize("openai")
3100 .expect("a refusal turn must normalize")
3101 .choice
3102 }
3103
3104 #[test]
3105 fn refusal_sibling_of_null_content_becomes_assistant_text() {
3106 let response = refusal_response(json!({
3107 "role": "assistant",
3108 "content": null,
3109 "refusal": "I'm sorry, I can't help with that."
3110 }));
3111
3112 assert_eq!(
3113 normalized_text(response),
3114 vec![completion::AssistantContent::text(
3115 "I'm sorry, I can't help with that."
3116 )]
3117 );
3118 }
3119
3120 #[test]
3123 fn refusal_raw_and_normalized_views_agree() {
3124 let message = json!({
3125 "role": "assistant",
3126 "content": null,
3127 "refusal": "I'm sorry, I can't help with that."
3128 });
3129 let raw_text = refusal_response(message.clone())
3130 .get_text_response()
3131 .expect("raw text view");
3132
3133 assert_eq!(
3134 normalized_text(refusal_response(message)),
3135 vec![completion::AssistantContent::text(raw_text)]
3136 );
3137 }
3138
3139 #[test]
3142 fn refusal_beside_non_empty_content_does_not_duplicate() {
3143 let response = refusal_response(json!({
3144 "role": "assistant",
3145 "content": "here is the answer",
3146 "refusal": "I'm sorry, I can't help with that."
3147 }));
3148
3149 assert_eq!(
3150 normalized_text(response),
3151 vec![completion::AssistantContent::text("here is the answer")]
3152 );
3153 }
3154
3155 #[test]
3158 fn empty_refusal_is_not_content() {
3159 use crate::completion::NormalizeCompletionResponse;
3160
3161 let response = refusal_response(json!({
3162 "role": "assistant",
3163 "content": null,
3164 "refusal": ""
3165 }));
3166
3167 assert!(response.normalize("openai").is_err());
3168 }
3169
3170 #[test]
3173 fn refusal_beside_tool_calls_keeps_both() {
3174 let response = refusal_response(json!({
3175 "role": "assistant",
3176 "content": null,
3177 "refusal": "I'm sorry, I can't help with that.",
3178 "tool_calls": [{
3179 "id": "call_1",
3180 "type": "function",
3181 "function": { "name": "lookup", "arguments": "{}" }
3182 }]
3183 }));
3184
3185 let content = normalized_text(response);
3186 assert_eq!(content.len(), 2);
3187 assert_eq!(
3188 content.first(),
3189 Some(&completion::AssistantContent::text(
3190 "I'm sorry, I can't help with that."
3191 ))
3192 );
3193 assert!(matches!(
3194 content.get(1),
3195 Some(completion::AssistantContent::ToolCall(_))
3196 ));
3197 }
3198
3199 #[test]
3203 fn refusal_content_part_still_maps_to_text_without_the_fallback() {
3204 let response = refusal_response(json!({
3205 "role": "assistant",
3206 "content": [{ "type": "refusal", "refusal": "part refusal" }],
3207 "refusal": "sibling refusal"
3208 }));
3209
3210 assert_eq!(
3211 normalized_text(response),
3212 vec![completion::AssistantContent::text("part refusal")]
3213 );
3214 }
3215
3216 #[test]
3219 fn refusal_only_message_converts_into_rig_history() {
3220 let wire: Message = serde_json::from_value(json!({
3221 "role": "assistant",
3222 "content": null,
3223 "refusal": "I'm sorry, I can't help with that."
3224 }))
3225 .expect("wire message");
3226
3227 let converted = message::Message::try_from(wire).expect("history conversion");
3228
3229 assert_eq!(
3230 converted,
3231 message::Message::Assistant {
3232 id: None,
3233 content: vec![message::AssistantContent::text(
3234 "I'm sorry, I can't help with that."
3235 )],
3236 }
3237 );
3238 }
3239
3240 #[test]
3245 fn refusal_beside_an_empty_content_string_converts_to_the_refusal_alone() {
3246 let wire: Message = serde_json::from_value(json!({
3247 "role": "assistant",
3248 "content": "",
3249 "refusal": "I'm sorry, I can't help with that."
3250 }))
3251 .expect("wire message");
3252
3253 let converted = message::Message::try_from(wire).expect("history conversion");
3254
3255 assert_eq!(
3256 converted,
3257 message::Message::Assistant {
3258 id: None,
3259 content: vec![message::AssistantContent::text(
3260 "I'm sorry, I can't help with that."
3261 )],
3262 },
3263 "the empty part must not ride along beside the refusal"
3264 );
3265 }
3266
3267 #[test]
3270 fn refusal_beside_real_content_converts_to_the_content_alone() {
3271 let wire: Message = serde_json::from_value(json!({
3272 "role": "assistant",
3273 "content": "here is the answer",
3274 "refusal": "I'm sorry, I can't help with that."
3275 }))
3276 .expect("wire message");
3277
3278 let converted = message::Message::try_from(wire).expect("history conversion");
3279
3280 assert_eq!(
3281 converted,
3282 message::Message::Assistant {
3283 id: None,
3284 content: vec![message::AssistantContent::text("here is the answer")],
3285 }
3286 );
3287 }
3288
3289 #[test]
3290 fn refusal_only_message_with_empty_refusal_still_fails_conversion() {
3291 let wire: Message = serde_json::from_value(json!({
3292 "role": "assistant",
3293 "content": null,
3294 "refusal": ""
3295 }))
3296 .expect("wire message");
3297
3298 assert!(message::Message::try_from(wire).is_err());
3299 }
3300
3301 #[test]
3302 fn test_max_tokens_is_forwarded_to_request() {
3303 let request = crate::completion::CompletionRequest {
3304 model: None,
3305 preamble: None,
3306 chat_history: vec!["Hello".into()],
3307 documents: vec![],
3308 tools: vec![],
3309 temperature: None,
3310 max_tokens: Some(4096),
3311 tool_choice: None,
3312 additional_params: None,
3313 output_schema: None,
3314 record_telemetry_content: false,
3315 };
3316
3317 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3318 model: "gpt-4o-mini".to_string(),
3319 request,
3320 strict_tools: false,
3321 tool_result_array_content: false,
3322 supports_response_format: true,
3323 supports_tools: true,
3324 })
3325 .expect("request conversion should succeed");
3326 let serialized =
3327 serde_json::to_value(openai_request).expect("serialization should succeed");
3328
3329 assert_eq!(serialized["max_tokens"], 4096);
3330 }
3331
3332 fn capped_request(
3334 max_tokens: Option<u64>,
3335 additional_params: Option<Value>,
3336 ) -> CompletionRequest {
3337 CompletionRequest::try_from(OpenAIRequestParams {
3338 model: "gpt-4o-mini".to_string(),
3339 request: crate::completion::CompletionRequest {
3340 model: None,
3341 preamble: None,
3342 chat_history: vec!["Hello".into()],
3343 documents: vec![],
3344 tools: vec![],
3345 temperature: None,
3346 max_tokens,
3347 tool_choice: None,
3348 additional_params,
3349 output_schema: None,
3350 record_telemetry_content: false,
3351 },
3352 strict_tools: false,
3353 tool_result_array_content: false,
3354 supports_response_format: true,
3355 supports_tools: true,
3356 })
3357 .expect("request conversion should succeed")
3358 }
3359
3360 #[test]
3361 fn request_body_keeps_the_legacy_cap_when_the_endpoint_wants_it() {
3362 let body =
3363 request_body(&capped_request(Some(4096), None), false).expect("body should serialize");
3364
3365 assert_eq!(body["max_tokens"], 4096);
3366 assert!(body.get("max_completion_tokens").is_none());
3367 }
3368
3369 #[test]
3370 fn request_body_renames_the_cap_for_the_modern_endpoint() {
3371 let body =
3372 request_body(&capped_request(Some(4096), None), true).expect("body should serialize");
3373
3374 assert_eq!(body["max_completion_tokens"], 4096);
3375 assert!(
3376 body.get("max_tokens").is_none(),
3377 "the legacy key must leave the body: reasoning models reject its presence"
3378 );
3379 }
3380
3381 #[test]
3382 fn request_body_without_a_cap_carries_neither_spelling() {
3383 let body = request_body(&capped_request(None, None), true).expect("body should serialize");
3384
3385 assert!(body.get("max_tokens").is_none());
3386 assert!(body.get("max_completion_tokens").is_none());
3387 }
3388
3389 #[test]
3390 fn request_body_keeps_a_caller_supplied_modern_cap() {
3391 let body = request_body(
3392 &capped_request(Some(4096), Some(json!({ "max_completion_tokens": 48 }))),
3393 true,
3394 )
3395 .expect("body should serialize");
3396
3397 assert_eq!(body["max_completion_tokens"], 48);
3398 assert!(body.get("max_tokens").is_none());
3399 }
3400
3401 #[test]
3402 fn request_body_upgrades_a_caller_supplied_legacy_cap() {
3403 let body = request_body(
3404 &capped_request(None, Some(json!({ "max_tokens": 48 }))),
3405 true,
3406 )
3407 .expect("body should serialize");
3408
3409 assert_eq!(body["max_completion_tokens"], 48);
3410 assert!(body.get("max_tokens").is_none());
3411 }
3412
3413 #[test]
3414 fn request_body_moves_nothing_but_the_cap() {
3415 let request = capped_request(Some(4096), Some(json!({ "top_p": 0.5 })));
3416 let plain = serde_json::to_value(&request).expect("serialization should succeed");
3417 let mut renamed = request_body(&request, true).expect("body");
3418
3419 let cap = renamed
3420 .as_object_mut()
3421 .expect("object body")
3422 .remove("max_completion_tokens")
3423 .expect("renamed cap");
3424 renamed["max_tokens"] = cap;
3425
3426 assert_eq!(renamed, plain);
3427 }
3428
3429 #[test]
3434 fn modern_output_cap_covers_exactly_the_reasoning_families() {
3435 for model in [
3436 "gpt-5",
3437 "gpt-5.1",
3438 "gpt-5.2",
3439 "gpt-5-nano",
3440 "gpt-5-2025-08-07",
3441 "gpt-6",
3442 "o1",
3443 "o1-mini",
3444 "o3",
3445 "o3-mini",
3446 "o4-mini",
3447 "o4-mini-2025-04-16",
3448 ] {
3449 assert!(
3450 is_openai_reasoning_model(model),
3451 "{model} rejects `max_tokens` and must get the modern spelling"
3452 );
3453 }
3454
3455 for model in [
3456 "gpt-4o",
3457 "gpt-4o-mini",
3458 "gpt-4.1",
3459 "gpt-4.1-nano",
3460 "gpt-4-turbo",
3461 "gpt-3.5-turbo",
3462 "chatgpt-4o-latest",
3463 "Qwen/Qwen3-4B",
3465 "openai/gpt-oss-20b",
3466 "gpt-oss-120b",
3467 "llama-3.1-8b-instruct",
3468 "gpt-45",
3470 "gpt-",
3471 "o",
3472 "opus",
3473 "o5x",
3474 "",
3475 ] {
3476 assert!(
3477 !is_openai_reasoning_model(model),
3478 "{model:?} still takes `max_tokens`; changing its request would be a regression"
3479 );
3480 }
3481 }
3482
3483 #[test]
3485 fn openai_extension_asks_for_the_modern_cap_only_on_reasoning_models() {
3486 let ext = super::super::OpenAICompletionsExt::default();
3487
3488 assert!(ext.requires_modern_output_cap("gpt-5-nano"));
3489 assert!(!ext.requires_modern_output_cap(GPT_4O_MINI));
3490 }
3491
3492 #[test]
3493 fn test_max_tokens_omitted_when_none() {
3494 let request = crate::completion::CompletionRequest {
3495 model: None,
3496 preamble: None,
3497 chat_history: vec!["Hello".into()],
3498 documents: vec![],
3499 tools: vec![],
3500 temperature: None,
3501 max_tokens: None,
3502 tool_choice: None,
3503 additional_params: None,
3504 output_schema: None,
3505 record_telemetry_content: false,
3506 };
3507
3508 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3509 model: "gpt-4o-mini".to_string(),
3510 request,
3511 strict_tools: false,
3512 tool_result_array_content: false,
3513 supports_response_format: true,
3514 supports_tools: true,
3515 })
3516 .expect("request conversion should succeed");
3517 let serialized =
3518 serde_json::to_value(openai_request).expect("serialization should succeed");
3519
3520 assert!(serialized.get("max_tokens").is_none());
3521 }
3522
3523 #[test]
3531 fn additional_params_function_tools_merge_and_native_tools_stay() {
3532 let request = CoreCompletionRequest {
3533 model: None,
3534 preamble: None,
3535 chat_history: vec!["Hello".into()],
3536 documents: vec![],
3537 tools: vec![crate::completion::ToolDefinition {
3538 name: "builder_tool".to_string(),
3539 description: "from the builder".to_string(),
3540 parameters: serde_json::json!({"type": "object", "properties": {}}),
3541 }],
3542 temperature: None,
3543 max_tokens: None,
3544 tool_choice: None,
3545 additional_params: Some(serde_json::json!({
3546 "tools": [
3547 {
3548 "type": "function",
3549 "function": {
3550 "name": "params_tool",
3551 "description": "from additional_params",
3552 "parameters": {"type": "object", "properties": {}}
3553 }
3554 },
3555 {"type": "browser_search"}
3556 ]
3557 })),
3558 output_schema: None,
3559 record_telemetry_content: false,
3560 };
3561
3562 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3563 model: "gpt-4o-mini".to_string(),
3564 request,
3565 strict_tools: false,
3566 tool_result_array_content: false,
3567 supports_response_format: true,
3568 supports_tools: true,
3569 })
3570 .expect("request conversion should succeed");
3571
3572 let names: Vec<&str> = openai_request
3573 .tools
3574 .iter()
3575 .map(|tool| tool.function.name.as_str())
3576 .collect();
3577 assert_eq!(names, vec!["builder_tool", "params_tool"]);
3578 assert_eq!(
3579 openai_request.additional_params,
3580 Some(serde_json::json!({"tools": [{"type": "browser_search"}]}))
3581 );
3582 }
3583
3584 #[test]
3585 fn request_conversion_errors_when_all_messages_are_filtered() {
3586 let request = CoreCompletionRequest {
3587 model: None,
3588 preamble: None,
3589 chat_history: vec![message::Message::Assistant {
3590 id: None,
3591 content: vec![message::AssistantContent::reasoning("hidden")],
3592 }],
3593 documents: vec![],
3594 tools: vec![],
3595 temperature: None,
3596 max_tokens: None,
3597 tool_choice: None,
3598 additional_params: None,
3599 output_schema: None,
3600 record_telemetry_content: false,
3601 };
3602
3603 let result = CompletionRequest::try_from(OpenAIRequestParams {
3604 model: "gpt-4o-mini".to_string(),
3605 request,
3606 strict_tools: false,
3607 tool_result_array_content: false,
3608 supports_response_format: true,
3609 supports_tools: true,
3610 });
3611
3612 assert!(matches!(result, Err(CompletionError::RequestError(_))));
3613 }
3614
3615 #[test]
3616 fn request_conversion_omits_response_format_on_initial_tool_turn() {
3617 let request = CoreCompletionRequest {
3618 model: None,
3619 preamble: None,
3620 chat_history: vec![message::Message::user(
3621 "Hello, whats the weather in London?",
3622 )],
3623 documents: vec![],
3624 tools: vec![completion::ToolDefinition {
3625 name: "weather".to_string(),
3626 description: "Get the weather".to_string(),
3627 parameters: serde_json::json!({
3628 "type": "object",
3629 "properties": {
3630 "city": { "type": "string" }
3631 },
3632 "required": ["city"]
3633 }),
3634 }],
3635 temperature: None,
3636 max_tokens: None,
3637 tool_choice: None,
3638 additional_params: None,
3639 output_schema: Some(
3640 serde_json::from_value(serde_json::json!({
3641 "title": "WeatherResponse",
3642 "type": "object",
3643 "properties": {
3644 "city": { "type": "string" },
3645 "weather": { "type": "string" }
3646 },
3647 "required": ["city", "weather"]
3648 }))
3649 .expect("schema should deserialize"),
3650 ),
3651 record_telemetry_content: false,
3652 };
3653
3654 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3655 model: "gpt-4o-mini".to_string(),
3656 request,
3657 strict_tools: false,
3658 tool_result_array_content: false,
3659 supports_response_format: true,
3660 supports_tools: true,
3661 })
3662 .expect("request conversion should succeed");
3663
3664 let serialized =
3665 serde_json::to_value(openai_request).expect("serialization should succeed");
3666
3667 assert!(
3668 serialized.get("response_format").is_none(),
3669 "initial tool turn should omit response_format: {serialized:?}"
3670 );
3671 }
3672
3673 #[test]
3674 fn request_conversion_restores_response_format_after_tool_result() {
3675 let request = CoreCompletionRequest {
3676 model: None,
3677 preamble: None,
3678 chat_history: vec![
3679 message::Message::user("Hello, whats the weather in London?"),
3680 message::Message::Assistant {
3681 id: None,
3682 content: vec![message::AssistantContent::tool_call(
3683 "call_1",
3684 "weather",
3685 serde_json::json!({ "city": "London" }),
3686 )],
3687 },
3688 message::Message::tool_result(
3689 "call_1",
3690 "weather",
3691 "The weather in London is all fire and brimstone",
3692 ),
3693 ],
3694 documents: vec![],
3695 tools: vec![completion::ToolDefinition {
3696 name: "weather".to_string(),
3697 description: "Get the weather".to_string(),
3698 parameters: serde_json::json!({
3699 "type": "object",
3700 "properties": {
3701 "city": { "type": "string" }
3702 },
3703 "required": ["city"]
3704 }),
3705 }],
3706 temperature: None,
3707 max_tokens: None,
3708 tool_choice: None,
3709 additional_params: None,
3710 output_schema: Some(
3711 serde_json::from_value(serde_json::json!({
3712 "title": "WeatherResponse",
3713 "type": "object",
3714 "properties": {
3715 "city": { "type": "string" },
3716 "weather": { "type": "string" }
3717 },
3718 "required": ["city", "weather"]
3719 }))
3720 .expect("schema should deserialize"),
3721 ),
3722 record_telemetry_content: false,
3723 };
3724
3725 let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3726 model: "gpt-4o-mini".to_string(),
3727 request,
3728 strict_tools: false,
3729 tool_result_array_content: false,
3730 supports_response_format: true,
3731 supports_tools: true,
3732 })
3733 .expect("request conversion should succeed");
3734
3735 let serialized =
3736 serde_json::to_value(openai_request).expect("serialization should succeed");
3737
3738 assert!(
3739 serialized.get("response_format").is_some(),
3740 "follow-up turn should restore response_format: {serialized:?}"
3741 );
3742 }
3743
3744 #[test]
3745 fn deserialize_llama_cpp_tool_call() {
3746 let request = r#"{
3747 "choices": [{
3748 "finish_reason": "tool_calls",
3749 "index": 0,
3750 "message": {
3751 "role": "assistant",
3752 "content": "",
3753 "tool_calls": [{ "type": "function", "function": { "name": "hello_world", "arguments": { "city": "Paris" } }, "id": "xxx" }]
3754 }
3755 }],
3756 "created": 0,
3757 "model": "gpt-4o-mini",
3758 "system_fingerprint": "fp_xxx",
3759 "object": "chat.completion",
3760 "usage": { "completion_tokens": 13, "prompt_tokens": 255, "total_tokens": 268 },
3761 "id": "xxx"
3762 }
3763 "#;
3764 let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
3765
3766 let ApiResponse::Ok(response) = response else {
3767 panic!("expected successful completion response");
3768 };
3769 assert_eq!(response.choices.len(), 1);
3770
3771 let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3772 panic!("expected assistant message");
3773 };
3774 assert_eq!(tool_calls.len(), 1);
3775 assert_eq!(tool_calls[0].id, "xxx");
3776 assert_eq!(tool_calls[0].function.name, "hello_world");
3777 assert_eq!(
3778 tool_calls[0].function.arguments,
3779 serde_json::json!({"city": "Paris"})
3780 );
3781 }
3782
3783 #[test]
3784 fn deserialize_openai_stringified_tool_call() {
3785 let request = r#"{
3786 "choices": [{
3787 "finish_reason": "tool_calls",
3788 "index": 0,
3789 "message": {
3790 "role": "assistant",
3791 "content": "",
3792 "tool_calls": [{ "type": "function", "function": { "name": "hello_world", "arguments": "{\"city\":\"Paris\"}" }, "id": "xxx" }]
3793 }
3794 }],
3795 "created": 0,
3796 "model": "gpt-4o-mini",
3797 "system_fingerprint": "fp_xxx",
3798 "object": "chat.completion",
3799 "usage": { "completion_tokens": 13, "prompt_tokens": 255, "total_tokens": 268 },
3800 "id": "xxx"
3801 }
3802 "#;
3803 let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
3804
3805 let ApiResponse::Ok(response) = response else {
3806 panic!("expected successful completion response");
3807 };
3808 assert_eq!(response.choices.len(), 1);
3809
3810 let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3811 panic!("expected assistant message");
3812 };
3813 assert_eq!(tool_calls.len(), 1);
3814 assert_eq!(tool_calls[0].id, "xxx");
3815 assert_eq!(tool_calls[0].function.name, "hello_world");
3816 assert_eq!(
3817 tool_calls[0].function.arguments,
3818 serde_json::json!({"city": "Paris"})
3819 );
3820 }
3821
3822 #[test]
3830 fn truncated_tool_arguments_do_not_destroy_the_response() {
3831 let request = r#"{
3832 "choices": [{
3833 "finish_reason": "length",
3834 "index": 0,
3835 "message": {
3836 "role": "assistant",
3837 "content": "Acknowledged.",
3838 "tool_calls": [
3839 { "type": "function", "id": "call_1", "function": { "name": "page", "arguments": "{\"team\":\"platform\"}" } },
3840 { "type": "function", "id": "call_2", "function": { "name": "file_report", "arguments": "{\"summary\": " } }
3841 ]
3842 }
3843 }],
3844 "created": 0,
3845 "model": "gpt-4o-mini",
3846 "object": "chat.completion",
3847 "usage": { "completion_tokens": 24, "prompt_tokens": 372, "total_tokens": 396 },
3848 "id": "chatcmpl-truncated"
3849 }
3850 "#;
3851
3852 let ApiResponse::Ok(response) =
3853 serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap()
3854 else {
3855 panic!("expected successful completion response");
3856 };
3857
3858 let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3859 panic!("expected assistant message");
3860 };
3861 assert_eq!(
3862 tool_calls.len(),
3863 1,
3864 "the unusable call is dropped at decode; the complete one survives"
3865 );
3866
3867 let converted = response.normalize("openai").unwrap();
3868
3869 assert_eq!(
3870 converted.finish_reason(),
3871 Some(crate::completion::FinishReason::Length)
3872 );
3873 assert_eq!(converted.usage.total_tokens, 396);
3874 assert_eq!(converted.response_id.as_deref(), Some("chatcmpl-truncated"));
3875 let names = converted
3876 .choice
3877 .iter()
3878 .filter_map(|content| match content {
3879 completion::AssistantContent::ToolCall(call) => Some(call.function.name.as_str()),
3880 _ => None,
3881 })
3882 .collect::<Vec<_>>();
3883 assert_eq!(names, vec!["page"], "only the truncated call is dropped");
3884 assert!(
3885 converted.choice.iter().any(|content| matches!(
3886 content,
3887 completion::AssistantContent::Text(text) if text.text == "Acknowledged."
3888 )),
3889 "the turn's text survives: {:?}",
3890 converted.choice
3891 );
3892 }
3893
3894 fn response_with_tool_call(finish_reason: &str, call: serde_json::Value) -> serde_json::Value {
3895 serde_json::json!({
3896 "choices": [{
3897 "finish_reason": finish_reason,
3898 "index": 0,
3899 "message": {
3900 "role": "assistant",
3901 "content": "",
3902 "tool_calls": [call]
3903 },
3904 "logprobs": null
3905 }],
3906 "created": 0,
3907 "model": "gpt-4o-mini",
3908 "object": "chat.completion",
3909 "system_fingerprint": null,
3910 "usage": { "completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2 },
3911 "id": "chatcmpl-tool-call"
3912 })
3913 }
3914
3915 #[test]
3918 fn malformed_completed_tool_call_is_not_silently_dropped() {
3919 let response = response_with_tool_call(
3920 "tool_calls",
3921 serde_json::json!({
3922 "type": "function",
3923 "id": "call_1",
3924 "function": { "name": "page", "arguments": "{\"team\":" }
3925 }),
3926 );
3927
3928 assert!(
3929 serde_json::from_value::<CompletionResponse>(response).is_err(),
3930 "ordinary malformed tool output must remain a loud response defect"
3931 );
3932 }
3933
3934 #[test]
3937 fn truncated_tool_call_with_a_compound_defect_is_not_dropped() {
3938 let response = response_with_tool_call(
3939 "length",
3940 serde_json::json!({
3941 "type": "not_a_real_tool_type",
3942 "id": "call_1",
3943 "function": { "name": "page", "arguments": "{\"team\":" }
3944 }),
3945 );
3946
3947 assert!(
3948 serde_json::from_value::<CompletionResponse>(response).is_err(),
3949 "the unknown type must remain loud even beside truncated arguments"
3950 );
3951 }
3952
3953 #[test]
3957 fn output_length_drops_a_tool_call_with_no_argument_tokens() {
3958 let response = response_with_tool_call(
3959 "length",
3960 serde_json::json!({
3961 "type": "function",
3962 "id": "call_1",
3963 "function": { "name": "page", "arguments": "" }
3964 }),
3965 );
3966 let response: CompletionResponse =
3967 serde_json::from_value(response).expect("the truncated turn should survive");
3968 let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3969 panic!("expected assistant message");
3970 };
3971 assert!(tool_calls.is_empty());
3972 }
3973
3974 #[test]
3984 fn tolerant_tool_arguments_leave_complete_payloads_alone() {
3985 let request = r#"{
3986 "choices": [{
3987 "finish_reason": "tool_calls",
3988 "index": 0,
3989 "message": {
3990 "role": "assistant",
3991 "content": "",
3992 "tool_calls": [
3993 { "type": "function", "id": "a", "function": { "name": "ping", "arguments": "" } },
3994 { "type": "function", "id": "b", "function": { "name": "hello", "arguments": { "city": "Paris" } } },
3995 { "type": "function", "id": "c", "function": { "name": "pong", "arguments": "null" } },
3996 { "type": "function", "id": "d", "function": { "name": "pang", "arguments": null } }
3997 ]
3998 }
3999 }],
4000 "created": 0,
4001 "model": "gpt-4o-mini",
4002 "object": "chat.completion",
4003 "usage": { "completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2 },
4004 "id": "chatcmpl-complete"
4005 }
4006 "#;
4007
4008 let ApiResponse::Ok(response) =
4009 serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap()
4010 else {
4011 panic!("expected successful completion response");
4012 };
4013 let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
4014 panic!("expected assistant message");
4015 };
4016 assert_eq!(tool_calls[0].function.arguments, serde_json::json!({}));
4017 assert_eq!(
4018 tool_calls[1].function.arguments,
4019 serde_json::json!({"city": "Paris"})
4020 );
4021 assert_eq!(
4022 tool_calls[2].function.arguments,
4023 serde_json::Value::Null,
4024 "Groq's `\"null\"` spelling parses, so the call survives untouched — \
4025 which is exactly why `null` cannot be a truncation sentinel"
4026 );
4027 assert_eq!(
4028 tool_calls[3].function.arguments,
4029 serde_json::Value::Null,
4030 "and the same for a bare JSON null in the non-string branch"
4031 );
4032
4033 let converted = response.normalize("openai").unwrap();
4034 assert_eq!(
4035 converted
4036 .choice
4037 .iter()
4038 .filter(|content| matches!(content, completion::AssistantContent::ToolCall(_)))
4039 .count(),
4040 4,
4041 "every completed parameterless call survives"
4042 );
4043 }
4044
4045 #[test]
4046 fn deserialize_llama_cpp_response_with_reasoning_content() {
4047 let request = r#"
4048 {
4049 "choices": [
4050 {
4051 "finish_reason": "stop",
4052 "index": 0,
4053 "message": {
4054 "role": "assistant",
4055 "content": "",
4056 "reasoning_content": "Now I understand the structure better. I need to: ..."
4057 }
4058 }
4059 ],
4060 "created": 1776750378,
4061 "model": "unsloth/Qwen3.6-35B-A3B-GGUF:Q8_0",
4062 "system_fingerprint": "fp_xxx",
4063 "object": "chat.completion",
4064 "usage": {
4065 "completion_tokens": 920,
4066 "prompt_tokens": 27806,
4067 "total_tokens": 28726,
4068 "prompt_tokens_details": { "cached_tokens": 18698 }
4069 },
4070 "id": "chatcmpl-xxxx",
4071 "timings": {
4072 "cache_n": 18698,
4073 "prompt_n": 9108,
4074 "prompt_ms": 226645.81,
4075 "prompt_per_token_ms": 24.884256697408873,
4076 "prompt_per_second": 40.186050648807495,
4077 "predicted_n": 920,
4078 "predicted_ms": 177167.955,
4079 "predicted_per_token_ms": 192.57386413043477,
4080 "predicted_per_second": 5.192812661860888
4081 }
4082 }
4083 "#;
4084 let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
4085 let ApiResponse::Ok(response) = response else {
4086 panic!("expected successful completion response");
4087 };
4088
4089 let response: completion::CompletionResponse =
4090 response
4091 .normalize(<crate::providers::openai::OpenAICompletionsExt as OpenAICompatibleProvider>::PROVIDER_NAME)
4092 .unwrap();
4093
4094 assert_eq!(response.choice.len(), 1);
4095
4096 let Some(completion::message::AssistantContent::Reasoning(reasoning)) =
4097 response.choice.first()
4098 else {
4099 panic!("expected assistant content to be reasoning");
4100 };
4101 assert_eq!(
4102 reasoning.first_text(),
4103 Some("Now I understand the structure better. I need to: ...")
4104 );
4105 }
4106
4107 #[test]
4108 fn pdf_base64_document_serializes_as_file_content_part() {
4109 let doc = message::UserContent::Document(message::Document {
4110 data: DocumentSourceKind::Base64("JVBERi0xLjQK".into()),
4111 media_type: Some(message::DocumentMediaType::PDF),
4112 additional_params: None,
4113 });
4114 let converted: UserContent = doc.try_into().expect("conversion should succeed");
4115 let json = serde_json::to_value(&converted).expect("serialize");
4116
4117 assert_eq!(json["type"], "file");
4118 assert_eq!(
4119 json["file"]["file_data"],
4120 "data:application/pdf;base64,JVBERi0xLjQK"
4121 );
4122 assert_eq!(json["file"]["filename"], "document.pdf");
4123 assert!(json["file"].get("file_id").is_none());
4124 }
4125
4126 #[test]
4127 fn file_id_document_serializes_as_file_content_part() {
4128 let doc = message::UserContent::Document(message::Document {
4129 data: DocumentSourceKind::FileId("file_abc".into()),
4130 media_type: None,
4131 additional_params: None,
4132 });
4133 let converted: UserContent = doc.try_into().expect("conversion should succeed");
4134 let json = serde_json::to_value(&converted).expect("serialize");
4135
4136 assert_eq!(json["type"], "file");
4137 assert_eq!(json["file"]["file_id"], "file_abc");
4138 assert!(json["file"].get("file_data").is_none());
4139 }
4140
4141 #[test]
4142 fn base64_image_without_detail_defaults_to_auto() {
4143 let image = message::UserContent::Image(message::Image {
4144 data: DocumentSourceKind::Base64("iVBORw0KGgo=".into()),
4145 media_type: Some(message::ImageMediaType::PNG),
4146 detail: None,
4147 additional_params: None,
4148 });
4149 let converted: UserContent = image.try_into().expect("conversion should succeed");
4150 let UserContent::Image { image_url } = converted else {
4151 panic!("expected image content");
4152 };
4153
4154 assert_eq!(image_url.url, "data:image/png;base64,iVBORw0KGgo=");
4155 assert_eq!(image_url.detail, Some(ImageDetail::Auto));
4156 }
4157
4158 #[test]
4161 fn non_pdf_document_still_serializes_as_text() {
4162 let doc = message::UserContent::Document(message::Document {
4163 data: DocumentSourceKind::String("# Markdown".into()),
4164 media_type: None,
4165 additional_params: None,
4166 });
4167 let converted: UserContent = doc.try_into().expect("conversion should succeed");
4168 let json = serde_json::to_value(&converted).expect("serialize");
4169
4170 assert_eq!(json["type"], "text");
4171 assert_eq!(json["text"], "# Markdown");
4172 }
4173
4174 #[test]
4175 fn pdf_url_document_returns_conversion_error() {
4176 let doc = message::UserContent::Document(message::Document {
4177 data: DocumentSourceKind::Url("https://example.com/x.pdf".into()),
4178 media_type: Some(message::DocumentMediaType::PDF),
4179 additional_params: None,
4180 });
4181 let res: Result<UserContent, _> = doc.try_into();
4182 assert!(matches!(
4183 res,
4184 Err(message::MessageError::ConversionError(_))
4185 ));
4186 }
4187
4188 #[test]
4189 fn pdf_raw_document_returns_conversion_error() {
4190 let doc = message::UserContent::Document(message::Document {
4191 data: DocumentSourceKind::Raw(b"%PDF-1.4\n".to_vec()),
4192 media_type: Some(message::DocumentMediaType::PDF),
4193 additional_params: None,
4194 });
4195 let res: Result<UserContent, _> = doc.try_into();
4196 assert!(matches!(
4197 res,
4198 Err(message::MessageError::ConversionError(_))
4199 ));
4200 }
4201
4202 #[test]
4203 fn file_user_content_deserializes_from_wire_json() {
4204 let raw = r#"{"type":"file","file":{"file_data":"data:application/pdf;base64,AAAA","filename":"x.pdf"}}"#;
4205 let parsed: UserContent = serde_json::from_str(raw).expect("deserialize");
4206 let UserContent::File { file } = parsed else {
4207 panic!("expected File variant");
4208 };
4209 assert_eq!(
4210 file.file_data.as_deref(),
4211 Some("data:application/pdf;base64,AAAA")
4212 );
4213 assert_eq!(file.filename.as_deref(), Some("x.pdf"));
4214 assert!(file.file_id.is_none());
4215 }
4216
4217 #[test]
4218 fn file_variant_round_trips_back_to_pdf_document() {
4219 let wire = UserContent::File {
4220 file: FileData {
4221 file_data: Some("data:application/pdf;base64,QUJD".to_string()),
4222 file_id: None,
4223 filename: Some("document.pdf".to_string()),
4224 },
4225 };
4226 let rig: message::UserContent = wire.into();
4227 let message::UserContent::Document(doc) = rig else {
4228 panic!("expected Document");
4229 };
4230 assert_eq!(doc.media_type, Some(message::DocumentMediaType::PDF));
4231 assert!(matches!(doc.data, DocumentSourceKind::Base64(ref b) if b == "QUJD"));
4232 }
4233
4234 #[test]
4235 fn file_variant_with_file_id_only_round_trips_to_document_file_id() {
4236 let wire = UserContent::File {
4237 file: FileData {
4238 file_data: None,
4239 file_id: Some("file_abc".to_string()),
4240 filename: None,
4241 },
4242 };
4243 let rig: message::UserContent = wire.into();
4244 let message::UserContent::Document(doc) = rig else {
4245 panic!("expected Document");
4246 };
4247 assert_eq!(doc.media_type, None);
4248 assert!(matches!(doc.data, DocumentSourceKind::FileId(ref id) if id == "file_abc"));
4249
4250 let converted: UserContent = message::UserContent::Document(doc)
4251 .try_into()
4252 .expect("conversion should succeed");
4253 let json = serde_json::to_value(&converted).expect("serialize");
4254
4255 assert_eq!(json["type"], "file");
4256 assert_eq!(json["file"]["file_id"], "file_abc");
4257 assert!(json["file"].get("file_data").is_none());
4258 }
4259
4260 #[test]
4263 fn mixed_text_and_pdf_user_message_produces_two_content_parts() {
4264 let user = message::Message::User {
4265 content: vec![
4266 message::UserContent::text("What is in this PDF?"),
4267 message::UserContent::Document(message::Document {
4268 data: DocumentSourceKind::Base64("JVBERi0K".into()),
4269 media_type: Some(message::DocumentMediaType::PDF),
4270 additional_params: None,
4271 }),
4272 ],
4273 };
4274 let converted: Vec<Message> = user.try_into().expect("conversion should succeed");
4275 assert_eq!(converted.len(), 1);
4276 let Message::User { content, .. } = &converted[0] else {
4277 panic!("expected user message");
4278 };
4279 let parts: Vec<&UserContent> = content.iter().collect();
4280 assert_eq!(parts.len(), 2);
4281 assert!(matches!(parts[0], UserContent::Text { .. }));
4282 assert!(matches!(parts[1], UserContent::File { .. }));
4283 }
4284
4285 #[tokio::test]
4286 async fn completion_preserves_raw_provider_error_json_on_api_error_envelope() {
4287 use crate::client::CompletionClient;
4288 use crate::completion::CompletionModel;
4289 use crate::providers::openai::CompletionsClient;
4290 use crate::test_utils::RecordingHttpClient;
4291
4292 let body = r#"{"message":"slow down","type":"rate_limit","code":"rate_limit_exceeded"}"#;
4293 let http_client =
4294 RecordingHttpClient::with_error_response(http::StatusCode::ACCEPTED, body);
4295 let client = CompletionsClient::builder()
4296 .api_key("test-key")
4297 .http_client(http_client)
4298 .build()
4299 .expect("build client");
4300 let model = client.completion_model("gpt-4o-mini");
4301 let request = model.completion_request("hello").build();
4302
4303 let error = model
4304 .completion(request)
4305 .await
4306 .expect_err("completion should fail with provider error envelope");
4307
4308 match &error {
4309 CompletionError::ProviderResponse(stored) => {
4310 assert_eq!(stored.body, body);
4311 assert_eq!(stored.status, Some(http::StatusCode::ACCEPTED));
4312 assert_eq!(error.provider_response_body(), Some(body));
4313 assert_eq!(
4314 error.provider_response_status(),
4315 Some(http::StatusCode::ACCEPTED)
4316 );
4317 let json = error
4318 .provider_response_json()
4319 .expect("raw body should be valid JSON")
4320 .expect("parsed JSON should be present");
4321 assert_eq!(json["code"], "rate_limit_exceeded");
4322 assert_eq!(json["type"], "rate_limit");
4323 }
4324 other => panic!("expected ProviderResponse, got {other:?}"),
4325 }
4326 }
4327
4328 #[tokio::test]
4329 async fn completion_http_non_success_preserves_status_and_body() {
4330 use crate::client::CompletionClient;
4331 use crate::completion::CompletionModel;
4332 use crate::providers::openai::CompletionsClient;
4333 use crate::test_utils::RecordingHttpClient;
4334
4335 let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error"}}"#;
4336 let http_client =
4337 RecordingHttpClient::with_error_response(http::StatusCode::TOO_MANY_REQUESTS, body);
4338 let client = CompletionsClient::builder()
4339 .api_key("test-key")
4340 .http_client(http_client)
4341 .build()
4342 .expect("build client");
4343 let model = client.completion_model("gpt-4o-mini");
4344 let request = model.completion_request("hello").build();
4345
4346 let error = model
4347 .completion(request)
4348 .await
4349 .expect_err("completion should fail with non-success status");
4350
4351 assert!(matches!(error, CompletionError::ProviderResponse(_)));
4355 assert_eq!(error.provider_request_id(), None);
4356 assert_eq!(
4357 error.provider_response_status(),
4358 Some(http::StatusCode::TOO_MANY_REQUESTS)
4359 );
4360 assert_eq!(error.provider_response_body(), Some(body));
4361 let json = error
4362 .provider_response_json()
4363 .expect("raw body should be valid JSON")
4364 .expect("parsed JSON should be present");
4365 assert_eq!(json["error"]["type"], "rate_limit_error");
4366 }
4367
4368 mod raw_capture {
4376 use super::*;
4377 use crate::client::CompletionClient;
4378 use crate::completion::CompletionModel as _;
4379 use crate::providers::openai::CompletionsClient;
4380 use crate::test_utils::RecordingHttpClient;
4381
4382 const REQUEST_ID: &str = "req_unit_chat_0001";
4383
4384 const BODY: &str = r#"{
4388 "id": "chatcmpl-raw-1",
4389 "object": "chat.completion",
4390 "created": 1700000000,
4391 "model": "gpt-4o-mini-2024-07-18",
4392 "system_fingerprint": "fp_unit_test",
4393 "service_tier": "default",
4394 "choices": [{
4395 "index": 0,
4396 "message": {"role": "assistant", "content": "hello"},
4397 "logprobs": null,
4398 "finish_reason": "stop"
4399 }],
4400 "usage": {"prompt_tokens": 4, "completion_tokens": 1, "total_tokens": 5}
4401 }"#;
4402
4403 fn model() -> CompletionModel<RecordingHttpClient> {
4404 let mut headers = http::HeaderMap::new();
4405 headers.insert("x-request-id", http::HeaderValue::from_static(REQUEST_ID));
4406 let http_client = RecordingHttpClient::with_error_response_headers(
4407 http::StatusCode::OK,
4408 BODY,
4409 headers,
4410 );
4411 let client = CompletionsClient::builder()
4412 .api_key("test-key")
4413 .http_client(http_client)
4414 .build()
4415 .expect("build client");
4416 client.completion_model("gpt-4o-mini")
4417 }
4418
4419 #[tokio::test]
4427 async fn completion_captures_raw_that_round_trips_into_the_wire_type() {
4428 let model = model();
4429
4430 let response = model
4431 .completion(model.completion_request("hello").build())
4432 .await
4433 .expect("completion");
4434
4435 let raw = &response.raw;
4436 let typed = super::CompletionResponse::deserialize(raw)
4437 .expect("raw must deserialize into the provider wire type");
4438 assert_eq!(
4439 serde_json::to_value(&typed).expect("re-serialize"),
4440 *raw,
4441 "the capture must be exactly what the wire type serializes to"
4442 );
4443 assert_eq!(typed.system_fingerprint.as_deref(), Some("fp_unit_test"));
4444 assert_eq!(raw["service_tier"], "default");
4445
4446 let renormalized = typed
4448 .normalize(<crate::providers::openai::OpenAICompletionsExt as OpenAICompatibleProvider>::PROVIDER_NAME)
4449 .expect("re-normalize the capture")
4450 .with_optional_provider_request_id(Some(REQUEST_ID.to_string()));
4451 assert_eq!(response.identity(), renormalized.identity());
4452 assert_eq!(response.finish_reason(), renormalized.finish_reason());
4453 assert_eq!(response.model, renormalized.model);
4454 assert_eq!(response.usage, renormalized.usage);
4455 assert_eq!(response.choice, renormalized.choice);
4456 assert_eq!(response.provider_request_id.as_deref(), Some(REQUEST_ID));
4457 assert_eq!(
4458 response.finish_reason(),
4459 Some(crate::completion::FinishReason::Stop)
4460 );
4461 }
4462
4463 #[tokio::test]
4470 async fn raw_completion_with_request_id_reproduces_completion() {
4471 let model = model();
4472
4473 let (raw, id) = model
4474 .raw_completion_with_request_id(model.completion_request("hello").build())
4475 .await
4476 .expect("typed route");
4477 assert_eq!(id.as_deref(), Some(REQUEST_ID));
4478 let reassembled = raw
4479 .normalize(<crate::providers::openai::OpenAICompletionsExt as OpenAICompatibleProvider>::PROVIDER_NAME)
4480 .expect("normalize")
4481 .with_optional_provider_request_id(id);
4482
4483 let normalized = model
4484 .completion(model.completion_request("hello").build())
4485 .await
4486 .expect("normalized route");
4487
4488 assert_eq!(reassembled.identity(), normalized.identity());
4489 assert_eq!(reassembled.finish_reason(), normalized.finish_reason());
4490 assert_eq!(reassembled.model, normalized.model);
4491 assert_eq!(reassembled.usage, normalized.usage);
4492 assert_eq!(reassembled.provider_request_id.as_deref(), Some(REQUEST_ID));
4493 assert_eq!(normalized.provider_request_id.as_deref(), Some(REQUEST_ID));
4494 }
4495 }
4496}