use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureConfig {
pub resource_name: String,
pub api_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIConfig {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "OpenAIConfig::default_base_url")]
pub base_url: String,
#[serde(default)]
pub organization: Option<String>,
#[serde(default)]
pub project: Option<String>,
#[serde(default = "OpenAIConfig::default_model")]
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pricing_model: Option<String>,
#[serde(default)]
pub max_output_tokens: Option<i32>,
#[serde(default)]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(default = "OpenAIConfig::default_stream")]
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_format: Option<TextFormatConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_verbosity: Option<TextVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation: Option<ConversationConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryConfig>,
#[serde(default)]
pub store: Option<bool>,
#[serde(default)]
pub background: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub azure: Option<AzureConfig>,
}
impl OpenAIConfig {
fn default_base_url() -> String {
"https://api.openai.com/v1".to_string()
}
fn default_model() -> String {
"gpt-5.4".to_string()
}
fn default_stream() -> bool {
true
}
pub fn validate(&self) -> Result<()> {
if let Some(temperature) = self.temperature {
if !(0.0..=2.0).contains(&temperature) {
bail!(
"OpenAI temperature must be between 0.0 and 2.0, got {}",
temperature
);
}
}
if let Some(top_p) = self.top_p {
if !(0.0..=1.0).contains(&top_p) {
bail!("OpenAI top_p must be between 0.0 and 1.0, got {}", top_p);
}
}
if let Some(top_logprobs) = self.top_logprobs {
if !(0..=20).contains(&top_logprobs) {
bail!(
"OpenAI top_logprobs must be between 0 and 20, got {}",
top_logprobs
);
}
}
if self.azure.is_none() && matches!(self.service_tier, Some(ServiceTier::Scale)) {
bail!(
"OpenAI service_tier = \"scale\" is no longer supported; use auto, default, flex, or priority"
);
}
let normalized_model = normalize_openai_model(&self.model);
let requested_effort = self
.reasoning
.as_ref()
.and_then(|reasoning| reasoning.effort);
let effective_effort = self
.reasoning
.as_ref()
.map(|_| resolve_reasoning_effort_for_model(&normalized_model, requested_effort));
if let Some(reasoning) = &self.reasoning {
if matches!(effective_effort, Some(ReasoningEffort::None))
&& reasoning.summary.is_some()
{
bail!(
"OpenAI reasoning summaries are unavailable when reasoning.effort is \"none\""
);
}
if let Some(effort) = reasoning.effort {
if effort == ReasoningEffort::None
&& !model_supports_none_reasoning(&normalized_model)
{
bail!(
"Model {} does not support reasoning.effort = \"none\"",
normalized_model
);
}
if effort == ReasoningEffort::XHigh
&& !model_supports_xhigh_reasoning(&normalized_model)
{
bail!(
"Model {} does not support reasoning.effort = \"xhigh\"",
normalized_model
);
}
if model_requires_high_reasoning(&normalized_model)
&& effort != ReasoningEffort::High
{
bail!(
"Model {} only supports reasoning.effort = \"high\"",
normalized_model
);
}
}
}
if (self.temperature.is_some() || self.top_p.is_some() || self.top_logprobs.is_some())
&& !model_supports_sampling_parameters(&normalized_model, requested_effort)
{
bail!(
"Model {} only supports temperature, top_p, and logprobs when sampling is enabled. \
GPT-5.4 requires reasoning.effort = \"none\", while older GPT-5/o-series models reject these fields.",
normalized_model
);
}
Ok(())
}
}
pub fn normalize_openai_model(model: &str) -> String {
model
.strip_prefix("openai/")
.unwrap_or(model)
.trim()
.to_string()
}
fn is_gpt_54_pro_model(model: &str) -> bool {
let model = normalize_openai_model(model);
model == "gpt-5.4-pro" || model.starts_with("gpt-5.4-pro-")
}
fn is_gpt_54_model(model: &str) -> bool {
let model = normalize_openai_model(model);
model == "gpt-5.4" || model.starts_with("gpt-5.4-") && !is_gpt_54_pro_model(&model)
}
fn is_gpt_52_default_none_model(model: &str) -> bool {
let model = normalize_openai_model(model);
model == "gpt-5.2" || model.starts_with("gpt-5.2-20")
}
pub fn model_supports_none_reasoning(model: &str) -> bool {
is_gpt_54_model(model) || is_gpt_52_default_none_model(model)
}
fn model_requires_high_reasoning(model: &str) -> bool {
let model = normalize_openai_model(model);
model == "gpt-5-pro" || model.starts_with("gpt-5-pro-")
}
fn model_defaults_to_high_reasoning(model: &str) -> bool {
is_gpt_54_pro_model(model) || model_requires_high_reasoning(model)
}
pub fn model_supports_sampling_parameters(
model: &str,
requested_effort: Option<ReasoningEffort>,
) -> bool {
let normalized = normalize_openai_model(model);
if model_supports_none_reasoning(&normalized) {
return matches!(
requested_effort.unwrap_or(ReasoningEffort::None),
ReasoningEffort::None
);
}
if normalized.starts_with("gpt-5")
|| normalized.starts_with("o1")
|| normalized.starts_with("o3")
|| normalized.starts_with("o4")
{
return false;
}
true
}
impl Default for OpenAIConfig {
fn default() -> Self {
Self {
api_key: None,
base_url: Self::default_base_url(),
organization: None,
project: None,
model: Self::default_model(),
pricing_model: None,
max_output_tokens: Some(4096),
parallel_tool_calls: Some(false),
temperature: None,
top_p: None,
stream: Self::default_stream(),
reasoning: None,
text_format: None,
text_verbosity: None,
service_tier: None,
conversation: None,
retry: Some(RetryConfig::default()),
store: None,
background: None,
metadata: None,
prompt_cache_key: None,
safety_identifier: None,
top_logprobs: None,
azure: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<ReasoningEffort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<ReasoningSummary>,
}
impl Default for ReasoningConfig {
fn default() -> Self {
Self {
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
}
}
}
impl ReasoningConfig {
pub fn auto() -> Self {
Self {
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
}
}
pub fn high_effort() -> Self {
Self {
effort: Some(ReasoningEffort::High),
summary: Some(ReasoningSummary::Detailed),
}
}
pub fn xhigh_effort() -> Self {
Self {
effort: Some(ReasoningEffort::XHigh),
summary: Some(ReasoningSummary::Detailed),
}
}
pub fn no_reasoning() -> Self {
Self {
effort: Some(ReasoningEffort::None),
summary: None,
}
}
pub fn low_latency() -> Self {
Self {
effort: Some(ReasoningEffort::Low),
summary: Some(ReasoningSummary::Concise),
}
}
pub fn minimal() -> Self {
Self {
effort: Some(ReasoningEffort::Minimal),
summary: Some(ReasoningSummary::Concise),
}
}
pub fn custom(effort: ReasoningEffort, summary: ReasoningSummary) -> Self {
Self {
effort: Some(effort),
summary: Some(summary),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
None,
Minimal,
Low,
#[default]
Medium,
High,
XHigh,
}
pub fn default_reasoning_effort_for_model(model: &str) -> ReasoningEffort {
if model_supports_none_reasoning(model) {
ReasoningEffort::None
} else if model_defaults_to_high_reasoning(model) {
ReasoningEffort::High
} else if model_supports_xhigh_reasoning(model) {
ReasoningEffort::XHigh
} else {
ReasoningEffort::High
}
}
pub fn model_supports_xhigh_reasoning(model: &str) -> bool {
let model = normalize_openai_model(model);
matches!(
model.as_str(),
"gpt-5.4" | "gpt-5.1-codex-max" | "gpt-5.2-codex" | "gpt-5.3-codex"
) || model.starts_with("gpt-5.4-")
}
pub fn resolve_reasoning_effort_for_model(
model: &str,
requested_effort: Option<ReasoningEffort>,
) -> ReasoningEffort {
let normalized = normalize_openai_model(model);
let selected =
requested_effort.unwrap_or_else(|| default_reasoning_effort_for_model(&normalized));
if selected == ReasoningEffort::None && !model_supports_none_reasoning(&normalized) {
default_reasoning_effort_for_model(&normalized)
} else if selected == ReasoningEffort::XHigh && !model_supports_xhigh_reasoning(&normalized) {
ReasoningEffort::High
} else {
selected
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningSummary {
#[default]
Auto,
Concise,
Detailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TextVerbosity {
Low,
#[default]
Medium,
High,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TextFormatConfig {
Text,
#[serde(rename = "json_object")]
JsonObject,
#[serde(rename = "json_schema")]
JsonSchema {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
schema: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
strict: Option<bool>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ServiceTier {
#[default]
Auto,
Default,
Flex,
Scale,
Priority,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
#[serde(default = "RetryConfig::default_max_retries")]
pub max_retries: u32,
#[serde(default = "RetryConfig::default_initial_backoff_ms")]
pub initial_backoff_ms: u64,
#[serde(default = "RetryConfig::default_max_backoff_ms")]
pub max_backoff_ms: u64,
#[serde(default = "RetryConfig::default_backoff_multiplier")]
pub backoff_multiplier: f32,
#[serde(default = "RetryConfig::default_jitter")]
pub jitter: bool,
}
impl RetryConfig {
fn default_max_retries() -> u32 {
3
}
fn default_initial_backoff_ms() -> u64 {
1000
}
fn default_max_backoff_ms() -> u64 {
60000
}
fn default_backoff_multiplier() -> f32 {
2.0
}
fn default_jitter() -> bool {
true
}
pub fn calculate_backoff(&self, attempt: u32) -> u64 {
let exponent = (attempt as f32 - 1.0).max(0.0);
let base_backoff =
(self.initial_backoff_ms as f32) * self.backoff_multiplier.powf(exponent);
let capped_backoff = base_backoff.min(self.max_backoff_ms as f32);
if self.jitter {
let jitter_factor = 1.0 + rand::random_range(-0.5..0.5); (capped_backoff * jitter_factor) as u64
} else {
capped_backoff as u64
}
}
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: Self::default_max_retries(),
initial_backoff_ms: Self::default_initial_backoff_ms(),
max_backoff_ms: Self::default_max_backoff_ms(),
backoff_multiplier: Self::default_backoff_multiplier(),
jitter: Self::default_jitter(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = OpenAIConfig::default();
assert_eq!(config.model, "gpt-5.4");
assert_eq!(config.base_url, "https://api.openai.com/v1");
assert!(config.stream);
assert_eq!(config.max_output_tokens, Some(4096));
}
#[test]
fn test_normalize_openai_model_strips_provider_prefix() {
assert_eq!(normalize_openai_model("openai/gpt-5.4"), "gpt-5.4");
assert_eq!(normalize_openai_model("openai/gpt-5.4-pro"), "gpt-5.4-pro");
}
#[test]
fn test_default_reasoning_effort_uses_gpt54_none_mode() {
assert_eq!(
default_reasoning_effort_for_model("gpt-5.4"),
ReasoningEffort::None
);
assert_eq!(
default_reasoning_effort_for_model("gpt-5.4-pro"),
ReasoningEffort::High
);
assert_eq!(
default_reasoning_effort_for_model("gpt-5-pro"),
ReasoningEffort::High
);
}
#[test]
fn test_model_supports_sampling_parameters_only_for_gpt54_none() {
assert!(model_supports_sampling_parameters("gpt-5.4", None));
assert!(model_supports_sampling_parameters(
"gpt-5.4",
Some(ReasoningEffort::None)
));
assert!(!model_supports_sampling_parameters(
"gpt-5.4",
Some(ReasoningEffort::High)
));
assert!(!model_supports_sampling_parameters(
"gpt-5-mini",
Some(ReasoningEffort::High)
));
assert!(!model_supports_sampling_parameters("gpt-5.4-pro", None));
}
#[test]
fn test_validate_rejects_sampling_with_gpt54_reasoning() {
let config = OpenAIConfig {
model: "gpt-5.4".to_string(),
reasoning: Some(ReasoningConfig::high_effort()),
temperature: Some(0.7),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_allows_sampling_with_gpt54_none_reasoning() {
let config = OpenAIConfig {
model: "gpt-5.4".to_string(),
reasoning: Some(ReasoningConfig::no_reasoning()),
temperature: Some(0.7),
top_p: Some(0.9),
top_logprobs: Some(5),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_summary_when_reasoning_is_none() {
let config = OpenAIConfig {
model: "gpt-5.4".to_string(),
reasoning: Some(ReasoningConfig {
effort: Some(ReasoningEffort::None),
summary: Some(ReasoningSummary::Detailed),
}),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_rejects_scale_service_tier_for_direct_openai() {
let config = OpenAIConfig {
service_tier: Some(ServiceTier::Scale),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_allows_scale_service_tier_for_azure() {
let config = OpenAIConfig {
service_tier: Some(ServiceTier::Scale),
azure: Some(AzureConfig {
resource_name: "example".to_string(),
api_version: "2025-04-01-preview".to_string(),
}),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_none_reasoning_for_gpt54_pro() {
let config = OpenAIConfig {
model: "gpt-5.4-pro".to_string(),
reasoning: Some(ReasoningConfig {
effort: Some(ReasoningEffort::None),
summary: None,
}),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_allows_xhigh_reasoning_for_gpt54_pro() {
let config = OpenAIConfig {
model: "gpt-5.4-pro".to_string(),
reasoning: Some(ReasoningConfig {
effort: Some(ReasoningEffort::XHigh),
summary: Some(ReasoningSummary::Detailed),
}),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_reasoning_config_minimal_builder_uses_minimal_effort() {
let reasoning = ReasoningConfig::minimal();
assert_eq!(reasoning.effort, Some(ReasoningEffort::Minimal));
assert_eq!(reasoning.summary, Some(ReasoningSummary::Concise));
}
#[test]
fn test_validate_rejects_summary_when_gpt54_default_resolves_to_none() {
let config = OpenAIConfig {
model: "gpt-5.4".to_string(),
reasoning: Some(ReasoningConfig {
effort: None,
summary: Some(ReasoningSummary::Detailed),
}),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_retry_config_defaults() {
let retry = RetryConfig::default();
assert_eq!(retry.max_retries, 3);
assert_eq!(retry.initial_backoff_ms, 1000);
assert_eq!(retry.max_backoff_ms, 60000);
assert_eq!(retry.backoff_multiplier, 2.0);
assert!(retry.jitter);
}
#[test]
fn test_retry_config_backoff_calculation() {
let retry = RetryConfig {
max_retries: 5,
initial_backoff_ms: 1000,
max_backoff_ms: 60000,
backoff_multiplier: 2.0,
jitter: false,
};
assert_eq!(retry.calculate_backoff(1), 1000);
assert_eq!(retry.calculate_backoff(2), 2000);
assert_eq!(retry.calculate_backoff(3), 4000);
assert_eq!(retry.calculate_backoff(4), 8000);
}
#[test]
fn test_retry_config_with_jitter() {
let retry = RetryConfig {
jitter: true,
..Default::default()
};
let backoff = retry.calculate_backoff(1);
assert!((500..=1500).contains(&backoff));
}
}