Merge branch 'main' into feat/vici-dial

This commit is contained in:
Abhishek Kumar 2026-07-20 21:22:26 +05:30
commit 9458fdb67e
27 changed files with 2118 additions and 157 deletions

View file

@ -696,8 +696,8 @@ async def update_campaign(
@router.get("/{campaign_id}/runs")
async def get_campaign_runs(
campaign_id: int,
page: int = 1,
limit: int = 50,
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(
None, description="Field to sort by (e.g., 'duration', 'created_at')"

View file

@ -1,5 +1,6 @@
"""API routes for managing tools."""
import time
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
@ -22,6 +23,8 @@ from api.schemas.tool import (
ToolDefinition,
ToolParameter,
ToolResponse,
ToolTestRequest,
ToolTestResponse,
TransferCallConfig,
TransferCallToolDefinition,
UpdateToolRequest,
@ -39,6 +42,10 @@ from api.services.tool_management import (
from api.services.tool_management import (
populate_discovered_tools as _populate_discovered_tools,
)
from api.services.workflow.tools.custom_tool import (
execute_http_tool,
serialize_query_params,
)
router = APIRouter(prefix="/tools")
@ -57,6 +64,8 @@ __all__ = [
"ToolDefinition",
"ToolParameter",
"ToolResponse",
"ToolTestRequest",
"ToolTestResponse",
"TransferCallConfig",
"TransferCallToolDefinition",
"UpdateToolRequest",
@ -196,6 +205,149 @@ async def refresh_mcp_tools(
raise HTTPException(status_code=e.status_code, detail=e.message) from e
@router.post("/{tool_uuid}/test")
async def test_tool(
tool_uuid: str,
request: ToolTestRequest,
user: UserModel = Depends(get_user),
) -> ToolTestResponse:
"""Execute an HTTP API tool with sample LLM and preset parameters."""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
tool = await db_client.get_tool_by_uuid(
tool_uuid, user.selected_organization_id, include_archived=True
)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
if tool.category != ToolCategory.HTTP_API.value:
raise HTTPException(status_code=400, detail="Only HTTP API tools can be tested")
tool_config = (
tool.definition.get("config", {}) if isinstance(tool.definition, dict) else {}
)
configured_method = tool_config.get("method", "?")
configured_url = tool_config.get("url", "?")
started_at = time.perf_counter()
result = await execute_http_tool(
tool,
request.llm_params,
preset_params=request.preset_params,
organization_id=user.selected_organization_id,
include_request_headers=True,
)
duration_ms = max(0, round((time.perf_counter() - started_at) * 1000))
status = result.get("status", "error")
status_code = result.get("status_code")
if status_code is not None and status_code >= 400:
status = "error"
hint = _hint_for_status_code(status_code, configured_method)
# Preset values take precedence over model-supplied values, matching live
# execution after configured preset templates have been resolved.
resolved_arguments = {**request.llm_params, **request.preset_params}
# Mirror execute_http_tool's own branch: POST/PUT/PATCH send the
# resolved arguments as a JSON body; GET/DELETE send them as query
# params. Never both.
request_body = None
request_params = None
if configured_method in ("POST", "PUT", "PATCH"):
request_body = resolved_arguments # keep {} so preview matches wire request
elif resolved_arguments:
request_params = serialize_query_params(resolved_arguments)
return ToolTestResponse(
status=status,
status_code=status_code,
data=result.get("data"),
error=result.get("error"),
duration_ms=duration_ms,
hint=hint,
request_method=configured_method,
request_url=configured_url,
request_headers=result.get("request_headers", {}),
request_body=request_body,
request_params=request_params,
)
def _hint_for_status_code(
status_code: Optional[int], configured_method: str
) -> Optional[str]:
"""Human-readable explanation for a status code a misconfigured tool
is likely to hit. Returns None for 2xx and any code not covered."""
if status_code == 400:
return (
"HTTP 400 Bad Request — the server rejected the request payload. "
"Verify the arguments/body match what this endpoint expects."
)
if status_code == 401:
return (
"HTTP 401 Unauthorized — the request wasn't authenticated. Check "
"the credential configured on the Authentication tab is present "
"and valid."
)
if status_code == 403:
return (
"HTTP 403 Forbidden — authenticated, but the configured "
"credential doesn't have permission for this endpoint/action."
)
if status_code == 404:
return (
f"HTTP 404 Not Found — verify the endpoint URL is correct and "
f"that {configured_method} is a valid method for it."
)
if status_code == 405:
return (
f"HTTP 405 Method Not Allowed — the endpoint rejected the "
f"configured method ({configured_method}). Verify the API expects "
f"{configured_method} for this URL."
)
if status_code == 408:
return (
"HTTP 408 Request Timeout — the endpoint didn't respond in time. "
"Check the endpoint is reachable, or increase Timeout (ms) if it's "
"just slow."
)
if status_code == 409:
return (
"HTTP 409 Conflict — the endpoint rejected the request due to a "
"conflicting resource state (e.g. duplicate create). Not "
"necessarily a configuration problem."
)
if status_code == 415:
return (
"HTTP 415 Unsupported Media Type — check the Content-Type header "
"matches the format this endpoint expects for the body."
)
if status_code == 422:
return (
"HTTP 422 Unprocessable Entity — the request was well-formed but "
"the payload's structure or field types don't match what this "
"endpoint expects. Compare your arguments against the API's "
"documented schema."
)
if status_code == 429:
return (
"HTTP 429 Too Many Requests — the endpoint is rate-limiting. Wait "
"and retry; not a configuration problem."
)
if status_code is not None and 500 <= status_code < 600:
return (
f"HTTP {status_code} — the endpoint itself errored. This is "
"likely an issue on the API's side, not your tool configuration."
)
return None
@router.put("/{tool_uuid}")
async def update_tool(
tool_uuid: str,

View file

@ -1407,8 +1407,8 @@ class WorkflowRunsResponse(BaseModel):
@router.get("/{workflow_id}/runs")
async def get_workflow_runs(
workflow_id: int,
page: int = 1,
limit: int = 50,
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(
None, description="Field to sort by (e.g., 'duration', 'created_at')"

View file

@ -561,3 +561,32 @@ class McpRefreshResponse(BaseModel):
tool_uuid: str
discovered_tools: list = Field(default_factory=list)
error: Optional[str] = None
class ToolTestRequest(BaseModel):
"""Request body for testing an HTTP API tool outside a live call."""
llm_params: Dict[str, Any] = Field(
default_factory=dict,
description="Values for parameters normally supplied by the model.",
)
preset_params: Dict[str, Any] = Field(
default_factory=dict,
description="Resolved values for parameters normally supplied from presets.",
)
class ToolTestResponse(BaseModel):
"""Result of testing an HTTP API tool."""
status: str
status_code: Optional[int] = None
data: Optional[Any] = None
error: Optional[str] = None
hint: Optional[str] = None
request_method: str
request_url: str
request_headers: Dict[str, str] = Field(default_factory=dict)
request_body: Optional[Dict[str, Any]] = None
request_params: Optional[Dict[str, Any]] = None
duration_ms: int

View file

@ -584,6 +584,20 @@ class SarvamLLMConfiguration(BaseLLMConfiguration):
OPENAI_REALTIME_MODELS = ["gpt-realtime-2"]
# ISO 639-1 codes accepted by the Realtime API's input_audio_transcription.
# Not exhaustive — the field allows custom input.
OPENAI_REALTIME_LANGUAGES = [
"en",
"es",
"pt",
"fr",
"de",
"it",
"hi",
"ja",
"ko",
"zh",
]
OPENAI_REALTIME_VOICES = [
"alloy",
"ash",
@ -618,6 +632,17 @@ class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
"allow_custom_input": True,
},
)
language: str | None = Field(
default=None,
description=(
"ISO 639-1 language code for input audio transcription (e.g. 'pt', 'es'). "
"Improves transcription accuracy and latency. Leave unset to auto-detect."
),
json_schema_extra={
"examples": OPENAI_REALTIME_LANGUAGES,
"allow_custom_input": True,
},
)
GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"]

View file

@ -52,16 +52,6 @@ class AudioConfig:
)
self.pipeline_sample_rate = 16000
# Log configuration for auditing
logger.info(
f"AudioConfig initialized: "
f"transport_in={self.transport_in_sample_rate}Hz, "
f"transport_out={self.transport_out_sample_rate}Hz, "
f"vad={self.vad_sample_rate}Hz, "
f"pipeline={self.pipeline_sample_rate}Hz, "
f"buffer={self.buffer_size_seconds}s"
)
@property
def buffer_size_bytes(self) -> int:
"""Calculate buffer size in bytes based on pipeline sample rate."""

View file

@ -95,6 +95,9 @@ from pipecat.turns.user_start import (
MinWordsUserTurnStartStrategy,
ProvisionalVADUserTurnStartStrategy,
)
from pipecat.turns.user_start.transcription_user_turn_start_strategy import (
TranscriptionUserTurnStartStrategy,
)
from pipecat.turns.user_start.vad_user_turn_start_strategy import (
VADUserTurnStartStrategy,
)
@ -160,9 +163,10 @@ def _create_non_realtime_user_turn_start_strategies(
if turn_start_strategy == "provisional_vad":
return [
TranscriptionUserTurnStartStrategy(),
ProvisionalVADUserTurnStartStrategy(
pause_secs=_resolve_provisional_vad_pause_secs(run_configs)
)
),
]
if uses_external_turns:
@ -172,7 +176,7 @@ def _create_non_realtime_user_turn_start_strategies(
# confirms a real turn.
return [ExternalUserTurnStartStrategy(enable_interruptions=True)]
return [VADUserTurnStartStrategy()]
return [TranscriptionUserTurnStartStrategy(), VADUserTurnStartStrategy()]
def _create_non_realtime_user_turn_stop_strategies(

View file

@ -227,7 +227,6 @@ def create_stt_service(
# Other models than flux
# Use language from user config, defaulting to "multi" for multilingual support
language = getattr(user_config.stt, "language", None) or "multi"
logger.debug(f"Using DeepGram Model - {user_config.stt.model}")
return DeepgramSTTService(
api_key=user_config.stt.api_key,
settings=DeepgramSTTSettings(
@ -1009,6 +1008,13 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
SessionProperties,
)
# Pin the transcription language when configured. Without it the model
# auto-detects per utterance, which misfires on short/noisy telephony
# audio (e.g. Portuguese transcribed as English or Chinese).
transcription_kwargs = {}
if language:
transcription_kwargs["language"] = language
return DograhOpenAIRealtimeLLMService(
api_key=api_key,
settings=DograhOpenAIRealtimeLLMService.Settings(
@ -1016,7 +1022,9 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
session_properties=SessionProperties(
audio=AudioConfiguration(
input=AudioInput(
transcription=InputAudioTranscription(),
transcription=InputAudioTranscription(
**transcription_kwargs
),
),
output=AudioOutput(
voice=voice or "alloy",

View file

@ -8,6 +8,7 @@ import httpx
from loguru import logger
from api.db import db_client
from api.services.configuration.masking import mask_key
from api.utils.credential_auth import build_auth_header
from api.utils.template_renderer import render_template
@ -21,6 +22,19 @@ TYPE_MAP = {
}
def serialize_query_params(arguments: Dict[str, Any]) -> Dict[str, Any]:
"""JSON-stringify dict/list values so they're safe to pass as query params.
httpx (and query strings in general) only support primitive param values.
Object/array-typed tool arguments must be serialized before going out as
GET/DELETE query params, otherwise httpx raises a TypeError.
"""
return {
k: json.dumps(v) if isinstance(v, (dict, list)) else v
for k, v in arguments.items()
}
def tool_to_function_schema(tool: Any) -> Dict[str, Any]:
"""Convert a ToolModel to an LLM function schema.
@ -224,7 +238,9 @@ async def execute_http_tool(
arguments: Dict[str, Any],
call_context_vars: Optional[Dict[str, Any]] = None,
gathered_context_vars: Optional[Dict[str, Any]] = None,
preset_params: Optional[Dict[str, Any]] = None,
organization_id: Optional[int] = None,
include_request_headers: bool = False,
) -> Dict[str, Any]:
"""Execute an HTTP API tool.
@ -233,7 +249,11 @@ async def execute_http_tool(
arguments: Arguments passed by the LLM (parameter name -> value)
call_context_vars: Initial context variables available at runtime
gathered_context_vars: Variables extracted during the conversation
preset_params: Pre-resolved preset parameter values. Used by the test
endpoint; live calls omit this so configured templates are resolved.
organization_id: Organization ID for credential lookup
include_request_headers: Include a client-safe header preview in the result.
Headers supplied by a stored credential are masked.
Returns:
Result dict with response data or error
@ -248,7 +268,9 @@ async def execute_http_tool(
# Get headers from config
headers = dict(config.get("headers", {}) or {})
# Add auth header if credential is configured
# Add auth header if credential is configured. Keep track of which headers
# came from the credential so only those values are masked in test previews.
credential_headers: Dict[str, str] = {}
credential_uuid = config.get("credential_uuid")
if credential_uuid and organization_id:
try:
@ -256,8 +278,8 @@ async def execute_http_tool(
credential_uuid, organization_id
)
if credential:
auth_header = build_auth_header(credential)
headers.update(auth_header)
credential_headers = build_auth_header(credential)
headers.update(credential_headers)
logger.debug(f"Applied credential '{credential.name}' to tool request")
else:
logger.warning(
@ -266,17 +288,31 @@ async def execute_http_tool(
except Exception as e:
logger.error(f"Failed to fetch credential for tool '{tool.name}': {e}")
request_headers: Dict[str, str] = {}
if include_request_headers:
request_headers = {str(name): str(value) for name, value in headers.items()}
for header_name, header_value in credential_headers.items():
request_headers[header_name] = mask_key(str(header_value))
def build_result(result: Dict[str, Any]) -> Dict[str, Any]:
if include_request_headers:
return {**result, "request_headers": request_headers}
return result
# Get timeout
timeout_ms = config.get("timeout_ms", 5000)
timeout_seconds = timeout_ms / 1000
try:
preset_arguments = _resolve_preset_parameters(
config, call_context_vars, gathered_context_vars
)
except ValueError as e:
logger.error(f"Custom tool '{tool.name}' preset parameter error: {e}")
return {"status": "error", "error": str(e)}
if preset_params is None:
try:
preset_arguments = _resolve_preset_parameters(
config, call_context_vars, gathered_context_vars
)
except ValueError as e:
logger.error(f"Custom tool '{tool.name}' preset parameter error: {e}")
return build_result({"status": "error", "error": str(e)})
else:
preset_arguments = dict(preset_params)
resolved_arguments = {**(arguments or {}), **preset_arguments}
@ -286,7 +322,7 @@ async def execute_http_tool(
if method in ("POST", "PUT", "PATCH"):
body = resolved_arguments
elif method in ("GET", "DELETE") and resolved_arguments:
params = resolved_arguments
params = serialize_query_params(resolved_arguments)
logger.info(
f"Executing custom tool '{tool.name}' ({tool.tool_uuid}): {method} {url}"
@ -322,23 +358,29 @@ async def execute_http_tool(
logger.debug(
f"Custom tool '{tool.name}' completed with status {response.status_code}"
)
return result
return build_result(result)
except httpx.TimeoutException:
logger.error(f"Custom tool '{tool.name}' timed out after {timeout_seconds}s")
return {
"status": "error",
"error": f"Request timed out after {timeout_seconds} seconds",
}
return build_result(
{
"status": "error",
"error": f"Request timed out after {timeout_seconds} seconds",
}
)
except httpx.RequestError as e:
logger.error(f"Custom tool '{tool.name}' request failed: {e}")
return {
"status": "error",
"error": f"Request failed: {str(e)}",
}
return build_result(
{
"status": "error",
"error": f"Request failed: {str(e)}",
}
)
except Exception as e:
logger.error(f"Custom tool '{tool.name}' execution failed: {e}")
return {
"status": "error",
"error": f"Tool execution failed: {str(e)}",
}
return build_result(
{
"status": "error",
"error": f"Tool execution failed: {str(e)}",
}
)

View file

@ -30,6 +30,7 @@ from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.llm_service import FunctionCallParams
from api.enums import WorkflowRunMode
from api.services.configuration.masking import mask_key
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
from api.services.workflow.tools.custom_tool import (
_coerce_parameter_value,
@ -430,6 +431,7 @@ class TestExecuteHttpTool:
assert result["status"] == "success"
assert result["status_code"] == 201
assert result["data"]["id"] == 123
assert "request_headers" not in result
@pytest.mark.asyncio
async def test_post_request_sends_nested_json_body(self):
@ -546,6 +548,55 @@ class TestExecuteHttpTool:
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_post_request_accepts_pre_resolved_preset_params(self):
"""The test endpoint can supply preset values without call context."""
tool = MockToolModel(
tool_uuid="test-uuid-preset-override",
name="Create Lead",
description="Create a lead with caller context",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "POST",
"url": "https://api.example.com/leads",
"timeout_ms": 5000,
"preset_parameters": [
{
"name": "phone_number",
"type": "string",
"value_template": "{{initial_context.phone_number}}",
"required": True,
}
],
},
},
)
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": 123}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await execute_http_tool(
tool,
{"name": "John"},
preset_params={"phone_number": "+14155550123"},
)
assert mock_client.request.call_args.kwargs["json"] == {
"name": "John",
"phone_number": "+14155550123",
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_missing_required_preset_parameter_returns_error(self):
"""Test that required preset parameters fail before the HTTP request."""
@ -619,6 +670,52 @@ class TestExecuteHttpTool:
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_get_request_serializes_object_and_array_query_params(self):
"""Object/array-typed arguments must be JSON-stringified for GET query
params httpx raises a TypeError if a dict/list is passed as-is."""
tool = MockToolModel(
tool_uuid="test-uuid",
name="Search Users",
description="Search for users",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "GET",
"url": "https://api.example.com/users/search",
"timeout_ms": 5000,
},
},
)
arguments = {
"source": "voice",
"metadata": {"campaign": "spring"},
"tags": ["a", "b"],
}
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 = {"users": []}
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["params"] == {
"source": "voice",
"metadata": '{"campaign": "spring"}',
"tags": '["a", "b"]',
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_get_request_without_arguments_preserves_url_query_params(self):
"""Empty runtime args should not override query params already in the URL."""
@ -762,11 +859,17 @@ class TestExecuteHttpTool:
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
await execute_http_tool(tool, {"data": "test"})
result = await execute_http_tool(
tool, {"data": "test"}, include_request_headers=True
)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["headers"]["X-API-Key"] == "secret-key"
assert call_kwargs["headers"]["X-Custom-Header"] == "custom-value"
assert result["request_headers"] == {
"X-API-Key": "secret-key",
"X-Custom-Header": "custom-value",
}
@pytest.mark.asyncio
async def test_request_includes_auth_header_from_credential(self):
@ -807,7 +910,12 @@ class TestExecuteHttpTool:
with patch("api.services.workflow.tools.custom_tool.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=mock_credential)
await execute_http_tool(tool, {"data": "test"}, organization_id=1)
result = await execute_http_tool(
tool,
{"data": "test"},
organization_id=1,
include_request_headers=True,
)
# Verify credential was fetched
mock_db.get_credential_by_uuid.assert_called_once_with(
@ -819,6 +927,12 @@ class TestExecuteHttpTool:
assert (
call_kwargs["headers"]["Authorization"] == "Bearer my-secret-token"
)
assert result["request_headers"]["Authorization"] == mask_key(
"Bearer my-secret-token"
)
assert (
"my-secret-token" not in result["request_headers"]["Authorization"]
)
@pytest.mark.asyncio
async def test_no_credential_lookup_without_organization_id(self):

View file

@ -26,9 +26,15 @@ from api.routes.tool import (
CreateToolRequest,
McpToolConfig,
McpToolDefinition,
ToolTestRequest,
ToolTestResponse,
UpdateToolRequest,
_populate_discovered_tools,
refresh_mcp_tools,
router,
)
from api.routes.tool import (
test_tool as call_test_tool_route,
)
from api.services.workflow.tools.mcp_tool import (
validate_mcp_definition,
@ -420,6 +426,314 @@ def _mcp_tool_model(org_id=1):
return t
def _http_tool_model(method="GET"):
t = MagicMock()
t.tool_uuid = "tu-http"
t.name = "Mock HTTP"
t.category = "http_api"
t.definition = {
"schema_version": 1,
"type": "http_api",
"config": {"method": method, "url": "https://example.com/search"},
}
return t
@pytest.mark.asyncio
async def test_tool_executes_http_api_tool_with_llm_and_preset_params(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model()
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
executor = AsyncMock(
return_value={
"status": "success",
"status_code": 200,
"data": {"ok": True},
}
)
monkeypatch.setattr(tool_route, "execute_http_tool", executor)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(
llm_params={"query": "cart"},
preset_params={
"customer_id": "c_123",
"sentiment": "cooperative",
},
),
user=_fake_user(),
)
assert resp.status == "success"
assert resp.status_code == 200
assert resp.data == {"ok": True}
assert resp.error is None
executor.assert_awaited_once_with(
tool,
{"query": "cart"},
preset_params={
"customer_id": "c_123",
"sentiment": "cooperative",
},
organization_id=1,
include_request_headers=True,
)
assert resp.hint is None
assert resp.request_method == "GET"
assert resp.request_url == "https://example.com/search"
assert resp.request_headers == {}
assert resp.request_body is None
assert resp.request_params == {
"query": "cart",
"customer_id": "c_123",
"sentiment": "cooperative",
}
@pytest.mark.asyncio
async def test_tool_test_sets_request_body_for_post_method(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "success", "status_code": 200, "data": {"id": 1}}
),
)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(llm_params={"name": "Ada"}),
user=_fake_user(),
)
assert resp.request_method == "POST"
assert resp.request_body == {"name": "Ada"}
assert resp.request_params is None
@pytest.mark.asyncio
async def test_tool_test_returns_masked_effective_request_headers(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={
"status": "success",
"status_code": 200,
"data": {"ok": True},
"request_headers": {
"X-Tenant": "acme",
"Authorization": "****************oken",
},
}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.request_headers == {
"X-Tenant": "acme",
"Authorization": "****************oken",
}
@pytest.mark.asyncio
async def test_tool_test_request_body_includes_resolved_preset_parameters(
monkeypatch,
):
"""The Request preview includes direct preset values alongside LLM values."""
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
tool.definition["config"]["preset_parameters"] = [
{
"name": "source",
"type": "string",
"value_template": "{{initial_context.metadata.channel}}",
"required": True,
}
]
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "success", "status_code": 200, "data": {"ok": True}}
),
)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(
llm_params={"name": "Ada"},
preset_params={"source": "web_widget"},
),
user=_fake_user(),
)
assert resp.request_body == {"name": "Ada", "source": "web_widget"}
@pytest.mark.asyncio
async def test_tool_test_no_arguments_post_shows_empty_body(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(return_value={"status": "success", "status_code": 200, "data": None}),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
# POST with no arguments sends json={} over the wire; preview must show {}
# so callers can distinguish an absent body from an empty one.
assert resp.request_body == {}
assert resp.request_params is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,expected_snippet",
[
(400, "HTTP 400 Bad Request"),
(401, "HTTP 401 Unauthorized"),
(403, "HTTP 403 Forbidden"),
(404, "HTTP 404 Not Found"),
(405, "HTTP 405 Method Not Allowed"),
(408, "HTTP 408 Request Timeout"),
(409, "HTTP 409 Conflict"),
(415, "HTTP 415 Unsupported Media Type"),
(422, "HTTP 422 Unprocessable Entity"),
(429, "HTTP 429 Too Many Requests"),
(500, "HTTP 500"),
(503, "HTTP 503"),
],
)
async def test_tool_test_hint_for_status_code(
monkeypatch, status_code, expected_snippet
):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={
"status": "error",
"status_code": status_code,
"error": "boom",
}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(llm_params={"a": 1}), user=_fake_user()
)
assert resp.hint is not None
assert resp.hint.startswith(expected_snippet)
@pytest.mark.asyncio
async def test_tool_test_no_hint_on_success(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="GET")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(return_value={"status": "success", "status_code": 200, "data": {}}),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.hint is None
@pytest.mark.asyncio
async def test_tool_test_no_hint_for_uncovered_status_code(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="GET")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "error", "status_code": 418, "error": "teapot"}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.hint is None
def test_tool_test_route_is_registered():
assert any(
route.path == "/tools/{tool_uuid}/test" and "POST" in route.methods
for route in router.routes
)
@pytest.mark.asyncio
async def test_tool_rejects_non_http_api_tool(monkeypatch):
import api.routes.tool as tool_route
monkeypatch.setattr(
tool_route.db_client,
"get_tool_by_uuid",
AsyncMock(return_value=_mcp_tool_model()),
)
with pytest.raises(HTTPException) as ei:
await call_test_tool_route(
"tu-mcp", request=ToolTestRequest(), user=_fake_user()
)
assert ei.value.status_code == 400
@pytest.mark.asyncio
async def test_refresh_success(monkeypatch):
import api.services.tool_management as tool_svc
@ -484,3 +798,41 @@ async def test_refresh_not_found_is_404(monkeypatch):
with pytest.raises(HTTPException) as ei:
await refresh_mcp_tools("nope", user=_fake_user())
assert ei.value.status_code == 404
def test_tool_test_response_has_hint_and_request_fields():
"""ToolTestResponse must carry hint + request_method/url/body/params
so the frontend can show what was sent and why it may have failed."""
resp = ToolTestResponse(
status="error",
status_code=405,
data=None,
error="Method Not Allowed",
duration_ms=12,
hint="HTTP 405 Method Not Allowed — the endpoint rejected the configured method (POST).",
request_method="POST",
request_url="https://example.com/thing",
request_headers={"Authorization": "********oken"},
request_body={"a": 1},
request_params=None,
)
assert resp.hint.startswith("HTTP 405")
assert resp.request_method == "POST"
assert resp.request_url == "https://example.com/thing"
assert resp.request_headers == {"Authorization": "********oken"}
assert resp.request_body == {"a": 1}
assert resp.request_params is None
def test_tool_test_response_request_fields_default_to_none_or_required():
"""hint/request_body/request_params are optional; request_method/url are required."""
resp = ToolTestResponse(
status="success",
duration_ms=5,
request_method="GET",
request_url="https://example.com/thing",
)
assert resp.hint is None
assert resp.request_headers == {}
assert resp.request_body is None
assert resp.request_params is None

View file

@ -0,0 +1,40 @@
"""Pagination bounds for the workflow-run and campaign-run list endpoints.
Regression for issue #553: `limit=0` raised an unhandled ZeroDivisionError
(HTTP 500) in the `total_pages` computation, and negative `limit`/`page`
produced nonsensical pagination. Both endpoints now validate the params
(`limit` in [1, 100], `page` >= 1), matching the sibling list endpoints.
"""
import pytest
async def _make_user(db_session, slug: str):
user, _ = await db_session.get_or_create_user_by_provider_id(f"{slug}_user")
org, _ = await db_session.get_or_create_organization_by_provider_id(
f"{slug}_org", user.id
)
await db_session.update_user_selected_organization(user.id, org.id)
return await db_session.get_user_by_id(user.id)
@pytest.mark.parametrize(
"path",
[
"/api/v1/workflow/1/runs",
"/api/v1/campaign/1/runs",
],
)
@pytest.mark.parametrize("query", ["limit=0", "limit=-5", "limit=101", "page=0"])
async def test_run_list_rejects_out_of_range_pagination(
test_client_factory, db_session, path, query
):
"""Out-of-range limit/page is a 422 validation error, never a 500."""
user = await _make_user(db_session, "paginate_bounds")
async with test_client_factory(user) as client:
response = await client.get(f"{path}?{query}")
assert response.status_code == 422, (
f"{path}?{query} expected 422, got {response.status_code}: {response.text}"
)