1use serde_json::{Value, json};
2
3use crate::{Error, Result};
4
5const MAX_MODEL_CHARACTERS: usize = 128;
6const MAX_AGENT_INPUT_CHARACTERS: usize = 2_000_000;
7const MAX_TOOLS: usize = 64;
8
9#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct AgentTool {
12 pub name: String,
14 pub description: String,
16 pub input_schema: Value,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct AgentTurnRequest {
23 pub model: String,
25 pub input: String,
27 pub reasoning_effort: String,
29 pub tools: Vec<AgentTool>,
31}
32
33impl AgentTurnRequest {
34 pub fn new(model: impl Into<String>, input: impl Into<String>) -> Self {
36 Self {
37 model: model.into(),
38 input: input.into(),
39 reasoning_effort: "medium".into(),
40 tools: Vec::new(),
41 }
42 }
43
44 pub(crate) fn validate(&self) -> Result<()> {
45 validate_model(&self.model)?;
46 if self.input.trim().is_empty() || self.input.chars().count() > MAX_AGENT_INPUT_CHARACTERS {
47 return Err(Error::InvalidInput(format!(
48 "agent input must contain 1 through {MAX_AGENT_INPUT_CHARACTERS} characters"
49 )));
50 }
51 if !matches!(
52 self.reasoning_effort.as_str(),
53 "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
54 ) {
55 return Err(Error::InvalidInput(
56 "reasoning effort is not supported".into(),
57 ));
58 }
59 if self.tools.len() > MAX_TOOLS {
60 return Err(Error::InvalidInput(format!(
61 "agent turn may expose at most {MAX_TOOLS} tools"
62 )));
63 }
64 for tool in &self.tools {
65 if tool.name.is_empty()
66 || tool.name.chars().count() > 100
67 || !tool
68 .name
69 .bytes()
70 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
71 {
72 return Err(Error::InvalidInput(
73 "agent tool name must be a safe non-empty identifier".into(),
74 ));
75 }
76 if tool.description.trim().is_empty()
77 || tool.description.chars().count() > 4_000
78 || !tool.input_schema.is_object()
79 {
80 return Err(Error::InvalidInput(
81 "agent tools require a bounded description and object JSON Schema".into(),
82 ));
83 }
84 }
85 Ok(())
86 }
87
88 pub(crate) fn payload(&self) -> Value {
89 let mut value = json!({
90 "model": self.model,
91 "input": self.input,
92 "store": false
93 });
94 if self.model.starts_with("gpt-5") || self.model.starts_with('o') {
95 value["reasoning"] = json!({"effort": self.reasoning_effort});
96 }
97 if !self.tools.is_empty() {
98 value["tools"] = json!(
99 self.tools
100 .iter()
101 .map(|tool| json!({
102 "type": "function",
103 "name": tool.name,
104 "description": tool.description,
105 "parameters": tool.input_schema,
106 "strict": false
107 }))
108 .collect::<Vec<_>>()
109 );
110 value["tool_choice"] = json!("auto");
111 value["parallel_tool_calls"] = json!(false);
112 }
113 value
114 }
115}
116
117#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct AgentToolCall {
120 pub call_id: String,
122 pub name: String,
124 pub arguments: Value,
126}
127
128#[derive(Clone, Debug, Default, Eq, PartialEq)]
130pub struct AgentTokenUsage {
131 pub input_tokens: u64,
133 pub output_tokens: u64,
135 pub cached_input_tokens: u64,
137 pub reasoning_output_tokens: u64,
139}
140
141#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct AgentTurnResponse {
144 pub model: String,
146 pub response_id: String,
148 pub text: String,
150 pub tool_call: Option<AgentToolCall>,
152 pub usage: Option<AgentTokenUsage>,
154 pub request_id: Option<String>,
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct ModelMetadata {
161 pub id: String,
163 pub context_window_tokens: Option<u64>,
165 pub max_input_tokens: Option<u64>,
167}
168
169pub(crate) fn validate_model(model: &str) -> Result<()> {
170 if model.trim().is_empty()
171 || model.chars().count() > MAX_MODEL_CHARACTERS
172 || !model.bytes().all(|byte| {
173 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'/')
174 })
175 {
176 return Err(Error::InvalidInput(
177 "model must be an exact safe OpenAI model identifier".into(),
178 ));
179 }
180 Ok(())
181}
182
183pub(crate) fn parse_agent_turn(
184 value: &Value,
185 requested_model: &str,
186 request_id: Option<String>,
187) -> Result<AgentTurnResponse> {
188 let mut text = Vec::new();
189 let mut tool_calls = Vec::new();
190 for item in value
191 .get("output")
192 .and_then(Value::as_array)
193 .into_iter()
194 .flatten()
195 {
196 match item.get("type").and_then(Value::as_str) {
197 Some("function_call") => tool_calls.push(parse_tool_call(item, tool_calls.len())?),
198 Some("message") => collect_text(item.get("content"), &mut text),
199 Some("output_text" | "text") => {
200 if let Some(fragment) = item.get("text").and_then(Value::as_str) {
201 text.push(fragment.to_owned());
202 }
203 }
204 _ => {}
205 }
206 }
207 if let Some(fragment) = value.get("output_text").and_then(Value::as_str) {
208 text.push(fragment.to_owned());
209 }
210 if tool_calls.len() > 1 {
211 return Err(Error::Protocol(
212 "OpenAI returned multiple function calls despite serial tool selection".into(),
213 ));
214 }
215 let response_id = value
216 .get("id")
217 .and_then(Value::as_str)
218 .filter(|value| !value.trim().is_empty())
219 .ok_or_else(|| Error::Protocol("agent response omitted its identifier".into()))?
220 .to_owned();
221 let usage = value
222 .get("usage")
223 .filter(|value| !value.is_null())
224 .map(|usage| AgentTokenUsage {
225 input_tokens: usage
226 .get("input_tokens")
227 .and_then(Value::as_u64)
228 .unwrap_or_default(),
229 output_tokens: usage
230 .get("output_tokens")
231 .and_then(Value::as_u64)
232 .unwrap_or_default(),
233 cached_input_tokens: usage
234 .pointer("/input_tokens_details/cached_tokens")
235 .and_then(Value::as_u64)
236 .unwrap_or_default(),
237 reasoning_output_tokens: usage
238 .pointer("/output_tokens_details/reasoning_tokens")
239 .and_then(Value::as_u64)
240 .unwrap_or_default(),
241 });
242 Ok(AgentTurnResponse {
243 model: value
244 .get("model")
245 .and_then(Value::as_str)
246 .unwrap_or(requested_model)
247 .to_owned(),
248 response_id,
249 text: text.join("\n\n"),
250 tool_call: tool_calls.pop(),
251 usage,
252 request_id,
253 })
254}
255
256pub(crate) fn parse_model_metadata(value: &Value, requested: &str) -> Result<ModelMetadata> {
257 let id = value
258 .get("id")
259 .and_then(Value::as_str)
260 .unwrap_or(requested)
261 .to_owned();
262 Ok(ModelMetadata {
263 id,
264 context_window_tokens: u64_field(
265 value,
266 &["context_window_tokens", "context_window", "contextWindow"],
267 ),
268 max_input_tokens: u64_field(
269 value,
270 &["max_input_tokens", "input_token_limit", "inputTokenLimit"],
271 ),
272 })
273}
274
275fn parse_tool_call(value: &Value, index: usize) -> Result<AgentToolCall> {
276 let arguments = match value.get("arguments") {
277 Some(Value::String(arguments)) => serde_json::from_str(arguments).map_err(|_| {
278 Error::Protocol("function call contained invalid JSON arguments".into())
279 })?,
280 Some(Value::Object(_)) => value["arguments"].clone(),
281 _ => {
282 return Err(Error::Protocol(
283 "function call omitted object arguments".into(),
284 ));
285 }
286 };
287 let name = value
288 .get("name")
289 .and_then(Value::as_str)
290 .filter(|name| !name.is_empty())
291 .ok_or_else(|| Error::Protocol("function call omitted its name".into()))?
292 .to_owned();
293 Ok(AgentToolCall {
294 call_id: value
295 .get("call_id")
296 .or_else(|| value.get("id"))
297 .and_then(Value::as_str)
298 .map(str::to_owned)
299 .unwrap_or_else(|| format!("openai-call-{index}")),
300 name,
301 arguments,
302 })
303}
304
305fn collect_text(value: Option<&Value>, output: &mut Vec<String>) {
306 for item in value.and_then(Value::as_array).into_iter().flatten() {
307 if matches!(
308 item.get("type").and_then(Value::as_str),
309 Some("output_text" | "text")
310 ) && let Some(text) = item.get("text").and_then(Value::as_str)
311 {
312 output.push(text.to_owned());
313 }
314 }
315}
316
317fn u64_field(value: &Value, fields: &[&str]) -> Option<u64> {
318 fields
319 .iter()
320 .find_map(|field| value.get(*field).and_then(Value::as_u64))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn request_disables_parallel_tools_and_response_normalizes_usage() {
329 let mut request = AgentTurnRequest::new("gpt-5.6", "Do the task.");
330 request.tools.push(AgentTool {
331 name: "call_ktool".into(),
332 description: "Call one tool.".into(),
333 input_schema: json!({"type": "object"}),
334 });
335 let payload = request.payload();
336 assert_eq!(payload["parallel_tool_calls"], false);
337 assert_eq!(payload["reasoning"]["effort"], "medium");
338
339 let parsed = parse_agent_turn(
340 &json!({
341 "id": "resp_1",
342 "model": "gpt-5.6-2026-07-01",
343 "output": [{
344 "type": "function_call",
345 "call_id": "call_1",
346 "name": "call_ktool",
347 "arguments": "{\"name\":\"Read\"}"
348 }],
349 "usage": {
350 "input_tokens": 10,
351 "output_tokens": 4,
352 "input_tokens_details": {"cached_tokens": 3},
353 "output_tokens_details": {"reasoning_tokens": 2}
354 }
355 }),
356 "gpt-5.6",
357 None,
358 )
359 .unwrap();
360 assert_eq!(parsed.response_id, "resp_1");
361 assert_eq!(parsed.tool_call.unwrap().arguments["name"], "Read");
362 assert_eq!(parsed.usage.unwrap().cached_input_tokens, 3);
363 }
364}