use serde_json::{Value, json};
use crate::{Error, Result};
const MAX_MODEL_CHARACTERS: usize = 128;
const MAX_AGENT_INPUT_CHARACTERS: usize = 2_000_000;
const MAX_TOOLS: usize = 64;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentTool {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentTurnRequest {
pub model: String,
pub input: String,
pub reasoning_effort: String,
pub tools: Vec<AgentTool>,
}
impl AgentTurnRequest {
pub fn new(model: impl Into<String>, input: impl Into<String>) -> Self {
Self {
model: model.into(),
input: input.into(),
reasoning_effort: "medium".into(),
tools: Vec::new(),
}
}
pub(crate) fn validate(&self) -> Result<()> {
validate_model(&self.model)?;
if self.input.trim().is_empty() || self.input.chars().count() > MAX_AGENT_INPUT_CHARACTERS {
return Err(Error::InvalidInput(format!(
"agent input must contain 1 through {MAX_AGENT_INPUT_CHARACTERS} characters"
)));
}
if !matches!(
self.reasoning_effort.as_str(),
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
) {
return Err(Error::InvalidInput(
"reasoning effort is not supported".into(),
));
}
if self.tools.len() > MAX_TOOLS {
return Err(Error::InvalidInput(format!(
"agent turn may expose at most {MAX_TOOLS} tools"
)));
}
for tool in &self.tools {
if tool.name.is_empty()
|| tool.name.chars().count() > 100
|| !tool
.name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(Error::InvalidInput(
"agent tool name must be a safe non-empty identifier".into(),
));
}
if tool.description.trim().is_empty()
|| tool.description.chars().count() > 4_000
|| !tool.input_schema.is_object()
{
return Err(Error::InvalidInput(
"agent tools require a bounded description and object JSON Schema".into(),
));
}
}
Ok(())
}
pub(crate) fn payload(&self) -> Value {
let mut value = json!({
"model": self.model,
"input": self.input,
"store": false
});
if self.model.starts_with("gpt-5") || self.model.starts_with('o') {
value["reasoning"] = json!({"effort": self.reasoning_effort});
}
if !self.tools.is_empty() {
value["tools"] = json!(
self.tools
.iter()
.map(|tool| json!({
"type": "function",
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
"strict": false
}))
.collect::<Vec<_>>()
);
value["tool_choice"] = json!("auto");
value["parallel_tool_calls"] = json!(false);
}
value
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentToolCall {
pub call_id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct AgentTokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub reasoning_output_tokens: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentTurnResponse {
pub model: String,
pub response_id: String,
pub text: String,
pub tool_call: Option<AgentToolCall>,
pub usage: Option<AgentTokenUsage>,
pub request_id: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ModelMetadata {
pub id: String,
pub context_window_tokens: Option<u64>,
pub max_input_tokens: Option<u64>,
}
pub(crate) fn validate_model(model: &str) -> Result<()> {
if model.trim().is_empty()
|| model.chars().count() > MAX_MODEL_CHARACTERS
|| !model.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'/')
})
{
return Err(Error::InvalidInput(
"model must be an exact safe OpenAI model identifier".into(),
));
}
Ok(())
}
pub(crate) fn parse_agent_turn(
value: &Value,
requested_model: &str,
request_id: Option<String>,
) -> Result<AgentTurnResponse> {
let mut text = Vec::new();
let mut tool_calls = Vec::new();
for item in value
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
match item.get("type").and_then(Value::as_str) {
Some("function_call") => tool_calls.push(parse_tool_call(item, tool_calls.len())?),
Some("message") => collect_text(item.get("content"), &mut text),
Some("output_text" | "text") => {
if let Some(fragment) = item.get("text").and_then(Value::as_str) {
text.push(fragment.to_owned());
}
}
_ => {}
}
}
if let Some(fragment) = value.get("output_text").and_then(Value::as_str) {
text.push(fragment.to_owned());
}
if tool_calls.len() > 1 {
return Err(Error::Protocol(
"OpenAI returned multiple function calls despite serial tool selection".into(),
));
}
let response_id = value
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| Error::Protocol("agent response omitted its identifier".into()))?
.to_owned();
let usage = value
.get("usage")
.filter(|value| !value.is_null())
.map(|usage| AgentTokenUsage {
input_tokens: usage
.get("input_tokens")
.and_then(Value::as_u64)
.unwrap_or_default(),
output_tokens: usage
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or_default(),
cached_input_tokens: usage
.pointer("/input_tokens_details/cached_tokens")
.and_then(Value::as_u64)
.unwrap_or_default(),
reasoning_output_tokens: usage
.pointer("/output_tokens_details/reasoning_tokens")
.and_then(Value::as_u64)
.unwrap_or_default(),
});
Ok(AgentTurnResponse {
model: value
.get("model")
.and_then(Value::as_str)
.unwrap_or(requested_model)
.to_owned(),
response_id,
text: text.join("\n\n"),
tool_call: tool_calls.pop(),
usage,
request_id,
})
}
pub(crate) fn parse_model_metadata(value: &Value, requested: &str) -> Result<ModelMetadata> {
let id = value
.get("id")
.and_then(Value::as_str)
.unwrap_or(requested)
.to_owned();
Ok(ModelMetadata {
id,
context_window_tokens: u64_field(
value,
&["context_window_tokens", "context_window", "contextWindow"],
),
max_input_tokens: u64_field(
value,
&["max_input_tokens", "input_token_limit", "inputTokenLimit"],
),
})
}
fn parse_tool_call(value: &Value, index: usize) -> Result<AgentToolCall> {
let arguments = match value.get("arguments") {
Some(Value::String(arguments)) => serde_json::from_str(arguments).map_err(|_| {
Error::Protocol("function call contained invalid JSON arguments".into())
})?,
Some(Value::Object(_)) => value["arguments"].clone(),
_ => {
return Err(Error::Protocol(
"function call omitted object arguments".into(),
));
}
};
let name = value
.get("name")
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
.ok_or_else(|| Error::Protocol("function call omitted its name".into()))?
.to_owned();
Ok(AgentToolCall {
call_id: value
.get("call_id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| format!("openai-call-{index}")),
name,
arguments,
})
}
fn collect_text(value: Option<&Value>, output: &mut Vec<String>) {
for item in value.and_then(Value::as_array).into_iter().flatten() {
if matches!(
item.get("type").and_then(Value::as_str),
Some("output_text" | "text")
) && let Some(text) = item.get("text").and_then(Value::as_str)
{
output.push(text.to_owned());
}
}
}
fn u64_field(value: &Value, fields: &[&str]) -> Option<u64> {
fields
.iter()
.find_map(|field| value.get(*field).and_then(Value::as_u64))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_disables_parallel_tools_and_response_normalizes_usage() {
let mut request = AgentTurnRequest::new("gpt-5.6", "Do the task.");
request.tools.push(AgentTool {
name: "call_ktool".into(),
description: "Call one tool.".into(),
input_schema: json!({"type": "object"}),
});
let payload = request.payload();
assert_eq!(payload["parallel_tool_calls"], false);
assert_eq!(payload["reasoning"]["effort"], "medium");
let parsed = parse_agent_turn(
&json!({
"id": "resp_1",
"model": "gpt-5.6-2026-07-01",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "call_ktool",
"arguments": "{\"name\":\"Read\"}"
}],
"usage": {
"input_tokens": 10,
"output_tokens": 4,
"input_tokens_details": {"cached_tokens": 3},
"output_tokens_details": {"reasoning_tokens": 2}
}
}),
"gpt-5.6",
None,
)
.unwrap();
assert_eq!(parsed.response_id, "resp_1");
assert_eq!(parsed.tool_call.unwrap().arguments["name"], "Read");
assert_eq!(parsed.usage.unwrap().cached_input_tokens, 3);
}
}