use std::collections::HashMap;
use serde::{Deserialize, Serialize, de};
pub use async_openai::types::responses::*;
pub use async_openai::types::responses::InputContent as UpstreamInputContent;
pub use crate::types::ImageDetail;
pub use crate::types::ReasoningEffort;
pub use crate::types::ResponseFormatJsonSchema;
pub type Input = InputParam;
pub type PromptConfig = Prompt;
pub type TextConfig = ResponseTextParam;
pub type TextResponseFormat = TextResponseFormatConfiguration;
pub type ResponseStream = std::pin::Pin<
Box<dyn futures::Stream<Item = Result<ResponseStreamEvent, crate::error::OpenAIError>> + Send>,
>;
pub const SPEC_NULLABLE_REQUIRED_RESPONSE_FIELDS: &[&str] = &[
"billing",
"completed_at",
"conversation",
"error",
"incomplete_details",
"instructions",
"max_output_tokens",
"max_tool_calls",
"previous_response_id",
"prompt",
"prompt_cache_key",
"prompt_cache_retention",
"reasoning",
"safety_identifier",
"usage",
];
fn deserialize_null_as_empty_vec<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
}
fn deserialize_null_as_default<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: Deserialize<'de> + Default,
D: serde::Deserializer<'de>,
{
Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
}
fn deserialize_tool_choice<'de, D>(deserializer: D) -> Result<Option<ToolChoiceParam>, D::Error>
where
D: serde::Deserializer<'de>,
{
let Some(value) = Option::<serde_json::Value>::deserialize(deserializer)? else {
return Ok(None);
};
if let Some(serde_json::Value::String(t)) = value.get("type") {
let mode = match t.as_str() {
"auto" => Some(ToolChoiceOptions::Auto),
"none" => Some(ToolChoiceOptions::None),
"required" => Some(ToolChoiceOptions::Required),
_ => None,
};
if let Some(mode) = mode {
return Ok(Some(ToolChoiceParam::Mode(mode)));
}
}
ToolChoiceParam::deserialize(value)
.map(Some)
.map_err(serde::de::Error::custom)
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct InputOutputTextContent {
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
pub annotations: Vec<Annotation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Vec<LogProb>>,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputOutputMessageContent {
OutputText(InputOutputTextContent),
Refusal(RefusalContent),
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct InputOutputMessage {
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
pub content: Vec<InputOutputMessageContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub role: AssistantRole,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<MessagePhase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<OutputStatus>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct InputImageContent {
#[serde(default, deserialize_with = "deserialize_null_as_default")]
pub detail: ImageDetail,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputContent {
InputText(InputTextContent),
InputImage(InputImageContent),
InputFile(InputFileContent),
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
pub struct InputMessage {
pub content: Vec<InputContent>,
pub role: InputRole,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<OutputStatus>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum EasyInputContent {
Text(String),
ContentList(Vec<InputContent>),
}
impl Default for EasyInputContent {
fn default() -> Self {
Self::Text(String::new())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
pub struct EasyInputMessage {
#[serde(default)]
pub r#type: MessageType,
pub role: Role,
pub content: EasyInputContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<MessagePhase>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum MessageItem {
Output(InputOutputMessage),
Input(InputMessage),
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct InputReasoningItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub summary: Vec<SummaryPart>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<Vec<ReasoningTextContent>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<OutputStatus>,
}
#[derive(Deserialize)]
struct CodexAgentMessage {
#[serde(default)]
content: Option<CodexAgentMessageContent>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum CodexAgentMessageContent {
Text(String),
Parts(Vec<CodexAgentMessageInputContent>),
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum CodexAgentMessageInputContent {
InputText(InputTextContent),
EncryptedContent { encrypted_content: String },
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Item {
Message(MessageItem),
FileSearchCall(FileSearchToolCall),
ComputerCall(ComputerToolCall),
ComputerCallOutput(ComputerCallOutputItemParam),
WebSearchCall(WebSearchToolCall),
FunctionCall(FunctionToolCall),
FunctionCallOutput(FunctionCallOutputItemParam),
ToolSearchCall(ToolSearchCallItemParam),
ToolSearchOutput(ToolSearchOutputItemParam),
Reasoning(InputReasoningItem),
Compaction(CompactionSummaryItemParam),
ImageGenerationCall(ImageGenToolCall),
CodeInterpreterCall(CodeInterpreterToolCall),
LocalShellCall(LocalShellToolCall),
LocalShellCallOutput(LocalShellToolCallOutput),
ShellCall(FunctionShellCallItemParam),
ShellCallOutput(FunctionShellCallOutputItemParam),
ApplyPatchCall(ApplyPatchToolCallItemParam),
ApplyPatchCallOutput(ApplyPatchToolCallOutputItemParam),
McpListTools(MCPListTools),
McpApprovalRequest(MCPApprovalRequest),
McpApprovalResponse(MCPApprovalResponse),
McpCall(MCPToolCall),
CustomToolCallOutput(CustomToolCallOutput),
CustomToolCall(CustomToolCall),
}
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum InputItem {
ItemReference(ItemReference),
Item(Item),
EasyMessage(EasyInputMessage),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum InputItemWire {
ItemReference(ItemReference),
Item(Item),
EasyMessage(EasyInputMessage),
}
impl<'de> Deserialize<'de> for InputItem {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if value.get("type").and_then(serde_json::Value::as_str) == Some("agent_message") {
let message = CodexAgentMessage::deserialize(value).map_err(de::Error::custom)?;
return Ok(normalize_codex_agent_message(message));
}
match InputItemWire::deserialize(value).map_err(de::Error::custom)? {
InputItemWire::ItemReference(item) => Ok(Self::ItemReference(item)),
InputItemWire::Item(item) => Ok(Self::Item(item)),
InputItemWire::EasyMessage(message) => Ok(Self::EasyMessage(message)),
}
}
}
fn normalize_codex_agent_message(message: CodexAgentMessage) -> InputItem {
let content = match message.content {
None => String::new(),
Some(CodexAgentMessageContent::Text(text)) => text,
Some(CodexAgentMessageContent::Parts(parts)) => parts
.into_iter()
.map(|part| match part {
CodexAgentMessageInputContent::InputText(part) => part.text,
CodexAgentMessageInputContent::EncryptedContent { encrypted_content } => {
encrypted_content
}
})
.collect::<Vec<_>>()
.join("\n"),
};
InputItem::EasyMessage(EasyInputMessage {
r#type: MessageType::Message,
role: Role::User,
content: EasyInputContent::Text(content),
phase: None,
})
}
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum InputParam {
Text(String),
Items(Vec<InputItem>),
}
impl<'de> Deserialize<'de> for InputParam {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::String(text) => Ok(Self::Text(text)),
serde_json::Value::Array(items) => {
serde_json::from_value(serde_json::Value::Array(items))
.map(Self::Items)
.map_err(de::Error::custom)
}
_ => Err(de::Error::custom(
"input must be a string or an array of input items",
)),
}
}
}
impl Default for InputParam {
fn default() -> Self {
Self::Text(String::new())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
pub struct CreateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation: Option<ConversationParam>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<IncludeEnum>>,
pub input: InputParam,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tool_calls: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<Prompt>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_retention: Option<PromptCacheRetention>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Reasoning>,
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<ResponseStreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<ResponseTextParam>,
#[serde(
default,
deserialize_with = "deserialize_tool_choice",
skip_serializing_if = "Option::is_none"
)]
pub tool_choice: Option<ToolChoiceParam>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation: Option<Truncation>,
}
pub const RESPONSE_INPUT_TOKENS_OBJECT: &str = "response.input_tokens";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
pub struct CountInputTokensRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_default_input")]
pub input: InputParam,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_lenient_tools"
)]
pub tools: Option<Vec<Tool>>,
}
fn deserialize_null_default_input<'de, D>(deserializer: D) -> Result<InputParam, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<InputParam>::deserialize(deserializer)?.unwrap_or_default())
}
fn deserialize_lenient_tools<'de, D>(deserializer: D) -> Result<Option<Vec<Tool>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let Some(raw) = Option::<Vec<serde_json::Value>>::deserialize(deserializer)? else {
return Ok(None);
};
Ok(Some(
raw.into_iter()
.filter_map(|tool| serde_json::from_value::<Tool>(tool).ok())
.collect(),
))
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct CountInputTokensResponse {
pub object: String,
pub input_tokens: u32,
}
impl CountInputTokensResponse {
pub fn new(input_tokens: u32) -> Self {
Self {
object: RESPONSE_INPUT_TOKENS_OBJECT.to_string(),
input_tokens,
}
}
}
impl CountInputTokensRequest {
pub fn estimate_tokens(&self) -> u32 {
let mut total_len: usize = 0;
if let Some(instructions) = &self.instructions.as_ref().filter(|text| !text.is_empty()) {
total_len += role_len(Role::System) + instructions.len();
}
match &self.input {
InputParam::Text(text) if text.is_empty() => {}
InputParam::Text(text) => total_len += role_len(Role::User) + text.len(),
InputParam::Items(items) => total_len += estimate_input_items_len(items),
}
if let Some(tools) = &self.tools {
for tool in tools {
total_len += estimate_tool_len(tool);
}
}
let tokens = total_len / 3;
if tokens == 0 && total_len > 0 {
1
} else {
tokens as u32
}
}
}
fn role_len(role: Role) -> usize {
match role {
Role::User => 4,
Role::Assistant => 9,
Role::System => 6,
Role::Developer => 9,
}
}
fn input_role_len(role: InputRole) -> usize {
match role {
InputRole::User => 4,
InputRole::System => 6,
InputRole::Developer => 9,
}
}
const TOOL_ROLE_LEN: usize = 4;
enum GroupEffect {
Assistant,
Flush,
Skip,
}
fn estimate_input_items_len(items: &[InputItem]) -> usize {
let mut total = 0;
let mut assistant_open = false;
for item in items {
let (effect, len) = measure_input_item(item);
total += len;
match effect {
GroupEffect::Assistant => {
if !assistant_open {
assistant_open = true;
total += role_len(Role::Assistant);
}
}
GroupEffect::Flush => assistant_open = false,
GroupEffect::Skip => {}
}
}
total
}
fn measure_input_item(item: &InputItem) -> (GroupEffect, usize) {
match item {
InputItem::ItemReference(_) => (GroupEffect::Skip, 0),
InputItem::EasyMessage(message) => {
let content = estimate_easy_content_len(&message.content);
match message.role {
Role::Assistant => (GroupEffect::Assistant, content),
role => (GroupEffect::Flush, role_len(role) + content),
}
}
InputItem::Item(item) => measure_item(item),
}
}
fn estimate_easy_content_len(content: &EasyInputContent) -> usize {
match content {
EasyInputContent::Text(text) => text.len(),
EasyInputContent::ContentList(parts) => parts.iter().map(estimate_input_content_len).sum(),
}
}
fn estimate_input_content_len(part: &InputContent) -> usize {
match part {
InputContent::InputText(text) => text.text.len(),
InputContent::InputImage(_) | InputContent::InputFile(_) => 0,
}
}
fn measure_item(item: &Item) -> (GroupEffect, usize) {
match item {
Item::Message(MessageItem::Input(message)) => (
GroupEffect::Flush,
input_role_len(message.role)
+ message
.content
.iter()
.map(estimate_input_content_len)
.sum::<usize>(),
),
Item::Message(MessageItem::Output(message)) => (
GroupEffect::Assistant,
message
.content
.iter()
.map(|part| match part {
InputOutputMessageContent::OutputText(text) => text.text.len(),
InputOutputMessageContent::Refusal(refusal) => refusal.refusal.len(),
})
.sum::<usize>(),
),
Item::FunctionCall(call) => (
GroupEffect::Assistant,
call.name.len() + call.arguments.len(),
),
Item::FunctionCallOutput(output) => (
GroupEffect::Flush,
TOOL_ROLE_LEN
+ match &output.output {
FunctionCallOutput::Text(text) => text.len(),
FunctionCallOutput::Content(parts) => parts
.iter()
.map(|part| match part {
UpstreamInputContent::InputText(text) => text.text.len(),
UpstreamInputContent::InputImage(_)
| UpstreamInputContent::InputFile(_) => 0,
})
.sum(),
},
),
Item::Reasoning(reasoning) => (
GroupEffect::Assistant,
reasoning
.summary
.iter()
.map(|part| match part {
SummaryPart::SummaryText(text) => text.text.len(),
})
.sum(),
),
Item::FileSearchCall(_)
| Item::ComputerCall(_)
| Item::ComputerCallOutput(_)
| Item::WebSearchCall(_)
| Item::ToolSearchCall(_)
| Item::ToolSearchOutput(_)
| Item::Compaction(_)
| Item::ImageGenerationCall(_)
| Item::CodeInterpreterCall(_)
| Item::LocalShellCall(_)
| Item::LocalShellCallOutput(_)
| Item::ShellCall(_)
| Item::ShellCallOutput(_)
| Item::ApplyPatchCall(_)
| Item::ApplyPatchCallOutput(_)
| Item::McpListTools(_)
| Item::McpApprovalRequest(_)
| Item::McpApprovalResponse(_)
| Item::McpCall(_)
| Item::CustomToolCallOutput(_)
| Item::CustomToolCall(_) => (GroupEffect::Flush, 0),
}
}
fn estimate_tool_len(tool: &Tool) -> usize {
match tool {
Tool::Function(function) => function_tool_len(
&function.name,
function.description.as_ref(),
function.parameters.as_ref(),
),
Tool::Namespace(namespace) => namespace
.tools
.iter()
.map(|tool| match tool {
NamespaceToolParamTool::Function(function) => function_tool_len(
&function.name,
function.description.as_ref(),
function.parameters.as_ref(),
),
NamespaceToolParamTool::Custom(_) => 0,
})
.sum(),
_ => 0,
}
}
fn function_tool_len(
name: &str,
description: Option<&String>,
parameters: Option<&serde_json::Value>,
) -> usize {
name.len()
+ description.map_or(0, |description| description.len())
+ parameters.map_or(0, |schema| schema.to_string().len())
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_choice_of(json: serde_json::Value) -> Option<ToolChoiceParam> {
let req: CreateResponse = serde_json::from_value(serde_json::json!({
"input": "hi",
"tool_choice": json,
}))
.expect("CreateResponse should deserialize");
req.tool_choice
}
#[test]
fn tool_choice_mode_object_coerces_to_mode() {
assert_eq!(
tool_choice_of(serde_json::json!({"type": "auto", "disable_parallel_tool_use": true})),
Some(ToolChoiceParam::Mode(ToolChoiceOptions::Auto)),
);
assert_eq!(
tool_choice_of(serde_json::json!({"type": "none"})),
Some(ToolChoiceParam::Mode(ToolChoiceOptions::None)),
);
assert_eq!(
tool_choice_of(serde_json::json!({"type": "required"})),
Some(ToolChoiceParam::Mode(ToolChoiceOptions::Required)),
);
}
#[test]
fn tool_choice_bare_string_still_works() {
assert_eq!(
tool_choice_of(serde_json::json!("auto")),
Some(ToolChoiceParam::Mode(ToolChoiceOptions::Auto)),
);
}
#[test]
fn tool_choice_specific_function_object_still_works() {
match tool_choice_of(serde_json::json!({"type": "function", "name": "get_weather"})) {
Some(ToolChoiceParam::Function(f)) => assert_eq!(f.name, "get_weather"),
other => panic!("expected Function tool choice, got {other:?}"),
}
}
#[test]
fn tool_choice_absent_is_none() {
let req: CreateResponse =
serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
assert!(req.tool_choice.is_none());
}
#[test]
fn reasoning_input_without_id_deserializes() {
let json = serde_json::json!({
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "thinking"}],
});
match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
InputItem::Item(Item::Reasoning(r)) => {
assert!(r.id.is_none());
assert_eq!(r.summary.len(), 1);
}
other => panic!("expected Item::Reasoning, got {other:?}"),
}
}
#[test]
fn reasoning_input_encrypted_without_id_or_summary_deserializes() {
let json = serde_json::json!({
"type": "reasoning",
"encrypted_content": "AB==",
});
match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
InputItem::Item(Item::Reasoning(r)) => {
assert!(r.id.is_none());
assert!(r.summary.is_empty());
assert_eq!(r.encrypted_content.as_deref(), Some("AB=="));
}
other => panic!("expected Item::Reasoning, got {other:?}"),
}
}
#[test]
fn reasoning_input_with_id_still_works() {
let json = serde_json::json!({
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "x"}],
"status": "completed",
});
match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
InputItem::Item(Item::Reasoning(r)) => assert_eq!(r.id.as_deref(), Some("rs_1")),
other => panic!("expected Item::Reasoning, got {other:?}"),
}
}
#[test]
fn full_request_with_idless_reasoning_item_deserializes() {
let req: Result<CreateResponse, _> = serde_json::from_value(serde_json::json!({
"model": "m",
"input": [
{"role": "user", "content": "hi"},
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "x"}]},
],
}));
assert!(
req.is_ok(),
"idless reasoning input should deserialize: {req:?}"
);
}
#[test]
fn codex_agent_message_normalizes_to_user_message() {
let req: CreateResponse = serde_json::from_value(serde_json::json!({
"input": [{
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
"content": [
{"type": "input_text", "text": "First."},
{"type": "input_text", "text": "Second."},
],
}],
}))
.expect("Codex agent message should deserialize");
let InputParam::Items(items) = req.input else {
panic!("expected items");
};
assert!(matches!(
&items[0],
InputItem::EasyMessage(EasyInputMessage {
role: Role::User,
content: EasyInputContent::Text(text),
..
}) if text == "First.\nSecond."
));
}
#[test]
fn codex_agent_message_string_content_normalizes_to_user_message() {
let item: InputItem = serde_json::from_value(serde_json::json!({
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
"content": "Return exactly OK.",
}))
.expect("Codex agent message with string content should deserialize");
assert!(matches!(
item,
InputItem::EasyMessage(EasyInputMessage {
content: EasyInputContent::Text(text),
..
}) if text == "Return exactly OK."
));
}
#[test]
fn codex_agent_message_normalizes_encrypted_content() {
let req: CreateResponse = serde_json::from_value(serde_json::json!({
"input": [{
"type": "agent_message",
"content": [
{"type": "input_text", "text": "Payload:"},
{"type": "encrypted_content", "encrypted_content": "Return exactly OK."},
],
}],
}))
.expect("Codex agent message with encrypted content should deserialize");
let InputParam::Items(items) = req.input else {
panic!("expected items");
};
assert!(matches!(
&items[0],
InputItem::EasyMessage(EasyInputMessage {
content: EasyInputContent::Text(text),
..
}) if text == "Payload:\nReturn exactly OK."
));
}
#[test]
fn codex_agent_message_missing_content_normalizes_empty() {
let item: InputItem = serde_json::from_value(serde_json::json!({
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
}))
.expect("Codex agent message without content should deserialize");
assert!(matches!(
item,
InputItem::EasyMessage(EasyInputMessage {
content: EasyInputContent::Text(text),
..
}) if text.is_empty()
));
}
#[test]
fn codex_agent_message_null_content_normalizes_empty() {
let item: InputItem = serde_json::from_value(serde_json::json!({
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
"content": null,
}))
.expect("Codex agent message with null content should deserialize");
assert!(matches!(
item,
InputItem::EasyMessage(EasyInputMessage {
content: EasyInputContent::Text(text),
..
}) if text.is_empty()
));
}
#[test]
fn relaxed_assistant_message_without_id_or_status() {
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi"}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert_eq!(out.role, AssistantRole::Assistant);
assert!(out.id.is_none());
assert!(out.status.is_none());
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn input_image_without_detail_defaults_to_auto() {
let json = serde_json::json!({
"type": "input_image",
"image_url": "https://example.com/cat.jpg"
});
let content: InputContent = serde_json::from_value(json).unwrap();
match content {
InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
other => panic!("expected InputImage, got {other:?}"),
}
}
#[test]
fn input_image_with_explicit_null_detail_defaults_to_auto() {
let json = serde_json::json!({
"type": "input_image",
"image_url": "https://example.com/cat.jpg",
"detail": null
});
let content: InputContent = serde_json::from_value(json).unwrap();
match content {
InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
other => panic!("expected InputImage, got {other:?}"),
}
}
#[test]
fn assistant_message_without_content_field_deserializes() {
let json = serde_json::json!({
"type": "message",
"role": "assistant"
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert_eq!(out.role, AssistantRole::Assistant);
assert!(out.content.is_empty());
assert!(out.id.is_none());
assert!(out.status.is_none());
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn assistant_message_with_explicit_null_content_deserializes() {
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"content": null
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert!(out.content.is_empty());
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn mcp_call_item_deserializes() {
let json = serde_json::json!({
"type": "mcp_call",
"id": "mcp_1",
"server_label": "srv",
"name": "t",
"arguments": "{}"
});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(item, InputItem::Item(Item::McpCall(_))));
}
#[test]
fn strict_assistant_message_still_deserializes() {
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"id": "msg_1",
"status": "completed",
"content": [{"type": "output_text", "text": "hi", "annotations": []}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert_eq!(out.id.as_deref(), Some("msg_1"));
assert_eq!(out.status, Some(OutputStatus::Completed));
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn user_message_routes_to_input_variant() {
let json = serde_json::json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hi"}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(
item,
InputItem::Item(Item::Message(MessageItem::Input(_)))
));
}
#[test]
fn function_call_item_still_deserializes() {
let json = serde_json::json!({
"type": "function_call",
"call_id": "c",
"name": "f",
"arguments": "{}"
});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(item, InputItem::Item(Item::FunctionCall(_))));
}
#[test]
fn easy_message_string_content_routes_to_easymessage() {
let json = serde_json::json!({"role": "assistant", "content": "x"});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(item, InputItem::EasyMessage(_)));
}
#[test]
fn output_text_without_annotations_defaults_empty() {
let json = serde_json::json!({"type": "output_text", "text": "hi"});
let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
match part {
InputOutputMessageContent::OutputText(t) => {
assert!(t.annotations.is_empty());
}
_ => panic!("expected OutputText"),
}
}
#[test]
fn output_text_with_explicit_null_annotations_deserializes_as_empty() {
let json = serde_json::json!({"type": "output_text", "text": "hi", "annotations": null});
let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
match part {
InputOutputMessageContent::OutputText(t) => {
assert!(t.annotations.is_empty());
}
_ => panic!("expected OutputText"),
}
}
#[test]
fn assistant_message_with_explicit_null_id_and_status_deserializes() {
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"id": null,
"status": null,
"content": [{"type": "output_text", "text": "hi", "annotations": null}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert!(out.id.is_none());
assert!(out.status.is_none());
assert_eq!(out.content.len(), 1);
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn create_response_roundtrip_with_relaxed_input() {
let body = serde_json::json!({
"model": "m",
"input": [
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "hi"}
]},
{"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
{"type": "message", "role": "assistant", "content": [
{"type": "output_text", "text": "\n\n"}
]},
{"type": "function_call_output", "call_id": "c", "output": "x"}
]
});
let req: CreateResponse = serde_json::from_value(body).unwrap();
let items = match &req.input {
InputParam::Items(items) => items,
_ => panic!("expected Items"),
};
assert_eq!(items.len(), 4);
assert!(matches!(
items[2],
InputItem::Item(Item::Message(MessageItem::Output(_)))
));
}
#[test]
fn easy_message_multimodal_without_type_routes_to_easymessage() {
let json = serde_json::json!({
"role": "user",
"content": [
{"type": "input_image", "image_url": "data:image/png;base64,abc"}
]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::EasyMessage(easy) => {
assert_eq!(easy.role, Role::User);
assert_eq!(easy.r#type, MessageType::Message);
match easy.content {
EasyInputContent::ContentList(parts) => {
assert_eq!(parts.len(), 1);
match &parts[0] {
InputContent::InputImage(img) => {
assert_eq!(img.detail, ImageDetail::Auto);
assert_eq!(
img.image_url.as_deref(),
Some("data:image/png;base64,abc")
);
}
other => panic!("expected InputImage, got {other:?}"),
}
}
other => panic!("expected ContentList, got {other:?}"),
}
}
other => panic!("expected EasyMessage, got {other:?}"),
}
}
#[test]
fn easy_message_multimodal_with_explicit_null_detail() {
let json = serde_json::json!({
"role": "user",
"content": [
{"type": "input_image", "image_url": "data:image/png;base64,abc", "detail": null}
]
});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(item, InputItem::EasyMessage(_)));
}
#[test]
fn easy_message_assistant_multimodal_without_type() {
let json = serde_json::json!({
"role": "assistant",
"content": [
{"type": "input_text", "text": "ok"}
]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::EasyMessage(easy) => {
assert_eq!(easy.role, Role::Assistant);
}
other => panic!("expected EasyMessage(assistant), got {other:?}"),
}
}
#[test]
fn easy_message_text_only_without_type_unchanged() {
let json = serde_json::json!({"role": "user", "content": "Hello"});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::EasyMessage(easy) => {
assert_eq!(easy.role, Role::User);
assert!(matches!(easy.content, EasyInputContent::Text(ref s) if s == "Hello"));
}
other => panic!("expected EasyMessage(Text), got {other:?}"),
}
}
#[test]
fn easy_message_with_explicit_type_still_routes_to_item_message() {
let json = serde_json::json!({
"type": "message",
"role": "user",
"content": [
{"type": "input_image", "image_url": "data:image/png;base64,abc"}
]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Input(msg))) => {
assert_eq!(msg.role, InputRole::User);
assert_eq!(msg.content.len(), 1);
}
other => panic!("expected Item::Message(Input), got {other:?}"),
}
}
#[test]
fn create_response_roundtrip_aiperf_pre_pr931_payload() {
let body = serde_json::json!({
"model": "Qwen/Qwen2-VL-2B-Instruct",
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "Describe"},
{"type": "input_image", "image_url": "data:image/png;base64,abc"}
]
},
{
"role": "assistant",
"content": [{"type": "input_text", "text": "ok"}]
},
{
"role": "user",
"content": [{"type": "input_text", "text": "Now describe a different one."}]
}
]
});
let req: CreateResponse = serde_json::from_value(body).unwrap();
let items = match &req.input {
InputParam::Items(items) => items,
_ => panic!("expected Items"),
};
assert_eq!(items.len(), 3);
for (idx, item) in items.iter().enumerate() {
assert!(
matches!(item, InputItem::EasyMessage(_)),
"turn {idx} did not route to EasyMessage: {item:?}",
);
}
}
fn count(body: serde_json::Value) -> u32 {
serde_json::from_value::<CountInputTokensRequest>(body)
.expect("count request should deserialize")
.estimate_tokens()
}
#[test]
fn count_tokens_plain_text_input() {
assert_eq!(
count(serde_json::json!({"model": "m", "input": "Hello, world!"})),
5
);
}
#[test]
fn count_tokens_input_is_optional() {
assert_eq!(count(serde_json::json!({"model": "m"})), 0);
}
#[test]
fn count_tokens_empty_input_is_zero() {
assert_eq!(count(serde_json::json!({"input": ""})), 0);
}
#[test]
fn count_tokens_short_input_never_rounds_to_zero() {
assert_eq!(
count(serde_json::json!({"tools": [{"type": "function", "name": "a"}]})),
1
);
assert_eq!(count(serde_json::json!({"input": "Hi"})), 2);
}
#[test]
fn count_tokens_instructions_contribute() {
assert_eq!(
count(serde_json::json!({"input": "Hi", "instructions": "You are helpful."})),
9
);
}
#[test]
fn count_tokens_scores_the_two_spellings_of_a_prompt_identically() {
assert_eq!(
count(serde_json::json!({"input": "Hello"})),
count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
);
assert_eq!(
count(serde_json::json!({
"input": "Hello",
"instructions": "You are helpful."
})),
count(serde_json::json!({"input": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
]})),
);
}
#[test]
fn count_tokens_easy_message_counts_role_and_content() {
assert_eq!(
count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
3
);
}
#[test]
fn count_tokens_structured_input_message() {
assert_eq!(
count(serde_json::json!({"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}],
}]})),
3
);
}
#[test]
fn count_tokens_function_call_counts_name_and_arguments() {
assert_eq!(
count(serde_json::json!({"input": [{
"type": "function_call",
"call_id": "call_1",
"name": "get_weather",
"arguments": r#"{"city":"SF"}"#,
}]})),
11
);
}
#[test]
fn count_tokens_charges_one_assistant_marker_per_coalesced_turn() {
let one = serde_json::json!({"input": [
{"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""}
]});
let two = serde_json::json!({"input": [
{"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
{"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
]});
assert_eq!(count(one), 3);
assert_eq!(count(two), 4);
let mixed = serde_json::json!({"input": [
{"role": "assistant", "content": "aa"},
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "bb"}]},
{"type": "function_call", "call_id": "c1", "name": "cc", "arguments": ""}
]});
assert_eq!(count(mixed), 5); }
#[test]
fn count_tokens_reopens_the_assistant_turn_after_a_flush() {
let two_turns = serde_json::json!({"input": [
{"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
{"type": "function_call_output", "call_id": "c1", "output": ""},
{"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
]});
assert_eq!(count(two_turns), 8);
}
#[test]
fn count_tokens_item_reference_does_not_split_an_assistant_turn() {
let split = serde_json::json!({"input": [
{"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
{"type": "item_reference", "id": "item_abc"},
{"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
]});
let unsplit = serde_json::json!({"input": [
{"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
{"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
]});
assert_eq!(count(split), count(unsplit));
}
#[test]
fn count_tokens_unsupported_item_splits_an_assistant_turn() {
let across = serde_json::json!({"input": [
{"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
{"type": "web_search_call", "id": "ws_1", "status": "completed"},
{"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
]});
assert_eq!(count(across), 7);
}
#[test]
fn count_tokens_function_call_output_counts_text() {
assert_eq!(
count(serde_json::json!({"input": [{
"type": "function_call_output",
"call_id": "call_1",
"output": "sunny",
}]})),
3
);
}
#[test]
fn count_tokens_tools_contribute() {
assert_eq!(
count(serde_json::json!({
"input": "",
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object"},
}],
})),
13
);
}
#[test]
fn count_tokens_images_contribute_nothing() {
let with_image = count(serde_json::json!({"input": [{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this"},
{"type": "input_image", "image_url": "https://example.com/a-very-long-url.png"},
],
}]}));
let without_image = count(serde_json::json!({"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Describe this"}],
}]}));
assert_eq!(with_image, without_image);
}
#[test]
fn count_tokens_dropped_item_variants_cost_nothing() {
for item in [
serde_json::json!({"type": "web_search_call", "id": "ws_1", "status": "completed"}),
serde_json::json!({
"type": "computer_call",
"call_id": "c_1",
"id": "cu_1",
"action": {"type": "screenshot"},
"pending_safety_checks": [],
"status": "completed",
}),
] {
assert_eq!(
count(serde_json::json!({ "input": [item.clone()] })),
0,
"dropped item variant should not be counted: {item}"
);
}
}
#[test]
fn count_tokens_counts_exactly_the_variants_the_converter_renders() {
for item in [
serde_json::json!({"role": "user", "content": "Hello"}),
serde_json::json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}],
}),
serde_json::json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hi", "annotations": []}],
}),
serde_json::json!({
"type": "function_call",
"call_id": "c1",
"name": "get_weather",
"arguments": "{}",
}),
serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "sunny"}),
serde_json::json!({
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "thinking"}],
}),
] {
assert!(
count(serde_json::json!({ "input": [item.clone()] })) > 0,
"rendered variant should be counted: {item}"
);
}
}
#[test]
fn count_tokens_reasoning_counts_summary_only() {
let summary_only = serde_json::json!({"input": [{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "thinking"}],
}]});
let with_dropped_fields = serde_json::json!({"input": [{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "thinking"}],
"content": [{"type": "reasoning_text", "text": "a much longer private chain of thought"}],
"encrypted_content": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
}]});
assert_eq!(count(summary_only.clone()), 5);
assert_eq!(count(with_dropped_fields), count(summary_only));
}
#[test]
fn count_tokens_hosted_tools_cost_nothing() {
assert_eq!(
count(serde_json::json!({
"input": "",
"tools": [{"type": "web_search"}],
})),
0
);
}
#[test]
fn count_tokens_namespaced_tools_count_their_functions() {
assert_eq!(
count(serde_json::json!({
"input": "",
"tools": [{
"type": "namespace",
"name": "weather_ns",
"description": "Weather tools",
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object"},
}],
}],
})),
13
);
}
#[test]
fn count_tokens_item_reference_contributes_nothing() {
assert_eq!(
count(serde_json::json!({"input": [{"type": "item_reference", "id": "msg_1"}]})),
0
);
}
#[test]
fn count_tokens_ignores_unsupported_stateful_fields() {
assert_eq!(
count(serde_json::json!({
"model": "m",
"input": "Hello, world!",
"previous_response_id": "resp_abc123",
"conversation": {"id": "conv_1"},
})),
5
);
}
#[test]
fn count_tokens_deserializes_the_litellm_request_shape() {
let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
"model": "dynamo/deepseek-ai/deepseek-v4-pro-sglang",
"input": [{"role": "user", "content": "Hello"}],
"instructions": "You are helpful.",
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object"},
}],
}))
.expect("LiteLLM request shape should deserialize");
assert_eq!(
request.model.as_deref(),
Some("dynamo/deepseek-ai/deepseek-v4-pro-sglang")
);
assert!(matches!(request.input, InputParam::Items(ref items) if items.len() == 1));
assert!(request.estimate_tokens() > 0);
}
#[test]
fn count_tokens_accepts_explicit_null_input() {
assert_eq!(count(serde_json::json!({"model": "m", "input": null})), 0);
assert_eq!(
count(serde_json::json!({
"model": "m",
"input": null,
"instructions": "You are helpful."
})),
7
);
}
#[test]
fn count_tokens_drops_unparseable_tools_instead_of_failing() {
let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
"model": "m",
"input": "Hello, world!",
"tools": [{"type": "custom", "custom": {"name": "x"}}],
}))
.expect("an unparseable tool should be dropped, not rejected");
assert_eq!(request.tools.as_deref(), Some(&[][..]));
assert_eq!(request.estimate_tokens(), 5);
}
#[test]
fn count_tokens_keeps_parseable_tools_alongside_dropped_ones() {
let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
"model": "m",
"input": "Hello, world!",
"tools": [
{"type": "custom", "custom": {"name": "x"}},
{"type": "function", "name": "get_weather", "description": "Get weather"},
],
}))
.expect("a mixed tool array should deserialize");
assert_eq!(request.tools.as_ref().map(Vec::len), Some(1));
assert!(
request.estimate_tokens()
> count(serde_json::json!({"model": "m", "input": "Hello, world!"}))
);
}
#[test]
fn count_tokens_distinguishes_absent_tools_from_empty_tools() {
let absent: CountInputTokensRequest =
serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
assert_eq!(absent.tools, None);
let empty: CountInputTokensRequest =
serde_json::from_value(serde_json::json!({"input": "hi", "tools": []})).unwrap();
assert_eq!(empty.tools.as_deref(), Some(&[][..]));
let null: CountInputTokensRequest =
serde_json::from_value(serde_json::json!({"input": "hi", "tools": null})).unwrap();
assert_eq!(null.tools, None);
}
#[test]
fn count_tokens_response_serializes_to_the_openai_shape() {
assert_eq!(
serde_json::to_value(CountInputTokensResponse::new(42)).unwrap(),
serde_json::json!({"object": "response.input_tokens", "input_tokens": 42})
);
}
}