Skip to main content

rig_core/providers/openai/
client.rs

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