vtcode_core/llm/providers/openai/
tool_serialization.rs1use 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}