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(

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-DuffQq.json
# timestamp: 2026-05-31T11:41:57+00:00
# filename: dograh-openapi-rs5H7P.json
# timestamp: 2026-06-02T06:01:29+00:00
from __future__ import annotations
@ -337,6 +337,8 @@ class Type(Enum):
string = 'string'
number = 'number'
boolean = 'boolean'
object = 'object'
array = 'array'
class PresetToolParameter(BaseModel):
@ -427,6 +429,8 @@ class Type1(Enum):
string = 'string'
number = 'number'
boolean = 'boolean'
object = 'object'
array = 'array'
class ToolParameter(BaseModel):

View file

@ -775,7 +775,7 @@ export interface components {
* @description JSON type for the resolved value.
* @enum {string}
*/
type: "string" | "number" | "boolean";
type: "string" | "number" | "boolean" | "object" | "array";
/**
* Value Template
* @description Fixed value or template, e.g. {{initial_context.phone_number}}.
@ -933,7 +933,7 @@ export interface components {
* @description JSON type for the parameter value.
* @enum {string}
*/
type: "string" | "number" | "boolean";
type: "string" | "number" | "boolean" | "object" | "array";
/**
* Description
* @description Description shown to the model for this parameter.

View file

@ -71,6 +71,21 @@ useEffect(() => {
The auth interceptor (which attaches the Bearer token) is only registered once auth is fully loaded. Fetching before that sends unauthenticated requests that silently fail.
### API Error Handling
The generated client does **not** throw on HTTP error responses — it resolves to `{ data, error }`. A `try/catch` only catches network failures, so a 4xx/5xx slips through silently if you only check `response.data`. Always check `response.error`:
```tsx
const response = await someApiCall({ ... });
if (response.error) {
setError(detailFromError(response.error, "Failed to save thing"));
return;
}
// ...use response.data
```
Use `detailFromError` from `@/lib/apiError` to turn the error into a string — never render `error.detail` directly. FastAPI returns `detail` as a string for `HTTPException` but as an **array** of `{ msg, loc, ... }` objects for 422 validation errors; passing that array to React (`{error}`) crashes the page with "Objects are not valid as a React child". The helper normalizes both shapes and takes an optional fallback message.
## Development
```bash

View file

@ -55,6 +55,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
const INBOUND_WEBHOOK_PATH = "/api/v1/telephony/inbound/run";
@ -449,14 +450,3 @@ export default function TelephonyConfigurationDetailPage() {
</div>
);
}
function detailFromError(err: unknown): string {
if (typeof err === "string") return err;
const e = err as { detail?: unknown };
if (typeof e?.detail === "string") return e.detail;
if (Array.isArray(e?.detail) && e.detail.length > 0) {
const first = e.detail[0] as { msg?: string };
if (first?.msg) return first.msg;
}
return "Request failed";
}

View file

@ -46,6 +46,7 @@ import {
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
export default function TelephonyConfigurationsPage() {
@ -331,14 +332,3 @@ export default function TelephonyConfigurationsPage() {
</div>
);
}
function detailFromError(err: unknown): string {
if (typeof err === "string") return err;
const e = err as { detail?: unknown };
if (typeof e?.detail === "string") return e.detail;
if (Array.isArray(e?.detail) && e.detail.length > 0) {
const first = e.detail[0] as { msg?: string };
if (first?.msg) return first.msg;
}
return "Request failed";
}

View file

@ -40,6 +40,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import {
@ -58,6 +59,8 @@ function normalizeParameterType(value: string | null | undefined): ParameterType
switch (value) {
case "number":
case "boolean":
case "object":
case "array":
return value;
default:
return "string";
@ -447,6 +450,11 @@ export default function ToolDetailPage() {
},
});
if (response.error) {
setError(detailFromError(response.error, "Failed to save tool"));
return;
}
if (response.data) {
setTool(response.data);
setSaveSuccess(true);

View file

@ -2539,7 +2539,7 @@ export type PresetToolParameter = {
*
* JSON type for the resolved value.
*/
type: 'string' | 'number' | 'boolean';
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
/**
* Value Template
*
@ -3625,7 +3625,7 @@ export type ToolParameter = {
*
* JSON type for the parameter value.
*/
type: 'string' | 'number' | 'boolean';
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
/**
* Description
*
@ -4594,6 +4594,12 @@ export type WorkflowRunResponseSchema = {
cost_info: {
[key: string]: unknown;
} | null;
/**
* Usage Info
*/
usage_info?: {
[key: string]: unknown;
} | null;
/**
* Definition Id
*/

View file

@ -2,6 +2,7 @@
import { PlusIcon, Trash2Icon } from "lucide-react";
import type { ToolParameter as ApiToolParameter } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -14,7 +15,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export type ParameterType = "string" | "number" | "boolean";
export type ParameterType = ApiToolParameter["type"];
export interface ToolParameter {
name: string;
@ -124,6 +125,8 @@ export function ParameterEditor({
<SelectItem value="string">String</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
<SelectItem value="object">Object</SelectItem>
<SelectItem value="array">Array</SelectItem>
</SelectContent>
</Select>
</div>
@ -267,6 +270,8 @@ export function PresetParameterEditor({
<SelectItem value="string">String</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
<SelectItem value="object">Object</SelectItem>
<SelectItem value="array">Array</SelectItem>
</SelectContent>
</Select>
</div>

View file

@ -36,6 +36,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
interface ConfigFormDialogProps {
@ -132,7 +133,7 @@ export function ConfigFormDialog({
body: { name: name || undefined, config: configPayload },
},
);
if (res.error) throw new Error(detailFromError(res.error));
if (res.error) throw new Error(detailFromError(res.error, "Failed to save configuration"));
toast.success("Configuration updated");
} else {
const res = await createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost(
@ -145,7 +146,7 @@ export function ConfigFormDialog({
},
},
);
if (res.error) throw new Error(detailFromError(res.error));
if (res.error) throw new Error(detailFromError(res.error, "Failed to save configuration"));
toast.success("Configuration created");
}
onOpenChange(false);
@ -345,16 +346,3 @@ function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
/>
);
}
// FastAPI error responses come back as { detail: string } or
// { detail: [{loc, msg, ...}] }. Surface a useful message either way.
function detailFromError(err: unknown): string {
if (typeof err === "string") return err;
const e = err as { detail?: unknown };
if (typeof e?.detail === "string") return e.detail;
if (Array.isArray(e?.detail) && e.detail.length > 0) {
const first = e.detail[0] as { msg?: string };
if (first?.msg) return first.msg;
}
return "Failed to save configuration";
}

View file

@ -28,6 +28,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
interface PhoneNumberDialogProps {
@ -146,7 +147,7 @@ export function PhoneNumberDialog({
},
},
);
if (res.error) throw new Error(detailFromError(res.error));
if (res.error) throw new Error(detailFromError(res.error, "Failed to save phone number"));
providerSync = res.data?.provider_sync;
toast.success("Phone number updated");
} else {
@ -164,7 +165,7 @@ export function PhoneNumberDialog({
},
},
);
if (res.error) throw new Error(detailFromError(res.error));
if (res.error) throw new Error(detailFromError(res.error, "Failed to save phone number"));
providerSync = res.data?.provider_sync;
toast.success("Phone number added");
}
@ -302,14 +303,3 @@ export function PhoneNumberDialog({
</Dialog>
);
}
function detailFromError(err: unknown): string {
if (typeof err === "string") return err;
const e = err as { detail?: unknown };
if (typeof e?.detail === "string") return e.detail;
if (Array.isArray(e?.detail) && e.detail.length > 0) {
const first = e.detail[0] as { msg?: string };
if (first?.msg) return first.msg;
}
return "Failed to save phone number";
}

19
ui/src/lib/apiError.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Extract a human-readable message from a backend error response.
*
* The generated API client returns `{ error }` on failure (it does not throw),
* and FastAPI shapes that error as either `{ detail: string }` (HTTPException)
* or `{ detail: [{ msg, loc, ... }] }` (422 validation). This normalizes both
* to a single string so it can be rendered or thrown directly never pass the
* raw `detail` to React, as the 422 array crashes rendering.
*/
export function detailFromError(err: unknown, fallback = "Request failed"): string {
if (typeof err === "string") return err;
const e = err as { detail?: unknown };
if (typeof e?.detail === "string") return e.detail;
if (Array.isArray(e?.detail) && e.detail.length > 0) {
const first = e.detail[0] as { msg?: string };
if (first?.msg) return first.msg;
}
return fallback;
}