use async_trait::async_trait;
use serde::de::DeserializeOwned;
use std::time::Duration;
use tracing::{Instrument, debug, error, info, instrument, trace, warn};
use crate::backend::model_macro::define_model_enum;
use crate::backend::routing::{
GROQ_BASE_URL, KeyPolicy, LM_STUDIO_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
OPENROUTER_BASE_URL,
};
use crate::backend::{
ChatMessage, DEFAULT_REQUEST_TIMEOUT, GenerateResult, LLMClient, MaterializeAttemptError,
MaterializeFailure, MaterializeInternalOutput, MaterializeReport, MaterializeResult, ModelInfo,
OpenAICompatibleChatCompletionRequest, OpenAICompatibleChatCompletionResponse, ResponseFormat,
StrictSchemaProvider, ThinkingLevel, build_http_client, check_response_status_with_capture,
compile_strict_schema, convert_openai_compatible_chat_messages,
generate_with_retry_attempts_with_history, generate_with_retry_attempts_with_initial_messages,
generate_with_retry_with_history, generate_with_retry_with_initial_messages, handle_http_error,
materialize_request_error, materialize_with_media_and_attempts_with_retry,
materialize_with_media_with_retry, parse_json_response, parse_validate_and_create_output,
request_messages,
};
#[cfg(feature = "streaming")]
use crate::backend::{OpenAICompatibleChatMessage, OpenAICompatibleMessageContent};
use crate::error::{ApiErrorKind, RStructorError, Result};
use crate::model::Instructor;
define_model_enum! {
pub enum Model {
Gpt56 => "gpt-5.6",
Gpt56Sol => "gpt-5.6-sol",
Gpt56Terra => "gpt-5.6-terra",
Gpt56Luna => "gpt-5.6-luna",
Gpt55Pro => "gpt-5.5-pro",
Gpt55 => "gpt-5.5",
Gpt54Pro => "gpt-5.4-pro",
Gpt54 => "gpt-5.4",
Gpt54Mini => "gpt-5.4-mini",
Gpt54Nano => "gpt-5.4-nano",
Gpt53ChatLatest => "gpt-5.3-chat-latest",
Gpt53Codex => "gpt-5.3-codex",
Gpt52Pro => "gpt-5.2-pro",
Gpt52 => "gpt-5.2",
Gpt52ChatLatest => "gpt-5.2-chat-latest",
Gpt52Codex => "gpt-5.2-codex",
Gpt51 => "gpt-5.1",
Gpt5ChatLatest => "gpt-5-chat-latest",
Gpt5Pro => "gpt-5-pro",
Gpt5 => "gpt-5",
Gpt5Nano => "gpt-5-nano",
Gpt5Mini => "gpt-5-mini",
Gpt41 => "gpt-4.1",
Gpt41Mini => "gpt-4.1-mini",
Gpt41Nano => "gpt-4.1-nano",
Gpt4O => "gpt-4o",
Gpt4OMini => "gpt-4o-mini",
Gpt4Turbo => "gpt-4-turbo",
Gpt4 => "gpt-4",
Gpt35Turbo => "gpt-3.5-turbo",
}
}
#[derive(Debug, Clone)]
pub struct OpenAIConfig {
pub api_key: String,
pub model: Model,
pub temperature: f32,
pub max_tokens: Option<u32>,
pub timeout: Option<Duration>,
pub max_retries: Option<usize>,
pub base_url: Option<String>,
pub thinking_level: Option<ThinkingLevel>,
pub response_body_capture: Option<crate::ResponseBodyCapture>,
}
#[derive(Clone)]
pub struct OpenAIClient {
config: OpenAIConfig,
client: reqwest::Client,
}
impl OpenAIClient {
fn with_api_key(api_key: String) -> Self {
let config = OpenAIConfig {
api_key,
model: Model::Gpt56Sol, temperature: 0.0,
max_tokens: None,
timeout: Some(DEFAULT_REQUEST_TIMEOUT), max_retries: Some(3), base_url: None, thinking_level: Some(ThinkingLevel::Medium), response_body_capture: None,
};
Self {
config,
client: build_http_client(DEFAULT_REQUEST_TIMEOUT),
}
}
fn api_key_from_env(provider: &'static str, variable: &'static str) -> Result<String> {
std::env::var(variable)
.ok()
.filter(|api_key| !api_key.is_empty())
.ok_or_else(|| RStructorError::api_error(provider, ApiErrorKind::AuthenticationFailed))
}
pub(crate) fn openai_compatible(base_url: &'static str, key_policy: KeyPolicy) -> Result<Self> {
let api_key = match key_policy {
KeyPolicy::ProviderDefault => Self::api_key_from_env("OpenAI", "OPENAI_API_KEY")?,
KeyPolicy::Keyless => String::new(),
KeyPolicy::Environment { provider, variable } => {
Self::api_key_from_env(provider, variable)?
}
};
Ok(Self::with_api_key(api_key).base_url(base_url))
}
#[instrument(name = "openai_client_new", skip(api_key), fields(model = ?Model::Gpt56Sol))]
pub fn new(api_key: impl Into<String>) -> Result<Self> {
let api_key = api_key.into();
if api_key.is_empty() {
return Err(RStructorError::api_error(
"OpenAI",
ApiErrorKind::AuthenticationFailed,
));
}
info!("Creating new OpenAI client");
trace!("API key length: {}", api_key.len());
debug!("OpenAI client created with default configuration");
Ok(Self::with_api_key(api_key))
}
#[instrument(name = "openai_client_from_env", fields(model = ?Model::Gpt56Sol))]
pub fn from_env() -> Result<Self> {
let api_key = std::env::var("OPENAI_API_KEY")
.map_err(|_| RStructorError::api_error("OpenAI", ApiErrorKind::AuthenticationFailed))?;
info!("Creating new OpenAI client from environment variable");
trace!("API key length: {}", api_key.len());
debug!("OpenAI client created with default configuration");
Ok(Self::with_api_key(api_key))
}
pub fn ollama() -> Result<Self> {
Self::openai_compatible(OLLAMA_BASE_URL, KeyPolicy::Keyless)
}
pub fn lm_studio() -> Result<Self> {
Self::openai_compatible(LM_STUDIO_BASE_URL, KeyPolicy::Keyless)
}
pub fn openrouter() -> Result<Self> {
Self::openai_compatible(
OPENROUTER_BASE_URL,
KeyPolicy::Environment {
provider: "OpenRouter",
variable: "OPENROUTER_API_KEY",
},
)
}
pub fn groq() -> Result<Self> {
Self::openai_compatible(
GROQ_BASE_URL,
KeyPolicy::Environment {
provider: "Groq",
variable: "GROQ_API_KEY",
},
)
}
pub fn moonshot() -> Result<Self> {
Self::openai_compatible(
MOONSHOT_BASE_URL,
KeyPolicy::Environment {
provider: "Moonshot",
variable: "MOONSHOT_API_KEY",
},
)
}
}
crate::impl_client_builder_methods! {
client_type: OpenAIClient,
config_type: OpenAIConfig,
model_type: Model,
provider_name: "OpenAI"
}
impl OpenAIClient {
#[tracing::instrument(skip(self, base_url))]
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
let base_url_str = base_url.into();
tracing::debug!(
previous_base_url = ?self.config.base_url,
new_base_url = %base_url_str,
"Setting custom base URL"
);
self.config.base_url = Some(base_url_str);
self
}
#[tracing::instrument(skip(self))]
pub fn thinking_level(mut self, level: ThinkingLevel) -> Self {
tracing::debug!(
previous_level = ?self.config.thinking_level,
new_level = ?level,
"Setting thinking level"
);
self.config.thinking_level = Some(level);
self
}
async fn materialize_internal<T>(
&self,
messages: &[ChatMessage],
) -> std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
info!("Generating structured response with OpenAI (native structured outputs)");
let schema = T::try_schema().map_err(MaterializeAttemptError::preflight)?;
let schema_name = T::schema_name().unwrap_or_else(|| "output".to_string());
trace!(schema_name = schema_name, "Retrieved JSON schema for type");
let schema_json =
compile_strict_schema(&schema, StrictSchemaProvider::OpenAI, "structured output")
.map_err(MaterializeAttemptError::preflight)?;
let response_format = ResponseFormat::json_schema(
schema_name.clone(),
schema_json,
Some("Output in the specified format. Include ALL required fields and follow the schema exactly.".to_string()),
);
let is_gpt5 = self.config.model.as_str().starts_with("gpt-5");
let reasoning_effort = if is_gpt5 {
self.config
.thinking_level
.and_then(|level| level.openai_reasoning_effort().map(|s| s.to_string()))
} else {
None
};
let effective_temp = if reasoning_effort.is_some() {
1.0
} else {
self.config.temperature
};
let api_messages = convert_openai_compatible_chat_messages(messages, "OpenAI")
.map_err(MaterializeAttemptError::preflight)?;
debug!(
"Building OpenAI API request with structured outputs (history_len={})",
api_messages.len()
);
let request = OpenAICompatibleChatCompletionRequest {
model: self.config.model.as_str().to_string(),
messages: api_messages,
response_format: Some(response_format),
temperature: effective_temp,
max_tokens: self.config.max_tokens,
reasoning_effort,
};
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.openai.com/v1");
let url = format!("{}/chat/completions", base_url);
let gen_ai_span = crate::telemetry::inference_span(
"openai",
"chat",
self.config.model.as_str(),
&url,
Some(f64::from(effective_temp)),
self.config.max_tokens.map(u64::from),
);
debug!(url = %url, "Sending request to OpenAI API");
let response = optional_bearer_auth(self.client.post(&url), &self.config.api_key)
.header("Content-Type", "application/json")
.json(&request)
.send()
.instrument(gen_ai_span.clone())
.await
.map_err(|error| {
crate::telemetry::record_http_client_error(&gen_ai_span, &error);
materialize_request_error(error, "OpenAI")
})?;
let response = check_response_status_with_capture(
response,
"OpenAI",
self.config.response_body_capture.as_ref(),
)
.await
.map_err(|error| {
crate::telemetry::record_error(&gen_ai_span, &error);
MaterializeAttemptError::transport(error)
})?;
debug!("Successfully received response from OpenAI");
let (completion, response_metadata): (OpenAICompatibleChatCompletionResponse, _) =
parse_json_response(
response,
"OpenAI",
self.config.response_body_capture.as_ref(),
)
.await
.map_err(|error| {
crate::telemetry::record_error(&gen_ai_span, &error);
MaterializeAttemptError::transport(error)
})?;
let model_name = completion
.model
.clone()
.unwrap_or_else(|| self.config.model.as_str().to_string());
let usage = completion
.usage
.as_ref()
.map(|u| u.token_usage(model_name.clone()));
if completion.choices.is_empty() {
error!("OpenAI returned empty choices array");
let error = RStructorError::api_error_with_response(
"OpenAI",
ApiErrorKind::UnexpectedResponse {
details: "No completion choices returned".to_string(),
},
response_metadata.clone(),
);
crate::telemetry::record_error(&gen_ai_span, &error);
return Err(MaterializeAttemptError::transport_with_usage(error, usage)
.with_response(response_metadata));
}
let message = &completion.choices[0].message;
trace!(finish_reason = %completion.choices[0].finish_reason, "Completion finish reason");
if let Some(content) = &message.content {
crate::telemetry::record_success(
&gen_ai_span,
completion.id.as_deref(),
Some(&model_name),
usage.as_ref(),
);
let raw_response = content.clone();
debug!(
content_len = raw_response.len(),
"Structured output received from OpenAI"
);
parse_validate_and_create_output(raw_response, usage)
.map(|output| output.with_response(response_metadata.clone()))
.map_err(|error| error.with_response(response_metadata))
} else {
error!("No content in OpenAI response");
let error = RStructorError::api_error_with_response(
"OpenAI",
ApiErrorKind::UnexpectedResponse {
details: "No content in response".to_string(),
},
response_metadata.clone(),
);
crate::telemetry::record_error(&gen_ai_span, &error);
Err(MaterializeAttemptError::transport_with_usage(error, usage)
.with_response(response_metadata))
}
}
async fn generate_internal(&self, messages: &[ChatMessage]) -> Result<GenerateResult> {
info!("Generating raw text response with OpenAI");
let is_gpt5 = self.config.model.as_str().starts_with("gpt-5");
let reasoning_effort = if is_gpt5 {
self.config
.thinking_level
.and_then(|level| level.openai_reasoning_effort().map(|s| s.to_string()))
} else {
None
};
let effective_temp = if reasoning_effort.is_some() {
1.0
} else {
self.config.temperature
};
debug!("Building OpenAI API request for text generation");
let request = OpenAICompatibleChatCompletionRequest {
model: self.config.model.as_str().to_string(),
messages: convert_openai_compatible_chat_messages(messages, "OpenAI")?,
response_format: None,
temperature: effective_temp,
max_tokens: self.config.max_tokens,
reasoning_effort,
};
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.openai.com/v1");
let url = format!("{}/chat/completions", base_url);
let gen_ai_span = crate::telemetry::inference_span(
"openai",
"chat",
self.config.model.as_str(),
&url,
Some(f64::from(effective_temp)),
self.config.max_tokens.map(u64::from),
);
debug!(url = %url, "Sending request to OpenAI API");
let response = optional_bearer_auth(self.client.post(&url), &self.config.api_key)
.header("Content-Type", "application/json")
.json(&request)
.send()
.instrument(gen_ai_span.clone())
.await
.map_err(|error| {
crate::telemetry::record_http_client_error(&gen_ai_span, &error);
handle_http_error(error, "OpenAI")
})?;
let response = check_response_status_with_capture(
response,
"OpenAI",
self.config.response_body_capture.as_ref(),
)
.await
.inspect_err(|error| crate::telemetry::record_error(&gen_ai_span, error))?;
debug!("Successfully received response from OpenAI");
let (completion, response_metadata): (OpenAICompatibleChatCompletionResponse, _) =
parse_json_response(
response,
"OpenAI",
self.config.response_body_capture.as_ref(),
)
.await
.inspect_err(|error| crate::telemetry::record_error(&gen_ai_span, error))?;
if completion.choices.is_empty() {
error!("OpenAI returned empty choices array");
let error = RStructorError::api_error_with_response(
"OpenAI",
ApiErrorKind::UnexpectedResponse {
details: "No completion choices returned".to_string(),
},
response_metadata,
);
crate::telemetry::record_error(&gen_ai_span, &error);
return Err(error);
}
let model_name = completion
.model
.clone()
.unwrap_or_else(|| self.config.model.as_str().to_string());
let usage = completion
.usage
.as_ref()
.map(|u| u.token_usage(model_name.clone()));
let message = &completion.choices[0].message;
trace!(finish_reason = %completion.choices[0].finish_reason, "Completion finish reason");
if let Some(content) = &message.content {
crate::telemetry::record_success(
&gen_ai_span,
completion.id.as_deref(),
Some(&model_name),
usage.as_ref(),
);
debug!(
content_len = content.len(),
"Successfully extracted content from response"
);
Ok(GenerateResult::new(content.clone(), usage))
} else {
error!("No content in OpenAI response");
let error = RStructorError::api_error_with_response(
"OpenAI",
ApiErrorKind::UnexpectedResponse {
details: "No content in response".to_string(),
},
response_metadata,
);
crate::telemetry::record_error(&gen_ai_span, &error);
Err(error)
}
}
}
#[cfg(feature = "streaming")]
impl OpenAIClient {
fn stream_body(
&self,
system: Option<&str>,
prompt: &str,
response_format: Option<ResponseFormat>,
) -> serde_json::Value {
let is_gpt5 = self.config.model.as_str().starts_with("gpt-5");
let reasoning_effort = if is_gpt5 {
self.config
.thinking_level
.and_then(|level| level.openai_reasoning_effort().map(|s| s.to_string()))
} else {
None
};
let effective_temp = if reasoning_effort.is_some() {
1.0
} else {
self.config.temperature
};
let mut messages = Vec::with_capacity(usize::from(system.is_some()) + 1);
if let Some(system) = system {
messages.push(OpenAICompatibleChatMessage {
role: "system".to_string(),
content: OpenAICompatibleMessageContent::Text(system.to_string()),
});
}
messages.push(OpenAICompatibleChatMessage {
role: "user".to_string(),
content: OpenAICompatibleMessageContent::Text(prompt.to_string()),
});
let request = OpenAICompatibleChatCompletionRequest {
model: self.config.model.as_str().to_string(),
messages,
response_format,
temperature: effective_temp,
max_tokens: self.config.max_tokens,
reasoning_effort,
};
let mut body = serde_json::to_value(&request).unwrap_or_else(|_| serde_json::json!({}));
body["stream"] = serde_json::Value::Bool(true);
body
}
fn send_stream(
&self,
body: serde_json::Value,
) -> impl std::future::Future<Output = Result<reqwest::Response>> + Send + 'static {
let client = self.client.clone();
let api_key = self.config.api_key.clone();
let response_body_capture = self.config.response_body_capture.clone();
let base_url = self
.config
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
async move {
let url = format!("{}/chat/completions", base_url);
let resp = optional_bearer_auth(client.post(&url), &api_key)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| handle_http_error(e, "OpenAI"))?;
check_response_status_with_capture(resp, "OpenAI", response_body_capture.as_ref()).await
}
}
}
#[cfg(feature = "tools")]
fn tool_reasoning_effort(model: &str) -> Option<String> {
model.starts_with("gpt-5.6").then(|| "none".to_string())
}
#[cfg(feature = "tools")]
#[async_trait]
impl crate::backend::tools::ToolRunner for OpenAIClient {
async fn run_tool_loop(
&self,
system: Option<&str>,
prompt: &str,
media: &[super::MediaFile],
toolbox: &crate::backend::tools::Toolbox,
max_iterations: usize,
) -> Result<String> {
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.openai.com/v1");
let url = format!("{}/chat/completions", base_url);
let is_gpt5 = self.config.model.as_str().starts_with("gpt-5");
let effective_temp = if is_gpt5 {
1.0
} else {
self.config.temperature
};
let reasoning_effort = tool_reasoning_effort(self.config.model.as_str());
crate::backend::tools::run_openai_compatible_tools(
&self.client,
&url,
&self.config.api_key,
"OpenAI",
self.config.model.as_str(),
effective_temp,
self.config.max_tokens,
reasoning_effort,
system,
prompt,
media,
toolbox,
max_iterations,
)
.await
}
}
#[async_trait]
impl LLMClient for OpenAIClient {
fn from_env() -> Result<Self> {
Self::from_env()
}
#[instrument(
name = "openai_materialize",
skip(self, prompt),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn materialize<T>(&self, prompt: &str) -> Result<T>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
let output = generate_with_retry_with_history(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
self.config.max_retries,
)
.await?;
Ok(output.data)
}
#[instrument(
name = "openai_materialize_with_media",
skip(self, prompt, media),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len(),
media_len = media.len()
)
)]
async fn materialize_with_media<T>(&self, prompt: &str, media: &[super::MediaFile]) -> Result<T>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
materialize_with_media_with_retry(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
media,
self.config.max_retries,
)
.await
}
#[instrument(
name = "openai_materialize_with_metadata",
skip(self, prompt),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn materialize_with_metadata<T>(&self, prompt: &str) -> Result<MaterializeResult<T>>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
let output = generate_with_retry_with_history(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
self.config.max_retries,
)
.await?;
Ok(MaterializeResult::new(output.data, output.usage))
}
#[instrument(
name = "openai_materialize_with_attempts",
skip(self, prompt),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn materialize_with_attempts<T>(
&self,
prompt: &str,
) -> std::result::Result<MaterializeReport<T>, MaterializeFailure>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
generate_with_retry_attempts_with_history(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
self.config.max_retries,
)
.await
}
#[instrument(
name = "openai_materialize_with_media_and_attempts",
skip(self, prompt, media),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len(),
media_len = media.len()
)
)]
async fn materialize_with_media_and_attempts<T>(
&self,
prompt: &str,
media: &[super::MediaFile],
) -> std::result::Result<MaterializeReport<T>, MaterializeFailure>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
materialize_with_media_and_attempts_with_retry(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
media,
self.config.max_retries,
)
.await
}
async fn materialize_request<T>(
&self,
system: Option<&str>,
prompt: &str,
media: &[super::MediaFile],
) -> Result<T>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
let output = generate_with_retry_with_initial_messages(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
request_messages(system, prompt, media),
self.config.max_retries,
)
.await?;
Ok(output.data)
}
async fn materialize_request_with_attempts<T>(
&self,
system: Option<&str>,
prompt: &str,
media: &[super::MediaFile],
) -> std::result::Result<MaterializeReport<T>, MaterializeFailure>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
generate_with_retry_attempts_with_initial_messages(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
request_messages(system, prompt, media),
self.config.max_retries,
)
.await
}
#[instrument(
name = "openai_generate",
skip(self, prompt),
fields(
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn generate(&self, prompt: &str) -> Result<String> {
let result = self.generate_with_metadata(prompt).await?;
Ok(result.text)
}
#[instrument(
name = "openai_generate_with_media",
skip(self, prompt, media),
fields(
model = %self.config.model.as_str(),
prompt_len = prompt.len(),
media_len = media.len()
)
)]
async fn generate_with_media(
&self,
prompt: &str,
media: &[super::MediaFile],
) -> Result<String> {
let result = self
.generate_internal(&[ChatMessage::user_with_media(prompt, media.to_vec())])
.await?;
Ok(result.text)
}
#[instrument(
name = "openai_generate_with_metadata",
skip(self, prompt),
fields(
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn generate_with_metadata(&self, prompt: &str) -> Result<GenerateResult> {
self.generate_internal(&[ChatMessage::user(prompt)]).await
}
async fn generate_request(
&self,
system: Option<&str>,
prompt: &str,
media: &[super::MediaFile],
) -> Result<String> {
let result = self
.generate_internal(&request_messages(system, prompt, media))
.await?;
Ok(result.text)
}
#[cfg(feature = "streaming")]
fn generate_stream<'a>(&'a self, prompt: &'a str) -> crate::backend::streaming::TextStream<'a>
where
Self: Sync,
{
self.generate_stream_request(None, prompt.to_string())
}
#[cfg(feature = "streaming")]
fn generate_stream_request<'a>(
&'a self,
system: Option<String>,
prompt: String,
) -> crate::backend::streaming::TextStream<'a>
where
Self: Sync,
{
let body = self.stream_body(system.as_deref(), &prompt, None);
crate::backend::streaming::sse_text_stream(
self.send_stream(body),
crate::backend::streaming::openai_stream_event,
crate::backend::streaming::TerminalMarker::DoneSentinel,
)
}
#[cfg(feature = "streaming")]
fn materialize_stream<'a, T>(
&'a self,
prompt: &'a str,
) -> crate::backend::streaming::ObjectStream<'a, T>
where
T: Instructor + DeserializeOwned + Send + 'static,
Self: Sync,
{
self.materialize_stream_request(None, prompt.to_string())
}
#[cfg(feature = "streaming")]
fn materialize_stream_request<'a, T>(
&'a self,
system: Option<String>,
prompt: String,
) -> crate::backend::streaming::ObjectStream<'a, T>
where
T: Instructor + DeserializeOwned + Send + 'static,
Self: Sync,
{
let schema = match T::try_schema() {
Ok(schema) => schema,
Err(error) => return crate::backend::streaming::error_stream(error),
};
let schema_name = T::schema_name().unwrap_or_else(|| "output".to_string());
let schema_json = match compile_strict_schema(
&schema,
StrictSchemaProvider::OpenAI,
"streamed structured output",
) {
Ok(schema) => schema,
Err(error) => return crate::backend::streaming::error_stream(error),
};
let response_format = ResponseFormat::json_schema(
schema_name,
schema_json,
Some("Output in the specified format. Include ALL required fields and follow the schema exactly.".to_string()),
);
let body = self.stream_body(system.as_deref(), &prompt, Some(response_format));
crate::backend::streaming::object_stream(
self.send_stream(body),
crate::backend::streaming::openai_stream_event,
crate::backend::streaming::TerminalMarker::DoneSentinel,
)
}
#[cfg(feature = "streaming")]
fn materialize_iter<'a, T>(
&'a self,
prompt: &'a str,
) -> crate::backend::streaming::ItemStream<'a, T>
where
T: Instructor + DeserializeOwned + Send + 'static,
Self: Sync,
{
self.materialize_iter_request(None, prompt.to_string())
}
#[cfg(feature = "streaming")]
fn materialize_iter_request<'a, T>(
&'a self,
system: Option<String>,
prompt: String,
) -> crate::backend::streaming::ItemStream<'a, T>
where
T: Instructor + DeserializeOwned + Send + 'static,
Self: Sync,
{
let schema = match T::try_schema() {
Ok(schema) => schema,
Err(error) => return crate::backend::streaming::error_stream(error),
};
let item_schema =
match compile_strict_schema(&schema, StrictSchemaProvider::OpenAI, "streamed item") {
Ok(schema) => schema,
Err(error) => return crate::backend::streaming::error_stream(error),
};
let wrapper = crate::backend::streaming::array_wrapper_schema(item_schema, true);
let response_format = ResponseFormat::json_schema(
"items".to_string(),
wrapper,
Some("Return a JSON object with an `items` array; each element must follow the item schema exactly.".to_string()),
);
let body = self.stream_body(system.as_deref(), &prompt, Some(response_format));
crate::backend::streaming::iter_stream(
self.send_stream(body),
crate::backend::streaming::openai_stream_event,
crate::backend::streaming::TerminalMarker::DoneSentinel,
crate::backend::streaming::finalize_item::<T>,
)
}
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.openai.com/v1");
let url = format!("{}/models", base_url);
debug!(url = %url, "Fetching available models from OpenAI");
let response = optional_bearer_auth(self.client.get(&url), &self.config.api_key)
.header("Content-Type", "application/json")
.send()
.await
.map_err(|e| handle_http_error(e, "OpenAI"))?;
let response = check_response_status_with_capture(
response,
"OpenAI",
self.config.response_body_capture.as_ref(),
)
.await?;
let json: serde_json::Value = response.json().await.map_err(|e| {
error!(error = %e, "Failed to parse models response from OpenAI");
e
})?;
let models = json
.get("data")
.and_then(|data| data.as_array())
.map(|models_array| {
models_array
.iter()
.filter_map(|model| {
let id = model.get("id").and_then(|id| id.as_str())?;
if id.starts_with("gpt-") {
Some(ModelInfo {
id: id.to_string(),
name: None,
description: None,
})
} else {
None
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
debug!(count = models.len(), "Fetched OpenAI models");
Ok(models)
}
}
fn optional_bearer_auth(
request: reqwest::RequestBuilder,
api_key: &str,
) -> reqwest::RequestBuilder {
if api_key.is_empty() {
request
} else {
request.bearer_auth(api_key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::DEFAULT_REQUEST_TIMEOUT;
#[test]
fn default_config_has_default_timeout() {
let client = OpenAIClient::new("test-key").unwrap();
assert_eq!(client.config.timeout, Some(DEFAULT_REQUEST_TIMEOUT));
}
#[test]
fn default_config_uses_latest_model() {
let client = OpenAIClient::new("test-key").unwrap();
assert_eq!(client.config.model, Model::Gpt56Sol);
}
#[test]
fn explicit_timeout_overrides_default() {
let client = OpenAIClient::new("test-key")
.unwrap()
.timeout(Duration::from_secs(10));
assert_eq!(client.config.timeout, Some(Duration::from_secs(10)));
}
#[test]
fn local_constructors_are_keyless_and_use_official_base_urls() {
let ollama = OpenAIClient::ollama().unwrap();
assert_eq!(ollama.config.base_url.as_deref(), Some(OLLAMA_BASE_URL));
assert!(ollama.config.api_key.is_empty());
let lm_studio = OpenAIClient::lm_studio().unwrap();
assert_eq!(
lm_studio.config.base_url.as_deref(),
Some(LM_STUDIO_BASE_URL)
);
assert!(lm_studio.config.api_key.is_empty());
}
#[cfg(feature = "tools")]
#[test]
fn gpt56_tool_calls_explicitly_disable_reasoning() {
for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
assert_eq!(tool_reasoning_effort(model), Some("none".to_string()));
}
assert_eq!(tool_reasoning_effort("gpt-5.5"), None);
assert_eq!(tool_reasoning_effort("gpt-4.1-mini"), None);
}
}