You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Calls Audexum's OpenAI-compatible POST /v1/audio/speech with response_format=wav and decodes the WAV (stdlib wave) to PcmData. Uses aiohttp, which the xai plugin already depends on; no other new dependency.
api_key or AUDEXUM_API_KEY; voice (default Nova), speed, base_url, optional session.
One full clip per request, not incremental streaming. The README says so.
Registered in the workspace (pyproject.toml sources + members), vision-agents[audexum] extra in agents-core/pyproject.toml, uv.lock, a CHANGELOG entry and the README TTS list. The README link points at the plugin directory because there is no docs page yet.
Tests: plugins/audexum/tests/test_tts.py has 3 unit tests (defaults, missing key, request shape plus PCM decoding and HTTP error, against a local aiohttp test server) and one @pytest.mark.integration test that needs AUDEXUM_API_KEY, as DEVELOPMENT.md asks. ruff and mypy are clean.
Free tier: 2,000 credits at signup, then 250/day, no card.
Live check (2026-10-22): the endpoint used here returns audio for every voice listed above.
No actionable comments were generated in the recent review.
Walkthrough
The change adds an Audexum TTS plugin. It sends text to Audexum’s speech endpoint and decodes returned 16-bit WAV audio to PcmData. The plugin supports an explicit API key or AUDEXUM_API_KEY, and defaults to the Nova voice. Workspace configuration, tests, and documentation are added.
Merge Risk: Low · up to 2f957
This adds an opt-in Audexum TTS plugin with tests and documentation. One minor edge case remains open: on Python 3.10, caller cancellation that coincides with a request timeout can emit an unretrieved-exception warning. It does not affect normal use, so the change is mergeable with that follow-up noted.
- [ ] Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
The defaults test checks only constructor attributes. The HTTP test supplies voice="Atlas" and does not assert speed, so it will not detect incorrect default fields in stream_audio().
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
🚧 Files skipped from review as they are similar to previous changes: 2
CHANGELOG.md
plugins/audexum/README.md
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.
Review of GetStream#1127: stream_audio() stored only the latest task, so with two
concurrent calls a manual stop_audio() cancelled the second request while the
first could still return speech after the stop.
All in-flight tasks are now tracked in a set and stop_audio() cancels each
one. Only a cancel requested by stop_audio() is turned into empty audio; a
caller that is itself cancelled (also in the same tick as a stop, via
Task.cancelling() on 3.11+) still gets CancelledError. close() stops pending
requests before closing the session, so their callers receive empty audio
instead of a connection error.
Tests: concurrent requests all return empty audio after a stop (fails on the
previous code), a stop with nothing in flight does not affect the next
request, external and same-tick cancellation propagate, close() with pending
requests. Docs note that `speed` applies to the F1-F5 / M1-M5 voices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01No5BjeaLzSDP4LhEthEGit
Addressed the open review thread in e2f2386 (rebased on current main): stream_audio() now tracks every in-flight task and stop_audio() cancels all of them, so concurrent requests can no longer return speech after a stop. Only a stop-requested cancel becomes empty audio; a caller's own cancellation still propagates (also in the same tick as a stop, via Task.cancelling() on 3.11+), and close() stops pending requests before closing the session. New tests for concurrent stop, stop-then-new-request, external and same-tick cancel, and close with pending requests; ruff and mypy clean; the integration test passes against the live API.
Files skipped from review as they are similar to previous changes: 2
README.md
CHANGELOG.md
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
…ecode errors
stream_audio() now waits with asyncio.wait(), which raises only when the
caller is cancelled and leaves the request task alone, so a stop_audio()
and a caller cancel in the same tick both behave on every Python version
without Task.cancelling(). Truncated or non-WAV 200 responses raise a
RuntimeError naming Audexum instead of a bare wave.Error/EOFError.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01No5BjeaLzSDP4LhEthEGit
Unverified finding
We couldn't verify this from the code. It is shown for review, not as a verified issue.
In stream_audio, when the caller is cancelled after the inner task has already been cancelled by stop_audio() (or the inner task finishes in the same tick), asyncio.wait may return normally. The code then sees task.cancelled() and returns an empty buffer, swallowing the caller's own cancellation. The comment says the two cases stay distinct, but the code relies on wait raising only when the caller is cancelled; the same-tick race is a possible hole.
The caller's cancellation is lost. Agent shutdown or a task group awaiting cancellation could hang, or the caller could continue after being cancelled.
This can occur when: stop_audio() cancels the inner task, and the caller task's cancel() is delivered in the same event-loop iteration before the caller resumes from asyncio.wait.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
printf'%s\n''--- core TTS lifecycle and caller ---'
nl -ba agents-core/vision_agents/core/tts/tts.py | sed -n '160,350p'printf'%s\n''--- retrieve locked aiohttp sources from lockfile URL without writing files ---'
python3 - <<'PY'import io, tarfile, urllib.requesturl = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz"try: data = urllib.request.urlopen(url, timeout=20).read() with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: for suffix, terms in [ ("aiohttp/helpers.py", ["class TimerContext", "def timeout(self)"]), ("aiohttp/client_reqrep.py", ["async def wait_for_close", "async def __aexit__", "async def read(self)"]), ("aiohttp/client.py", ["class _BaseRequestContextManager", "async def __aexit__", "async def close("]) ]: matches = [m for m in tf.getmembers() if m.name.endswith(suffix)] member = matches[0] lines = tf.extractfile(member).read().decode().splitlines() print("\n---", member.name, "---") for i, line in enumerate(lines): if any(term in line for term in terms): print('\n'.join(f'{j+1:4} {lines[j]}' for j in range(i, min(i+40, len(lines))))) print()except Exception as e: print("FETCH FAILED:", repr(e))PY
python3 - <<'PY'import io, tarfile, urllib.requestbase = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz"data = urllib.request.urlopen(base, timeout=20).read()with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: for name, terms, context in [ ("aiohttp-3.14.1/aiohttp/client.py", ["TimeoutHandle(", "timer = tm.timer()", "with timer:"], 16), ("aiohttp-3.14.1/aiohttp/streams.py", ["with self._timer:"], 12), ("aiohttp-3.14.1/aiohttp/helpers.py", ["def __exit__(", "def timeout(self)"], 25), ]: lines = tf.extractfile(name).read().decode().splitlines() print('\n---',name,'---') for i,line in enumerate(lines): if any(t in line for t in terms): if name.endswith('helpers.py') and not 700 <= i+1 <= 770: continue print('\n'.join(f'{j+1:4} {lines[j]}' for j in range(max(0,i-5),min(i+context,len(lines))))) print()url = "https://raw.githubusercontent.com/python/cpython/v3.10.19/Lib/asyncio/tasks.py"lines = urllib.request.urlopen(url, timeout=20).read().decode().splitlines()print('\n--- CPython v3.10.19 Lib/asyncio/tasks.py ---')for i,line in enumerate(lines): if line.startswith(' def cancel(self, msg=None):') or line.strip() == 'except BaseException as exc:': print('\n'.join(f'{j+1:4} {lines[j]}' for j in range(i,min(i+32,len(lines)))))url = "https://raw.githubusercontent.com/python/cpython/v3.10.19/Lib/asyncio/futures.py"lines = urllib.request.urlopen(url, timeout=20).read().decode().splitlines()print('\n--- CPython v3.10.19 Lib/asyncio/futures.py ---')for i,line in enumerate(lines): if line.startswith(' def __del__(self):') or line.startswith(' def set_exception(self, exception):'): print('\n'.join(f'{j+1:4} {lines[j]}' for j in range(i,min(i+28,len(lines)))))PY
Repository: GetStream/Vision-Agents
Length of output: 15506
Finish the request task after caller cancellation.
If caller cancellation coincides with the request timeout on Python 3.10, the inner task can raise TimeoutError without its exception being retrieved. This produces an asyncio warning. Keep the task tracked until it finishes, and retrieve its exception while preserving caller cancellation.
…inal
A WAV whose data chunk is shorter than its header advertises was decoded
and returned as complete speech. Compare the decoded byte count with the
advertised frame count and raise, as suggested in review.
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
plugins/audexumTTS plugin (audexum.TTS), laid out like thespeechify/xaiplugins.Disclosure: I work on Audexum (https://audexum.com), a TTS/STT API.
POST /v1/audio/speechwithresponse_format=wavand decodes the WAV (stdlibwave) toPcmData. Usesaiohttp, which thexaiplugin already depends on; no other new dependency.api_keyorAUDEXUM_API_KEY;voice(defaultNova),speed,base_url, optionalsession.pyproject.tomlsources + members),vision-agents[audexum]extra inagents-core/pyproject.toml,uv.lock, a CHANGELOG entry and the README TTS list. The README link points at the plugin directory because there is no docs page yet.plugins/audexum/tests/test_tts.pyhas 3 unit tests (defaults, missing key, request shape plus PCM decoding and HTTP error, against a local aiohttp test server) and one@pytest.mark.integrationtest that needsAUDEXUM_API_KEY, as DEVELOPMENT.md asks.ruffandmypyare clean.Free tier: 2,000 credits at signup, then 250/day, no card.
Live check (2026-10-22): the endpoint used here returns audio for every voice listed above.