fix: support object and array parameters in custom HTTP tools (#373)

* fix: support object and array parameters in custom HTTP tools

* feat(ui): expose object and array types in the custom tool parameter editor

* fix: error handling and schema generation

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Matt Van Horn 2026-06-01 23:05:38 -07:00 committed by GitHub
parent 98d2b24cba
commit dd85c4a1b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 308 additions and 67 deletions

View file

@ -19,7 +19,7 @@ from api.enums import ToolCategory
DEFAULT_MCP_TIMEOUT_SECS = 30
DEFAULT_MCP_SSE_READ_TIMEOUT_SECS = 300
ToolParameterType = Literal["string", "number", "boolean"]
ToolParameterType = Literal["string", "number", "boolean", "object", "array"]
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
ToolCategoryValue = Literal[
"http_api",
@ -47,7 +47,9 @@ class ToolParameter(BaseModel):
)
type: ToolParameterType = Field(
description="JSON type for the parameter value.",
json_schema_extra=_llm_hint("Allowed values are string, number, and boolean."),
json_schema_extra=_llm_hint(
"Allowed values are string, number, boolean, object, and array."
),
)
description: str = Field(
description="Description shown to the model for this parameter.",
@ -65,7 +67,12 @@ class PresetToolParameter(BaseModel):
"""A parameter injected by Dograh at runtime."""
name: str = Field(description="Parameter name used as a key in the request body.")
type: ToolParameterType = Field(description="JSON type for the resolved value.")
type: ToolParameterType = Field(
description="JSON type for the resolved value.",
json_schema_extra=_llm_hint(
"Allowed values are string, number, boolean, object, and array."
),
)
value_template: str = Field(
description="Fixed value or template, e.g. {{initial_context.phone_number}}.",
json_schema_extra=_llm_hint(

View file

@ -17,8 +17,8 @@ from .google import (
from .sarvam import (
SARVAM_LANGUAGES,
SARVAM_LLM_MODELS,
SARVAM_STT_LANGUAGES_V25,
SARVAM_STT_LANGUAGES_V3,
SARVAM_STT_LANGUAGES_V25,
SARVAM_STT_MODELS,
SARVAM_TTS_MODELS,
SARVAM_V2_VOICES,

View file

@ -23,8 +23,8 @@ from api.services.configuration.options import (
GOOGLE_VERTEX_REALTIME_VOICES,
SARVAM_LANGUAGES,
SARVAM_LLM_MODELS,
SARVAM_STT_LANGUAGES_V25,
SARVAM_STT_LANGUAGES_V3,
SARVAM_STT_LANGUAGES_V25,
SARVAM_STT_MODELS,
SARVAM_TTS_MODELS,
SARVAM_V2_VOICES,

View file

@ -16,6 +16,8 @@ TYPE_MAP = {
"string": "string",
"number": "number",
"boolean": "boolean",
"object": "object",
"array": "array",
}
@ -45,10 +47,24 @@ def tool_to_function_schema(tool: Any) -> Dict[str, Any]:
if not param_name:
continue
properties[param_name] = {
"type": TYPE_MAP.get(param_type, "string"),
"description": param_desc,
}
schema_type = TYPE_MAP.get(param_type, "string")
if schema_type == "object":
properties[param_name] = {
"type": "object",
"additionalProperties": True,
"description": param_desc,
}
elif schema_type == "array":
properties[param_name] = {
"type": "array",
"items": {},
"description": param_desc,
}
else:
properties[param_name] = {
"type": schema_type,
"description": param_desc,
}
if param_required:
required.append(param_name)
@ -127,6 +143,26 @@ def _coerce_parameter_value(value: Any, param_type: str) -> Any:
raise ValueError(f"Cannot convert '{value}' to boolean")
if param_type == "object":
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError(f"Cannot convert '{value}' to object") from exc
if isinstance(value, dict):
return value
raise ValueError(f"Cannot convert '{value}' to object")
if param_type == "array":
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError(f"Cannot convert '{value}' to array") from exc
if isinstance(value, list):
return value
raise ValueError(f"Cannot convert '{value}' to array")
return value

View file

@ -29,6 +29,7 @@ from pipecat.services.llm_service import FunctionCallParams
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
from api.services.workflow.tools.custom_tool import (
_coerce_parameter_value,
execute_http_tool,
tool_to_function_schema,
)
@ -141,6 +142,51 @@ class TestToolToFunctionSchema:
assert "duration_minutes" in required
assert "is_priority" not in required
def test_tool_with_object_and_array_parameters(self):
"""Test converting a tool with object and array parameters."""
tool = MockToolModel(
tool_uuid="test-uuid-nested",
name="Create Booking",
description="Create a booking with nested details",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "POST",
"url": "https://api.example.com/bookings",
"parameters": [
{
"name": "booking",
"type": "object",
"description": "Nested booking payload",
"required": True,
},
{
"name": "attendees",
"type": "array",
"description": "Booking attendees",
"required": False,
},
],
},
},
)
schema = tool_to_function_schema(tool)
props = schema["function"]["parameters"]["properties"]
assert props["booking"] == {
"type": "object",
"additionalProperties": True,
"description": "Nested booking payload",
}
assert props["attendees"] == {
"type": "array",
"items": {},
"description": "Booking attendees",
}
def test_preset_parameters_are_not_exposed_to_llm_schema(self):
"""Test that preset parameters are injected at runtime, not shown to the LLM."""
tool = MockToolModel(
@ -295,6 +341,51 @@ class TestExecuteHttpTool:
assert result["status_code"] == 201
assert result["data"]["id"] == 123
@pytest.mark.asyncio
async def test_post_request_sends_nested_json_body(self):
"""Test that POST requests preserve nested arguments in the JSON body."""
tool = MockToolModel(
tool_uuid="test-uuid-nested",
name="Create Booking",
description="Create a nested booking",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "POST",
"url": "https://api.example.com/bookings",
"timeout_ms": 5000,
},
},
)
arguments = {
"booking": {
"start": "2026-05-28T10:00:00Z",
"attendee": {"name": "Jane", "email": "jane@example.com"},
"metadata": {"source": "voice"},
}
}
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"bookingId": "booking-123"}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await execute_http_tool(tool, arguments)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["json"] == arguments
assert isinstance(call_kwargs["json"]["booking"], dict)
assert isinstance(call_kwargs["json"]["booking"]["attendee"], dict)
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_post_request_injects_preset_parameters(self):
"""Test that preset parameters are resolved from runtime context."""
@ -640,6 +731,51 @@ class TestExecuteHttpTool:
mock_db.get_credential_by_uuid.assert_not_called()
class TestCoerceParameterValue:
"""Tests for _coerce_parameter_value function."""
def test_object_value_returns_dict_unchanged(self):
"""Test that object parameters preserve dict values."""
value = {"attendee": {"name": "Jane"}}
assert _coerce_parameter_value(value, "object") is value
def test_object_value_parses_json_string(self):
"""Test that object parameters parse JSON string values."""
value = '{"attendee": {"name": "Jane"}}'
assert _coerce_parameter_value(value, "object") == {
"attendee": {"name": "Jane"}
}
def test_array_value_returns_list_unchanged(self):
"""Test that array parameters preserve list values."""
value = [{"name": "Jane"}, {"name": "Sam"}]
assert _coerce_parameter_value(value, "array") is value
def test_array_value_parses_json_string(self):
"""Test that array parameters parse JSON string values."""
value = '[{"name": "Jane"}, {"name": "Sam"}]'
assert _coerce_parameter_value(value, "array") == [
{"name": "Jane"},
{"name": "Sam"},
]
@pytest.mark.parametrize("value", ["not json", "[]", "null"])
def test_object_value_rejects_invalid_or_wrong_shape(self, value):
"""Test that object parameters require a JSON object."""
with pytest.raises(ValueError, match="Cannot convert"):
_coerce_parameter_value(value, "object")
@pytest.mark.parametrize("value", ["not json", "{}", "null"])
def test_array_value_rejects_invalid_or_wrong_shape(self, value):
"""Test that array parameters require a JSON array."""
with pytest.raises(ValueError, match="Cannot convert"):
_coerce_parameter_value(value, "array")
class TestAuthHeaders:
"""Tests for auth header building utilities."""

View file

@ -80,6 +80,53 @@ def test_update_tool_request_accepts_mcp_definition():
assert req.definition.config.url == "https://x/mcp"
def test_update_tool_request_accepts_http_api_complex_parameter_types():
"""HTTP API tools may accept structured JSON parameters."""
req = UpdateToolRequest(
name="Check Availability New Multi",
description="Check Availability when asked for it.",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "POST",
"url": "https://automation.dograh.com/webhook/example",
"parameters": [
{
"name": "params",
"type": "object",
"description": (
"An object containing the name and datetime in ISO format"
),
"required": True,
},
{
"name": "slots",
"type": "array",
"description": "Candidate availability slots.",
"required": False,
},
],
"preset_parameters": [
{
"name": "phone_number",
"type": "string",
"value_template": "{{initial_context.phone_number}}",
"required": True,
}
],
"timeout_ms": 5000,
"customMessageType": "text",
},
},
)
assert req.definition.type == "http_api"
parameters = req.definition.config.parameters
assert parameters[0].type == "object"
assert parameters[1].type == "array"
def test_create_tool_request_accepts_mcp_with_all_fields():
"""All optional MCP config fields are accepted and preserved."""
req = CreateToolRequest(