Skip to main content

rig_core/providers/openai/extension/
mod.rs

1//! OpenAI's typed request options and reply extras. [`OpenAiExt`] keys one
2//! entry that serves both routes: [`OpenAiShared`] (`"*"`) goes to whichever
3//! route the request takes, [`ChatOptions`] (`"openai.chat"`) only to Chat
4//! Completions and [`OpenAiResponsesOptions`] (`"openai.responses"`) only to
5//! the Responses route.
6//!
7//! ```
8//! use rig_core::completion::CompletionRequest;
9//! use rig_core::providers::openai::extension::{OpenAiOptions, ReasoningSummary};
10//!
11//! let options = OpenAiOptions::default()
12//!     .store(false)
13//!     .reasoning_summary(ReasoningSummary::Auto);
14//! let request = CompletionRequest::new("hi").provider_option(options);
15//! # let _ = request;
16//! ```
17
18use std::collections::BTreeMap;
19
20use serde::Serialize;
21use serde_json::Value;
22
23use crate::completion::provider_options::reply_field;
24use crate::completion::{ExtensionOptions, ProviderExtension, ReplyExtras};
25use crate::message::Api;
26
27mod chat;
28mod responses;
29
30pub use chat::{
31    ApproximateLocation, AudioFormat, AudioOutput, ChatOptions, CompletionTokensDetails, Modality,
32    Prediction, PromptTokensDetails, SearchContextSize, UserLocation, WebSearchOptions,
33};
34pub(crate) use responses::Envelope;
35pub use responses::{
36    AccessPrograms, ContextManagement, CyberAccess, Include, ItemPhase, OpenAiResponsesOptions,
37    PromptCacheOptions, ReasoningContext, ReasoningMode, ReasoningOptions, ReasoningSummary,
38    Truncation,
39};
40
41/// OpenAI's provider extension, keyed by
42/// [`PROVIDER_NAME`](crate::providers::openai::PROVIDER_NAME).
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub struct OpenAiExt;
45
46impl ProviderExtension for OpenAiExt {
47    const PROVIDER: &'static str = crate::providers::openai::PROVIDER_NAME;
48    type Options = OpenAiOptions;
49    type Extras = OpenAiExtras;
50}
51
52/// OpenAI's request options, one section per route plus the shared one.
53/// Serialize-only; an unset field is not sent.
54///
55/// The field setters here write the same field as the section setters and
56/// name the route that sends it: the shared fields go to either route, the
57/// Responses fields only to the Responses route, OpenAI's default. Chat-only
58/// fields (`logit_bias`, `prediction`, `frequency_penalty`,
59/// `presence_penalty`, ...) have no setter here, since on the default route
60/// they would be skipped without an error; set them on [`ChatOptions`]
61/// through [`Self::chat`] for a Chat Completions client. A field both
62/// routes take with different spellings, such as `top_logprobs` or Chat's
63/// `logprobs`, is set on its section only. A section setter replaces every
64/// field of that section set before it.
65#[non_exhaustive]
66#[derive(Clone, Debug, Default, PartialEq, Serialize)]
67pub struct OpenAiOptions {
68    /// Fields both routes spell the same.
69    #[serde(rename = "*")]
70    pub shared: OpenAiShared,
71    /// Fields only Chat Completions sends.
72    #[serde(rename = "openai.chat")]
73    pub chat: ChatOptions,
74    /// Fields only the Responses route sends.
75    #[serde(rename = "openai.responses")]
76    pub responses: OpenAiResponsesOptions,
77}
78
79impl OpenAiOptions {
80    /// No option set.
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// These options with `chat` as the Chat Completions section.
86    #[must_use]
87    pub fn chat(mut self, chat: ChatOptions) -> Self {
88        self.chat = chat;
89        self
90    }
91
92    /// These options with `shared` as the shared section.
93    #[must_use]
94    pub fn shared(mut self, shared: OpenAiShared) -> Self {
95        self.shared = shared;
96        self
97    }
98
99    /// These options with `responses` as the Responses section.
100    #[must_use]
101    pub fn responses(mut self, responses: OpenAiResponsesOptions) -> Self {
102        self.responses = responses;
103        self
104    }
105
106    /// Apply `set` to the shared section.
107    fn with_shared(mut self, set: impl FnOnce(OpenAiShared) -> OpenAiShared) -> Self {
108        self.shared = set(std::mem::take(&mut self.shared));
109        self
110    }
111
112    /// Apply `set` to the Responses section.
113    fn with_responses(
114        mut self,
115        set: impl FnOnce(OpenAiResponsesOptions) -> OpenAiResponsesOptions,
116    ) -> Self {
117        self.responses = set(std::mem::take(&mut self.responses));
118        self
119    }
120
121    /// Send `store` on either route (the shared `*` section), as [`OpenAiShared::store`].
122    #[must_use]
123    pub fn store(self, store: bool) -> Self {
124        self.with_shared(|shared| shared.store(store))
125    }
126
127    /// Add `key: value` to `metadata` on either route (the shared `*` section), as
128    /// [`OpenAiShared::metadata`].
129    #[must_use]
130    pub fn metadata(self, key: impl Into<String>, value: impl Into<String>) -> Self {
131        self.with_shared(|shared| shared.metadata(key, value))
132    }
133
134    /// Send `prompt_cache_key` on either route (the shared `*` section), as
135    /// [`OpenAiShared::prompt_cache_key`].
136    #[must_use]
137    pub fn prompt_cache_key(self, key: impl Into<String>) -> Self {
138        self.with_shared(|shared| shared.prompt_cache_key(key))
139    }
140
141    /// Send `safety_identifier` on either route (the shared `*` section), as
142    /// [`OpenAiShared::safety_identifier`].
143    #[must_use]
144    pub fn safety_identifier(self, id: impl Into<String>) -> Self {
145        self.with_shared(|shared| shared.safety_identifier(id))
146    }
147
148    /// Send `reasoning.summary` on the Responses route only (OpenAI's default; a Chat
149    /// Completions client skips it), as
150    /// [`OpenAiResponsesOptions::reasoning_summary`].
151    #[must_use]
152    pub fn reasoning_summary(self, summary: ReasoningSummary) -> Self {
153        self.with_responses(|responses| responses.reasoning_summary(summary))
154    }
155
156    /// Add `include` entries on the Responses route only (OpenAI's default; a Chat
157    /// Completions client skips it), as
158    /// [`OpenAiResponsesOptions::include`].
159    #[must_use]
160    pub fn include(self, include: impl IntoIterator<Item = Include>) -> Self {
161        self.with_responses(|responses| responses.include(include))
162    }
163
164    /// Send `conversation` on the Responses route only (OpenAI's default; a Chat
165    /// Completions client skips it), as
166    /// [`OpenAiResponsesOptions::conversation`].
167    #[must_use]
168    pub fn conversation(self, id: impl Into<String>) -> Self {
169        self.with_responses(|responses| responses.conversation(id))
170    }
171
172    /// Send `truncation` on the Responses route only (OpenAI's default; a Chat
173    /// Completions client skips it), as
174    /// [`OpenAiResponsesOptions::truncation`].
175    #[must_use]
176    pub fn truncation(self, truncation: Truncation) -> Self {
177        self.with_responses(|responses| responses.truncation(truncation))
178    }
179
180    /// Send `background` on the Responses route only (OpenAI's default; a Chat
181    /// Completions client skips it), as
182    /// [`OpenAiResponsesOptions::background`].
183    #[must_use]
184    pub fn background(self, background: bool) -> Self {
185        self.with_responses(|responses| responses.background(background))
186    }
187
188    /// Send `max_tool_calls` on the Responses route only (OpenAI's default; a Chat
189    /// Completions client skips it), as
190    /// [`OpenAiResponsesOptions::max_tool_calls`].
191    #[must_use]
192    pub fn max_tool_calls(self, max: u32) -> Self {
193        self.with_responses(|responses| responses.max_tool_calls(max))
194    }
195}
196
197impl ExtensionOptions for OpenAiOptions {
198    type Ext = OpenAiExt;
199}
200
201/// The fields both OpenAI routes take at the top level of the body.
202#[non_exhaustive]
203#[derive(Clone, Debug, Default, PartialEq, Serialize)]
204pub struct OpenAiShared {
205    /// `store`: whether the provider keeps the response. On Responses,
206    /// `false` also sends prior reasoning with its ciphertext instead of by
207    /// reference.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub store: Option<bool>,
210    /// `metadata`: string pairs the provider keeps with the response.
211    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
212    pub metadata: BTreeMap<String, String>,
213    /// `prompt_cache_key`: the key the provider routes cached prompts by.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub prompt_cache_key: Option<String>,
216    /// `safety_identifier`: a stable, hashed id of the end user.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub safety_identifier: Option<String>,
219}
220
221impl OpenAiShared {
222    /// Send `store`.
223    #[must_use]
224    pub fn store(mut self, store: bool) -> Self {
225        self.store = Some(store);
226        self
227    }
228
229    /// Add `key: value` to `metadata`.
230    #[must_use]
231    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
232        self.metadata.insert(key.into(), value.into());
233        self
234    }
235
236    /// Send `prompt_cache_key`.
237    #[must_use]
238    pub fn prompt_cache_key(mut self, key: impl Into<String>) -> Self {
239        self.prompt_cache_key = Some(key.into());
240        self
241    }
242
243    /// Send `safety_identifier`.
244    #[must_use]
245    pub fn safety_identifier(mut self, id: impl Into<String>) -> Self {
246        self.safety_identifier = Some(id.into());
247        self
248    }
249}
250
251/// The typed view of an OpenAI reply. A field the reply does not carry,
252/// including one only the other route returns, is `None`.
253#[non_exhaustive]
254#[derive(Clone, Debug, Default, PartialEq)]
255pub struct OpenAiExtras {
256    /// `/service_tier`: the tier that served the request, on both routes.
257    pub service_tier: Option<String>,
258    /// `/reasoning/effort` on Responses: the effort the model used.
259    pub reasoning_effort: Option<String>,
260    /// `/reasoning/summary` on Responses: `auto`, `concise` or `detailed`.
261    pub reasoning_summary: Option<String>,
262    /// `/reasoning/mode` on Responses: `standard` or `pro`.
263    pub reasoning_mode: Option<String>,
264    /// `/reasoning/context` on Responses: `auto`, `all_turns` or
265    /// `current_turn`.
266    pub reasoning_context: Option<String>,
267    /// `/prompt_cache_retention` on Responses: `in_memory` or `24h`.
268    pub prompt_cache_retention: Option<String>,
269    /// `/incomplete_details/reason` on Responses, such as
270    /// `max_output_tokens`.
271    pub incomplete_reason: Option<String>,
272    /// The `phase` of each `message` item in `/output` on Responses, in
273    /// order; `None` when the output holds no message.
274    pub phases: Option<Vec<ItemPhase>>,
275    /// `/billing/payer` on a unary Responses reply: who pays for it.
276    pub billing_payer: Option<String>,
277    /// `/system_fingerprint` on Chat: the backend configuration's
278    /// fingerprint.
279    pub system_fingerprint: Option<String>,
280    /// `/usage/prompt_tokens_details` on Chat.
281    pub prompt_tokens_details: Option<PromptTokensDetails>,
282    /// `/usage/completion_tokens_details` on Chat.
283    pub completion_tokens_details: Option<CompletionTokensDetails>,
284    /// `/choices/0/message/annotations` on Chat, such as URL citations.
285    pub annotations: Option<Vec<Value>>,
286}
287
288impl ReplyExtras for OpenAiExtras {
289    fn from_reply(api: &Api, raw: &Value) -> Result<Self, serde_json::Error> {
290        if api.as_str() == "openai.chat" {
291            return Ok(Self {
292                service_tier: reply_field(raw, "/service_tier")?,
293                system_fingerprint: reply_field(raw, "/system_fingerprint")?,
294                prompt_tokens_details: reply_field(raw, "/usage/prompt_tokens_details")?,
295                completion_tokens_details: reply_field(raw, "/usage/completion_tokens_details")?,
296                annotations: reply_field(raw, "/choices/0/message/annotations")?,
297                ..Self::default()
298            });
299        }
300        let envelope = responses::Envelope::from_reply(raw)?;
301        Ok(Self {
302            service_tier: envelope.service_tier,
303            reasoning_effort: envelope.reasoning_effort,
304            reasoning_summary: envelope.reasoning_summary,
305            reasoning_mode: envelope.reasoning_mode,
306            reasoning_context: envelope.reasoning_context,
307            prompt_cache_retention: envelope.prompt_cache_retention,
308            incomplete_reason: envelope.incomplete_reason,
309            phases: envelope.phases,
310            billing_payer: reply_field(raw, "/billing/payer")?,
311            ..Self::default()
312        })
313    }
314}
315
316#[cfg(test)]
317mod tests;