mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
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:
parent
98d2b24cba
commit
dd85c4a1b4
18 changed files with 308 additions and 67 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue