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}