kcode-openai-api 0.2.0

Typed OpenAI transcription, text/tool turns, model metadata, and image operations
Documentation
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;

/// One function available to an OpenAI text turn.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentTool {
    /// Exact function name.
    pub name: String,
    /// Human-readable function description.
    pub description: String,
    /// JSON Schema object accepted by the function.
    pub input_schema: Value,
}

/// One non-streaming OpenAI text/tool turn.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentTurnRequest {
    /// Exact OpenAI model identifier.
    pub model: String,
    /// Complete input for this stateless turn.
    pub input: String,
    /// OpenAI reasoning effort, such as `low`, `medium`, or `high`.
    pub reasoning_effort: String,
    /// Functions available to the model.
    pub tools: Vec<AgentTool>,
}

impl AgentTurnRequest {
    /// Constructs a tool-free turn with medium reasoning effort.
    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
    }
}

/// One normalized function call returned by OpenAI.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentToolCall {
    /// Provider call identifier.
    pub call_id: String,
    /// Exact function name.
    pub name: String,
    /// Parsed JSON arguments.
    pub arguments: Value,
}

/// Token usage for one OpenAI text/tool turn.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct AgentTokenUsage {
    /// Total input tokens.
    pub input_tokens: u64,
    /// Total output tokens.
    pub output_tokens: u64,
    /// Cached input tokens included in `input_tokens`.
    pub cached_input_tokens: u64,
    /// Reasoning tokens included in `output_tokens`.
    pub reasoning_output_tokens: u64,
}

/// Normalized result of one non-streaming OpenAI text/tool turn.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentTurnResponse {
    /// Actual model identifier returned by OpenAI.
    pub model: String,
    /// OpenAI response identifier.
    pub response_id: String,
    /// Ordered assistant text.
    pub text: String,
    /// At most one function call.
    pub tool_call: Option<AgentToolCall>,
    /// Provider token usage, when returned.
    pub usage: Option<AgentTokenUsage>,
    /// HTTP request identifier, when returned.
    pub request_id: Option<String>,
}

/// Metadata advertised for one exact OpenAI model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ModelMetadata {
    /// Exact model identifier.
    pub id: String,
    /// Advertised context window, when returned.
    pub context_window_tokens: Option<u64>,
    /// Advertised maximum input, when returned.
    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);
    }
}