Skip to main content

vtcode_core/llm/providers/openai/
tool_serialization.rs

1//! Tool serialization helpers for OpenAI payloads.
2//!
3//! Keeps tool JSON shaping isolated from the main provider logic.
4
5use crate::config::constants::tools;
6use crate::config::core::{
7    OpenAIHostedShellConfig, OpenAIHostedShellDomainSecret, OpenAIHostedShellEnvironment,
8    OpenAIHostedShellNetworkPolicy, OpenAIHostedShellNetworkPolicyType, OpenAIHostedSkill,
9    OpenAIHostedSkillVersion,
10};
11use crate::llm::provider;
12use hashbrown::HashSet;
13use serde_json::{Value, json};
14use vtcode_utility_tool_specs::parse_tool_input_schema;
15
16fn responses_dedupe_key(serialized_tool: &Value) -> String {
17    if let Some(name) = serialized_tool.get("name").and_then(Value::as_str) {
18        return format!("name:{name}");
19    }
20
21    serialized_tool.to_string()
22}
23
24fn serialize_responses_hosted_tool(tool_type: &str, config: Option<&Value>) -> Option<Value> {
25    let mut payload = serde_json::Map::new();
26    payload.insert("type".to_string(), json!(tool_type));
27
28    match config {
29        Some(Value::Object(config_map)) => {
30            payload.extend(config_map.clone());
31        }
32        Some(_) | None => return None,
33    }
34
35    Some(Value::Object(payload))
36}
37
38fn serialize_responses_function_tool(
39    func: &provider::FunctionDefinition,
40    defer_loading: bool,
41) -> Value {
42    let mut value = json!({
43        "type": "function",
44        "name": &func.name,
45        "description": &func.description,
46        "parameters": sanitize_openai_function_parameters(
47            func.parameters.clone(),
48            should_strip_any_of_for_builtin_tool(&func.name),
49        )
50    });
51    if defer_loading && let Some(obj) = value.as_object_mut() {
52        obj.insert("defer_loading".to_string(), json!(true));
53    }
54    value
55}
56
57fn should_strip_any_of_for_builtin_tool(tool_name: &str) -> bool {
58    matches!(
59        tool_name,
60        tools::UNIFIED_SEARCH
61            | tools::UNIFIED_EXEC
62            | tools::UNIFIED_FILE
63            | tools::THINK
64            | tools::SEARCH_TOOLS
65            | tools::WEB_SEARCH
66            | tools::WEB_FETCH
67            | tools::FETCH_URL
68            | tools::LIST
69            | tools::GREP
70            | tools::FETCH
71            | tools::EXEC_PTY_CMD
72            | tools::SHELL
73            | tools::GREP_FILE
74            | tools::LIST_FILES
75            | tools::LIST_SKILLS
76            | tools::LOAD_SKILL
77            | tools::LOAD_SKILL_RESOURCE
78            | tools::EXEC_COMMAND
79            | tools::WRITE_STDIN
80            | tools::RUN_PTY_CMD
81            | tools::CREATE_PTY_SESSION
82            | tools::LIST_PTY_SESSIONS
83            | tools::CLOSE_PTY_SESSION
84            | tools::SEND_PTY_INPUT
85            | tools::READ_PTY_SESSION
86            | tools::RESIZE_PTY_SESSION
87            | tools::EXECUTE_CODE
88            | tools::READ_FILE
89            | tools::WRITE_FILE
90            | tools::EDIT_FILE
91            | tools::DELETE_FILE
92            | tools::CREATE_FILE
93            | tools::APPLY_PATCH
94            | tools::SEARCH_REPLACE
95            | tools::FILE_OP
96            | tools::MOVE_FILE
97            | tools::COPY_FILE
98            | tools::GET_ERRORS
99            | tools::REQUEST_USER_INPUT
100            | tools::MEMORY
101            | tools::ASK_QUESTIONS
102            | tools::ASK_USER_QUESTION
103            | tools::CRON_CREATE
104            | tools::CRON_LIST
105            | tools::CRON_DELETE
106            | tools::START_PLANNING
107            | tools::FINISH_PLANNING
108            | tools::TASK_TRACKER
109            | tools::SPAWN_AGENT
110            | tools::SPAWN_BACKGROUND_SUBPROCESS
111            | tools::SEND_INPUT
112            | tools::WAIT_AGENT
113            | tools::RESUME_AGENT
114            | tools::CLOSE_AGENT
115    )
116}
117
118pub fn sanitize_openai_function_parameters(value: Value, strip_any_of: bool) -> Value {
119    sanitize_openai_schema_node(parse_tool_input_schema(&value), strip_any_of)
120}
121
122fn sanitize_openai_schema_node(value: Value, strip_any_of: bool) -> Value {
123    match value {
124        Value::Object(mut map) => {
125            map.remove("default");
126            map.remove("format");
127            map.remove("allOf");
128            map.remove("oneOf");
129            map.remove("if");
130            map.remove("then");
131            map.remove("else");
132
133            if let Some(properties) = map.get_mut("properties").and_then(Value::as_object_mut) {
134                for schema in properties.values_mut() {
135                    *schema =
136                        sanitize_openai_schema_node(parse_tool_input_schema(schema), strip_any_of);
137                }
138            }
139
140            if let Some(items) = map.get_mut("items") {
141                *items = sanitize_openai_schema_node(parse_tool_input_schema(items), strip_any_of);
142            }
143
144            if let Some(prefix_items) = map.get_mut("prefixItems") {
145                *prefix_items = match std::mem::take(prefix_items) {
146                    Value::Array(items) => Value::Array(
147                        items
148                            .into_iter()
149                            .map(|value| sanitize_openai_schema_node(value, strip_any_of))
150                            .collect(),
151                    ),
152                    other => {
153                        sanitize_openai_schema_node(parse_tool_input_schema(&other), strip_any_of)
154                    }
155                };
156            }
157
158            if let Some(additional_properties) = map.get_mut("additionalProperties") {
159                if matches!(additional_properties, Value::Bool(true)) {
160                    *additional_properties = json!({ "type": "string" });
161                } else if !matches!(additional_properties, Value::Bool(_)) {
162                    *additional_properties = sanitize_openai_schema_node(
163                        parse_tool_input_schema(additional_properties),
164                        strip_any_of,
165                    );
166                }
167            }
168
169            if let Some(any_of) = map.get_mut("anyOf") {
170                let sanitized_any_of = match std::mem::take(any_of) {
171                    Value::Array(items) => items
172                        .into_iter()
173                        .map(|value| sanitize_openai_schema_node(value, strip_any_of))
174                        .collect::<Vec<_>>(),
175                    other => vec![sanitize_openai_schema_node(other, strip_any_of)],
176                };
177
178                if let Some(fallback_type) = fallback_type_from_any_of(&sanitized_any_of) {
179                    map.insert("type".to_string(), json!(fallback_type));
180                    map.remove("anyOf");
181                } else if strip_any_of || any_of_is_constraint_only(&sanitized_any_of) {
182                    map.remove("anyOf");
183                } else {
184                    map.insert("anyOf".to_string(), Value::Array(sanitized_any_of));
185                }
186            }
187
188            if map.get("type").and_then(Value::as_str) == Some("object") {
189                map.entry("properties".to_string())
190                    .or_insert_with(|| json!({}));
191            }
192
193            Value::Object(map)
194        }
195        Value::Array(items) => Value::Array(
196            items
197                .into_iter()
198                .map(|value| sanitize_openai_schema_node(value, strip_any_of))
199                .collect(),
200        ),
201        other => other,
202    }
203}
204
205fn fallback_type_from_any_of(variants: &[Value]) -> Option<&'static str> {
206    variants.iter().find_map(|variant| {
207        variant
208            .get("type")
209            .and_then(Value::as_str)
210            .filter(|schema_type| *schema_type == "string")
211            .map(|_| "string")
212    })
213}
214
215fn any_of_is_constraint_only(variants: &[Value]) -> bool {
216    variants.iter().all(|variant| {
217        let Some(map) = variant.as_object() else {
218            return false;
219        };
220
221        !map.contains_key("type")
222            && !map.contains_key("properties")
223            && !map.contains_key("items")
224            && !map.contains_key("prefixItems")
225            && !map.contains_key("additionalProperties")
226            && !map.contains_key("enum")
227            && !map.contains_key("const")
228    })
229}
230
231fn trim_non_empty_owned(value: &str) -> Option<String> {
232    let trimmed = value.trim();
233    (!trimmed.is_empty()).then(|| trimmed.to_string())
234}
235
236fn serialize_hosted_skill_version(version: &OpenAIHostedSkillVersion) -> Option<Value> {
237    match version {
238        OpenAIHostedSkillVersion::Latest(_) => None,
239        OpenAIHostedSkillVersion::Number(value) => Some(json!(value)),
240        OpenAIHostedSkillVersion::String(value) => {
241            let version = trim_non_empty_owned(value)?;
242            (!version.eq_ignore_ascii_case("latest")).then_some(Value::String(version))
243        }
244    }
245}
246
247fn serialize_hosted_skill(skill: &OpenAIHostedSkill) -> Option<Value> {
248    match skill {
249        OpenAIHostedSkill::SkillReference { skill_id, version } => {
250            let skill_id = trim_non_empty_owned(skill_id)?;
251            let mut payload = serde_json::Map::from_iter([
252                ("type".to_string(), json!("skill_reference")),
253                ("skill_id".to_string(), json!(skill_id)),
254            ]);
255
256            if let Some(version) = serialize_hosted_skill_version(version) {
257                payload.insert("version".to_string(), version);
258            }
259
260            Some(Value::Object(payload))
261        }
262        OpenAIHostedSkill::Inline { bundle_b64, sha256 } => {
263            let bundle_b64 = trim_non_empty_owned(bundle_b64)?;
264            let mut payload = serde_json::Map::from_iter([
265                ("type".to_string(), json!("inline")),
266                ("bundle_b64".to_string(), json!(bundle_b64)),
267            ]);
268            if let Some(sha256) = sha256.as_deref().and_then(trim_non_empty_owned) {
269                payload.insert("sha256".to_string(), json!(sha256));
270            }
271            Some(Value::Object(payload))
272        }
273    }
274}
275
276fn serialize_hosted_shell_domain_secret(secret: &OpenAIHostedShellDomainSecret) -> Option<Value> {
277    let domain = trim_non_empty_owned(&secret.domain)?;
278    let name = trim_non_empty_owned(&secret.name)?;
279    let value = trim_non_empty_owned(&secret.value)?;
280
281    Some(json!({
282        "domain": domain,
283        "name": name,
284        "value": value,
285    }))
286}
287
288fn serialize_openai_hosted_shell_network_policy(
289    policy: &OpenAIHostedShellNetworkPolicy,
290) -> Option<Value> {
291    match policy.policy_type {
292        OpenAIHostedShellNetworkPolicyType::Disabled => Some(json!({ "type": "disabled" })),
293        OpenAIHostedShellNetworkPolicyType::Allowlist => {
294            let allowed_domains: Vec<String> = policy
295                .allowed_domains
296                .iter()
297                .filter_map(|value| trim_non_empty_owned(value))
298                .collect();
299            if allowed_domains.is_empty() {
300                return None;
301            }
302
303            let mut payload = serde_json::Map::from_iter([
304                ("type".to_string(), json!("allowlist")),
305                ("allowed_domains".to_string(), json!(allowed_domains)),
306            ]);
307
308            let domain_secrets: Vec<Value> = policy
309                .domain_secrets
310                .iter()
311                .filter_map(serialize_hosted_shell_domain_secret)
312                .collect();
313            if !domain_secrets.is_empty() {
314                payload.insert("domain_secrets".to_string(), Value::Array(domain_secrets));
315            }
316
317            Some(Value::Object(payload))
318        }
319    }
320}
321
322fn serialize_openai_hosted_shell(config: &OpenAIHostedShellConfig) -> Option<Value> {
323    if !config.enabled {
324        return None;
325    }
326
327    let mut environment = serde_json::Map::new();
328    environment.insert("type".to_string(), json!(config.environment.as_str()));
329
330    match config.environment {
331        OpenAIHostedShellEnvironment::ContainerAuto => {
332            if let Some(network_policy) =
333                serialize_openai_hosted_shell_network_policy(&config.network_policy)
334            {
335                environment.insert("network_policy".to_string(), network_policy);
336            }
337
338            let file_ids: Vec<String> = config
339                .file_ids
340                .iter()
341                .filter_map(|value| trim_non_empty_owned(value))
342                .collect();
343            if !file_ids.is_empty() {
344                environment.insert("file_ids".to_string(), json!(file_ids));
345            }
346
347            let skills: Vec<Value> = config
348                .skills
349                .iter()
350                .filter_map(serialize_hosted_skill)
351                .collect();
352            if !skills.is_empty() {
353                environment.insert("skills".to_string(), Value::Array(skills));
354            }
355        }
356        OpenAIHostedShellEnvironment::ContainerReference => {
357            let container_id = config
358                .container_id
359                .as_deref()
360                .and_then(trim_non_empty_owned)?;
361            environment.insert("container_id".to_string(), json!(container_id));
362        }
363    }
364
365    Some(json!({
366        "type": "shell",
367        "environment": Value::Object(environment),
368    }))
369}
370
371pub fn serialize_tools(tools: &[provider::ToolDefinition], model: &str) -> Option<Value> {
372    if tools.is_empty() {
373        return None;
374    }
375
376    let mut seen_names = HashSet::new();
377    let serialized_tools = tools
378        .iter()
379        .filter_map(|tool| {
380            let canonical_name = tool
381                .function
382                .as_ref()
383                .map(|f| f.name.as_str())
384                .unwrap_or(tool.tool_type.as_str());
385            if !seen_names.insert(canonical_name.to_string()) {
386                return None;
387            }
388
389            let serialized = match tool.tool_type.as_str() {
390                "function" => {
391                    let func = tool.function.as_ref()?;
392                    let name = &func.name;
393                    let description = &func.description;
394                    let parameters = sanitize_openai_function_parameters(
395                        func.parameters.clone(),
396                        should_strip_any_of_for_builtin_tool(name),
397                    );
398                    let mut value = json!({
399                        "type": &tool.tool_type,
400                        "name": name,
401                        "description": description,
402                        "parameters": parameters,
403                        "function": {
404                            "name": name,
405                            "description": description,
406                            "parameters": parameters,
407                        }
408                    });
409                    if tool.defer_loading == Some(true)
410                        && let Some(obj) = value.as_object_mut()
411                    {
412                        obj.insert("defer_loading".to_string(), json!(true));
413                    }
414                    value
415                }
416                tools::APPLY_PATCH | tools::SHELL | "custom" | "grammar" => {
417                    if is_gpt5_or_newer(model) {
418                        json!(tool)
419                    } else if let Some(func) = &tool.function {
420                        let parameters = sanitize_openai_function_parameters(
421                            func.parameters.clone(),
422                            should_strip_any_of_for_builtin_tool(&func.name),
423                        );
424                        json!({
425                            "type": "function",
426                            "function": {
427                                "name": func.name,
428                                "description": func.description,
429                                "parameters": parameters
430                            }
431                        })
432                    } else {
433                        return None;
434                    }
435                }
436                "tool_search" => json!({ "type": "tool_search" }),
437                _ => json!(tool),
438            };
439
440            Some(serialized)
441        })
442        .collect::<Vec<Value>>();
443
444    Some(Value::Array(serialized_tools))
445}
446
447pub fn serialize_tools_for_responses(
448    tools: &[provider::ToolDefinition],
449    hosted_shell: Option<&OpenAIHostedShellConfig>,
450) -> Option<Value> {
451    if tools.is_empty() {
452        return None;
453    }
454
455    let mut seen_names = HashSet::new();
456    let serialized_tools = tools
457        .iter()
458        .filter_map(|tool| {
459            let serialized = match tool.tool_type.as_str() {
460                "function" => {
461                    let func = tool.function.as_ref()?;
462                    if func.name == tools::SHELL {
463                        hosted_shell
464                            .and_then(serialize_openai_hosted_shell)
465                            .or_else(|| {
466                                Some(serialize_responses_function_tool(
467                                    func,
468                                    tool.defer_loading == Some(true),
469                                ))
470                            })
471                    } else {
472                        Some(serialize_responses_function_tool(
473                            func,
474                            tool.defer_loading == Some(true),
475                        ))
476                    }
477                }
478                tools::APPLY_PATCH => {
479                    if let Some(func) = tool.function.as_ref() {
480                        Some(serialize_responses_function_tool(func, false))
481                    } else {
482                        Some(json!({
483                            "type": "function",
484                            "name": tools::APPLY_PATCH,
485                            "description": crate::tools::apply_patch::with_semantic_anchor_guidance("Apply VT Code patches. Use format: *** Begin Patch, *** Update File: path, @@ context, -/+ lines, *** End Patch. Do NOT use unified diff (---/+++)"),
486                            "parameters": crate::tools::apply_patch::parameter_schema("Patch in VT Code format")
487                        }))
488                    }
489                }
490                tools::SHELL => hosted_shell.and_then(serialize_openai_hosted_shell),
491                "custom" => tool.function.as_ref().map(|func| {
492                    json!({
493                        "type": "custom",
494                        "name": &func.name,
495                        "description": &func.description,
496                        "format": func.parameters.get("format")
497                    })
498                }),
499                "grammar" => tool.grammar.as_ref().map(|grammar| {
500                    json!({
501                        "type": "custom",
502                        "name": "apply_patch_grammar",
503                        "description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool.",
504                        "format": {
505                            "type": "grammar",
506                            "syntax": &grammar.syntax,
507                            "definition": &grammar.definition
508                        }
509                    })
510                }),
511                "tool_search" => Some(json!({ "type": "tool_search" })),
512                "web_search" => serialize_responses_hosted_tool("web_search", tool.web_search.as_ref()),
513                "file_search" | "mcp" => serialize_responses_hosted_tool(
514                    tool.tool_type.as_str(),
515                    tool.hosted_tool_config.as_ref(),
516                ),
517                _ => tool
518                    .function
519                    .as_ref()
520                    .map(|func| serialize_responses_function_tool(func, false)),
521            }?;
522
523            if !seen_names.insert(responses_dedupe_key(&serialized)) {
524                return None;
525            }
526
527            Some(serialized)
528        })
529        .collect::<Vec<Value>>();
530
531    Some(Value::Array(serialized_tools))
532}
533
534fn is_gpt5_or_newer(model: &str) -> bool {
535    let normalized = model.to_lowercase();
536    normalized.contains("gpt-5")
537        || normalized.contains("gpt5")
538        || normalized.contains("o1")
539        || normalized.contains("o3")
540        || normalized.contains("o4")
541}