use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize)]
pub(crate) struct OpenAiRequest {
pub(crate) model: String,
pub(crate) messages: Vec<OpenAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) max_completion_tokens: Option<u32>,
pub(crate) stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) stream_options: Option<OpenAiStreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tools: Option<Vec<OpenAiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) service_tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) verbosity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) metadata: Option<std::collections::HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) response_format: Option<Value>,
}
impl OpenAiRequest {
pub(crate) fn reasoning_effort_for(
config: &crate::driver_registry::LlmCallConfig,
) -> Option<String> {
let effort = config.reasoning_effort;
effort
.filter(crate::model::ReasoningEffort::requests_reasoning)
.map(|e| e.as_str().to_string())
}
pub(crate) fn response_format_for(
config: &crate::driver_registry::LlmCallConfig,
) -> Option<Value> {
config
.response_format
.as_ref()
.map(|f| f.chat_completions_response_format())
}
}
#[derive(Debug, Serialize)]
pub(crate) struct OpenAiStreamOptions {
pub(crate) include_usage: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum OpenAiContent {
Text(String),
Parts(Vec<OpenAiContentPart>),
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum OpenAiContentPart {
Text {
r#type: String,
text: String,
},
ImageUrl {
r#type: String,
image_url: OpenAiImageUrl,
},
InputAudio {
r#type: String,
input_audio: OpenAiInputAudio,
},
File {
r#type: String,
file: OpenAiFile,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) filename: Option<String>,
pub(crate) file_data: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiImageUrl {
pub(crate) url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiInputAudio {
pub(crate) data: String,
pub(crate) format: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiMessage {
pub(crate) role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) content: Option<OpenAiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tool_calls: Option<Vec<OpenAiToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tool_call_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChatCompletionResponse {
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(default)]
pub(crate) model: Option<String>,
#[serde(default)]
pub(crate) choices: Vec<OpenAiChatChoice>,
#[serde(default)]
pub(crate) usage: Option<OpenAiUsage>,
#[serde(default)]
pub(crate) error: Option<OpenAiErrorEnvelope>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChatChoice {
pub(crate) message: OpenAiChatMessage,
#[serde(default)]
pub(crate) finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChatMessage {
#[serde(default)]
pub(crate) content: Option<OpenAiContent>,
#[serde(default)]
pub(crate) tool_calls: Vec<OpenAiToolCall>,
#[serde(default)]
pub(crate) reasoning_content: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiTool {
pub(crate) r#type: String,
pub(crate) function: OpenAiFunction,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiFunction {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) parameters: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) strict: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiToolCall {
pub(crate) id: String,
pub(crate) r#type: String,
pub(crate) function: OpenAiFunctionCall,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenAiFunctionCall {
pub(crate) name: String,
#[serde(deserialize_with = "arguments_as_json_text")]
pub(crate) arguments: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)] pub(crate) struct OpenAiStreamChunk {
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(default)]
pub(crate) model: Option<String>,
#[serde(default)]
pub(crate) choices: Vec<OpenAiStreamChoice>,
#[serde(default)]
pub(crate) usage: Option<OpenAiUsage>,
#[serde(default)]
pub(crate) error: Option<OpenAiErrorEnvelope>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct OpenAiErrorEnvelope {
#[serde(default)]
pub(crate) message: Option<String>,
#[serde(default)]
pub(crate) code: Option<Value>,
#[serde(default, rename = "type")]
pub(crate) kind: Option<String>,
#[serde(default)]
pub(crate) status: Option<u16>,
}
impl OpenAiErrorEnvelope {
pub(crate) fn into_stream_error(self) -> crate::stream_error::LlmStreamError {
let (code, status) = match self.code {
Some(Value::String(code)) => (Some(code), self.status),
Some(Value::Number(number)) => {
let status = number.as_u64().and_then(|n| u16::try_from(n).ok());
(None, self.status.or(status))
}
_ => (None, self.status),
};
let code = code.or(self.kind);
let message = self
.message
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| "provider reported an error in the response stream".to_string());
crate::stream_error::LlmStreamError::provider(code, status, message)
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsage {
pub(crate) prompt_tokens: Option<u32>,
pub(crate) completion_tokens: Option<u32>,
#[serde(default)]
pub(crate) prompt_tokens_details: Option<OpenAiPromptTokensDetails>,
#[serde(default)]
pub(crate) completion_tokens_details: Option<OpenAiCompletionTokensDetails>,
#[serde(default)]
pub(crate) cost: Option<f64>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct OpenAiPromptTokensDetails {
#[serde(default)]
pub(crate) cached_tokens: Option<u32>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct OpenAiCompletionTokensDetails {
#[serde(default)]
pub(crate) reasoning_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChoice {
pub(crate) delta: OpenAiDelta,
#[serde(default)]
pub(crate) finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiDelta {
#[serde(default, deserialize_with = "deserialize_lenient_text")]
pub(crate) content: Option<String>,
#[serde(default)]
pub(crate) reasoning_content: Option<String>,
#[serde(default)]
pub(crate) reasoning: Option<String>,
#[serde(default)]
pub(crate) tool_calls: Option<Vec<OpenAiStreamToolCall>>,
}
fn deserialize_lenient_text<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
match Option::<serde_json::Value>::deserialize(deserializer)? {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(text)) => Ok(Some(text)),
Some(serde_json::Value::Number(number)) => Ok(Some(number.to_string())),
Some(other) => Err(serde::de::Error::custom(format!(
"expected a string or number for streamed text, got {other}"
))),
}
}
impl OpenAiDelta {
pub(crate) fn reasoning_text(&self) -> Option<&str> {
self.reasoning_content
.as_deref()
.or(self.reasoning.as_deref())
.filter(|text| !text.is_empty())
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamToolCall {
pub(crate) index: u32,
pub(crate) id: Option<String>,
pub(crate) function: Option<OpenAiStreamFunction>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamFunction {
pub(crate) name: Option<String>,
#[serde(default, deserialize_with = "optional_arguments_as_json_text")]
pub(crate) arguments: Option<String>,
}
fn arguments_as_json_text<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match Value::deserialize(deserializer)? {
Value::String(text) => text,
Value::Null => String::new(),
other => other.to_string(),
})
}
fn optional_arguments_as_json_text<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match Value::deserialize(deserializer)? {
Value::Null => None,
Value::String(text) => Some(text),
other => Some(other.to_string()),
})
}
#[cfg(test)]
mod delta_text_tests {
use super::OpenAiDelta;
#[test]
fn a_string_delta_reads_as_text() {
let delta: OpenAiDelta = serde_json::from_value(serde_json::json!({"content": ", 2"}))
.expect("a string delta should deserialize");
assert_eq!(delta.content.as_deref(), Some(", 2"));
}
#[test]
fn a_numeric_delta_is_not_dropped() {
let delta: OpenAiDelta = serde_json::from_value(serde_json::json!({"content": 1}))
.expect("a numeric delta should deserialize");
assert_eq!(delta.content.as_deref(), Some("1"));
}
#[test]
fn an_absent_or_null_delta_is_none() {
let absent: OpenAiDelta =
serde_json::from_value(serde_json::json!({})).expect("absent content is valid");
assert_eq!(absent.content, None);
let null: OpenAiDelta = serde_json::from_value(serde_json::json!({"content": null}))
.expect("null content is valid");
assert_eq!(null.content, None);
}
#[test]
fn a_structured_delta_is_still_an_error() {
let result: Result<OpenAiDelta, _> =
serde_json::from_value(serde_json::json!({"content": {"text": "hi"}}));
assert!(result.is_err(), "an object is not streamed text");
}
}