[Frontend] Support OpenAI Responses API namespace tools (#47024)

Signed-off-by: zhongjing123 <jimzhong5193@gmail.com> Co-authored-by: zhongjing123 <jimzhong5193@gmail.com>

Zhong committed Jul 6, 2026 at 14:21 UTC 98ba9b9583cc98dc49039a1353c6ad158f678d64
8 files changed +383 -22
tests/entrypoints/openai/responses/test_namespace_tool_separator.py new
+114
@@ -0,0 +1,114 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import json
5 +
6 +import openai # use the official client for correctness check
7 +import pytest
8 +
9 +MODEL_NAME = "Qwen/Qwen3-1.7B"
10 +NAMESPACE = "mcp__computer_use"
11 +TOOL_NAME = "get_app_state"
12 +FLAT_TOOL_NAME = f"{NAMESPACE}__{TOOL_NAME}"
13 +
14 +tools = [
15 + {
16 + "type": "namespace",
17 + "name": NAMESPACE,
18 + "description": "Computer control tools.",
19 + "tools": [
20 + {
21 + "type": "function",
22 + "name": TOOL_NAME,
23 + "description": "Get the current state of a desktop application.",
24 + "parameters": {
25 + "type": "object",
26 + "properties": {
27 + "app": {
28 + "type": "string",
29 + "description": "Application name, for example Chrome.",
30 + }
31 + },
32 + "required": ["app"],
33 + "additionalProperties": False,
34 + },
35 + }
36 + ],
37 + }
38 +]
39 +
40 +prompt = [
41 + {
42 + "role": "user",
43 + "content": "Use the computer app state tool to inspect Google Chrome.",
44 + },
45 +]
46 +
47 +
48 +def _assert_namespace_tool_call(tool_call) -> None:
49 + assert tool_call.type == "function_call"
50 + assert tool_call.name == TOOL_NAME
51 + assert tool_call.namespace == NAMESPACE
52 + assert tool_call.name != FLAT_TOOL_NAME
53 +
54 + args = json.loads(tool_call.arguments)
55 + assert args["app"]
56 +
57 +
58 +@pytest.mark.asyncio
59 +@pytest.mark.parametrize("model_name", [MODEL_NAME])
60 +async def test_namespace_tool_separator(client: openai.AsyncOpenAI, model_name: str):
61 + response = await client.responses.create(
62 + model=model_name,
63 + input=prompt,
64 + tools=tools,
65 + temperature=0.0,
66 + )
67 +
68 + assert len(response.output) >= 1
69 + tool_call = next(
70 + (out for out in response.output if out.type == "function_call"), None
71 + )
72 + assert tool_call is not None
73 + _assert_namespace_tool_call(tool_call)
74 +
75 +
76 +@pytest.mark.asyncio
77 +@pytest.mark.parametrize("model_name", [MODEL_NAME])
78 +async def test_namespace_tool_separator_streaming(
79 + client: openai.AsyncOpenAI, model_name: str
80 +):
81 + stream = await client.responses.create(
82 + model=model_name,
83 + input=prompt,
84 + tools=tools,
85 + temperature=0.0,
86 + stream=True,
87 + )
88 + events = [event async for event in stream]
89 +
90 + added_call = next(
91 + (
92 + event.item
93 + for event in events
94 + if event.type == "response.output_item.added"
95 + and getattr(event.item, "type", None) == "function_call"
96 + ),
97 + None,
98 + )
99 + done_call = next(
100 + (
101 + event.item
102 + for event in events
103 + if event.type == "response.output_item.done"
104 + and getattr(event.item, "type", None) == "function_call"
105 + ),
106 + None,
107 + )
108 +
109 + assert added_call is not None
110 + assert added_call.name == TOOL_NAME
111 + assert added_call.namespace == NAMESPACE
112 +
113 + assert done_call is not None
114 + _assert_namespace_tool_call(done_call)
tests/tool_parsers/test_glm47_moe_tool_parser.py
+66
@@ -7,12 +7,16 @@ import json
7 from unittest.mock import Mock
8
9 import pytest
10 +from openai.types.responses import ResponseFunctionToolCall
11
12 from vllm.entrypoints.openai.chat_completion.protocol import (
13 ChatCompletionRequest,
14 ChatCompletionToolsParam,
15 FunctionDefinition,
16 )
17 +from vllm.entrypoints.openai.engine.protocol import FunctionCall
18 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
19 +from vllm.entrypoints.openai.responses.utils import build_response_output_items
20 from vllm.tokenizers import get_tokenizer
21 from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
22
@@ -58,7 +62,69 @@ def mock_request(sample_tools) -> ChatCompletionRequest:
62 return request
63
64
65 +@pytest.fixture
66 +def namespace_tool_request() -> ResponsesRequest:
67 + return ResponsesRequest.model_validate(
68 + {
69 + "input": "hi",
70 + "tools": [
71 + {
72 + "type": "namespace",
73 + "name": "mcp__computer_use",
74 + "description": "Computer use tools.",
75 + "tools": [
76 + {
77 + "type": "function",
78 + "name": "get_app_state",
79 + "description": "Get app state.",
80 + "parameters": {
81 + "type": "object",
82 + "properties": {
83 + "app": {"type": "string"},
84 + },
85 + },
86 + }
87 + ],
88 + }
89 + ],
90 + }
91 + )
92 +
93 +
94 class TestGlm47ExtractToolCalls:
95 + def test_namespace_tool_call_round_trip_to_responses_output(
96 + self, glm47_tokenizer, namespace_tool_request
97 + ):
98 + parser = Glm47MoeModelToolParser(
99 + glm47_tokenizer, tools=namespace_tool_request.tools
100 + )
101 + out = (
102 + "<tool_call>mcp__computer_use__get_app_state"
103 + "<arg_key>app</arg_key>"
104 + "<arg_value>Google Chrome</arg_value>"
105 + "</tool_call>"
106 + )
107 +
108 + result = parser.extract_tool_calls(out, request=namespace_tool_request)
109 +
110 + assert result.tools_called
111 + tool_call = result.tool_calls[0].function
112 + assert tool_call == FunctionCall(
113 + name="mcp__computer_use__get_app_state",
114 + arguments='{"app": "Google Chrome"}',
115 + )
116 +
117 + output_items = build_response_output_items(
118 + reasoning=None,
119 + content=None,
120 + tool_calls=[tool_call],
121 + tools=namespace_tool_request.tools,
122 + )
123 + output_tool_call = output_items[0]
124 + assert isinstance(output_tool_call, ResponseFunctionToolCall)
125 + assert output_tool_call.name == "get_app_state"
126 + assert output_tool_call.namespace == "mcp__computer_use"
127 +
128 def test_no_tool_call(self, glm47_tool_parser, mock_request):
129 out = "This is a plain response."
130 r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
vllm/entrypoints/openai/responses/context.py
+1
@@ -349,6 +349,7 @@ class ParsableContext(ConversationContext):
349 reasoning=reasoning,
350 content=content,
351 tool_calls=tool_calls,
352 + tools=self.request.tools,
353 )
354 )
355 elif completion.text:
vllm/entrypoints/openai/responses/protocol.py
+13 -4
@@ -592,10 +592,19 @@ class ResponsesRequest(OpenAIBaseModel):
592 )
593 elif is_named_tool_choice and tools is not None:
594 tool_name = tool_choice.get("name")
595 - tool_names = {
596 - t.get("name") if isinstance(t, dict) else getattr(t, "name", None)
597 - for t in tools
598 - }
595 + tool_names = set()
596 + for tool in tools:
597 + if isinstance(tool, dict):
598 + if tool.get("type") == "namespace":
599 + namespace = tool.get("name")
600 + for namespaced_tool in tool.get("tools", []):
601 + namespaced_name = namespaced_tool.get("name")
602 + tool_names.add(namespaced_name)
603 + tool_names.add(f"{namespace}__{namespaced_name}")
604 + else:
605 + tool_names.add(tool.get("name"))
606 + else:
607 + tool_names.add(getattr(tool, "name", None))
608 if not tool_name or tool_name not in tool_names:
609 raise VLLMValidationError(
610 "Tool choice 'function' not found in 'tools' parameter.",
vllm/entrypoints/openai/responses/serving.py
+2 -1
@@ -1073,6 +1073,7 @@ class OpenAIServingResponses(GenerateBaseServing):
1073 content=content,
1074 tool_calls=tool_calls,
1075 logprobs=logprobs,
1076 + tools=request.tools,
1077 )
1078
1079 # Fallback when no parser is configured
@@ -1339,7 +1340,7 @@ class OpenAIServingResponses(GenerateBaseServing):
1340 [StreamingResponsesResponse], StreamingResponsesResponse
1341 ],
1342 ) -> AsyncGenerator[StreamingResponsesResponse, None]:
1342 - processor = SimpleStreamingEventProcessor()
1343 + processor = SimpleStreamingEventProcessor(tools=request.tools)
1344
1345 def _get_logprobs(
1346 output: CompletionOutput,
vllm/entrypoints/openai/responses/streaming_events.py
+25 -2
@@ -58,6 +58,7 @@ from openai.types.responses.response_output_item import McpCall
58 from openai.types.responses.response_reasoning_item import (
59 Content as ResponseReasoningTextContent,
60 )
61 +from openai.types.responses.tool import Tool
62 from openai_harmony import Message as HarmonyMessage
63
64 from vllm.entrypoints.mcp.tool_server import ToolServer
@@ -71,6 +72,10 @@ from vllm.entrypoints.openai.responses.protocol import (
72 ResponseReasoningPartDoneEvent,
73 StreamingResponsesResponse,
74 )
75 +from vllm.entrypoints.openai.responses.utils import (
76 + build_responses_tool_call_name_map,
77 + resolve_responses_tool_call_name,
78 +)
79 from vllm.outputs import CompletionOutput
80 from vllm.parser.harmony import Segment
81 from vllm.utils import random_uuid
@@ -815,6 +820,7 @@ class SimpleStreamingState:
820 accumulated_text: str = ""
821 tool_call_id: str = ""
822 tool_call_name: str = ""
823 + tool_call_namespace: str | None = None
824 tool_call_index: int | None = None
825 has_emitted_tool_call_delta: bool = False
826 current_state: _StateType = field(default_factory=lambda: _StateType.NONE)
@@ -1016,11 +1022,13 @@ def emit_simple_tool_call_open(
1022 state: SimpleStreamingState,
1023 name: str,
1024 index: int | None,
1025 + namespace: str | None = None,
1026 ) -> list[StreamingResponsesResponse]:
1027 state.current_state = _StateType.TOOL_CALL
1028 state.current_item_id = random_uuid()
1029 state.tool_call_id = f"call_{random_uuid()}"
1030 state.tool_call_name = name
1031 + state.tool_call_namespace = namespace
1032 state.tool_call_index = index
1033 state.accumulated_text = ""
1034 state.has_emitted_tool_call_delta = False
@@ -1034,6 +1042,7 @@ def emit_simple_tool_call_open(
1042 id=state.current_item_id,
1043 call_id=state.tool_call_id,
1044 name=name,
1045 + namespace=namespace,
1046 arguments="",
1047 status="in_progress",
1048 ),
@@ -1081,6 +1090,7 @@ def emit_simple_tool_call_done(
1090 item=ResponseFunctionToolCall(
1091 type="function_call",
1092 name=state.tool_call_name,
1093 + namespace=state.tool_call_namespace,
1094 arguments=state.accumulated_text,
1095 status="completed",
1096 id=state.current_item_id,
@@ -1089,6 +1099,7 @@ def emit_simple_tool_call_done(
1099 ),
1100 )
1101 state.output_index += 1
1102 + state.tool_call_namespace = None
1103 state.current_state = _StateType.NONE
1104 return events
1105
@@ -1166,8 +1177,13 @@ class SimpleStreamingEventProcessor:
1177 ),
1178 }
1179
1169 - def __init__(self, state: SimpleStreamingState | None = None) -> None:
1180 + def __init__(
1181 + self,
1182 + state: SimpleStreamingState | None = None,
1183 + tools: list[Tool] | None = None,
1184 + ) -> None:
1185 self.state = state or SimpleStreamingState()
1186 + self.tool_call_name_map = build_responses_tool_call_name_map(tools)
1187
1188 def resolve_target_state(
1189 self, delta_message: DeltaMessage
@@ -1224,8 +1240,15 @@ class SimpleStreamingEventProcessor:
1240 handlers = self._STATE_HANDLERS[target_state]
1241 if target_state == _StateType.TOOL_CALL:
1242 assert tool_call is not None
1243 + call_name = resolve_responses_tool_call_name(
1244 + tool_call.function.name,
1245 + tool_call_name_map=self.tool_call_name_map,
1246 + )
1247 return handlers.open_fn(
1228 - self.state, tool_call.function.name, tool_call.index
1248 + self.state,
1249 + call_name.name,
1250 + tool_call.index,
1251 + call_name.namespace,
1252 )
1253 return handlers.open_fn(self.state)
1254
vllm/entrypoints/openai/responses/utils.py
+30 -5
@@ -35,6 +35,12 @@ from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionMessa
35 from vllm.entrypoints.openai.engine.protocol import FunctionCall
36 from vllm.entrypoints.openai.responses.protocol import ResponseInputOutputItem
37 from vllm.logger import init_logger
38 +from vllm.tool_parsers.utils import (
39 + build_responses_tool_call_name_map,
40 + flat_namespace_tool_name,
41 + iter_response_function_tool_dicts,
42 + resolve_responses_tool_call_name,
43 +)
44 from vllm.utils import random_uuid
45
46 logger = init_logger(__name__)
@@ -45,8 +51,10 @@ def build_response_output_items(
51 content: str | None,
52 tool_calls: list[FunctionCall] | None,
53 logprobs: list[Logprob] | None = None,
54 + tools: list[Tool] | None = None,
55 ) -> list[ResponseOutputItem]:
56 outputs: list[ResponseOutputItem] = []
57 + tool_call_name_map = build_responses_tool_call_name_map(tools)
58
59 if reasoning:
60 outputs.append(
@@ -81,6 +89,9 @@ def build_response_output_items(
89
90 if tool_calls:
91 for idx, tool_call in enumerate(tool_calls):
92 + call_name = resolve_responses_tool_call_name(
93 + tool_call.name, tool_call_name_map=tool_call_name_map
94 + )
95 outputs.append(
96 ResponseFunctionToolCall(
97 id=f"fc_{random_uuid()}",
@@ -88,7 +99,8 @@ def build_response_output_items(
99 or make_tool_call_id(func_name=tool_call.name, idx=idx),
100 type="function_call",
101 status="completed",
91 - name=tool_call.name,
102 + name=call_name.name,
103 + namespace=call_name.namespace,
104 arguments=tool_call.arguments,
105 )
106 )
@@ -219,10 +231,13 @@ def _construct_message_from_response_item(
231 )
232
233 if isinstance(item, ResponseFunctionToolCall):
234 + tool_name = item.name
235 + if item.namespace:
236 + tool_name = flat_namespace_tool_name(item.namespace, item.name)
237 tool_call = ChatCompletionMessageToolCallParam(
238 id=item.call_id,
239 function=FunctionCallTool(
225 - name=item.name,
240 + name=tool_name,
241 arguments=item.arguments,
242 ),
243 type="function",
@@ -318,7 +333,17 @@ def _construct_message_from_response_item(
333
334
335 def extract_function_tool_names(tools: list[Tool]) -> frozenset[str]:
321 - return frozenset(tool.name for tool in tools if tool.type == "function")
336 + names = []
337 + for tool in tools:
338 + if tool.type == "function":
339 + names.append(tool.name)
340 + elif tool.type == "namespace":
341 + names.extend(
342 + flat_namespace_tool_name(tool.name, namespaced_tool.name)
343 + for namespaced_tool in tool.tools
344 + if namespaced_tool.type == "function"
345 + )
346 + return frozenset(names)
347
348
349 def extract_tool_types(tools: list[Tool]) -> set[str]:
@@ -358,7 +383,7 @@ def construct_tool_dicts(
383 tool_dicts = None
384 else:
385 tool_dicts = [
361 - convert_tool_responses_to_completions_format(tool.model_dump())
362 - for tool in tools
386 + convert_tool_responses_to_completions_format(tool)
387 + for tool in iter_response_function_tool_dicts(tools)
388 ]
389 return tool_dicts
vllm/tool_parsers/utils.py
+132 -10
@@ -5,12 +5,14 @@ import ast
5 import json
6 import math
7 import warnings
8 +from dataclasses import dataclass
9 from json import JSONDecodeError, JSONDecoder
10 from typing import Any, TypeAlias
11
12 import partial_json_parser
13 from openai.types.responses import (
14 FunctionTool,
15 + NamespaceTool,
16 ToolChoiceFunction,
17 )
18 from openai.types.responses.tool import Tool as ResponsesTool
@@ -166,6 +168,91 @@ def consume_space(i: int, s: str) -> int:
168 return i
169
170
171 +_NAMESPACE_TOOL_SEPARATOR = "__"
172 +
173 +
174 +@dataclass(frozen=True)
175 +class ResponsesToolCallName:
176 + name: str
177 + namespace: str | None = None
178 +
179 +
180 +def flat_namespace_tool_name(namespace: str, name: str) -> str:
181 + return f"{namespace}{_NAMESPACE_TOOL_SEPARATOR}{name}"
182 +
183 +
184 +def iter_response_function_tool_info(
185 + tool: ResponsesTool,
186 +) -> list[tuple[str, dict[str, Any] | None]]:
187 + if isinstance(tool, FunctionTool):
188 + return [(tool.name, tool.parameters)]
189 + if not isinstance(tool, NamespaceTool):
190 + return []
191 +
192 + namespace = tool.name
193 + return [
194 + (
195 + flat_namespace_tool_name(namespace, namespaced_tool.name),
196 + namespaced_tool.parameters,
197 + )
198 + for namespaced_tool in tool.tools
199 + if namespaced_tool.type == "function"
200 + ]
201 +
202 +
203 +def iter_response_function_tool_dicts(
204 + tools: list[ResponsesTool],
205 +) -> list[dict[str, Any]]:
206 + function_tools: list[dict[str, Any]] = []
207 + for tool in tools:
208 + if isinstance(tool, NamespaceTool):
209 + namespace = tool.name
210 + for namespaced_tool in tool.tools:
211 + if namespaced_tool.type != "function":
212 + continue
213 + tool_dict = namespaced_tool.model_dump()
214 + tool_dict["name"] = flat_namespace_tool_name(
215 + namespace, namespaced_tool.name
216 + )
217 + function_tools.append(tool_dict)
218 + else:
219 + function_tools.append(tool.model_dump())
220 + return function_tools
221 +
222 +
223 +def build_responses_tool_call_name_map(
224 + tools: list[ResponsesTool] | None,
225 +) -> dict[str, ResponsesToolCallName]:
226 + if not tools:
227 + return {}
228 +
229 + name_map: dict[str, ResponsesToolCallName] = {}
230 + for tool in tools:
231 + if not isinstance(tool, NamespaceTool):
232 + continue
233 + namespace = tool.name
234 + for namespaced_tool in tool.tools:
235 + if namespaced_tool.type != "function":
236 + continue
237 + flat_name = flat_namespace_tool_name(namespace, namespaced_tool.name)
238 + name_map[flat_name] = ResponsesToolCallName(
239 + name=namespaced_tool.name,
240 + namespace=namespace,
241 + )
242 + return name_map
243 +
244 +
245 +def resolve_responses_tool_call_name(
246 + name: str,
247 + tools: list[ResponsesTool] | None = None,
248 + tool_call_name_map: dict[str, ResponsesToolCallName] | None = None,
249 +) -> ResponsesToolCallName:
250 + name_map = tool_call_name_map
251 + if name_map is None:
252 + name_map = build_responses_tool_call_name_map(tools)
253 + return name_map.get(name, ResponsesToolCallName(name=name))
254 +
255 +
256 def _is_function_tool(tool: Tool) -> bool:
257 return isinstance(tool, (FunctionTool, ChatCompletionToolsParam))
258
@@ -189,6 +276,11 @@ def find_tool_properties(
276 if not tools:
277 return {}
278 for tool in tools:
279 + if isinstance(tool, (FunctionTool, NamespaceTool)):
280 + for name, params in iter_response_function_tool_info(tool):
281 + if name == tool_name:
282 + return (params or {}).get("properties", {})
283 + continue
284 if not _is_function_tool(tool):
285 continue
286 name, params = _extract_tool_info(tool)
@@ -205,6 +297,11 @@ def find_tool_name(
297 if not tools:
298 return False
299 for tool in tools:
300 + if isinstance(tool, (FunctionTool, NamespaceTool)):
301 + for name, _ in iter_response_function_tool_info(tool):
302 + if name == tool_name:
303 + return True
304 + continue
305 if not _is_function_tool(tool):
306 continue
307 name, _ = _extract_tool_info(tool)
@@ -213,8 +310,9 @@ def find_tool_name(
310 return False
311
312
216 -def _get_tool_schema_from_tool(tool: Tool) -> dict:
217 - name, params = _extract_tool_info(tool)
313 +def _get_tool_schema_from_name_and_params(
314 + name: str, params: dict[str, Any] | None
315 +) -> dict:
316 params = params if params else {"type": "object", "properties": {}}
317 return {
318 "properties": {
@@ -225,6 +323,11 @@ def _get_tool_schema_from_tool(tool: Tool) -> dict:
323 }
324
325
326 +def _get_tool_schema_from_tool(tool: Tool) -> dict:
327 + name, params = _extract_tool_info(tool)
328 + return _get_tool_schema_from_name_and_params(name, params)
329 +
330 +
331 def _get_tool_schema_defs(
332 tools: list[Tool],
333 ) -> dict:
@@ -247,13 +350,25 @@ def _get_tool_schema_defs(
350 def _get_json_schema_from_tools(
351 tools: list[Tool],
352 ) -> dict:
250 - fn_tools = [t for t in tools if _is_function_tool(t)]
353 + fn_tool_schemas: list[dict[str, Any]] = []
354 + fn_tools: list[Tool] = []
355 + for tool in tools:
356 + if isinstance(tool, (FunctionTool, NamespaceTool)):
357 + fn_tool_schemas.extend(
358 + _get_tool_schema_from_name_and_params(name, params)
359 + for name, params in iter_response_function_tool_info(tool)
360 + )
361 + if isinstance(tool, FunctionTool):
362 + fn_tools.append(tool)
363 + elif _is_function_tool(tool):
364 + fn_tool_schemas.append(_get_tool_schema_from_tool(tool))
365 + fn_tools.append(tool)
366 json_schema = {
367 "type": "array",
368 "minItems": 1,
369 "items": {
370 "type": "object",
256 - "anyOf": [_get_tool_schema_from_tool(tool) for tool in fn_tools],
371 + "anyOf": fn_tool_schemas,
372 },
373 }
374 json_schema_defs = _get_tool_schema_defs(fn_tools)
@@ -274,23 +389,30 @@ def get_json_schema_from_tools(
389 tool_choice, ToolChoiceFunction
390 ):
391 tool_name = tool_choice.name
277 - tool_map = {tool.name: tool for tool in tools if isinstance(tool, FunctionTool)}
278 - if tool_name not in tool_map:
392 + responses_tool_map: dict[str, dict[str, Any] | None] = {}
393 + for tool in tools:
394 + if not isinstance(tool, (FunctionTool, NamespaceTool)):
395 + continue
396 + for name, params in iter_response_function_tool_info(tool):
397 + responses_tool_map[name] = params
398 + if "__" in name:
399 + responses_tool_map.setdefault(name.rsplit("__", 1)[1], params)
400 + if tool_name not in responses_tool_map:
401 raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.")
280 - return tool_map[tool_name].parameters
402 + return responses_tool_map[tool_name]
403 # tool_choice: Forced Function (ChatCompletion)
404 if (not isinstance(tool_choice, str)) and isinstance(
405 tool_choice, ChatCompletionNamedToolChoiceParam
406 ):
407 tool_name = tool_choice.function.name
286 - tool_map = {
408 + chat_tool_map: dict[str, ChatCompletionToolsParam] = {
409 tool.function.name: tool
410 for tool in tools
411 if isinstance(tool, ChatCompletionToolsParam)
412 }
291 - if tool_name not in tool_map:
413 + if tool_name not in chat_tool_map:
414 raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.")
293 - return tool_map[tool_name].function.parameters
415 + return chat_tool_map[tool_name].function.parameters
416 # tool_choice: "required"
417 if tool_choice == "required":
418 return _get_json_schema_from_tools(tools)