use std::{num::NonZeroU32, sync::Arc};
use ::tower::Layer;
mod config;
mod platform;
use crate::{
DefaultResponsesService, OpenAiAuth, OpenAiAuthError, OpenAiAuthMode, ReasoningMode,
ResponsesHistory, ResponsesRetryPolicy, ResponsesTransport, Thinking, session::SessionBuilder,
};
#[doc(hidden)]
pub use config::ModelConfig;
pub use config::ModelConfig as ResponsesServiceConfig;
#[derive(Clone)]
pub struct OpenAi<F = StandardServiceFactory> {
config: ModelConfig,
factory: F,
}
impl OpenAi<StandardServiceFactory> {
pub fn new(auth: impl Into<OpenAiAuth>) -> Result<Self, OpenAiError> {
Self::builder(auth).build()
}
#[must_use]
pub fn builder(auth: impl Into<OpenAiAuth>) -> OpenAiBuilder<StandardServiceFactory> {
let auth = auth.into();
let mode = auth.mode();
let mut config = ModelConfig {
auth,
..ModelConfig::default()
};
apply_mode_defaults(&mut config, mode);
OpenAiBuilder {
config,
factory: StandardServiceFactory::default(),
}
}
}
impl<F> OpenAi<F>
where
F: ResponsesServiceFactory,
{
#[must_use]
pub fn instructions(&self, instructions: impl Into<Arc<str>>) -> SessionBuilder<F> {
SessionBuilder::new(self.clone(), instructions.into())
}
pub(crate) const fn config(&self) -> &ModelConfig {
&self.config
}
pub(crate) fn make_service(&self) -> F::Service {
self.factory.make(Arc::new(self.config.clone()))
}
pub(crate) fn into_parts(self) -> (ModelConfig, F) {
(self.config, self.factory)
}
}
#[derive(Clone)]
pub struct OpenAiBuilder<F = StandardServiceFactory> {
config: ModelConfig,
factory: F,
}
impl<F> OpenAiBuilder<F> {
#[must_use]
pub const fn transport(mut self, transport: ResponsesTransport) -> Self {
self.config.responses_transport = transport;
if matches!(transport, ResponsesTransport::Https) && !self.config.store_responses {
self.config.responses_history = ResponsesHistory::FullReplay;
}
self
}
#[must_use]
pub const fn history(mut self, history: ResponsesHistory) -> Self {
self.config.responses_history = history;
self
}
#[must_use]
pub const fn store(mut self, store: bool) -> Self {
self.config.store_responses = store;
if matches!(self.config.responses_transport, ResponsesTransport::Https) {
self.config.responses_history = if store {
ResponsesHistory::Incremental
} else {
ResponsesHistory::FullReplay
};
}
self
}
#[must_use]
pub const fn thinking(mut self, thinking: Thinking) -> Self {
self.config.thinking = thinking;
self
}
#[must_use]
pub const fn reasoning_mode(mut self, reasoning_mode: ReasoningMode) -> Self {
self.config.reasoning_mode = reasoning_mode;
self
}
#[must_use]
pub const fn fast_mode(mut self, enabled: bool) -> Self {
self.config.fast_mode = enabled;
self
}
#[must_use]
pub fn websocket_url(mut self, url: impl Into<String>) -> Self {
self.config.websocket_url = url.into();
self
}
#[must_use]
pub fn api_base_url(mut self, url: impl Into<String>) -> Self {
self.config.api_base_url = url.into();
self
}
#[cfg(any(target_family = "wasm", docsrs))]
#[cfg_attr(docsrs, doc(cfg(target_family = "wasm")))]
#[must_use]
pub fn host_transport(mut self, transport: impl crate::transport::host::HostTransport) -> Self {
self.config.host_transport = Some(Arc::new(transport));
self
}
#[must_use]
pub fn layer<L>(self, layer: L) -> OpenAiBuilder<LayeredServiceFactory<F, L>> {
OpenAiBuilder {
config: self.config,
factory: LayeredServiceFactory {
inner: self.factory,
layer,
},
}
}
#[must_use]
pub fn service<M>(self, make: M) -> OpenAiBuilder<CallerServiceFactory<M>> {
OpenAiBuilder {
config: self.config,
factory: CallerServiceFactory {
make: Arc::new(make),
},
}
}
}
impl OpenAiBuilder<StandardServiceFactory> {
#[must_use]
pub const fn max_attempts(mut self, max_attempts: NonZeroU32) -> Self {
self.factory.max_attempts = max_attempts;
self
}
#[cfg(not(target_family = "wasm"))]
#[cfg_attr(docsrs, doc(cfg(not(target_family = "wasm"))))]
#[must_use]
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.factory.platform.set_http_client(client);
self
}
}
impl<F> OpenAiBuilder<F>
where
F: ResponsesServiceFactory,
{
pub fn build(self) -> Result<OpenAi<F>, OpenAiError> {
validate(&self.config)?;
self.factory.validate_config(&self.config)?;
Ok(OpenAi {
config: self.config,
factory: self.factory,
})
}
}
#[derive(Clone)]
pub struct StandardServiceFactory {
max_attempts: NonZeroU32,
platform: platform::FactoryPlatform,
}
impl Default for StandardServiceFactory {
fn default() -> Self {
Self {
max_attempts: ResponsesRetryPolicy::DEFAULT_MAX_ATTEMPTS,
platform: platform::FactoryPlatform::new(),
}
}
}
pub struct CallerServiceFactory<M> {
make: Arc<M>,
}
impl<M> Clone for CallerServiceFactory<M> {
fn clone(&self) -> Self {
Self {
make: Arc::clone(&self.make),
}
}
}
#[derive(Clone)]
pub struct LayeredServiceFactory<F, L> {
inner: F,
layer: L,
}
pub trait ResponsesServiceFactory: Clone {
type Service;
fn validate_config(&self, _config: &ResponsesServiceConfig) -> Result<(), OpenAiError> {
Ok(())
}
fn make(&self, config: Arc<ResponsesServiceConfig>) -> Self::Service;
}
impl ResponsesServiceFactory for StandardServiceFactory {
type Service = DefaultResponsesService;
fn validate_config(&self, config: &ModelConfig) -> Result<(), OpenAiError> {
self.platform.validate_config(config)
}
fn make(&self, config: Arc<ModelConfig>) -> Self::Service {
self.platform.make(config, self.max_attempts)
}
}
impl<M, S> ResponsesServiceFactory for CallerServiceFactory<M>
where
M: Fn() -> S,
{
type Service = S;
fn make(&self, _config: Arc<ModelConfig>) -> Self::Service {
(self.make)()
}
}
impl<F, L> ResponsesServiceFactory for LayeredServiceFactory<F, L>
where
F: ResponsesServiceFactory,
L: Layer<F::Service> + Clone,
{
type Service = L::Service;
fn validate_config(&self, config: &ModelConfig) -> Result<(), OpenAiError> {
self.inner.validate_config(config)
}
fn make(&self, config: Arc<ModelConfig>) -> Self::Service {
self.layer.layer(self.inner.make(config))
}
}
#[derive(Debug, thiserror::Error)]
pub enum OpenAiError {
#[error(transparent)]
Authorization(#[from] OpenAiAuthError),
#[error("invalid OpenAI client configuration: {detail}")]
InvalidConfiguration {
detail: &'static str,
},
}
fn apply_mode_defaults(config: &mut ModelConfig, mode: OpenAiAuthMode) {
config.store_responses = false;
config.responses_history = ResponsesHistory::Incremental;
mode.default_websocket_url()
.clone_into(&mut config.websocket_url);
mode.default_api_base_url()
.clone_into(&mut config.api_base_url);
}
fn validate(config: &ModelConfig) -> Result<(), OpenAiError> {
config.auth.validate()?;
if config.websocket_url.trim().is_empty() {
return Err(OpenAiError::InvalidConfiguration {
detail: "the Responses WebSocket URL must not be empty",
});
}
if config.api_base_url.trim().is_empty() {
return Err(OpenAiError::InvalidConfiguration {
detail: "the OpenAI API base URL must not be empty",
});
}
if config.auth.mode() == OpenAiAuthMode::ChatGpt && config.store_responses {
return Err(OpenAiError::InvalidConfiguration {
detail: "ChatGPT subscription authentication does not support store: true",
});
}
if matches!(config.responses_transport, ResponsesTransport::Https)
&& !config.store_responses
&& matches!(config.responses_history, ResponsesHistory::Incremental)
{
return Err(OpenAiError::InvalidConfiguration {
detail: "HTTPS with store: false requires full client-history replay",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::{
convert::Infallible,
error::Error as _,
future::{Ready, pending},
time::Duration,
};
use ::tower::{Service, service_fn, timeout::TimeoutLayer};
use crate::{
ModelConfig, OpenAiAuthMode, ResponseError, ResponsesAttempt, ResponsesHistory,
ResponsesServiceResponse, ResponsesTransport,
};
use super::{OpenAi, apply_mode_defaults};
#[derive(Clone)]
struct NeverCalled;
impl Service<ResponsesAttempt> for NeverCalled {
type Response = ResponsesServiceResponse;
type Error = Infallible;
type Future = Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(
&mut self,
_context: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, _request: ResponsesAttempt) -> Self::Future {
panic!("test service should not be called")
}
}
#[test]
fn one_client_recipe_builds_independent_sessions() {
let client = OpenAi::builder("test-key")
.service(|| NeverCalled)
.build()
.unwrap();
let session = client.instructions("Answer only from supplied facts.");
let first = session.clone().build().unwrap();
let second = session.build().unwrap();
assert_ne!(first.id(), second.id());
}
#[test]
fn response_storage_is_opt_in_for_both_auth_modes() {
for mode in [OpenAiAuthMode::ApiKey, OpenAiAuthMode::ChatGpt] {
let mut config = ModelConfig {
store_responses: true,
..ModelConfig::default()
};
apply_mode_defaults(&mut config, mode);
assert!(!config.store_responses);
}
}
#[test]
fn api_key_can_opt_into_https_checkpoints() {
let client = OpenAi::builder("test-key")
.transport(ResponsesTransport::Https)
.store(true)
.build()
.unwrap();
assert!(client.config.store_responses);
assert_eq!(
client.config.responses_history,
ResponsesHistory::Incremental
);
}
#[tokio::test]
async fn tower_box_errors_remain_usable_through_the_managed_response_api() {
let client = OpenAi::builder("test-key")
.service(|| {
service_fn(|_request: ResponsesAttempt| {
pending::<Result<ResponsesServiceResponse, ResponseError>>()
})
})
.layer(TimeoutLayer::new(Duration::from_millis(1)))
.build()
.unwrap();
let mut session = client
.instructions("Return exactly one short answer.")
.build()
.unwrap();
let error = session
.turn()
.create("This request should reach the test deadline.")
.await
.unwrap_err();
assert!(error.source().is_some());
assert!(error.to_string().contains("request timed out"));
}
}