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<Value>,
#[serde(default, deserialize_with = "null_as_empty")]
pub(crate) tool_calls: Vec<OpenAiToolCall>,
#[serde(default)]
pub(crate) reasoning_content: Option<String>,
}
fn null_as_empty<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}
impl OpenAiChatMessage {
pub(crate) fn text_and_thinking(&mut self) -> (String, Option<String>) {
let (text, thinking) = match self.content.take() {
Some(Value::String(text)) => (Some(text), None),
Some(Value::Array(chunks)) => content_chunks_text(&chunks),
_ => (None, None),
};
(
text.unwrap_or_default(),
self.reasoning_content.take().or(thinking),
)
}
}
#[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)]
#[serde(try_from = "RawOpenAiDelta")]
pub(crate) struct OpenAiDelta {
pub(crate) content: Option<String>,
pub(crate) reasoning_content: Option<String>,
pub(crate) reasoning: Option<String>,
pub(crate) tool_calls: Option<Vec<OpenAiStreamToolCall>>,
}
#[derive(Deserialize)]
struct RawOpenAiDelta {
#[serde(default)]
content: Option<Value>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<OpenAiStreamToolCall>>,
}
impl TryFrom<RawOpenAiDelta> for OpenAiDelta {
type Error = String;
fn try_from(raw: RawOpenAiDelta) -> Result<Self, Self::Error> {
let (content, thinking) = match raw.content {
None | Some(Value::Null) => (None, None),
Some(Value::String(text)) => (Some(text), None),
Some(Value::Number(number)) => (Some(number.to_string()), None),
Some(Value::Array(chunks)) => content_chunks_text(&chunks),
Some(other) => {
return Err(format!(
"expected a string, number or chunk array for streamed text, got {other}"
));
}
};
Ok(Self {
content,
reasoning_content: raw.reasoning_content.or(thinking),
reasoning: raw.reasoning,
tool_calls: raw.tool_calls,
})
}
}
pub(crate) fn content_chunks_text(chunks: &[Value]) -> (Option<String>, Option<String>) {
fn texts<'a>(chunks: impl IntoIterator<Item = &'a Value>) -> String {
chunks
.into_iter()
.filter(|chunk| chunk.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|chunk| chunk.get("text").and_then(Value::as_str))
.collect()
}
let text = texts(chunks);
let thinking: String = chunks
.iter()
.filter(|chunk| chunk.get("type").and_then(Value::as_str) == Some("thinking"))
.filter_map(|chunk| chunk.get("thinking").and_then(Value::as_array))
.map(texts)
.collect();
let non_empty = |text: String| (!text.is_empty()).then_some(text);
(non_empty(text), non_empty(thinking))
}
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_chunk_array_splits_text_from_thinking() {
let delta: OpenAiDelta = serde_json::from_value(serde_json::json!({"content": [
{"type": "thinking", "thinking": [{"type": "text", "text": "plan"}]},
{"type": "text", "text": "an"}, {"type": "text", "text": "swer"}
]}))
.expect("a chunk array should deserialize");
assert_eq!(delta.content.as_deref(), Some("answer"));
assert_eq!(delta.reasoning_text(), Some("plan"));
let empty: OpenAiDelta = serde_json::from_value(
serde_json::json!({"content": [{"type": "thinking", "thinking": []}]}),
)
.expect("an empty thinking chunk is valid");
assert_eq!(
(empty.content.as_deref(), empty.reasoning_text()),
(None, 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");
}
}