Merge remote-tracking branch 'origin/main' into review-560

# Conflicts:
#	api/services/telephony/ari_manager.py
This commit is contained in:
Abhishek Kumar 2026-07-21 13:18:21 +05:30
commit 045a012c40
75 changed files with 5418 additions and 368 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,4 +1,5 @@
from typing import List, Optional
from copy import deepcopy
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
@ -74,6 +75,7 @@ from api.services.organization_context import (
get_organization_context,
)
from api.services.organization_preferences import (
external_pbx_integrations_enabled,
get_organization_preferences,
upsert_organization_preferences,
)
@ -101,14 +103,24 @@ def _sensitive_fields(provider_name: str) -> List[str]:
def _mask_sensitive(provider_name: str, value: dict) -> dict:
"""Return a copy of ``value`` with sensitive fields masked for display."""
out = dict(value)
out = deepcopy(value)
for field_name in _sensitive_fields(provider_name):
v = out.get(field_name)
v = _get_nested_field(out, field_name)
if v:
out[field_name] = mask_key(v)
_set_nested_field(out, field_name, mask_key(str(v)))
return out
class TelephonyProviderUIOption(BaseModel):
value: str
label: str
class TelephonyProviderUICondition(BaseModel):
field: str
equals: Any
class TelephonyProviderUIField(BaseModel):
"""One form field on a telephony provider's configuration UI."""
@ -119,6 +131,9 @@ class TelephonyProviderUIField(BaseModel):
sensitive: bool
description: Optional[str] = None
placeholder: Optional[str] = None
options: Optional[List[TelephonyProviderUIOption]] = None
visible_when: Optional[TelephonyProviderUICondition] = None
section: Optional[str] = None
class TelephonyProviderMetadata(BaseModel):
@ -184,6 +199,9 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
external_pbx_enabled = await external_pbx_integrations_enabled(
user.selected_organization_id
)
providers = []
for spec in telephony_registry.all_specs():
if spec.ui_metadata is None:
@ -201,8 +219,30 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
sensitive=f.sensitive,
description=f.description,
placeholder=f.placeholder,
options=(
[
{"value": option.value, "label": option.label}
for option in f.options
]
if f.options
else None
),
visible_when=(
{
"field": f.visible_when.field,
"equals": f.visible_when.equals,
}
if f.visible_when
else None
),
section=f.section,
)
for f in spec.ui_metadata.fields
if not f.feature_gate
or (
f.feature_gate == "external_pbx_integrations"
and external_pbx_enabled
)
],
docs_url=spec.ui_metadata.docs_url,
)
@ -496,24 +536,34 @@ async def get_model_configuration_preferences_legacy(
return await get_preferences(user=user)
@router.put(
"/model-configurations/preferences",
response_model=OrganizationPreferences,
include_in_schema=False,
)
async def save_model_configuration_preferences_legacy(
request: OrganizationPreferences,
user: UserModel = Depends(get_user_with_selected_organization),
):
return await save_preferences(request=request, user=user)
def preserve_masked_fields(provider: str, request_dict: dict, existing: dict):
"""If the client re-submitted a masked sensitive field, restore the original."""
for field_name in _sensitive_fields(provider):
v = request_dict.get(field_name)
if v and is_mask_of(v, existing.get(field_name, "")):
request_dict[field_name] = existing[field_name]
v = _get_nested_field(request_dict, field_name)
existing_value = _get_nested_field(existing, field_name)
if v and is_mask_of(v, existing_value or ""):
_set_nested_field(request_dict, field_name, existing_value)
def _get_nested_field(value: dict, dotted_path: str):
current = value
for part in dotted_path.split("."):
if not isinstance(current, dict):
return None
current = current.get(part)
return current
def _set_nested_field(value: dict, dotted_path: str, field_value) -> None:
current = value
parts = dotted_path.split(".")
for part in parts[:-1]:
child = current.get(part)
if not isinstance(child, dict):
child = {}
current[part] = child
current = child
current[parts[-1]] = field_value
def _credentials_from_payload(config: TelephonyConfigRequest) -> dict:

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,
@ -33,11 +36,16 @@ from api.services.tool_management import (
build_tool_response,
create_tool_for_user,
refresh_mcp_tool_for_user,
validate_external_pbx_tool_definition,
validate_tool_credential_references,
)
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")
@ -56,6 +64,8 @@ __all__ = [
"ToolDefinition",
"ToolParameter",
"ToolResponse",
"ToolTestRequest",
"ToolTestResponse",
"TransferCallConfig",
"TransferCallToolDefinition",
"UpdateToolRequest",
@ -195,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,
@ -223,6 +376,18 @@ async def update_tool(
if request.definition:
definition = request.definition.model_dump()
try:
existing_tool = await db_client.get_tool_by_uuid(
tool_uuid,
user.selected_organization_id,
include_archived=True,
)
await validate_external_pbx_tool_definition(
definition,
organization_id=user.selected_organization_id,
existing_definition=(
existing_tool.definition if existing_tool else None
),
)
await validate_tool_credential_references(
definition,
organization_id=user.selected_organization_id,

View file

@ -44,6 +44,11 @@ from api.services.mps_service_key_client import mps_service_key_client
from api.services.posthog_client import capture_event
from api.services.reports import generate_workflow_report_csv
from api.services.storage import storage_fs
from api.services.workflow.configuration_policy import (
ExternalPBXConfigurationDisabledError,
WorkflowConfigurationNotFoundError,
apply_external_pbx_mapping_policy,
)
from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition
from api.services.workflow.duplicate import duplicate_workflow
from api.services.workflow.errors import ItemKind, WorkflowError
@ -1051,6 +1056,16 @@ async def update_workflow(
if request.workflow_configurations is not None
else None
)
try:
workflow_configurations = await apply_external_pbx_mapping_policy(
workflow_configurations,
workflow_id=workflow_id,
organization_id=user.selected_organization_id,
)
except WorkflowConfigurationNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ExternalPBXConfigurationDisabledError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
if workflow_configurations and workflow_configurations.get(
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
):
@ -1407,8 +1422,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')"