dv-pipecat-ai 0.0.82.dev857__py3-none-any.whl → 0.0.85.dev837__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.

Files changed (195) hide show
  1. {dv_pipecat_ai-0.0.82.dev857.dist-info → dv_pipecat_ai-0.0.85.dev837.dist-info}/METADATA +98 -130
  2. {dv_pipecat_ai-0.0.82.dev857.dist-info → dv_pipecat_ai-0.0.85.dev837.dist-info}/RECORD +192 -140
  3. pipecat/adapters/base_llm_adapter.py +38 -1
  4. pipecat/adapters/services/anthropic_adapter.py +9 -14
  5. pipecat/adapters/services/aws_nova_sonic_adapter.py +120 -5
  6. pipecat/adapters/services/bedrock_adapter.py +236 -13
  7. pipecat/adapters/services/gemini_adapter.py +12 -8
  8. pipecat/adapters/services/open_ai_adapter.py +19 -7
  9. pipecat/adapters/services/open_ai_realtime_adapter.py +5 -0
  10. pipecat/audio/dtmf/dtmf-0.wav +0 -0
  11. pipecat/audio/dtmf/dtmf-1.wav +0 -0
  12. pipecat/audio/dtmf/dtmf-2.wav +0 -0
  13. pipecat/audio/dtmf/dtmf-3.wav +0 -0
  14. pipecat/audio/dtmf/dtmf-4.wav +0 -0
  15. pipecat/audio/dtmf/dtmf-5.wav +0 -0
  16. pipecat/audio/dtmf/dtmf-6.wav +0 -0
  17. pipecat/audio/dtmf/dtmf-7.wav +0 -0
  18. pipecat/audio/dtmf/dtmf-8.wav +0 -0
  19. pipecat/audio/dtmf/dtmf-9.wav +0 -0
  20. pipecat/audio/dtmf/dtmf-pound.wav +0 -0
  21. pipecat/audio/dtmf/dtmf-star.wav +0 -0
  22. pipecat/audio/filters/krisp_viva_filter.py +193 -0
  23. pipecat/audio/filters/noisereduce_filter.py +15 -0
  24. pipecat/audio/turn/base_turn_analyzer.py +9 -1
  25. pipecat/audio/turn/smart_turn/base_smart_turn.py +14 -8
  26. pipecat/audio/turn/smart_turn/data/__init__.py +0 -0
  27. pipecat/audio/turn/smart_turn/data/smart-turn-v3.0.onnx +0 -0
  28. pipecat/audio/turn/smart_turn/http_smart_turn.py +6 -2
  29. pipecat/audio/turn/smart_turn/local_smart_turn.py +1 -1
  30. pipecat/audio/turn/smart_turn/local_smart_turn_v2.py +1 -1
  31. pipecat/audio/turn/smart_turn/local_smart_turn_v3.py +124 -0
  32. pipecat/audio/vad/data/README.md +10 -0
  33. pipecat/audio/vad/data/silero_vad_v2.onnx +0 -0
  34. pipecat/audio/vad/silero.py +9 -3
  35. pipecat/audio/vad/vad_analyzer.py +13 -1
  36. pipecat/extensions/voicemail/voicemail_detector.py +5 -5
  37. pipecat/frames/frames.py +277 -86
  38. pipecat/observers/loggers/debug_log_observer.py +3 -3
  39. pipecat/observers/loggers/llm_log_observer.py +7 -3
  40. pipecat/observers/loggers/user_bot_latency_log_observer.py +22 -10
  41. pipecat/pipeline/runner.py +18 -6
  42. pipecat/pipeline/service_switcher.py +64 -36
  43. pipecat/pipeline/task.py +125 -79
  44. pipecat/pipeline/tts_switcher.py +30 -0
  45. pipecat/processors/aggregators/dtmf_aggregator.py +2 -3
  46. pipecat/processors/aggregators/{gated_openai_llm_context.py → gated_llm_context.py} +9 -9
  47. pipecat/processors/aggregators/gated_open_ai_llm_context.py +12 -0
  48. pipecat/processors/aggregators/llm_context.py +40 -2
  49. pipecat/processors/aggregators/llm_response.py +32 -15
  50. pipecat/processors/aggregators/llm_response_universal.py +19 -15
  51. pipecat/processors/aggregators/user_response.py +6 -6
  52. pipecat/processors/aggregators/vision_image_frame.py +24 -2
  53. pipecat/processors/audio/audio_buffer_processor.py +43 -8
  54. pipecat/processors/dtmf_aggregator.py +174 -77
  55. pipecat/processors/filters/stt_mute_filter.py +17 -0
  56. pipecat/processors/frame_processor.py +110 -24
  57. pipecat/processors/frameworks/langchain.py +8 -2
  58. pipecat/processors/frameworks/rtvi.py +210 -68
  59. pipecat/processors/frameworks/strands_agents.py +170 -0
  60. pipecat/processors/logger.py +2 -2
  61. pipecat/processors/transcript_processor.py +26 -5
  62. pipecat/processors/user_idle_processor.py +35 -11
  63. pipecat/runner/daily.py +59 -20
  64. pipecat/runner/run.py +395 -93
  65. pipecat/runner/types.py +6 -4
  66. pipecat/runner/utils.py +51 -10
  67. pipecat/serializers/__init__.py +5 -1
  68. pipecat/serializers/asterisk.py +16 -2
  69. pipecat/serializers/convox.py +41 -4
  70. pipecat/serializers/custom.py +257 -0
  71. pipecat/serializers/exotel.py +5 -5
  72. pipecat/serializers/livekit.py +20 -0
  73. pipecat/serializers/plivo.py +5 -5
  74. pipecat/serializers/protobuf.py +6 -5
  75. pipecat/serializers/telnyx.py +2 -2
  76. pipecat/serializers/twilio.py +43 -23
  77. pipecat/serializers/vi.py +324 -0
  78. pipecat/services/ai_service.py +2 -6
  79. pipecat/services/anthropic/llm.py +2 -25
  80. pipecat/services/assemblyai/models.py +6 -0
  81. pipecat/services/assemblyai/stt.py +13 -5
  82. pipecat/services/asyncai/tts.py +5 -3
  83. pipecat/services/aws/__init__.py +1 -0
  84. pipecat/services/aws/llm.py +147 -105
  85. pipecat/services/aws/nova_sonic/__init__.py +0 -0
  86. pipecat/services/aws/nova_sonic/context.py +436 -0
  87. pipecat/services/aws/nova_sonic/frames.py +25 -0
  88. pipecat/services/aws/nova_sonic/llm.py +1265 -0
  89. pipecat/services/aws/stt.py +3 -3
  90. pipecat/services/aws_nova_sonic/__init__.py +19 -1
  91. pipecat/services/aws_nova_sonic/aws.py +11 -1151
  92. pipecat/services/aws_nova_sonic/context.py +8 -354
  93. pipecat/services/aws_nova_sonic/frames.py +13 -17
  94. pipecat/services/azure/llm.py +51 -1
  95. pipecat/services/azure/realtime/__init__.py +0 -0
  96. pipecat/services/azure/realtime/llm.py +65 -0
  97. pipecat/services/azure/stt.py +15 -0
  98. pipecat/services/cartesia/stt.py +77 -70
  99. pipecat/services/cartesia/tts.py +80 -13
  100. pipecat/services/deepgram/__init__.py +1 -0
  101. pipecat/services/deepgram/flux/__init__.py +0 -0
  102. pipecat/services/deepgram/flux/stt.py +640 -0
  103. pipecat/services/elevenlabs/__init__.py +4 -1
  104. pipecat/services/elevenlabs/stt.py +339 -0
  105. pipecat/services/elevenlabs/tts.py +87 -46
  106. pipecat/services/fish/tts.py +5 -2
  107. pipecat/services/gemini_multimodal_live/events.py +38 -524
  108. pipecat/services/gemini_multimodal_live/file_api.py +23 -173
  109. pipecat/services/gemini_multimodal_live/gemini.py +41 -1403
  110. pipecat/services/gladia/stt.py +56 -72
  111. pipecat/services/google/__init__.py +1 -0
  112. pipecat/services/google/gemini_live/__init__.py +3 -0
  113. pipecat/services/google/gemini_live/file_api.py +189 -0
  114. pipecat/services/google/gemini_live/llm.py +1582 -0
  115. pipecat/services/google/gemini_live/llm_vertex.py +184 -0
  116. pipecat/services/google/llm.py +15 -11
  117. pipecat/services/google/llm_openai.py +3 -3
  118. pipecat/services/google/llm_vertex.py +86 -16
  119. pipecat/services/google/stt.py +4 -0
  120. pipecat/services/google/tts.py +7 -3
  121. pipecat/services/heygen/api.py +2 -0
  122. pipecat/services/heygen/client.py +8 -4
  123. pipecat/services/heygen/video.py +2 -0
  124. pipecat/services/hume/__init__.py +5 -0
  125. pipecat/services/hume/tts.py +220 -0
  126. pipecat/services/inworld/tts.py +6 -6
  127. pipecat/services/llm_service.py +15 -5
  128. pipecat/services/lmnt/tts.py +4 -2
  129. pipecat/services/mcp_service.py +4 -2
  130. pipecat/services/mem0/memory.py +6 -5
  131. pipecat/services/mistral/llm.py +29 -8
  132. pipecat/services/moondream/vision.py +42 -16
  133. pipecat/services/neuphonic/tts.py +5 -2
  134. pipecat/services/openai/__init__.py +1 -0
  135. pipecat/services/openai/base_llm.py +27 -20
  136. pipecat/services/openai/realtime/__init__.py +0 -0
  137. pipecat/services/openai/realtime/context.py +272 -0
  138. pipecat/services/openai/realtime/events.py +1106 -0
  139. pipecat/services/openai/realtime/frames.py +37 -0
  140. pipecat/services/openai/realtime/llm.py +829 -0
  141. pipecat/services/openai/tts.py +49 -10
  142. pipecat/services/openai_realtime/__init__.py +27 -0
  143. pipecat/services/openai_realtime/azure.py +21 -0
  144. pipecat/services/openai_realtime/context.py +21 -0
  145. pipecat/services/openai_realtime/events.py +21 -0
  146. pipecat/services/openai_realtime/frames.py +21 -0
  147. pipecat/services/openai_realtime_beta/azure.py +16 -0
  148. pipecat/services/openai_realtime_beta/openai.py +17 -5
  149. pipecat/services/piper/tts.py +7 -9
  150. pipecat/services/playht/tts.py +34 -4
  151. pipecat/services/rime/tts.py +12 -12
  152. pipecat/services/riva/stt.py +3 -1
  153. pipecat/services/salesforce/__init__.py +9 -0
  154. pipecat/services/salesforce/llm.py +700 -0
  155. pipecat/services/sarvam/__init__.py +7 -0
  156. pipecat/services/sarvam/stt.py +540 -0
  157. pipecat/services/sarvam/tts.py +97 -13
  158. pipecat/services/simli/video.py +2 -2
  159. pipecat/services/speechmatics/stt.py +22 -10
  160. pipecat/services/stt_service.py +47 -0
  161. pipecat/services/tavus/video.py +2 -2
  162. pipecat/services/tts_service.py +75 -22
  163. pipecat/services/vision_service.py +7 -6
  164. pipecat/services/vistaar/llm.py +51 -9
  165. pipecat/tests/utils.py +4 -4
  166. pipecat/transcriptions/language.py +41 -1
  167. pipecat/transports/base_input.py +13 -34
  168. pipecat/transports/base_output.py +140 -104
  169. pipecat/transports/daily/transport.py +199 -26
  170. pipecat/transports/heygen/__init__.py +0 -0
  171. pipecat/transports/heygen/transport.py +381 -0
  172. pipecat/transports/livekit/transport.py +228 -63
  173. pipecat/transports/local/audio.py +6 -1
  174. pipecat/transports/local/tk.py +11 -2
  175. pipecat/transports/network/fastapi_websocket.py +1 -1
  176. pipecat/transports/smallwebrtc/connection.py +103 -19
  177. pipecat/transports/smallwebrtc/request_handler.py +246 -0
  178. pipecat/transports/smallwebrtc/transport.py +65 -23
  179. pipecat/transports/tavus/transport.py +23 -12
  180. pipecat/transports/websocket/client.py +41 -5
  181. pipecat/transports/websocket/fastapi.py +21 -11
  182. pipecat/transports/websocket/server.py +14 -7
  183. pipecat/transports/whatsapp/api.py +8 -0
  184. pipecat/transports/whatsapp/client.py +47 -0
  185. pipecat/utils/base_object.py +54 -22
  186. pipecat/utils/redis.py +58 -0
  187. pipecat/utils/string.py +13 -1
  188. pipecat/utils/tracing/service_decorators.py +21 -21
  189. pipecat/serializers/genesys.py +0 -95
  190. pipecat/services/google/test-google-chirp.py +0 -45
  191. pipecat/services/openai.py +0 -698
  192. {dv_pipecat_ai-0.0.82.dev857.dist-info → dv_pipecat_ai-0.0.85.dev837.dist-info}/WHEEL +0 -0
  193. {dv_pipecat_ai-0.0.82.dev857.dist-info → dv_pipecat_ai-0.0.85.dev837.dist-info}/licenses/LICENSE +0 -0
  194. {dv_pipecat_ai-0.0.82.dev857.dist-info → dv_pipecat_ai-0.0.85.dev837.dist-info}/top_level.txt +0 -0
  195. /pipecat/services/{aws_nova_sonic → aws/nova_sonic}/ready.wav +0 -0
@@ -0,0 +1,700 @@
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 env_config import api_config
17
+ from loguru import logger
18
+
19
+ from pipecat.frames.frames import (
20
+ Frame,
21
+ LLMFullResponseEndFrame,
22
+ LLMFullResponseStartFrame,
23
+ LLMMessagesFrame,
24
+ LLMTextFrame,
25
+ LLMUpdateSettingsFrame,
26
+ )
27
+ from pipecat.processors.aggregators.llm_response import (
28
+ LLMAssistantAggregatorParams,
29
+ LLMUserAggregatorParams,
30
+ )
31
+ from pipecat.processors.aggregators.openai_llm_context import (
32
+ OpenAILLMContext,
33
+ OpenAILLMContextFrame,
34
+ )
35
+ from pipecat.processors.frame_processor import FrameDirection
36
+ from pipecat.services.llm_service import LLMService
37
+ from pipecat.services.openai.llm import (
38
+ OpenAIAssistantContextAggregator,
39
+ OpenAIContextAggregatorPair,
40
+ OpenAIUserContextAggregator,
41
+ )
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
+
60
+ AI capabilities using Salesforce's Agentforce platform.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ model: str = "salesforce-agent",
67
+ session_timeout_secs: float = 3600.0,
68
+ agent_id: str = api_config.SALESFORCE_AGENT_ID,
69
+ org_domain: str = api_config.SALESFORCE_ORG_DOMAIN,
70
+ client_id: str = api_config.SALESFORCE_CLIENT_ID,
71
+ client_secret: str = api_config.SALESFORCE_CLIENT_SECRET,
72
+ api_host: str = api_config.SALESFORCE_API_HOST,
73
+ redis_url: Optional[str] = None,
74
+ **kwargs,
75
+ ):
76
+ """Initialize Salesforce Agent LLM service.
77
+
78
+ Reads configuration from environment variables:
79
+ - SALESFORCE_AGENT_ID: The Salesforce agent ID to interact with
80
+ - SALESFORCE_ORG_DOMAIN: Salesforce org domain (e.g., https://myorg.my.salesforce.com)
81
+ - SALESFORCE_CLIENT_ID: Connected app client ID for OAuth
82
+ - SALESFORCE_CLIENT_SECRET: Connected app client secret for OAuth
83
+ - SALESFORCE_API_HOST: Salesforce API host base URL (e.g., https://api.salesforce.com)
84
+
85
+ Args:
86
+ model: The model name (defaults to "salesforce-agent").
87
+ session_timeout_secs: Session timeout in seconds (default: 1 hour).
88
+ agent_id: Salesforce agent ID. Defaults to SALESFORCE_AGENT_ID.
89
+ org_domain: Salesforce org domain. Defaults to SALESFORCE_ORG_DOMAIN.
90
+ client_id: Salesforce connected app client ID. Defaults to SALESFORCE_CLIENT_ID.
91
+ client_secret: Salesforce connected app client secret. Defaults to SALESFORCE_CLIENT_SECRET.
92
+ api_host: Salesforce API host base URL. Defaults to SALESFORCE_API_HOST.
93
+ redis_url: Optional Redis URL override for token caching.
94
+ **kwargs: Additional arguments passed to parent LLMService.
95
+ """
96
+ # Initialize parent LLM service
97
+ super().__init__(**kwargs)
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
+ # Validate required environment variables
105
+ required_vars = {
106
+ "SALESFORCE_AGENT_ID": self._agent_id,
107
+ "SALESFORCE_ORG_DOMAIN": self._org_domain,
108
+ "SALESFORCE_API_HOST": self._api_host,
109
+ "SALESFORCE_CLIENT_ID": self._client_id,
110
+ "SALESFORCE_CLIENT_SECRET": self._client_secret,
111
+ }
112
+
113
+ missing_vars = [var for var, value in required_vars.items() if not value]
114
+ if missing_vars:
115
+ raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
116
+
117
+ logger.info(f"Salesforce LLM initialized - Agent ID: {self._agent_id}")
118
+
119
+ self._session_timeout_secs = session_timeout_secs
120
+
121
+ if redis_url is not None:
122
+ self._redis_url = redis_url
123
+ else:
124
+ self._redis_url = getattr(api_config, "REDIS_URL", None)
125
+ self._redis_client = None
126
+ self._redis_client_init_attempted = False
127
+ self._token_cache_key = f"salesforce_agent_access_token:{self._agent_id}"
128
+ self._token_cache_leeway_secs = 300
129
+ self._sequence_counter = 0
130
+ self._warmup_task: Optional[asyncio.Task] = None
131
+
132
+ # Session management
133
+ self._sessions: Dict[str, SalesforceSessionInfo] = {}
134
+ self._current_session_id: Optional[str] = None
135
+
136
+ # HTTP client for API calls
137
+ self._http_client = httpx.AsyncClient(
138
+ timeout=30.0,
139
+ limits=httpx.Limits(
140
+ max_keepalive_connections=10,
141
+ max_connections=100,
142
+ keepalive_expiry=None,
143
+ ),
144
+ )
145
+
146
+ self._schedule_session_warmup()
147
+
148
+ async def __aenter__(self):
149
+ """Async context manager entry."""
150
+ await self.ensure_session_ready()
151
+ return self
152
+
153
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
154
+ """Async context manager exit."""
155
+ if self._warmup_task:
156
+ try:
157
+ await asyncio.shield(self._warmup_task)
158
+ except Exception as exc: # pragma: no cover - warmup best effort
159
+ logger.debug(f"Salesforce warmup task failed during exit: {exc}")
160
+ finally:
161
+ self._warmup_task = None
162
+
163
+ await self._cleanup_sessions()
164
+ await self._http_client.aclose()
165
+
166
+ if self._redis_client:
167
+ close_coro = getattr(self._redis_client, "close", None)
168
+ if callable(close_coro):
169
+ try:
170
+ await close_coro()
171
+ except Exception as exc: # pragma: no cover - best effort cleanup
172
+ logger.debug(f"Failed to close Redis client cleanly: {exc}")
173
+ self._redis_client = None
174
+ self._redis_client_init_attempted = False
175
+
176
+ def can_generate_metrics(self) -> bool:
177
+ """Check if this service can generate processing metrics."""
178
+ return True
179
+
180
+ def _schedule_session_warmup(self):
181
+ """Kick off background warm-up if an event loop is running."""
182
+ try:
183
+ loop = asyncio.get_running_loop()
184
+ except RuntimeError:
185
+ return
186
+
187
+ if loop.is_closed():
188
+ return
189
+
190
+ async def _warmup():
191
+ try:
192
+ await self.ensure_session_ready()
193
+ except Exception as exc: # pragma: no cover - warmup best effort
194
+ logger.warning(f"Salesforce warmup failed: {exc}")
195
+ raise
196
+
197
+ task = loop.create_task(_warmup())
198
+
199
+ def _on_done(warmup_task: asyncio.Task):
200
+ if warmup_task.cancelled():
201
+ logger.debug("Salesforce warmup task cancelled")
202
+ elif warmup_task.exception():
203
+ logger.warning(f"Salesforce warmup task error: {warmup_task.exception()}")
204
+ self._warmup_task = None
205
+
206
+ task.add_done_callback(_on_done)
207
+ self._warmup_task = task
208
+
209
+ def _get_redis_client(self):
210
+ """Return a Redis client for token caching if configured."""
211
+ if self._redis_client is None and not self._redis_client_init_attempted:
212
+ self._redis_client_init_attempted = True
213
+ self._redis_client = create_async_redis_client(
214
+ self._redis_url, decode_responses=True, encoding="utf-8", logger=logger
215
+ )
216
+
217
+ return self._redis_client
218
+
219
+ async def _get_cached_access_token(self) -> Optional[str]:
220
+ """Return cached access token from Redis."""
221
+ redis_client = self._get_redis_client()
222
+ if not redis_client:
223
+ return None
224
+
225
+ try:
226
+ return await redis_client.get(self._token_cache_key)
227
+ except Exception as exc: # pragma: no cover - cache failures shouldn't break flow
228
+ logger.warning(f"Failed to read Salesforce token from Redis: {exc}")
229
+ return None
230
+
231
+ async def _set_cached_access_token(self, token: str, expires_in: Optional[int]):
232
+ """Persist access token in Redis with TTL matching Salesforce expiry."""
233
+ redis_client = self._get_redis_client()
234
+ if not redis_client:
235
+ return
236
+
237
+ ttl_seconds = 3600 # Default fallback
238
+
239
+ # Try to get expiration from expires_in parameter first
240
+ if expires_in is not None:
241
+ try:
242
+ ttl_seconds = max(int(expires_in) - self._token_cache_leeway_secs, 30)
243
+ logger.debug(f"Using expires_in parameter: {expires_in}s, TTL: {ttl_seconds}s")
244
+ except (TypeError, ValueError):
245
+ logger.debug("Unable to parse expires_in parameter")
246
+ expires_in = None
247
+
248
+ # If no expires_in available, use default TTL
249
+ if expires_in is None:
250
+ logger.debug("No expiration info found, using default TTL")
251
+
252
+ try:
253
+ await redis_client.set(self._token_cache_key, token, ex=ttl_seconds)
254
+ logger.debug(f"Cached Salesforce token with TTL: {ttl_seconds}s")
255
+ except Exception as exc: # pragma: no cover - cache failures shouldn't break flow
256
+ logger.warning(f"Failed to store Salesforce token in Redis: {exc}")
257
+
258
+ async def _clear_cached_access_token(self):
259
+ """Clear cached access token from Redis."""
260
+ redis_client = self._get_redis_client()
261
+ if not redis_client:
262
+ return
263
+
264
+ try:
265
+ await redis_client.delete(self._token_cache_key)
266
+ logger.debug("Cleared cached Salesforce access token")
267
+ except Exception as exc: # pragma: no cover - cache failures shouldn't break flow
268
+ logger.warning(f"Failed to clear Salesforce token from Redis: {exc}")
269
+
270
+ async def _get_access_token(self, *, force_refresh: bool = False) -> str:
271
+ """Get OAuth access token using client credentials.
272
+
273
+ Args:
274
+ force_refresh: If True, skip cache and fetch fresh token from Salesforce.
275
+ """
276
+ if not force_refresh:
277
+ cached_token = await self._get_cached_access_token()
278
+ if cached_token:
279
+ return cached_token
280
+
281
+ token_url = f"{self._org_domain}/services/oauth2/token"
282
+ data = {
283
+ "grant_type": "client_credentials",
284
+ "client_id": self._client_id,
285
+ "client_secret": self._client_secret,
286
+ }
287
+
288
+ try:
289
+ response = await self._http_client.post(token_url, data=data)
290
+ response.raise_for_status()
291
+ token_data = response.json()
292
+ access_token = token_data["access_token"]
293
+ await self._set_cached_access_token(access_token, token_data.get("expires_in"))
294
+ logger.debug("Retrieved fresh Salesforce access token")
295
+ return access_token
296
+ except Exception as e:
297
+ logger.error(f"Failed to get access token: {e}")
298
+ raise
299
+
300
+ async def _make_authenticated_request(self, method: str, url: str, **kwargs):
301
+ """Make an authenticated HTTP request with automatic token refresh on auth errors.
302
+
303
+ Args:
304
+ method: HTTP method (GET, POST, DELETE, etc.)
305
+ url: Request URL
306
+ **kwargs: Additional arguments passed to httpx request
307
+
308
+ Returns:
309
+ httpx.Response: The HTTP response
310
+
311
+ Raises:
312
+ Exception: If request fails after token refresh attempt
313
+ """
314
+ # First attempt with current token
315
+ access_token = await self._get_access_token()
316
+ headers = kwargs.get("headers", {})
317
+ headers["Authorization"] = f"Bearer {access_token}"
318
+ kwargs["headers"] = headers
319
+
320
+ try:
321
+ response = await self._http_client.request(method, url, **kwargs)
322
+ response.raise_for_status()
323
+ return response
324
+ except httpx.HTTPStatusError as e:
325
+ # If authentication error, clear cache and retry with fresh token
326
+ if e.response.status_code in (401, 403):
327
+ logger.warning(
328
+ f"Salesforce authentication error ({e.response.status_code}), refreshing token"
329
+ )
330
+ await self._clear_cached_access_token()
331
+
332
+ # Retry with fresh token
333
+ fresh_token = await self._get_access_token(force_refresh=True)
334
+ headers["Authorization"] = f"Bearer {fresh_token}"
335
+ kwargs["headers"] = headers
336
+
337
+ response = await self._http_client.request(method, url, **kwargs)
338
+ response.raise_for_status()
339
+ return response
340
+ else:
341
+ # Re-raise non-auth errors
342
+ raise
343
+
344
+ async def _create_session(self) -> str:
345
+ """Create a new Salesforce Agent session."""
346
+ session_url = f"{self._api_host}/einstein/ai-agent/v1/agents/{self._agent_id}/sessions"
347
+
348
+ external_session_key = f"pipecat-{int(time.time())}-{id(self)}"
349
+
350
+ payload = {
351
+ "externalSessionKey": external_session_key,
352
+ "instanceConfig": {"endpoint": self._org_domain},
353
+ "tz": "America/Los_Angeles",
354
+ "variables": [{"name": "$Context.EndUserLanguage", "type": "Text", "value": "en_US"}],
355
+ "featureSupport": "Streaming",
356
+ "streamingCapabilities": {"chunkTypes": ["Text"]},
357
+ "bypassUser": True,
358
+ }
359
+
360
+ try:
361
+ response = await self._make_authenticated_request(
362
+ "POST", session_url, headers={"Content-Type": "application/json"}, json=payload
363
+ )
364
+ session_data = response.json()
365
+ session_id = session_data["sessionId"]
366
+
367
+ # Store session info
368
+ current_time = time.time()
369
+ self._sessions[session_id] = SalesforceSessionInfo(
370
+ session_id=session_id,
371
+ agent_id=self._agent_id,
372
+ created_at=current_time,
373
+ last_used=current_time,
374
+ )
375
+
376
+ logger.debug(f"Created Salesforce Agent session: {session_id}")
377
+ return session_id
378
+
379
+ except Exception as e:
380
+ logger.error(f"Failed to create Salesforce Agent session: {e}")
381
+ raise
382
+
383
+ async def _get_or_create_session(self) -> str:
384
+ """Get existing session or create a new one."""
385
+ current_time = time.time()
386
+
387
+ # Check if current session is still valid
388
+ if self._current_session_id and self._current_session_id in self._sessions:
389
+ session = self._sessions[self._current_session_id]
390
+ if current_time - session.last_used < self._session_timeout_secs:
391
+ session.last_used = current_time
392
+ return self._current_session_id
393
+ else:
394
+ # Session expired, remove it
395
+ self._sessions.pop(self._current_session_id, None)
396
+ self._current_session_id = None
397
+
398
+ # Create new session
399
+ self._current_session_id = await self._create_session()
400
+ return self._current_session_id
401
+
402
+ async def ensure_session_ready(self) -> str:
403
+ """Ensure a Salesforce session is ready for use."""
404
+ return await self._get_or_create_session()
405
+
406
+ async def _cleanup_sessions(self):
407
+ """Clean up expired sessions."""
408
+ current_time = time.time()
409
+ expired_sessions = []
410
+
411
+ for session_id, session in self._sessions.items():
412
+ if current_time - session.last_used > self._session_timeout_secs:
413
+ expired_sessions.append(session_id)
414
+
415
+ for session_id in expired_sessions:
416
+ try:
417
+ # End the session via API
418
+ url = f"{self._api_host}/einstein/ai-agent/v1/sessions/{session_id}"
419
+ await self._make_authenticated_request(
420
+ "DELETE", url, headers={"x-session-end-reason": "UserRequest"}
421
+ )
422
+ except Exception as e:
423
+ logger.warning(f"Failed to end session {session_id}: {e}")
424
+ finally:
425
+ self._sessions.pop(session_id, None)
426
+ if self._current_session_id == session_id:
427
+ self._current_session_id = None
428
+
429
+ def _extract_user_message(self, context: OpenAILLMContext) -> str:
430
+ """Extract the last user message from context.
431
+
432
+ Similar to Vistaar pattern - extract only the most recent user message.
433
+
434
+ Args:
435
+ context: The OpenAI LLM context containing messages.
436
+
437
+ Returns:
438
+ The last user message as a string.
439
+ """
440
+ messages = context.get_messages()
441
+
442
+ # Find the last user message (iterate in reverse for efficiency)
443
+ for message in reversed(messages):
444
+ if message.get("role") == "user":
445
+ content = message.get("content", "")
446
+
447
+ # Handle content that might be a list (for multimodal messages)
448
+ if isinstance(content, list):
449
+ text_parts = [
450
+ item.get("text", "") for item in content if item.get("type") == "text"
451
+ ]
452
+ content = " ".join(text_parts)
453
+
454
+ if isinstance(content, str):
455
+ return content.strip()
456
+
457
+ return ""
458
+
459
+ def _generate_sequence_id(self) -> int:
460
+ """Generate a sequence ID for the message."""
461
+ self._sequence_counter += 1
462
+ return self._sequence_counter
463
+
464
+ async def _stream_salesforce_response(
465
+ self, session_id: str, user_message: str
466
+ ) -> AsyncGenerator[str, None]:
467
+ """Stream response from Salesforce Agent API."""
468
+ url = f"{self._api_host}/einstein/ai-agent/v1/sessions/{session_id}/messages/stream"
469
+
470
+ message_data = {
471
+ "message": {
472
+ "sequenceId": self._generate_sequence_id(),
473
+ "type": "Text",
474
+ "text": user_message,
475
+ },
476
+ "variables": [{"name": "$Context.EndUserLanguage", "type": "Text", "value": "en_US"}],
477
+ }
478
+
479
+ # First attempt with current token
480
+ access_token = await self._get_access_token()
481
+ headers = {
482
+ "Authorization": f"Bearer {access_token}",
483
+ "Content-Type": "application/json",
484
+ "Accept": "text/event-stream",
485
+ }
486
+
487
+ try:
488
+ logger.info(f"🌐 Salesforce API request: {user_message[:50]}...")
489
+ async with self._http_client.stream(
490
+ "POST", url, headers=headers, json=message_data
491
+ ) as response:
492
+ response.raise_for_status()
493
+
494
+ async for line in response.aiter_lines():
495
+ if not line:
496
+ continue
497
+
498
+ # Parse SSE format
499
+ if line.startswith("data: "):
500
+ try:
501
+ data = json.loads(line[6:])
502
+ message = data.get("message", {})
503
+ message_type = message.get("type")
504
+
505
+ if message_type == "TextChunk":
506
+ content = message.get("text", "") or message.get("message", "")
507
+ if content:
508
+ yield content
509
+ elif message_type == "EndOfTurn":
510
+ logger.info("🏁 Salesforce response complete")
511
+ break
512
+ elif message_type == "Inform":
513
+ # Skip INFORM events to avoid duplication
514
+ continue
515
+
516
+ except json.JSONDecodeError as e:
517
+ logger.warning(f"JSON decode error: {e}, line: {line}")
518
+ continue
519
+
520
+ except httpx.HTTPStatusError as e:
521
+ # If authentication error, retry with fresh token
522
+ if e.response.status_code in (401, 403):
523
+ logger.warning(
524
+ f"Salesforce streaming authentication error ({e.response.status_code}), refreshing token"
525
+ )
526
+ await self._clear_cached_access_token()
527
+
528
+ # Retry with fresh token
529
+ fresh_token = await self._get_access_token(force_refresh=True)
530
+ headers["Authorization"] = f"Bearer {fresh_token}"
531
+
532
+ logger.info(
533
+ f"🔄 Retrying Salesforce stream with fresh token: {user_message[:50]}..."
534
+ )
535
+ async with self._http_client.stream(
536
+ "POST", url, headers=headers, json=message_data
537
+ ) as response:
538
+ response.raise_for_status()
539
+
540
+ async for line in response.aiter_lines():
541
+ if not line:
542
+ continue
543
+
544
+ # Parse SSE format
545
+ if line.startswith("data: "):
546
+ try:
547
+ data = json.loads(line[6:])
548
+ message = data.get("message", {})
549
+ message_type = message.get("type")
550
+
551
+ if message_type == "TextChunk":
552
+ content = message.get("text", "") or message.get("message", "")
553
+ if content:
554
+ yield content
555
+ elif message_type == "EndOfTurn":
556
+ logger.info("🏁 Salesforce response complete")
557
+ break
558
+ elif message_type == "Inform":
559
+ # Skip INFORM events to avoid duplication
560
+ continue
561
+
562
+ except json.JSONDecodeError as e:
563
+ logger.warning(f"JSON decode error: {e}, line: {line}")
564
+ continue
565
+ else:
566
+ # Re-raise non-auth errors
567
+ logger.error(f"Failed to stream from Salesforce Agent API: {e}")
568
+ raise
569
+ except Exception as e:
570
+ logger.error(f"Failed to stream from Salesforce Agent API: {e}")
571
+ raise
572
+
573
+ async def _process_context(self, context: OpenAILLMContext):
574
+ """Process the LLM context and generate streaming response.
575
+
576
+ Args:
577
+ context: The OpenAI LLM context containing messages to process.
578
+ """
579
+ logger.info(f"🔄 Salesforce processing context with {len(context.get_messages())} messages")
580
+
581
+ # Extract user message from context first
582
+ user_message = self._extract_user_message(context)
583
+
584
+ if not user_message:
585
+ logger.warning("Salesforce: No user message found in context")
586
+ return
587
+
588
+ try:
589
+ logger.info(f"🎯 Salesforce extracted query: {user_message}")
590
+
591
+ # Start response
592
+ await self.push_frame(LLMFullResponseStartFrame())
593
+ await self.push_frame(LLMFullResponseStartFrame(), FrameDirection.UPSTREAM)
594
+ await self.start_processing_metrics()
595
+ await self.start_ttfb_metrics()
596
+
597
+ # Get or create session
598
+ session_id = await self._get_or_create_session()
599
+
600
+ first_chunk = True
601
+
602
+ # Stream the response
603
+ async for text_chunk in self._stream_salesforce_response(session_id, user_message):
604
+ if first_chunk:
605
+ await self.stop_ttfb_metrics()
606
+ first_chunk = False
607
+
608
+ # Push each text chunk as it arrives
609
+ await self.push_frame(LLMTextFrame(text=text_chunk))
610
+
611
+ except Exception as e:
612
+ logger.error(f"Salesforce context processing error: {type(e).__name__}: {str(e)}")
613
+ import traceback
614
+
615
+ logger.error(f"Salesforce traceback: {traceback.format_exc()}")
616
+ raise
617
+ finally:
618
+ await self.stop_processing_metrics()
619
+ await self.push_frame(LLMFullResponseEndFrame())
620
+ await self.push_frame(LLMFullResponseEndFrame(), FrameDirection.UPSTREAM)
621
+
622
+ async def process_frame(self, frame: Frame, direction: FrameDirection):
623
+ """Process frames for LLM completion requests.
624
+
625
+ Args:
626
+ frame: The frame to process.
627
+ direction: The direction of frame processing.
628
+ """
629
+ context = None
630
+ if isinstance(frame, OpenAILLMContextFrame):
631
+ context = frame.context
632
+ logger.info(
633
+ f"🔍 Received OpenAILLMContextFrame with {len(context.get_messages())} messages"
634
+ )
635
+ elif isinstance(frame, LLMMessagesFrame):
636
+ context = OpenAILLMContext.from_messages(frame.messages)
637
+ logger.info(f"🔍 Received LLMMessagesFrame with {len(frame.messages)} messages")
638
+ elif isinstance(frame, LLMUpdateSettingsFrame):
639
+ # Call super for settings frames and update settings
640
+ await super().process_frame(frame, direction)
641
+ settings = frame.settings
642
+ logger.debug(f"Updated Salesforce settings: {settings}")
643
+ else:
644
+ # For non-context frames, call super and push them downstream
645
+ await super().process_frame(frame, direction)
646
+ await self.push_frame(frame, direction)
647
+
648
+ if context:
649
+ try:
650
+ await self._process_context(context)
651
+ except httpx.TimeoutException:
652
+ logger.error("Timeout while processing Salesforce request")
653
+ await self._call_event_handler("on_completion_timeout")
654
+ except Exception as e:
655
+ logger.error(f"Error processing Salesforce request: {e}")
656
+ raise
657
+
658
+ def create_context_aggregator(
659
+ self,
660
+ context: OpenAILLMContext,
661
+ *,
662
+ user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(),
663
+ assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(),
664
+ ) -> OpenAIContextAggregatorPair:
665
+ """Create context aggregators for Salesforce LLM.
666
+
667
+ Since Salesforce uses OpenAI-compatible message format, we reuse OpenAI's
668
+ context aggregators directly
669
+
670
+ Args:
671
+ context: The LLM context to create aggregators for.
672
+ user_params: Parameters for user message aggregation.
673
+ assistant_params: Parameters for assistant message aggregation.
674
+
675
+ Returns:
676
+ OpenAIContextAggregatorPair: A pair of OpenAI context aggregators,
677
+ compatible with Salesforce's OpenAI-like message format.
678
+ """
679
+ context.set_llm_adapter(self.get_llm_adapter())
680
+ user = OpenAIUserContextAggregator(context, params=user_params)
681
+ assistant = OpenAIAssistantContextAggregator(context, params=assistant_params)
682
+ return OpenAIContextAggregatorPair(_user=user, _assistant=assistant)
683
+
684
+ def get_llm_adapter(self):
685
+ """Get the LLM adapter for this service."""
686
+ from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
687
+
688
+ return OpenAILLMAdapter()
689
+
690
+ async def close(self):
691
+ """Close the HTTP client when the service is destroyed."""
692
+ await self._cleanup_sessions()
693
+ await self._http_client.aclose()
694
+
695
+ def __del__(self):
696
+ """Ensure the client is closed on deletion."""
697
+ try:
698
+ asyncio.create_task(self._http_client.aclose())
699
+ except:
700
+ pass