Skip to main content

kcode_openai_api/
agent.rs

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/// One function available to an OpenAI text turn.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct AgentTool {
12    /// Exact function name.
13    pub name: String,
14    /// Human-readable function description.
15    pub description: String,
16    /// JSON Schema object accepted by the function.
17    pub input_schema: Value,
18}
19
20/// One non-streaming OpenAI text/tool turn.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct AgentTurnRequest {
23    /// Exact OpenAI model identifier.
24    pub model: String,
25    /// Complete input for this stateless turn.
26    pub input: String,
27    /// OpenAI reasoning effort, such as `low`, `medium`, or `high`.
28    pub reasoning_effort: String,
29    /// Functions available to the model.
30    pub tools: Vec<AgentTool>,
31}
32
33impl AgentTurnRequest {
34    /// Constructs a tool-free turn with medium reasoning effort.
35    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/// One normalized function call returned by OpenAI.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct AgentToolCall {
120    /// Provider call identifier.
121    pub call_id: String,
122    /// Exact function name.
123    pub name: String,
124    /// Parsed JSON arguments.
125    pub arguments: Value,
126}
127
128/// Token usage for one OpenAI text/tool turn.
129#[derive(Clone, Debug, Default, Eq, PartialEq)]
130pub struct AgentTokenUsage {
131    /// Total input tokens.
132    pub input_tokens: u64,
133    /// Total output tokens.
134    pub output_tokens: u64,
135    /// Cached input tokens included in `input_tokens`.
136    pub cached_input_tokens: u64,
137    /// Reasoning tokens included in `output_tokens`.
138    pub reasoning_output_tokens: u64,
139}
140
141/// Normalized result of one non-streaming OpenAI text/tool turn.
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct AgentTurnResponse {
144    /// Actual model identifier returned by OpenAI.
145    pub model: String,
146    /// OpenAI response identifier.
147    pub response_id: String,
148    /// Ordered assistant text.
149    pub text: String,
150    /// At most one function call.
151    pub tool_call: Option<AgentToolCall>,
152    /// Provider token usage, when returned.
153    pub usage: Option<AgentTokenUsage>,
154    /// HTTP request identifier, when returned.
155    pub request_id: Option<String>,
156}
157
158/// Metadata advertised for one exact OpenAI model.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct ModelMetadata {
161    /// Exact model identifier.
162    pub id: String,
163    /// Advertised context window, when returned.
164    pub context_window_tokens: Option<u64>,
165    /// Advertised maximum input, when returned.
166    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}