mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-04 10:52:17 +02:00
transfer call
This commit is contained in:
parent
5d14d17ceb
commit
51adfdda66
41 changed files with 2633 additions and 167 deletions
|
|
@ -4,13 +4,22 @@ Consolidated from split modules for easier maintenance.
|
|||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
Form,
|
||||
Header,
|
||||
HTTPException,
|
||||
Request,
|
||||
WebSocket,
|
||||
)
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.future import select
|
||||
from starlette.responses import HTMLResponse
|
||||
|
|
@ -26,6 +35,8 @@ from api.services.auth.depends import get_user
|
|||
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
|
||||
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
|
||||
from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id
|
||||
from api.services.telephony.transfer_coordination import get_transfer_coordinator
|
||||
from api.services.telephony.transfer_event_protocol import TransferContext
|
||||
from api.services.telephony.factory import (
|
||||
get_all_telephony_providers,
|
||||
get_telephony_provider,
|
||||
|
|
@ -37,10 +48,18 @@ from api.utils.telephony_helper import (
|
|||
numbers_match,
|
||||
parse_webhook_request,
|
||||
)
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/telephony")
|
||||
|
||||
# Module-level storage for webhook-driven function call completion
|
||||
# Stores function call contexts that are waiting for webhook completion
|
||||
pending_function_calls: Dict[str, tuple[FunctionCallParams, float]] = {}
|
||||
|
||||
# Note: Transfer contexts now stored in Redis via TransferCoordinator
|
||||
# pending_transfers dictionary removed in favor of Redis-based coordination
|
||||
|
||||
|
||||
class InitiateCallRequest(BaseModel):
|
||||
workflow_id: int
|
||||
|
|
@ -157,7 +176,8 @@ async def initiate_call(
|
|||
if not phone_number:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Phone number must be provided in request or set in user configuration",
|
||||
detail="Phone number must be provided in request or set in user "
|
||||
"configuration",
|
||||
)
|
||||
|
||||
workflow_run_id = request.workflow_run_id
|
||||
|
|
@ -473,12 +493,13 @@ async def _validate_organization_provider_config(
|
|||
|
||||
@router.post("/twiml", include_in_schema=False)
|
||||
async def handle_twiml_webhook(
|
||||
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
|
||||
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int, CallSid: str = Form(...)
|
||||
):
|
||||
"""
|
||||
Handle initial webhook from telephony provider.
|
||||
Returns provider-specific response (e.g., TwiML for Twilio).
|
||||
"""
|
||||
logger.info(f"[TWIML-DEBUG] CallSid received: {CallSid}")
|
||||
|
||||
provider = await get_telephony_provider(organization_id)
|
||||
|
||||
|
|
@ -489,6 +510,26 @@ async def handle_twiml_webhook(
|
|||
return HTMLResponse(content=response_content, media_type="application/xml")
|
||||
|
||||
|
||||
@router.post("/transfer-twiml/{conference_name}", include_in_schema=False)
|
||||
async def transfer_twiml(conference_name: str):
|
||||
"""
|
||||
TwiML endpoint that puts the caller into a conference.
|
||||
Called by Twilio when we redirect the call after closing the WebSocket stream.
|
||||
"""
|
||||
logger.info(f"[TRANSFER-TWIML] Generating conference TwiML for: {conference_name}")
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>Connecting you now.</Say>
|
||||
<Dial>
|
||||
<Conference endConferenceOnExit="false" startConferenceOnEnter="true">{conference_name}</Conference>
|
||||
</Dial>
|
||||
</Response>"""
|
||||
|
||||
logger.info(f"[TRANSFER-TWIML] Generated TwiML: {twiml_content}")
|
||||
return HTMLResponse(content=twiml_content, media_type="application/xml")
|
||||
|
||||
|
||||
@router.get("/ncco", include_in_schema=False)
|
||||
async def handle_ncco_webhook(
|
||||
workflow_id: int,
|
||||
|
|
@ -1385,6 +1426,7 @@ async def handle_inbound_telephony(
|
|||
logger.info(
|
||||
f"Generated {normalized_data.provider} response for call {normalized_data.call_id}"
|
||||
)
|
||||
logger.info(f"response is {response}")
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
|
|
@ -1427,8 +1469,6 @@ async def handle_inbound_fallback(request: Request):
|
|||
)
|
||||
|
||||
return generic_hangup_response()
|
||||
|
||||
|
||||
@router.post("/cloudonix/cdr")
|
||||
async def handle_cloudonix_cdr(request: Request):
|
||||
"""Handle Cloudonix CDR (Call Detail Record) webhooks.
|
||||
|
|
@ -1479,4 +1519,366 @@ async def handle_cloudonix_cdr(request: Request):
|
|||
f"disposition: {cdr_data.get('disposition')}, status: {status_update.status}"
|
||||
)
|
||||
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
class CallTransferRequest(BaseModel):
|
||||
"""Request model for call transfer"""
|
||||
target_phone_number: Optional[str] = None
|
||||
phone_number: Optional[str] = None # Alternative field name
|
||||
number: Optional[str] = None # Another alternative
|
||||
current_call_sid: Optional[str] = None
|
||||
|
||||
def get_target_number(self) -> str:
|
||||
"""Get the target phone number from any of the possible fields"""
|
||||
return self.target_phone_number or self.phone_number or self.number or ""
|
||||
|
||||
|
||||
class TransferCallRequest(BaseModel):
|
||||
"""Request model for initiating call transfer using webhook-driven completion"""
|
||||
destination: str # E.164 format phone number (required)
|
||||
organization_id: int # Organization ID for provider configuration
|
||||
timeout: Optional[int] = 20 # seconds to wait for answer
|
||||
tool_call_id: Optional[str] = None # will generate if not provided
|
||||
tool_uuid: Optional[str] = None # tool UUID for tracing and validation
|
||||
original_call_sid: Optional[str] = None # original caller's call SID
|
||||
caller_number: Optional[str] = None # original caller's phone number
|
||||
|
||||
@field_validator("destination")
|
||||
@classmethod
|
||||
def validate_destination(cls, destination: str) -> str:
|
||||
"""Validate destination is in E.164 format."""
|
||||
import re
|
||||
if not destination or not destination.strip():
|
||||
raise ValueError("Destination phone number is required")
|
||||
|
||||
e164_pattern = r"^\+[1-9]\d{1,14}$"
|
||||
if not re.match(e164_pattern, destination.strip()):
|
||||
raise ValueError(f"Invalid phone number format: {destination}. Must be E.164 format (e.g., +1234567890)")
|
||||
|
||||
return destination.strip()
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/call-transfer")
|
||||
async def initiate_call_transfer(request: TransferCallRequest):
|
||||
"""Initiate call transfer without blocking the pipeline"""
|
||||
import aiohttp
|
||||
|
||||
logger.info(f"Received call transfer request: {request}")
|
||||
# Generate tool_call_id if not provided
|
||||
if not request.tool_call_id:
|
||||
request.tool_call_id = f"transfer_{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Log tool details for tracing
|
||||
logger.info(f"Starting non-blocking call transfer to {request.destination} with tool_call_id: {request.tool_call_id}, tool_uuid: {request.tool_uuid}")
|
||||
|
||||
# TODO: Add tool UUID validation here if needed
|
||||
# For example: Validate that the tool UUID corresponds to a valid transfer call tool
|
||||
# and that the destination matches the tool's configured destination pattern
|
||||
|
||||
try:
|
||||
# Get provider that supports transfers (validates Twilio-only requirement)
|
||||
from api.services.telephony.factory import get_transfer_provider
|
||||
|
||||
try:
|
||||
provider = await get_transfer_provider(request.organization_id)
|
||||
except ValueError as e:
|
||||
# Provider doesn't support transfers or organization not configured
|
||||
logger.error(f"Transfer provider validation failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Call transfer not supported: {str(e)}"
|
||||
)
|
||||
|
||||
# Validate configuration before attempting transfer
|
||||
if not provider.validate_config():
|
||||
logger.error(f"Provider {provider.PROVIDER_NAME} configuration is invalid")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Telephony provider '{provider.PROVIDER_NAME}' is not properly configured for transfers"
|
||||
)
|
||||
|
||||
# Initiate transfer call via provider
|
||||
logger.info(f"Initiating transfer call via {provider.PROVIDER_NAME} provider")
|
||||
try:
|
||||
transfer_result = await provider.transfer_call(
|
||||
destination=request.destination,
|
||||
tool_call_id=request.tool_call_id,
|
||||
timeout=request.timeout
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
# This shouldn't happen due to get_transfer_provider validation, but safety check
|
||||
logger.error(f"Provider {provider.PROVIDER_NAME} doesn't support transfers: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers"
|
||||
)
|
||||
except Exception as e:
|
||||
# Provider API call failed
|
||||
logger.error(f"Provider transfer call failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Transfer call failed: {str(e)}"
|
||||
)
|
||||
|
||||
call_sid = transfer_result.get("call_sid")
|
||||
logger.info(f"Transfer call initiated successfully: {call_sid}")
|
||||
logger.info(f"Transfer result: {transfer_result}")
|
||||
|
||||
# Store the transfer context in Redis for webhook completion
|
||||
transfer_coordinator = await get_transfer_coordinator()
|
||||
transfer_context = TransferContext(
|
||||
tool_call_id=request.tool_call_id,
|
||||
call_sid=call_sid,
|
||||
target_number=request.destination,
|
||||
tool_uuid=request.tool_uuid,
|
||||
original_call_sid=request.original_call_sid,
|
||||
caller_number=request.caller_number,
|
||||
initiated_at=time.time(),
|
||||
workflow_run_id=0 # TODO: Add workflow_run_id to request if needed
|
||||
)
|
||||
await transfer_coordinator.store_transfer_context(transfer_context)
|
||||
|
||||
# Return immediately without blocking
|
||||
return {
|
||||
"status": "transfer_initiated",
|
||||
"call_id": call_sid,
|
||||
"message": f"Calling {request.destination}...",
|
||||
"tool_call_id": request.tool_call_id,
|
||||
"provider": provider.PROVIDER_NAME
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (already properly formatted)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Catch any other unexpected errors
|
||||
logger.error(f"Unexpected error during transfer call: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal error during transfer: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/transfer-call-handler/{tool_call_id}")
|
||||
async def handle_transfer_call_answered(tool_call_id: str, request: Request):
|
||||
"""Handle when target answers the transfer call"""
|
||||
logger.info(f"Transfer call answered for tool_call_id: {tool_call_id}")
|
||||
|
||||
form_data = await request.form()
|
||||
data = dict(form_data)
|
||||
call_sid = data.get("CallSid", "")
|
||||
|
||||
# Get transfer context from Redis
|
||||
transfer_coordinator = await get_transfer_coordinator()
|
||||
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
|
||||
|
||||
original_call_sid = transfer_context.original_call_sid if transfer_context else None
|
||||
caller_number = transfer_context.caller_number if transfer_context else None
|
||||
|
||||
# Use original call SID for conference name if available, otherwise fall back to transfer call SID
|
||||
base_call_sid = original_call_sid or call_sid
|
||||
conference_name = f"transfer-{base_call_sid}"
|
||||
|
||||
logger.info(f"Using conference name: {conference_name}")
|
||||
|
||||
# Publish Redis event for transfer answer completion
|
||||
try:
|
||||
# Get transfer coordinator and context
|
||||
transfer_coordinator = await get_transfer_coordinator()
|
||||
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
|
||||
|
||||
if transfer_context:
|
||||
# Create transfer answered event
|
||||
from api.services.telephony.transfer_event_protocol import TransferEvent, TransferEventType
|
||||
|
||||
transfer_event = TransferEvent(
|
||||
type=TransferEventType.TRANSFER_ANSWERED,
|
||||
tool_call_id=tool_call_id,
|
||||
workflow_run_id=transfer_context.workflow_run_id,
|
||||
original_call_sid=original_call_sid,
|
||||
transfer_call_sid=call_sid,
|
||||
conference_name=conference_name,
|
||||
message="Great! The person answered. Let me transfer you now.",
|
||||
status="success",
|
||||
action="transfer_success"
|
||||
)
|
||||
|
||||
# Publish the event to Redis
|
||||
await transfer_coordinator.publish_transfer_event(transfer_event)
|
||||
logger.info(f"Published TRANSFER_ANSWERED event for {tool_call_id}")
|
||||
|
||||
else:
|
||||
logger.warning(f"No transfer context found for {tool_call_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error publishing transfer answered event for {tool_call_id}: {e}")
|
||||
|
||||
# Return TwiML to put the answerer into the conference
|
||||
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>You have answered a transfer call. Connecting you now.</Say>
|
||||
<Dial>
|
||||
<Conference>{conference_name}</Conference>
|
||||
</Dial>
|
||||
</Response>"""
|
||||
|
||||
return HTMLResponse(content=twiml, media_type="application/xml")
|
||||
|
||||
|
||||
@router.post("/transfer-result/{tool_call_id}")
|
||||
async def complete_transfer_function_call(tool_call_id: str, request: Request):
|
||||
"""Webhook endpoint to complete the function call with transfer result"""
|
||||
form_data = await request.form()
|
||||
data = dict(form_data)
|
||||
|
||||
call_status = data.get("CallStatus", "")
|
||||
call_sid = data.get("CallSid", "")
|
||||
|
||||
logger.info(f"Transfer result webhook: {tool_call_id} status={call_status}")
|
||||
|
||||
# Note: All transfer coordination now handled via Redis events
|
||||
|
||||
# If this is a "completed" status but we already handled the success case via answer webhook,
|
||||
# we should still publish the COMPLETED event but not override previous results
|
||||
if call_status == "completed":
|
||||
# Check if we already published an ANSWERED event
|
||||
logger.info(f"Processing 'completed' status for {tool_call_id}")
|
||||
# We'll still publish COMPLETED event but with different handling
|
||||
|
||||
# Import required event classes
|
||||
from api.services.telephony.transfer_event_protocol import TransferEvent, TransferEventType
|
||||
|
||||
# Get transfer context from Redis for additional information
|
||||
transfer_coordinator = await get_transfer_coordinator()
|
||||
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
|
||||
|
||||
original_call_sid = transfer_context.original_call_sid if transfer_context else None
|
||||
caller_number = transfer_context.caller_number if transfer_context else None
|
||||
|
||||
|
||||
# Determine the result based on call status with user-friendly messaging
|
||||
if call_status == "answered":
|
||||
# Use original call SID for conference name if available, otherwise fall back to transfer call SID
|
||||
base_call_sid = original_call_sid or call_sid
|
||||
conference_name = f"transfer-{base_call_sid}"
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"message": "Great! The person answered. Let me transfer you now.",
|
||||
"action": "transfer_success",
|
||||
"conference_id": conference_name,
|
||||
"transfer_call_sid": call_sid, # The outbound transfer call SID
|
||||
"original_call_sid": original_call_sid, # The original caller's SID
|
||||
"caller_number": caller_number,
|
||||
"end_call": False # Continue with transfer
|
||||
}
|
||||
elif call_status == "no-answer":
|
||||
result = {
|
||||
"status": "transfer_failed",
|
||||
"reason": "no_answer",
|
||||
"message": "The transfer call was not answered. The person may be busy or unavailable right now.",
|
||||
"action": "transfer_failed",
|
||||
"call_sid": call_sid,
|
||||
"end_call": True
|
||||
}
|
||||
elif call_status == "busy":
|
||||
result = {
|
||||
"status": "transfer_failed",
|
||||
"reason": "busy",
|
||||
"message": "The transfer call encountered a busy signal. The person is likely on another call.",
|
||||
"action": "transfer_failed",
|
||||
"call_sid": call_sid,
|
||||
"end_call": True
|
||||
}
|
||||
elif call_status == "failed":
|
||||
result = {
|
||||
"status": "transfer_failed",
|
||||
"reason": "call_failed",
|
||||
"message": "The transfer call failed to connect. There may be a network issue or the number is unavailable.",
|
||||
"action": "transfer_failed",
|
||||
"call_sid": call_sid,
|
||||
"end_call": True
|
||||
}
|
||||
elif call_status == "completed":
|
||||
# Call completed successfully
|
||||
result = {
|
||||
"status": "completed",
|
||||
"message": "Your call has been completed successfully.",
|
||||
"action": "transfer_completed",
|
||||
"call_sid": call_sid,
|
||||
"end_call": True # End call normally
|
||||
}
|
||||
else:
|
||||
# Intermediate status (ringing, in-progress, etc.), don't complete yet
|
||||
logger.info(f"Received intermediate status {call_status}, waiting for final status")
|
||||
return {"status": "pending"}
|
||||
|
||||
# Complete the function call with Redis event publishing
|
||||
try:
|
||||
# Determine event type based on result status
|
||||
if result["status"] == "success":
|
||||
event_type = TransferEventType.TRANSFER_COMPLETED
|
||||
elif result.get("reason") == "timeout":
|
||||
event_type = TransferEventType.TRANSFER_TIMEOUT
|
||||
else:
|
||||
event_type = TransferEventType.TRANSFER_FAILED
|
||||
|
||||
# Create and publish transfer event
|
||||
# Add caller_number to result if not already present
|
||||
if "caller_number" not in result and caller_number:
|
||||
result["caller_number"] = caller_number
|
||||
|
||||
transfer_event = TransferEvent(
|
||||
type=event_type,
|
||||
tool_call_id=tool_call_id,
|
||||
workflow_run_id=0, # TODO: Extract from context if needed
|
||||
original_call_sid=original_call_sid or "",
|
||||
transfer_call_sid=call_sid,
|
||||
conference_name=result.get("conference_id"),
|
||||
message=result.get("message", ""),
|
||||
status=result["status"],
|
||||
action=result.get("action", ""),
|
||||
reason=result.get("reason"),
|
||||
end_call=result.get("end_call", False)
|
||||
)
|
||||
|
||||
# Publish the event via Redis
|
||||
await transfer_coordinator.publish_transfer_event(transfer_event)
|
||||
logger.info(f"Published {event_type} event for {tool_call_id}")
|
||||
|
||||
|
||||
# Clean up transfer context from Redis
|
||||
await transfer_coordinator.remove_transfer_context(tool_call_id)
|
||||
|
||||
logger.info(f"Function call {tool_call_id} completed with result: {result['status']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error completing function call {tool_call_id}: {e}")
|
||||
|
||||
return {"status": "completed", "result": result}
|
||||
|
||||
|
||||
@router.post("/register-transfer-tool-call")
|
||||
async def register_transfer_tool_call(request: Request):
|
||||
"""Register a pending transfer function call for webhook completion"""
|
||||
data = await request.json()
|
||||
|
||||
tool_call_id = data.get("tool_call_id")
|
||||
function_call_params = data.get("function_call_params")
|
||||
|
||||
if not tool_call_id or not function_call_params:
|
||||
raise HTTPException(status_code=400, detail="Missing required fields")
|
||||
|
||||
# Store the function call context for webhook completion
|
||||
pending_function_calls[tool_call_id] = (function_call_params, time.time())
|
||||
|
||||
logger.info(f"Registered transfer tool call: {tool_call_id}")
|
||||
|
||||
return {"status": "registered", "tool_call_id": tool_call_id}
|
||||
|
||||
|
||||
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
|
|
@ -56,6 +57,42 @@ class EndCallConfig(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class TransferCallConfig(BaseModel):
|
||||
"""Configuration for Transfer Call tools."""
|
||||
|
||||
destination: str = Field(
|
||||
description="Phone number to transfer the call to (E.164 format, e.g., +1234567890)"
|
||||
)
|
||||
messageType: Literal["none", "custom"] = Field(
|
||||
default="none", description="Type of message to play before transfer"
|
||||
)
|
||||
customMessage: Optional[str] = Field(
|
||||
default=None, description="Custom message to play before transferring the call"
|
||||
)
|
||||
timeout: int = Field(
|
||||
default=30,
|
||||
ge=5,
|
||||
le=120,
|
||||
description="Maximum time in seconds to wait for destination to answer (5-120 seconds)"
|
||||
)
|
||||
|
||||
@field_validator("destination")
|
||||
@classmethod
|
||||
def validate_destination(cls, v: str) -> str:
|
||||
"""Validate that destination is a valid E.164 phone number."""
|
||||
# Allow empty string for initial creation (like HTTP API tools with empty URL)
|
||||
if not v.strip():
|
||||
return v
|
||||
|
||||
# E.164 format: +[1-9]\d{1,14}
|
||||
e164_pattern = r"^\+[1-9]\d{1,14}$"
|
||||
if not re.match(e164_pattern, v):
|
||||
raise ValueError(
|
||||
"Destination must be a valid E.164 phone number (e.g., +1234567890)"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class HttpApiToolDefinition(BaseModel):
|
||||
"""Tool definition for HTTP API tools."""
|
||||
|
||||
|
|
@ -72,9 +109,17 @@ class EndCallToolDefinition(BaseModel):
|
|||
config: EndCallConfig = Field(description="End Call configuration")
|
||||
|
||||
|
||||
class TransferCallToolDefinition(BaseModel):
|
||||
"""Tool definition for Transfer Call tools."""
|
||||
|
||||
schema_version: int = Field(default=1, description="Schema version")
|
||||
type: Literal["transfer_call"] = Field(description="Tool type")
|
||||
config: TransferCallConfig = Field(description="Transfer Call configuration")
|
||||
|
||||
|
||||
# Union type for tool definitions - Pydantic will discriminate based on 'type' field
|
||||
ToolDefinition = Annotated[
|
||||
Union[HttpApiToolDefinition, EndCallToolDefinition],
|
||||
Union[HttpApiToolDefinition, EndCallToolDefinition, TransferCallToolDefinition],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -89,6 +134,15 @@ class CreateToolRequest(BaseModel):
|
|||
icon_color: Optional[str] = Field(default="#3B82F6", max_length=7)
|
||||
definition: ToolDefinition
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def validate_category(cls, v: str) -> str:
|
||||
"""Validate that category is a valid ToolCategory value."""
|
||||
valid_categories = [c.value for c in ToolCategory]
|
||||
if v not in valid_categories:
|
||||
raise ValueError(f"Invalid category '{v}'. Must be one of: {', '.join(valid_categories)}")
|
||||
return v
|
||||
|
||||
|
||||
class UpdateToolRequest(BaseModel):
|
||||
"""Request schema for updating a tool."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue