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::ENTER_PLAN_MODE
107            | tools::EXIT_PLAN_MODE
108            | tools::TASK_TRACKER
109            | tools::PLAN_TASK_TRACKER
110            | tools::SPAWN_AGENT
111            | tools::SPAWN_BACKGROUND_SUBPROCESS
112            | tools::SEND_INPUT
113            | tools::WAIT_AGENT
114            | tools::RESUME_AGENT
115            | tools::CLOSE_AGENT
116    )
117}
118
119pub fn sanitize_openai_function_parameters(value: Value, strip_any_of: bool) -> Value {
120    sanitize_openai_schema_node(parse_tool_input_schema(&value), strip_any_of)
121}
122
123fn sanitize_openai_schema_node(value: Value, strip_any_of: bool) -> Value {
124    match value {
125        Value::Object(mut map) => {
126            map.remove("default");
127            map.remove("format");
128            map.remove("allOf");
129            map.remove("oneOf");
130            map.remove("if");
131            map.remove("then");
132            map.remove("else");
133
134            if let Some(properties) = map.get_mut("properties").and_then(Value::as_object_mut) {
135                for schema in properties.values_mut() {
136                    *schema =
137                        sanitize_openai_schema_node(parse_tool_input_schema(schema), strip_any_of);
138                }
139            }
140
141            if let Some(items) = map.get_mut("items") {
142                *items = sanitize_openai_schema_node(parse_tool_input_schema(items), strip_any_of);
143            }
144
145            if let Some(prefix_items) = map.get_mut("prefixItems") {
146                *prefix_items = match std::mem::take(prefix_items) {
147                    Value::Array(items) => Value::Array(
148                        items
149                            .into_iter()
150                            .map(|value| sanitize_openai_schema_node(value, strip_any_of))
151                            .collect(),
152                    ),
153                    other => {
154                        sanitize_openai_schema_node(parse_tool_input_schema(&other), strip_any_of)
155                    }
156                };
157            }
158
159            if let Some(additional_properties) = map.get_mut("additionalProperties") {
160                if matches!(additional_properties, Value::Bool(true)) {
161                    *additional_properties = json!({ "type": "string" });
162                } else if !matches!(additional_properties, Value::Bool(_)) {
163                    *additional_properties = sanitize_openai_schema_node(
164                        parse_tool_input_schema(additional_properties),
165                        strip_any_of,
166                    );
167                }
168            }
169
170            if let Some(any_of) = map.get_mut("anyOf") {
171                let sanitized_any_of = match std::mem::take(any_of) {
172                    Value::Array(items) => items
173                        .into_iter()
174                        .map(|value| sanitize_openai_schema_node(value, strip_any_of))
175                        .collect::<Vec<_>>(),
176                    other => vec![sanitize_openai_schema_node(other, strip_any_of)],
177                };
178
179                if let Some(fallback_type) = fallback_type_from_any_of(&sanitized_any_of) {
180                    map.insert("type".to_string(), json!(fallback_type));
181                    map.remove("anyOf");
182                } else if strip_any_of || any_of_is_constraint_only(&sanitized_any_of) {
183                    map.remove("anyOf");
184                } else {
185                    map.insert("anyOf".to_string(), Value::Array(sanitized_any_of));
186                }
187            }
188
189            if map.get("type").and_then(Value::as_str) == Some("object") {
190                map.entry("properties".to_string())
191                    .or_insert_with(|| json!({}));
192            }
193
194            Value::Object(map)
195        }
196        Value::Array(items) => Value::Array(
197            items
198                .into_iter()
199                .map(|value| sanitize_openai_schema_node(value, strip_any_of))
200                .collect(),
201        ),
202        other => other,
203    }
204}
205
206fn fallback_type_from_any_of(variants: &[Value]) -> Option<&'static str> {
207    variants.iter().find_map(|variant| {
208        variant
209            .get("type")
210            .and_then(Value::as_str)
211            .filter(|schema_type| *schema_type == "string")
212            .map(|_| "string")
213    })
214}
215
216fn any_of_is_constraint_only(variants: &[Value]) -> bool {
217    variants.iter().all(|variant| {
218        let Some(map) = variant.as_object() else {
219            return false;
220        };
221
222        !map.contains_key("type")
223            && !map.contains_key("properties")
224            && !map.contains_key("items")
225            && !map.contains_key("prefixItems")
226            && !map.contains_key("additionalProperties")
227            && !map.contains_key("enum")
228            && !map.contains_key("const")
229    })
230}
231
232fn trim_non_empty_owned(value: &str) -> Option<String> {
233    let trimmed = value.trim();
234    (!trimmed.is_empty()).then(|| trimmed.to_string())
235}
236
237fn serialize_hosted_skill_version(version: &OpenAIHostedSkillVersion) -> Option<Value> {
238    match version {
239        OpenAIHostedSkillVersion::Latest(_) => None,
240        OpenAIHostedSkillVersion::Number(value) => Some(json!(value)),
241        OpenAIHostedSkillVersion::String(value) => {
242            let version = trim_non_empty_owned(value)?;
243            (!version.eq_ignore_ascii_case("latest")).then_some(Value::String(version))
244        }
245    }
246}
247
248fn serialize_hosted_skill(skill: &OpenAIHostedSkill) -> Option<Value> {
249    match skill {
250        OpenAIHostedSkill::SkillReference { skill_id, version } => {
251            let skill_id = trim_non_empty_owned(skill_id)?;
252            let mut payload = serde_json::Map::from_iter([
253                ("type".to_string(), json!("skill_reference")),
254                ("skill_id".to_string(), json!(skill_id)),
255            ]);
256
257            if let Some(version) = serialize_hosted_skill_version(version) {
258                payload.insert("version".to_string(), version);
259            }
260
261            Some(Value::Object(payload))
262        }
263        OpenAIHostedSkill::Inline { bundle_b64, sha256 } => {
264            let bundle_b64 = trim_non_empty_owned(bundle_b64)?;
265            let mut payload = serde_json::Map::from_iter([
266                ("type".to_string(), json!("inline")),
267                ("bundle_b64".to_string(), json!(bundle_b64)),
268            ]);
269            if let Some(sha256) = sha256.as_deref().and_then(trim_non_empty_owned) {
270                payload.insert("sha256".to_string(), json!(sha256));
271            }
272            Some(Value::Object(payload))
273        }
274    }
275}
276
277fn serialize_hosted_shell_domain_secret(secret: &OpenAIHostedShellDomainSecret) -> Option<Value> {
278    let domain = trim_non_empty_owned(&secret.domain)?;
279    let name = trim_non_empty_owned(&secret.name)?;
280    let value = trim_non_empty_owned(&secret.value)?;
281
282    Some(json!({
283        "domain": domain,
284        "name": name,
285        "value": value,
286    }))
287}
288
289fn serialize_openai_hosted_shell_network_policy(
290    policy: &OpenAIHostedShellNetworkPolicy,
291) -> Option<Value> {
292    match policy.policy_type {
293        OpenAIHostedShellNetworkPolicyType::Disabled => Some(json!({ "type": "disabled" })),
294        OpenAIHostedShellNetworkPolicyType::Allowlist => {
295            let allowed_domains: Vec<String> = policy
296                .allowed_domains
297                .iter()
298                .filter_map(|value| trim_non_empty_owned(value))
299                .collect();
300            if allowed_domains.is_empty() {
301                return None;
302            }
303
304            let mut payload = serde_json::Map::from_iter([
305                ("type".to_string(), json!("allowlist")),
306                ("allowed_domains".to_string(), json!(allowed_domains)),
307            ]);
308
309            let domain_secrets: Vec<Value> = policy
310                .domain_secrets
311                .iter()
312                .filter_map(serialize_hosted_shell_domain_secret)
313                .collect();
314            if !domain_secrets.is_empty() {
315                payload.insert("domain_secrets".to_string(), Value::Array(domain_secrets));
316            }
317
318            Some(Value::Object(payload))
319        }
320    }
321}
322
323fn serialize_openai_hosted_shell(config: &OpenAIHostedShellConfig) -> Option<Value> {
324    if !config.enabled {
325        return None;
326    }
327
328    let mut environment = serde_json::Map::new();
329    environment.insert("type".to_string(), json!(config.environment.as_str()));
330
331    match config.environment {
332        OpenAIHostedShellEnvironment::ContainerAuto => {
333            if let Some(network_policy) =
334                serialize_openai_hosted_shell_network_policy(&config.network_policy)
335            {
336                environment.insert("network_policy".to_string(), network_policy);
337            }
338
339            let file_ids: Vec<String> = config
340                .file_ids
341                .iter()
342                .filter_map(|value| trim_non_empty_owned(value))
343                .collect();
344            if !file_ids.is_empty() {
345                environment.insert("file_ids".to_string(), json!(file_ids));
346            }
347
348            let skills: Vec<Value> = config
349                .skills
350                .iter()
351                .filter_map(serialize_hosted_skill)
352                .collect();
353            if !skills.is_empty() {
354                environment.insert("skills".to_string(), Value::Array(skills));
355            }
356        }
357        OpenAIHostedShellEnvironment::ContainerReference => {
358            let container_id = config
359                .container_id
360                .as_deref()
361                .and_then(trim_non_empty_owned)?;
362            environment.insert("container_id".to_string(), json!(container_id));
363        }
364    }
365
366    Some(json!({
367        "type": "shell",
368        "environment": Value::Object(environment),
369    }))
370}
371
372pub fn serialize_tools(tools: &[provider::ToolDefinition], model: &str) -> Option<Value> {
373    if tools.is_empty() {
374        return None;
375    }
376
377    let mut seen_names = HashSet::new();
378    let serialized_tools = tools
379        .iter()
380        .filter_map(|tool| {
381            let canonical_name = tool
382                .function
383                .as_ref()
384                .map(|f| f.name.as_str())
385                .unwrap_or(tool.tool_type.as_str());
386            if !seen_names.insert(canonical_name.to_string()) {
387                return None;
388            }
389
390            let serialized = match tool.tool_type.as_str() {
391                "function" => {
392                    let func = tool.function.as_ref()?;
393                    let name = &func.name;
394                    let description = &func.description;
395                    let parameters = sanitize_openai_function_parameters(
396                        func.parameters.clone(),
397                        should_strip_any_of_for_builtin_tool(name),
398                    );
399                    let mut value = json!({
400                        "type": &tool.tool_type,
401                        "name": name,
402                        "description": description,
403                        "parameters": parameters,
404                        "function": {
405                            "name": name,
406                            "description": description,
407                            "parameters": parameters,
408                        }
409                    });
410                    if tool.defer_loading == Some(true)
411                        && let Some(obj) = value.as_object_mut()
412                    {
413                        obj.insert("defer_loading".to_string(), json!(true));
414                    }
415                    value
416                }
417                tools::APPLY_PATCH | tools::SHELL | "custom" | "grammar" => {
418                    if is_gpt5_or_newer(model) {
419                        json!(tool)
420                    } else if let Some(func) = &tool.function {
421                        let parameters = sanitize_openai_function_parameters(
422                            func.parameters.clone(),
423                            should_strip_any_of_for_builtin_tool(&func.name),
424                        );
425                        json!({
426                            "type": "function",
427                            "function": {
428                                "name": func.name,
429                                "description": func.description,
430                                "parameters": parameters
431                            }
432                        })
433                    } else {
434                        return None;
435                    }
436                }
437                "tool_search" => json!({ "type": "tool_search" }),
438                _ => json!(tool),
439            };
440
441            Some(serialized)
442        })
443        .collect::<Vec<Value>>();
444
445    Some(Value::Array(serialized_tools))
446}
447
448pub fn serialize_tools_for_responses(
449    tools: &[provider::ToolDefinition],
450    hosted_shell: Option<&OpenAIHostedShellConfig>,
451) -> Option<Value> {
452    if tools.is_empty() {
453        return None;
454    }
455
456    let mut seen_names = HashSet::new();
457    let serialized_tools = tools
458        .iter()
459        .filter_map(|tool| {
460            let serialized = match tool.tool_type.as_str() {
461                "function" => {
462                    let func = tool.function.as_ref()?;
463                    if func.name == tools::SHELL {
464                        hosted_shell
465                            .and_then(serialize_openai_hosted_shell)
466                            .or_else(|| {
467                                Some(serialize_responses_function_tool(
468                                    func,
469                                    tool.defer_loading == Some(true),
470                                ))
471                            })
472                    } else {
473                        Some(serialize_responses_function_tool(
474                            func,
475                            tool.defer_loading == Some(true),
476                        ))
477                    }
478                }
479                tools::APPLY_PATCH => {
480                    if let Some(func) = tool.function.as_ref() {
481                        Some(serialize_responses_function_tool(func, false))
482                    } else {
483                        Some(json!({
484                            "type": "function",
485                            "name": tools::APPLY_PATCH,
486                            "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 (---/+++)"),
487                            "parameters": crate::tools::apply_patch::parameter_schema("Patch in VT Code format")
488                        }))
489                    }
490                }
491                tools::SHELL => hosted_shell.and_then(serialize_openai_hosted_shell),
492                "custom" => tool.function.as_ref().map(|func| {
493                    json!({
494                        "type": "custom",
495                        "name": &func.name,
496                        "description": &func.description,
497                        "format": func.parameters.get("format")
498                    })
499                }),
500                "grammar" => tool.grammar.as_ref().map(|grammar| {
501                    json!({
502                        "type": "custom",
503                        "name": "apply_patch_grammar",
504                        "description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool.",
505                        "format": {
506                            "type": "grammar",
507                            "syntax": &grammar.syntax,
508                            "definition": &grammar.definition
509                        }
510                    })
511                }),
512                "tool_search" => Some(json!({ "type": "tool_search" })),
513                "web_search" => serialize_responses_hosted_tool("web_search", tool.web_search.as_ref()),
514                "file_search" | "mcp" => serialize_responses_hosted_tool(
515                    tool.tool_type.as_str(),
516                    tool.hosted_tool_config.as_ref(),
517                ),
518                _ => tool
519                    .function
520                    .as_ref()
521                    .map(|func| serialize_responses_function_tool(func, false)),
522            }?;
523
524            if !seen_names.insert(responses_dedupe_key(&serialized)) {
525                return None;
526            }
527
528            Some(serialized)
529        })
530        .collect::<Vec<Value>>();
531
532    Some(Value::Array(serialized_tools))
533}
534
535fn is_gpt5_or_newer(model: &str) -> bool {
536    let normalized = model.to_lowercase();
537    normalized.contains("gpt-5")
538        || normalized.contains("gpt5")
539        || normalized.contains("o1")
540        || normalized.contains("o3")
541        || normalized.contains("o4")
542}