diff --git a/tests/entrypoints/openai/responses/test_protocol.py b/tests/entrypoints/openai/responses/test_protocol.py index db5d7d6924..04b0799252 100644 --- a/tests/entrypoints/openai/responses/test_protocol.py +++ b/tests/entrypoints/openai/responses/test_protocol.py @@ -1,13 +1,17 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import pytest from openai_harmony import ( Message, ) +from pydantic import ValidationError from vllm.entrypoints.openai.responses.protocol import ( + ResponsesRequest, serialize_message, serialize_messages, ) +from vllm.exceptions import VLLMValidationError def test_serialize_message() -> None: @@ -37,3 +41,34 @@ def test_serialize_messages() -> None: } msg = Message.from_dict(msg_value) assert serialize_messages([msg, dict_value]) == [msg_value, dict_value] + + +def test_custom_tool_grammar_format_rejected() -> None: + grammar = {"type": "grammar", "syntax": "lark", "definition": 'start: "pwd"'} + with pytest.raises(VLLMValidationError) as exc_info: + ResponsesRequest( + input="hi", tools=[{"type": "custom", "name": "emit", "format": grammar}] + ) + assert exc_info.value.parameter == "tools" + + request = ResponsesRequest( + input="hi", + tools=[{"type": "custom", "name": "emit", "format": {"type": "text"}}], + ) + assert request.tools is not None and request.tools[0].type == "custom" + + +@pytest.mark.parametrize( + "tools", + [ + "abc", + {"type": "custom"}, + [None], + [{"type": "custom", "name": "x", "format": "text"}], + ], +) +def test_malformed_tools_rejected_by_field_validation(tools) -> None: + """Malformed tools bodies fail pydantic field validation (4xx), not an + AttributeError inside the custom-format validator (500).""" + with pytest.raises(ValidationError): + ResponsesRequest(input="hi", tools=tools) diff --git a/tests/entrypoints/openai/responses/test_responses_utils.py b/tests/entrypoints/openai/responses/test_responses_utils.py index 967ddacda6..6789ddc08b 100644 --- a/tests/entrypoints/openai/responses/test_responses_utils.py +++ b/tests/entrypoints/openai/responses/test_responses_utils.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from openai.types.responses import CustomTool, FunctionTool, ResponseCustomToolCall from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall from openai.types.responses.response_function_tool_call_output_item import ( ResponseFunctionToolCallOutputItem, @@ -16,10 +17,14 @@ from openai.types.responses.response_reasoning_item import ( Summary, ) +from vllm.entrypoints.generate.base.protocol import FunctionCall from vllm.entrypoints.openai.responses.utils import ( _construct_message_from_response_item, + build_response_output_items, construct_chat_messages_with_tool_call, construct_input_messages, + decode_custom_tool_input, + decode_custom_tool_input_prefix, should_continue_final_message, ) from vllm.exceptions import VLLMValidationError @@ -248,6 +253,89 @@ class TestResponsesUtils: assert formatted_item["content"] == "dongyi" +class TestCustomTools: + def test_custom_tool_call_output_item(self): + tools = [ + CustomTool(type="custom", name="emit_command", format={"type": "text"}), + FunctionTool( + type="function", name="get_weather", parameters={}, strict=None + ), + ] + custom, function = build_response_output_items( + reasoning=None, + content=None, + tool_calls=[ + FunctionCall( + id="call_1", name="emit_command", arguments='{"input": "pwd"}' + ), + FunctionCall( + id="call_2", name="get_weather", arguments='{"city": "Paris"}' + ), + ], + tools=tools, + ) + assert isinstance(custom, ResponseCustomToolCall) + assert (custom.call_id, custom.input) == ("call_1", "pwd") + assert custom.id.startswith("ctc_") + assert isinstance(function, ResponseFunctionToolCall) + assert function.arguments == '{"city": "Paris"}' + + def test_custom_tool_history_replays_through_function_shim(self): + messages = construct_chat_messages_with_tool_call( + [ + make_reasoning_item(content_text="Run pwd."), + ResponseCustomToolCall( + type="custom_tool_call", + id="ctc_1", + call_id="call_exec", + name="emit_command", + input='echo "hi"', + ), + { + "type": "custom_tool_call_output", + "call_id": "call_exec", + "output": "hi", + }, + {"role": "user", "content": "Continue"}, + ] + ) + assert messages == [ + { + "role": "assistant", + "reasoning": "Run pwd.", + "tool_calls": [ + { + "id": "call_exec", + "type": "function", + "function": { + "name": "emit_command", + "arguments": '{"input": "echo \\"hi\\""}', + }, + } + ], + }, + {"role": "tool", "content": "hi", "tool_call_id": "call_exec"}, + {"role": "user", "content": "Continue"}, + ] + + def test_decode_custom_tool_input(self): + assert decode_custom_tool_input('{"input": "pwd"}') == "pwd" + assert decode_custom_tool_input('{"cmd": "pwd"}') == '{"cmd": "pwd"}' + assert decode_custom_tool_input('{"file": "a.py"}') == '{"file": "a.py"}' + assert decode_custom_tool_input('{"input": 7}') == '{"input": 7}' + assert decode_custom_tool_input("pwd") == "pwd" + + def test_decode_custom_tool_input_prefix(self): + assert decode_custom_tool_input_prefix('{"inp') == "" + assert decode_custom_tool_input_prefix('{"input": "ls \\"dir') == 'ls "dir' + assert decode_custom_tool_input_prefix('{"input": "a\\') == "a" + assert decode_custom_tool_input_prefix('{"input": "\\ud83d') == "" + assert ( + decode_custom_tool_input_prefix('{"input": "\\ud83d\\ude00!"}') + == "\U0001f600!" + ) + + class TestReasoningItemContentPriority: """Tests that content is prioritized over summary for reasoning items.""" diff --git a/tests/entrypoints/openai/responses/test_streaming_events.py b/tests/entrypoints/openai/responses/test_streaming_events.py index 21fe5537bb..a37109f851 100644 --- a/tests/entrypoints/openai/responses/test_streaming_events.py +++ b/tests/entrypoints/openai/responses/test_streaming_events.py @@ -2,7 +2,12 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import pytest -from openai.types.responses import ResponseFunctionWebSearch, ResponseStreamEvent +from openai.types.responses import ( + CustomTool, + ResponseCustomToolCall, + ResponseFunctionWebSearch, + ResponseStreamEvent, +) from openai_harmony import Message, Role from pydantic import TypeAdapter @@ -20,6 +25,7 @@ from vllm.entrypoints.openai.responses.streaming_events import ( emit_reasoning_done_events, split_delta, ) +from vllm.entrypoints.openai.responses.utils import decode_custom_tool_input def test_browser_find_uses_responses_action_type(): @@ -190,3 +196,111 @@ class TestProcessorCompoundDeltas: types = [e.type for e in events] assert "response.reasoning_text.delta" in types assert "response.output_text.delta" in types + + +class TestSimpleToolCallShapes: + def test_arguments_done_emitted_without_deltas(self): + processor = SimpleStreamingEventProcessor() + events = _run_through_processor( + processor, DeltaMessage(tool_calls=[_make_tool_call(0, name="noop")]) + ) + events += processor.close_current() + assert [e.type for e in events] == [ + "response.output_item.added", + "response.function_call_arguments.done", + "response.output_item.done", + ] + + +class TestCustomToolStreaming: + def test_custom_tool_input_events(self): + processor = SimpleStreamingEventProcessor( + tools=[CustomTool(type="custom", name="emit_command")] + ) + events = _run_through_processor( + processor, + DeltaMessage( + tool_calls=[ + _make_tool_call(0, name="emit_command", arguments='{"input": "ls ') + ] + ), + ) + events += _run_through_processor( + processor, + DeltaMessage(tool_calls=[_make_tool_call(0, arguments='-la \\"x\\""}')]), + ) + events += processor.close_current() + + assert [e.type for e in events] == [ + "response.output_item.added", + "response.custom_tool_call_input.delta", + "response.custom_tool_call_input.delta", + "response.custom_tool_call_input.done", + "response.output_item.done", + ] + done = events[-1].item + assert isinstance(done, ResponseCustomToolCall) + assert done.input == 'ls -la "x"' + assert "".join(e.delta for e in events[1:3]) == done.input + assert events[3].input == done.input + assert events[0].item.type == "custom_tool_call" + assert events[0].item.id == done.id + assert done.id.startswith("ctc_") + assert events[0].item.call_id == done.call_id + + def test_unrelated_keys_are_not_streamed_as_payload(self): + """Review repro: chunks of an object whose input key arrives last must + not leak fragments of other keys into the payload.""" + processor = SimpleStreamingEventProcessor( + tools=[CustomTool(type="custom", name="emit_command")] + ) + events = _run_through_processor( + processor, + DeltaMessage( + tool_calls=[ + _make_tool_call(0, name="emit_command", arguments='{"a": "xy"') + ] + ), + ) + events += _run_through_processor( + processor, + DeltaMessage( + tool_calls=[_make_tool_call(0, arguments=', "input": "hello"}')] + ), + ) + events += processor.close_current() + + deltas = [ + e for e in events if e.type == "response.custom_tool_call_input.delta" + ] + done_input = next( + e for e in events if e.type == "response.custom_tool_call_input.done" + ).input + item = events[-1].item + assert "".join(e.delta for e in deltas) == "hello" # "xy" never leaks + assert done_input == "hello" + assert item.input == "hello" + assert decode_custom_tool_input('{"a": "xy", "input": "hello"}') == "hello" + + def test_done_reported_authoritative_payload_on_mismatch(self): + """If the final decode is not an extension of what was streamed, the + done event and item carry the authoritative value and a correcting + delta is emitted rather than dropping the divergence.""" + processor = SimpleStreamingEventProcessor( + tools=[CustomTool(type="custom", name="emit_command")] + ) + processor.state.current_state = _StateType.TOOL_CALL + processor.state.tool_call_is_custom = True + processor.state.tool_call_payload = "xy" # stale fragment already streamed + processor.state.accumulated_text = '{"a": "xy", "input": "hello"}' + + events = processor.close_current() + delta = next( + e for e in events if e.type == "response.custom_tool_call_input.delta" + ) + done_input = next( + e for e in events if e.type == "response.custom_tool_call_input.done" + ).input + assert delta.delta == "hello" # full authoritative resend, not a dropped suffix + assert done_input == "hello" + assert events[-1].item.input == "hello" diff --git a/tests/tool_parsers/test_gemma4_tool_parser.py b/tests/tool_parsers/test_gemma4_tool_parser.py index 97ed0cd69d..43fac97405 100644 --- a/tests/tool_parsers/test_gemma4_tool_parser.py +++ b/tests/tool_parsers/test_gemma4_tool_parser.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock import pytest from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.parser.gemma4 import ( TOOL_CALL_END, TOOL_CALL_START, @@ -117,6 +118,21 @@ def mock_request(): return request +def test_named_custom_tool_choice_skips_structured_outputs(mock_tokenizer): + # Responses custom tools ride the function shim, so a named custom choice + # must route like a named function choice, not into guided JSON. + tools = [{"type": "custom", "name": "emit_command"}] + request = ResponsesRequest( + input="hi", + tools=tools, + tool_choice={"type": "custom", "name": "emit_command"}, + ) + Gemma4EngineToolParser(mock_tokenizer, tools=tools).adjust_request(request) + + assert request.skip_special_tokens is False + assert request.structured_outputs is None + + # --------------------------------------------------------------------------- # Unit tests for _parse_gemma4_args (shared parser logic) # --------------------------------------------------------------------------- diff --git a/tests/tool_parsers/test_k2_horizon_tool_parser.py b/tests/tool_parsers/test_k2_horizon_tool_parser.py index 6fd4844aeb..b57b50553e 100644 --- a/tests/tool_parsers/test_k2_horizon_tool_parser.py +++ b/tests/tool_parsers/test_k2_horizon_tool_parser.py @@ -8,6 +8,7 @@ import pytest from vllm.entrypoints.generate.base.protocol import DeltaMessage from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.parser.parser_manager import ParserManager from vllm.tool_parsers import ToolParserManager from vllm.tool_parsers.k2_horizon_tool_parser import K2HorizonToolParser @@ -316,3 +317,14 @@ def test_whitespace_only_surrounding_content_is_not_preserved(tokenizer): def test_tool_parser_registered(): assert ToolParserManager.get_tool_parser("k2_horizon") is K2HorizonToolParser assert K2HorizonToolParser.supports_required_and_named is False + + +def test_named_tool_resolves_custom_choice(): + # Custom tools ride the function shim; a ToolChoiceCustom must resolve to + # its name like ToolChoiceFunction does. + request = ResponsesRequest( + input="hi", + tools=[{"type": "custom", "name": "emit_command"}], + tool_choice={"type": "custom", "name": "emit_command"}, + ) + assert K2HorizonToolParser._named_tool(request) == "emit_command" diff --git a/tests/tool_parsers/test_kimi_k3_named_tool_choice.py b/tests/tool_parsers/test_kimi_k3_named_tool_choice.py index 36b04b67b1..d426f623da 100644 --- a/tests/tool_parsers/test_kimi_k3_named_tool_choice.py +++ b/tests/tool_parsers/test_kimi_k3_named_tool_choice.py @@ -8,6 +8,7 @@ import pytest from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ) +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.exceptions import VLLMValidationError from vllm.sampling_params import StructuredOutputsParams @@ -60,3 +61,26 @@ def test_named_choice_allowed_with_structural_tag(): def test_named_choice_rejected_without_structural_tag(): with pytest.raises(VLLMValidationError): _parser().adjust_request(_request(with_tag=False)) + + +def _custom_request(with_tag: bool) -> ResponsesRequest: + req = ResponsesRequest( + input="hi", + tools=[{"type": "custom", "name": "emit_command"}], + tool_choice={"type": "custom", "name": "emit_command"}, + ) + if with_tag: + req.structured_outputs = StructuredOutputsParams( + structural_tag='{"type": "structural_tag", "format": {}}' + ) + return req + + +def test_named_custom_choice_allowed_with_structural_tag(): + req = _parser().adjust_request(_custom_request(with_tag=True)) + assert req.skip_special_tokens is False + + +def test_named_custom_choice_rejected_without_structural_tag(): + with pytest.raises(VLLMValidationError): + _parser().adjust_request(_custom_request(with_tag=False)) diff --git a/tests/tool_parsers/test_poolside_v1_tool_parser.py b/tests/tool_parsers/test_poolside_v1_tool_parser.py index de6e751e5c..635081b43b 100644 --- a/tests/tool_parsers/test_poolside_v1_tool_parser.py +++ b/tests/tool_parsers/test_poolside_v1_tool_parser.py @@ -78,12 +78,15 @@ def _build_chat_request(*, tool_choice: str | dict[str, Any]) -> ChatCompletionR def _build_responses_request( - *, tool_choice: str | dict[str, Any], include: list[str] | None = None + *, + tool_choice: str | dict[str, Any], + include: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, ) -> ResponsesRequest: return ResponsesRequest( model="poolside-test", input=[{"role": "user", "content": "write the file"}], - tools=[_responses_write_file_tool()], + tools=tools if tools is not None else [_responses_write_file_tool()], tool_choice=tool_choice, stream=True, max_output_tokens=200, @@ -145,6 +148,19 @@ def test_named_skips_structured_outputs_responses() -> None: assert request.skip_special_tokens is False +def test_named_custom_tool_choice_skips_structured_outputs_responses() -> None: + # A named custom choice parses to ToolChoiceCustom; custom tools ride the + # function shim, so it must route like ToolChoiceFunction. + request = _build_responses_request( + tools=[{"type": "custom", "name": "emit_command"}], + tool_choice={"type": "custom", "name": "emit_command"}, + ) + PoolsideV1ToolParser(_StubTokenizer()).adjust_request(request) + + assert request.text is None + assert request.skip_special_tokens is False + + def test_auto_still_keeps_special_tokens() -> None: request = _build_chat_request(tool_choice="auto") _make_parser(request).adjust_request(request) diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py index 817c48a440..52d0d82469 100644 --- a/tests/tool_parsers/test_structural_tag_registry.py +++ b/tests/tool_parsers/test_structural_tag_registry.py @@ -7,7 +7,7 @@ from typing import Any from unittest.mock import MagicMock import pytest -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import CustomTool, ToolChoiceFunction from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed from xgrammar import Grammar, StructuralTag from xgrammar import get_model_structural_tag as get_xgrammar_builtin_structural_tag @@ -787,6 +787,19 @@ def test_get_model_structural_tag_supports_named_tool_choice( assert isinstance(tag, StructuralTag) +def test_glm_4_7_forced_custom_tool_uses_function_shim(): + tools = [CustomTool(type="custom", name="emit_command", description="Emit.")] + tag = get_model_structural_tag( + model="glm_4_7", + tools=tools, + tool_choice="required", + reasoning=False, + ) + + assert isinstance(tag, StructuralTag) + assert "emit_command" in tag.model_dump_json() + + @pytest.mark.parametrize( ("parser_cls", "model"), [ diff --git a/tests/tool_use/test_muse_glimmer.py b/tests/tool_use/test_muse_glimmer.py index 5badd9b031..2fbf3cff49 100644 --- a/tests/tool_use/test_muse_glimmer.py +++ b/tests/tool_use/test_muse_glimmer.py @@ -29,6 +29,7 @@ from typing import cast import pytest from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.reasoning.muse_glimmer_reasoning_parser import MuseGlimmerReasoningParser from vllm.tool_parsers.muse_glimmer_tool_parser import MuseGlimmerToolParser @@ -400,3 +401,15 @@ def test_exact_match_kept(): T, _call("get_weather"), _req("get_weather") ) assert out.tool_calls[0].function.name == "get_weather" + + +def test_named_custom_tool_choice_skips_structured_outputs(): + # Custom tools ride the function shim; a named custom choice must skip + # the base adjust_request like a named function choice does. + request = ResponsesRequest( + input="hi", + tools=[{"type": "custom", "name": "emit_command"}], + tool_choice={"type": "custom", "name": "emit_command"}, + ) + T.adjust_request(request) + assert request.structured_outputs is None diff --git a/tests/tool_use/test_tool_choice_required.py b/tests/tool_use/test_tool_choice_required.py index be58ffa83a..aedada111a 100644 --- a/tests/tool_use/test_tool_choice_required.py +++ b/tests/tool_use/test_tool_choice_required.py @@ -6,7 +6,9 @@ from copy import deepcopy import pytest import regex as re from openai.types.responses import ( + CustomTool, FunctionTool, + ToolChoiceCustom, ToolChoiceFunction, WebSearchTool, ) @@ -21,8 +23,10 @@ from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.exceptions import VLLMValidationError from vllm.tool_parsers.streaming import extract_required_tool_call_streaming from vllm.tool_parsers.utils import ( + custom_tool_parameters, find_tool_properties, get_json_schema_from_tools, + iter_response_function_tool_dicts, ) pytestmark = pytest.mark.cpu_test @@ -483,6 +487,37 @@ class TestNonFunctionToolsSkipped: get_json_schema_from_tools(tools=[WEB_SEARCH_TOOL], tool_choice="required") +class TestCustomToolShim: + """``custom`` tools are shown to the model as a single-string function.""" + + def test_rendered_as_single_string_function(self): + tool = CustomTool( + type="custom", name="emit_command", description="Emit a command." + ) + [tool_dict] = iter_response_function_tool_dicts([tool]) + assert tool_dict["type"] == "function" + assert tool_dict["name"] == "emit_command" + assert tool_dict["description"] == "Emit a command." + assert tool_dict["parameters"] == custom_tool_parameters() + assert tool_dict["parameters"]["required"] == ["input"] + + def test_forced_tool_choice_sees_custom_tool(self): + tools = [CustomTool(type="custom", name="emit_command"), FUNCTION_TOOL] + schema = get_json_schema_from_tools(tools=tools, tool_choice="required") + assert isinstance(schema, dict) + names = [e["properties"]["name"]["enum"] for e in schema["items"]["anyOf"]] + assert names == [["emit_command"], ["get_weather"]] + named = get_json_schema_from_tools( + tools=tools, + tool_choice=ToolChoiceCustom(type="custom", name="emit_command"), + ) + assert named == custom_tool_parameters() + assert ( + find_tool_properties(tools, "emit_command") + == custom_tool_parameters()["properties"] + ) + + class TestMalformedToolSchemaDefs: """Malformed `$defs` in caller-supplied tool parameters is a 400, not a 500.""" diff --git a/vllm/entrypoints/openai/responses/protocol.py b/vllm/entrypoints/openai/responses/protocol.py index 373cb35df0..200054a4ef 100644 --- a/vllm/entrypoints/openai/responses/protocol.py +++ b/vllm/entrypoints/openai/responses/protocol.py @@ -14,6 +14,8 @@ from openai.types.responses import ( ResponseCodeInterpreterCallInterpretingEvent, ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, + ResponseCustomToolCallInputDeltaEvent, + ResponseCustomToolCallInputDoneEvent, ResponseFunctionToolCall, ResponseInputItemParam, ResponseMcpCallArgumentsDeltaEvent, @@ -699,6 +701,34 @@ class ResponsesRequest(OpenAIBaseModel): data["input"] = processed_input return data + @model_validator(mode="before") + @classmethod + def check_custom_tool_format(cls, data): + if not isinstance(data, dict): + return data + tools = data.get("tools") + if not isinstance(tools, list): + return data + for tool in tools: + if isinstance(tool, dict): + tool_dict = tool + elif hasattr(tool, "model_dump"): + tool_dict = tool.model_dump() + else: + continue + tool_format = tool_dict.get("format") + if ( + tool_dict.get("type") == "custom" + and isinstance(tool_format, dict) + and tool_format.get("type") == "grammar" + ): + raise VLLMValidationError( + "Custom tools with a grammar format are not supported; " + "only free-form text custom tools are accepted.", + parameter="tools", + ) + return data + @model_validator(mode="before") @classmethod def check_tool_usage(cls, data): @@ -708,9 +738,9 @@ class ResponsesRequest(OpenAIBaseModel): tools = data.get("tools") tool_choice = data.get("tool_choice", "auto") has_tools = tools is not None and len(tools) > 0 - is_named_tool_choice = ( - isinstance(tool_choice, dict) and tool_choice.get("type") == "function" - ) + is_named_tool_choice = isinstance(tool_choice, dict) and tool_choice.get( + "type" + ) in ("function", "custom") if not has_tools: if tool_choice in ("auto", "none"): @@ -913,6 +943,8 @@ StreamingResponsesResponse: TypeAlias = ( | ResponseOutputItemDoneEvent | ResponseContentPartAddedEvent | ResponseContentPartDoneEvent + | ResponseCustomToolCallInputDeltaEvent + | ResponseCustomToolCallInputDoneEvent | ResponseReasoningTextDeltaEvent | ResponseReasoningTextDoneEvent | ResponseCodeInterpreterCallInProgressEvent diff --git a/vllm/entrypoints/openai/responses/streaming_events.py b/vllm/entrypoints/openai/responses/streaming_events.py index b101040a57..49107661a7 100644 --- a/vllm/entrypoints/openai/responses/streaming_events.py +++ b/vllm/entrypoints/openai/responses/streaming_events.py @@ -29,6 +29,9 @@ from openai.types.responses import ( ResponseCodeInterpreterToolCallParam, ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, + ResponseCustomToolCall, + ResponseCustomToolCallInputDeltaEvent, + ResponseCustomToolCallInputDoneEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, @@ -73,6 +76,9 @@ from vllm.entrypoints.openai.responses.protocol import ( ) from vllm.entrypoints.openai.responses.utils import ( build_responses_tool_call_name_map, + custom_tool_names, + decode_custom_tool_input, + decode_custom_tool_input_prefix, resolve_responses_tool_call_name, ) from vllm.outputs import CompletionOutput @@ -821,6 +827,8 @@ class SimpleStreamingState: tool_call_name: str = "" tool_call_namespace: str | None = None tool_call_index: int | None = None + tool_call_is_custom: bool = False + tool_call_payload: str = "" current_state: _StateType = field(default_factory=lambda: _StateType.NONE) @@ -1039,38 +1047,80 @@ def emit_simple_tool_call_open( name: str, index: int | None, namespace: str | None = None, + is_custom: bool = False, call_id: str | None = None, ) -> list[StreamingResponsesResponse]: state.current_state = _StateType.TOOL_CALL - state.current_item_id = f"fc_{random_uuid()}" + state.current_item_id = ( + f"ctc_{random_uuid()}" if is_custom else f"fc_{random_uuid()}" + ) state.tool_call_id = call_id or make_tool_call_id() state.tool_call_name = name state.tool_call_namespace = namespace state.tool_call_index = index + state.tool_call_is_custom = is_custom + state.tool_call_payload = "" state.accumulated_text = "" + item: ResponseOutputItem + if is_custom: + item = ResponseCustomToolCall( + type="custom_tool_call", + id=state.current_item_id, + call_id=state.tool_call_id, + name=name, + input="", + ) + else: + item = ResponseFunctionToolCallItem( + type="function_call", + id=state.current_item_id, + call_id=state.tool_call_id, + name=name, + namespace=namespace, + arguments="", + status="in_progress", + ) return [ ResponseOutputItemAddedEvent( type="response.output_item.added", sequence_number=-1, output_index=state.output_index, - item=ResponseFunctionToolCallItem( - type="function_call", - id=state.current_item_id, - call_id=state.tool_call_id, - name=name, - namespace=namespace, - arguments="", - status="in_progress", - ), + item=item, ), ] +def _emit_custom_tool_input_delta( + state: SimpleStreamingState, + payload: str, +) -> list[StreamingResponsesResponse]: + streamed = state.tool_call_payload + if payload == streamed: + return [] + state.tool_call_payload = payload + # A payload that is not an extension of what was streamed cannot be + # retracted, so the authoritative value is resent in full. + delta = payload[len(streamed) :] if payload.startswith(streamed) else payload + return [ + ResponseCustomToolCallInputDeltaEvent( + type="response.custom_tool_call_input.delta", + sequence_number=-1, + output_index=state.output_index, + item_id=state.current_item_id, + delta=delta, + ) + ] + + def emit_simple_tool_call_delta( state: SimpleStreamingState, delta: str, ) -> list[StreamingResponsesResponse]: state.accumulated_text += delta + if state.tool_call_is_custom: + return _emit_custom_tool_input_delta( + state, decode_custom_tool_input_prefix(state.accumulated_text) + ) return [ ResponseFunctionCallArgumentsDeltaEvent( type="response.function_call_arguments.delta", @@ -1085,32 +1135,58 @@ def emit_simple_tool_call_delta( def emit_simple_tool_call_done( state: SimpleStreamingState, ) -> list[StreamingResponsesResponse]: - events: list[StreamingResponsesResponse] = [ - ResponseFunctionCallArgumentsDoneEvent( - type="response.function_call_arguments.done", - sequence_number=-1, - output_index=state.output_index, - item_id=state.current_item_id, - arguments=state.accumulated_text, + events: list[StreamingResponsesResponse] + item: ResponseOutputItem + if state.tool_call_is_custom: + payload = decode_custom_tool_input(state.accumulated_text) + events = _emit_custom_tool_input_delta(state, payload) + events.append( + ResponseCustomToolCallInputDoneEvent( + type="response.custom_tool_call_input.done", + sequence_number=-1, + output_index=state.output_index, + item_id=state.current_item_id, + input=payload, + ) + ) + item = ResponseCustomToolCall( + type="custom_tool_call", + id=state.current_item_id, + call_id=state.tool_call_id, name=state.tool_call_name, - ), + input=payload, + ) + else: + events = [ + ResponseFunctionCallArgumentsDoneEvent( + type="response.function_call_arguments.done", + sequence_number=-1, + output_index=state.output_index, + item_id=state.current_item_id, + arguments=state.accumulated_text, + name=state.tool_call_name, + ) + ] + item = ResponseFunctionToolCall( + type="function_call", + name=state.tool_call_name, + namespace=state.tool_call_namespace, + arguments=state.accumulated_text, + status="completed", + id=state.current_item_id, + call_id=state.tool_call_id, + ) + events.append( ResponseOutputItemDoneEvent( type="response.output_item.done", sequence_number=-1, output_index=state.output_index, - item=ResponseFunctionToolCall( - type="function_call", - name=state.tool_call_name, - namespace=state.tool_call_namespace, - arguments=state.accumulated_text, - status="completed", - id=state.current_item_id, - call_id=state.tool_call_id, - ), - ), - ] + item=item, + ) + ) state.output_index += 1 state.tool_call_namespace = None + state.tool_call_is_custom = False state.current_state = _StateType.NONE return events @@ -1194,6 +1270,7 @@ class SimpleStreamingEventProcessor: ) -> None: self.state = state or SimpleStreamingState() self.tool_call_name_map = build_responses_tool_call_name_map(tools) + self.custom_tool_names = custom_tool_names(tools) self.output_items: list[ResponseOutputItem] = [] def resolve_target_state( @@ -1264,6 +1341,7 @@ class SimpleStreamingEventProcessor: call_name.name, tool_call.index, call_name.namespace, + call_name.name in self.custom_tool_names, tool_call.id, ) return handlers.open_fn(self.state) diff --git a/vllm/entrypoints/openai/responses/utils.py b/vllm/entrypoints/openai/responses/utils.py index c4523964e9..d9b71d6849 100644 --- a/vllm/entrypoints/openai/responses/utils.py +++ b/vllm/entrypoints/openai/responses/utils.py @@ -1,9 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import json from collections.abc import Iterable from typing import Any +import partial_json_parser from openai.types.chat import ( ChatCompletionAssistantMessageParam, ChatCompletionMessageToolCallParam, @@ -13,6 +15,7 @@ from openai.types.chat.chat_completion_message_tool_call_param import ( Function as FunctionCallTool, ) from openai.types.responses import ( + ResponseCustomToolCall, ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage, @@ -28,6 +31,7 @@ from openai.types.responses.response_reasoning_item import ( Content as ResponseReasoningTextContent, ) from openai.types.responses.tool import Tool +from partial_json_parser.core.options import Allow from vllm import envs from vllm.entrypoints.generate.base.protocol import FunctionCall, FunctionDefinition @@ -40,6 +44,7 @@ from vllm.exceptions import VLLMValidationError from vllm.logger import init_logger from vllm.renderers.chat_utils import make_tool_call_id from vllm.tool_parsers.utils import ( + CUSTOM_TOOL_INPUT_KEY, build_responses_tool_call_name_map, flat_namespace_tool_name, iter_response_function_tool_dicts, @@ -50,6 +55,51 @@ from vllm.utils import random_uuid logger = init_logger(__name__) +def custom_tool_names(tools: list[Tool] | None) -> frozenset[str]: + return frozenset(tool.name for tool in tools or [] if tool.type == "custom") + + +def encode_custom_tool_input(payload: str) -> str: + return json.dumps({CUSTOM_TOOL_INPUT_KEY: payload}, ensure_ascii=False) + + +def _custom_tool_payload(parsed: Any) -> str | None: + if not isinstance(parsed, dict): + return None + value = parsed.get(CUSTOM_TOOL_INPUT_KEY) + return value if isinstance(value, str) else None + + +def decode_custom_tool_input(arguments: str) -> str: + """Payload of a completed shim call; bare text is passed through.""" + try: + payload = _custom_tool_payload(json.loads(arguments)) + except ValueError: + return arguments + return arguments if payload is None else payload + + +def decode_custom_tool_input_prefix(arguments: str) -> str: + """Longest decodable payload prefix of partial shim arguments, so that + streamed ``custom_tool_call_input`` deltas stay a prefix of the final input.""" + try: + parsed = partial_json_parser.loads(arguments, Allow.STR | Allow.OBJ) + except (ValueError, IndexError, AssertionError): + return "" + payload = _custom_tool_payload(parsed) or "" + if payload and "\ud800" <= payload[-1] <= "\udbff": + payload = payload[:-1] + return payload + + +def _item_type(item: ResponseInputOutputItem) -> str | None: + return item.get("type") if isinstance(item, dict) else getattr(item, "type", None) + + +def _item_dict(item: ResponseInputOutputItem) -> dict[str, Any]: + return item if isinstance(item, dict) else item.model_dump() + + def build_response_output_items( reasoning: str | None, content: str | None, @@ -59,6 +109,7 @@ def build_response_output_items( ) -> list[ResponseOutputItem]: outputs: list[ResponseOutputItem] = [] tool_call_name_map = build_responses_tool_call_name_map(tools) + custom_names = custom_tool_names(tools) if reasoning: outputs.append( @@ -93,14 +144,27 @@ def build_response_output_items( if tool_calls: for idx, tool_call in enumerate(tool_calls): + call_id = tool_call.id or make_tool_call_id( + func_name=tool_call.name, idx=idx + ) + if tool_call.name in custom_names: + outputs.append( + ResponseCustomToolCall( + id=f"ctc_{random_uuid()}", + call_id=call_id, + type="custom_tool_call", + name=tool_call.name, + input=decode_custom_tool_input(tool_call.arguments), + ) + ) + continue call_name = resolve_responses_tool_call_name( tool_call.name, tool_call_name_map=tool_call_name_map ) outputs.append( ResponseFunctionToolCall( id=f"fc_{random_uuid()}", - call_id=tool_call.id - or make_tool_call_id(func_name=tool_call.name, idx=idx), + call_id=call_id, type="function_call", status="completed", name=call_name.name, @@ -255,6 +319,7 @@ def _construct_message_from_response_item( prev_msg if prev_msg and prev_msg.get("role") == "assistant" else None ) + tool_call: ChatCompletionMessageToolCallParam | None = None if isinstance(item, ResponseFunctionToolCall): tool_name = item.name if item.namespace: @@ -267,6 +332,17 @@ def _construct_message_from_response_item( ), type="function", ) + elif _item_type(item) == "custom_tool_call": + call = _item_dict(item) + tool_call = ChatCompletionMessageToolCallParam( + id=call["call_id"], + function=FunctionCallTool( + name=call["name"], + arguments=encode_custom_tool_input(call.get("input") or ""), + ), + type="function", + ) + if tool_call is not None: if prev_assistant_msg: tool_calls = prev_assistant_msg.get("tool_calls") if tool_calls is None: @@ -285,14 +361,14 @@ def _construct_message_from_response_item( logger.warning( "Previous assistant message has unknown tool_calls format. " "Tool call merging is skipped and a new assistant message is created. " - "Item %s", - item.id, + "Call %s", + tool_call["id"], ) return ChatCompletionAssistantMessageParam( role="assistant", tool_calls=[tool_call], ) - elif isinstance(item, ResponseReasoningItem): + if isinstance(item, ResponseReasoningItem): reasoning = "" if item.encrypted_content: raise VLLMValidationError( @@ -336,12 +412,12 @@ def _construct_message_from_response_item( content=item.output, tool_call_id=item.call_id, ) - elif isinstance(item, dict) and item.get("type") == "function_call_output": - # Append the function call output as a tool message. + elif _item_type(item) in ("function_call_output", "custom_tool_call_output"): + output = _item_dict(item) return ChatCompletionToolMessageParam( role="tool", - content=item.get("output"), - tool_call_id=item.get("call_id"), + content=output.get("output"), + tool_call_id=output.get("call_id"), ) elif isinstance(item, dict) and item.get("role") == "assistant": content = item.get("content") diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py index 61826dc8c8..12a9c23698 100644 --- a/vllm/parser/abstract_parser.py +++ b/vllm/parser/abstract_parser.py @@ -7,7 +7,7 @@ from abc import abstractmethod from collections.abc import Sequence from dataclasses import dataclass, field, replace -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction from pydantic import TypeAdapter, ValidationError from xgrammar import Grammar, StructuralTag from xgrammar.structural_tag import ( @@ -359,7 +359,9 @@ class DelegatingParser(Parser): def _get_function_name( self, request: ChatCompletionRequest | ResponsesRequest ) -> str: - if request.tool_choice and isinstance(request.tool_choice, ToolChoiceFunction): + if request.tool_choice and isinstance( + request.tool_choice, (ToolChoiceFunction, ToolChoiceCustom) + ): return request.tool_choice.name if request.tool_choice and isinstance( request.tool_choice, ChatCompletionNamedToolChoiceParam @@ -398,7 +400,7 @@ class DelegatingParser(Parser): supports_required_and_named = tool_parser.supports_required_and_named is_named_tool_choice = request.tool_choice and isinstance( request.tool_choice, - (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam), + (ToolChoiceFunction, ToolChoiceCustom, ChatCompletionNamedToolChoiceParam), ) is_required_tool_choice = request.tool_choice == "required" is_auto_tool_choice = enable_auto_tools and ( @@ -503,7 +505,11 @@ class DelegatingParser(Parser): or request.tool_choice == "required" or isinstance( request.tool_choice, - (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction), + ( + ChatCompletionNamedToolChoiceParam, + ToolChoiceFunction, + ToolChoiceCustom, + ), ) ) if not need_tool_calling: @@ -723,7 +729,11 @@ class DelegatingParser(Parser): and request.tool_choice and isinstance( request.tool_choice, - (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam), + ( + ToolChoiceFunction, + ToolChoiceCustom, + ChatCompletionNamedToolChoiceParam, + ), ) ): delta_message, function_name_returned = extract_named_tool_call_streaming( diff --git a/vllm/parser/response_template.py b/vllm/parser/response_template.py index 85f8b67efb..b1665f1772 100644 --- a/vllm/parser/response_template.py +++ b/vllm/parser/response_template.py @@ -9,7 +9,7 @@ import json from collections.abc import Callable, Iterable, Sequence from typing import TYPE_CHECKING, Any, cast -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction from vllm.entrypoints.generate.base.protocol import ( DeltaFunctionCall, @@ -173,7 +173,8 @@ def _streaming_template(template: dict[str, Any]) -> ResponseTemplate: def _is_named_tool_choice(request: ChatCompletionRequest | ResponsesRequest) -> bool: return isinstance( - request.tool_choice, (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam) + request.tool_choice, + (ToolChoiceFunction, ToolChoiceCustom, ChatCompletionNamedToolChoiceParam), ) diff --git a/vllm/tool_parsers/gemma4_engine_tool_parser.py b/vllm/tool_parsers/gemma4_engine_tool_parser.py index 04c03ecaa2..fcb578b129 100644 --- a/vllm/tool_parsers/gemma4_engine_tool_parser.py +++ b/vllm/tool_parsers/gemma4_engine_tool_parser.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionNamedToolChoiceParam, @@ -29,7 +29,12 @@ class Gemma4EngineToolParser(Gemma4ParserToolAdapter): # type: ignore[valid-typ if request.tools: tc = request.tool_choice if tc == "required" or isinstance( - tc, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction) + tc, + ( + ChatCompletionNamedToolChoiceParam, + ToolChoiceFunction, + ToolChoiceCustom, + ), ): request.skip_special_tokens = False return request diff --git a/vllm/tool_parsers/k2_horizon_tool_parser.py b/vllm/tool_parsers/k2_horizon_tool_parser.py index 742bf7d140..a63f5fa122 100644 --- a/vllm/tool_parsers/k2_horizon_tool_parser.py +++ b/vllm/tool_parsers/k2_horizon_tool_parser.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from typing import Any import regex as re -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction from vllm.entrypoints.generate.base.protocol import ( DeltaFunctionCall, @@ -184,7 +184,7 @@ class K2HorizonToolParser(ToolParser): tool_choice = request.tool_choice if isinstance(tool_choice, ChatCompletionNamedToolChoiceParam): return tool_choice.function.name - if isinstance(tool_choice, ToolChoiceFunction): + if isinstance(tool_choice, (ToolChoiceFunction, ToolChoiceCustom)): return tool_choice.name return None diff --git a/vllm/tool_parsers/kimi_k3_tool_parser.py b/vllm/tool_parsers/kimi_k3_tool_parser.py index 76c6b0aa65..15b5a22df8 100644 --- a/vllm/tool_parsers/kimi_k3_tool_parser.py +++ b/vllm/tool_parsers/kimi_k3_tool_parser.py @@ -35,7 +35,7 @@ import json from collections.abc import Sequence import regex as re -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction from vllm.entrypoints.generate.base.protocol import ( DeltaFunctionCall, @@ -149,7 +149,11 @@ class KimiK3ToolParser(ToolParser): ) -> ChatCompletionRequest | ResponsesRequest: named = isinstance( request.tool_choice, - (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction), + ( + ChatCompletionNamedToolChoiceParam, + ToolChoiceFunction, + ToolChoiceCustom, + ), ) structured_outputs = getattr(request, "structured_outputs", None) has_structural_tag = ( diff --git a/vllm/tool_parsers/muse_glimmer_tool_parser.py b/vllm/tool_parsers/muse_glimmer_tool_parser.py index 5a0b26cf42..644ca5ed3e 100644 --- a/vllm/tool_parsers/muse_glimmer_tool_parser.py +++ b/vllm/tool_parsers/muse_glimmer_tool_parser.py @@ -38,7 +38,7 @@ import json from collections.abc import Iterator, Sequence import regex as re -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction from transformers import PreTrainedTokenizerBase from vllm.entrypoints.generate.base.protocol import ( @@ -228,7 +228,12 @@ class MuseGlimmerToolParser(ToolParser): if request.tools and ( tool_choice == "required" or isinstance( - tool_choice, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction) + tool_choice, + ( + ChatCompletionNamedToolChoiceParam, + ToolChoiceFunction, + ToolChoiceCustom, + ), ) ): return request diff --git a/vllm/tool_parsers/poolside_v1_tool_parser.py b/vllm/tool_parsers/poolside_v1_tool_parser.py index d2dfb7815f..9989272647 100644 --- a/vllm/tool_parsers/poolside_v1_tool_parser.py +++ b/vllm/tool_parsers/poolside_v1_tool_parser.py @@ -16,7 +16,7 @@ from typing import Any import partial_json_parser.core.complete import regex as re -from openai.types.responses import ToolChoiceFunction +from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction from partial_json_parser.core.options import Allow from vllm.entrypoints.generate.base.protocol import ( @@ -171,7 +171,12 @@ class PoolsideV1ToolParser(ToolParser): if request.tools: tc = request.tool_choice if tc == "required" or isinstance( - tc, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction) + tc, + ( + ChatCompletionNamedToolChoiceParam, + ToolChoiceFunction, + ToolChoiceCustom, + ), ): request.skip_special_tokens = False return request diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py index f3148a827e..7e5902b8c2 100644 --- a/vllm/tool_parsers/structural_tag_registry.py +++ b/vllm/tool_parsers/structural_tag_registry.py @@ -6,7 +6,7 @@ import json from collections.abc import Callable, Sequence from typing import Any, Literal, TypeAlias -from openai.types.responses import FunctionTool +from openai.types.responses import CustomTool, FunctionTool, ToolChoiceCustom from openai.types.responses.response import ToolChoice as ResponsesToolChoice from openai.types.responses.tool import Tool as ResponsesTool from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed @@ -39,7 +39,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, ) from vllm.tool_parsers.tool_strict_level import ToolStrictLevel -from vllm.tool_parsers.utils import get_function_tools, require_function_tools +from vllm.tool_parsers.utils import ( + custom_tool_parameters, + get_function_tools, + require_function_tools, +) ToolChoice: TypeAlias = ( Literal["none", "auto", "required"] @@ -287,6 +291,11 @@ def _dump_tool_for_xgrammar( if tool.strict is not None: function["strict"] = tool.strict return {"type": "function", "function": function} + if isinstance(tool, CustomTool): + function = {"name": tool.name, "parameters": custom_tool_parameters()} + if tool.description is not None: + function["description"] = tool.description + return {"type": "function", "function": function} dumped_tool = tool.model_dump(mode="json", exclude_none=True) if isinstance(tool, ChatCompletionToolsParam): return dumped_tool @@ -306,7 +315,7 @@ def _dump_tool_choice_for_xgrammar( if isinstance(tool_choice, ChatCompletionNamedToolChoiceParam): return tool_choice.model_dump(mode="json", exclude_none=True) - if isinstance(tool_choice, ToolChoiceFunction): + if isinstance(tool_choice, (ToolChoiceFunction, ToolChoiceCustom)): return { "type": "function", "function": {"name": tool_choice.name}, diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 7a93c843b8..2462824ccf 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -13,8 +13,10 @@ from typing import Any, TypeAlias import partial_json_parser from openai.types.responses import ( + CustomTool, FunctionTool, NamespaceTool, + ToolChoiceCustom, ToolChoiceFunction, ) from openai.types.responses.tool import Tool as ResponsesTool @@ -37,6 +39,32 @@ Tool: TypeAlias = ChatCompletionToolsParam | ResponsesTool logger = init_logger(__name__) +CUSTOM_TOOL_INPUT_KEY = "input" + + +def custom_tool_parameters() -> dict[str, Any]: + """Schema of the single-string function shim a ``custom`` tool is shown as.""" + return { + "type": "object", + "properties": { + CUSTOM_TOOL_INPUT_KEY: { + "type": "string", + "description": "The freeform text payload for this tool.", + } + }, + "required": [CUSTOM_TOOL_INPUT_KEY], + "additionalProperties": False, + } + + +def custom_tool_as_function_dict(tool: CustomTool) -> dict[str, Any]: + return { + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": custom_tool_parameters(), + } + def safe_literal_eval(text: str): with warnings.catch_warnings(): @@ -188,8 +216,9 @@ def get_function_tools( This is the single source for both the chat template's tool list and the tools a tool-call grammar constrains, so the two cannot drift apart. - Namespace functions are flattened to ``__``; other - Responses tool types are never rendered for the model and are dropped. + Namespace functions are flattened to ``__``; custom tools + become a single-string function shim. Other Responses tool types are never + rendered for the model and are dropped. """ function_tools: list[ChatCompletionToolsParam | FunctionTool] = [] for tool in tools: @@ -206,6 +235,10 @@ def get_function_tools( for namespaced_tool in tool.tools if namespaced_tool.type == "function" ) + elif isinstance(tool, CustomTool): + function_tools.append( + FunctionTool.model_validate(custom_tool_as_function_dict(tool)) + ) elif isinstance(tool, (FunctionTool, ChatCompletionToolsParam)): function_tools.append(tool) return function_tools @@ -307,7 +340,7 @@ def find_tool_properties( if not tools: return {} for tool in tools: - if isinstance(tool, (FunctionTool, NamespaceTool)): + if isinstance(tool, (FunctionTool, NamespaceTool, CustomTool)): for name, params in iter_response_function_tool_info(tool): if name == tool_name: return (params or {}).get("properties", {}) @@ -328,7 +361,7 @@ def find_tool_name( if not tools: return False for tool in tools: - if isinstance(tool, (FunctionTool, NamespaceTool)): + if isinstance(tool, (FunctionTool, NamespaceTool, CustomTool)): for name, _ in iter_response_function_tool_info(tool): if name == tool_name: return True @@ -414,7 +447,9 @@ def _get_json_schema_from_tools( def get_json_schema_from_tools( - tool_choice: str | ToolChoiceFunction | ChatCompletionNamedToolChoiceParam, + tool_choice: ( + str | ToolChoiceFunction | ToolChoiceCustom | ChatCompletionNamedToolChoiceParam + ), tools: list[Tool] | None, parallel_tool_calls: bool | None = None, ) -> str | dict | None: @@ -423,12 +458,12 @@ def get_json_schema_from_tools( return None # tool_choice: Forced Function (Responses) if (not isinstance(tool_choice, str)) and isinstance( - tool_choice, ToolChoiceFunction + tool_choice, (ToolChoiceFunction, ToolChoiceCustom) ): tool_name = tool_choice.name responses_tool_map: dict[str, dict[str, Any] | None] = {} for tool in tools: - if not isinstance(tool, (FunctionTool, NamespaceTool)): + if not isinstance(tool, (FunctionTool, NamespaceTool, CustomTool)): continue for name, params in iter_response_function_tool_info(tool): responses_tool_map[name] = params