Skip to content

feat(audexum): add Audexum TTS plugin - #1127

Open
pepikir wants to merge 5 commits into
GetStream:mainfrom
pepikir:add-audexum-provider
Open

pepikir wants to merge 5 commits into
GetStream:mainfrom
pepikir:add-audexum-provider

Conversation

@pepikir

@pepikir pepikir commented Oct 22, 2026

Copy link
Copy Markdown

Adds a plugins/audexum TTS plugin (audexum.TTS), laid out like the speechify / xai plugins.

Disclosure: I work on Audexum (https://audexum.com), a TTS/STT API.

  • 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.

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

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.

Share

Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 3c0ebef and 2f95738.

Files selected for processing: 2

  • plugins/audexum/tests/test_tts.py
  • plugins/audexum/vision_agents/plugins/audexum/tts.py

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments: 2
CHANGELOG.md: 1

68-70: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the PR number to this entry.

The new changelog entry omits its PR reference. As per coding guidelines, “Reference PR numbers inline, e.g. (#374).”

Source: Coding guidelines

plugins/audexum/tests/test_tts.py: 1

22-25: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert default request fields.

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().

Suggested test update
         tts = audexum.TTS(
-            api_key="sk_live_test", voice="Atlas", base_url=str(server.make_url("/v1"))
+            api_key="sk_live_test", base_url=str(server.make_url("/v1"))
         )
...
-            assert seen["body"]["voice"] == "Atlas"
+            assert seen["body"]["voice"] == "Nova"
+            assert seen["body"]["speed"] == 1.0

ℹ️ Review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 902db86 and d8d1026.

⛔ Files ignored due to path filters: 1
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing: 11
  • CHANGELOG.md
  • README.md
  • agents-core/pyproject.toml
  • plugins/audexum/README.md
  • plugins/audexum/py.typed
  • plugins/audexum/pyproject.toml
  • plugins/audexum/tests/__init__.py
  • plugins/audexum/tests/test_tts.py
  • plugins/audexum/vision_agents/plugins/audexum/__init__.py
  • plugins/audexum/vision_agents/plugins/audexum/tts.py
  • pyproject.toml

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread plugins/audexum/README.md Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between d8d1026 and 2243371.

📒 Files selected for processing: 4
  • CHANGELOG.md
  • plugins/audexum/README.md
  • plugins/audexum/tests/test_tts.py
  • plugins/audexum/vision_agents/plugins/audexum/tts.py
🚧 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.

pepikir and others added 3 commits November 8, 2026 11:39
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… clarify .env loading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
@pepikir
pepikir force-pushed the add-audexum-provider branch from 2243371 to e2f2386 Compare November 8, 2026 10:08
@pepikir

pepikir commented Nov 8, 2026

Copy link
Copy Markdown
Author

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Nitpick comments: 1

plugins/audexum/vision_agents/plugins/audexum/__init__.py: 1

3-4: Trivial · Maintainability & Code Quality · Low value

Import pkgutil before the relative import.

The dynamic pkgutil import runs after from .tts import TTS. Import extend_path at the top of the module and use it in the assignment.

As per coding guidelines, imports are “ordered as: stdlib, third-party, local package, relative.”

Review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 2243371 and e2f2386.

Files ignored due to path filters: 1

  • uv.lock is excluded by !**/*.lock
Files selected for processing: 11

  • CHANGELOG.md
  • README.md
  • agents-core/pyproject.toml
  • plugins/audexum/README.md
  • plugins/audexum/py.typed
  • plugins/audexum/pyproject.toml
  • plugins/audexum/tests/__init__.py
  • plugins/audexum/tests/test_tts.py
  • plugins/audexum/vision_agents/plugins/audexum/__init__.py
  • plugins/audexum/vision_agents/plugins/audexum/tts.py
  • pyproject.toml
Files with no reviewable changes: 2

  • plugins/audexum/py.typed
  • plugins/audexum/tests/init.py
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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Additional comments: 1

plugins/audexum/vision_agents/plugins/audexum/tts.py: 1

78-87: Minor · Potential issue · Low value

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.

Review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between e2f2386 and 3c0ebef.

Files selected for processing: 2

  • plugins/audexum/tests/test_tts.py
  • plugins/audexum/vision_agents/plugins/audexum/tts.py

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

channels=1,
format=AudioFormat.S16,
)
return task.result()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Stability & Availability · Low value

Supported by static analysis

Script executed:

git diff 0cdb2e178c1baf7ad40729d616d673a972f5b533 3c0ebef6782c842e52961c96d5a6c91b892777fd -- plugins/audexum/vision_agents/plugins/audexum/tts.py plugins/audexum/tests/test_tts.py
printf '\n--- TTS source ---\n'
nl -ba plugins/audexum/vision_agents/plugins/audexum/tts.py
printf '\n--- tests ---\n'
nl -ba plugins/audexum/tests/test_tts.py
printf '\n--- package config ---\n'
nl -ba plugins/audexum/pyproject.toml

Repository: GetStream/Vision-Agents

Length of output: 35152


Script executed:

printf '%s\n' '--- aiohttp locked version ---'
rg -n -A 15 '^name = "aiohttp"$' uv.lock
printf '%s\n' '--- Python stdlib task cancel and future exception handling ---'
python3 - <<'PY'
import pathlib, sysconfig
root = pathlib.Path(sysconfig.get_path("stdlib")) / "asyncio"
for name, terms in [('tasks.py', ['def cancel(', 'def __step(']), ('futures.py', ['def cancel(', 'def __del__(', 'def exception('])]:
    path = root / name
    lines = path.read_text().splitlines()
    print(path)
    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+35, len(lines)))))
PY
printf '%s\n' '--- core TTS API locations ---'
rg -n -F -e 'async def send_iter' -e 'async def close' -e 'async def stop_audio' -- agents-core/vision_agents/core/tts

Repository: GetStream/Vision-Agents

Length of output: 10764


Web query:

aiohttp 3.13.3 ClientResponse __aexit__ wait_for_close source cancellation response release async def

Result:

Sources consulted:
- aiohttp.client_reqrep — aiohttp 3.13.4 documentation — https://docs.aiohttp.org/en/v3.13.4/_modules/aiohttp/client_reqrep.html
- aiohttp/docs/client_reference.rst at master · aio-libs/aiohttp · GitHub — https://github.com/aio-libs/aiohttp/blob/master/docs/client_reference.rst
- aiohttp/aiohttp/helpers.py at master · aio-libs/aiohttp · GitHub — https://github.com/aio-libs/aiohttp/blob/master/aiohttp/helpers.py
- Releases · aio-libs/aiohttp · GitHub — https://github.com/aio-libs/aiohttp/releases
- …and 22 more

Script executed:

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.request
url = "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

Repository: GetStream/Vision-Agents

Length of output: 22851


Web query:

aiohttp 3.14.1 TimerContext __exit__ python 3.10 _cancelled TimeoutError source

Result:

Sources consulted:
- aiohttp/aiohttp/helpers.py at v3.14.1 · aio-libs/aiohttp · GitHub — https://github.com/aio-libs/aiohttp/blob/v3.14.1/aiohttp/helpers.py
- aiohttp/aiohttp/helpers.py at master · aio-libs/aiohttp · GitHub — https://github.com/aio-libs/aiohttp/blob/master/aiohttp/helpers.py
- Releases · aio-libs/aiohttp · GitHub — https://github.com/aio-libs/aiohttp/releases?after=v3.6.0a9
- aiohttp.helpers — aiohttp 3.10.9 documentation — https://docs.aiohttp.org/en/v3.10.9/_modules/aiohttp/helpers.html
- …and 25 more

Script executed:

python3 - <<'PY'
import io, tarfile, urllib.request
base = "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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant