dv-pipecat-ai 0.0.85.dev10__py3-none-any.whl → 0.0.85.dev12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dv-pipecat-ai might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-ai
3
- Version: 0.0.85.dev10
3
+ Version: 0.0.85.dev12
4
4
  Summary: An open source framework for voice (and multimodal) assistants
5
5
  License-Expression: BSD-2-Clause
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat
@@ -1,4 +1,4 @@
1
- dv_pipecat_ai-0.0.85.dev10.dist-info/licenses/LICENSE,sha256=DWY2QGf2eMCFhuu2ChairtT6CB7BEFffNVhXWc4Od08,1301
1
+ dv_pipecat_ai-0.0.85.dev12.dist-info/licenses/LICENSE,sha256=DWY2QGf2eMCFhuu2ChairtT6CB7BEFffNVhXWc4Od08,1301
2
2
  pipecat/__init__.py,sha256=j0Xm6adxHhd7D06dIyyPV_GlBYLlBnTAERVvD_jAARQ,861
3
3
  pipecat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  pipecat/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -77,7 +77,7 @@ pipecat/extensions/voicemail/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NM
77
77
  pipecat/extensions/voicemail/voicemail_detector.py,sha256=g3L1m3cPJzsadeB5a8WRC9klH0D8m7xfPgB2YEaL6Do,29983
78
78
  pipecat/frames/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
79
79
  pipecat/frames/frames.proto,sha256=JXZm3VXLR8zMOUcOuhVoe2mhM3MQIQGMJXLopdJO_5Y,839
80
- pipecat/frames/frames.py,sha256=oqoo7p-uJOqak50mxhCGq7S0TusM0I4qp3QAftKHQnw,45428
80
+ pipecat/frames/frames.py,sha256=2aXsBpZB6dU7I1PZRFh8RqALDktxxoRgZPa1pf_cdCM,45804
81
81
  pipecat/frames/protobufs/frames_pb2.py,sha256=VHgGV_W7qQ4sfQK6RHb5_DggLm3PiSYMr6aBZ8_p1cQ,2590
82
82
  pipecat/metrics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
83
83
  pipecat/metrics/metrics.py,sha256=bdZNciEtLTtA-xgoKDz2RJAy6fKrXkTwz3pryVHzc2M,2713
@@ -111,7 +111,7 @@ pipecat/processors/idle_frame_processor.py,sha256=z8AuhGap61lA5K35P6XCaOpn4kkmK_
111
111
  pipecat/processors/logger.py,sha256=VGNwxQSc_F0rS3KBmfqas7f5aFyRQKfeljozOxfGXk4,2393
112
112
  pipecat/processors/producer_processor.py,sha256=iIIOHZd77APvUGP7JqFbznAHUnCULcq_qYiSEjwXHcc,3265
113
113
  pipecat/processors/text_transformer.py,sha256=LnfWJYzntJhZhrQ1lgSSY4D4VbHtrQJgrC227M69ZYU,1718
114
- pipecat/processors/transcript_processor.py,sha256=CG9yej6WOiy_HhagNXjxkISHkHii0JDfK_V6opseC2E,11740
114
+ pipecat/processors/transcript_processor.py,sha256=SDbqFLzasptZWqeiA6BESJ4hky-Uh-ZUNYBZR0q0Dnw,12508
115
115
  pipecat/processors/two_stage_user_idle_processor.py,sha256=uf2aZh_lfW-eMxmFogP3R4taAJ1yXOSqjKsR7oXtD0Y,2938
116
116
  pipecat/processors/user_idle_processor.py,sha256=PQItBx5bL1y_lFTtHf0zgLubjCXv60jU1jrVtm-K4bg,9268
117
117
  pipecat/processors/aggregators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -119,7 +119,7 @@ pipecat/processors/aggregators/dtmf_aggregator.py,sha256=nngjLiaOtcZtuCNpYPyfUVL
119
119
  pipecat/processors/aggregators/gated.py,sha256=tii0sRrBkRW6y9Xq5iTWPnqlOEejU4VqPIPtdOa61pc,3073
120
120
  pipecat/processors/aggregators/gated_openai_llm_context.py,sha256=cr6MT8J6SpPzZbppKPOKe3_pt_5qXC9g6a4wvZDyrec,3005
121
121
  pipecat/processors/aggregators/llm_context.py,sha256=eDf1cQElcISLx3onaA9LCWuepzb2G_JGszLzpNXggXo,9723
122
- pipecat/processors/aggregators/llm_response.py,sha256=0StzYtq7EzlAFSWp10I0yY0pV1jysw1ySEWv5R50h_s,47360
122
+ pipecat/processors/aggregators/llm_response.py,sha256=W0bqc5IZTE1cIB8egBefUOaPZiLklzge5npFWvAbCcw,48248
123
123
  pipecat/processors/aggregators/llm_response_universal.py,sha256=fBnB3rZVdxj4iEKIWcnR7yTpqyKupbcg7IUv6XVxrDQ,34287
124
124
  pipecat/processors/aggregators/openai_llm_context.py,sha256=cC8DXdVPERRN04i0i-1Ys6kusvnbMALeH-Z8Pu5K684,12999
125
125
  pipecat/processors/aggregators/sentence.py,sha256=E7e3knfQl6HEGpYMKPklF1aO_gOn-rr7SnynErwfkQk,2235
@@ -289,6 +289,8 @@ pipecat/services/rime/tts.py,sha256=XHMSnQUi7gMtWF42u4rBVv6oBDor4KkwkL7O-Sj9MPo,
289
289
  pipecat/services/riva/__init__.py,sha256=rObSsj504O_TMXhPBg_ymqKslZBhovlR-A0aaRZ0O6A,276
290
290
  pipecat/services/riva/stt.py,sha256=dtg8toijmexWB3uipw0EQ7ov3DFgHj40kFFv1Zadmmc,25116
291
291
  pipecat/services/riva/tts.py,sha256=idbqx3I2NlWCXtrIFsjEaYapxA3BLIA14ai3aMBh-2w,8158
292
+ pipecat/services/salesforce/__init__.py,sha256=OFvYbcvCadYhcKdBAVLj3ZUXVXQ1HyVyhgxIFf6_Thg,173
293
+ pipecat/services/salesforce/llm.py,sha256=mpozkzldgz3plbMOJcKddiyJxn7x4qqPuJVn22a41Ag,23009
292
294
  pipecat/services/sambanova/__init__.py,sha256=oTXExLic-qTcsfsiWmssf3Elclf3IIWoN41_2IpoF18,128
293
295
  pipecat/services/sambanova/llm.py,sha256=5XVfPLEk__W8ykFqLdV95ZUhlGGkAaJwmbciLdZYtTc,8976
294
296
  pipecat/services/sambanova/stt.py,sha256=ZZgEZ7WQjLFHbCko-3LNTtVajjtfUvbtVLtFcaNadVQ,2536
@@ -361,6 +363,7 @@ pipecat/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
361
363
  pipecat/utils/base_object.py,sha256=62e5_0R_rcQe-JdzUM0h1wtv1okw-0LPyG78ZKkDyzE,5963
362
364
  pipecat/utils/logger_config.py,sha256=5-RmvReZIINeqSXz3ALhEIiMZ_azmpOxnlIkdyCjWWk,5606
363
365
  pipecat/utils/network.py,sha256=RRQ7MmTcbeXBJ2aY5UbMCQ6elm5B8Rxkn8XqkJ9S0Nc,825
366
+ pipecat/utils/redis.py,sha256=JmBaC1yY6e8qygUQkAER3DNFCYSCH18hd7NN9qqjDMU,1677
364
367
  pipecat/utils/string.py,sha256=TskK9KxQSwbljct0J6y9ffkRcx4xYjTtPhFjEL4M1i8,6720
365
368
  pipecat/utils/time.py,sha256=lirjh24suz9EI1pf2kYwvAYb3I-13U_rJ_ZRg3nRiGs,1741
366
369
  pipecat/utils/utils.py,sha256=T2y1Mcd9yWiZiIToUiRkhW-n7EFf8juk3kWX3TF8XOQ,2451
@@ -381,7 +384,7 @@ pipecat/utils/tracing/service_decorators.py,sha256=HwDCqLGijhYD3F8nxDuQmEw-YkRw0
381
384
  pipecat/utils/tracing/setup.py,sha256=7TEgPNpq6M8lww8OQvf0P9FzYc5A30xICGklVA-fua0,2892
382
385
  pipecat/utils/tracing/turn_context_provider.py,sha256=ikon3plFOx0XbMrH6DdeHttNpb-U0gzMZIm3bWLc9eI,2485
383
386
  pipecat/utils/tracing/turn_trace_observer.py,sha256=dma16SBJpYSOE58YDWy89QzHyQFc_9gQZszKeWixuwc,9725
384
- dv_pipecat_ai-0.0.85.dev10.dist-info/METADATA,sha256=ezbvZ9D9Q9E1aVPhwoNcHu02GKAveWpHvFp0lgahMVc,32858
385
- dv_pipecat_ai-0.0.85.dev10.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
386
- dv_pipecat_ai-0.0.85.dev10.dist-info/top_level.txt,sha256=kQzG20CxGf-nSsHmtXHx3hY2-8zHA3jYg8jk0TajqXc,8
387
- dv_pipecat_ai-0.0.85.dev10.dist-info/RECORD,,
387
+ dv_pipecat_ai-0.0.85.dev12.dist-info/METADATA,sha256=pj9DcBZS3A1SFUXm5aZDJS5K9e4YldFoPBcrrBiFxPI,32858
388
+ dv_pipecat_ai-0.0.85.dev12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
389
+ dv_pipecat_ai-0.0.85.dev12.dist-info/top_level.txt,sha256=kQzG20CxGf-nSsHmtXHx3hY2-8zHA3jYg8jk0TajqXc,8
390
+ dv_pipecat_ai-0.0.85.dev12.dist-info/RECORD,,
pipecat/frames/frames.py CHANGED
@@ -451,12 +451,14 @@ class TranscriptionMessage:
451
451
  content: The message content/text.
452
452
  user_id: Optional identifier for the user.
453
453
  timestamp: Optional timestamp when the message was created.
454
+ message_id: Optional unique identifier for tracking and dropping messages.
454
455
  """
455
456
 
456
457
  role: Literal["user", "assistant"]
457
458
  content: str
458
459
  user_id: Optional[str] = None
459
460
  timestamp: Optional[str] = None
461
+ message_id: Optional[int] = None
460
462
 
461
463
 
462
464
  @dataclass
@@ -510,6 +512,17 @@ class TranscriptionUpdateFrame(DataFrame):
510
512
  return f"{self.name}(pts: {pts}, messages: {len(self.messages)})"
511
513
 
512
514
 
515
+ @dataclass
516
+ class TranscriptDropFrame(DataFrame):
517
+ """Frame indicating previously emitted transcript chunks should be discarded.
518
+
519
+ Parameters:
520
+ transcript_ids: List of frame/message identifiers to drop.
521
+ """
522
+
523
+ transcript_ids: List[int]
524
+
525
+
513
526
  @dataclass
514
527
  class LLMContextFrame(Frame):
515
528
  """Frame containing a universal LLM context.
@@ -51,6 +51,7 @@ from pipecat.frames.frames import (
51
51
  StartFrame,
52
52
  StartInterruptionFrame,
53
53
  TextFrame,
54
+ TranscriptDropFrame,
54
55
  TranscriptionFrame,
55
56
  UserImageRawFrame,
56
57
  UserStartedSpeakingFrame,
@@ -446,6 +447,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
446
447
  self._latest_final_transcript = ""
447
448
  self._last_user_speaking_time = 0
448
449
  self._last_aggregation_push_time = 0
450
+ self._pending_transcription_ids: List[int] = []
449
451
 
450
452
  async def reset(self):
451
453
  """Reset the aggregation state and interruption strategies."""
@@ -453,6 +455,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
453
455
  self._was_bot_speaking = False
454
456
  self._seen_interim_results = False
455
457
  self._waiting_for_aggregation = False
458
+ self._pending_transcription_ids.clear()
456
459
  [await s.reset() for s in self._interruption_strategies]
457
460
 
458
461
  async def handle_aggregation(self, aggregation: str):
@@ -548,7 +551,8 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
548
551
  await self._process_aggregation()
549
552
  else:
550
553
  self.logger.debug("Interruption conditions not met - not pushing aggregation")
551
- # Don't process aggregation, just reset it
554
+ # Don't process aggregation, discard pending transcriptions and reset
555
+ await self._discard_pending_transcriptions("interruption_conditions_not_met")
552
556
  await self.reset()
553
557
  else:
554
558
  if trigger_interruption:
@@ -614,10 +618,18 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
614
618
  for s in self.interruption_strategies:
615
619
  await s.append_audio(frame.audio, frame.sample_rate)
616
620
 
621
+ async def _discard_pending_transcriptions(self, reason: str):
622
+ """Notify upstream processors that pending transcripts should be dropped."""
623
+ if self._pending_transcription_ids:
624
+ drop_frame = TranscriptDropFrame(transcript_ids=list(self._pending_transcription_ids))
625
+ await self.push_frame(drop_frame, FrameDirection.UPSTREAM)
626
+ self._pending_transcription_ids.clear()
627
+
617
628
  async def _handle_user_started_speaking(self, frame: UserStartedSpeakingFrame):
618
629
  if len(self._aggregation) > 0:
619
630
  self.logger.debug(f"Dropping {self._aggregation}")
620
631
  self._aggregation = ""
632
+ await self._discard_pending_transcriptions("user_started_speaking")
621
633
  self._latest_final_transcript = ""
622
634
  self._last_user_speaking_time = time.time()
623
635
  self._user_speaking = True
@@ -662,6 +674,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
662
674
  return
663
675
 
664
676
  self._aggregation += f" {text}" if self._aggregation else text
677
+ self._pending_transcription_ids.append(frame.id)
665
678
  # We just got a final result, so let's reset interim results.
666
679
  self._seen_interim_results = False
667
680
 
@@ -791,6 +804,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
791
804
  if self._bot_speaking and not self._params.enable_emulated_vad_interruptions:
792
805
  # If emulated VAD interruptions are disabled and bot is speaking, ignore
793
806
  logger.debug("Ignoring user speaking emulation, bot is speaking.")
807
+ await self._discard_pending_transcriptions("emulated_vad_ignored")
794
808
  await self.reset()
795
809
  else:
796
810
  # Either bot is not speaking, or emulated VAD interruptions are enabled
@@ -20,6 +20,7 @@ from pipecat.frames.frames import (
20
20
  EndFrame,
21
21
  Frame,
22
22
  StartInterruptionFrame,
23
+ TranscriptDropFrame,
23
24
  TranscriptionFrame,
24
25
  TranscriptionMessage,
25
26
  TranscriptionUpdateFrame,
@@ -44,6 +45,7 @@ class BaseTranscriptProcessor(FrameProcessor):
44
45
  super().__init__(**kwargs)
45
46
  self._processed_messages: List[TranscriptionMessage] = []
46
47
  self._register_event_handler("on_transcript_update")
48
+ self._register_event_handler("on_transcript_drop")
47
49
 
48
50
  async def _emit_update(self, messages: List[TranscriptionMessage]):
49
51
  """Emit transcript updates for new messages.
@@ -57,6 +59,19 @@ class BaseTranscriptProcessor(FrameProcessor):
57
59
  await self._call_event_handler("on_transcript_update", update_frame)
58
60
  await self.push_frame(update_frame)
59
61
 
62
+ async def _handle_transcript_drop(self, frame: TranscriptDropFrame):
63
+ """Handle transcript drop notifications by removing stored messages."""
64
+ if not frame.transcript_ids:
65
+ return
66
+
67
+ await self._call_event_handler("on_transcript_drop", frame)
68
+
69
+ drop_ids = set(frame.transcript_ids)
70
+ if drop_ids:
71
+ self._processed_messages = [
72
+ msg for msg in self._processed_messages if msg.message_id not in drop_ids
73
+ ]
74
+
60
75
 
61
76
  class UserTranscriptProcessor(BaseTranscriptProcessor):
62
77
  """Processes user transcription frames into timestamped conversation messages."""
@@ -72,9 +87,15 @@ class UserTranscriptProcessor(BaseTranscriptProcessor):
72
87
 
73
88
  if isinstance(frame, TranscriptionFrame):
74
89
  message = TranscriptionMessage(
75
- role="user", user_id=frame.user_id, content=frame.text, timestamp=frame.timestamp
90
+ role="user",
91
+ user_id=frame.user_id,
92
+ content=frame.text,
93
+ timestamp=frame.timestamp,
94
+ message_id=frame.id,
76
95
  )
77
96
  await self._emit_update([message])
97
+ elif isinstance(frame, TranscriptDropFrame):
98
+ await self._handle_transcript_drop(frame)
78
99
 
79
100
  await self.push_frame(frame, direction)
80
101
 
@@ -0,0 +1,9 @@
1
+ #
2
+ # Copyright (c) 2024–2025, Daily
3
+ #
4
+ # SPDX-License-Identifier: BSD 2-Clause License
5
+ #
6
+
7
+ from .llm import SalesforceAgentLLMService
8
+
9
+ __all__ = ["SalesforceAgentLLMService"]
@@ -0,0 +1,587 @@
1
+ #
2
+ # Copyright (c) 2024–2025, Daily
3
+ #
4
+ # SPDX-License-Identifier: BSD 2-Clause License
5
+ #
6
+
7
+ """Salesforce Agent API LLM service implementation."""
8
+
9
+ import asyncio
10
+ import json
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import AsyncGenerator, Dict, Optional
14
+
15
+ import httpx
16
+ from loguru import logger
17
+
18
+ from pipecat.frames.frames import (
19
+ Frame,
20
+ LLMFullResponseEndFrame,
21
+ LLMFullResponseStartFrame,
22
+ LLMMessagesFrame,
23
+ LLMTextFrame,
24
+ LLMUpdateSettingsFrame,
25
+ )
26
+ from pipecat.processors.aggregators.openai_llm_context import (
27
+ OpenAILLMContext,
28
+ OpenAILLMContextFrame,
29
+ )
30
+ from pipecat.processors.frame_processor import FrameDirection
31
+ from pipecat.services.llm_service import LLMService
32
+ from pipecat.services.openai.llm import (
33
+ OpenAIAssistantContextAggregator,
34
+ OpenAIContextAggregatorPair,
35
+ OpenAIUserContextAggregator,
36
+ )
37
+ from pipecat.processors.aggregators.llm_response import (
38
+ LLMAssistantAggregatorParams,
39
+ LLMUserAggregatorParams,
40
+ )
41
+ from env_config import api_config
42
+ from pipecat.utils.redis import create_async_redis_client
43
+
44
+
45
+ @dataclass
46
+ class SalesforceSessionInfo:
47
+ """Information about an active Salesforce Agent session."""
48
+
49
+ session_id: str
50
+ agent_id: str
51
+ created_at: float
52
+ last_used: float
53
+
54
+
55
+ class SalesforceAgentLLMService(LLMService):
56
+ """Salesforce Agent API LLM service implementation.
57
+
58
+ This service integrates with Salesforce Agent API to provide conversational
59
+ AI capabilities using Salesforce's Agentforce platform.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ model: str = "salesforce-agent",
66
+ session_timeout_secs: float = 3600.0,
67
+ agent_id: str = api_config.SALESFORCE_AGENT_ID,
68
+ org_domain: str = api_config.SALESFORCE_ORG_DOMAIN,
69
+ client_id: str = api_config.SALESFORCE_CLIENT_ID,
70
+ client_secret: str = api_config.SALESFORCE_CLIENT_SECRET,
71
+ api_host: str = api_config.SALESFORCE_API_HOST,
72
+ redis_url: Optional[str] = None,
73
+ **kwargs,
74
+ ):
75
+ """Initialize Salesforce Agent LLM service.
76
+
77
+ Reads configuration from environment variables:
78
+ - SALESFORCE_AGENT_ID: The Salesforce agent ID to interact with
79
+ - SALESFORCE_ORG_DOMAIN: Salesforce org domain (e.g., https://myorg.my.salesforce.com)
80
+ - SALESFORCE_CLIENT_ID: Connected app client ID for OAuth
81
+ - SALESFORCE_CLIENT_SECRET: Connected app client secret for OAuth
82
+ - SALESFORCE_API_HOST: Salesforce API host base URL (e.g., https://api.salesforce.com)
83
+
84
+ Args:
85
+ model: The model name (defaults to "salesforce-agent").
86
+ session_timeout_secs: Session timeout in seconds (default: 1 hour).
87
+ agent_id: Salesforce agent ID. Defaults to SALESFORCE_AGENT_ID.
88
+ org_domain: Salesforce org domain. Defaults to SALESFORCE_ORG_DOMAIN.
89
+ client_id: Salesforce connected app client ID. Defaults to SALESFORCE_CLIENT_ID.
90
+ client_secret: Salesforce connected app client secret. Defaults to SALESFORCE_CLIENT_SECRET.
91
+ api_host: Salesforce API host base URL. Defaults to SALESFORCE_API_HOST.
92
+ redis_url: Optional Redis URL override for token caching.
93
+ **kwargs: Additional arguments passed to parent LLMService.
94
+ """
95
+ # Initialize parent LLM service
96
+ super().__init__(**kwargs)
97
+
98
+ self._agent_id = agent_id
99
+ self._org_domain = org_domain
100
+ self._client_id = client_id
101
+ self._client_secret = client_secret
102
+ self._api_host = api_host
103
+
104
+
105
+ # Validate required environment variables
106
+ required_vars = {
107
+ "SALESFORCE_AGENT_ID": self._agent_id,
108
+ "SALESFORCE_ORG_DOMAIN": self._org_domain,
109
+ "SALESFORCE_API_HOST": self._api_host,
110
+ "SALESFORCE_CLIENT_ID": self._client_id,
111
+ "SALESFORCE_CLIENT_SECRET": self._client_secret,
112
+ }
113
+
114
+ missing_vars = [var for var, value in required_vars.items() if not value]
115
+ if missing_vars:
116
+ raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
117
+
118
+ logger.info(f"Salesforce LLM initialized - Agent ID: {self._agent_id}")
119
+
120
+ self._session_timeout_secs = session_timeout_secs
121
+
122
+ if redis_url is not None:
123
+ self._redis_url = redis_url
124
+ else:
125
+ self._redis_url = getattr(api_config, "REDIS_URL", None)
126
+ self._redis_client = None
127
+ self._redis_client_init_attempted = False
128
+ self._token_cache_key = f"salesforce_agent_access_token:{self._agent_id}"
129
+ self._token_cache_leeway_secs = 300
130
+ self._sequence_counter = 0
131
+ self._warmup_task: Optional[asyncio.Task] = None
132
+
133
+ # Session management
134
+ self._sessions: Dict[str, SalesforceSessionInfo] = {}
135
+ self._current_session_id: Optional[str] = None
136
+
137
+ # HTTP client for API calls
138
+ self._http_client = httpx.AsyncClient(
139
+ timeout=30.0,
140
+ limits=httpx.Limits(
141
+ max_keepalive_connections=10,
142
+ max_connections=100,
143
+ keepalive_expiry=None,
144
+ ),
145
+ )
146
+
147
+ self._schedule_session_warmup()
148
+
149
+
150
+ async def __aenter__(self):
151
+ """Async context manager entry."""
152
+ await self.ensure_session_ready()
153
+ return self
154
+
155
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
156
+ """Async context manager exit."""
157
+ if self._warmup_task:
158
+ try:
159
+ await asyncio.shield(self._warmup_task)
160
+ except Exception as exc: # pragma: no cover - warmup best effort
161
+ logger.debug(f"Salesforce warmup task failed during exit: {exc}")
162
+ finally:
163
+ self._warmup_task = None
164
+
165
+ await self._cleanup_sessions()
166
+ await self._http_client.aclose()
167
+
168
+ if self._redis_client:
169
+ close_coro = getattr(self._redis_client, "close", None)
170
+ if callable(close_coro):
171
+ try:
172
+ await close_coro()
173
+ except Exception as exc: # pragma: no cover - best effort cleanup
174
+ logger.debug(f"Failed to close Redis client cleanly: {exc}")
175
+ self._redis_client = None
176
+ self._redis_client_init_attempted = False
177
+
178
+ def can_generate_metrics(self) -> bool:
179
+ """Check if this service can generate processing metrics."""
180
+ return True
181
+
182
+ def _schedule_session_warmup(self):
183
+ """Kick off background warm-up if an event loop is running."""
184
+ try:
185
+ loop = asyncio.get_running_loop()
186
+ except RuntimeError:
187
+ return
188
+
189
+ if loop.is_closed():
190
+ return
191
+
192
+ async def _warmup():
193
+ try:
194
+ await self.ensure_session_ready()
195
+ except Exception as exc: # pragma: no cover - warmup best effort
196
+ logger.warning(f"Salesforce warmup failed: {exc}")
197
+ raise
198
+
199
+ task = loop.create_task(_warmup())
200
+
201
+ def _on_done(warmup_task: asyncio.Task):
202
+ if warmup_task.cancelled():
203
+ logger.debug("Salesforce warmup task cancelled")
204
+ elif warmup_task.exception():
205
+ logger.warning(f"Salesforce warmup task error: {warmup_task.exception()}")
206
+ self._warmup_task = None
207
+
208
+ task.add_done_callback(_on_done)
209
+ self._warmup_task = task
210
+
211
+ def _get_redis_client(self):
212
+ """Return a Redis client for token caching if configured."""
213
+ if self._redis_client is None and not self._redis_client_init_attempted:
214
+ self._redis_client_init_attempted = True
215
+ self._redis_client = create_async_redis_client(
216
+ self._redis_url, decode_responses=True, encoding="utf-8", logger=logger
217
+ )
218
+
219
+ return self._redis_client
220
+
221
+ async def _get_cached_access_token(self) -> Optional[str]:
222
+ """Return cached access token from Redis."""
223
+ redis_client = self._get_redis_client()
224
+ if not redis_client:
225
+ return None
226
+
227
+ try:
228
+ return await redis_client.get(self._token_cache_key)
229
+ except Exception as exc: # pragma: no cover - cache failures shouldn't break flow
230
+ logger.warning(f"Failed to read Salesforce token from Redis: {exc}")
231
+ return None
232
+
233
+ async def _set_cached_access_token(self, token: str, expires_in: Optional[int]):
234
+ """Persist access token in Redis with TTL matching Salesforce expiry."""
235
+ redis_client = self._get_redis_client()
236
+ if not redis_client:
237
+ return
238
+
239
+ ttl_seconds = 3600
240
+ if expires_in is not None:
241
+ try:
242
+ ttl_seconds = max(int(expires_in) - self._token_cache_leeway_secs, 30)
243
+ except (TypeError, ValueError):
244
+ logger.debug("Unable to parse Salesforce token expiry; falling back to default TTL")
245
+
246
+ try:
247
+ await redis_client.set(self._token_cache_key, token, ex=ttl_seconds)
248
+ except Exception as exc: # pragma: no cover - cache failures shouldn't break flow
249
+ logger.warning(f"Failed to store Salesforce token in Redis: {exc}")
250
+
251
+ async def _get_access_token(self) -> str:
252
+ """Get OAuth access token using client credentials."""
253
+ cached_token = await self._get_cached_access_token()
254
+ if cached_token:
255
+ return cached_token
256
+
257
+ token_url = f"{self._org_domain}/services/oauth2/token"
258
+ data = {
259
+ "grant_type": "client_credentials",
260
+ "client_id": self._client_id,
261
+ "client_secret": self._client_secret,
262
+ }
263
+
264
+ try:
265
+ response = await self._http_client.post(token_url, data=data)
266
+ response.raise_for_status()
267
+ token_data = response.json()
268
+ access_token = token_data["access_token"]
269
+ await self._set_cached_access_token(access_token, token_data.get("expires_in"))
270
+ return access_token
271
+ except Exception as e:
272
+ logger.error(f"Failed to get access token: {e}")
273
+ raise
274
+
275
+ async def _create_session(self) -> str:
276
+ """Create a new Salesforce Agent session."""
277
+ access_token = await self._get_access_token()
278
+ session_url = f"{self._api_host}/einstein/ai-agent/v1/agents/{self._agent_id}/sessions"
279
+
280
+ headers = {
281
+ "Authorization": f"Bearer {access_token}",
282
+ "Content-Type": "application/json",
283
+ }
284
+
285
+ external_session_key = f"pipecat-{int(time.time())}-{id(self)}"
286
+
287
+ payload = {
288
+ "externalSessionKey": external_session_key,
289
+ "instanceConfig": {"endpoint": self._org_domain},
290
+ "tz": "America/Los_Angeles",
291
+ "variables": [{"name": "$Context.EndUserLanguage", "type": "Text", "value": "en_US"}],
292
+ "featureSupport": "Streaming",
293
+ "streamingCapabilities": {"chunkTypes": ["Text"]},
294
+ "bypassUser": True,
295
+ }
296
+
297
+ try:
298
+ response = await self._http_client.post(session_url, headers=headers, json=payload)
299
+ response.raise_for_status()
300
+ session_data = response.json()
301
+ session_id = session_data["sessionId"]
302
+
303
+ # Store session info
304
+ current_time = time.time()
305
+ self._sessions[session_id] = SalesforceSessionInfo(
306
+ session_id=session_id,
307
+ agent_id=self._agent_id,
308
+ created_at=current_time,
309
+ last_used=current_time,
310
+ )
311
+
312
+ logger.debug(f"Created Salesforce Agent session: {session_id}")
313
+ return session_id
314
+
315
+ except Exception as e:
316
+ logger.error(f"Failed to create Salesforce Agent session: {e}")
317
+ raise
318
+
319
+ async def _get_or_create_session(self) -> str:
320
+ """Get existing session or create a new one."""
321
+ current_time = time.time()
322
+
323
+ # Check if current session is still valid
324
+ if self._current_session_id and self._current_session_id in self._sessions:
325
+ session = self._sessions[self._current_session_id]
326
+ if current_time - session.last_used < self._session_timeout_secs:
327
+ session.last_used = current_time
328
+ return self._current_session_id
329
+ else:
330
+ # Session expired, remove it
331
+ self._sessions.pop(self._current_session_id, None)
332
+ self._current_session_id = None
333
+
334
+ # Create new session
335
+ self._current_session_id = await self._create_session()
336
+ return self._current_session_id
337
+
338
+ async def ensure_session_ready(self) -> str:
339
+ """Ensure a Salesforce session is ready for use."""
340
+ return await self._get_or_create_session()
341
+
342
+ async def _cleanup_sessions(self):
343
+ """Clean up expired sessions."""
344
+ current_time = time.time()
345
+ expired_sessions = []
346
+
347
+ for session_id, session in self._sessions.items():
348
+ if current_time - session.last_used > self._session_timeout_secs:
349
+ expired_sessions.append(session_id)
350
+
351
+ for session_id in expired_sessions:
352
+ try:
353
+ # End the session via API
354
+ access_token = await self._get_access_token()
355
+ url = f"{self._api_host}/einstein/ai-agent/v1/sessions/{session_id}"
356
+ headers = {
357
+ "Authorization": f"Bearer {access_token}",
358
+ "x-session-end-reason": "UserRequest",
359
+ }
360
+ await self._http_client.delete(url, headers=headers)
361
+ except Exception as e:
362
+ logger.warning(f"Failed to end session {session_id}: {e}")
363
+ finally:
364
+ self._sessions.pop(session_id, None)
365
+ if self._current_session_id == session_id:
366
+ self._current_session_id = None
367
+
368
+ def _extract_user_message(self, context: OpenAILLMContext) -> str:
369
+ """Extract the last user message from context.
370
+
371
+ Similar to Vistaar pattern - extract only the most recent user message.
372
+
373
+ Args:
374
+ context: The OpenAI LLM context containing messages.
375
+
376
+ Returns:
377
+ The last user message as a string.
378
+ """
379
+ messages = context.get_messages()
380
+
381
+ # Find the last user message (iterate in reverse for efficiency)
382
+ for message in reversed(messages):
383
+ if message.get("role") == "user":
384
+ content = message.get("content", "")
385
+
386
+ # Handle content that might be a list (for multimodal messages)
387
+ if isinstance(content, list):
388
+ text_parts = [
389
+ item.get("text", "") for item in content if item.get("type") == "text"
390
+ ]
391
+ content = " ".join(text_parts)
392
+
393
+ if isinstance(content, str):
394
+ return content.strip()
395
+
396
+ return ""
397
+
398
+ def _generate_sequence_id(self) -> int:
399
+ """Generate a sequence ID for the message."""
400
+ self._sequence_counter += 1
401
+ return self._sequence_counter
402
+
403
+ async def _stream_salesforce_response(self, session_id: str, user_message: str) -> AsyncGenerator[str, None]:
404
+ """Stream response from Salesforce Agent API."""
405
+ access_token = await self._get_access_token()
406
+ url = f"{self._api_host}/einstein/ai-agent/v1/sessions/{session_id}/messages/stream"
407
+
408
+ headers = {
409
+ "Authorization": f"Bearer {access_token}",
410
+ "Content-Type": "application/json",
411
+ "Accept": "text/event-stream",
412
+ }
413
+
414
+ message_data = {
415
+ "message": {
416
+ "sequenceId": self._generate_sequence_id(),
417
+ "type": "Text",
418
+ "text": user_message
419
+ },
420
+ "variables": [
421
+ {
422
+ "name": "$Context.EndUserLanguage",
423
+ "type": "Text",
424
+ "value": "en_US"
425
+ }
426
+ ]
427
+ }
428
+
429
+ try:
430
+ logger.info(f"🌐 Salesforce API request: {user_message[:50]}...")
431
+ async with self._http_client.stream("POST", url, headers=headers, json=message_data) as response:
432
+ response.raise_for_status()
433
+
434
+ async for line in response.aiter_lines():
435
+ if not line:
436
+ continue
437
+
438
+ # Parse SSE format
439
+ if line.startswith("data: "):
440
+ try:
441
+ data = json.loads(line[6:])
442
+ message = data.get("message", {})
443
+ message_type = message.get("type")
444
+
445
+ if message_type == "TextChunk":
446
+ content = message.get("text", "") or message.get("message", "")
447
+ if content:
448
+ yield content
449
+ elif message_type == "EndOfTurn":
450
+ logger.info("🏁 Salesforce response complete")
451
+ break
452
+ elif message_type == "Inform":
453
+ # Skip INFORM events to avoid duplication
454
+ continue
455
+
456
+ except json.JSONDecodeError as e:
457
+ logger.warning(f"JSON decode error: {e}, line: {line}")
458
+ continue
459
+
460
+ except Exception as e:
461
+ logger.error(f"Failed to stream from Salesforce Agent API: {e}")
462
+ raise
463
+
464
+ async def _process_context(self, context: OpenAILLMContext):
465
+ """Process the LLM context and generate streaming response.
466
+
467
+ Args:
468
+ context: The OpenAI LLM context containing messages to process.
469
+ """
470
+ logger.info(f"🔄 Salesforce processing context with {len(context.get_messages())} messages")
471
+
472
+ # Extract user message from context first
473
+ user_message = self._extract_user_message(context)
474
+
475
+ if not user_message:
476
+ logger.warning("Salesforce: No user message found in context")
477
+ return
478
+
479
+ try:
480
+ logger.info(f"🎯 Salesforce extracted query: {user_message}")
481
+
482
+ # Start response
483
+ await self.push_frame(LLMFullResponseStartFrame())
484
+ await self.push_frame(LLMFullResponseStartFrame(),FrameDirection.UPSTREAM)
485
+ await self.start_processing_metrics()
486
+ await self.start_ttfb_metrics()
487
+
488
+ # Get or create session
489
+ session_id = await self._get_or_create_session()
490
+
491
+ first_chunk = True
492
+
493
+ # Stream the response
494
+ async for text_chunk in self._stream_salesforce_response(session_id, user_message):
495
+ if first_chunk:
496
+ await self.stop_ttfb_metrics()
497
+ first_chunk = False
498
+
499
+ # Push each text chunk as it arrives
500
+ await self.push_frame(LLMTextFrame(text=text_chunk))
501
+
502
+ except Exception as e:
503
+ logger.error(f"Salesforce context processing error: {type(e).__name__}: {str(e)}")
504
+ import traceback
505
+ logger.error(f"Salesforce traceback: {traceback.format_exc()}")
506
+ raise
507
+ finally:
508
+ await self.stop_processing_metrics()
509
+ await self.push_frame(LLMFullResponseEndFrame())
510
+ await self.push_frame(LLMFullResponseEndFrame(), FrameDirection.UPSTREAM)
511
+
512
+ async def process_frame(self, frame: Frame, direction: FrameDirection):
513
+ """Process frames for LLM completion requests.
514
+
515
+ Args:
516
+ frame: The frame to process.
517
+ direction: The direction of frame processing.
518
+ """
519
+ context = None
520
+ if isinstance(frame, OpenAILLMContextFrame):
521
+ context = frame.context
522
+ logger.info(f"🔍 Received OpenAILLMContextFrame with {len(context.get_messages())} messages")
523
+ elif isinstance(frame, LLMMessagesFrame):
524
+ context = OpenAILLMContext.from_messages(frame.messages)
525
+ logger.info(f"🔍 Received LLMMessagesFrame with {len(frame.messages)} messages")
526
+ elif isinstance(frame, LLMUpdateSettingsFrame):
527
+ # Call super for settings frames and update settings
528
+ await super().process_frame(frame, direction)
529
+ settings = frame.settings
530
+ logger.debug(f"Updated Salesforce settings: {settings}")
531
+ else:
532
+ # For non-context frames, call super and push them downstream
533
+ await super().process_frame(frame, direction)
534
+ await self.push_frame(frame, direction)
535
+
536
+ if context:
537
+ try:
538
+ await self._process_context(context)
539
+ except httpx.TimeoutException:
540
+ logger.error("Timeout while processing Salesforce request")
541
+ await self._call_event_handler("on_completion_timeout")
542
+ except Exception as e:
543
+ logger.error(f"Error processing Salesforce request: {e}")
544
+ raise
545
+
546
+ def create_context_aggregator(
547
+ self,
548
+ context: OpenAILLMContext,
549
+ *,
550
+ user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(),
551
+ assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(),
552
+ ) -> OpenAIContextAggregatorPair:
553
+ """Create context aggregators for Salesforce LLM.
554
+
555
+ Since Salesforce uses OpenAI-compatible message format, we reuse OpenAI's
556
+ context aggregators directly
557
+
558
+ Args:
559
+ context: The LLM context to create aggregators for.
560
+ user_params: Parameters for user message aggregation.
561
+ assistant_params: Parameters for assistant message aggregation.
562
+
563
+ Returns:
564
+ OpenAIContextAggregatorPair: A pair of OpenAI context aggregators,
565
+ compatible with Salesforce's OpenAI-like message format.
566
+ """
567
+ context.set_llm_adapter(self.get_llm_adapter())
568
+ user = OpenAIUserContextAggregator(context, params=user_params)
569
+ assistant = OpenAIAssistantContextAggregator(context, params=assistant_params)
570
+ return OpenAIContextAggregatorPair(_user=user, _assistant=assistant)
571
+
572
+ def get_llm_adapter(self):
573
+ """Get the LLM adapter for this service."""
574
+ from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
575
+ return OpenAILLMAdapter()
576
+
577
+ async def close(self):
578
+ """Close the HTTP client when the service is destroyed."""
579
+ await self._cleanup_sessions()
580
+ await self._http_client.aclose()
581
+
582
+ def __del__(self):
583
+ """Ensure the client is closed on deletion."""
584
+ try:
585
+ asyncio.create_task(self._http_client.aclose())
586
+ except:
587
+ pass
pipecat/utils/redis.py ADDED
@@ -0,0 +1,58 @@
1
+ """Async Redis helper utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional, TYPE_CHECKING
6
+
7
+ from urllib.parse import urlparse
8
+
9
+ try:
10
+ import redis.asyncio as redis
11
+ except ImportError: # pragma: no cover - Redis is optional
12
+ redis = None
13
+
14
+
15
+ if TYPE_CHECKING: # pragma: no cover - typing aid
16
+ from redis.asyncio import Redis
17
+
18
+
19
+ def create_async_redis_client(
20
+ url: Optional[str],
21
+ *,
22
+ decode_responses: bool = True,
23
+ encoding: str = "utf-8",
24
+ logger: Optional[Any] = None,
25
+ **kwargs,
26
+ ) -> Optional["Redis"]:
27
+ """Return a configured async Redis client or None if unavailable.
28
+
29
+ Args:
30
+ url: Redis connection URL.
31
+ decode_responses: Whether to decode responses to str.
32
+ encoding: Character encoding to use with decoded responses.
33
+ logger: Optional logger supporting .warning() for diagnostics.
34
+ **kwargs: Additional keyword arguments forwarded to Redis.from_url.
35
+ """
36
+ if redis is None:
37
+ return None
38
+
39
+ if not url or url in {"redis_url", "REDIS_URL"}:
40
+ return None
41
+
42
+ parsed = urlparse(url)
43
+ connection_kwargs = {
44
+ "decode_responses": decode_responses,
45
+ "encoding": encoding,
46
+ }
47
+ connection_kwargs.update(kwargs)
48
+
49
+ if parsed.scheme == "rediss":
50
+ connection_kwargs.setdefault("ssl_cert_reqs", "none")
51
+ connection_kwargs.setdefault("ssl_check_hostname", False)
52
+
53
+ try:
54
+ return redis.Redis.from_url(url, **connection_kwargs)
55
+ except Exception as exc: # pragma: no cover - best effort logging
56
+ if logger is not None:
57
+ logger.warning(f"Failed to create Redis client: {exc}")
58
+ return None