1use anyhow::{Context as _, Result};
2use async_openai::types::chat::{
3 ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
4 ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessageContentPartAudio,
5 ChatCompletionRequestMessageContentPartImage, ChatCompletionRequestMessageContentPartText,
6 ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestToolMessageArgs,
7 ChatCompletionRequestUserMessageArgs, ChatCompletionRequestUserMessageContent,
8 ChatCompletionRequestUserMessageContentPart, ChatCompletionResponseStream,
9 ChatCompletionStreamOptions, ChatCompletionToolChoiceOption, ChatCompletionTools,
10 CreateChatCompletionResponse, CreateChatCompletionStreamResponse, FunctionCall, FunctionObject,
11 ImageUrl, InputAudio, InputAudioFormat, ToolChoiceOptions,
12};
13use async_trait::async_trait;
14use base64::Engine as _;
15use futures_util::StreamExt as _;
16use futures_util::stream;
17use itertools::Itertools;
18use serde::Serialize;
19use swiftide_core::ChatCompletionStream;
20use swiftide_core::chat_completion::Usage;
21use swiftide_core::chat_completion::{
22 ChatCompletion, ChatCompletionRequest, ChatCompletionResponse, ChatMessage,
23 ChatMessageContentPart, ChatMessageContentSource, ToolCall, ToolSpec,
24 errors::LanguageModelError,
25};
26#[cfg(feature = "metrics")]
27use swiftide_core::metrics::emit_usage;
28
29use super::responses_api::{
30 build_responses_request_from_chat, response_to_chat_completion, responses_stream_adapter,
31};
32use super::tool_schema::OpenAiToolSchema;
33use super::{GenericOpenAI, openai_error_to_language_model_error, request_body_with_extra_body};
34use tracing_futures::Instrument;
35
36#[async_trait]
37impl<
38 C: async_openai::config::Config
39 + std::default::Default
40 + Sync
41 + Send
42 + std::fmt::Debug
43 + Clone
44 + 'static,
45> ChatCompletion for GenericOpenAI<C>
46{
47 #[cfg_attr(not(feature = "langfuse"), tracing::instrument(skip_all, err))]
48 #[cfg_attr(
49 feature = "langfuse",
50 tracing::instrument(skip_all, err, fields(langfuse.type = "GENERATION"))
51 )]
52 async fn complete(
53 &self,
54 request: &ChatCompletionRequest<'_>,
55 ) -> Result<ChatCompletionResponse, LanguageModelError> {
56 if self.is_responses_api_enabled() {
57 return self.complete_via_responses_api(request).await;
58 }
59
60 let model = self
61 .default_options
62 .prompt_model
63 .as_ref()
64 .context("Model not set")?;
65
66 let messages = request
67 .messages()
68 .iter()
69 .filter_map(|message| message_to_openai(message).transpose())
70 .collect::<Result<Vec<_>>>()?;
71
72 let mut openai_request = self
74 .chat_completion_request_defaults()
75 .model(model)
76 .messages(messages)
77 .to_owned();
78
79 if !request.tools_spec().is_empty() {
80 openai_request
81 .tools(
82 request
83 .tools_spec()
84 .iter()
85 .map(tools_to_openai)
86 .collect::<Result<Vec<_>>>()?,
87 )
88 .tool_choice(ChatCompletionToolChoiceOption::Mode(
89 ToolChoiceOptions::Auto,
90 ));
91 if let Some(par) = self.default_options.parallel_tool_calls {
92 openai_request.parallel_tool_calls(par);
93 }
94 }
95
96 let openai_request = openai_request
97 .build()
98 .map_err(openai_error_to_language_model_error)?;
99
100 tracing::trace!(model, request = ?request, "Sending request to OpenAI");
101
102 let tracking_request = openai_request.clone();
103 let body = request_body_with_extra_body(&openai_request, &self.default_options.extra_body)?;
104 let response: CreateChatCompletionResponse = self
105 .client
106 .chat()
107 .create_byot(body)
108 .await
109 .map_err(openai_error_to_language_model_error)?;
110
111 tracing::trace!(?response, "[ChatCompletion] Full response from OpenAI");
112 let mut builder = ChatCompletionResponse::builder()
115 .maybe_message(
116 response
117 .choices
118 .first()
119 .and_then(|choice| choice.message.content.clone()),
120 )
121 .maybe_tool_calls(
122 response
123 .choices
124 .first()
125 .and_then(|choice| choice.message.tool_calls.as_ref())
126 .map(|tool_calls| {
127 tool_calls
128 .iter()
129 .filter_map(|tool_call| match tool_call {
130 ChatCompletionMessageToolCalls::Function(call) => Some(
131 ToolCall::builder()
132 .id(call.id.clone())
133 .args(call.function.arguments.clone())
134 .name(call.function.name.clone())
135 .build()
136 .expect("infallible"),
137 ),
138 ChatCompletionMessageToolCalls::Custom(_) => None,
139 })
140 .collect_vec()
141 }),
142 )
143 .to_owned();
144
145 if let Some(usage) = &response.usage {
146 builder.usage(Usage::from(usage));
147 }
148
149 let our_response = builder.build().map_err(LanguageModelError::from)?;
150
151 self.track_completion(
152 model,
153 our_response.usage.as_ref(),
154 Some(&tracking_request),
155 Some(&our_response),
156 );
157
158 Ok(our_response)
159 }
160
161 #[tracing::instrument(skip_all)]
162 async fn complete_stream(&self, request: &ChatCompletionRequest<'_>) -> ChatCompletionStream {
163 if self.is_responses_api_enabled() {
164 return self.complete_stream_via_responses_api(request).await;
165 }
166
167 match self.try_complete_stream(request).await {
168 Ok(stream) => stream,
169 Err(err) => err.into(),
170 }
171 }
172}
173
174impl<
175 C: async_openai::config::Config
176 + std::default::Default
177 + Sync
178 + Send
179 + std::fmt::Debug
180 + Clone
181 + 'static,
182> GenericOpenAI<C>
183{
184 #[allow(clippy::too_many_lines)]
185 async fn try_complete_stream(
186 &self,
187 request: &ChatCompletionRequest<'_>,
188 ) -> Result<ChatCompletionStream, LanguageModelError> {
189 let model_name = self
190 .default_options
191 .prompt_model
192 .clone()
193 .ok_or_else(|| LanguageModelError::permanent("Model not set"))?;
194
195 #[cfg(not(any(feature = "metrics", feature = "langfuse")))]
196 let _ = &model_name;
197
198 let messages = request
199 .messages()
200 .iter()
201 .filter_map(|message| message_to_openai(message).transpose())
202 .collect::<Result<Vec<_>>>()?;
203
204 let mut openai_request = self
206 .chat_completion_request_defaults()
207 .model(&model_name)
208 .messages(messages)
209 .stream(true)
210 .stream_options(ChatCompletionStreamOptions {
211 include_usage: Some(true),
212 include_obfuscation: None,
213 })
214 .to_owned();
215
216 if !request.tools_spec().is_empty() {
217 openai_request
218 .tools(
219 request
220 .tools_spec()
221 .iter()
222 .map(tools_to_openai)
223 .collect::<Result<Vec<_>>>()?,
224 )
225 .tool_choice(ChatCompletionToolChoiceOption::Mode(
226 ToolChoiceOptions::Auto,
227 ));
228 if let Some(par) = self.default_options.parallel_tool_calls {
229 openai_request.parallel_tool_calls(par);
230 }
231 }
232
233 let openai_request = openai_request
234 .build()
235 .map_err(openai_error_to_language_model_error)?;
236
237 tracing::trace!(model = %model_name, request = ?request, "Sending request to OpenAI");
238
239 let body = request_body_with_extra_body(&openai_request, &self.default_options.extra_body)?;
240 let response_stream: ChatCompletionResponseStream = self
241 .client
242 .chat()
243 .create_stream_byot::<_, CreateChatCompletionStreamResponse>(body)
244 .await
245 .map_err(openai_error_to_language_model_error)?;
246
247 let stream_full = self.stream_full;
248 let model_name_for_track = model_name.clone();
249 let self_for_stream = self.clone();
250 let tracking_request = openai_request;
251
252 let span = if cfg!(feature = "langfuse") {
253 tracing::info_span!("stream", langfuse.type = "GENERATION")
254 } else {
255 tracing::info_span!("stream")
256 };
257
258 let stream = stream::unfold(
259 (
260 response_stream,
261 ChatCompletionResponse::default(),
262 tracking_request.clone(),
263 false, ),
265 move |(mut response_stream, mut state, tracking_request, finished)| {
266 let stream_full = stream_full;
267 let self_for_stream = self_for_stream.clone();
268 let model_name_for_track = model_name_for_track.clone();
269 async move {
270 if finished {
271 return None;
272 }
273
274 match response_stream.next().await {
275 Some(Ok(chunk)) => {
276 let delta_message = chunk
277 .choices
278 .first()
279 .and_then(|d| d.delta.content.as_deref());
280 let delta_tool_calls = chunk
281 .choices
282 .first()
283 .and_then(|d| d.delta.tool_calls.as_deref());
284 let usage = chunk.usage.as_ref();
285
286 state.append_message_delta(delta_message);
287 if let Some(delta_tool_calls) = delta_tool_calls {
288 for tc in delta_tool_calls {
289 state.append_tool_call_delta(
290 tc.index as usize,
291 tc.id.as_deref(),
292 tc.function.as_ref().and_then(|f| f.name.as_deref()),
293 tc.function.as_ref().and_then(|f| f.arguments.as_deref()),
294 );
295 }
296 }
297 if let Some(usage) = usage {
298 let usage = Usage::from(usage);
299 state.append_usage_delta(
300 usage.prompt_tokens,
301 usage.completion_tokens,
302 usage.total_tokens,
303 );
304 }
305
306 let snapshot = if stream_full {
307 state.clone()
308 } else {
309 ChatCompletionResponse {
310 id: state.id,
311 message: None,
312 tool_calls: None,
313 usage: None,
314 reasoning: None,
315 delta: state.delta.clone(),
316 }
317 };
318
319 Some((
320 Ok(snapshot),
321 (response_stream, state, tracking_request, false),
322 ))
323 }
324 Some(Err(err)) => Some((
325 Err(openai_error_to_language_model_error(err)),
326 (response_stream, state, tracking_request, true),
327 )),
328 None => {
329 self_for_stream.track_completion(
331 &model_name_for_track,
332 state.usage.as_ref(),
333 Some(&tracking_request),
334 Some(&state),
335 );
336 let final_snapshot = state.clone();
337 Some((
338 Ok(final_snapshot),
339 (response_stream, state, tracking_request, true),
340 ))
341 }
342 }
343 }
344 },
345 );
346
347 Ok(Box::pin(tracing_futures::Instrument::instrument(
348 stream, span,
349 )))
350 }
351
352 async fn complete_via_responses_api(
353 &self,
354 request: &ChatCompletionRequest<'_>,
355 ) -> Result<ChatCompletionResponse, LanguageModelError> {
356 let model = self
357 .default_options
358 .prompt_model
359 .as_ref()
360 .context("Model not set")?;
361
362 let create_request = build_responses_request_from_chat(self, request)?;
363
364 let body = request_body_with_extra_body(&create_request, &self.default_options.extra_body)?;
365 let response = self
366 .client
367 .responses()
368 .create_byot(body)
369 .await
370 .map_err(openai_error_to_language_model_error)?;
371
372 let completion = response_to_chat_completion(response)?;
373
374 self.track_completion(
375 model,
376 completion.usage.as_ref(),
377 Some(&create_request),
378 Some(&completion),
379 );
380
381 Ok(completion)
382 }
383
384 #[allow(clippy::too_many_lines)]
385 async fn complete_stream_via_responses_api(
386 &self,
387 request: &ChatCompletionRequest<'_>,
388 ) -> ChatCompletionStream {
389 match self.try_complete_stream_via_responses_api(request).await {
390 Ok(stream) => stream,
391 Err(err) => err.into(),
392 }
393 }
394
395 #[allow(clippy::too_many_lines)]
396 async fn try_complete_stream_via_responses_api(
397 &self,
398 request: &ChatCompletionRequest<'_>,
399 ) -> Result<ChatCompletionStream, LanguageModelError> {
400 #[allow(unused_variables)]
401 let model_name = self
402 .default_options
403 .prompt_model
404 .clone()
405 .ok_or_else(|| LanguageModelError::permanent("Model not set"))?;
406
407 let mut create_request = build_responses_request_from_chat(self, request)?;
408
409 create_request.stream = Some(true);
410
411 let body = request_body_with_extra_body(&create_request, &self.default_options.extra_body)?;
412 let stream = self
413 .client
414 .responses()
415 .create_stream_byot::<_, async_openai::types::responses::ResponseStreamEvent>(body)
416 .await
417 .map_err(openai_error_to_language_model_error)?;
418
419 let stream_full = self.stream_full;
420
421 let span = if cfg!(feature = "langfuse") {
422 tracing::info_span!("responses_stream", langfuse.type = "GENERATION")
423 } else {
424 tracing::info_span!("responses_stream")
425 };
426
427 let mapped_stream = responses_stream_adapter(stream, stream_full);
428
429 let this = self.clone();
430 let tracked_request = create_request;
431
432 let mapped_stream = mapped_stream.map(move |result| match result {
433 Ok(item) => {
434 if item.finished {
435 this.track_completion(
436 &model_name,
437 item.response.usage.as_ref(),
438 Some(&tracked_request),
439 Some(&item.response),
440 );
441 }
442
443 Ok(item.response)
444 }
445 Err(err) => Err(err),
446 });
447
448 Ok(Box::pin(Instrument::instrument(mapped_stream, span)))
449 }
450
451 #[allow(unused_variables)]
452 pub(crate) fn track_completion<R, S>(
453 &self,
454 model: &str,
455 usage: Option<&Usage>,
456 request: Option<&R>,
457 response: Option<&S>,
458 ) where
459 R: Serialize + ?Sized,
460 S: Serialize + ?Sized,
461 {
462 if let Some(usage) = usage {
463 let cb_usage = usage.clone();
464 if let Some(callback) = &self.on_usage {
465 let callback = callback.clone();
466 tokio::spawn(async move {
467 if let Err(err) = callback(&cb_usage).await {
468 tracing::error!("Error in on_usage callback: {err}");
469 }
470 });
471 }
472
473 #[cfg(feature = "metrics")]
474 emit_usage(
475 model,
476 usage.prompt_tokens.into(),
477 usage.completion_tokens.into(),
478 usage.total_tokens.into(),
479 self.metric_metadata.as_ref(),
480 );
481 }
482
483 #[cfg(feature = "langfuse")]
484 tracing::debug!(
485 langfuse.model = model,
486 langfuse.input = request.and_then(langfuse_json_redacted).unwrap_or_default(),
487 langfuse.output = response.and_then(langfuse_json).unwrap_or_default(),
488 langfuse.usage = usage.and_then(langfuse_json).unwrap_or_default(),
489 );
490 }
491}
492
493#[cfg(feature = "langfuse")]
494pub(crate) fn langfuse_json<T: Serialize + ?Sized>(value: &T) -> Option<String> {
495 serde_json::to_string_pretty(value).ok()
496}
497
498#[cfg(feature = "langfuse")]
499pub(crate) fn langfuse_json_redacted<T: Serialize + ?Sized>(value: &T) -> Option<String> {
500 let mut value = serde_json::to_value(value).ok()?;
501 redact_image_urls(&mut value);
502 serde_json::to_string_pretty(&value).ok()
503}
504
505#[cfg(feature = "langfuse")]
506fn redact_image_urls(value: &mut serde_json::Value) {
507 match value {
508 serde_json::Value::Object(map) => {
509 if let Some(image_url) = map.get_mut("image_url")
510 && let serde_json::Value::Object(image_obj) = image_url
511 && let Some(serde_json::Value::String(url)) = image_obj.get_mut("url")
512 && let Some(truncated) = truncate_data_url(url)
513 {
514 *url = truncated;
515 }
516
517 for val in map.values_mut() {
518 redact_image_urls(val);
519 }
520 }
521 serde_json::Value::Array(arr) => {
522 for val in arr {
523 redact_image_urls(val);
524 }
525 }
526 _ => {}
527 }
528}
529
530#[cfg(feature = "langfuse")]
531fn truncate_data_url(url: &str) -> Option<String> {
532 const MAX_DATA_PREVIEW: usize = 32;
533
534 if !url.starts_with("data:") {
535 return None;
536 }
537
538 let (prefix, data) = url.split_once(',')?;
539 if data.len() <= MAX_DATA_PREVIEW {
540 return None;
541 }
542
543 let preview = &data[..MAX_DATA_PREVIEW];
544 let truncated = data.len() - MAX_DATA_PREVIEW;
545
546 Some(format!(
547 "{prefix},{preview}...[truncated {truncated} chars]"
548 ))
549}
550
551#[cfg(not(feature = "langfuse"))]
552#[allow(dead_code)]
553pub(crate) fn langfuse_json<T>(_value: &T) -> Option<String> {
554 None
555}
556
557fn tools_to_openai(spec: &ToolSpec) -> Result<ChatCompletionTools> {
558 let parameters = OpenAiToolSchema::try_from(spec)
559 .context("tool schema must be OpenAI compatible")?
560 .into_value();
561
562 let function = FunctionObject {
563 name: spec.name.clone(),
564 description: Some(spec.description.clone()),
565 parameters: Some(parameters),
566 strict: Some(true),
567 };
568
569 Ok(ChatCompletionTools::Function(
570 async_openai::types::chat::ChatCompletionTool { function },
571 ))
572}
573
574fn message_to_openai(
575 message: &ChatMessage,
576) -> Result<Option<async_openai::types::chat::ChatCompletionRequestMessage>> {
577 let openai_message = match message {
578 ChatMessage::User(msg) => ChatCompletionRequestUserMessageArgs::default()
579 .content(msg.as_ref())
580 .build()?
581 .into(),
582 ChatMessage::UserWithParts(parts) => ChatCompletionRequestUserMessageArgs::default()
583 .content(user_parts_to_openai(parts)?)
584 .build()?
585 .into(),
586 ChatMessage::System(msg) => ChatCompletionRequestSystemMessageArgs::default()
587 .content(msg.as_ref())
588 .build()?
589 .into(),
590 ChatMessage::Summary(msg) => ChatCompletionRequestAssistantMessageArgs::default()
591 .content(msg.as_ref())
592 .build()?
593 .into(),
594 ChatMessage::ToolOutput(tool_call, tool_output) => {
595 let Some(content) = tool_output.content() else {
596 return Ok(Some(
597 ChatCompletionRequestToolMessageArgs::default()
598 .tool_call_id(tool_call.id())
599 .build()?
600 .into(),
601 ));
602 };
603
604 ChatCompletionRequestToolMessageArgs::default()
605 .content(content)
606 .tool_call_id(tool_call.id())
607 .build()?
608 .into()
609 }
610 ChatMessage::Assistant(content, tool_calls) => {
611 let mut builder = ChatCompletionRequestAssistantMessageArgs::default();
612
613 let has_tool_calls = tool_calls.as_ref().is_some_and(|calls| !calls.is_empty());
614
615 if let Some(content) = content.as_deref() {
616 builder.content(content);
617 }
618
619 if let Some(tool_calls) = tool_calls.as_ref() {
620 let calls = tool_calls
621 .iter()
622 .map(|tool_call| {
623 ChatCompletionMessageToolCalls::Function(ChatCompletionMessageToolCall {
624 id: tool_call.id().to_string(),
625 function: FunctionCall {
626 name: tool_call.name().to_string(),
627 arguments: tool_call.args().unwrap_or_default().to_string(),
628 },
629 })
630 })
631 .collect::<Vec<_>>();
632
633 builder.tool_calls(calls);
634 }
635
636 if content.is_none() && !has_tool_calls {
637 return Ok(None);
638 }
639
640 builder.build()?.into()
641 }
642 ChatMessage::Reasoning(_) => return Ok(None),
643 };
644
645 Ok(Some(openai_message))
646}
647
648fn user_parts_to_openai(
649 parts: &[ChatMessageContentPart],
650) -> Result<ChatCompletionRequestUserMessageContent> {
651 let mapped = parts
652 .iter()
653 .map(part_to_openai_user_content_part)
654 .collect::<Result<Vec<_>>>()?;
655
656 Ok(ChatCompletionRequestUserMessageContent::Array(mapped))
657}
658
659fn part_to_openai_user_content_part(
660 part: &ChatMessageContentPart,
661) -> Result<ChatCompletionRequestUserMessageContentPart> {
662 Ok(match part {
663 ChatMessageContentPart::Text { text } => ChatCompletionRequestUserMessageContentPart::from(
664 ChatCompletionRequestMessageContentPartText::from(text.as_ref()),
665 ),
666 ChatMessageContentPart::Image { source, .. } => {
667 let image_url = ImageUrl {
668 url: source_to_openai_url(source)?,
669 detail: None,
670 };
671 ChatCompletionRequestUserMessageContentPart::from(
672 ChatCompletionRequestMessageContentPartImage { image_url },
673 )
674 }
675 ChatMessageContentPart::Audio { source, format } => {
676 let ChatMessageContentSource::Bytes { data, .. } = source else {
677 anyhow::bail!("OpenAI chat input_audio only supports bytes sources");
678 };
679
680 let format = match format.as_deref() {
681 Some("wav") => InputAudioFormat::Wav,
682 Some("mp3") | None => InputAudioFormat::Mp3,
683 Some(other) => anyhow::bail!("Unsupported OpenAI chat input_audio format: {other}"),
684 };
685
686 let input_audio = InputAudio {
687 data: base64::engine::general_purpose::STANDARD.encode(data),
688 format,
689 };
690
691 ChatCompletionRequestUserMessageContentPart::from(
692 ChatCompletionRequestMessageContentPartAudio { input_audio },
693 )
694 }
695 ChatMessageContentPart::Document { .. } => {
696 anyhow::bail!("OpenAI chat file parts are not supported by async-openai yet")
697 }
698 ChatMessageContentPart::Video { .. } => {
699 anyhow::bail!("OpenAI chat completion does not support video parts")
700 }
701 })
702}
703
704fn source_to_openai_url(source: &ChatMessageContentSource) -> Result<String> {
705 match source {
706 ChatMessageContentSource::Url { url } => Ok(url.clone()),
707 ChatMessageContentSource::FileId { .. } => {
708 anyhow::bail!("OpenAI chat image_url does not accept file_id sources")
709 }
710 ChatMessageContentSource::S3 { .. } => {
711 anyhow::bail!("OpenAI chat image_url does not accept s3 sources")
712 }
713 ChatMessageContentSource::Bytes { data, media_type } => {
714 let media_type = media_type.as_deref().unwrap_or("application/octet-stream");
715 let encoded = base64::engine::general_purpose::STANDARD.encode(data);
716 Ok(format!("data:{media_type};base64,{encoded}"))
717 }
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use crate::openai::{OpenAI, Options};
724
725 use super::*;
726 use futures_util::StreamExt;
727 use serde_json::json;
728 use std::sync::Arc;
729 use swiftide_core::chat_completion::{ToolCallBuilder, ToolOutput, UsageBuilder};
730 use wiremock::matchers::{method, path};
731 use wiremock::{Mock, MockServer, ResponseTemplate};
732
733 #[allow(dead_code)]
734 #[derive(schemars::JsonSchema)]
735 struct WeatherArgs {
736 _city: String,
737 }
738
739 #[allow(dead_code)]
740 #[derive(schemars::JsonSchema)]
741 #[serde(deny_unknown_fields)]
742 struct NestedCommentArgs {
743 request: NestedCommentRequest,
744 }
745
746 #[allow(dead_code)]
747 #[derive(schemars::JsonSchema)]
748 #[serde(deny_unknown_fields)]
749 struct NestedCommentRequest {
750 #[serde(default, skip_serializing_if = "Option::is_none")]
751 body: Option<String>,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 text: Option<String>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
755 page_id: Option<String>,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
757 block_id: Option<String>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
759 discussion_id: Option<String>,
760 }
761
762 #[test]
763 fn test_tools_to_openai_sets_additional_properties_false() {
764 let spec = ToolSpec::builder()
765 .name("get_weather")
766 .description("Retrieve weather data")
767 .parameters_schema(schemars::schema_for!(WeatherArgs))
768 .build()
769 .unwrap();
770
771 let tool = tools_to_openai(&spec).expect("tool conversion succeeds");
772
773 let function = match tool {
774 ChatCompletionTools::Function(ref tool) => &tool.function,
775 ChatCompletionTools::Custom(_) => panic!("expected function tool"),
776 };
777
778 let additional_properties = function
779 .parameters
780 .as_ref()
781 .and_then(serde_json::Value::as_object)
782 .and_then(|obj| obj.get("additionalProperties"))
783 .cloned();
784
785 assert_eq!(
786 additional_properties,
787 Some(serde_json::Value::Bool(false)),
788 "Chat Completions require additionalProperties=false for tool parameters, got {}",
789 serde_json::to_string_pretty(&function.parameters).unwrap()
790 );
791 }
792
793 #[test]
794 fn test_tools_to_openai_sets_nested_required_for_typed_request_objects() {
795 let spec = ToolSpec::builder()
796 .name("notion_create_comment")
797 .description("Create a comment")
798 .parameters_schema(schemars::schema_for!(NestedCommentArgs))
799 .build()
800 .unwrap();
801
802 let tool = tools_to_openai(&spec).expect("tool conversion succeeds");
803
804 let function = match tool {
805 ChatCompletionTools::Function(ref tool) => &tool.function,
806 ChatCompletionTools::Custom(_) => panic!("expected function tool"),
807 };
808
809 let nested_required = function.parameters.as_ref().and_then(|schema| {
810 let request_schema = schema
811 .get("properties")
812 .and_then(|value| value.get("request"))
813 .and_then(serde_json::Value::as_object)?;
814 let referenced_required = request_schema
815 .get("$ref")
816 .and_then(serde_json::Value::as_str)
817 .and_then(|reference| reference.strip_prefix("#/$defs/"))
818 .and_then(|definition_name| {
819 schema
820 .get("$defs")
821 .and_then(|value| value.get(definition_name))
822 })
823 .and_then(|value| value.get("required"))
824 .and_then(serde_json::Value::as_array);
825
826 referenced_required.or_else(|| {
827 request_schema
828 .get("required")
829 .and_then(serde_json::Value::as_array)
830 })
831 });
832
833 let nested_required = nested_required.expect("nested request should have required");
834 let names: std::collections::HashSet<_> = nested_required
835 .iter()
836 .filter_map(serde_json::Value::as_str)
837 .collect();
838
839 assert!(names.contains("body"));
840 assert!(names.contains("text"));
841 assert!(names.contains("page_id"));
842 assert!(names.contains("block_id"));
843 assert!(names.contains("discussion_id"));
844 }
845
846 #[test]
847 fn test_message_to_openai_with_image_parts() {
848 let message = ChatMessage::new_user_with_parts(vec![
849 ChatMessageContentPart::text("Describe this image."),
850 ChatMessageContentPart::image("https://example.com/image.png"),
851 ]);
852
853 let openai_message = message_to_openai(&message)
854 .expect("message conversion succeeds")
855 .expect("message present");
856
857 let value = serde_json::to_value(openai_message).expect("serialize message");
858 let content = value
859 .get("content")
860 .and_then(serde_json::Value::as_array)
861 .expect("content array");
862
863 assert_eq!(content[0]["type"], "text");
864 assert_eq!(content[0]["text"], "Describe this image.");
865 assert_eq!(content[1]["type"], "image_url");
866 assert_eq!(
867 content[1]["image_url"]["url"],
868 "https://example.com/image.png"
869 );
870 assert!(content[1]["image_url"]["detail"].is_null());
871 }
872
873 #[test]
874 fn test_message_to_openai_with_image_bytes_source() {
875 let message = ChatMessage::new_user_with_parts(vec![
876 ChatMessageContentPart::text("Describe this image."),
877 ChatMessageContentPart::Image {
878 source: ChatMessageContentSource::bytes(
879 vec![0_u8, 1_u8, 2_u8],
880 Some("image/png".to_string()),
881 ),
882 format: None,
883 },
884 ]);
885
886 let openai_message = message_to_openai(&message)
887 .expect("message conversion succeeds")
888 .expect("message present");
889
890 let value = serde_json::to_value(openai_message).expect("serialize message");
891 let content = value
892 .get("content")
893 .and_then(serde_json::Value::as_array)
894 .expect("content array");
895
896 let image_url = content[1]["image_url"]["url"]
897 .as_str()
898 .expect("image_url must be string");
899 assert!(image_url.starts_with("data:image/png;base64,"));
900 }
901
902 #[test_log::test(tokio::test)]
903 async fn test_complete() {
904 let mock_server = MockServer::start().await;
905
906 let response_body = json!({
908 "id": "chatcmpl-B9MBs8CjcvOU2jLn4n570S5qMJKcT",
909 "object": "chat.completion",
910 "created": 123,
911 "model": "gpt-4o",
912 "choices": [
913 {
914 "index": 0,
915 "message": {
916 "role": "assistant",
917 "content": "Hello, world!",
918 "refusal": null,
919 "annotations": []
920 },
921 "logprobs": null,
922 "finish_reason": "stop"
923 }
924 ],
925 "usage": {
926 "prompt_tokens": 19,
927 "completion_tokens": 10,
928 "total_tokens": 29,
929 "prompt_tokens_details": {
930 "cached_tokens": 0,
931 "audio_tokens": 0
932 },
933 "completion_tokens_details": {
934 "reasoning_tokens": 0,
935 "audio_tokens": 0,
936 "accepted_prediction_tokens": 0,
937 "rejected_prediction_tokens": 0
938 }
939 },
940 "service_tier": "default"
941 });
942 Mock::given(method("POST"))
943 .and(path("/chat/completions"))
944 .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
945 .mount(&mock_server)
946 .await;
947
948 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
950 let async_openai = async_openai::Client::with_config(config);
951
952 let openai = OpenAI::builder()
953 .client(async_openai)
954 .default_prompt_model("gpt-4o")
955 .build()
956 .expect("Can create OpenAI client.");
957
958 let request = ChatCompletionRequest::builder()
960 .messages(vec![ChatMessage::User("Hi".into())])
961 .build()
962 .unwrap();
963
964 let response = openai.complete(&request).await.unwrap();
966
967 assert_eq!(response.message(), Some("Hello, world!"));
969
970 let usage = response.usage.unwrap();
972 assert_eq!(usage.prompt_tokens, 19);
973 assert_eq!(usage.completion_tokens, 10);
974 assert_eq!(usage.total_tokens, 29);
975
976 let details = usage.details.as_ref().expect("usage details");
977 assert_eq!(
978 details
979 .prompt_tokens_details
980 .as_ref()
981 .and_then(|d| d.cached_tokens),
982 Some(0)
983 );
984 assert_eq!(
985 details
986 .completion_tokens_details
987 .as_ref()
988 .and_then(|d| d.reasoning_tokens),
989 Some(0)
990 );
991
992 let normalized = usage.normalized();
993 let normalized_details = normalized.details.expect("normalized details");
994 assert_eq!(normalized_details.input.cached_tokens, Some(0));
995 assert_eq!(normalized_details.output.reasoning_tokens, Some(0));
996 }
997
998 #[test_log::test(tokio::test)]
999 async fn test_complete_http_200_provider_error_envelope() {
1000 let mock_server = MockServer::start().await;
1001
1002 Mock::given(method("POST"))
1003 .and(path("/chat/completions"))
1004 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1005 "error": {
1006 "message": "Service unavailable.",
1007 "code": 504,
1008 "metadata": { "error_type": "timeout" }
1009 }
1010 })))
1011 .mount(&mock_server)
1012 .await;
1013
1014 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1015 let openai = OpenAI::builder()
1016 .client(async_openai::Client::with_config(config))
1017 .default_prompt_model("gpt-4o")
1018 .build()
1019 .unwrap();
1020 let request = ChatCompletionRequest::from(vec![ChatMessage::User("Hi".into())]);
1021
1022 let error = openai.complete(&request).await.unwrap_err();
1023 assert!(matches!(error, LanguageModelError::TransientError(_)));
1024 let message = error.to_string();
1025 assert!(message.contains("Service unavailable."));
1026 assert!(message.contains("504"));
1027 assert!(message.contains("timeout"));
1028 assert!(!message.contains("missing field `id`"));
1029 }
1030
1031 #[test_log::test(tokio::test)]
1032 async fn test_complete_partial_provider_error() {
1033 let mock_server = MockServer::start().await;
1034
1035 Mock::given(method("POST"))
1036 .and(path("/chat/completions"))
1037 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1038 "choices": [{
1039 "message": { "role": "assistant", "content": "partial output" },
1040 "finish_reason": "error",
1041 "error": {
1042 "message": "Provider disconnected mid-generation",
1043 "code": 502,
1044 "metadata": { "error_type": "provider_unavailable" }
1045 }
1046 }]
1047 })))
1048 .mount(&mock_server)
1049 .await;
1050
1051 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1052 let openai = OpenAI::builder()
1053 .client(async_openai::Client::with_config(config))
1054 .default_prompt_model("gpt-4o")
1055 .build()
1056 .unwrap();
1057 let request = ChatCompletionRequest::from(vec![ChatMessage::User("Hi".into())]);
1058
1059 let error = openai.complete(&request).await.unwrap_err();
1060 assert!(matches!(error, LanguageModelError::TransientError(_)));
1061 let message = error.to_string();
1062 assert!(message.contains("Provider disconnected mid-generation"));
1063 assert!(message.contains("502"));
1064 assert!(message.contains("provider_unavailable"));
1065 }
1066
1067 #[test_log::test(tokio::test)]
1068 async fn test_complete_stream_mid_stream_provider_error() {
1069 let mock_server = MockServer::start().await;
1070 let sse_body = concat!(
1071 "data: {\"id\":\"gen-123\",\"object\":\"chat.completion.chunk\",",
1072 "\"created\":123,\"model\":\"gpt-4o\",\"provider\":\"OpenAI\",",
1073 "\"error\":{\"code\":429,\"message\":\"Rate limit exceeded\",",
1074 "\"metadata\":{\"error_type\":\"rate_limit_exceeded\"}},",
1075 "\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},",
1076 "\"finish_reason\":\"error\"}]}\n\n",
1077 "data: [DONE]\n\n",
1078 );
1079
1080 Mock::given(method("POST"))
1081 .and(path("/chat/completions"))
1082 .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
1083 .mount(&mock_server)
1084 .await;
1085
1086 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1087 let openai = OpenAI::builder()
1088 .client(async_openai::Client::with_config(config))
1089 .default_prompt_model("gpt-4o")
1090 .build()
1091 .unwrap();
1092 let request = ChatCompletionRequest::from(vec![ChatMessage::User("Hi".into())]);
1093
1094 let mut stream = openai.complete_stream(&request).await;
1095 let error = stream.next().await.unwrap().unwrap_err();
1096 assert!(matches!(error, LanguageModelError::TransientError(_)));
1097 let message = error.to_string();
1098 assert!(message.contains("Rate limit exceeded"));
1099 assert!(message.contains("429"));
1100 assert!(message.contains("rate_limit_exceeded"));
1101 assert!(stream.next().await.is_none());
1102 }
1103
1104 #[test_log::test(tokio::test)]
1105 #[allow(clippy::items_after_statements)]
1106 #[allow(clippy::too_many_lines)]
1107 async fn test_complete_responses_api() {
1108 use serde_json::{Value, json};
1109 use wiremock::{Request, Respond};
1110
1111 let mock_server = MockServer::start().await;
1112
1113 let response_body = json!({
1114 "created_at": 123,
1115 "id": "resp_123",
1116 "model": "gpt-4.1-mini",
1117 "object": "response",
1118 "status": "completed",
1119 "output": [
1120 {
1121 "type": "message",
1122 "id": "msg_1",
1123 "role": "assistant",
1124 "status": "completed",
1125 "content": [
1126 {"type": "output_text", "text": "Hello via responses", "annotations": []}
1127 ]
1128 }
1129 ],
1130 "usage": {
1131 "input_tokens": 5,
1132 "input_tokens_details": {"cached_tokens": 0},
1133 "output_tokens": 3,
1134 "output_tokens_details": {"reasoning_tokens": 0},
1135 "total_tokens": 8
1136 }
1137 });
1138
1139 struct ValidateResponsesRequest {
1140 expected_model: &'static str,
1141 response: Value,
1142 }
1143
1144 impl Respond for ValidateResponsesRequest {
1145 fn respond(&self, request: &Request) -> ResponseTemplate {
1146 let body: Value = serde_json::from_slice(&request.body).unwrap();
1147 assert_eq!(body["model"], self.expected_model);
1148 assert_eq!(body["tensorzero::episode_id"], "responses-episode");
1149 assert_eq!(body["tensorzero::tags"]["user_id"], "responses-user");
1150 let input = body["input"].as_array().expect("input array");
1151 assert_eq!(input.len(), 1);
1152 assert_eq!(input[0]["role"], "user");
1153 assert_eq!(input[0]["content"], "Hello via prompt");
1154
1155 let _: async_openai::types::responses::Response =
1156 serde_json::from_value(self.response.clone()).unwrap();
1157
1158 ResponseTemplate::new(200).set_body_json(self.response.clone())
1159 }
1160 }
1161
1162 Mock::given(method("POST"))
1163 .and(path("/responses"))
1164 .respond_with(ValidateResponsesRequest {
1165 expected_model: "gpt-4.1-mini",
1166 response: response_body,
1167 })
1168 .mount(&mock_server)
1169 .await;
1170
1171 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1172 let async_openai = async_openai::Client::with_config(config);
1173
1174 let extra_body = serde_json::Map::from_iter([
1175 (
1176 "tensorzero::episode_id".to_string(),
1177 serde_json::json!("responses-episode"),
1178 ),
1179 (
1180 "tensorzero::tags".to_string(),
1181 serde_json::json!({"user_id": "responses-user"}),
1182 ),
1183 ]);
1184
1185 let openai = OpenAI::builder()
1186 .client(async_openai)
1187 .default_prompt_model("gpt-4.1-mini")
1188 .default_options(Options::builder().extra_body(extra_body))
1189 .use_responses_api(true)
1190 .build()
1191 .expect("Can create OpenAI client.");
1192
1193 let request = ChatCompletionRequest::builder()
1194 .messages(vec![ChatMessage::User("Hello via prompt".into())])
1195 .build()
1196 .unwrap();
1197
1198 let response = openai.complete(&request).await.unwrap();
1199
1200 assert_eq!(response.message(), Some("Hello via responses"));
1201
1202 let usage = response.usage.expect("usage present");
1203 assert_eq!(usage.prompt_tokens, 5);
1204 assert_eq!(usage.completion_tokens, 3);
1205 assert_eq!(usage.total_tokens, 8);
1206
1207 let details = usage.details.as_ref().expect("usage details");
1208 assert_eq!(
1209 details
1210 .input_tokens_details
1211 .as_ref()
1212 .and_then(|d| d.cached_tokens),
1213 Some(0)
1214 );
1215 assert_eq!(
1216 details
1217 .output_tokens_details
1218 .as_ref()
1219 .and_then(|d| d.reasoning_tokens),
1220 Some(0)
1221 );
1222
1223 let normalized = usage.normalized();
1224 let normalized_details = normalized.details.expect("normalized details");
1225 assert_eq!(normalized_details.input.cached_tokens, Some(0));
1226 assert_eq!(normalized_details.output.reasoning_tokens, Some(0));
1227 }
1228
1229 #[test_log::test(tokio::test)]
1230 async fn test_complete_stream_responses_api_sends_extra_body() {
1231 use serde_json::Value;
1232 use wiremock::{Request, Respond};
1233
1234 struct ValidateResponsesStreamRequest {
1235 sse_body: &'static str,
1236 }
1237
1238 impl Respond for ValidateResponsesStreamRequest {
1239 fn respond(&self, request: &Request) -> ResponseTemplate {
1240 let body: Value = serde_json::from_slice(&request.body).unwrap();
1241
1242 assert_eq!(body["tensorzero::episode_id"], "responses-stream-episode");
1243 assert_eq!(body["tensorzero::tags"]["user_id"], "responses-stream-user");
1244 assert_eq!(body["stream"], true);
1245
1246 ResponseTemplate::new(200).set_body_raw(self.sse_body, "text/event-stream")
1247 }
1248 }
1249
1250 let mock_server = MockServer::start().await;
1251
1252 let sse_body = "\
1253data: {\"type\":\"response.completed\",\"sequence_number\":0,\"response\":{\"id\":\"resp_stream\",\"object\":\"response\",\"created_at\":123,\"status\":\"completed\",\"model\":\"gpt-4.1-mini\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"stream via responses\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":8}}}\n\
1254\n\
1255data: [DONE]\n\n";
1256
1257 Mock::given(method("POST"))
1258 .and(path("/responses"))
1259 .respond_with(ValidateResponsesStreamRequest { sse_body })
1260 .mount(&mock_server)
1261 .await;
1262
1263 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1264 let async_openai = async_openai::Client::with_config(config);
1265
1266 let extra_body = serde_json::Map::from_iter([
1267 (
1268 "tensorzero::episode_id".to_string(),
1269 serde_json::json!("responses-stream-episode"),
1270 ),
1271 (
1272 "tensorzero::tags".to_string(),
1273 serde_json::json!({"user_id": "responses-stream-user"}),
1274 ),
1275 ]);
1276
1277 let openai = OpenAI::builder()
1278 .client(async_openai)
1279 .default_prompt_model("gpt-4.1-mini")
1280 .default_options(Options::builder().extra_body(extra_body))
1281 .use_responses_api(true)
1282 .build()
1283 .expect("Can create OpenAI client.");
1284
1285 let request = ChatCompletionRequest::builder()
1286 .messages(vec![ChatMessage::User("Hello via responses stream".into())])
1287 .build()
1288 .unwrap();
1289
1290 let results: Vec<_> = openai.complete_stream(&request).await.collect().await;
1291 let last = results.last().unwrap().as_ref().unwrap();
1292 assert_eq!(last.message(), Some("stream via responses"));
1293 assert_eq!(last.usage.as_ref().map(|u| u.total_tokens), Some(8));
1294 }
1295
1296 #[test_log::test(tokio::test)]
1297 #[allow(clippy::items_after_statements)]
1298 async fn test_complete_with_all_default_settings() {
1299 use serde_json::Value;
1300 use wiremock::{Request, Respond, ResponseTemplate};
1301
1302 let mock_server = wiremock::MockServer::start().await;
1303
1304 struct ValidateAllSettings;
1306
1307 impl Respond for ValidateAllSettings {
1308 fn respond(&self, request: &Request) -> ResponseTemplate {
1309 let v: Value = serde_json::from_slice(&request.body).unwrap();
1310
1311 assert_eq!(v["model"], "gpt-4-turbo");
1313 let arr = v["messages"].as_array().unwrap();
1314 assert_eq!(arr.len(), 1);
1315 assert_eq!(arr[0]["content"], "Test");
1316
1317 assert_eq!(v["parallel_tool_calls"], true);
1318 assert_eq!(v["max_completion_tokens"], 77);
1319 assert!((v["temperature"].as_f64().unwrap() - 0.42).abs() < 1e-5);
1320 assert_eq!(v["reasoning_effort"], "low");
1321 assert_eq!(v["seed"], 42);
1322 assert!((v["presence_penalty"].as_f64().unwrap() - 1.1).abs() < 1e-5);
1323
1324 assert_eq!(v["metadata"], serde_json::json!({"key": "value"}));
1326 assert_eq!(v["user"], "test-user");
1327 ResponseTemplate::new(200).set_body_json(serde_json::json!({
1328 "id": "chatcmpl-xxx",
1329 "object": "chat.completion",
1330 "created": 123,
1331 "model": "gpt-4-turbo",
1332 "choices": [{
1333 "index": 0,
1334 "message": {
1335 "role": "assistant",
1336 "content": "All settings validated",
1337 "refusal": null,
1338 "annotations": []
1339 },
1340 "logprobs": null,
1341 "finish_reason": "stop"
1342 }],
1343 "usage": {
1344 "prompt_tokens": 19,
1345 "completion_tokens": 10,
1346 "total_tokens": 29,
1347 "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
1348 "completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
1349 },
1350 "service_tier": "default"
1351 }))
1352 }
1353 }
1354
1355 wiremock::Mock::given(wiremock::matchers::method("POST"))
1356 .and(wiremock::matchers::path("/chat/completions"))
1357 .respond_with(ValidateAllSettings)
1358 .mount(&mock_server)
1359 .await;
1360
1361 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1362 let async_openai = async_openai::Client::with_config(config);
1363
1364 let openai = crate::openai::OpenAI::builder()
1365 .client(async_openai)
1366 .default_prompt_model("gpt-4-turbo")
1367 .default_embed_model("not-used")
1368 .parallel_tool_calls(Some(true))
1369 .default_options(
1370 Options::builder()
1371 .max_completion_tokens(77)
1372 .temperature(0.42)
1373 .reasoning_effort(async_openai::types::responses::ReasoningEffort::Low)
1374 .seed(42)
1375 .presence_penalty(1.1)
1376 .metadata(serde_json::json!({"key": "value"}))
1377 .user("test-user"),
1378 )
1379 .build()
1380 .expect("Can create OpenAI client.");
1381
1382 let request = swiftide_core::chat_completion::ChatCompletionRequest::builder()
1383 .messages(vec![swiftide_core::chat_completion::ChatMessage::User(
1384 "Test".into(),
1385 )])
1386 .build()
1387 .unwrap();
1388
1389 let response = openai.complete(&request).await.unwrap();
1390
1391 assert_eq!(response.message(), Some("All settings validated"));
1392 }
1393
1394 #[test_log::test(tokio::test)]
1395 async fn test_complete_sends_extra_body() {
1396 use serde_json::Value;
1397 use wiremock::{Request, Respond, ResponseTemplate};
1398
1399 struct ValidateExtraBody;
1400
1401 impl Respond for ValidateExtraBody {
1402 fn respond(&self, request: &Request) -> ResponseTemplate {
1403 let body: Value = serde_json::from_slice(&request.body).unwrap();
1404
1405 assert_eq!(body["tensorzero::episode_id"], "episode-1");
1406 assert_eq!(body["tensorzero::tags"]["user_id"], "user-1");
1407 assert_eq!(body["model"], "gpt-4-turbo");
1408
1409 ResponseTemplate::new(200).set_body_json(serde_json::json!({
1410 "id": "chatcmpl-extra-body",
1411 "object": "chat.completion",
1412 "created": 123,
1413 "model": "gpt-4-turbo",
1414 "choices": [{
1415 "index": 0,
1416 "message": {
1417 "role": "assistant",
1418 "content": "extra body validated",
1419 "refusal": null,
1420 "annotations": []
1421 },
1422 "logprobs": null,
1423 "finish_reason": "stop"
1424 }],
1425 "usage": {
1426 "prompt_tokens": 1,
1427 "completion_tokens": 2,
1428 "total_tokens": 3
1429 },
1430 "service_tier": "default"
1431 }))
1432 }
1433 }
1434
1435 let mock_server = wiremock::MockServer::start().await;
1436
1437 wiremock::Mock::given(wiremock::matchers::method("POST"))
1438 .and(wiremock::matchers::path("/chat/completions"))
1439 .respond_with(ValidateExtraBody)
1440 .mount(&mock_server)
1441 .await;
1442
1443 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1444 let async_openai = async_openai::Client::with_config(config);
1445
1446 let extra_body = serde_json::Map::from_iter([
1447 (
1448 "tensorzero::episode_id".to_string(),
1449 serde_json::json!("episode-1"),
1450 ),
1451 (
1452 "tensorzero::tags".to_string(),
1453 serde_json::json!({"user_id": "user-1"}),
1454 ),
1455 ]);
1456
1457 let openai = crate::openai::OpenAI::builder()
1458 .client(async_openai)
1459 .default_prompt_model("gpt-4-turbo")
1460 .default_options(Options::builder().extra_body(extra_body))
1461 .build()
1462 .expect("Can create OpenAI client.");
1463
1464 let request = swiftide_core::chat_completion::ChatCompletionRequest::builder()
1465 .messages(vec![swiftide_core::chat_completion::ChatMessage::User(
1466 "Test".into(),
1467 )])
1468 .build()
1469 .unwrap();
1470
1471 let response = openai.complete(&request).await.unwrap();
1472
1473 assert_eq!(response.message(), Some("extra body validated"));
1474 }
1475
1476 #[test_log::test(tokio::test)]
1477 async fn test_complete_with_tools_sets_auto_choice_and_parallel_calls() {
1478 use serde_json::Value;
1479 use wiremock::{Request, Respond, ResponseTemplate};
1480
1481 #[derive(schemars::JsonSchema)]
1482 struct WeatherArgs {
1483 _city: String,
1484 }
1485
1486 struct Validate(Value);
1487
1488 impl Respond for Validate {
1489 fn respond(&self, request: &Request) -> ResponseTemplate {
1490 let v: Value = serde_json::from_slice(&request.body).unwrap();
1491 assert_eq!(v["model"], "gpt-4o");
1492 assert_eq!(v["parallel_tool_calls"], true);
1493 assert_eq!(v["tool_choice"], "auto");
1494 let tools = v["tools"].as_array().unwrap();
1495 assert_eq!(tools.len(), 2);
1496 let tool_names = tools
1497 .iter()
1498 .map(|tool| tool["function"]["name"].as_str().unwrap())
1499 .collect::<Vec<_>>();
1500 assert_eq!(tool_names, vec!["alpha_tool", "get_weather"]);
1501 ResponseTemplate::new(200)
1502 .insert_header("content-type", "application/json")
1503 .set_body_json(self.0.clone())
1504 }
1505 }
1506
1507 let weather_tool = ToolSpec::builder()
1508 .name("get_weather")
1509 .description("weather")
1510 .parameters_schema(schemars::schema_for!(WeatherArgs))
1511 .build()
1512 .unwrap();
1513
1514 let alpha_tool = ToolSpec::builder()
1515 .name("alpha_tool")
1516 .description("alpha")
1517 .parameters_schema(schemars::schema_for!(WeatherArgs))
1518 .build()
1519 .unwrap();
1520
1521 let mock_server = MockServer::start().await;
1522
1523 let response_body = json!({
1524 "id": "chatcmpl-xyz",
1525 "object": "chat.completion",
1526 "created": 1,
1527 "model": "gpt-4o",
1528 "choices": [{
1529 "index": 0,
1530 "message": {
1531 "role": "assistant",
1532 "content": "Here",
1533 "refusal": null,
1534 "annotations": []
1535 },
1536 "finish_reason": "stop"
1537 }],
1538 "usage": {
1539 "prompt_tokens": 2,
1540 "completion_tokens": 3,
1541 "total_tokens": 5,
1542 "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
1543 "completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
1544 }
1545 });
1546
1547 Mock::given(method("POST"))
1548 .and(path("/chat/completions"))
1549 .respond_with(Validate(response_body.clone()))
1550 .mount(&mock_server)
1551 .await;
1552
1553 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1554 let async_openai = async_openai::Client::with_config(config);
1555
1556 let openai = OpenAI::builder()
1557 .client(async_openai)
1558 .default_prompt_model("gpt-4o")
1559 .parallel_tool_calls(Some(true))
1560 .build()
1561 .unwrap();
1562
1563 let req = ChatCompletionRequest::builder()
1564 .messages(vec![ChatMessage::User("hi".into())])
1565 .tool_specs([weather_tool, alpha_tool])
1566 .build()
1567 .unwrap();
1568
1569 let resp = openai.complete(&req).await.unwrap();
1570 assert_eq!(resp.message(), Some("Here"));
1571 assert_eq!(resp.usage.unwrap().total_tokens, 5);
1572 }
1573
1574 #[test_log::test(tokio::test)]
1575 async fn test_complete_stream_happy_path() {
1576 use serde_json::Value;
1577 use wiremock::{Request, Respond};
1578
1579 struct ValidateStreamExtraBody {
1580 sse_body: &'static str,
1581 }
1582
1583 impl Respond for ValidateStreamExtraBody {
1584 fn respond(&self, request: &Request) -> ResponseTemplate {
1585 let body: Value = serde_json::from_slice(&request.body).unwrap();
1586
1587 assert_eq!(body["tensorzero::episode_id"], "stream-episode");
1588 assert_eq!(body["tensorzero::tags"]["user_id"], "stream-user");
1589 assert_eq!(body["stream"], true);
1590
1591 ResponseTemplate::new(200).set_body_raw(self.sse_body, "text/event-stream")
1592 }
1593 }
1594
1595 let mock_server = MockServer::start().await;
1596
1597 let sse_body = "\
1598data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}]}\n\
1599\n\
1600data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\
1601\n\
1602data: [DONE]\n\n";
1603
1604 Mock::given(method("POST"))
1605 .and(path("/chat/completions"))
1606 .respond_with(ValidateStreamExtraBody { sse_body })
1607 .mount(&mock_server)
1608 .await;
1609
1610 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1611 let async_openai = async_openai::Client::with_config(config);
1612
1613 let extra_body = serde_json::Map::from_iter([
1614 (
1615 "tensorzero::episode_id".to_string(),
1616 serde_json::json!("stream-episode"),
1617 ),
1618 (
1619 "tensorzero::tags".to_string(),
1620 serde_json::json!({"user_id": "stream-user"}),
1621 ),
1622 ]);
1623
1624 let openai = OpenAI::builder()
1625 .client(async_openai)
1626 .default_prompt_model("gpt-4o-mini")
1627 .default_options(Options::builder().extra_body(extra_body))
1628 .build()
1629 .unwrap();
1630
1631 let req = ChatCompletionRequest::builder()
1632 .messages(vec![ChatMessage::User("Hello".into())])
1633 .build()
1634 .unwrap();
1635
1636 let results: Vec<_> = openai.complete_stream(&req).await.collect().await;
1637 let last = results.last().unwrap().as_ref().unwrap();
1638 assert_eq!(last.message(), Some("Hi"));
1639 assert_eq!(last.usage.as_ref().map(|u| u.total_tokens), Some(3));
1640 }
1641
1642 #[test_log::test(tokio::test)]
1643 async fn test_complete_stream_delta_only_mode() {
1644 let mock_server = MockServer::start().await;
1645
1646 let sse_body = "\
1647data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}]}\n\
1648\n\
1649data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\
1650\n\
1651data: [DONE]\n\n";
1652
1653 Mock::given(method("POST"))
1654 .and(path("/chat/completions"))
1655 .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
1656 .mount(&mock_server)
1657 .await;
1658
1659 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1660 let async_openai = async_openai::Client::with_config(config);
1661
1662 let openai = OpenAI::builder()
1663 .client(async_openai)
1664 .default_prompt_model("gpt-4o-mini")
1665 .stream_full(false)
1666 .build()
1667 .unwrap();
1668
1669 let req = ChatCompletionRequest::builder()
1670 .messages(vec![ChatMessage::User("Hello".into())])
1671 .build()
1672 .unwrap();
1673
1674 let mut stream = openai.complete_stream(&req).await;
1675 let first = stream.next().await.unwrap().unwrap();
1676 assert!(first.message.is_none());
1677 assert!(first.usage.is_none());
1678 assert!(
1679 first.delta.is_some(),
1680 "delta-only mode should emit delta snapshots"
1681 );
1682
1683 let final_snapshot = stream.next().await.unwrap().unwrap();
1684 assert!(final_snapshot.usage.is_none() || final_snapshot.usage.is_some());
1686 while let Some(item) = stream.next().await {
1687 item.expect("stream should not error");
1688 }
1689 }
1690
1691 #[test_log::test(tokio::test)]
1692 async fn test_complete_stream_invalid_tool_schema_errors() {
1693 let invalid_schema = schemars::Schema::from(true);
1694
1695 let err = ToolSpec::builder()
1696 .name("bad")
1697 .description("bad schema")
1698 .parameters_schema(invalid_schema)
1699 .build()
1700 .expect_err("invalid tool schemas should be rejected at build time");
1701
1702 assert!(
1703 err.to_string()
1704 .contains("tool schema must be a JSON object")
1705 );
1706 }
1707
1708 #[test_log::test(tokio::test)]
1709 async fn test_complete_invalid_tool_schema_errors() {
1710 let invalid_schema = schemars::Schema::from(true);
1711
1712 let err = ToolSpec::builder()
1713 .name("bad")
1714 .description("bad schema")
1715 .parameters_schema(invalid_schema)
1716 .build()
1717 .expect_err("invalid tool schemas should be rejected at build time");
1718
1719 assert!(
1720 err.to_string()
1721 .contains("tool schema must be a JSON object")
1722 );
1723 }
1724
1725 #[test_log::test(tokio::test)]
1726 async fn test_complete_stream_rate_limit_transient_error() {
1727 let mock_server = MockServer::start().await;
1728
1729 Mock::given(method("POST"))
1730 .and(path("/chat/completions"))
1731 .respond_with(ResponseTemplate::new(429).set_body_string("rate limit"))
1732 .mount(&mock_server)
1733 .await;
1734
1735 let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1736 let async_openai = async_openai::Client::with_config(config);
1737
1738 let openai = OpenAI::builder()
1739 .client(async_openai)
1740 .default_prompt_model("gpt-4o-mini")
1741 .build()
1742 .unwrap();
1743
1744 let req = ChatCompletionRequest::builder()
1745 .messages(vec![ChatMessage::User("hi".into())])
1746 .build()
1747 .unwrap();
1748
1749 let mut stream = openai.complete_stream(&req).await;
1750 let first = stream.next().await.expect("stream yields one item");
1751 assert!(matches!(first, Err(LanguageModelError::TransientError(_))));
1752 assert!(stream.next().await.is_none());
1753 }
1754
1755 #[test]
1756 fn test_message_to_openai_tool_output_without_content() {
1757 let tool_call = ToolCallBuilder::default()
1758 .id("call_1")
1759 .name("noop")
1760 .build()
1761 .unwrap();
1762 let msg = ChatMessage::ToolOutput(tool_call, ToolOutput::stop());
1763
1764 let converted = message_to_openai(&msg)
1765 .expect("conversion succeeds")
1766 .expect("message is not filtered");
1767 match converted {
1768 async_openai::types::chat::ChatCompletionRequestMessage::Tool(m) => {
1769 assert_eq!(m.tool_call_id, "call_1");
1770 assert_eq!(
1771 m.content,
1772 async_openai::types::chat::ChatCompletionRequestToolMessageContent::Text(
1773 String::new()
1774 )
1775 );
1776 }
1777 other => panic!("expected tool message, got {other:?}"),
1778 }
1779 }
1780
1781 #[test]
1782 fn test_message_to_openai_assistant_with_tool_calls_and_text() {
1783 let tool_call = ToolCallBuilder::default()
1784 .id("call_2")
1785 .name("math")
1786 .args("{\"x\":1}")
1787 .build()
1788 .unwrap();
1789
1790 let msg = ChatMessage::new_assistant(Some("pending"), Some(vec![tool_call.clone()]));
1791 let converted = message_to_openai(&msg)
1792 .expect("conversion succeeds")
1793 .expect("message is not filtered");
1794 match converted {
1795 async_openai::types::chat::ChatCompletionRequestMessage::Assistant(m) => {
1796 assert_eq!(m.content.unwrap(), "pending".into());
1797 let calls = m.tool_calls.unwrap();
1798 assert_eq!(calls.len(), 1);
1799 let async_openai::types::chat::ChatCompletionMessageToolCalls::Function(call) =
1800 &calls[0]
1801 else {
1802 panic!("expected function tool call");
1803 };
1804 assert_eq!(call.id, "call_2");
1805 assert_eq!(call.function.name, "math");
1806 assert_eq!(call.function.arguments, "{\"x\":1}");
1807 }
1808 other => panic!("expected assistant message, got {other:?}"),
1809 }
1810 }
1811
1812 #[test_log::test(tokio::test)]
1813 async fn test_complete_stream_model_missing_errors_immediately() {
1814 let openai = OpenAI::builder()
1815 .default_embed_model("unused")
1816 .build()
1817 .expect("builder without prompt model still constructs");
1818
1819 let request = ChatCompletionRequest::builder()
1820 .messages(vec![ChatMessage::User("hi".into())])
1821 .build()
1822 .unwrap();
1823
1824 let mut stream = openai.complete_stream(&request).await;
1825 let first = stream.next().await.expect("stream yields one item");
1826 assert!(
1827 matches!(first, Err(LanguageModelError::PermanentError(msg)) if msg.to_string().contains("Model not set"))
1828 );
1829 assert!(stream.next().await.is_none(), "stream ends after error");
1830 }
1831
1832 #[test_log::test(tokio::test)]
1833 async fn test_track_completion_invokes_on_usage_callback() {
1834 use std::sync::atomic::{AtomicUsize, Ordering};
1835 let hits = Arc::new(AtomicUsize::new(0));
1836 let hits_clone = hits.clone();
1837 let openai = OpenAI::builder()
1838 .default_prompt_model("gpt-4o")
1839 .on_usage(move |_usage| {
1840 hits_clone.fetch_add(1, Ordering::SeqCst);
1841 Ok(())
1842 })
1843 .build()
1844 .unwrap();
1845
1846 let usage = UsageBuilder::default()
1847 .prompt_tokens(1)
1848 .completion_tokens(1)
1849 .total_tokens(2)
1850 .build()
1851 .unwrap();
1852
1853 openai.track_completion(
1854 "gpt-4o",
1855 Some(&usage),
1856 Option::<&()>::None,
1857 Option::<&()>::None,
1858 );
1859
1860 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1862 assert_eq!(hits.load(Ordering::SeqCst), 1);
1863 }
1864}