Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/sdk-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jobs:
tests/test_retell_transport_validation.py
tests/test_call_originator_factory.py
tests/test_retell_evidence.py
tests/test_retell_webcall_bridge.py
tests/harness/test_process_runtime.py
tests/harness/test_source_folder.py
tests/test_harness_sandbox_server.py
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ evaluation = []
optimize = []
livekit = [
"aiohttp>=3.10",
"aiortc>=1.9,<2",
"audioop-lts>=0.2.1; python_version >= '3.13'",
"livekit-agents[cartesia,deepgram,openai,silero,google]>=1.8.3,<2",
"livekit-plugins-elevenlabs>=1.8.3,<2",
Expand Down Expand Up @@ -123,12 +124,14 @@ notebook = [
]
trinity = [
"aiohttp>=3.10",
"aiortc>=1.9,<2",
"audioop-lts>=0.2.1; python_version >= '3.13'",
"livekit-agents[cartesia,deepgram,openai,silero,google]>=1.8.3,<2",
"livekit-plugins-elevenlabs>=1.8.3,<2",
]
all = [
"aiohttp>=3.10",
"aiortc>=1.9,<2",
"audioop-lts>=0.2.1; python_version >= '3.13'",
"chromadb>=0.4.0",
"livekit-agents[cartesia,deepgram,openai,silero,google]>=1.8.3,<2",
Expand Down
10 changes: 2 additions & 8 deletions src/fi/alk/harness/call_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from typing import Any, Awaitable, Callable, Mapping, Protocol

from fi import agentcc, simulate
from fi.simulate.agent.definition import retell_web_call_url as _retell_web_call_url
from fi.simulate.runtime import (
SimulationSpec,
new_run_id,
Expand Down Expand Up @@ -598,14 +599,7 @@ def setting(name: str) -> str:
target={
"provider": "retell",
"agent_id": provider_target_id,
"api_url": str(
simulator_config.get("retell_api_url")
or "https://api.retellai.com/v2/create-web-call"
),
"livekit_url": str(
simulator_config.get("retell_livekit_url")
or "wss://retell-ai-4ihahnq7.livekit.cloud"
),
"api_url": _retell_web_call_url(simulator_config.get("retell_api_url")),
"api_key_env": RETELL_API_KEY_ALIAS,
},
transport={"kind": "retell_webcall"},
Expand Down
47 changes: 36 additions & 11 deletions src/fi/simulate/agent/definition.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import re
from typing import Annotated, Literal, Optional
from urllib.parse import urlsplit, urlunsplit

from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, model_validator
from pydantic import (
AnyHttpUrl,
AnyUrl,
BaseModel,
Field,
field_validator,
model_validator,
)


# Always .fullmatch, never .match: "$" also matches immediately before a
Expand Down Expand Up @@ -229,29 +237,46 @@ class VapiTargetConfig(BaseModel):
)


RETELL_WEB_CALL_URL = "https://api.retellai.com/v3/create-web-call"
_RETELL_LEGACY_WEB_CALL_PATH = "/v2/create-web-call"
_RETELL_WEB_CALL_PATH = "/v3/create-web-call"


def retell_web_call_url(configured: object) -> str:
"""Return the v3 web-call URL for a configured value.

Retell retired ``/v2/create-web-call``. Only that path is rewritten, so a
custom scheme and host (a proxy) are kept. An empty value gives the default.
"""
url = str(configured or "").strip()
if not url:
return RETELL_WEB_CALL_URL
parts = urlsplit(url)
path = parts.path.rstrip("/")
if path.endswith(_RETELL_LEGACY_WEB_CALL_PATH):
prefix = path[: -len(_RETELL_LEGACY_WEB_CALL_PATH)]
return urlunsplit(parts._replace(path=prefix + _RETELL_WEB_CALL_PATH))
return url


class RetellTargetConfig(BaseModel):
"""Non-secret configuration for a Retell agent under test."""

provider: Literal["retell"] = "retell"
agent_id: str = Field(..., min_length=1)
api_url: AnyHttpUrl = Field(
"https://api.retellai.com/v2/create-web-call",
validate_default=True,
)
livekit_url: AnyUrl = Field(
"wss://retell-ai-4ihahnq7.livekit.cloud",
"https://api.retellai.com/v3/create-web-call",
validate_default=True,
)
api_key_env: str = Field(
"RETELL_API_KEY",
pattern=r"^[A-Za-z_][A-Za-z0-9_]*$",
)

@model_validator(mode="after")
def _check_livekit_url(self) -> "RetellTargetConfig":
if self.livekit_url.scheme not in {"ws", "wss"}:
raise ValueError("retell_livekit_url_invalid: URL must use ws:// or wss://")
return self
@field_validator("api_url", mode="before")
@classmethod
def _upgrade_legacy_api_url(cls, value: object) -> object:
return retell_web_call_url(value)


VoiceProviderTarget = Annotated[
Expand Down
16 changes: 15 additions & 1 deletion src/fi/simulate/simulation/bridge/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
from typing import TYPE_CHECKING, Any

from fi.simulate.simulation.bridge.livekit import LiveKitAudioBridge
from fi.simulate.simulation.bridge.retell import RetellWebCallConnector
from fi.simulate.simulation.bridge.vapi import VapiWebSocketConnector

if TYPE_CHECKING:
from fi.simulate.simulation.bridge.retell import RetellWebCallConnector

__all__ = [
"LiveKitAudioBridge",
"RetellWebCallConnector",
"VapiWebSocketConnector",
]


def __getattr__(name: str) -> Any:
# The Retell connector needs aiortc, so it loads on first use: a Vapi, SIP
# or native LiveKit run never imports it.
if name == "RetellWebCallConnector":
from fi.simulate.simulation.bridge.retell import RetellWebCallConnector

return RetellWebCallConnector
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
4 changes: 2 additions & 2 deletions src/fi/simulate/simulation/bridge/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from dataclasses import dataclass
from dataclasses import dataclass, field


class ProviderConnector(ABC):
Expand Down Expand Up @@ -39,7 +39,7 @@ def call_id(self) -> str | None:

@dataclass(frozen=True)
class ConnectorConfig:
api_key: str
api_key: str = field(repr=False)
assistant_id: str
api_url: str
livekit_url: str = ""
Expand Down
3 changes: 3 additions & 0 deletions src/fi/simulate/simulation/bridge/livekit.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ async def _pump() -> None:
while True:
chunk = await queue.get()
if chunk is None:
# The pump has finished. Surface its error, such as a coded
# provider timeout, instead of treating it as a normal end.
await pump
break
pcm, sample_rate = chunk
if sample_rate != ROOM_SAMPLE_RATE:
Expand Down
Loading
Loading