diff --git a/CHANGELOG.md b/CHANGELOG.md index 37189eb47..0f489556c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,9 @@ async def on_connected(processor): ### Changed +- Updated individual update settings frame classes into a single UpdateSettingsFrame + class for STT, LLM, and TTS. + - We now distinguish between input and output audio and image frames. We introduce `InputAudioRawFrame`, `OutputAudioRawFrame`, `InputImageRawFrame` and `OutputImageRawFrame` (and other subclasses of those). The input frames diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 273aad214..1b31b9c88 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -4,9 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # -from typing import Any, List, Optional, Tuple - from dataclasses import dataclass, field +from typing import Any, List, Optional, Tuple from pipecat.clocks.base_clock import BaseClock from pipecat.metrics.metrics import MetricsData @@ -528,113 +527,35 @@ class UserImageRequestFrame(ControlFrame): @dataclass -class LLMModelUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM model.""" +class LLMUpdateSettingsFrame(ControlFrame): + """A control frame containing a request to update LLM settings.""" - model: str + model: Optional[str] = None + temperature: Optional[float] = None + top_k: Optional[int] = None + top_p: Optional[float] = None + frequency_penalty: Optional[float] = None + presence_penalty: Optional[float] = None + max_tokens: Optional[int] = None + seed: Optional[int] = None + extra: dict = field(default_factory=dict) @dataclass -class LLMTemperatureUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM temperature.""" +class TTSUpdateSettingsFrame(ControlFrame): + """A control frame containing a request to update TTS settings.""" - temperature: float + model: Optional[str] = None + voice: Optional[str] = None + language: Optional[Language] = None @dataclass -class LLMTopKUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM top_k.""" +class STTUpdateSettingsFrame(ControlFrame): + """A control frame containing a request to update STT settings.""" - top_k: int - - -@dataclass -class LLMTopPUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM top_p.""" - - top_p: float - - -@dataclass -class LLMFrequencyPenaltyUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM frequency - penalty. - - """ - - frequency_penalty: float - - -@dataclass -class LLMPresencePenaltyUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM presence - penalty. - - """ - - presence_penalty: float - - -@dataclass -class LLMMaxTokensUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM max tokens.""" - - max_tokens: int - - -@dataclass -class LLMSeedUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM seed.""" - - seed: int - - -@dataclass -class LLMExtraUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new LLM extra params.""" - - extra: dict - - -@dataclass -class TTSModelUpdateFrame(ControlFrame): - """A control frame containing a request to update the TTS model.""" - - model: str - - -@dataclass -class TTSVoiceUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new TTS voice.""" - - voice: str - - -@dataclass -class TTSLanguageUpdateFrame(ControlFrame): - """A control frame containing a request to update to a new TTS language and - optional voice. - - """ - - language: Language - - -@dataclass -class STTModelUpdateFrame(ControlFrame): - """A control frame containing a request to update the STT model and optional - language. - - """ - - model: str - - -@dataclass -class STTLanguageUpdateFrame(ControlFrame): - """A control frame containing a request to update to STT language.""" - - language: Language + model: Optional[str] = None + language: Optional[Language] = None @dataclass diff --git a/src/pipecat/services/ai_services.py b/src/pipecat/services/ai_services.py index 16280b024..79e52531d 100644 --- a/src/pipecat/services/ai_services.py +++ b/src/pipecat/services/ai_services.py @@ -7,10 +7,11 @@ import asyncio import io import wave - from abc import abstractmethod from typing import AsyncGenerator, List, Optional, Tuple +from loguru import logger + from pipecat.frames.frames import ( AudioRawFrame, CancelFrame, @@ -18,31 +19,26 @@ from pipecat.frames.frames import ( ErrorFrame, Frame, LLMFullResponseEndFrame, - STTLanguageUpdateFrame, - STTModelUpdateFrame, StartFrame, StartInterruptionFrame, + STTUpdateSettingsFrame, + TextFrame, TTSAudioRawFrame, - TTSLanguageUpdateFrame, - TTSModelUpdateFrame, TTSSpeakFrame, TTSStartedFrame, TTSStoppedFrame, - TTSVoiceUpdateFrame, - TextFrame, + TTSUpdateSettingsFrame, UserImageRequestFrame, VisionImageRawFrame, ) from pipecat.metrics.metrics import MetricsData +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.transcriptions.language import Language from pipecat.utils.audio import calculate_audio_volume from pipecat.utils.string import match_endofsentence from pipecat.utils.time import seconds_to_nanoseconds from pipecat.utils.utils import exp_smoothing -from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext - -from loguru import logger class AIService(FrameProcessor): @@ -230,12 +226,13 @@ class TTSService(AIService): await self.push_frame(frame, direction) elif isinstance(frame, TTSSpeakFrame): await self._push_tts_frames(frame.text) - elif isinstance(frame, TTSModelUpdateFrame): - await self.set_model(frame.model) - elif isinstance(frame, TTSVoiceUpdateFrame): - await self.set_voice(frame.voice) - elif isinstance(frame, TTSLanguageUpdateFrame): - await self.set_language(frame.language) + elif isinstance(frame, TTSUpdateSettingsFrame): + if frame.model is not None: + await self.set_model(frame.model) + if frame.voice is not None: + await self.set_voice(frame.voice) + if frame.language is not None: + await self.set_language(frame.language) else: await self.push_frame(frame, direction) @@ -408,10 +405,11 @@ class STTService(AIService): # In this service we accumulate audio internally and at the end we # push a TextFrame. We don't really want to push audio frames down. await self.process_audio_frame(frame) - elif isinstance(frame, STTModelUpdateFrame): - await self.set_model(frame.model) - elif isinstance(frame, STTLanguageUpdateFrame): - await self.set_language(frame.language) + elif isinstance(frame, STTUpdateSettingsFrame): + if frame.model is not None: + await self.set_model(frame.model) + if frame.language is not None: + await self.set_language(frame.language) else: await self.push_frame(frame, direction) diff --git a/src/pipecat/services/anthropic.py b/src/pipecat/services/anthropic.py index 8b8e187ea..1c4cd284e 100644 --- a/src/pipecat/services/anthropic.py +++ b/src/pipecat/services/anthropic.py @@ -5,47 +5,47 @@ # import base64 -import json -import io import copy -from typing import Any, Dict, List, Optional -from dataclasses import dataclass -from PIL import Image -from asyncio import CancelledError +import io +import json import re +from asyncio import CancelledError +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from loguru import logger +from PIL import Image from pydantic import BaseModel, Field from pipecat.frames.frames import ( Frame, - LLMEnablePromptCachingFrame, - LLMModelUpdateFrame, - TextFrame, - VisionImageRawFrame, - UserImageRequestFrame, - UserImageRawFrame, - LLMMessagesFrame, - LLMFullResponseStartFrame, - LLMFullResponseEndFrame, - FunctionCallResultFrame, FunctionCallInProgressFrame, + FunctionCallResultFrame, + LLMEnablePromptCachingFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, + LLMMessagesFrame, + LLMUpdateSettingsFrame, StartInterruptionFrame, + TextFrame, + UserImageRawFrame, + UserImageRequestFrame, + VisionImageRawFrame, ) from pipecat.metrics.metrics import LLMTokenUsage -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.ai_services import LLMService +from pipecat.processors.aggregators.llm_response import ( + LLMAssistantContextAggregator, + LLMUserContextAggregator, +) from pipecat.processors.aggregators.openai_llm_context import ( OpenAILLMContext, OpenAILLMContextFrame, ) -from pipecat.processors.aggregators.llm_response import ( - LLMUserContextAggregator, - LLMAssistantContextAggregator, -) - -from loguru import logger +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.ai_services import LLMService try: - from anthropic import AsyncAnthropic, NOT_GIVEN, NotGiven + from anthropic import NOT_GIVEN, AsyncAnthropic, NotGiven except ModuleNotFoundError as e: logger.error(f"Exception: {e}") logger.error( @@ -293,9 +293,20 @@ class AnthropicLLMService(LLMService): # UserImageRawFrames coming through the pipeline and add them # to the context. context = AnthropicLLMContext.from_image_frame(frame) - elif isinstance(frame, LLMModelUpdateFrame): - logger.debug(f"Switching LLM model to: [{frame.model}]") - self.set_model_name(frame.model) + elif isinstance(frame, LLMUpdateSettingsFrame): + if frame.model is not None: + logger.debug(f"Switching LLM model to: [{frame.model}]") + self.set_model_name(frame.model) + if frame.max_tokens is not None: + await self.set_max_tokens(frame.max_tokens) + if frame.temperature is not None: + await self.set_temperature(frame.temperature) + if frame.top_k is not None: + await self.set_top_k(frame.top_k) + if frame.top_p is not None: + await self.set_top_p(frame.top_p) + if frame.extra: + await self.set_extra(frame.extra) elif isinstance(frame, LLMEnablePromptCachingFrame): logger.debug(f"Setting enable prompt caching to: [{frame.enable}]") self._enable_prompt_caching_beta = frame.enable diff --git a/src/pipecat/services/google.py b/src/pipecat/services/google.py index 38af3e41f..53efd8c17 100644 --- a/src/pipecat/services/google.py +++ b/src/pipecat/services/google.py @@ -17,7 +17,7 @@ from pipecat.frames.frames import ( LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMMessagesFrame, - LLMModelUpdateFrame, + LLMUpdateSettingsFrame, TextFrame, TTSAudioRawFrame, TTSStartedFrame, @@ -136,9 +136,10 @@ class GoogleLLMService(LLMService): context = OpenAILLMContext.from_messages(frame.messages) elif isinstance(frame, VisionImageRawFrame): context = OpenAILLMContext.from_image_frame(frame) - elif isinstance(frame, LLMModelUpdateFrame): - logger.debug(f"Switching LLM model to: [{frame.model}]") - self._create_client(frame.model) + elif isinstance(frame, LLMUpdateSettingsFrame): + if frame.model is not None: + logger.debug(f"Switching LLM model to: [{frame.model}]") + self.set_model_name(frame.model) else: await self.push_frame(frame, direction) diff --git a/src/pipecat/services/openai.py b/src/pipecat/services/openai.py index 47bee5ec1..a830b65a8 100644 --- a/src/pipecat/services/openai.py +++ b/src/pipecat/services/openai.py @@ -4,38 +4,39 @@ # SPDX-License-Identifier: BSD 2-Clause License # -import aiohttp import base64 import io import json -import httpx - from dataclasses import dataclass - from typing import Any, AsyncGenerator, Dict, List, Literal, Optional + +import aiohttp +import httpx +from loguru import logger +from PIL import Image from pydantic import BaseModel, Field from pipecat.frames.frames import ( ErrorFrame, Frame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMMessagesFrame, - LLMModelUpdateFrame, + LLMUpdateSettingsFrame, + StartInterruptionFrame, + TextFrame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame, - TextFrame, URLImageRawFrame, VisionImageRawFrame, - FunctionCallResultFrame, - FunctionCallInProgressFrame, - StartInterruptionFrame, ) from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.llm_response import ( - LLMUserContextAggregator, LLMAssistantContextAggregator, + LLMUserContextAggregator, ) from pipecat.processors.aggregators.openai_llm_context import ( OpenAILLMContext, @@ -44,12 +45,14 @@ from pipecat.processors.aggregators.openai_llm_context import ( from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_services import ImageGenService, LLMService, TTSService -from PIL import Image - -from loguru import logger - try: - from openai import AsyncOpenAI, AsyncStream, DefaultAsyncHttpxClient, BadRequestError, NOT_GIVEN + from openai import ( + NOT_GIVEN, + AsyncOpenAI, + AsyncStream, + BadRequestError, + DefaultAsyncHttpxClient, + ) from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam except ModuleNotFoundError as e: logger.error(f"Exception: {e}") @@ -280,9 +283,22 @@ class BaseOpenAILLMService(LLMService): context = OpenAILLMContext.from_messages(frame.messages) elif isinstance(frame, VisionImageRawFrame): context = OpenAILLMContext.from_image_frame(frame) - elif isinstance(frame, LLMModelUpdateFrame): - logger.debug(f"Switching LLM model to: [{frame.model}]") - self.set_model_name(frame.model) + elif isinstance(frame, LLMUpdateSettingsFrame): + if frame.model is not None: + logger.debug(f"Switching LLM model to: [{frame.model}]") + self.set_model_name(frame.model) + if frame.frequency_penalty is not None: + await self.set_frequency_penalty(frame.frequency_penalty) + if frame.presence_penalty is not None: + await self.set_presence_penalty(frame.presence_penalty) + if frame.seed is not None: + await self.set_seed(frame.seed) + if frame.temperature is not None: + await self.set_temperature(frame.temperature) + if frame.top_p is not None: + await self.set_top_p(frame.top_p) + if frame.extra: + await self.set_extra(frame.extra) else: await self.push_frame(frame, direction) @@ -464,7 +480,7 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator): await self._push_aggregation() else: logger.warning( - f"FunctionCallResultFrame tool_call_id does not match FunctionCallInProgressFrame tool_call_id" + "FunctionCallResultFrame tool_call_id does not match FunctionCallInProgressFrame tool_call_id" ) self._function_call_in_progress = None self._function_call_result = None diff --git a/src/pipecat/services/together.py b/src/pipecat/services/together.py index b1365bc69..981aa6de2 100644 --- a/src/pipecat/services/together.py +++ b/src/pipecat/services/together.py @@ -7,37 +7,36 @@ import json import re import uuid -from pydantic import BaseModel, Field - -from typing import Any, Dict, List, Optional -from dataclasses import dataclass from asyncio import CancelledError +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from loguru import logger +from pydantic import BaseModel, Field from pipecat.frames.frames import ( Frame, - LLMModelUpdateFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, + LLMMessagesFrame, + LLMUpdateSettingsFrame, + StartInterruptionFrame, TextFrame, UserImageRequestFrame, - LLMMessagesFrame, - LLMFullResponseStartFrame, - LLMFullResponseEndFrame, - FunctionCallResultFrame, - FunctionCallInProgressFrame, - StartInterruptionFrame, ) from pipecat.metrics.metrics import LLMTokenUsage -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.ai_services import LLMService +from pipecat.processors.aggregators.llm_response import ( + LLMAssistantContextAggregator, + LLMUserContextAggregator, +) from pipecat.processors.aggregators.openai_llm_context import ( OpenAILLMContext, OpenAILLMContextFrame, ) -from pipecat.processors.aggregators.llm_response import ( - LLMUserContextAggregator, - LLMAssistantContextAggregator, -) - -from loguru import logger +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.ai_services import LLMService try: from together import AsyncTogether @@ -188,7 +187,7 @@ class TogetherLLMService(LLMService): if chunk.choices[0].finish_reason == "eos" and accumulating_function_call: await self._extract_function_call(context, function_call_accumulator) - except CancelledError as e: + except CancelledError: # todo: implement token counting estimates for use when the user interrupts a long generation # we do this in the anthropic.py service raise @@ -206,9 +205,24 @@ class TogetherLLMService(LLMService): context = frame.context elif isinstance(frame, LLMMessagesFrame): context = TogetherLLMContext.from_messages(frame.messages) - elif isinstance(frame, LLMModelUpdateFrame): - logger.debug(f"Switching LLM model to: [{frame.model}]") - self.set_model_name(frame.model) + elif isinstance(frame, LLMUpdateSettingsFrame): + if frame.model is not None: + logger.debug(f"Switching LLM model to: [{frame.model}]") + self.set_model_name(frame.model) + if frame.frequency_penalty is not None: + await self.set_frequency_penalty(frame.frequency_penalty) + if frame.max_tokens is not None: + await self.set_max_tokens(frame.max_tokens) + if frame.presence_penalty is not None: + await self.set_presence_penalty(frame.presence_penalty) + if frame.temperature is not None: + await self.set_temperature(frame.temperature) + if frame.top_k is not None: + await self.set_top_k(frame.top_k) + if frame.top_p is not None: + await self.set_top_p(frame.top_p) + if frame.extra: + await self.set_extra(frame.extra) else: await self.push_frame(frame, direction) @@ -338,7 +352,7 @@ class TogetherAssistantContextAggregator(LLMAssistantContextAggregator): await self._push_aggregation() else: logger.warning( - f"FunctionCallResultFrame tool_call_id does not match FunctionCallInProgressFrame tool_call_id" + "FunctionCallResultFrame tool_call_id does not match FunctionCallInProgressFrame tool_call_id" ) self._function_call_in_progress = None self._function_call_result = None