solana-agent 31.1.7__tar.gz → 31.2.1__tar.gz

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.
Files changed (47) hide show
  1. {solana_agent-31.1.7 → solana_agent-31.2.1}/PKG-INFO +40 -8
  2. {solana_agent-31.1.7 → solana_agent-31.2.1}/README.md +32 -1
  3. {solana_agent-31.1.7 → solana_agent-31.2.1}/pyproject.toml +9 -8
  4. solana_agent-31.2.1/solana_agent/adapters/ffmpeg_transcoder.py +282 -0
  5. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/adapters/openai_adapter.py +5 -0
  6. solana_agent-31.2.1/solana_agent/adapters/openai_realtime_ws.py +1613 -0
  7. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/client/solana_agent.py +29 -3
  8. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/factories/agent_factory.py +2 -1
  9. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/client/client.py +18 -1
  10. solana_agent-31.2.1/solana_agent/interfaces/providers/audio.py +40 -0
  11. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/providers/llm.py +0 -1
  12. solana_agent-31.2.1/solana_agent/interfaces/providers/realtime.py +100 -0
  13. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/services/agent.py +0 -1
  14. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/services/query.py +12 -1
  15. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/repositories/memory.py +184 -19
  16. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/services/agent.py +0 -5
  17. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/services/query.py +561 -6
  18. solana_agent-31.2.1/solana_agent/services/realtime.py +506 -0
  19. {solana_agent-31.1.7 → solana_agent-31.2.1}/LICENSE +0 -0
  20. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/__init__.py +0 -0
  21. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/adapters/__init__.py +0 -0
  22. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/adapters/mongodb_adapter.py +0 -0
  23. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/adapters/pinecone_adapter.py +0 -0
  24. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/cli.py +0 -0
  25. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/client/__init__.py +0 -0
  26. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/domains/__init__.py +0 -0
  27. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/domains/agent.py +0 -0
  28. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/domains/routing.py +0 -0
  29. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/factories/__init__.py +0 -0
  30. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/guardrails/pii.py +0 -0
  31. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/__init__.py +0 -0
  32. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/guardrails/guardrails.py +0 -0
  33. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/plugins/plugins.py +0 -0
  34. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/providers/data_storage.py +0 -0
  35. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/providers/memory.py +0 -0
  36. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/providers/vector_storage.py +0 -0
  37. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/services/knowledge_base.py +0 -0
  38. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/interfaces/services/routing.py +0 -0
  39. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/plugins/__init__.py +0 -0
  40. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/plugins/manager.py +0 -0
  41. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/plugins/registry.py +0 -0
  42. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/plugins/tools/__init__.py +0 -0
  43. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/plugins/tools/auto_tool.py +0 -0
  44. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/repositories/__init__.py +0 -0
  45. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/services/__init__.py +0 -0
  46. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/services/knowledge_base.py +0 -0
  47. {solana_agent-31.1.7 → solana_agent-31.2.1}/solana_agent/services/routing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: solana-agent
3
- Version: 31.1.7
3
+ Version: 31.2.1
4
4
  Summary: AI Agents for Solana
5
5
  License: MIT
6
6
  Keywords: solana,solana ai,solana agent,ai,ai agent,ai agents
@@ -14,11 +14,11 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
- Requires-Dist: instructor (==1.11.2)
18
- Requires-Dist: llama-index-core (==0.13.5)
19
- Requires-Dist: llama-index-embeddings-openai (==0.5.0)
20
- Requires-Dist: logfire (==4.3.6)
21
- Requires-Dist: openai (==1.106.1)
17
+ Requires-Dist: instructor (==1.11.3)
18
+ Requires-Dist: llama-index-core (==0.14.0)
19
+ Requires-Dist: llama-index-embeddings-openai (==0.5.1)
20
+ Requires-Dist: logfire (==4.5.0)
21
+ Requires-Dist: openai (==1.107.0)
22
22
  Requires-Dist: pillow (==11.3.0)
23
23
  Requires-Dist: pinecone[asyncio] (==7.3.0)
24
24
  Requires-Dist: pydantic (>=2)
@@ -26,7 +26,8 @@ Requires-Dist: pymongo (==4.14.1)
26
26
  Requires-Dist: pypdf (==6.0.0)
27
27
  Requires-Dist: rich (>=13,<14.0)
28
28
  Requires-Dist: scrubadub (==2.0.1)
29
- Requires-Dist: typer (==0.17.3)
29
+ Requires-Dist: typer (==0.17.4)
30
+ Requires-Dist: websockets (>=13,<16)
30
31
  Requires-Dist: zep-cloud (==3.4.3)
31
32
  Project-URL: Documentation, https://docs.solana-agent.com
32
33
  Project-URL: Homepage, https://solana-agent.com
@@ -52,7 +53,7 @@ Build your AI agents in three lines of code!
52
53
  ## Why?
53
54
  * Three lines of code setup
54
55
  * Simple Agent Definition
55
- * Fast & Streaming Responses
56
+ * Streaming or Realtime Responses
56
57
  * Solana Integration
57
58
  * Multi-Agent Swarm
58
59
  * Multi-Modal (Images & Audio & Text)
@@ -131,6 +132,7 @@ Smart workflows are as easy as combining your tools and prompts.
131
132
  **OpenAI**
132
133
  * [gpt-4.1](https://platform.openai.com/docs/models/gpt-4.1) (agent & router)
133
134
  * [text-embedding-3-large](https://platform.openai.com/docs/models/text-embedding-3-large) (embedding)
135
+ * [gpt-realtime](https://platform.openai.com/docs/models/gpt-realtime) (realtime audio agent)
134
136
  * [tts-1](https://platform.openai.com/docs/models/tts-1) (audio TTS)
135
137
  * [gpt-4o-mini-transcribe](https://platform.openai.com/docs/models/gpt-4o-mini-transcribe) (audio transcription)
136
138
 
@@ -307,6 +309,36 @@ async for response in solana_agent.process("user123", audio_content, audio_input
307
309
  print(response, end="")
308
310
  ```
309
311
 
312
+ ### Realtime Audio Streaming
313
+
314
+ If input and/or output is encoded (compressed) like mp4/aac then you must have `ffmpeg` installed.
315
+
316
+ Due to the overhead of the router (API call) - realtime only supports a single agent setup.
317
+
318
+ Realtime uses MongoDB for memory so Zep is not needed.
319
+
320
+ ```python
321
+ from solana_agent import SolanaAgent
322
+
323
+ solana_agent = SolanaAgent(config=config)
324
+
325
+ # Example: mobile sends MP4/AAC; server encodes output to AAC
326
+ audio_content = await audio_file.read() # bytes
327
+ async for audio_chunk in solana_agent.process(
328
+ "user123", # required
329
+ audio_content, # required
330
+ realtime=True, # optional (default False)
331
+ output_format="audio", # required
332
+ vad=True, # enable VAD (optional)
333
+ rt_encode_input=True, # accept compressed input (optional)
334
+ rt_encode_output=True, # encode output for client (optional)
335
+ rt_voice="marin" # the voice to use for interactions (optional)
336
+ audio_input_format="mp4", # client transport (optional)
337
+ audio_output_format="aac" # client transport (optional)
338
+ ):
339
+ handle_audio(audio_chunk)
340
+ ```
341
+
310
342
  ### Image/Text Streaming
311
343
 
312
344
  ```python
@@ -17,7 +17,7 @@ Build your AI agents in three lines of code!
17
17
  ## Why?
18
18
  * Three lines of code setup
19
19
  * Simple Agent Definition
20
- * Fast & Streaming Responses
20
+ * Streaming or Realtime Responses
21
21
  * Solana Integration
22
22
  * Multi-Agent Swarm
23
23
  * Multi-Modal (Images & Audio & Text)
@@ -96,6 +96,7 @@ Smart workflows are as easy as combining your tools and prompts.
96
96
  **OpenAI**
97
97
  * [gpt-4.1](https://platform.openai.com/docs/models/gpt-4.1) (agent & router)
98
98
  * [text-embedding-3-large](https://platform.openai.com/docs/models/text-embedding-3-large) (embedding)
99
+ * [gpt-realtime](https://platform.openai.com/docs/models/gpt-realtime) (realtime audio agent)
99
100
  * [tts-1](https://platform.openai.com/docs/models/tts-1) (audio TTS)
100
101
  * [gpt-4o-mini-transcribe](https://platform.openai.com/docs/models/gpt-4o-mini-transcribe) (audio transcription)
101
102
 
@@ -272,6 +273,36 @@ async for response in solana_agent.process("user123", audio_content, audio_input
272
273
  print(response, end="")
273
274
  ```
274
275
 
276
+ ### Realtime Audio Streaming
277
+
278
+ If input and/or output is encoded (compressed) like mp4/aac then you must have `ffmpeg` installed.
279
+
280
+ Due to the overhead of the router (API call) - realtime only supports a single agent setup.
281
+
282
+ Realtime uses MongoDB for memory so Zep is not needed.
283
+
284
+ ```python
285
+ from solana_agent import SolanaAgent
286
+
287
+ solana_agent = SolanaAgent(config=config)
288
+
289
+ # Example: mobile sends MP4/AAC; server encodes output to AAC
290
+ audio_content = await audio_file.read() # bytes
291
+ async for audio_chunk in solana_agent.process(
292
+ "user123", # required
293
+ audio_content, # required
294
+ realtime=True, # optional (default False)
295
+ output_format="audio", # required
296
+ vad=True, # enable VAD (optional)
297
+ rt_encode_input=True, # accept compressed input (optional)
298
+ rt_encode_output=True, # encode output for client (optional)
299
+ rt_voice="marin" # the voice to use for interactions (optional)
300
+ audio_input_format="mp4", # client transport (optional)
301
+ audio_output_format="aac" # client transport (optional)
302
+ ):
303
+ handle_audio(audio_chunk)
304
+ ```
305
+
275
306
  ### Image/Text Streaming
276
307
 
277
308
  ```python
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "solana-agent"
3
- version = "31.1.7"
3
+ version = "31.2.1"
4
4
  description = "AI Agents for Solana"
5
5
  authors = ["Bevan Hunt <bevan@bevanhunt.com>"]
6
6
  license = "MIT"
@@ -24,24 +24,25 @@ testpaths = ["tests"]
24
24
 
25
25
  [tool.poetry.dependencies]
26
26
  python = ">=3.12,<4.0"
27
- openai = "1.106.1"
27
+ openai = "1.107.0"
28
28
  pydantic = ">=2"
29
29
  pymongo = "4.14.1"
30
30
  zep-cloud = "3.4.3"
31
- instructor = "1.11.2"
31
+ instructor = "1.11.3"
32
32
  pinecone = { version = "7.3.0", extras = ["asyncio"] }
33
- llama-index-core = "0.13.5"
34
- llama-index-embeddings-openai = "0.5.0"
33
+ llama-index-core = "0.14.0"
34
+ llama-index-embeddings-openai = "0.5.1"
35
35
  pypdf = "6.0.0"
36
36
  scrubadub = "2.0.1"
37
- logfire = "4.3.6"
38
- typer = "0.17.3"
37
+ logfire = "4.5.0"
38
+ typer = "0.17.4"
39
39
  rich = ">=13,<14.0"
40
40
  pillow = "11.3.0"
41
+ websockets = ">=13,<16"
41
42
 
42
43
  [tool.poetry.group.dev.dependencies]
43
44
  pytest = "^8.4.2"
44
- pytest-cov = "^6.1.1"
45
+ pytest-cov = "^7.0.0"
45
46
  pytest-asyncio = "^1.1.0"
46
47
  pytest-mock = "^3.15.0"
47
48
  pytest-github-actions-annotate-failures = "^0.3.0"
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ from typing import List, AsyncGenerator
7
+
8
+ from solana_agent.interfaces.providers.audio import AudioTranscoder
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FFmpegTranscoder(AudioTranscoder):
14
+ """FFmpeg-based transcoder. Requires 'ffmpeg' binary in PATH.
15
+
16
+ This uses subprocess to stream bytes through ffmpeg for encode/decode.
17
+ """
18
+
19
+ async def _run_ffmpeg(
20
+ self, args: List[str], data: bytes
21
+ ) -> bytes: # pragma: no cover
22
+ logger.info("FFmpeg: starting process args=%s, input_len=%d", args, len(data))
23
+ proc = await asyncio.create_subprocess_exec(
24
+ "ffmpeg",
25
+ *args,
26
+ stdin=asyncio.subprocess.PIPE,
27
+ stdout=asyncio.subprocess.PIPE,
28
+ stderr=asyncio.subprocess.PIPE,
29
+ )
30
+ stdout, stderr = await proc.communicate(input=data)
31
+ if proc.returncode != 0:
32
+ err = (stderr or b"").decode("utf-8", errors="ignore")
33
+ logger.error("FFmpeg failed (code=%s): %s", proc.returncode, err[:2000])
34
+ raise RuntimeError("ffmpeg failed to transcode audio")
35
+ logger.info("FFmpeg: finished successfully, output_len=%d", len(stdout or b""))
36
+ if stderr:
37
+ logger.debug(
38
+ "FFmpeg stderr: %s", stderr.decode("utf-8", errors="ignore")[:2000]
39
+ )
40
+ return stdout
41
+
42
+ async def to_pcm16( # pragma: no cover
43
+ self, audio_bytes: bytes, input_mime: str, rate_hz: int
44
+ ) -> bytes:
45
+ """Decode compressed audio to mono PCM16LE at rate_hz."""
46
+ logger.info(
47
+ "Transcode to PCM16: input_mime=%s, rate_hz=%d, input_len=%d",
48
+ input_mime,
49
+ rate_hz,
50
+ len(audio_bytes),
51
+ )
52
+ # Prefer to hint format for common containers/codecs; ffmpeg can still autodetect if hint is wrong.
53
+ hinted_format = None
54
+ if input_mime in ("audio/mp4", "audio/m4a"):
55
+ hinted_format = "mp4"
56
+ elif input_mime in ("audio/aac",):
57
+ # Raw AAC is typically in ADTS stream format
58
+ hinted_format = "adts"
59
+ elif input_mime in ("audio/ogg", "audio/webm"):
60
+ hinted_format = None # container detection is decent here
61
+ elif input_mime in ("audio/wav", "audio/x-wav"):
62
+ hinted_format = "wav"
63
+
64
+ args = [
65
+ "-hide_banner",
66
+ "-loglevel",
67
+ "error",
68
+ ]
69
+ if hinted_format:
70
+ args += ["-f", hinted_format]
71
+ args += [
72
+ "-i",
73
+ "pipe:0",
74
+ "-acodec",
75
+ "pcm_s16le",
76
+ "-ac",
77
+ "1",
78
+ "-ar",
79
+ str(rate_hz),
80
+ "-f",
81
+ "s16le",
82
+ "pipe:1",
83
+ ]
84
+ out = await self._run_ffmpeg(args, audio_bytes)
85
+ logger.info("Transcoded to PCM16: output_len=%d", len(out))
86
+ return out
87
+
88
+ async def from_pcm16( # pragma: no cover
89
+ self, pcm16_bytes: bytes, output_mime: str, rate_hz: int
90
+ ) -> bytes:
91
+ """Encode PCM16LE to desired format (currently AAC ADTS for mobile streaming)."""
92
+ logger.info(
93
+ "Encode from PCM16: output_mime=%s, rate_hz=%d, input_len=%d",
94
+ output_mime,
95
+ rate_hz,
96
+ len(pcm16_bytes),
97
+ )
98
+ if output_mime in ("audio/mpeg", "audio/mp3"):
99
+ # Encode to MP3 (often better streaming compatibility on mobile)
100
+ args = [
101
+ "-hide_banner",
102
+ "-loglevel",
103
+ "error",
104
+ "-f",
105
+ "s16le",
106
+ "-ac",
107
+ "1",
108
+ "-ar",
109
+ str(rate_hz),
110
+ "-i",
111
+ "pipe:0",
112
+ "-c:a",
113
+ "libmp3lame",
114
+ "-b:a",
115
+ "128k",
116
+ "-f",
117
+ "mp3",
118
+ "pipe:1",
119
+ ]
120
+ out = await self._run_ffmpeg(args, pcm16_bytes)
121
+ logger.info(
122
+ "Encoded from PCM16 to %s: output_len=%d", output_mime, len(out)
123
+ )
124
+ return out
125
+ if output_mime in ("audio/aac", "audio/mp4", "audio/m4a"):
126
+ # Encode to AAC in ADTS stream; clients can play it as AAC.
127
+ args = [
128
+ "-hide_banner",
129
+ "-loglevel",
130
+ "error",
131
+ "-f",
132
+ "s16le",
133
+ "-ac",
134
+ "1",
135
+ "-ar",
136
+ str(rate_hz),
137
+ "-i",
138
+ "pipe:0",
139
+ "-c:a",
140
+ "aac",
141
+ "-b:a",
142
+ "96k",
143
+ "-f",
144
+ "adts",
145
+ "pipe:1",
146
+ ]
147
+ out = await self._run_ffmpeg(args, pcm16_bytes)
148
+ logger.info(
149
+ "Encoded from PCM16 to %s: output_len=%d", output_mime, len(out)
150
+ )
151
+ return out
152
+ # Default: passthrough
153
+ logger.info("Encode passthrough (no change), output_len=%d", len(pcm16_bytes))
154
+ return pcm16_bytes
155
+
156
+ async def stream_from_pcm16( # pragma: no cover
157
+ self,
158
+ pcm_iter: AsyncGenerator[bytes, None],
159
+ output_mime: str,
160
+ rate_hz: int,
161
+ read_chunk_size: int = 4096,
162
+ ) -> AsyncGenerator[bytes, None]:
163
+ """Start a single continuous encoder and stream encoded audio chunks.
164
+
165
+ - Launches one ffmpeg subprocess for the entire response.
166
+ - Feeds PCM16LE mono bytes from pcm_iter into stdin.
167
+ - Yields encoded bytes from stdout as they become available.
168
+ """
169
+ if output_mime in ("audio/mpeg", "audio/mp3"):
170
+ args = [
171
+ "-hide_banner",
172
+ "-loglevel",
173
+ "error",
174
+ "-f",
175
+ "s16le",
176
+ "-ac",
177
+ "1",
178
+ "-ar",
179
+ str(rate_hz),
180
+ "-i",
181
+ "pipe:0",
182
+ "-c:a",
183
+ "libmp3lame",
184
+ "-b:a",
185
+ "128k",
186
+ "-f",
187
+ "mp3",
188
+ "pipe:1",
189
+ ]
190
+ elif output_mime in ("audio/aac", "audio/mp4", "audio/m4a"):
191
+ args = [
192
+ "-hide_banner",
193
+ "-loglevel",
194
+ "error",
195
+ "-f",
196
+ "s16le",
197
+ "-ac",
198
+ "1",
199
+ "-ar",
200
+ str(rate_hz),
201
+ "-i",
202
+ "pipe:0",
203
+ "-c:a",
204
+ "aac",
205
+ "-b:a",
206
+ "96k",
207
+ "-f",
208
+ "adts",
209
+ "pipe:1",
210
+ ]
211
+ else:
212
+ # Passthrough streaming: just yield input
213
+ async for chunk in pcm_iter:
214
+ yield chunk
215
+ return
216
+
217
+ logger.info("FFmpeg(stream): starting args=%s", args)
218
+ proc = await asyncio.create_subprocess_exec(
219
+ "ffmpeg",
220
+ *args,
221
+ stdin=asyncio.subprocess.PIPE,
222
+ stdout=asyncio.subprocess.PIPE,
223
+ stderr=asyncio.subprocess.PIPE,
224
+ )
225
+
226
+ assert proc.stdin is not None and proc.stdout is not None
227
+
228
+ async def _writer():
229
+ try:
230
+ async for pcm in pcm_iter:
231
+ if not pcm:
232
+ continue
233
+ proc.stdin.write(pcm)
234
+ # Backpressure
235
+ await proc.stdin.drain()
236
+ except asyncio.CancelledError:
237
+ # Swallow cancellation; stdin will be closed below.
238
+ pass
239
+ except Exception as e:
240
+ logger.debug("FFmpeg(stream) writer error: %s", str(e))
241
+ finally:
242
+ with contextlib.suppress(Exception):
243
+ proc.stdin.close()
244
+
245
+ writer_task = asyncio.create_task(_writer())
246
+
247
+ buf = bytearray()
248
+ try:
249
+ while True:
250
+ data = await proc.stdout.read(read_chunk_size)
251
+ if not data:
252
+ break
253
+ buf.extend(data)
254
+ # Emit fixed-size chunks even if read returns a larger blob
255
+ while len(buf) >= read_chunk_size:
256
+ yield bytes(buf[:read_chunk_size])
257
+ del buf[:read_chunk_size]
258
+ # Flush any remainder
259
+ if buf:
260
+ yield bytes(buf)
261
+ finally:
262
+ # Ensure writer is done
263
+ if not writer_task.done():
264
+ with contextlib.suppress(Exception):
265
+ writer_task.cancel()
266
+ try:
267
+ await writer_task
268
+ except asyncio.CancelledError:
269
+ pass
270
+ except Exception:
271
+ pass
272
+ # Drain remaining stderr and check return code
273
+ try:
274
+ stderr = await proc.stderr.read() if proc.stderr else b""
275
+ code = await proc.wait()
276
+ if code != 0:
277
+ err = (stderr or b"").decode("utf-8", errors="ignore")
278
+ logger.error(
279
+ "FFmpeg(stream) failed (code=%s): %s", code, err[:2000]
280
+ )
281
+ except Exception:
282
+ pass
@@ -56,6 +56,7 @@ class OpenAIAdapter(LLMProvider):
56
56
  """OpenAI implementation of LLMProvider with web search capabilities."""
57
57
 
58
58
  def __init__(self, api_key: str, logfire_api_key: Optional[str] = None):
59
+ self.api_key = api_key
59
60
  self.client = AsyncOpenAI(api_key=api_key)
60
61
 
61
62
  self.logfire = False
@@ -76,6 +77,10 @@ class OpenAIAdapter(LLMProvider):
76
77
  self.embedding_model = DEFAULT_EMBEDDING_MODEL
77
78
  self.embedding_dimensions = DEFAULT_EMBEDDING_DIMENSIONS
78
79
 
80
+ def get_api_key(self) -> Optional[str]: # pragma: no cover
81
+ """Return the API key used to configure the OpenAI client."""
82
+ return getattr(self, "api_key", None)
83
+
79
84
  async def tts(
80
85
  self,
81
86
  text: str,