Skip to main content

rig_core/providers/openai/
client.rs

1use super::responses_api::{ResponsesProviderExt, SystemInstructionsPlacement};
2use crate::{
3    client::{
4        self, BearerAuth, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
5        ProviderClient,
6    },
7    extractor::ExtractorBuilder,
8    http_client::{self, HttpClientExt},
9    prelude::CompletionClient,
10    wasm_compat::{WasmCompatSend, WasmCompatSync},
11};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::fmt::Debug;
15
16// ================================================================
17// Main OpenAI Client
18// ================================================================
19const OPENAI_API_BASE_URL: &str = "https://api.openai.com/v1";
20
21// ================================================================
22// OpenAI Responses API Extension
23// ================================================================
24#[derive(Debug, Default, Clone, Copy)]
25pub struct OpenAIResponsesExt {
26    pub(crate) system_instructions_placement: SystemInstructionsPlacement,
27}
28
29#[derive(Debug, Default, Clone, Copy)]
30pub struct OpenAIResponsesExtBuilder;
31
32// ================================================================
33// OpenAI Completions API Extension
34// ================================================================
35#[derive(Debug, Default, Clone, Copy)]
36pub struct OpenAICompletionsExt {
37    /// Carried through API switches so that a placement configured on a
38    /// Responses client survives `completions_api()` → `responses_api()`
39    /// round trips. Not used by Chat Completions requests themselves.
40    pub(crate) system_instructions_placement: SystemInstructionsPlacement,
41}
42
43#[derive(Debug, Default, Clone, Copy)]
44pub struct OpenAICompletionsExtBuilder;
45
46type OpenAIApiKey = BearerAuth;
47
48// Responses API client (default)
49pub type Client<H = reqwest::Client> = client::Client<OpenAIResponsesExt, H>;
50pub type ClientBuilder<H = crate::markers::Missing> =
51    client::ClientBuilder<OpenAIResponsesExtBuilder, OpenAIApiKey, H>;
52
53// Completions API client
54pub type CompletionsClient<H = reqwest::Client> = client::Client<OpenAICompletionsExt, H>;
55pub type CompletionsClientBuilder<H = crate::markers::Missing> =
56    client::ClientBuilder<OpenAICompletionsExtBuilder, OpenAIApiKey, H>;
57
58impl Provider for OpenAIResponsesExt {
59    type Builder = OpenAIResponsesExtBuilder;
60    const VERIFY_PATH: &'static str = "/models";
61}
62
63impl ResponsesProviderExt for OpenAIResponsesExt {
64    fn system_instructions_placement(&self) -> SystemInstructionsPlacement {
65        self.system_instructions_placement
66    }
67}
68
69impl Provider for OpenAICompletionsExt {
70    type Builder = OpenAICompletionsExtBuilder;
71    const VERIFY_PATH: &'static str = "/models";
72}
73
74impl<H> Capabilities<H> for OpenAIResponsesExt {
75    type Completion = Capable<super::responses_api::ResponsesCompletionModel<H>>;
76    type Embeddings = Capable<super::EmbeddingModel<H>>;
77    type Transcription = Capable<super::TranscriptionModel<H>>;
78    type ModelListing = Capable<super::OpenAIModelLister<H>>;
79    #[cfg(feature = "image")]
80    type ImageGeneration = Capable<super::ImageGenerationModel<H>>;
81    #[cfg(feature = "audio")]
82    type AudioGeneration = Capable<super::audio_generation::AudioGenerationModel<H>>;
83    type Rerank = Nothing;
84}
85
86impl<H> Capabilities<H> for OpenAICompletionsExt {
87    type Completion = Capable<super::completion::CompletionModel<H>>;
88    type Embeddings = Capable<super::GenericEmbeddingModel<OpenAICompletionsExt, H>>;
89    type Transcription = Capable<super::TranscriptionModel<H>>;
90    type ModelListing = Capable<super::OpenAIModelLister<H>>;
91    #[cfg(feature = "image")]
92    type ImageGeneration = Capable<super::ImageGenerationModel<H>>;
93    #[cfg(feature = "audio")]
94    type AudioGeneration = Capable<super::audio_generation::AudioGenerationModel<H>>;
95    type Rerank = Nothing;
96}
97
98impl DebugExt for OpenAIResponsesExt {}
99
100impl DebugExt for OpenAICompletionsExt {}
101
102impl ProviderBuilder for OpenAIResponsesExtBuilder {
103    type Extension<H>
104        = OpenAIResponsesExt
105    where
106        H: HttpClientExt;
107    type ApiKey = OpenAIApiKey;
108
109    const BASE_URL: &'static str = OPENAI_API_BASE_URL;
110
111    fn build<H>(
112        _builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
113    ) -> http_client::Result<Self::Extension<H>>
114    where
115        H: HttpClientExt,
116    {
117        Ok(OpenAIResponsesExt::default())
118    }
119}
120
121impl ProviderBuilder for OpenAICompletionsExtBuilder {
122    type Extension<H>
123        = OpenAICompletionsExt
124    where
125        H: HttpClientExt;
126    type ApiKey = OpenAIApiKey;
127
128    const BASE_URL: &'static str = OPENAI_API_BASE_URL;
129
130    fn build<H>(
131        _builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
132    ) -> http_client::Result<Self::Extension<H>>
133    where
134        H: HttpClientExt,
135    {
136        Ok(OpenAICompletionsExt::default())
137    }
138}
139
140impl<H> Client<H>
141where
142    H: HttpClientExt
143        + Clone
144        + std::fmt::Debug
145        + Default
146        + WasmCompatSend
147        + WasmCompatSync
148        + 'static,
149{
150    /// Create an extractor builder with the given completion model.
151    /// Uses the OpenAI Responses API (default behavior).
152    pub fn extractor<U>(
153        &self,
154        model: impl Into<String>,
155    ) -> ExtractorBuilder<super::responses_api::ResponsesCompletionModel<H>, U>
156    where
157        U: JsonSchema + for<'a> Deserialize<'a> + Serialize + WasmCompatSend + WasmCompatSync,
158    {
159        ExtractorBuilder::new(self.completion_model(model))
160    }
161
162    /// Sets where Rig system instructions are placed in Responses requests for
163    /// every completion model created from this client, including through
164    /// [`CompletionClient::agent`] and [`Self::extractor`]. Models capture the
165    /// placement when they are created, so models built before this call are
166    /// unaffected. See [`SystemInstructionsPlacement`] for when each placement
167    /// applies.
168    pub fn with_system_instructions_placement(
169        self,
170        placement: SystemInstructionsPlacement,
171    ) -> Self {
172        let mut ext = *self.ext();
173        ext.system_instructions_placement = placement;
174        self.with_ext(ext)
175    }
176
177    /// Sends Rig system instructions as `system` messages in `input` instead of
178    /// as top-level Responses API `instructions` for every completion model
179    /// created from this client, including through [`CompletionClient::agent`]
180    /// and [`Self::extractor`]. Models built before this call are unaffected.
181    ///
182    /// OpenAI's Responses API supports `instructions`, and Rig uses it by
183    /// default. Use this compatibility fallback for OpenAI-compatible providers
184    /// that reject or ignore top-level `instructions`.
185    pub fn with_system_instructions_as_messages(self) -> Self {
186        self.with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
187    }
188
189    /// Create a Completions API client from this Responses API client.
190    /// Useful for switching to the traditional Chat Completions API.
191    pub fn completions_api(self) -> CompletionsClient<H> {
192        let system_instructions_placement = self.ext().system_instructions_placement;
193        self.with_ext(OpenAICompletionsExt {
194            system_instructions_placement,
195        })
196    }
197}
198
199#[cfg(all(not(target_family = "wasm"), feature = "websocket"))]
200impl Client<reqwest::Client> {
201    /// WebSocket mode currently uses a native `tokio-tungstenite` transport and does
202    /// not reuse custom `HttpClientExt` backends, so this API is only exposed for the
203    /// default `reqwest::Client` transport.
204    pub fn responses_websocket_builder(
205        &self,
206        model: impl Into<String>,
207    ) -> super::responses_api::websocket::ResponsesWebSocketSessionBuilder {
208        super::responses_api::websocket::ResponsesWebSocketSessionBuilder::new(
209            self.completion_model(model),
210        )
211    }
212
213    /// This API is OpenAI-specific and only available on non-wasm targets in `rig-core`.
214    pub async fn responses_websocket(
215        &self,
216        model: impl Into<String>,
217    ) -> Result<
218        super::responses_api::websocket::ResponsesWebSocketSession,
219        crate::completion::CompletionError,
220    > {
221        self.responses_websocket_builder(model).connect().await
222    }
223}
224
225impl<H> CompletionsClient<H>
226where
227    H: HttpClientExt
228        + Clone
229        + std::fmt::Debug
230        + Default
231        + WasmCompatSend
232        + WasmCompatSync
233        + 'static,
234{
235    /// Create an extractor builder with the given completion model.
236    /// Uses the OpenAI Chat Completions API.
237    pub fn extractor<U>(
238        &self,
239        model: impl Into<String>,
240    ) -> ExtractorBuilder<super::completion::CompletionModel<H>, U>
241    where
242        U: JsonSchema + for<'a> Deserialize<'a> + Serialize + WasmCompatSend + WasmCompatSync,
243    {
244        ExtractorBuilder::new(self.completion_model(model))
245    }
246
247    /// Create a Responses API client from this Completions API client.
248    /// Useful for switching to the newer Responses API. A system-instructions
249    /// placement configured before switching to the Completions API is
250    /// restored.
251    pub fn responses_api(self) -> Client<H> {
252        let system_instructions_placement = self.ext().system_instructions_placement;
253        self.with_ext(OpenAIResponsesExt {
254            system_instructions_placement,
255        })
256    }
257}
258
259impl ProviderClient for Client {
260    type Input = OpenAIApiKey;
261    type Error = crate::client::ProviderClientError;
262
263    /// Create a new OpenAI Responses API client from the `OPENAI_API_KEY` environment variable.
264    fn from_env() -> Result<Self, Self::Error> {
265        let base_url = crate::client::optional_env_var("OPENAI_BASE_URL")?;
266        let api_key = crate::client::required_env_var("OPENAI_API_KEY")?;
267
268        let mut builder = Client::builder().api_key(&api_key);
269
270        if let Some(base) = base_url {
271            builder = builder.base_url(&base);
272        }
273
274        builder.build().map_err(Into::into)
275    }
276
277    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
278        Self::new(input).map_err(Into::into)
279    }
280}
281
282impl ProviderClient for CompletionsClient {
283    type Input = OpenAIApiKey;
284    type Error = crate::client::ProviderClientError;
285
286    /// Create a new OpenAI Completions API client from the `OPENAI_API_KEY` environment variable.
287    fn from_env() -> Result<Self, Self::Error> {
288        let base_url = crate::client::optional_env_var("OPENAI_BASE_URL")?;
289        let api_key = crate::client::required_env_var("OPENAI_API_KEY")?;
290
291        let mut builder = CompletionsClient::builder().api_key(&api_key);
292
293        if let Some(base) = base_url {
294            builder = builder.base_url(&base);
295        }
296
297        builder.build().map_err(Into::into)
298    }
299
300    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
301        Self::new(input).map_err(Into::into)
302    }
303}
304
305/// Error envelope returned by OpenAI-compatible providers alongside 2xx
306/// statuses. Providers spell the message field differently (`message`,
307/// `error`, nested objects), so anything that isn't a valid success payload
308/// is treated as an error envelope and the raw body is preserved for the
309/// caller; `message` is only used for logging.
310#[derive(Debug, Deserialize)]
311pub struct ApiErrorResponse {
312    #[serde(default, alias = "error", deserialize_with = "error_message_or_value")]
313    pub(crate) message: String,
314}
315
316fn error_message_or_value<'de, D>(deserializer: D) -> Result<String, D::Error>
317where
318    D: serde::Deserializer<'de>,
319{
320    let value = serde_json::Value::deserialize(deserializer)?;
321    Ok(match value {
322        serde_json::Value::String(message) => message,
323        other => other.to_string(),
324    })
325}
326
327#[derive(Debug, Deserialize)]
328#[serde(untagged)]
329pub(crate) enum ApiResponse<T> {
330    Ok(T),
331    Err(ApiErrorResponse),
332}
333
334#[cfg(test)]
335mod tests {
336    use crate::client::{CompletionClient, EmbeddingsClient};
337    use crate::message::ImageDetail;
338    use crate::providers::openai::{
339        AssistantContent, Function, ImageUrl, Message, ToolCall, ToolType, UserContent,
340    };
341    use crate::{OneOrMany, message};
342    use serde_path_to_error::deserialize;
343
344    #[test]
345    fn test_deserialize_message() {
346        let assistant_message_json = r#"
347        {
348            "role": "assistant",
349            "content": "\n\nHello there, how may I assist you today?"
350        }
351        "#;
352
353        let assistant_message_json2 = r#"
354        {
355            "role": "assistant",
356            "content": [
357                {
358                    "type": "text",
359                    "text": "\n\nHello there, how may I assist you today?"
360                }
361            ],
362            "tool_calls": null
363        }
364        "#;
365
366        let assistant_message_json3 = r#"
367        {
368            "role": "assistant",
369            "tool_calls": [
370                {
371                    "id": "call_h89ipqYUjEpCPI6SxspMnoUU",
372                    "type": "function",
373                    "function": {
374                        "name": "subtract",
375                        "arguments": "{\"x\": 2, \"y\": 5}"
376                    }
377                }
378            ],
379            "content": null,
380            "refusal": null
381        }
382        "#;
383
384        let user_message_json = r#"
385        {
386            "role": "user",
387            "content": [
388                {
389                    "type": "text",
390                    "text": "What's in this image?"
391                },
392                {
393                    "type": "image_url",
394                    "image_url": {
395                        "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
396                    }
397                },
398                {
399                    "type": "audio",
400                    "input_audio": {
401                        "data": "...",
402                        "format": "mp3"
403                    }
404                }
405            ]
406        }
407        "#;
408
409        let assistant_message: Message = {
410            let jd = &mut serde_json::Deserializer::from_str(assistant_message_json);
411            deserialize(jd).unwrap_or_else(|err| {
412                panic!(
413                    "Deserialization error at {} ({}:{}): {}",
414                    err.path(),
415                    err.inner().line(),
416                    err.inner().column(),
417                    err
418                );
419            })
420        };
421
422        let assistant_message2: Message = {
423            let jd = &mut serde_json::Deserializer::from_str(assistant_message_json2);
424            deserialize(jd).unwrap_or_else(|err| {
425                panic!(
426                    "Deserialization error at {} ({}:{}): {}",
427                    err.path(),
428                    err.inner().line(),
429                    err.inner().column(),
430                    err
431                );
432            })
433        };
434
435        let assistant_message3: Message = {
436            let jd: &mut serde_json::Deserializer<serde_json::de::StrRead<'_>> =
437                &mut serde_json::Deserializer::from_str(assistant_message_json3);
438            deserialize(jd).unwrap_or_else(|err| {
439                panic!(
440                    "Deserialization error at {} ({}:{}): {}",
441                    err.path(),
442                    err.inner().line(),
443                    err.inner().column(),
444                    err
445                );
446            })
447        };
448
449        let user_message: Message = {
450            let jd = &mut serde_json::Deserializer::from_str(user_message_json);
451            deserialize(jd).unwrap_or_else(|err| {
452                panic!(
453                    "Deserialization error at {} ({}:{}): {}",
454                    err.path(),
455                    err.inner().line(),
456                    err.inner().column(),
457                    err
458                );
459            })
460        };
461
462        match assistant_message {
463            Message::Assistant { content, .. } => {
464                assert_eq!(
465                    content[0],
466                    AssistantContent::Text {
467                        text: "\n\nHello there, how may I assist you today?".to_string()
468                    }
469                );
470            }
471            _ => panic!("Expected assistant message"),
472        }
473
474        match assistant_message2 {
475            Message::Assistant {
476                content,
477                tool_calls,
478                ..
479            } => {
480                assert_eq!(
481                    content[0],
482                    AssistantContent::Text {
483                        text: "\n\nHello there, how may I assist you today?".to_string()
484                    }
485                );
486
487                assert_eq!(tool_calls, vec![]);
488            }
489            _ => panic!("Expected assistant message"),
490        }
491
492        match assistant_message3 {
493            Message::Assistant {
494                content,
495                tool_calls,
496                refusal,
497                ..
498            } => {
499                assert!(content.is_empty());
500                assert!(refusal.is_none());
501                assert_eq!(
502                    tool_calls[0],
503                    ToolCall {
504                        id: "call_h89ipqYUjEpCPI6SxspMnoUU".to_string(),
505                        r#type: ToolType::Function,
506                        function: Function {
507                            name: "subtract".to_string(),
508                            arguments: serde_json::json!({"x": 2, "y": 5}),
509                        },
510                    }
511                );
512            }
513            _ => panic!("Expected assistant message"),
514        }
515
516        match user_message {
517            Message::User { content, .. } => {
518                let (first, second) = {
519                    let mut iter = content.into_iter();
520                    (iter.next().unwrap(), iter.next().unwrap())
521                };
522                assert_eq!(
523                    first,
524                    UserContent::Text {
525                        text: "What's in this image?".to_string()
526                    }
527                );
528                assert_eq!(second, UserContent::Image { image_url: ImageUrl { url: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg".to_string(), detail: None } });
529            }
530            _ => panic!("Expected user message"),
531        }
532    }
533
534    #[test]
535    fn test_message_to_message_conversion() {
536        let user_message = message::Message::User {
537            content: OneOrMany::one(message::UserContent::text("Hello")),
538        };
539
540        let assistant_message = message::Message::Assistant {
541            id: None,
542            content: OneOrMany::one(message::AssistantContent::text("Hi there!")),
543        };
544
545        let converted_user_message: Vec<Message> = user_message.clone().try_into().unwrap();
546        let converted_assistant_message: Vec<Message> =
547            assistant_message.clone().try_into().unwrap();
548
549        match converted_user_message[0].clone() {
550            Message::User { content, .. } => {
551                assert_eq!(
552                    content.first(),
553                    UserContent::Text {
554                        text: "Hello".to_string()
555                    }
556                );
557            }
558            _ => panic!("Expected user message"),
559        }
560
561        match converted_assistant_message[0].clone() {
562            Message::Assistant { content, .. } => {
563                assert_eq!(
564                    content[0].clone(),
565                    AssistantContent::Text {
566                        text: "Hi there!".to_string()
567                    }
568                );
569            }
570            _ => panic!("Expected assistant message"),
571        }
572
573        let original_user_message: message::Message =
574            converted_user_message[0].clone().try_into().unwrap();
575        let original_assistant_message: message::Message =
576            converted_assistant_message[0].clone().try_into().unwrap();
577
578        assert_eq!(original_user_message, user_message);
579        assert_eq!(original_assistant_message, assistant_message);
580    }
581
582    #[test]
583    fn test_message_from_message_conversion() {
584        let user_message = Message::User {
585            content: OneOrMany::one(UserContent::Text {
586                text: "Hello".to_string(),
587            }),
588            name: None,
589        };
590
591        let assistant_message = Message::Assistant {
592            content: vec![AssistantContent::Text {
593                text: "Hi there!".to_string(),
594            }],
595            reasoning: None,
596            refusal: None,
597            audio: None,
598            name: None,
599            tool_calls: vec![],
600            reasoning_details: vec![],
601            images: vec![],
602        };
603
604        let converted_user_message: message::Message = user_message.clone().try_into().unwrap();
605        let converted_assistant_message: message::Message =
606            assistant_message.clone().try_into().unwrap();
607
608        match converted_user_message.clone() {
609            message::Message::User { content } => {
610                assert_eq!(content.first(), message::UserContent::text("Hello"));
611            }
612            _ => panic!("Expected user message"),
613        }
614
615        match converted_assistant_message.clone() {
616            message::Message::Assistant { content, .. } => {
617                assert_eq!(
618                    content.first(),
619                    message::AssistantContent::text("Hi there!")
620                );
621            }
622            _ => panic!("Expected assistant message"),
623        }
624
625        let original_user_message: Vec<Message> = converted_user_message.try_into().unwrap();
626        let original_assistant_message: Vec<Message> =
627            converted_assistant_message.try_into().unwrap();
628
629        assert_eq!(original_user_message[0], user_message);
630        assert_eq!(original_assistant_message[0], assistant_message);
631    }
632
633    #[test]
634    fn test_user_message_single_text_serializes_as_string() {
635        let user_message = Message::User {
636            content: OneOrMany::one(UserContent::Text {
637                text: "Hello world".to_string(),
638            }),
639            name: None,
640        };
641
642        let serialized = serde_json::to_value(&user_message).unwrap();
643
644        assert_eq!(serialized["role"], "user");
645        assert_eq!(serialized["content"], "Hello world");
646    }
647
648    #[test]
649    fn test_user_message_multiple_parts_serializes_as_array() {
650        let user_message = Message::User {
651            content: OneOrMany::many(vec![
652                UserContent::Text {
653                    text: "What's in this image?".to_string(),
654                },
655                UserContent::Image {
656                    image_url: ImageUrl {
657                        url: "https://example.com/image.jpg".to_string(),
658                        detail: Some(ImageDetail::default()),
659                    },
660                },
661            ])
662            .unwrap(),
663            name: None,
664        };
665
666        let serialized = serde_json::to_value(&user_message).unwrap();
667
668        assert_eq!(serialized["role"], "user");
669        assert!(serialized["content"].is_array());
670        assert_eq!(serialized["content"].as_array().unwrap().len(), 2);
671    }
672
673    #[test]
674    fn test_user_message_single_image_serializes_as_array() {
675        let user_message = Message::User {
676            content: OneOrMany::one(UserContent::Image {
677                image_url: ImageUrl {
678                    url: "https://example.com/image.jpg".to_string(),
679                    detail: Some(ImageDetail::default()),
680                },
681            }),
682            name: None,
683        };
684
685        let serialized = serde_json::to_value(&user_message).unwrap();
686
687        assert_eq!(serialized["role"], "user");
688        // Single non-text content should still serialize as array
689        assert!(serialized["content"].is_array());
690    }
691    #[test]
692    fn test_client_initialization() {
693        let _client =
694            crate::providers::openai::Client::new("dummy-key").expect("Client::new() failed");
695        let _client_from_builder = crate::providers::openai::Client::builder()
696            .api_key("dummy-key")
697            .build()
698            .expect("Client::builder() failed");
699    }
700
701    #[test]
702    fn test_legacy_chat_completion_model_type_annotation_still_compiles() {
703        let client = crate::providers::openai::Client::new("dummy-key")
704            .expect("Client::new() failed")
705            .completions_api();
706
707        let _model: crate::providers::openai::completion::CompletionModel<reqwest::Client> =
708            client.completion_model("gpt-4o");
709    }
710
711    #[test]
712    fn test_legacy_embedding_model_type_annotation_still_compiles() {
713        let client =
714            crate::providers::openai::Client::new("dummy-key").expect("Client::new() failed");
715
716        let _model: crate::providers::openai::EmbeddingModel<reqwest::Client> =
717            client.embedding_model(crate::providers::openai::TEXT_EMBEDDING_3_SMALL);
718    }
719}