Skip to main content

typesec_agent/interop/
openai.rs

1//! OpenAI Chat Completions dialect: `tool_calls` in, `role: "tool"` denial
2//! messages out. Works with the OpenAI SDKs and any OpenAI-compatible server.
3
4use serde_json::{Value, json};
5
6use super::call::{GuardedToolCall, InteropError, ToolCallRequest};
7use super::wire;
8
9/// Dialect name used in error messages and the Python bindings.
10pub const DIALECT: &str = "openai";
11
12/// Parse tool calls from any of the shapes the OpenAI API surfaces them in:
13/// a full chat completion (`choices[*].message.tool_calls`), an assistant
14/// message (`{"tool_calls": [...]}`), or the bare `tool_calls` array.
15pub fn parse_tool_calls(payload: &Value) -> Result<Vec<ToolCallRequest>, InteropError> {
16    if let Some(choices) = payload.get("choices").and_then(Value::as_array) {
17        let mut calls = Vec::new();
18        for choice in choices {
19            if let Some(items) = choice
20                .pointer("/message/tool_calls")
21                .and_then(Value::as_array)
22            {
23                for item in items {
24                    calls.push(parse_call(item)?);
25                }
26            }
27        }
28        return Ok(calls);
29    }
30    wire::call_array(payload, "tool_calls", DIALECT)?
31        .iter()
32        .map(parse_call)
33        .collect()
34}
35
36fn parse_call(item: &Value) -> Result<ToolCallRequest, InteropError> {
37    let function = item.get("function").ok_or_else(|| {
38        InteropError::malformed(DIALECT, "tool call entry has no 'function' object")
39    })?;
40    let name = wire::required_str(function, "name", DIALECT)?;
41    let arguments = wire::parse_arguments(function.get("arguments"), DIALECT, name)?;
42    let mut request = ToolCallRequest::new(name, arguments);
43    if let Some(id) = wire::optional_str(item, "id") {
44        request = request.with_call_id(id);
45    }
46    Ok(request)
47}
48
49/// Filter a request's `tools` array down to the definitions `keep` accepts.
50/// Handles both the Chat Completions shape (`{"type": "function",
51/// "function": {"name": ...}}`) and the Responses API shape
52/// (`{"type": "function", "name": ...}`). Unidentifiable entries are dropped.
53pub fn filter_tools(tools: &Value, keep: &dyn Fn(&str) -> bool) -> Value {
54    wire::filter_tool_array(
55        tools,
56        |item| {
57            item.pointer("/function/name")
58                .or_else(|| item.get("name"))
59                .and_then(Value::as_str)
60                .map(str::to_owned)
61        },
62        keep,
63    )
64}
65
66/// Render a denied/undecided call as the `role: "tool"` message the chat
67/// history expects, so the model receives the refusal as tool output.
68/// Returns `None` for allowed calls.
69pub fn denial(call: &GuardedToolCall) -> Option<Value> {
70    call.denial_message().map(|content| {
71        json!({
72            "role": "tool",
73            "tool_call_id": call.request.call_id.clone().unwrap_or_default(),
74            "content": content,
75        })
76    })
77}