Skip to main content

acorn/io/api/
openai.rs

1//! Module for interacting with OpenAI-compatible interfaces
2//!
3//! See <https://platform.openai.com/docs/api-reference> for more information
4//!
5use crate::io::api::{ApiResult, Configuration, Endpoint, Fallback, Param, Params, RemoteResource};
6use crate::param;
7use crate::prelude::var;
8use crate::util::Label;
9use bon::Builder;
10use color_eyre::eyre::eyre;
11use dotenvy;
12use serde::{Deserialize, Serialize};
13use tracing::debug;
14
15/// Raw OpenAI chat completion response payload.
16///
17/// The OpenAPI schema for chat completions is broad and evolves quickly,
18/// so the baseline implementation preserves the full JSON document.
19pub type ChatCompletionListResponse = serde_json::Value;
20/// Raw OpenAI chat completion response payload.
21///
22/// The OpenAPI schema for chat completions is broad and evolves quickly,
23/// so the baseline implementation preserves the full JSON document.
24pub type ChatCompletionResponse = serde_json::Value;
25/// Raw OpenAI response payload for chat completion message lists.
26///
27/// The OpenAPI schema for chat completion messages is broad and evolves quickly,
28/// so the baseline implementation preserves the full JSON document.
29pub type ChatCompletionMessagesResponse = serde_json::Value;
30/// Raw OpenAI responses API payload.
31///
32/// The OpenAPI schema for responses is broad and evolves quickly,
33/// so the baseline implementation preserves the full JSON document.
34pub type CreateResponseOutput = serde_json::Value;
35/// Raw OpenAI responses deletion payload.
36///
37/// The OpenAPI schema for response deletion is broad and evolves quickly,
38/// so the baseline implementation preserves the full JSON document.
39pub type DeleteResponseOutput = serde_json::Value;
40/// Raw OpenAI model deletion payload.
41///
42/// The OpenAPI schema for model deletion is broad and evolves quickly,
43/// so the baseline implementation preserves the full JSON document.
44pub type ModelDeleteOutput = serde_json::Value;
45/// Raw OpenAI response input item list payload.
46///
47/// The OpenAPI schema for response input items is broad and evolves quickly,
48/// so the baseline implementation preserves the full JSON document.
49pub type ResponseInputItemsOutput = serde_json::Value;
50/// OpenAI API error payload
51///
52/// Matches `#/components/schemas/Error` from the OpenAI OpenAPI specification.
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct Error {
55    /// Stable error code, when available
56    pub code: Option<String>,
57    /// Human-readable error message
58    pub message: String,
59    /// Parameter associated with this error, when applicable
60    pub param: Option<String>,
61    /// Error type identifier
62    #[serde(rename = "type")]
63    pub error_type: String,
64}
65/// OpenAI API error response
66///
67/// Matches `#/components/schemas/ErrorResponse` from the OpenAI OpenAPI specification.
68#[derive(Clone, Debug, Serialize, Deserialize)]
69pub struct ErrorResponse {
70    /// Wrapped error details
71    pub error: Error,
72}
73/// OpenAI list models response
74///
75/// Matches baseline fields from `#/components/schemas/ListModelsResponse`.
76#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct ListModelsResponse {
78    /// Object type, expected to be `list`
79    pub object: String,
80    /// Collection of models available to the caller
81    pub data: Vec<Model>,
82}
83/// OpenAI model descriptor
84///
85/// Matches baseline fields from `#/components/schemas/Model`.
86#[derive(Clone, Debug, Serialize, Deserialize)]
87pub struct Model {
88    /// Model identifier (for example, `gpt-5.4`)
89    pub id: String,
90    /// Object type, typically `model`
91    pub object: String,
92    /// Unix timestamp (seconds) when model metadata was created
93    pub created: i64,
94    /// Owning organization
95    pub owned_by: String,
96}
97/// OpenAI API options
98///
99/// Configuration options used across OpenAI API operations.
100#[derive(Builder, Clone, Debug)]
101#[builder(start_fn = with_token, on(String, into))]
102pub struct Options {
103    /// Bearer token for authentication
104    #[builder(start_fn)]
105    pub token: String,
106    /// Request body payload for POST requests
107    pub body: Option<String>,
108    /// OpenAI API domain (defaults to `api.openai.com`)
109    #[builder(default = String::from("api.openai.com"))]
110    pub domain: String,
111    /// Optional model identifier for model retrieval
112    pub identifier: Option<String>,
113    /// Optional cursor for paginated list APIs
114    pub after: Option<String>,
115    /// Optional page size for list APIs
116    pub limit: Option<u32>,
117    /// Optional ordering value for list APIs (for example, `asc` or `desc`)
118    pub order: Option<String>,
119    /// Optional model filter used by selected list APIs
120    pub model: Option<String>,
121    /// Custom API parameters to include in every request
122    #[builder(default = vec![])]
123    pub custom_params: Vec<Param>,
124}
125impl Configuration for Options {
126    /// Build options from OpenAI-related environment variables.
127    /// - `OPENAI_API_KEY` -> `token` (defaults to empty string when unset)
128    /// - `OPENAI_SERVER_HOST` -> `domain` (defaults to api.openai.com when unset)
129    fn from_env() -> Self {
130        if let Err(why) = dotenvy::from_filename(".env") {
131            debug!("=> {} Load .env — {why}", Label::skip());
132        }
133        Self {
134            token: var("OPENAI_API_KEY").unwrap_or_default(),
135            body: None,
136            domain: var("OPENAI_SERVER_HOST").unwrap_or_else(|_| String::from("api.openai.com")),
137            identifier: None,
138            after: None,
139            limit: None,
140            order: None,
141            model: None,
142            custom_params: vec![],
143        }
144    }
145    /// Return a copy of options with request body payload set
146    fn with_body(self, value: impl Into<String>) -> Self {
147        Self {
148            body: Some(value.into()),
149            ..self
150        }
151    }
152    /// Return a copy of options with OpenAI server domain set
153    fn with_domain(self, value: impl Into<String>) -> Self {
154        Self {
155            domain: value.into(),
156            ..self
157        }
158    }
159    /// Return a copy of options with model identifier set
160    fn with_identifier(self, value: impl Into<String>) -> Self {
161        Self {
162            identifier: Some(value.into()),
163            ..self
164        }
165    }
166    /// Return the authentication token
167    fn token(&self) -> &str {
168        &self.token
169    }
170    /// Return the OpenAI server domain
171    fn domain(&self) -> &str {
172        &self.domain
173    }
174    /// Return the optional model identifier
175    fn identifier(&self) -> Option<&str> {
176        self.identifier.as_deref()
177    }
178    /// Return a copy of options with custom API parameters set
179    fn with_params(self, params: Vec<Param>) -> Self {
180        Self {
181            custom_params: params,
182            ..self
183        }
184    }
185    /// Return any custom API parameters
186    fn params(&self) -> &[Param] {
187        &self.custom_params
188    }
189}
190impl Options {
191    /// Return a copy of options with list cursor set
192    pub fn with_after(self, value: impl Into<String>) -> Self {
193        Self {
194            after: Some(value.into()),
195            ..self
196        }
197    }
198    /// Return a copy of options with page size set
199    pub fn with_limit(self, value: u32) -> Self {
200        Self { limit: Some(value), ..self }
201    }
202    /// Return a copy of options with model filter set
203    pub fn with_model(self, value: impl Into<String>) -> Self {
204        Self {
205            model: Some(value.into()),
206            ..self
207        }
208    }
209    /// Return a copy of options with order value set
210    pub fn with_order(self, value: impl Into<String>) -> Self {
211        Self {
212            order: Some(value.into()),
213            ..self
214        }
215    }
216}
217/// Create a chat completion response via `POST /chat/completions`.
218pub async fn chat_completion(options: &Options) -> ApiResult<ChatCompletionResponse> {
219    let template = "openai::api";
220    let action = "chat-completion";
221    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
222        | Ok(endpoint) => match &options.body {
223            | Some(value) if !value.is_empty() => {
224                let params = Params::new()
225                    .with_auth(options.token(), None)
226                    .with(param!(Body, value.as_str()))
227                    .with_custom(options.params())
228                    .build();
229                let response = endpoint.invoke(action, Some(params)).await;
230                endpoint.handle_or::<ChatCompletionResponse, Fallback<ErrorResponse>>(response)
231            }
232            | Some(_) | None => Err(eyre!("OpenAI chat completion request body is required")),
233        },
234        | Err(why) => Err(why),
235    }
236}
237/// List stored chat completions via `GET /chat/completions`.
238pub async fn chat_completion_list(options: &Options) -> ApiResult<ChatCompletionListResponse> {
239    let template = "openai::api";
240    let action = "chat-completion::list";
241    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
242        | Ok(endpoint) => {
243            let params = Params::new()
244                .with_auth(options.token(), None)
245                .with_keyvalue("after", options.after.as_deref().filter(|v| !v.is_empty()))
246                .with_keyvalue("limit", options.limit.map(|v| v.to_string()).as_deref())
247                .with_keyvalue("order", options.order.as_deref().filter(|v| !v.is_empty()))
248                .with_keyvalue("model", options.model.as_deref().filter(|v| !v.is_empty()))
249                .with_custom(options.params())
250                .build();
251            let response = endpoint.invoke(action, Some(params)).await;
252            endpoint.handle_or::<ChatCompletionListResponse, Fallback<ErrorResponse>>(response)
253        }
254        | Err(why) => Err(why),
255    }
256}
257/// Delete a stored chat completion by identifier via `DELETE /chat/completions/{completion_id}`.
258pub async fn chat_completion_delete(options: &Options) -> ApiResult<DeleteResponseOutput> {
259    let template = "openai::api";
260    let action = "chat-completion::delete";
261    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
262        | Ok(endpoint) => match options.identifier() {
263            | Some(value) if !value.is_empty() => {
264                let params = Params::from_config(options).with_custom(options.params()).build();
265                let response = endpoint.invoke(action, Some(params)).await;
266                endpoint.handle_or::<DeleteResponseOutput, Fallback<ErrorResponse>>(response)
267            }
268            | Some(_) | None => Err(eyre!("OpenAI chat completion identifier is required")),
269        },
270        | Err(why) => Err(why),
271    }
272}
273/// List stored chat completion messages by completion identifier.
274pub async fn chat_completion_messages(options: &Options) -> ApiResult<ChatCompletionMessagesResponse> {
275    let template = "openai::api";
276    let action = "chat-completion::messages";
277    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
278        | Ok(endpoint) => match &options.identifier {
279            | Some(value) if !value.is_empty() => {
280                let params = Params::new()
281                    .with_auth(options.token(), None)
282                    .with_template("identifier", Some(value))
283                    .with_keyvalue("after", options.after.as_deref().filter(|v| !v.is_empty()))
284                    .with_keyvalue("limit", options.limit.map(|v| v.to_string()).as_deref())
285                    .with_keyvalue("order", options.order.as_deref().filter(|v| !v.is_empty()))
286                    .with_custom(options.params())
287                    .build();
288                let response = endpoint.invoke(action, Some(params)).await;
289                endpoint.handle_or::<ChatCompletionMessagesResponse, Fallback<ErrorResponse>>(response)
290            }
291            | Some(_) | None => Err(eyre!("OpenAI chat completion identifier is required")),
292        },
293        | Err(why) => Err(why),
294    }
295}
296/// Retrieve a stored chat completion by identifier via `GET /chat/completions/{completion_id}`.
297pub async fn chat_completion_retrieve(options: &Options) -> ApiResult<ChatCompletionResponse> {
298    let template = "openai::api";
299    let action = "chat-completion::retrieve";
300    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
301        | Ok(endpoint) => match options.identifier() {
302            | Some(value) if !value.is_empty() => {
303                let params = Params::from_config(options).with_custom(options.params()).build();
304                let response = endpoint.invoke(action, Some(params)).await;
305                endpoint.handle_or::<ChatCompletionResponse, Fallback<ErrorResponse>>(response)
306            }
307            | Some(_) | None => Err(eyre!("OpenAI chat completion identifier is required")),
308        },
309        | Err(why) => Err(why),
310    }
311}
312/// Update a stored chat completion by identifier via `POST /chat/completions/{completion_id}`.
313pub async fn chat_completion_update(options: &Options) -> ApiResult<ChatCompletionResponse> {
314    let template = "openai::api";
315    let action = "chat-completion::update";
316    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
317        | Ok(endpoint) => match (&options.identifier, &options.body) {
318            | (Some(id), Some(value)) if !id.is_empty() && !value.is_empty() => {
319                let params = Params::new()
320                    .with_auth(options.token(), None)
321                    .with_template("identifier", Some(id))
322                    .with(param!(Body, value.as_str()))
323                    .with_custom(options.params())
324                    .build();
325                let response = endpoint.invoke(action, Some(params)).await;
326                endpoint.handle_or::<ChatCompletionResponse, Fallback<ErrorResponse>>(response)
327            }
328            | (Some(_), Some(_)) => Err(eyre!("OpenAI chat completion identifier and body are required")),
329            | _ => Err(eyre!("OpenAI chat completion identifier and body are required")),
330        },
331        | Err(why) => Err(why),
332    }
333}
334/// Retrieve details for a specific model identifier.
335pub async fn model(options: &Options) -> ApiResult<Model> {
336    let template = "openai::api";
337    let action = "model";
338    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
339        | Ok(endpoint) => match options.identifier() {
340            | Some(value) if !value.is_empty() => {
341                let params = Params::from_config(options).with_custom(options.params()).build();
342                let response = endpoint.invoke(action, Some(params)).await;
343                endpoint.handle_or::<Model, Fallback<ErrorResponse>>(response)
344            }
345            | Some(_) | None => Err(eyre!("OpenAI model identifier is required")),
346        },
347        | Err(why) => Err(why),
348    }
349}
350/// Delete a model by identifier via `DELETE /models/{model}`.
351pub async fn model_delete(options: &Options) -> ApiResult<ModelDeleteOutput> {
352    let template = "openai::api";
353    let action = "model::delete";
354    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
355        | Ok(endpoint) => match options.identifier() {
356            | Some(value) if !value.is_empty() => {
357                let params = Params::from_config(options).with_custom(options.params()).build();
358                let response = endpoint.invoke(action, Some(params)).await;
359                endpoint.handle_or::<ModelDeleteOutput, Fallback<ErrorResponse>>(response)
360            }
361            | Some(_) | None => Err(eyre!("OpenAI model identifier is required")),
362        },
363        | Err(why) => Err(why),
364    }
365}
366/// Retrieve all models available to the authenticated caller.
367pub async fn models(options: &Options) -> ApiResult<ListModelsResponse> {
368    let template = "openai::api";
369    let action = "models";
370    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
371        | Ok(endpoint) => {
372            let params = Params::from_config(options).with_custom(options.params()).build();
373            let response = endpoint.invoke(action, Some(params)).await;
374            endpoint.handle_or::<ListModelsResponse, Fallback<ErrorResponse>>(response)
375        }
376        | Err(why) => Err(why),
377    }
378}
379/// Create a response via `POST /responses`.
380pub async fn response(options: &Options) -> ApiResult<CreateResponseOutput> {
381    let template = "openai::api";
382    let action = "response";
383    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
384        | Ok(endpoint) => match &options.body {
385            | Some(value) if !value.is_empty() => {
386                let params = Params::new()
387                    .with_auth(options.token(), None)
388                    .with(param!(Body, value.as_str()))
389                    .with_custom(options.params())
390                    .build();
391                let response = endpoint.invoke(action, Some(params)).await;
392                endpoint.handle_or::<CreateResponseOutput, Fallback<ErrorResponse>>(response)
393            }
394            | Some(_) | None => Err(eyre!("OpenAI responses request body is required")),
395        },
396        | Err(why) => Err(why),
397    }
398}
399/// Delete a response by identifier via `DELETE /responses/{response_id}`.
400pub async fn response_delete(options: &Options) -> ApiResult<DeleteResponseOutput> {
401    let template = "openai::api";
402    let action = "response::delete";
403    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
404        | Ok(endpoint) => match options.identifier() {
405            | Some(value) if !value.is_empty() => {
406                let params = Params::from_config(options).with_custom(options.params()).build();
407                let response = endpoint.invoke(action, Some(params)).await;
408                endpoint.handle_or::<DeleteResponseOutput, Fallback<ErrorResponse>>(response)
409            }
410            | Some(_) | None => Err(eyre!("OpenAI response identifier is required")),
411        },
412        | Err(why) => Err(why),
413    }
414}
415/// List input items for a response by identifier via `GET /responses/{response_id}/input_items`.
416pub async fn response_input_items(options: &Options) -> ApiResult<ResponseInputItemsOutput> {
417    let template = "openai::api";
418    let action = "response::input-items";
419    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
420        | Ok(endpoint) => match &options.identifier {
421            | Some(value) if !value.is_empty() => {
422                let params = Params::new()
423                    .with_auth(options.token(), None)
424                    .with_template("identifier", Some(value))
425                    .with_keyvalue("after", options.after.as_deref().filter(|v| !v.is_empty()))
426                    .with_keyvalue("limit", options.limit.map(|v| v.to_string()).as_deref())
427                    .with_keyvalue("order", options.order.as_deref().filter(|v| !v.is_empty()))
428                    .with_custom(options.params())
429                    .build();
430                let response = endpoint.invoke(action, Some(params)).await;
431                endpoint.handle_or::<ResponseInputItemsOutput, Fallback<ErrorResponse>>(response)
432            }
433            | Some(_) | None => Err(eyre!("OpenAI response identifier is required")),
434        },
435        | Err(why) => Err(why),
436    }
437}
438/// Cancel a response by identifier via `POST /responses/{response_id}/cancel`.
439pub async fn response_cancel(options: &Options) -> ApiResult<CreateResponseOutput> {
440    let template = "openai::api";
441    let action = "response::cancel";
442    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
443        | Ok(endpoint) => match options.identifier() {
444            | Some(value) if !value.is_empty() => {
445                let params = Params::from_config(options).with_custom(options.params()).build();
446                let response = endpoint.invoke(action, Some(params)).await;
447                endpoint.handle_or::<CreateResponseOutput, Fallback<ErrorResponse>>(response)
448            }
449            | Some(_) | None => Err(eyre!("OpenAI response identifier is required")),
450        },
451        | Err(why) => Err(why),
452    }
453}
454/// Retrieve a response by identifier via `GET /responses/{response_id}`.
455pub async fn response_retrieve(options: &Options) -> ApiResult<CreateResponseOutput> {
456    let template = "openai::api";
457    let action = "response::retrieve";
458    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
459        | Ok(endpoint) => match options.identifier() {
460            | Some(value) if !value.is_empty() => {
461                let params = Params::from_config(options).with_custom(options.params()).build();
462                let response = endpoint.invoke(action, Some(params)).await;
463                endpoint.handle_or::<CreateResponseOutput, Fallback<ErrorResponse>>(response)
464            }
465            | Some(_) | None => Err(eyre!("OpenAI response identifier is required")),
466        },
467        | Err(why) => Err(why),
468    }
469}