Merge pull request #3939 from pipecat-ai/pk/openai-realtime-settings-pattern

Adopt the `settings` pattern for OpenAI Realtime session properties
This commit is contained in:
kompfner
2026-03-06 12:39:08 -05:00
committed by GitHub
7 changed files with 462 additions and 130 deletions

View File

@@ -37,7 +37,10 @@ from pipecat.services.openai.realtime.events import (
SemanticTurnDetection, SemanticTurnDetection,
SessionProperties, SessionProperties,
) )
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService from pipecat.services.openai.realtime.llm import (
OpenAIRealtimeLLMService,
OpenAIRealtimeLLMSettings,
)
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams from pipecat.transports.daily.transport import DailyParams
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
@@ -137,22 +140,10 @@ transport_params = {
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot") logger.info(f"Starting bot")
session_properties = SessionProperties( llm = OpenAIRealtimeLLMService(
audio=AudioConfiguration( api_key=os.getenv("OPENAI_API_KEY"),
input=AudioInput( settings=OpenAIRealtimeLLMSettings(
transcription=InputAudioTranscription(), system_instruction="""You are a helpful and friendly AI.
# Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=SemanticTurnDetection(),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
noise_reduction=InputAudioNoiseReduction(type="near_field"),
)
),
# In this example we provide tools through the context, but you could
# alternatively provide them here.
# tools=tools,
instructions="""You are a helpful and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human Act like a human, but remember that you aren't a human and that you can't do human
things in the real world. Your voice and personality should be warm and engaging, with a lively and things in the real world. Your voice and personality should be warm and engaging, with a lively and
@@ -166,11 +157,23 @@ You are participating in a voice conversation. Keep your responses concise, shor
unless specifically asked to elaborate on a topic. unless specifically asked to elaborate on a topic.
Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""",
) session_properties=SessionProperties(
audio=AudioConfiguration(
llm = OpenAIRealtimeLLMService( input=AudioInput(
api_key=os.getenv("OPENAI_API_KEY"), transcription=InputAudioTranscription(),
session_properties=session_properties, # Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=SemanticTurnDetection(),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
noise_reduction=InputAudioNoiseReduction(type="near_field"),
)
),
# In this example we provide tools through the context, but you could
# alternatively provide them here.
# tools=tools,
),
),
) )
# you can either register a single function for all function calls, or specific functions # you can either register a single function for all function calls, or specific functions

View File

@@ -30,6 +30,7 @@ from pipecat.services.openai.realtime.events import (
InputAudioTranscription, InputAudioTranscription,
SessionProperties, SessionProperties,
) )
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMSettings
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams from pipecat.transports.daily.transport import DailyParams
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
@@ -111,19 +112,11 @@ transport_params = {
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot") logger.info(f"Starting bot")
session_properties = SessionProperties( llm = AzureRealtimeLLMService(
audio=AudioConfiguration( api_key=os.getenv("AZURE_REALTIME_API_KEY"),
input=AudioInput( base_url=os.getenv("AZURE_REALTIME_BASE_URL"),
transcription=InputAudioTranscription(model="whisper-1"), settings=OpenAIRealtimeLLMSettings(
# Set openai TurnDetection parameters. Not setting this at all will turn it system_instruction="""You are a helpful and friendly AI.
# on by default
# turn_detection=TurnDetection(silence_duration_ms=1000),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
)
),
# tools=tools,
instructions="""You are a helpful and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human Act like a human, but remember that you aren't a human and that you can't do human
things in the real world. Your voice and personality should be warm and engaging, with a lively and things in the real world. Your voice and personality should be warm and engaging, with a lively and
@@ -141,12 +134,20 @@ You have access to the following tools:
- get_restaurant_recommendation: Get a restaurant recommendation for a given location. - get_restaurant_recommendation: Get a restaurant recommendation for a given location.
Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""",
) session_properties=SessionProperties(
audio=AudioConfiguration(
llm = AzureRealtimeLLMService( input=AudioInput(
api_key=os.getenv("AZURE_REALTIME_API_KEY"), transcription=InputAudioTranscription(model="whisper-1"),
base_url=os.getenv("AZURE_REALTIME_BASE_URL"), # Set openai TurnDetection parameters. Not setting this at all will turn it
session_properties=session_properties, # on by default
# turn_detection=TurnDetection(silence_duration_ms=1000),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
)
),
# tools=tools,
),
),
) )
# you can either register a single function for all function calls, or specific functions # you can either register a single function for all function calls, or specific functions

View File

@@ -32,7 +32,10 @@ from pipecat.services.openai.realtime.events import (
SemanticTurnDetection, SemanticTurnDetection,
SessionProperties, SessionProperties,
) )
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService from pipecat.services.openai.realtime.llm import (
OpenAIRealtimeLLMService,
OpenAIRealtimeLLMSettings,
)
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams from pipecat.transports.daily.transport import DailyParams
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
@@ -113,21 +116,10 @@ transport_params = {
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot") logger.info(f"Starting bot")
session_properties = SessionProperties( llm = OpenAIRealtimeLLMService(
audio=AudioConfiguration( api_key=os.getenv("OPENAI_API_KEY"),
input=AudioInput( settings=OpenAIRealtimeLLMSettings(
transcription=InputAudioTranscription(), system_instruction="""You are a helpful and friendly AI.
# Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=SemanticTurnDetection(),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
noise_reduction=InputAudioNoiseReduction(type="near_field"),
)
),
output_modalities=["text"],
# tools=tools,
instructions="""You are a helpful and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human Act like a human, but remember that you aren't a human and that you can't do human
things in the real world. Your voice and personality should be warm and engaging, with a lively and things in the real world. Your voice and personality should be warm and engaging, with a lively and
@@ -145,11 +137,22 @@ You have access to the following tools:
- get_restaurant_recommendation: Get a restaurant recommendation for a given location. - get_restaurant_recommendation: Get a restaurant recommendation for a given location.
Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""",
) session_properties=SessionProperties(
audio=AudioConfiguration(
llm = OpenAIRealtimeLLMService( input=AudioInput(
api_key=os.getenv("OPENAI_API_KEY"), transcription=InputAudioTranscription(),
session_properties=session_properties, # Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=SemanticTurnDetection(),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
noise_reduction=InputAudioNoiseReduction(type="near_field"),
)
),
output_modalities=["text"],
# tools=tools,
),
),
) )
tts = CartesiaTTSService( tts = CartesiaTTSService(

View File

@@ -32,7 +32,10 @@ from pipecat.services.openai.realtime.events import (
SemanticTurnDetection, SemanticTurnDetection,
SessionProperties, SessionProperties,
) )
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService from pipecat.services.openai.realtime.llm import (
OpenAIRealtimeLLMService,
OpenAIRealtimeLLMSettings,
)
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams from pipecat.transports.daily.transport import DailyParams
@@ -60,22 +63,10 @@ transport_params = {
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot") logger.info(f"Starting bot")
session_properties = SessionProperties( llm = OpenAIRealtimeLLMService(
audio=AudioConfiguration( api_key=os.getenv("OPENAI_API_KEY"),
input=AudioInput( settings=OpenAIRealtimeLLMSettings(
transcription=InputAudioTranscription(), system_instruction="""You are a helpful and friendly AI.
# Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=SemanticTurnDetection(),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
noise_reduction=InputAudioNoiseReduction(type="near_field"),
)
),
# In this example we provide tools through the context, but you could
# alternatively provide them here.
# tools=tools,
instructions="""You are a helpful and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human Act like a human, but remember that you aren't a human and that you can't do human
things in the real world. Your voice and personality should be warm and engaging, with a lively and things in the real world. Your voice and personality should be warm and engaging, with a lively and
@@ -89,11 +80,23 @@ You are participating in a voice conversation. Keep your responses concise, shor
unless specifically asked to elaborate on a topic. unless specifically asked to elaborate on a topic.
Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""",
) session_properties=SessionProperties(
audio=AudioConfiguration(
llm = OpenAIRealtimeLLMService( input=AudioInput(
api_key=os.getenv("OPENAI_API_KEY"), transcription=InputAudioTranscription(),
session_properties=session_properties, # Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=SemanticTurnDetection(),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
noise_reduction=InputAudioNoiseReduction(type="near_field"),
)
),
# In this example we provide tools through the context, but you could
# alternatively provide them here.
# tools=tools,
),
),
) )
# Create a standard OpenAI LLM context object using the normal messages format. The # Create a standard OpenAI LLM context object using the normal messages format. The

View File

@@ -33,7 +33,10 @@ from pipecat.services.openai.realtime.events import (
SessionProperties, SessionProperties,
TurnDetection, TurnDetection,
) )
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService from pipecat.services.openai.realtime.llm import (
OpenAIRealtimeLLMService,
OpenAIRealtimeLLMSettings,
)
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams from pipecat.transports.daily.transport import DailyParams
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
@@ -173,19 +176,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
session_properties = SessionProperties( llm = OpenAIRealtimeLLMService(
audio=AudioConfiguration( api_key=os.getenv("OPENAI_API_KEY"),
input=AudioInput( settings=OpenAIRealtimeLLMSettings(
transcription=InputAudioTranscription(), system_instruction="""Your knowledge cutoff is 2023-10. You are a helpful and friendly AI.
# Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=TurnDetection(silence_duration_ms=1000),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
)
),
# tools=tools,
instructions="""Your knowledge cutoff is 2023-10. You are a helpful and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human Act like a human, but remember that you aren't a human and that you can't do human
things in the real world. Your voice and personality should be warm and engaging, with a lively and things in the real world. Your voice and personality should be warm and engaging, with a lively and
@@ -199,11 +193,20 @@ You are participating in a voice conversation. Keep your responses concise, shor
unless specifically asked to elaborate on a topic. unless specifically asked to elaborate on a topic.
Remember, your responses should be short. Just one or two sentences, usually.""", Remember, your responses should be short. Just one or two sentences, usually.""",
) session_properties=SessionProperties(
audio=AudioConfiguration(
llm = OpenAIRealtimeLLMService( input=AudioInput(
api_key=os.getenv("OPENAI_API_KEY"), transcription=InputAudioTranscription(),
session_properties=session_properties, # Set openai TurnDetection parameters. Not setting this at all will turn it
# on by default
turn_detection=TurnDetection(silence_duration_ms=1000),
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
)
),
# tools=tools,
),
),
) )
# you can either register a single function for all function calls, or specific functions # you can either register a single function for all function calls, or specific functions

View File

@@ -10,8 +10,9 @@ import base64
import io import io
import json import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any, Optional from dataclasses import fields as dataclass_fields
from typing import Any, Dict, Mapping, Optional, Type
from loguru import logger from loguru import logger
from PIL import Image from PIL import Image
@@ -59,7 +60,13 @@ from pipecat.processors.aggregators.openai_llm_context import (
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.services.llm_service import FunctionCallFromLLM, LLMService
from pipecat.services.settings import LLMSettings, _warn_deprecated_param from pipecat.services.settings import (
NOT_GIVEN,
LLMSettings,
_NotGiven,
_warn_deprecated_param,
is_given,
)
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
from pipecat.utils.time import time_now_iso8601 from pipecat.utils.time import time_now_iso8601
from pipecat.utils.tracing.service_decorators import traced_openai_realtime, traced_stt from pipecat.utils.tracing.service_decorators import traced_openai_realtime, traced_stt
@@ -93,9 +100,107 @@ class CurrentAudioResponse:
@dataclass @dataclass
class OpenAIRealtimeLLMSettings(LLMSettings): class OpenAIRealtimeLLMSettings(LLMSettings):
"""Settings for OpenAI Realtime LLM services.""" """Settings for OpenAI Realtime LLM services.
pass Parameters:
session_properties: OpenAI Realtime session properties (modalities,
audio config, tools, etc.). ``model`` and ``instructions`` are
synced bidirectionally with the top-level ``model`` and
``system_instruction`` fields.
"""
session_properties: events.SessionProperties | _NotGiven = field(
default_factory=lambda: NOT_GIVEN
)
# -- Bidirectional sync helpers ------------------------------------------
@staticmethod
def _sync_top_level_to_sp(settings: "OpenAIRealtimeLLMSettings"):
"""Push top-level ``model``/``system_instruction`` into ``session_properties``."""
if not is_given(settings.session_properties):
return
sp = settings.session_properties
if is_given(settings.model) and settings.model is not None:
sp.model = settings.model
if is_given(settings.system_instruction):
sp.instructions = settings.system_instruction
# -- apply_update override -----------------------------------------------
def apply_update(self, delta: "OpenAIRealtimeLLMSettings") -> Dict[str, Any]:
"""Merge a delta, keeping ``model``/``system_instruction`` in sync with SP.
When the delta contains ``session_properties``, it **replaces** the
stored SP wholesale (matching legacy behaviour). Top-level field
values always take precedence over conflicting SP values.
"""
# 1. Let the base class handle all fields including session_properties
# (wholesale replacement when given).
changed = super().apply_update(delta)
# 2. SP → top-level: if the SP was just replaced and carries
# model/instructions that the delta didn't set at top level,
# pull them up.
if "session_properties" in changed and is_given(self.session_properties):
sp = self.session_properties
if "model" not in changed and sp.model is not None:
old_model = self.model
self.model = sp.model
if old_model != self.model:
changed["model"] = old_model
if "system_instruction" not in changed and sp.instructions is not None:
old_si = self.system_instruction
self.system_instruction = sp.instructions
if old_si != self.system_instruction:
changed["system_instruction"] = old_si
# 3. Top-level → SP: ensure SP mirrors the authoritative top-level
# values. Covers all cases: top-level-only delta, SP-only delta,
# and mixed deltas where top-level takes precedence.
self._sync_top_level_to_sp(self)
return changed
# -- from_mapping override -----------------------------------------------
@classmethod
def from_mapping(
cls: Type["OpenAIRealtimeLLMSettings"], settings: Mapping[str, Any]
) -> "OpenAIRealtimeLLMSettings":
"""Build a delta from a plain dict, routing SP keys into ``session_properties``.
Keys that correspond to ``SessionProperties`` fields (except ``model``)
are collected into a nested ``session_properties`` value. ``model`` is
always routed to the top-level field. Unknown keys go to ``extra``.
"""
# Determine which keys belong to our own dataclass fields.
own_field_names = {f.name for f in dataclass_fields(cls)} - {"extra"}
top: Dict[str, Any] = {}
sp_dict: Dict[str, Any] = {}
extra: Dict[str, Any] = {}
# Build the SP field set without instantiating (avoid __post_init__
# cost for every from_mapping call).
sp_keys = set(events.SessionProperties.model_fields.keys()) - {"model"}
for key, value in settings.items():
# Resolve aliases first
canonical = cls._aliases.get(key, key)
if canonical in own_field_names:
top[canonical] = value
elif canonical in sp_keys:
sp_dict[canonical] = value
else:
extra[key] = value
if sp_dict:
top["session_properties"] = events.SessionProperties(**sp_dict)
instance = cls(**top)
instance.extra = extra
return instance
class OpenAIRealtimeLLMService(LLMService): class OpenAIRealtimeLLMService(LLMService):
@@ -140,6 +245,10 @@ class OpenAIRealtimeLLMService(LLMService):
Defaults to "wss://api.openai.com/v1/realtime". Defaults to "wss://api.openai.com/v1/realtime".
session_properties: Configuration properties for the realtime session. session_properties: Configuration properties for the realtime session.
If None, uses default SessionProperties. If None, uses default SessionProperties.
.. deprecated::
Use ``settings=OpenAIRealtimeLLMSettings(session_properties=...)``
instead.
settings: Runtime-updatable settings for this service. settings: Runtime-updatable settings for this service.
start_audio_paused: Whether to start with audio input paused. Defaults to False. start_audio_paused: Whether to start with audio input paused. Defaults to False.
start_video_paused: Whether to start with video input paused. Defaults to False. start_video_paused: Whether to start with video input paused. Defaults to False.
@@ -180,6 +289,7 @@ class OpenAIRealtimeLLMService(LLMService):
seed=None, seed=None,
filter_incomplete_user_turns=False, filter_incomplete_user_turns=False,
user_turn_completion_config=None, user_turn_completion_config=None,
session_properties=events.SessionProperties(),
) )
# 2. Apply direct init arg overrides (deprecated) # 2. Apply direct init arg overrides (deprecated)
@@ -187,6 +297,23 @@ class OpenAIRealtimeLLMService(LLMService):
_warn_deprecated_param("model", OpenAIRealtimeLLMSettings, "model") _warn_deprecated_param("model", OpenAIRealtimeLLMSettings, "model")
default_settings.model = model default_settings.model = model
if session_properties is not None:
_warn_deprecated_param(
"session_properties",
OpenAIRealtimeLLMSettings,
"session_properties",
)
default_settings.session_properties = session_properties
# Sync model/instructions from the deprecated SP arg to top-level,
# but only if the deprecated `model` arg didn't already set it.
if model is None and session_properties.model is not None:
default_settings.model = session_properties.model
if session_properties.instructions is not None:
default_settings.system_instruction = session_properties.instructions
# Sync top-level model back into session_properties
OpenAIRealtimeLLMSettings._sync_top_level_to_sp(default_settings)
# 3. Apply settings delta (canonical API, always wins) # 3. Apply settings delta (canonical API, always wins)
if settings is not None: if settings is not None:
default_settings.apply_update(settings) default_settings.apply_update(settings)
@@ -202,7 +329,6 @@ class OpenAIRealtimeLLMService(LLMService):
self.api_key = api_key self.api_key = api_key
self.base_url = full_url self.base_url = full_url
self._session_properties = session_properties or events.SessionProperties()
self._audio_input_paused = start_audio_paused self._audio_input_paused = start_audio_paused
self._video_input_paused = start_video_paused self._video_input_paused = start_video_paused
self._video_frame_detail = video_frame_detail self._video_frame_detail = video_frame_detail
@@ -265,12 +391,12 @@ class OpenAIRealtimeLLMService(LLMService):
def _is_modality_enabled(self, modality: str) -> bool: def _is_modality_enabled(self, modality: str) -> bool:
"""Check if a specific modality is enabled, "text" or "audio".""" """Check if a specific modality is enabled, "text" or "audio"."""
modalities = self._session_properties.output_modalities or ["audio", "text"] modalities = self._settings.session_properties.output_modalities or ["audio", "text"]
return modality in modalities return modality in modalities
def _get_enabled_modalities(self) -> list[str]: def _get_enabled_modalities(self) -> list[str]:
"""Get the list of enabled modalities.""" """Get the list of enabled modalities."""
modalities = self._session_properties.output_modalities or ["audio", "text"] modalities = self._settings.session_properties.output_modalities or ["audio", "text"]
# API only supports single modality responses: either ["text"] or ["audio"] # API only supports single modality responses: either ["text"] or ["audio"]
if "audio" in modalities: if "audio" in modalities:
return ["audio"] return ["audio"]
@@ -343,9 +469,9 @@ class OpenAIRealtimeLLMService(LLMService):
# None and False are different. Check for False. None means we're using OpenAI's # None and False are different. Check for False. None means we're using OpenAI's
# built-in turn detection defaults. # built-in turn detection defaults.
turn_detection_disabled = ( turn_detection_disabled = (
self._session_properties.audio self._settings.session_properties.audio
and self._session_properties.audio.input and self._settings.session_properties.audio.input
and self._session_properties.audio.input.turn_detection is False and self._settings.session_properties.audio.input.turn_detection is False
) )
if turn_detection_disabled: if turn_detection_disabled:
await self.send_client_event(events.InputAudioBufferClearEvent()) await self.send_client_event(events.InputAudioBufferClearEvent())
@@ -365,9 +491,9 @@ class OpenAIRealtimeLLMService(LLMService):
# None and False are different. Check for False. None means we're using OpenAI's # None and False are different. Check for False. None means we're using OpenAI's
# built-in turn detection defaults. # built-in turn detection defaults.
turn_detection_disabled = ( turn_detection_disabled = (
self._session_properties.audio self._settings.session_properties.audio
and self._session_properties.audio.input and self._settings.session_properties.audio.input
and self._session_properties.audio.input.turn_detection is False and self._settings.session_properties.audio.input.turn_detection is False
) )
if turn_detection_disabled: if turn_detection_disabled:
await self.send_client_event(events.InputAudioBufferCommitEvent()) await self.send_client_event(events.InputAudioBufferCommitEvent())
@@ -435,16 +561,6 @@ class OpenAIRealtimeLLMService(LLMService):
frame: The frame to process. frame: The frame to process.
direction: The direction of frame flow in the pipeline. direction: The direction of frame flow in the pipeline.
""" """
# Backward-compatible dict path: frame.settings contains SessionProperties
# fields, not our Settings fields, so we construct SessionProperties
# directly. The frame.delta path falls through to super, which calls
# _update_settings → our override handles the rest.
if isinstance(frame, LLMUpdateSettingsFrame) and frame.delta is None:
self._session_properties = events.SessionProperties(**frame.settings)
await self._send_session_update()
await self.push_frame(frame, direction)
return
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
if isinstance(frame, TranscriptionFrame): if isinstance(frame, TranscriptionFrame):
@@ -559,13 +675,16 @@ class OpenAIRealtimeLLMService(LLMService):
await self.push_error(error_msg=f"Error sending client event: {e}", exception=e) await self.push_error(error_msg=f"Error sending client event: {e}", exception=e)
async def _update_settings(self, delta): async def _update_settings(self, delta):
"""Apply a settings delta.""" """Apply a settings delta, sending a session update when needed."""
changed = await super()._update_settings(delta) changed = await super()._update_settings(delta)
self._warn_unhandled_updated_settings(changed.keys()) handled = {"session_properties", "system_instruction"}
if changed.keys() & handled:
await self._send_session_update()
self._warn_unhandled_updated_settings(changed.keys() - handled)
return changed return changed
async def _send_session_update(self): async def _send_session_update(self):
settings = self._session_properties settings = self._settings.session_properties
adapter: OpenAIRealtimeLLMAdapter = self.get_llm_adapter() adapter: OpenAIRealtimeLLMAdapter = self.get_llm_adapter()
if self._context: if self._context:

View File

@@ -12,6 +12,8 @@ import pytest
from pipecat.services.deepgram.stt import DeepgramSTTService, DeepgramSTTSettings from pipecat.services.deepgram.stt import DeepgramSTTService, DeepgramSTTSettings
from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTSettings from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTSettings
from pipecat.services.openai.realtime import events
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMSettings
from pipecat.services.settings import ( from pipecat.services.settings import (
NOT_GIVEN, NOT_GIVEN,
LLMSettings, LLMSettings,
@@ -615,3 +617,201 @@ class TestDeepgramSTTSettingsExtraSync:
kwargs = svc._build_connect_kwargs() kwargs = svc._build_connect_kwargs()
assert kwargs["numerals"] == "true" assert kwargs["numerals"] == "true"
assert kwargs["custom_param"] == "test" assert kwargs["custom_param"] == "test"
# ---------------------------------------------------------------------------
# OpenAIRealtimeLLMSettings: apply_update with bidirectional sync
# ---------------------------------------------------------------------------
class TestOpenAIRealtimeSettingsApplyUpdate:
def _make_store(self, **kwargs) -> OpenAIRealtimeLLMSettings:
"""Helper to build a store-mode OpenAIRealtimeLLMSettings."""
defaults = dict(
model="gpt-realtime-1.5",
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=events.SessionProperties(),
)
defaults.update(kwargs)
return OpenAIRealtimeLLMSettings(**defaults)
def test_top_level_model_syncs_to_sp(self):
"""Updating top-level model should propagate to session_properties.model."""
store = self._make_store()
delta = OpenAIRealtimeLLMSettings(model="gpt-realtime-2.0")
changed = store.apply_update(delta)
assert "model" in changed
assert store.model == "gpt-realtime-2.0"
assert store.session_properties.model == "gpt-realtime-2.0"
def test_top_level_system_instruction_syncs_to_sp(self):
"""Updating top-level system_instruction should propagate to session_properties.instructions."""
store = self._make_store()
delta = OpenAIRealtimeLLMSettings(system_instruction="Be helpful.")
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "Be helpful."
assert store.session_properties.instructions == "Be helpful."
def test_sp_replaces_wholesale(self):
"""session_properties in delta replaces the entire stored SP."""
store = self._make_store(
session_properties=events.SessionProperties(
output_modalities=["audio", "text"],
instructions="Old instructions.",
),
system_instruction="Old instructions.",
)
new_sp = events.SessionProperties(output_modalities=["text"])
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.output_modalities == ["text"]
# Fields not in the new SP become None (wholesale replacement)
# But model is synced from top-level
assert store.session_properties.model == "gpt-realtime-1.5"
def test_sp_model_syncs_to_top_level(self):
"""session_properties.model should sync to top-level model."""
store = self._make_store()
new_sp = events.SessionProperties(model="gpt-realtime-2.0")
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "model" in changed
assert store.model == "gpt-realtime-2.0"
assert store.session_properties.model == "gpt-realtime-2.0"
def test_sp_instructions_syncs_to_top_level(self):
"""session_properties.instructions should sync to top-level system_instruction."""
store = self._make_store()
new_sp = events.SessionProperties(instructions="New instructions.")
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "New instructions."
assert store.session_properties.instructions == "New instructions."
def test_top_level_model_takes_precedence_over_sp_model(self):
"""When both model and session_properties.model are in the delta, top-level wins."""
store = self._make_store()
new_sp = events.SessionProperties(model="sp-model")
delta = OpenAIRealtimeLLMSettings(model="top-model", session_properties=new_sp)
store.apply_update(delta)
assert store.model == "top-model"
assert store.session_properties.model == "top-model"
def test_top_level_si_takes_precedence_over_sp_instructions(self):
"""When both system_instruction and SP.instructions are in delta, top-level wins."""
store = self._make_store()
new_sp = events.SessionProperties(instructions="sp instructions")
delta = OpenAIRealtimeLLMSettings(
system_instruction="top instructions",
session_properties=new_sp,
)
store.apply_update(delta)
assert store.system_instruction == "top instructions"
assert store.session_properties.instructions == "top instructions"
def test_non_synced_field_update_does_not_affect_sp(self):
"""Updating a non-synced field like temperature shouldn't touch session_properties."""
store = self._make_store(
session_properties=events.SessionProperties(instructions="Keep me."),
system_instruction="Keep me.",
)
original_sp = store.session_properties
delta = OpenAIRealtimeLLMSettings(temperature=0.5)
changed = store.apply_update(delta)
assert "temperature" in changed
assert store.temperature == 0.5
# SP should be untouched (same object)
assert store.session_properties is original_sp
assert store.session_properties.instructions == "Keep me."
# ---------------------------------------------------------------------------
# OpenAIRealtimeLLMSettings: from_mapping
# ---------------------------------------------------------------------------
class TestOpenAIRealtimeSettingsFromMapping:
def test_sp_keys_route_to_session_properties(self):
"""SessionProperties fields (instructions, audio, etc.) route into nested SP."""
delta = OpenAIRealtimeLLMSettings.from_mapping(
{"instructions": "Be concise.", "output_modalities": ["text"]}
)
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be concise."
assert delta.session_properties.output_modalities == ["text"]
def test_model_routes_to_top_level(self):
"""model should go to the top-level field, not session_properties."""
delta = OpenAIRealtimeLLMSettings.from_mapping({"model": "gpt-realtime-2.0"})
assert delta.model == "gpt-realtime-2.0"
# No session_properties should be created since no SP keys were present
assert not is_given(delta.session_properties)
def test_unknown_keys_go_to_extra(self):
"""Unrecognized keys should land in extra."""
delta = OpenAIRealtimeLLMSettings.from_mapping({"unknown_param": 42})
assert not is_given(delta.model)
assert not is_given(delta.session_properties)
assert delta.extra == {"unknown_param": 42}
def test_mixed_keys(self):
"""model + SP keys + unknown keys are routed correctly."""
delta = OpenAIRealtimeLLMSettings.from_mapping(
{
"model": "gpt-realtime-2.0",
"instructions": "Be helpful.",
"unknown": "val",
}
)
assert delta.model == "gpt-realtime-2.0"
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be helpful."
assert delta.extra == {"unknown": "val"}
def test_roundtrip_from_mapping_apply_update(self):
"""Simulate dict-style update: from_mapping -> apply_update."""
store = OpenAIRealtimeLLMSettings(
model="gpt-realtime-1.5",
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=events.SessionProperties(),
)
raw = {"instructions": "Be concise.", "output_modalities": ["text"]}
delta = OpenAIRealtimeLLMSettings.from_mapping(raw)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.instructions == "Be concise."
assert store.session_properties.output_modalities == ["text"]
assert store.system_instruction == "Be concise."