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::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}