feat: add google stt and tts. add folders to organize agents

This commit is contained in:
Abhishek Kumar 2026-05-22 14:36:50 +05:30
parent 21951eca18
commit ad2fa07058
52 changed files with 3412 additions and 621 deletions

View file

@ -0,0 +1,55 @@
from types import SimpleNamespace
from unittest.mock import patch
from pipecat.transcriptions.language import Language
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.service_factory import create_stt_service
def test_create_google_stt_service_uses_credentials_location_and_language():
user_config = SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials='{"project_id":"demo-project"}',
api_key=None,
model="latest_long",
language="en-US",
location="us-central1",
)
)
audio_config = SimpleNamespace(transport_in_sample_rate=16000)
with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service:
create_stt_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["credentials"] == '{"project_id":"demo-project"}'
assert kwargs["location"] == "us-central1"
assert kwargs["sample_rate"] == 16000
assert kwargs["settings"].model == "latest_long"
assert kwargs["settings"].languages == [Language.EN_US]
def test_create_google_stt_service_falls_back_to_raw_language_codes():
user_config = SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials=None,
api_key=None,
model="chirp_3",
language="cmn-Hans-CN",
location="global",
)
)
audio_config = SimpleNamespace(transport_in_sample_rate=24000)
with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service:
create_stt_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["sample_rate"] == 24000
assert kwargs["settings"].model == "chirp_3"
assert kwargs["settings"].language_codes == ["cmn-Hans-CN"]

View file

@ -0,0 +1,67 @@
from types import SimpleNamespace
from unittest.mock import patch
from pipecat.services.settings import NOT_GIVEN
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.service_factory import create_tts_service
def test_create_google_tts_service_uses_credentials_location_and_settings():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials='{"project_id":"demo-project"}',
api_key=None,
model="chirp_3_hd",
voice="en-US-Chirp3-HD-Charon",
language="en-US",
speed=1.15,
location="us-central1",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["credentials"] == '{"project_id":"demo-project"}'
assert kwargs["location"] == "us-central1"
assert kwargs["settings"].model == "chirp_3_hd"
assert kwargs["settings"].voice == "en-US-Chirp3-HD-Charon"
assert kwargs["settings"].language == "en-US"
assert kwargs["settings"].speaking_rate == 1.15
def test_create_google_tts_service_omits_default_speed():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials=None,
api_key=None,
model="chirp_3_hd",
voice="en-US-Chirp3-HD-Charon",
language="sw-KE",
speed=1.0,
location=None,
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["location"] is None
assert kwargs["settings"].model == "chirp_3_hd"
assert kwargs["settings"].language == "sw-KE"
assert kwargs["settings"].speaking_rate is NOT_GIVEN

View file

@ -186,6 +186,45 @@ const n = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" }));
update_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_invalid_trigger_path_surfaces_validation_error(mock_backends):
save_mock, update_mock = mock_backends
payload = {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": "support/west"},
}
],
"edges": [],
}
with (
patch(
"api.mcp_server.tools.save_workflow.parse_code",
AsyncMock(
return_value={
"ok": True,
"workflowName": _FakeWorkflowModel.name,
"workflow": payload,
}
),
),
patch(
"api.mcp_server.tools.save_workflow.reconcile_positions",
return_value=payload,
),
):
result = await save_workflow(workflow_id=1, code="ignored")
assert result["saved"] is False
assert result["error_code"] == "validation_error"
assert "single URL path segment" in result["error"]
save_mock.assert_not_awaited()
update_mock.assert_not_awaited()
# ─── Graph-stage rejections ──────────────────────────────────────────────

View file

@ -0,0 +1,191 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.public_agent import router
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
return app
def _active_workflow(*, trigger_path: str | None = None):
nodes = []
if trigger_path is not None:
nodes.append(
{
"type": "trigger",
"data": {"trigger_path": trigger_path},
}
)
return SimpleNamespace(
id=33,
user_id=99,
organization_id=11,
status="active",
workflow_uuid="workflow-uuid-123",
released_definition=SimpleNamespace(
workflow_json={"nodes": nodes, "edges": []}
),
)
def _provider():
return SimpleNamespace(
PROVIDER_NAME="twilio",
WEBHOOK_ENDPOINT="outbound",
validate_config=Mock(return_value=True),
initiate_call=AsyncMock(),
)
def test_trigger_route_executes_as_workflow_owner():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow(trigger_path="trigger-uuid-123")
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.public_agent.db_client") as mock_db,
patch(
"api.routes.public_agent.check_dograh_quota_by_user_id",
new=quota_mock,
),
patch(
"api.routes.public_agent.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.public_agent.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=7, organization_id=11, created_by=22)
)
mock_db.get_agent_trigger_by_path = AsyncMock(
return_value=SimpleNamespace(
workflow_id=workflow.id,
organization_id=11,
state="active",
)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501))
response = client.post(
"/public/agent/trigger-uuid-123",
headers={"X-API-Key": "test-api-key"},
json={"phone_number": "+15551234567"},
)
assert response.status_code == 200
quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id)
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["workflow_id"] == workflow.id
assert create_kwargs["user_id"] == workflow.user_id
assert create_kwargs["organization_id"] == workflow.organization_id
assert create_kwargs["initial_context"]["agent_uuid"] == "trigger-uuid-123"
assert create_kwargs["initial_context"]["agent_identifier"] == "trigger-uuid-123"
assert create_kwargs["initial_context"]["agent_identifier_type"] == "trigger_path"
assert create_kwargs["initial_context"]["workflow_uuid"] == workflow.workflow_uuid
assert create_kwargs["initial_context"]["api_key_id"] == 7
assert create_kwargs["initial_context"]["api_key_created_by"] == 22
initiate_kwargs = provider.initiate_call.await_args.kwargs
assert initiate_kwargs["workflow_id"] == workflow.id
assert initiate_kwargs["user_id"] == workflow.user_id
def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow()
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.public_agent.db_client") as mock_db,
patch(
"api.routes.public_agent.check_dograh_quota_by_user_id",
new=quota_mock,
),
patch(
"api.routes.public_agent.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.public_agent.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=8, organization_id=11, created_by=22)
)
mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=601))
response = client.post(
f"/public/agent/workflow/{workflow.workflow_uuid}",
headers={"X-API-Key": "test-api-key"},
json={"phone_number": "+15551234567"},
)
assert response.status_code == 200
mock_db.get_workflow_by_uuid.assert_awaited_once_with(
workflow.workflow_uuid,
11,
)
assert not mock_db.get_agent_trigger_by_path.called
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["user_id"] == workflow.user_id
assert (
create_kwargs["initial_context"]["agent_identifier"] == workflow.workflow_uuid
)
assert create_kwargs["initial_context"]["agent_identifier_type"] == "workflow_uuid"
assert "agent_uuid" not in create_kwargs["initial_context"]
def test_workflow_uuid_route_rejects_archived_workflows():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow()
workflow.status = "archived"
with patch("api.routes.public_agent.db_client") as mock_db:
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=9, organization_id=11, created_by=22)
)
mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow)
response = client.post(
f"/public/agent/workflow/{workflow.workflow_uuid}",
headers={"X-API-Key": "test-api-key"},
json={"phone_number": "+15551234567"},
)
assert response.status_code == 404
assert response.json()["detail"] == "Workflow is not active"
assert not mock_db.create_workflow_run.called

View file

@ -0,0 +1,56 @@
from api.services.workflow.trigger_paths import (
TRIGGER_PATH_MAX_LENGTH,
validate_trigger_paths,
)
def test_validate_trigger_paths_rejects_invalid_path_segments():
workflow_definition = {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": "support/west"},
}
],
"edges": [],
}
issues = validate_trigger_paths(workflow_definition)
assert len(issues) == 1
assert issues[0].node_id == "trigger-1"
assert "single URL path segment" in issues[0].message
def test_validate_trigger_paths_rejects_long_and_duplicate_paths():
long_path = "a" * (TRIGGER_PATH_MAX_LENGTH + 1)
workflow_definition = {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": long_path},
},
{
"id": "trigger-2",
"type": "trigger",
"data": {"trigger_path": "sales_agent"},
},
{
"id": "trigger-3",
"type": "trigger",
"data": {"trigger_path": "sales_agent"},
},
],
"edges": [],
}
issues = validate_trigger_paths(workflow_definition)
messages = [issue.message for issue in issues]
assert (
f"Trigger path must be {TRIGGER_PATH_MAX_LENGTH} characters or fewer."
in messages
)
assert "Trigger path is duplicated in this workflow." in messages

View file

@ -0,0 +1,49 @@
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.workflow import router
from api.services.auth.depends import get_user
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
id=1,
provider_id="provider-1",
selected_organization_id=11,
)
return app
def test_create_workflow_rejects_invalid_trigger_path_before_db_write():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
response = client.post(
"/workflow/create/definition",
json={
"name": "Support Agent",
"workflow_definition": {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": "support/west"},
}
],
"edges": [],
},
},
)
assert response.status_code == 422
detail = response.json()["detail"]
assert detail["is_valid"] is False
assert detail["errors"][0]["field"] == "data.trigger_path"
assert "single URL path segment" in detail["errors"][0]["message"]
assert mock_db.mock_calls == []

View file

@ -0,0 +1,52 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.workflow import router
from api.services.auth.depends import get_user
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
id=1,
selected_organization_id=11,
)
return app
def test_workflow_fetch_list_includes_workflow_uuid():
app = _make_test_app()
client = TestClient(app)
workflow = SimpleNamespace(
id=5,
name="Sales Agent",
status="active",
created_at=datetime(2026, 5, 22, 10, 30, tzinfo=timezone.utc),
folder_id=3,
workflow_uuid="workflow-uuid-123",
)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[workflow])
mock_db.get_workflow_run_counts = AsyncMock(return_value={workflow.id: 9})
response = client.get("/workflow/fetch")
assert response.status_code == 200
assert response.json() == [
{
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": "2026-05-22T10:30:00Z",
"total_runs": 9,
"folder_id": workflow.folder_id,
"workflow_uuid": workflow.workflow_uuid,
}
]

View file

@ -182,7 +182,7 @@ class TestSaveDraft:
workflow_definition=GRAPH_V2,
)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == original_released_id
async def test_save_draft_twice_updates_in_place(
@ -264,7 +264,7 @@ class TestPublishDraft:
await db_session.publish_workflow_draft(workflow.id)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == draft.id
async def test_publish_sets_published_at(self, db_session, workflow_with_v1):
@ -346,7 +346,7 @@ class TestDiscardDraft:
)
await db_session.discard_workflow_draft(workflow.id)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == original_released_id
async def test_discard_when_no_draft_raises(self, db_session, workflow_with_v1):
@ -464,7 +464,7 @@ class TestRevert:
await db_session.revert_to_version(workflow.id, v1_id)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == v2.id # still V2