mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
chore: generate SDK and fix other realtime providers
This commit is contained in:
parent
51525b7e24
commit
d66eb8fd47
20 changed files with 535 additions and 473 deletions
|
|
@ -1,6 +1,10 @@
|
|||
AZURE_MODELS = ["gpt-4.1-mini"]
|
||||
|
||||
AZURE_REALTIME_MODELS = ["gpt-4o-realtime-preview"]
|
||||
AZURE_REALTIME_MODELS = [
|
||||
"gpt-realtime",
|
||||
"gpt-realtime-1.5",
|
||||
"gpt-realtime-mini",
|
||||
]
|
||||
AZURE_REALTIME_VOICES = [
|
||||
"alloy",
|
||||
"ash",
|
||||
|
|
@ -12,6 +16,7 @@ AZURE_REALTIME_VOICES = [
|
|||
"verse",
|
||||
]
|
||||
AZURE_REALTIME_API_VERSIONS = [
|
||||
"v1",
|
||||
"2025-04-01-preview",
|
||||
"2024-10-01-preview",
|
||||
"2024-12-17",
|
||||
|
|
|
|||
|
|
@ -621,7 +621,7 @@ class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
|
|||
|
||||
|
||||
GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"]
|
||||
GROK_REALTIME_VOICES = ["Ara", "Rex", "Sal", "Eve", "Leo"]
|
||||
GROK_REALTIME_VOICES = ["ara", "rex", "sal", "eve", "leo"]
|
||||
ULTRAVOX_REALTIME_MODELS = ["ultravox-v0.7", "fixie-ai/ultravox"]
|
||||
|
||||
|
||||
|
|
@ -638,7 +638,7 @@ class GrokRealtimeLLMConfiguration(BaseLLMConfiguration):
|
|||
},
|
||||
)
|
||||
voice: str = Field(
|
||||
default="Ara",
|
||||
default="ara",
|
||||
description="Voice the model speaks in.",
|
||||
json_schema_extra={
|
||||
"examples": GROK_REALTIME_VOICES,
|
||||
|
|
@ -756,7 +756,7 @@ class AzureRealtimeLLMConfiguration(BaseLLMConfiguration):
|
|||
model_config = AZURE_REALTIME_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.AZURE_REALTIME] = ServiceProviders.AZURE_REALTIME
|
||||
model: str = Field(
|
||||
default="gpt-4o-realtime-preview",
|
||||
default="gpt-realtime",
|
||||
description="Azure OpenAI realtime deployment name.",
|
||||
json_schema_extra={
|
||||
"examples": AZURE_REALTIME_MODELS,
|
||||
|
|
@ -775,8 +775,11 @@ class AzureRealtimeLLMConfiguration(BaseLLMConfiguration):
|
|||
},
|
||||
)
|
||||
api_version: str = Field(
|
||||
default="2025-04-01-preview",
|
||||
description="Azure OpenAI API version.",
|
||||
default="v1",
|
||||
description=(
|
||||
"Azure OpenAI Realtime protocol version. Use 'v1' for the GA API; "
|
||||
"date-based versions select the deprecated preview endpoint."
|
||||
),
|
||||
json_schema_extra={
|
||||
"examples": AZURE_REALTIME_API_VERSIONS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""Dograh subclass of pipecat's Azure OpenAI Realtime LLM service.
|
||||
|
||||
Layers Dograh engine integration quirks (mute gating, TTSSpeakFrame greeting
|
||||
trigger, LLMMessagesAppendFrame handling, deferred tool calls) onto pipecat's
|
||||
AzureRealtimeLLMService, mirroring what DograhOpenAIRealtimeLLMService does
|
||||
for the standard OpenAI Realtime endpoint.
|
||||
trigger, LLMMessagesAppendFrame handling, workflow-control deferral) onto
|
||||
pipecat's AzureRealtimeLLMService, mirroring what
|
||||
DograhOpenAIRealtimeLLMService does for the standard OpenAI Realtime endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -40,7 +40,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
|
|||
- User-mute audio gating
|
||||
- TTSSpeakFrame as initial-response trigger
|
||||
- One-off LLMMessagesAppendFrame handling
|
||||
- Deferred tool calls until bot finishes speaking
|
||||
- Workflow-control calls deferred until bot finishes speaking
|
||||
- finalized=True on TranscriptionFrame for consistency
|
||||
"""
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
|
|||
self._user_is_muted: bool = False
|
||||
self._handled_initial_context: bool = False
|
||||
self._bot_is_speaking: bool = False
|
||||
self._deferred_function_calls: list[FunctionCallFromLLM] = []
|
||||
self._deferred_node_transition_function_calls: list[FunctionCallFromLLM] = []
|
||||
self._pending_initial_greeting_text: str | None = None
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
|
|
@ -81,7 +81,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
|
|||
self._bot_is_speaking = True
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
self._bot_is_speaking = False
|
||||
await self._run_pending_function_calls()
|
||||
await self._run_pending_node_transition_function_calls()
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
|
||||
|
|
@ -247,18 +247,19 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
|
|||
)
|
||||
)
|
||||
|
||||
async def _run_pending_function_calls(self):
|
||||
if not self._deferred_function_calls:
|
||||
async def _run_pending_node_transition_function_calls(self):
|
||||
if not self._deferred_node_transition_function_calls:
|
||||
return
|
||||
function_calls = self._deferred_function_calls
|
||||
self._deferred_function_calls = []
|
||||
function_calls = self._deferred_node_transition_function_calls
|
||||
self._deferred_node_transition_function_calls = []
|
||||
logger.debug(
|
||||
f"{self}: executing {len(function_calls)} deferred function call(s) "
|
||||
"after bot turn ended"
|
||||
f"{self}: executing {len(function_calls)} deferred workflow-control "
|
||||
"call(s) after bot turn ended"
|
||||
)
|
||||
await self.run_function_calls(function_calls)
|
||||
|
||||
async def _handle_evt_function_call_arguments_done(self, evt):
|
||||
"""Run ordinary tools immediately and defer workflow-control calls."""
|
||||
try:
|
||||
args = json.loads(evt.arguments)
|
||||
|
||||
|
|
@ -275,10 +276,14 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
|
|||
)
|
||||
]
|
||||
|
||||
if self._bot_is_speaking:
|
||||
self._deferred_function_calls.extend(function_calls)
|
||||
is_node_transition = self._function_is_node_transition(
|
||||
function_call_item.name
|
||||
)
|
||||
if self._bot_is_speaking and is_node_transition:
|
||||
self._deferred_node_transition_function_calls.extend(function_calls)
|
||||
logger.debug(
|
||||
f"{self}: deferring function call {function_call_item.name} "
|
||||
f"{self}: deferring workflow-control call "
|
||||
f"{function_call_item.name} "
|
||||
"until bot stops speaking"
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ Adds:
|
|||
flow kicks off the bot's first response.
|
||||
- **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts
|
||||
like user-idle checks, without mutating Dograh's local ``LLMContext``.
|
||||
- **Function-call deferral** until the bot finishes speaking, to avoid racing
|
||||
tool execution with the active audio turn.
|
||||
- **Workflow-control deferral** so node transitions, call termination, and
|
||||
transfers wait for any current bot audio to finish while ordinary tools run
|
||||
immediately.
|
||||
- **finalized=True on TranscriptionFrame** for parity with Dograh's other
|
||||
realtime providers.
|
||||
"""
|
||||
|
|
@ -50,7 +51,7 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
|
|||
self._user_is_muted: bool = False
|
||||
self._handled_initial_context: bool = False
|
||||
self._bot_is_speaking: bool = False
|
||||
self._deferred_function_calls: list[FunctionCallFromLLM] = []
|
||||
self._deferred_node_transition_function_calls: list[FunctionCallFromLLM] = []
|
||||
self._pending_initial_greeting_text: str | None = None
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
|
|
@ -82,7 +83,7 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
|
|||
self._bot_is_speaking = True
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
self._bot_is_speaking = False
|
||||
await self._run_pending_function_calls()
|
||||
await self._run_pending_node_transition_function_calls()
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
|
||||
|
|
@ -251,19 +252,19 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
|
|||
)
|
||||
)
|
||||
|
||||
async def _run_pending_function_calls(self):
|
||||
if not self._deferred_function_calls:
|
||||
async def _run_pending_node_transition_function_calls(self):
|
||||
if not self._deferred_node_transition_function_calls:
|
||||
return
|
||||
function_calls = self._deferred_function_calls
|
||||
self._deferred_function_calls = []
|
||||
function_calls = self._deferred_node_transition_function_calls
|
||||
self._deferred_node_transition_function_calls = []
|
||||
logger.debug(
|
||||
f"{self}: executing {len(function_calls)} deferred function call(s) "
|
||||
"after bot turn ended"
|
||||
f"{self}: executing {len(function_calls)} deferred workflow-control "
|
||||
"call(s) after bot turn ended"
|
||||
)
|
||||
await self.run_function_calls(function_calls)
|
||||
|
||||
async def _handle_evt_function_call_arguments_done(self, evt):
|
||||
"""Process or defer tool calls until the bot finishes speaking."""
|
||||
"""Run ordinary tools immediately and defer workflow-control calls."""
|
||||
try:
|
||||
args = json.loads(evt.arguments)
|
||||
|
||||
|
|
@ -281,10 +282,11 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
|
|||
)
|
||||
]
|
||||
|
||||
if self._bot_is_speaking:
|
||||
self._deferred_function_calls.extend(function_calls)
|
||||
is_node_transition = self._function_is_node_transition(function_name)
|
||||
if self._bot_is_speaking and is_node_transition:
|
||||
self._deferred_node_transition_function_calls.extend(function_calls)
|
||||
logger.debug(
|
||||
f"{self}: deferring function call {function_name} "
|
||||
f"{self}: deferring workflow-control call {function_name} "
|
||||
"until bot stops speaking"
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
Layers Dograh engine integration quirks onto upstream-pristine
|
||||
:class:`OpenAIRealtimeLLMService`. Substantially smaller than the Gemini
|
||||
subclass because OpenAI Realtime supports runtime ``session.update`` for
|
||||
both ``system_instruction`` and tools — no reconnect/defer-tool-call
|
||||
machinery needed.
|
||||
both ``system_instruction`` and tools, so node changes do not require a
|
||||
reconnect.
|
||||
|
||||
Adds:
|
||||
|
||||
|
|
@ -13,6 +13,9 @@ Adds:
|
|||
flow kicks off the bot's first response.
|
||||
- **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts
|
||||
like user-idle checks, without mutating Dograh's local ``LLMContext``.
|
||||
- **Workflow-control deferral** so node transitions, call termination, and
|
||||
transfers wait for any current bot audio to finish while ordinary tools run
|
||||
immediately.
|
||||
- **finalized=True on TranscriptionFrame** because every OpenAI
|
||||
transcription via the ``completed`` event is final by construction.
|
||||
"""
|
||||
|
|
@ -53,10 +56,10 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
|
|||
# LLMContextFrame arrives, so upstream's "first arrival means
|
||||
# self._context is None" check no longer works.
|
||||
self._handled_initial_context: bool = False
|
||||
# Track bot speech locally so tool calls can be deferred until the bot
|
||||
# has finished speaking, matching Dograh's Gemini Live behavior.
|
||||
# Track bot speech locally so workflow-control calls can wait until the
|
||||
# bot has finished speaking without delaying ordinary tools.
|
||||
self._bot_is_speaking: bool = False
|
||||
self._deferred_function_calls: list[FunctionCallFromLLM] = []
|
||||
self._deferred_node_transition_function_calls: list[FunctionCallFromLLM] = []
|
||||
self._pending_initial_greeting_text: str | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -100,7 +103,7 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
|
|||
self._bot_is_speaking = True
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
self._bot_is_speaking = False
|
||||
await self._run_pending_function_calls()
|
||||
await self._run_pending_node_transition_function_calls()
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
|
||||
|
|
@ -268,19 +271,19 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
|
|||
)
|
||||
)
|
||||
|
||||
async def _run_pending_function_calls(self):
|
||||
if not self._deferred_function_calls:
|
||||
async def _run_pending_node_transition_function_calls(self):
|
||||
if not self._deferred_node_transition_function_calls:
|
||||
return
|
||||
function_calls = self._deferred_function_calls
|
||||
self._deferred_function_calls = []
|
||||
function_calls = self._deferred_node_transition_function_calls
|
||||
self._deferred_node_transition_function_calls = []
|
||||
logger.debug(
|
||||
f"{self}: executing {len(function_calls)} deferred function call(s) "
|
||||
"after bot turn ended"
|
||||
f"{self}: executing {len(function_calls)} deferred workflow-control "
|
||||
"call(s) after bot turn ended"
|
||||
)
|
||||
await self.run_function_calls(function_calls)
|
||||
|
||||
async def _handle_evt_function_call_arguments_done(self, evt):
|
||||
"""Process or defer tool calls until the bot finishes speaking."""
|
||||
"""Run ordinary tools immediately and defer workflow-control calls."""
|
||||
try:
|
||||
args = json.loads(evt.arguments)
|
||||
|
||||
|
|
@ -297,10 +300,14 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
|
|||
)
|
||||
]
|
||||
|
||||
if self._bot_is_speaking:
|
||||
self._deferred_function_calls.extend(function_calls)
|
||||
is_node_transition = self._function_is_node_transition(
|
||||
function_call_item.name
|
||||
)
|
||||
if self._bot_is_speaking and is_node_transition:
|
||||
self._deferred_node_transition_function_calls.extend(function_calls)
|
||||
logger.debug(
|
||||
f"{self}: deferring function call {function_call_item.name} "
|
||||
f"{self}: deferring workflow-control call "
|
||||
f"{function_call_item.name} "
|
||||
"until bot stops speaking"
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
"""Dograh subclass of pipecat's Ultravox realtime LLM service.
|
||||
|
||||
Ultravox is audio-native and realtime, but prompt and tool configuration is
|
||||
bound to call creation. Dograh therefore cannot lean on in-session updates or
|
||||
Gemini-style session resumption handles. This wrapper adapts Ultravox to the
|
||||
Dograh engine contract by:
|
||||
Ultravox is audio-native and realtime. Its native call stages allow a client
|
||||
tool result to atomically change the system prompt and tools while preserving
|
||||
the call's server-side conversation history. This wrapper adapts that model to
|
||||
the Dograh engine contract by:
|
||||
|
||||
- deferring the first call creation until the engine queues the initial node
|
||||
opening via ``TTSSpeakFrame`` or ``LLMContextFrame``
|
||||
- marking the call for recreation when ``system_instruction`` changes across
|
||||
node transitions, then rebuilding it on the follow-up ``LLMContextFrame``
|
||||
so the transition tool result is present in ``initialMessages``
|
||||
- reconstructing Ultravox ``initialMessages`` from Dograh context when the
|
||||
call must be recreated after a node transition
|
||||
- appending a transient resumptive user nudge to recreated ``initialMessages``
|
||||
after tool-result transitions, without mutating Dograh's stored context
|
||||
- returning node-transition tool results with ``responseType="new-stage"`` so
|
||||
the existing call keeps its complete audio-native history
|
||||
- updating the next stage's system prompt and selected tools without a
|
||||
disconnect/reconnect cycle
|
||||
- handling Dograh-only frames such as user mute and idle append prompts
|
||||
- tagging user transcripts with ``finalized=True`` for downstream parity
|
||||
"""
|
||||
|
|
@ -34,12 +31,7 @@ from pipecat.frames.frames import (
|
|||
UserMuteStartedFrame,
|
||||
UserMuteStoppedFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators import async_tool_messages
|
||||
from pipecat.processors.aggregators.llm_context import (
|
||||
LLMContext,
|
||||
LLMSpecificMessage,
|
||||
is_given,
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext, is_given
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.llm_service import LLMService
|
||||
from pipecat.services.settings import _NotGiven, assert_given
|
||||
|
|
@ -58,10 +50,6 @@ class DograhUltravoxOneShotInputParams(OneShotInputParams):
|
|||
|
||||
|
||||
_ULTRAVOX_MAX_TOOL_TIMEOUT_SECS = 40.0
|
||||
_RESUMPTION_USER_MESSAGE = (
|
||||
"IMPORTANT: We are resuming an existing conversation. You are given previous turns ONLY for your reference. "
|
||||
"Do not use that to frame your response. Follow your ORIGINAL INSTRUCTIONS ONLY."
|
||||
)
|
||||
|
||||
|
||||
class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
||||
|
|
@ -72,12 +60,13 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
self._context: LLMContext | None = None
|
||||
self._selected_tools = None
|
||||
self._user_is_muted: bool = False
|
||||
self._call_system_instruction: str | None = None
|
||||
self._reconnect_required: bool = False
|
||||
self._call_started: bool = False
|
||||
self._has_connected_once: bool = False
|
||||
self._pending_reconnect_system_instruction: str | None = None
|
||||
self._pending_initial_messages: list[dict[str, Any]] | None = None
|
||||
self._stage_update_required: bool = False
|
||||
# Ultravox applies a stage update on the matching client tool result,
|
||||
# so retain the provider invocation ID until that result reaches us via
|
||||
# the context aggregator. Unlike Gemini, this ID is part of the wire
|
||||
# protocol needed to update the existing call without reconnecting.
|
||||
self._pending_node_transition_tool_call_ids: set[str] = set()
|
||||
self._pending_user_text_messages: list[str] = []
|
||||
|
||||
async def start(self, frame):
|
||||
|
|
@ -96,9 +85,7 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
if isinstance(frame, TTSSpeakFrame):
|
||||
if not self._socket:
|
||||
await self._connect_call(
|
||||
system_instruction=self._current_system_instruction(),
|
||||
greeting_text=frame.text,
|
||||
initial_messages=None,
|
||||
agent_speaks_first=True,
|
||||
)
|
||||
else:
|
||||
|
|
@ -116,18 +103,15 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
changed = await super(UltravoxRealtimeLLMService, self)._update_settings(delta)
|
||||
if "output_medium" in changed:
|
||||
await self._update_output_medium(assert_given(self._settings.output_medium))
|
||||
if "system_instruction" in changed and self._has_connected_once:
|
||||
# Mirror Gemini's "settings change means reconnect" intent, but
|
||||
# defer the actual new-call creation until the subsequent
|
||||
# LLMContextFrame arrives with the transition tool result. Ultravox
|
||||
# cannot accept that historical tool result over a formal
|
||||
# post-connect tool-response channel the way Gemini can.
|
||||
self._reconnect_required = True
|
||||
if "system_instruction" in changed and self._socket:
|
||||
# The updated instruction is included in the native new-stage
|
||||
# response when the transition tool result reaches _handle_context.
|
||||
self._stage_update_required = True
|
||||
handled = {"output_medium", "system_instruction"}
|
||||
self._warn_unhandled_updated_settings(changed.keys() - handled)
|
||||
return changed
|
||||
|
||||
async def _disconnect(self, preserve_completed_tool_calls: bool = True):
|
||||
async def _disconnect(self):
|
||||
self._disconnecting = True
|
||||
await self.stop_all_metrics()
|
||||
if self._socket:
|
||||
|
|
@ -136,10 +120,10 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
if self._receive_task:
|
||||
await self.cancel_task(self._receive_task, timeout=1.0)
|
||||
self._receive_task = None
|
||||
if not preserve_completed_tool_calls:
|
||||
self._completed_tool_calls = set()
|
||||
self._completed_tool_calls = set()
|
||||
self._call_started = False
|
||||
self._started_placeholder_sent = set()
|
||||
self._pending_node_transition_tool_call_ids = set()
|
||||
self._disconnecting = False
|
||||
|
||||
async def _send_user_audio(self, frame):
|
||||
|
|
@ -149,39 +133,20 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
|
||||
async def _handle_context(self, context: LLMContext):
|
||||
self._context = context
|
||||
system_instruction = self._current_system_instruction()
|
||||
|
||||
if self._socket and not self._reconnect_required:
|
||||
await super()._handle_context(context)
|
||||
if not self._socket:
|
||||
await self._connect_call(
|
||||
greeting_text=None,
|
||||
agent_speaks_first=True,
|
||||
)
|
||||
return
|
||||
|
||||
initial_messages, history_tool_call_ids = self._build_initial_messages(context)
|
||||
if history_tool_call_ids:
|
||||
self._completed_tool_calls.update(history_tool_call_ids)
|
||||
|
||||
if self._bot_responding:
|
||||
self._pending_reconnect_system_instruction = system_instruction
|
||||
self._pending_initial_messages = initial_messages
|
||||
return
|
||||
|
||||
await self._reconnect_with_context(
|
||||
system_instruction=system_instruction,
|
||||
initial_messages=initial_messages,
|
||||
)
|
||||
|
||||
async def _handle_response_end(self):
|
||||
await super()._handle_response_end()
|
||||
if self._pending_reconnect_system_instruction is None:
|
||||
return
|
||||
|
||||
system_instruction = self._pending_reconnect_system_instruction
|
||||
initial_messages = self._pending_initial_messages
|
||||
self._pending_reconnect_system_instruction = None
|
||||
self._pending_initial_messages = None
|
||||
await self._reconnect_with_context(
|
||||
system_instruction=system_instruction,
|
||||
initial_messages=initial_messages,
|
||||
)
|
||||
current_tools = self._current_tools_schema(context)
|
||||
if self._pending_node_transition_tool_call_ids and self._tools_changed(
|
||||
current_tools
|
||||
):
|
||||
self._stage_update_required = True
|
||||
await super()._handle_context(context)
|
||||
|
||||
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
|
||||
texts = [
|
||||
|
|
@ -199,9 +164,7 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
if not self._socket:
|
||||
self._pending_user_text_messages.extend(texts)
|
||||
await self._connect_call(
|
||||
system_instruction=self._current_system_instruction(),
|
||||
greeting_text=None,
|
||||
initial_messages=None,
|
||||
agent_speaks_first=False,
|
||||
)
|
||||
return
|
||||
|
|
@ -229,17 +192,66 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
finalized=True,
|
||||
)
|
||||
|
||||
def _requires_node_transition_context_aggregation(self) -> bool:
|
||||
"""Commit any received final user transcript before changing stages.
|
||||
|
||||
Ultravox preserves its own audio-native history across a stage change,
|
||||
but Dograh's local context still needs the final transcript before the
|
||||
transition handler updates the workflow node.
|
||||
"""
|
||||
return True
|
||||
|
||||
async def _handle_tool_invocation(
|
||||
self, tool_name: str, invocation_id: str, parameters: dict[str, Any]
|
||||
):
|
||||
if self._function_is_node_transition(tool_name):
|
||||
self._pending_node_transition_tool_call_ids.add(invocation_id)
|
||||
await super()._handle_tool_invocation(tool_name, invocation_id, parameters)
|
||||
|
||||
async def _send_tool_result(self, tool_call_id: str, result: str):
|
||||
is_node_transition = tool_call_id in self._pending_node_transition_tool_call_ids
|
||||
try:
|
||||
if is_node_transition and self._stage_update_required:
|
||||
await self._send_node_transition_stage_result(tool_call_id, result)
|
||||
else:
|
||||
await super()._send_tool_result(tool_call_id, result)
|
||||
finally:
|
||||
if is_node_transition:
|
||||
self._pending_node_transition_tool_call_ids.discard(tool_call_id)
|
||||
|
||||
async def _send_node_transition_stage_result(self, tool_call_id: str, result: str):
|
||||
"""Apply node settings using Ultravox's native call-stage protocol."""
|
||||
next_tools = self._current_tools_schema(self._context)
|
||||
stage = {
|
||||
"systemPrompt": self._current_system_instruction(),
|
||||
"selectedTools": self._selected_tools_payload(next_tools),
|
||||
# Keep the workflow handler's result as the tool-result message in
|
||||
# the inherited conversation history for the next generation.
|
||||
"toolResultText": result,
|
||||
}
|
||||
logger.debug(
|
||||
f"{self}: updating Ultravox call stage for tool_call_id={tool_call_id} "
|
||||
f"with {len(stage['selectedTools'])} selected tool(s)"
|
||||
)
|
||||
await self._send(
|
||||
{
|
||||
"type": "client_tool_result",
|
||||
"invocationId": tool_call_id,
|
||||
"result": json.dumps(stage, ensure_ascii=True, default=str),
|
||||
"responseType": "new-stage",
|
||||
}
|
||||
)
|
||||
self._selected_tools = next_tools
|
||||
self._stage_update_required = False
|
||||
|
||||
async def _connect_call(
|
||||
self,
|
||||
*,
|
||||
system_instruction: str | None,
|
||||
greeting_text: str | None,
|
||||
initial_messages: list[dict[str, Any]] | None,
|
||||
agent_speaks_first: bool,
|
||||
):
|
||||
params = self._build_one_shot_params(
|
||||
greeting_text=greeting_text,
|
||||
initial_messages=initial_messages,
|
||||
agent_speaks_first=agent_speaks_first,
|
||||
)
|
||||
self._params = params
|
||||
|
|
@ -265,9 +277,7 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
logger.info(f"Joining Ultravox Realtime call via URL: {join_url}")
|
||||
self._socket = await websocket_client.connect(join_url)
|
||||
self._receive_task = self.create_task(self._receive_messages())
|
||||
self._call_system_instruction = system_instruction
|
||||
self._call_started = False
|
||||
self._has_connected_once = True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self}: Ultravox call creation/join failed "
|
||||
|
|
@ -365,40 +375,17 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
for pending_text in pending_texts:
|
||||
await self._send_user_text(pending_text)
|
||||
|
||||
async def _reconnect_with_context(
|
||||
self,
|
||||
*,
|
||||
system_instruction: str | None,
|
||||
initial_messages: list[dict[str, Any]] | None,
|
||||
):
|
||||
call_initial_messages = self._initial_messages_for_call(initial_messages)
|
||||
logger.debug(
|
||||
f"{self}: reconnecting Ultravox call with initialMessages="
|
||||
f"{json.dumps(call_initial_messages, ensure_ascii=True, default=str)}"
|
||||
)
|
||||
if self._socket:
|
||||
await self._disconnect(preserve_completed_tool_calls=True)
|
||||
|
||||
await self._connect_call(
|
||||
system_instruction=system_instruction,
|
||||
greeting_text=None,
|
||||
initial_messages=initial_messages,
|
||||
agent_speaks_first=self._should_agent_speak_first(initial_messages),
|
||||
)
|
||||
self._reconnect_required = False
|
||||
|
||||
def _build_one_shot_params(
|
||||
self,
|
||||
*,
|
||||
greeting_text: str | None,
|
||||
initial_messages: list[dict[str, Any]] | None,
|
||||
agent_speaks_first: bool,
|
||||
) -> DograhUltravoxOneShotInputParams:
|
||||
current_params = self._params
|
||||
extra = {
|
||||
key: value
|
||||
for key, value in current_params.extra.items()
|
||||
if key not in {"firstSpeakerSettings", "initialMessages"}
|
||||
if key != "firstSpeakerSettings"
|
||||
}
|
||||
|
||||
if greeting_text is not None:
|
||||
|
|
@ -407,10 +394,6 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
extra["firstSpeakerSettings"] = {"agent": {}}
|
||||
else:
|
||||
extra["firstSpeakerSettings"] = {"user": {}}
|
||||
call_initial_messages = self._initial_messages_for_call(initial_messages)
|
||||
if call_initial_messages:
|
||||
extra["initialMessages"] = call_initial_messages
|
||||
|
||||
output_medium = self._settings.output_medium
|
||||
if isinstance(output_medium, _NotGiven):
|
||||
output_medium = current_params.output_medium
|
||||
|
|
@ -432,6 +415,14 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
return None
|
||||
return context.tools
|
||||
|
||||
def _selected_tools_payload(self, tools: Any) -> list[dict[str, Any]]:
|
||||
return self._to_selected_tools(tools) if tools else []
|
||||
|
||||
def _tools_changed(self, tools: Any) -> bool:
|
||||
return self._selected_tools_payload(tools) != self._selected_tools_payload(
|
||||
self._selected_tools
|
||||
)
|
||||
|
||||
def _to_selected_tools(self, tool: Any) -> list[dict[str, Any]]:
|
||||
selected_tools = super()._to_selected_tools(tool)
|
||||
for selected_tool in selected_tools:
|
||||
|
|
@ -462,156 +453,6 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
timeout_secs = min(float(item.timeout_secs), _ULTRAVOX_MAX_TOOL_TIMEOUT_SECS)
|
||||
return f"{timeout_secs:g}s"
|
||||
|
||||
def _initial_messages_for_call(
|
||||
self, initial_messages: list[dict[str, Any]] | None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
if not initial_messages:
|
||||
return None
|
||||
if not self._should_add_resumption_user_message(initial_messages):
|
||||
return initial_messages
|
||||
|
||||
return [
|
||||
*initial_messages,
|
||||
{
|
||||
"role": "MESSAGE_ROLE_USER",
|
||||
"text": _RESUMPTION_USER_MESSAGE,
|
||||
},
|
||||
]
|
||||
|
||||
def _build_initial_messages(
|
||||
self, context: LLMContext
|
||||
) -> tuple[list[dict[str, Any]] | None, set[str]]:
|
||||
initial_messages: list[dict[str, Any]] = []
|
||||
tool_call_id_to_name: dict[str, str] = {}
|
||||
completed_tool_call_ids: set[str] = set()
|
||||
|
||||
for message in context.get_messages():
|
||||
if isinstance(message, LLMSpecificMessage):
|
||||
continue
|
||||
|
||||
async_payload = async_tool_messages.parse_message(message)
|
||||
if async_payload is not None:
|
||||
if async_payload.kind == "intermediate":
|
||||
logger.error(
|
||||
f"{self}: Ultravox does not support streamed async tool results; "
|
||||
f"dropping intermediate result from initialMessages for "
|
||||
f"tool_call_id={async_payload.tool_call_id}."
|
||||
)
|
||||
continue
|
||||
if async_payload.kind == "final":
|
||||
initial_message = self._build_ultravox_message(
|
||||
role="MESSAGE_ROLE_TOOL_RESULT",
|
||||
text=async_payload.result or "",
|
||||
invocation_id=async_payload.tool_call_id,
|
||||
tool_name=tool_call_id_to_name.get(async_payload.tool_call_id),
|
||||
)
|
||||
if initial_message is not None:
|
||||
initial_messages.append(initial_message)
|
||||
completed_tool_call_ids.add(async_payload.tool_call_id)
|
||||
continue
|
||||
|
||||
role = message.get("role")
|
||||
if role == "user":
|
||||
initial_message = self._build_ultravox_message(
|
||||
role="MESSAGE_ROLE_USER",
|
||||
text=self._extract_text_content(message.get("content")),
|
||||
)
|
||||
if initial_message is not None:
|
||||
initial_messages.append(initial_message)
|
||||
elif role == "assistant":
|
||||
text = self._extract_text_content(message.get("content"))
|
||||
initial_message = self._build_ultravox_message(
|
||||
role="MESSAGE_ROLE_AGENT",
|
||||
text=text,
|
||||
)
|
||||
if initial_message is not None:
|
||||
initial_messages.append(initial_message)
|
||||
|
||||
tool_calls = message.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for tool_call in tool_calls:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_id = tool_call.get("id")
|
||||
function = tool_call.get("function")
|
||||
tool_name = (
|
||||
function.get("name") if isinstance(function, dict) else None
|
||||
)
|
||||
if isinstance(tool_id, str) and isinstance(tool_name, str):
|
||||
tool_call_id_to_name[tool_id] = tool_name
|
||||
initial_message = self._build_ultravox_message(
|
||||
role="MESSAGE_ROLE_TOOL_CALL",
|
||||
text="",
|
||||
invocation_id=tool_id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
if initial_message is not None:
|
||||
initial_messages.append(initial_message)
|
||||
elif (
|
||||
role == "tool"
|
||||
and message.get("content") != "IN_PROGRESS"
|
||||
and message.get("content") != "CANCELLED"
|
||||
):
|
||||
tool_call_id = message.get("tool_call_id")
|
||||
initial_message = self._build_ultravox_message(
|
||||
role="MESSAGE_ROLE_TOOL_RESULT",
|
||||
text=self._stringify_tool_result(message.get("content")),
|
||||
invocation_id=tool_call_id
|
||||
if isinstance(tool_call_id, str)
|
||||
else None,
|
||||
tool_name=(
|
||||
tool_call_id_to_name.get(tool_call_id)
|
||||
if isinstance(tool_call_id, str)
|
||||
else None
|
||||
),
|
||||
)
|
||||
if initial_message is not None:
|
||||
initial_messages.append(initial_message)
|
||||
if isinstance(tool_call_id, str):
|
||||
completed_tool_call_ids.add(tool_call_id)
|
||||
|
||||
return (initial_messages or None), completed_tool_call_ids
|
||||
|
||||
@staticmethod
|
||||
def _build_ultravox_message(
|
||||
*,
|
||||
role: str,
|
||||
text: str | None,
|
||||
invocation_id: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
message: dict[str, Any] = {
|
||||
"role": role,
|
||||
"text": text,
|
||||
}
|
||||
if invocation_id is not None:
|
||||
message["invocationId"] = invocation_id
|
||||
if tool_name is not None:
|
||||
message["toolName"] = tool_name
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _should_agent_speak_first(
|
||||
initial_messages: list[dict[str, Any]] | None,
|
||||
) -> bool:
|
||||
if not initial_messages:
|
||||
return True
|
||||
return initial_messages[-1].get("role") in {
|
||||
"MESSAGE_ROLE_USER",
|
||||
"MESSAGE_ROLE_TOOL_RESULT",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _should_add_resumption_user_message(
|
||||
initial_messages: list[dict[str, Any]] | None,
|
||||
) -> bool:
|
||||
if not initial_messages:
|
||||
return False
|
||||
return initial_messages[-1].get("role") == "MESSAGE_ROLE_TOOL_RESULT"
|
||||
|
||||
@staticmethod
|
||||
def _is_benign_websocket_close(exc: ConnectionClosed) -> bool:
|
||||
return any(
|
||||
|
|
@ -636,18 +477,3 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
|
|||
parts.append(text)
|
||||
return "\n".join(parts) if parts else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _stringify_tool_result(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
if parts:
|
||||
return "".join(parts)
|
||||
return json.dumps(content, ensure_ascii=True, default=str)
|
||||
|
|
|
|||
|
|
@ -1029,14 +1029,28 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
|
|||
from api.services.pipecat.realtime.grok_realtime import (
|
||||
DograhGrokRealtimeLLMService,
|
||||
)
|
||||
from pipecat.services.xai.realtime.events import SessionProperties
|
||||
from pipecat.services.xai.realtime.events import (
|
||||
AudioConfiguration,
|
||||
AudioInput,
|
||||
InputAudioTranscription,
|
||||
SessionProperties,
|
||||
)
|
||||
|
||||
grok_voice = voice or "ara"
|
||||
if grok_voice.lower() in {"ara", "rex", "sal", "eve", "leo"}:
|
||||
grok_voice = grok_voice.lower()
|
||||
|
||||
return DograhGrokRealtimeLLMService(
|
||||
api_key=api_key,
|
||||
settings=DograhGrokRealtimeLLMService.Settings(
|
||||
model=model,
|
||||
session_properties=SessionProperties(
|
||||
voice=voice or "Ara",
|
||||
voice=grok_voice,
|
||||
audio=AudioConfiguration(
|
||||
input=AudioInput(
|
||||
transcription=InputAudioTranscription(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -1115,19 +1129,25 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
|
|||
detail="Azure Realtime requires an endpoint.",
|
||||
)
|
||||
_validate_runtime_service_url(endpoint, "endpoint")
|
||||
api_version = (
|
||||
getattr(realtime_config, "api_version", None) or "2025-04-01-preview"
|
||||
)
|
||||
# Construct the Azure Realtime WebSocket URL
|
||||
# https://<resource>.openai.azure.com/openai/realtime?api-version=<ver>&deployment=<model>
|
||||
api_version = getattr(realtime_config, "api_version", None) or "v1"
|
||||
parsed_endpoint = urlparse(endpoint)
|
||||
if api_version == "v1":
|
||||
# Azure's GA Realtime API uses the deployment name as `model` and
|
||||
# deliberately has no date-based api-version query parameter.
|
||||
path = "/openai/v1/realtime"
|
||||
query = urlencode({"model": model})
|
||||
else:
|
||||
# Preserve explicitly configured preview deployments while users
|
||||
# migrate. Microsoft deprecated this protocol on April 30, 2026.
|
||||
path = "/openai/realtime"
|
||||
query = urlencode({"api-version": api_version, "deployment": model})
|
||||
wss_url = urlunparse(
|
||||
(
|
||||
"wss",
|
||||
parsed_endpoint.netloc,
|
||||
"/openai/realtime",
|
||||
path,
|
||||
"",
|
||||
urlencode({"api-version": api_version, "deployment": model}),
|
||||
query,
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,3 +86,45 @@ async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
|
|||
assert service._pending_initial_greeting_text is None
|
||||
assert service._llm_needs_conversation_setup is False
|
||||
service._create_response.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_transition_function_call_runs_while_bot_is_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="lookup_order")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(call_id="call-1", arguments='{"order_id":"123"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_function_call_waits_until_bot_stops_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service.register_function(
|
||||
"customer_support",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_node_transition_function_calls) == 1
|
||||
|
||||
await service._run_pending_node_transition_function_calls()
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
|
|
|||
|
|
@ -170,6 +170,46 @@ def test_create_azure_realtime_blocks_private_endpoint_in_saas(monkeypatch):
|
|||
assert "public IP" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_create_azure_realtime_uses_ga_websocket_url_by_default(monkeypatch):
|
||||
monkeypatch.setattr("api.utils.url_security.DEPLOYMENT_MODE", "oss")
|
||||
user_config = SimpleNamespace(
|
||||
realtime=SimpleNamespace(
|
||||
provider=ServiceProviders.AZURE_REALTIME.value,
|
||||
api_key="test-key",
|
||||
endpoint="https://example.openai.azure.com",
|
||||
model="my-realtime-deployment",
|
||||
voice="alloy",
|
||||
)
|
||||
)
|
||||
|
||||
service = create_realtime_llm_service(user_config, _audio_config())
|
||||
|
||||
assert service.base_url == (
|
||||
"wss://example.openai.azure.com/openai/v1/realtime?model=my-realtime-deployment"
|
||||
)
|
||||
|
||||
|
||||
def test_create_azure_realtime_preserves_explicit_preview_websocket_url(monkeypatch):
|
||||
monkeypatch.setattr("api.utils.url_security.DEPLOYMENT_MODE", "oss")
|
||||
user_config = SimpleNamespace(
|
||||
realtime=SimpleNamespace(
|
||||
provider=ServiceProviders.AZURE_REALTIME.value,
|
||||
api_key="test-key",
|
||||
endpoint="https://example.openai.azure.com",
|
||||
api_version="2025-04-01-preview",
|
||||
model="my-preview-deployment",
|
||||
voice="alloy",
|
||||
)
|
||||
)
|
||||
|
||||
service = create_realtime_llm_service(user_config, _audio_config())
|
||||
|
||||
assert service.base_url == (
|
||||
"wss://example.openai.azure.com/openai/realtime?"
|
||||
"api-version=2025-04-01-preview&deployment=my-preview-deployment"
|
||||
)
|
||||
|
||||
|
||||
def test_azure_embedding_service_rejects_wrong_dimension():
|
||||
service = AzureOpenAIEmbeddingService(
|
||||
db_client=SimpleNamespace(),
|
||||
|
|
|
|||
|
|
@ -134,6 +134,21 @@ def test_gemini_live_service_classes_use_dograh_gemini_adapter_class():
|
|||
)
|
||||
|
||||
|
||||
def test_vertex_live_inherits_dograh_node_transition_lifecycle():
|
||||
assert (
|
||||
DograhGeminiLiveVertexLLMService._requires_node_transition_context_aggregation
|
||||
is DograhGeminiLiveLLMService._requires_node_transition_context_aggregation
|
||||
)
|
||||
assert (
|
||||
DograhGeminiLiveVertexLLMService._run_or_defer_function_calls
|
||||
is DograhGeminiLiveLLMService._run_or_defer_function_calls
|
||||
)
|
||||
assert (
|
||||
DograhGeminiLiveVertexLLMService._reconnect_for_node_transition
|
||||
is DograhGeminiLiveLLMService._reconnect_for_node_transition
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_live_config_accepts_json_schema_tools():
|
||||
function_schema = FunctionSchema(
|
||||
name="customer_lookup",
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ async def test_messages_append_frame_sends_conversation_item():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_call_is_deferred_until_bot_stops_speaking():
|
||||
async def test_non_transition_function_call_runs_while_bot_is_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
|
|
@ -145,13 +145,38 @@ async def test_function_call_is_deferred_until_bot_stops_speaking():
|
|||
)
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_function_calls) == 1
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
await service._run_pending_function_calls()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_function_call_waits_until_bot_stops_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service.register_function(
|
||||
"customer_support",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(
|
||||
call_id="call-1",
|
||||
name="customer_support",
|
||||
arguments='{"department":"sales"}',
|
||||
)
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_node_transition_function_calls) == 1
|
||||
|
||||
await service._run_pending_node_transition_function_calls()
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_function_calls == []
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -190,3 +215,21 @@ def test_factory_creates_dograh_grok_realtime_service():
|
|||
)
|
||||
|
||||
assert isinstance(service, DograhGrokRealtimeLLMService)
|
||||
assert service._settings.session_properties.voice == "sal"
|
||||
assert service._settings.session_properties.audio.input.transcription.model == (
|
||||
"grok-transcribe"
|
||||
)
|
||||
|
||||
|
||||
def test_grok_audio_config_preserves_transcription_when_filling_sample_rates():
|
||||
service = _make_service()
|
||||
service._settings.session_properties.audio = events.AudioConfiguration(
|
||||
input=events.AudioInput(transcription=events.InputAudioTranscription())
|
||||
)
|
||||
|
||||
service._ensure_audio_config(input_sample_rate=16000, output_sample_rate=24000)
|
||||
|
||||
audio = service._settings.session_properties.audio
|
||||
assert audio.input.format.rate == 16000
|
||||
assert audio.input.transcription.model == "grok-transcribe"
|
||||
assert audio.output.format.rate == 24000
|
||||
|
|
|
|||
|
|
@ -127,11 +127,11 @@ async def test_function_call_executes_immediately_when_bot_is_not_speaking():
|
|||
)
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_function_calls == []
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_call_is_deferred_until_bot_stops_speaking():
|
||||
async def test_non_transition_function_call_runs_while_bot_is_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
|
|
@ -142,10 +142,31 @@ async def test_function_call_is_deferred_until_bot_stops_speaking():
|
|||
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_function_calls) == 1
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
await service._run_pending_function_calls()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_function_call_is_deferred_until_bot_stops_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service.register_function(
|
||||
"customer_support",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_node_transition_function_calls) == 1
|
||||
|
||||
await service._run_pending_node_transition_function_calls()
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_function_calls == []
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, call
|
||||
|
||||
|
|
@ -13,7 +14,6 @@ from websockets.frames import Close
|
|||
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
||||
from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration
|
||||
from api.services.pipecat.realtime.ultravox_realtime import (
|
||||
_RESUMPTION_USER_MESSAGE,
|
||||
DograhUltravoxOneShotInputParams,
|
||||
DograhUltravoxRealtimeLLMService,
|
||||
)
|
||||
|
|
@ -100,50 +100,35 @@ async def test_initial_context_connects_without_replay():
|
|||
await service._handle_context(context)
|
||||
|
||||
service._connect_call.assert_awaited_once()
|
||||
assert service._connect_call.await_args.kwargs["initial_messages"] is None
|
||||
assert service._connect_call.await_args.kwargs["greeting_text"] is None
|
||||
assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_instruction_update_marks_reconnect_required():
|
||||
async def test_system_instruction_update_marks_stage_update_required():
|
||||
service = _make_service()
|
||||
service._has_connected_once = True
|
||||
service._socket = object()
|
||||
|
||||
changed = await service._update_settings(
|
||||
DograhUltravoxRealtimeLLMService.Settings(system_instruction="new instruction")
|
||||
)
|
||||
|
||||
assert "system_instruction" in changed
|
||||
assert service._reconnect_required is True
|
||||
assert service._stage_update_required is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_instruction_change_reconnects_with_full_initial_messages():
|
||||
async def test_node_transition_updates_native_stage_without_reconnecting():
|
||||
service = _make_service()
|
||||
service._socket = object()
|
||||
service._has_connected_once = True
|
||||
service._call_system_instruction = "old instruction"
|
||||
service._reconnect_required = True
|
||||
service._send = AsyncMock()
|
||||
service._connect_call = AsyncMock()
|
||||
service._pending_node_transition_tool_call_ids.add("call-transition")
|
||||
service._stage_update_required = True
|
||||
service._settings.system_instruction = "new instruction"
|
||||
service._reconnect_with_context = AsyncMock()
|
||||
|
||||
context = LLMContext(
|
||||
messages=[
|
||||
{"role": "user", "content": "I want to hear the pricing."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me check that for you.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-transition",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "transition_to_next_node",
|
||||
"arguments": '{"reason":"pricing requested"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call-transition",
|
||||
|
|
@ -155,43 +140,28 @@ async def test_system_instruction_change_reconnects_with_full_initial_messages()
|
|||
|
||||
await service._handle_context(context)
|
||||
|
||||
service._reconnect_with_context.assert_awaited_once()
|
||||
initial_messages = service._reconnect_with_context.await_args.kwargs[
|
||||
"initial_messages"
|
||||
]
|
||||
assert initial_messages == [
|
||||
{
|
||||
"role": "MESSAGE_ROLE_USER",
|
||||
"text": "I want to hear the pricing.",
|
||||
},
|
||||
{
|
||||
"role": "MESSAGE_ROLE_AGENT",
|
||||
"text": "Let me check that for you.",
|
||||
},
|
||||
{
|
||||
"role": "MESSAGE_ROLE_TOOL_CALL",
|
||||
"text": "",
|
||||
"invocationId": "call-transition",
|
||||
"toolName": "transition_to_next_node",
|
||||
},
|
||||
{
|
||||
"role": "MESSAGE_ROLE_TOOL_RESULT",
|
||||
"text": '{"status":"done"}',
|
||||
"invocationId": "call-transition",
|
||||
"toolName": "transition_to_next_node",
|
||||
},
|
||||
]
|
||||
service._connect_call.assert_not_awaited()
|
||||
service._send.assert_awaited_once()
|
||||
message = service._send.await_args.args[0]
|
||||
assert message["type"] == "client_tool_result"
|
||||
assert message["invocationId"] == "call-transition"
|
||||
assert message["responseType"] == "new-stage"
|
||||
stage = json.loads(message["result"])
|
||||
assert stage["systemPrompt"] == "new instruction"
|
||||
assert stage["toolResultText"] == '{"status":"done"}'
|
||||
assert stage["selectedTools"][0]["temporaryTool"]["modelToolName"] == (
|
||||
"transition_to_next_node"
|
||||
)
|
||||
assert "call-transition" in service._completed_tool_calls
|
||||
assert service._pending_node_transition_tool_call_ids == set()
|
||||
assert service._stage_update_required is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_context_update_does_not_reconnect_when_system_instruction_is_unchanged():
|
||||
async def test_ordinary_tool_result_uses_standard_tool_response():
|
||||
service = _make_service()
|
||||
service._socket = object()
|
||||
service._call_system_instruction = "same instruction"
|
||||
service._settings.system_instruction = "same instruction"
|
||||
service._reconnect_with_context = AsyncMock()
|
||||
service._send_tool_result = AsyncMock()
|
||||
service._send = AsyncMock()
|
||||
|
||||
context = LLMContext(
|
||||
messages=[
|
||||
|
|
@ -206,13 +176,40 @@ async def test_tool_context_update_does_not_reconnect_when_system_instruction_is
|
|||
|
||||
await service._handle_context(context)
|
||||
|
||||
service._reconnect_with_context.assert_not_awaited()
|
||||
service._send_tool_result.assert_awaited_once_with(
|
||||
"call-transition",
|
||||
'{"status":"done"}',
|
||||
service._send.assert_awaited_once_with(
|
||||
{
|
||||
"type": "client_tool_result",
|
||||
"invocationId": "call-transition",
|
||||
"result": '{"status":"done"}',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_registered_node_transition_invocations_are_tracked():
|
||||
service = _make_service()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service.register_function(
|
||||
"transition_to_next_node",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
|
||||
await service._handle_tool_invocation(
|
||||
"transition_to_next_node", "call-transition", {"reason": "pricing"}
|
||||
)
|
||||
await service._handle_tool_invocation("lookup_price", "call-lookup", {})
|
||||
|
||||
assert service._pending_node_transition_tool_call_ids == {"call-transition"}
|
||||
assert service.run_function_calls.await_count == 2
|
||||
|
||||
|
||||
def test_ultravox_requires_transition_context_aggregation():
|
||||
service = _make_service()
|
||||
|
||||
assert service._requires_node_transition_context_aggregation() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messages_append_frame_sends_user_text():
|
||||
service = _make_service()
|
||||
|
|
@ -287,7 +284,6 @@ def test_build_one_shot_params_uses_explicit_greeting_text():
|
|||
|
||||
params = service._build_one_shot_params(
|
||||
greeting_text="Welcome to Dograh",
|
||||
initial_messages=None,
|
||||
agent_speaks_first=True,
|
||||
)
|
||||
|
||||
|
|
@ -296,85 +292,18 @@ def test_build_one_shot_params_uses_explicit_greeting_text():
|
|||
}
|
||||
|
||||
|
||||
def test_build_one_shot_params_includes_initial_messages():
|
||||
def test_build_one_shot_params_uses_current_system_instruction():
|
||||
service = _make_service()
|
||||
service._settings.system_instruction = "Base instruction"
|
||||
|
||||
params = service._build_one_shot_params(
|
||||
greeting_text=None,
|
||||
initial_messages=[
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
|
||||
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
|
||||
],
|
||||
agent_speaks_first=True,
|
||||
)
|
||||
|
||||
assert params.extra["initialMessages"] == [
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
|
||||
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
|
||||
{"role": "MESSAGE_ROLE_USER", "text": _RESUMPTION_USER_MESSAGE},
|
||||
]
|
||||
assert params.system_prompt == "Base instruction"
|
||||
|
||||
|
||||
def test_build_one_shot_params_without_tool_result_does_not_add_resumption_user_message():
|
||||
service = _make_service()
|
||||
service._settings.system_instruction = "Base instruction"
|
||||
|
||||
params = service._build_one_shot_params(
|
||||
greeting_text=None,
|
||||
initial_messages=[
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
|
||||
{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."},
|
||||
],
|
||||
agent_speaks_first=False,
|
||||
)
|
||||
|
||||
assert params.system_prompt == "Base instruction"
|
||||
|
||||
|
||||
def test_should_agent_speak_first_when_history_ends_with_tool_result():
|
||||
service = _make_service()
|
||||
|
||||
assert (
|
||||
service._should_agent_speak_first(
|
||||
[
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "Hello"},
|
||||
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
|
||||
]
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_should_not_force_agent_speaks_first_when_history_ends_with_agent():
|
||||
service = _make_service()
|
||||
|
||||
assert (
|
||||
service._should_agent_speak_first(
|
||||
[{"role": "MESSAGE_ROLE_AGENT", "text": "How else can I help?"}]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_should_add_resumption_user_message_only_when_history_ends_with_tool_result():
|
||||
service = _make_service()
|
||||
|
||||
assert (
|
||||
service._should_add_resumption_user_message(
|
||||
[{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}]
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
service._should_add_resumption_user_message(
|
||||
[{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."}]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_to_selected_tools_includes_registered_timeout():
|
||||
service = _make_service()
|
||||
service.register_function(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue