Skip to main content

acorn/io/api/
openai.rs

1//! Module for interacting with OpenAI-compatible interfaces.
2//!
3//! This module intentionally implements the OpenAI-compatible endpoint subset
4//! supported by [llama-swap](https://github.com/mostlygeek/llama-swap):
5//! completions, chat completions, responses, embeddings, model listing, audio
6//! speech/transcriptions/voices, and image generations/edits. It does not try
7//! to mirror the full OpenAI API surface because ACORN primarily needs a stable
8//! interface for local OpenAI-compatible routers and model-swapping proxies.
9//!
10//! Set `OPENAI_SERVER_HOST` to a llama-swap host such as `localhost:8080` or
11//! `http://localhost:8080`; when unset, requests default to `api.openai.com`.
12//! Set `OPENAI_API_KEY` when the upstream server requires Bearer auth.
13//!
14//! # Examples
15//!
16//! Create a chat completion against a local llama-swap server:
17//!
18//! ```no_run
19//! use acorn::io::api::Configuration;
20//! use acorn::io::api::openai;
21//!
22//! # async fn example() -> color_eyre::Result<()> {
23//! let body = r#"{
24//!     "model": "qwen3-coder",
25//!     "messages": [{"role": "user", "content": "Summarize ACORN."}]
26//! }"#;
27//! let options = openai::Options::from_env()
28//!     .with_domain("http://localhost:8080")
29//!     .with_body(body);
30//! let output = openai::chat_completion(&options).await?;
31//! println!("{output:#}");
32//! # Ok(())
33//! # }
34//! ```
35//!
36//! List models exposed by the configured OpenAI-compatible server:
37//!
38//! ```no_run
39//! use acorn::io::api::Configuration;
40//! use acorn::io::api::openai;
41//!
42//! # async fn example() -> color_eyre::Result<()> {
43//! let options = openai::Options::from_env().with_domain("http://localhost:8080");
44//! let models = openai::models(&options).await?;
45//! for model in models.data {
46//!     println!("{}", model.id);
47//! }
48//! # Ok(())
49//! # }
50//! ```
51use crate::io::api::{ApiResult, Configuration, Endpoint, Fallback, Param, Params, RemoteResource, TextResponse};
52use crate::param;
53use crate::prelude::var;
54use crate::util::Label;
55use bon::Builder;
56use color_eyre::eyre::eyre;
57use dotenvy;
58use serde::{Deserialize, Serialize};
59use tracing::debug;
60
61/// Raw OpenAI API response payload.
62///
63/// The OpenAI-compatible schema is broad and evolves quickly,
64/// so the baseline implementation preserves the full JSON document.
65pub type Response = serde_json::Value;
66/// Raw OpenAI audio speech response payload.
67///
68/// Audio speech usually returns non-JSON audio bytes, so this preserves the raw response text.
69pub type AudioSpeechResponse = TextResponse;
70enum BodyMode {
71    Empty,
72    Required,
73}
74/// OpenAI API error payload
75///
76/// Matches `#/components/schemas/Error` from the OpenAI OpenAPI specification.
77#[derive(Clone, Debug, Serialize, Deserialize)]
78pub struct Error {
79    /// Stable error code, when available
80    pub code: Option<String>,
81    /// Human-readable error message
82    pub message: String,
83    /// Parameter associated with this error, when applicable
84    pub param: Option<String>,
85    /// Error type identifier
86    #[serde(rename = "type")]
87    pub error_type: String,
88}
89/// OpenAI API error response
90///
91/// Matches `#/components/schemas/ErrorResponse` from the OpenAI OpenAPI specification.
92#[derive(Clone, Debug, Serialize, Deserialize)]
93pub struct ErrorResponse {
94    /// Wrapped error details
95    pub error: Error,
96}
97/// OpenAI list models response
98///
99/// Matches baseline fields from `#/components/schemas/ListModelsResponse`.
100#[derive(Clone, Debug, Serialize, Deserialize)]
101pub struct ListModelsResponse {
102    /// Object type, expected to be `list`
103    pub object: String,
104    /// Collection of models available to the caller
105    pub data: Vec<Model>,
106}
107/// OpenAI model descriptor
108///
109/// Matches baseline fields from `#/components/schemas/Model`.
110#[derive(Clone, Debug, Serialize, Deserialize)]
111pub struct Model {
112    /// Model identifier (for example, `gpt-5.4`)
113    pub id: String,
114    /// Object type, typically `model`
115    pub object: String,
116    /// Unix timestamp (seconds) when model metadata was created
117    pub created: i64,
118    /// Owning organization
119    pub owned_by: String,
120}
121/// OpenAI API options
122///
123/// Configuration options used across OpenAI API operations.
124#[derive(Builder, Clone, Debug)]
125#[builder(start_fn = with_token, on(String, into))]
126pub struct Options {
127    /// Bearer token for authentication
128    #[builder(start_fn)]
129    pub token: String,
130    /// Request body payload for POST requests
131    pub body: Option<String>,
132    /// OpenAI API domain (defaults to `api.openai.com`)
133    #[builder(default = String::from("api.openai.com"))]
134    pub domain: String,
135    /// Optional resource identifier for custom API parameters
136    pub identifier: Option<String>,
137    /// Custom API parameters to include in every request
138    #[builder(default = vec![])]
139    pub custom_params: Vec<Param>,
140}
141impl Configuration for Options {
142    /// Build options from OpenAI-related environment variables.
143    /// - `OPENAI_API_KEY` -> `token` (defaults to empty string when unset)
144    /// - `OPENAI_SERVER_HOST` -> `domain` (defaults to api.openai.com when unset)
145    fn from_env() -> Self {
146        if let Err(why) = dotenvy::from_filename(".env") {
147            debug!("=> {} Load .env — {why}", Label::skip());
148        }
149        Self {
150            token: var("OPENAI_API_KEY").unwrap_or_default(),
151            body: None,
152            domain: var("OPENAI_SERVER_HOST").unwrap_or_else(|_| String::from("api.openai.com")),
153            identifier: None,
154            custom_params: vec![],
155        }
156    }
157    /// Return a copy of options with request body payload set
158    fn with_body(self, value: impl Into<String>) -> Self {
159        Self {
160            body: Some(value.into()),
161            ..self
162        }
163    }
164    /// Return a copy of options with OpenAI server domain set
165    fn with_domain(self, value: impl Into<String>) -> Self {
166        Self {
167            domain: value.into(),
168            ..self
169        }
170    }
171    /// Return a copy of options with model identifier set
172    fn with_identifier(self, value: impl Into<String>) -> Self {
173        Self {
174            identifier: Some(value.into()),
175            ..self
176        }
177    }
178    /// Return the authentication token
179    fn token(&self) -> &str {
180        &self.token
181    }
182    /// Return the OpenAI server domain
183    fn domain(&self) -> &str {
184        &self.domain
185    }
186    /// Return the optional model identifier
187    fn identifier(&self) -> Option<&str> {
188        self.identifier.as_deref()
189    }
190    /// Return a copy of options with custom API parameters set
191    fn with_params(self, params: Vec<Param>) -> Self {
192        Self {
193            custom_params: params,
194            ..self
195        }
196    }
197    /// Return any custom API parameters
198    fn params(&self) -> &[Param] {
199        &self.custom_params
200    }
201}
202/// Create audio speech via `POST /audio/speech`.
203pub async fn audio_speech(options: &Options) -> ApiResult<AudioSpeechResponse> {
204    invoke(options, "audio-speech", BodyMode::Required).await
205}
206/// Create an audio transcription via `POST /audio/transcriptions`.
207pub async fn audio_transcription(options: &Options) -> ApiResult<Response> {
208    invoke(options, "audio-transcription", BodyMode::Required).await
209}
210/// Retrieve available audio voices via `GET /audio/voices`.
211pub async fn audio_voices(options: &Options) -> ApiResult<Response> {
212    invoke(options, "audio-voices", BodyMode::Empty).await
213}
214/// Create a chat completion response via `POST /chat/completions`.
215pub async fn chat_completion(options: &Options) -> ApiResult<Response> {
216    invoke(options, "chat-completion", BodyMode::Required).await
217}
218/// Create a completion via `POST /completions`.
219pub async fn completion(options: &Options) -> ApiResult<Response> {
220    invoke(options, "completion", BodyMode::Required).await
221}
222/// Create embeddings via `POST /embeddings`.
223pub async fn embedding(options: &Options) -> ApiResult<Response> {
224    invoke(options, "embedding", BodyMode::Required).await
225}
226/// Edit an image via `POST /images/edits`.
227pub async fn image_edit(options: &Options) -> ApiResult<Response> {
228    invoke(options, "image-edit", BodyMode::Required).await
229}
230/// Create an image via `POST /images/generations`.
231pub async fn image_generation(options: &Options) -> ApiResult<Response> {
232    invoke(options, "image-generation", BodyMode::Required).await
233}
234/// Retrieve all models available to the authenticated caller.
235pub async fn models(options: &Options) -> ApiResult<ListModelsResponse> {
236    invoke(options, "models", BodyMode::Empty).await
237}
238/// Create a response via `POST /responses`.
239pub async fn response(options: &Options) -> ApiResult<Response> {
240    invoke(options, "response", BodyMode::Required).await
241}
242async fn invoke<R>(options: &Options, action: &str, body_mode: BodyMode) -> ApiResult<R>
243where
244    R: for<'de> Deserialize<'de>,
245{
246    let template = "openai::api";
247    match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
248        | Ok(endpoint) => match (body_mode, &options.body) {
249            | (BodyMode::Required, Some(value)) if !value.is_empty() => {
250                let params = Params::new()
251                    .with_auth(options.token(), None)
252                    .with(param!(Body, value.as_str()))
253                    .with_custom(options.params())
254                    .build();
255                let response = endpoint.invoke(action, Some(params)).await;
256                endpoint.handle_or::<R, Fallback<ErrorResponse>>(response)
257            }
258            | (BodyMode::Required, _) => Err(eyre!(format!("OpenAI {action} request body is required"))),
259            | (BodyMode::Empty, _) => {
260                let params = Params::from_config(options).with_custom(options.params()).build();
261                let response = endpoint.invoke(action, Some(params)).await;
262                endpoint.handle_or::<R, Fallback<ErrorResponse>>(response)
263            }
264        },
265        | Err(why) => Err(why),
266    }
267}