Skip to content

feat(bytedance): add Seed Speech STT, TTS and Live Interpretation - #634

Draft
Nash0x7E2 wants to merge 1 commit into
mainfrom
feat/bytedance
Draft

feat(bytedance): add Seed Speech STT, TTS and Live Interpretation#634
Nash0x7E2 wants to merge 1 commit into
mainfrom
feat/bytedance

Conversation

@Nash0x7E2

@Nash0x7E2 Nash0x7E2 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Why

Adds first-class support for ByteDance / BytePlus (Volcengine) Seed Speech, so agents can use Seed ASR, Seed TTS, and — most notably — AST 2.0 Live Interpretation, a speech-to-speech translator that slots into the same Agent role as Gemini Live Translate. There is no official Python SDK for these services, so the plugin talks to the WebSocket protocols directly. Both the new-console X-Api-Key auth and the legacy X-Api-App-Key + X-Api-Access-Key auth are supported, and every class takes an overridable ws_url so the same code targets either the mainland or a BytePlus regional host.

Changes

  • bytedance.STT — streaming ASR on Seed ASR 2.0 bigmodel_async, emitting replacement/final transcripts and turn events.
  • bytedance.TTS — bidirectional streaming TTS (seed-tts-2.0) over a persistent session, yielding PCM audio.
  • bytedance.Realtime — AST 2.0 Live Interpretation (s2s / s2t) with virtual-microphone input pacing by default.
  • Shared _auth, _v3 framing and _ast proto codecs; unit tests for the wire formats plus integration tests, runnable examples, README, and CHANGELOG/root-README integration entries.
  • Workspace wiring: vision-agents[bytedance] extra and new workspace member.

Wrap the ByteDance / BytePlus (Volcengine) Seed Speech WebSocket APIs as a
new plugin: bytedance.STT (streaming ASR on bigmodel_async), bytedance.TTS
(bidirectional streaming TTS), and bytedance.Realtime (AST 2.0 Live
Interpretation speech-to-speech translator). Includes a pure-Python v3
framing codec and AST proto codec, unit + integration tests, examples, and
docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Added a ByteDance plugin for Seed Speech streaming STT and TTS, plus AST 2.0 realtime interpretation. The change includes authentication helpers, WebSocket framing, protobuf encoding and decoding, package metadata, workspace integration, public exports, documentation, runnable examples, and unit and integration tests. Realtime support validates languages and modes, streams resampled PCM audio, emits subtitle and translated-audio events, and manages session cleanup.

Merge Risk: 🟠 High · up to 6abbb

The new ByteDance streaming integrations still have risks that can cause authentication with the wrong account, dropped or stale audio across sessions, continued usage after interruption, and connections that leak or fail to recover. These high-impact runtime and correctness issues should be fixed before merging.

✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/bytedance

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

Comment @coderabbitai help to get the list of available commands.

@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: 12

🧹 Nitpick comments (9)
plugins/bytedance/vision_agents/plugins/bytedance/_v3.py (1)

17-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove Any and bare dict from the protocol contract.

Use object | None for Message.payload and a parameterized mapping type for build_full_client_request. The existing consumers already narrow JSON payloads before use.

As per coding guidelines: "Avoid using Any type" and "Use type annotations everywhere."

Also applies to: 90-91, 114-114, 212-213

Source: Coding guidelines

plugins/bytedance/tests/test_protocol.py (2)

4-4: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not import private codec modules in external tests.

_ast and _v3 are private modules. Lines 133-136 also call private wire helpers. Re-export the supported protocol API from the package, then test that API with known wire fixtures.

As per coding guidelines: "Never import from private modules (_foo) outside of the package's own __init__.py; use the public re-export."

Also applies to: 133-136

Source: Coding guidelines


10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add return annotations to test methods.

Add -> None to each test method in this file.

As per coding guidelines: "Use type annotations everywhere."

Also applies to: 23-23, 30-30, 52-52, 75-75, 86-86, 99-99, 117-117, 132-132, 143-143

Source: Coding guidelines

plugins/bytedance/vision_agents/plugins/bytedance/stt.py (1)

202-225: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the missing return annotation.

close() and start() have no return annotation, while tts.py uses async def close(self) -> None. Keep the annotations consistent across the plugin.

Source: Coding guidelines

plugins/bytedance/tests/test_stt.py (1)

80-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Credential-clearing test setup is duplicated across both test files. Each file repeats the same four monkeypatch.delenv calls for the ByteDance credential variables. The root cause is a missing shared fixture for the plugin's tests.

  • plugins/bytedance/tests/test_stt.py#L80-L86: replace the inline delenv calls with a shared fixture from a conftest.py in plugins/bytedance/tests/.
  • plugins/bytedance/tests/test_tts.py#L29-L35: use the same shared fixture.
plugins/bytedance/vision_agents/plugins/bytedance/tts.py (2)

70-77: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Validate speech_rate in the constructor.

The docstring documents the range [-50, 100], but no code enforces it. An out-of-range value fails remotely, mid-session, with a provider error. Raise ValueError in __init__ instead.

Proposed fix
         super().__init__(provider_name="bytedance")
+        if not -50 <= speech_rate <= 100:
+            raise ValueError(
+                f"speech_rate must be in [-50, 100], got {speech_rate}"
+            )
         self._credentials = Credentials.resolve(api_key, app_key, access_key)

As per coding guidelines: "Raise ValueError with a descriptive message for invalid args".

Source: Coding guidelines


193-198: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Close the socket in a finally block.

If super().close() raises, the WebSocket stays open and _on_disconnected never runs. Move the socket teardown into finally.

Proposed fix
     async def close(self) -> None:
-        await super().close()
-        if self._ws is not None:
-            await self._ws.close()
-            self._ws = None
-            self._on_disconnected()
+        try:
+            await super().close()
+        finally:
+            if self._ws is not None:
+                await self._ws.close()
+                self._ws = None
+                self._on_disconnected()

As per coding guidelines: "Clean up resources in finally blocks".

Source: Coding guidelines

plugins/bytedance/vision_agents/plugins/bytedance/realtime.py (1)

54-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add annotations to all new function boundaries and task state.

  • plugins/bytedance/vision_agents/plugins/bytedance/realtime.py#L54-L67: add -> None to __init__.
  • plugins/bytedance/vision_agents/plugins/bytedance/realtime.py#L109-L110: parameterize _processing_task as asyncio.Task[None] | None.
  • plugins/bytedance/vision_agents/plugins/bytedance/realtime.py#L136-L193: add return annotations and concrete types for lifecycle and input methods.
  • plugins/bytedance/vision_agents/plugins/bytedance/realtime.py#L243-L243: add -> None to close.
  • plugins/bytedance/example/bytedance_realtime_example.py#L23-L33: type callback keyword arguments and return values.
  • plugins/bytedance/tests/test_realtime.py#L34-L95: add -> None to tests and types for fixture parameters.
  • plugins/bytedance/example/bytedance_stt_tts_example.py#L28-L40: type callback keyword arguments and return values.

Use object or concrete protocols where framework callback payload types are unknown. Do not introduce Any.

As per coding guidelines: “Use type annotations everywhere.”

Source: Coding guidelines

plugins/bytedance/tests/test_realtime.py (1)

12-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid private protocol imports in tests

Refactor plugins/bytedance/tests/test_realtime.py to avoid importing _ast and its private wire helpers. Assert Realtime behavior through its public interface, or expose a supported protocol API if message construction is required.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e67cd6f3-d7a9-4571-a16f-638bd7e8de9d

📥 Commits

Reviewing files that changed from the base of the PR and between adc3523 and 6abbb9c.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • CHANGELOG.md
  • README.md
  • agents-core/pyproject.toml
  • plugins/bytedance/README.md
  • plugins/bytedance/example/README.md
  • plugins/bytedance/example/bytedance_realtime_example.py
  • plugins/bytedance/example/bytedance_stt_tts_example.py
  • plugins/bytedance/example/pyproject.toml
  • plugins/bytedance/py.typed
  • plugins/bytedance/pyproject.toml
  • plugins/bytedance/tests/test_protocol.py
  • plugins/bytedance/tests/test_realtime.py
  • plugins/bytedance/tests/test_stt.py
  • plugins/bytedance/tests/test_tts.py
  • plugins/bytedance/vision_agents/plugins/bytedance/__init__.py
  • plugins/bytedance/vision_agents/plugins/bytedance/_ast.py
  • plugins/bytedance/vision_agents/plugins/bytedance/_auth.py
  • plugins/bytedance/vision_agents/plugins/bytedance/_v3.py
  • plugins/bytedance/vision_agents/plugins/bytedance/ast.proto
  • plugins/bytedance/vision_agents/plugins/bytedance/realtime.py
  • plugins/bytedance/vision_agents/plugins/bytedance/stt.py
  • plugins/bytedance/vision_agents/plugins/bytedance/tts.py
  • pyproject.toml

Included review availability: Your plan includes up to 8 reviews per rolling hour; 7 remain after this review.

Comment on lines +1 to +32
import gzip
import json

import pytest
from dotenv import load_dotenv

from vision_agents.core.edge.types import Participant
from vision_agents.core.stt import Transcript
from vision_agents.core.turn_detection import TurnEnded
from vision_agents.plugins import bytedance
from vision_agents.plugins.bytedance import _v3

load_dotenv()


def _server_result_frame(result: dict) -> _v3.Message:
body = gzip.compress(json.dumps({"result": result}).encode())
header = bytes(
[
(0b0001 << 4) | 0b0001,
(int(_v3.MsgType.FULL_SERVER_RESPONSE) << 4) | _v3.Flags.POS_SEQ,
(int(_v3.Serialization.JSON) << 4) | int(_v3.Compression.GZIP),
0,
]
)
frame = (
header
+ (1).to_bytes(4, "big", signed=True)
+ len(body).to_bytes(4, "big")
+ body
)
return _v3.parse_response(frame)

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Do not import the private _v3 module from tests, and do not hand-build the frame header.

Two issues in the same block:

  1. from vision_agents.plugins.bytedance import _v3 reaches into a private module from outside the package.
  2. _server_result_frame re-encodes the v3 header bit layout by hand. That duplicates the framing rules in _v3 and will drift silently if the header layout changes.

Add a server-frame builder in _v3 (or a small test helper exported by the package) and call it from the test.

As per coding guidelines: "Never import from private modules (_foo) outside of the package's own __init__.py; use the public re-export".

🧰 Tools
🪛 ast-grep (0.45.1)

[info] 16-16: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"result": result})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

Source: Coding guidelines

Comment on lines +72 to +78
async def test_error_frame_does_not_emit_transcript(self, stt, participant):
stt._current_participant = participant
error_message = _v3.Message(type=_v3.MsgType.ERROR, code=45000000, payload={})

stt._handle_message(error_message)

assert stt.output.peek() == []

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check where _emit_error_event routes events in the base STT class.
rg -nP --type=py -C6 'def _emit_error_event' agents-core

Repository: GetStream/Vision-Agents

Length of output: 3541


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- STT error-event implementation ---'
sed -n '150,215p' agents-core/vision_agents/core/stt/stt.py

printf '%s\n' '--- Bytedance test and implementation references ---'
rg -n -C8 --type=py '_handle_message|_emit_error_event|output\.peek|class .*STT' plugins/bytedance agents-core/vision_agents/core/stt

printf '%s\n' '--- Stream/output definitions ---'
rg -n -C6 --type=py 'class .*Output|def peek|_output|output\s*=' agents-core/vision_agents/core/stt plugins/bytedance

Repository: GetStream/Vision-Agents

Length of output: 34347


Make the test synchronous.

The body has no await, so change async def to def. _handle_message sends STTErrorEvent through stt.events, not stt.output; keep assert stt.output.peek() == [].

Comment on lines +88 to +100
@pytest.mark.integration
async def test_transcribe_mia_audio(self, mia_audio_16khz, participant):
stt = bytedance.STT()
try:
await stt.start()
await stt.process_audio(mia_audio_16khz, participant=participant)
items = await stt.output.collect(timeout=15.0)
finally:
await stt.close()

finals = [i for i in items if isinstance(i, Transcript) and i.final]
full = " ".join(t.text for t in finals)
assert "forgotten treasures" in full.lower()

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the mia_audio_16khz fixture and any conftest visible to the bytedance tests.
rg -nP --type=py -C3 'mia_audio_16khz'
fd -t f 'conftest.py'

Repository: GetStream/Vision-Agents

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- relevant test files ---'
git ls-files 'plugins/bytedance/tests/*' 'plugins/bytedance/*' | sed -n '1,120p'

printf '%s\n' '--- fixture and conftest references ---'
rg -n -C4 'mia_audio_16khz|conftest|def close|async def close|negative|sequence' plugins/bytedance plugins 2>/dev/null | sed -n '1,240p'

printf '%s\n' '--- package exports and STT implementation references ---'
rg -n -C4 'from .* import _v3|import _v3|class STT|process_audio|output\.collect|def start|def close|async def close' plugins/bytedance 2>/dev/null | sed -n '1,260p'

Repository: GetStream/Vision-Agents

Length of output: 31040


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- all tracked conftest files and pytest configuration ---'
git ls-files '*conftest.py' '*pytest*' | sed -n '1,200p'
find . -type f \( -name 'conftest.py' -o -name 'pytest.ini' -o -name 'pyproject.toml' \) -not -path './.git/*' -print | sed -n '1,240p'
rg -n -C5 'pytest_plugins|mia_audio_16khz|asyncio_mode|fixture' --glob 'conftest.py' --glob 'pyproject.toml' --glob 'pytest.ini' . 2>/dev/null | sed -n '1,260p'

printf '%s\n' '--- output stream implementation ---'
rg -n -C6 'class .*Channel|async def collect|def collect|collect\(' --glob '*.py' . 2>/dev/null | sed -n '1,320p'

printf '%s\n' '--- exact STT lifecycle ---'
sed -n '90,225p' plugins/bytedance/vision_agents/plugins/bytedance/stt.py

Repository: GetStream/Vision-Agents

Length of output: 41702


Send an end-of-audio frame before collecting

STT.close() sends the last=True frame only after collect() returns. This test therefore relies on server-side VAD to emit a final transcript within 15 seconds. Send an end-of-audio frame while keeping the listener active, then collect and close the stream.

Source: Path instructions

Comment on lines +37 to +47
@pytest.mark.integration
async def test_convert_text_to_audio(self):
tts = bytedance.TTS()
try:
out = [chunk async for chunk in tts.send_iter("你好,世界。")]
finally:
await tts.close()

assert len(out) > 0
assert isinstance(out[0].data, PcmData)
assert out[-1].final

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the send_iter contract and the yielded chunk type in the base TTS class.
rg -nP --type=py -C12 'def send_iter' agents-core

Repository: GetStream/Vision-Agents

Length of output: 1993


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- base TTS definitions ---'
sed -n '1,280p' agents-core/vision_agents/core/tts/tts.py
printf '%s\n' '--- TTS output definitions and implementations ---'
rg -nP --type=py -C8 'class TTSOutputChunk|TTSOutputChunk|_ensure_connection|async def send_iter' agents-core plugins/bytedance
printf '%s\n' '--- test file ---'
cat -n plugins/bytedance/tests/test_tts.py

Repository: GetStream/Vision-Agents

Length of output: 29473


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- ByteDance TTS implementation ---'
cat -n plugins/bytedance/vision_agents/plugins/bytedance/tts.py
printf '%s\n' '--- project timeout patterns ---'
rg -nP --type=py -C4 'asyncio\.timeout|wait_for|recv\(|websockets\.connect|open_timeout|close_timeout|ping_timeout' plugins agents-core
printf '%s\n' '--- ByteDance dependency and test configuration ---'
rg -nP -C3 'websockets|integration|asyncio_mode|pytest' plugins/bytedance pyproject.toml agents-core/pyproject.toml

Repository: GetStream/Vision-Agents

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
import ast
from pathlib import Path

base = ast.parse(Path("agents-core/vision_agents/core/tts/tts.py").read_text())
plugin = ast.parse(Path("plugins/bytedance/vision_agents/plugins/bytedance/tts.py").read_text())
test = ast.parse(Path("plugins/bytedance/tests/test_tts.py").read_text())

def find_class_method(tree, class_name, method_name):
    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef) and node.name == class_name:
            for child in node.body:
                if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name == method_name:
                    return child
    raise LookupError((class_name, method_name))

send_iter = find_class_method(base, "TTS", "send_iter")
print("send_iter_return_annotation:", ast.unparse(send_iter.returns))
print("send_iter_yield_types:", sorted({
    ast.unparse(value.func)
    for value in (
        node.value for node in ast.walk(send_iter)
        if isinstance(node, ast.Yield) and isinstance(node.value, ast.Call)
    )
    if isinstance(value, ast.Call)
}))
print("send_iter_final_values:", sorted({
    ast.unparse(keyword.value)
    for node in ast.walk(send_iter)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Name)
    and node.func.id == "TTSOutputChunk"
    for keyword in node.keywords
    if keyword.arg == "final"
}))
print("send_iter_data_values:", sorted({
    ast.unparse(keyword.value)
    for node in ast.walk(send_iter)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Name)
    and node.func.id == "TTSOutputChunk"
    for keyword in node.keywords
    if keyword.arg == "data"
}))

ensure = find_class_method(plugin, "TTS", "_ensure_connection")
receive = find_class_method(plugin, "TTS", "_receive_audio")
print("ensure_connection_recv_calls:", [
    ast.unparse(node)
    for node in ast.walk(ensure)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "recv"
])
print("ensure_connection_timeout_calls:", [
    ast.unparse(node)
    for node in ast.walk(ensure)
    if isinstance(node, ast.Call)
    and (
        (isinstance(node.func, ast.Attribute) and node.func.attr in {"wait_for", "timeout"})
        or (isinstance(node.func, ast.Name) and node.func.id == "wait_for")
    )
])
print("receive_audio_async_for_targets:", [
    ast.unparse(node.target)
    for node in ast.walk(receive)
    if isinstance(node, ast.AsyncFor)
])
print("receive_audio_timeout_calls:", [
    ast.unparse(node)
    for node in ast.walk(receive)
    if isinstance(node, ast.Call)
    and (
        (isinstance(node.func, ast.Attribute) and node.func.attr in {"wait_for", "timeout"})
        or (isinstance(node.func, ast.Name) and node.func.id == "wait_for")
    )
])

integration_test = find_class_method(test, "TestBytedanceTTS", "test_convert_text_to_audio")
print("integration_test_async_comprehensions:", [
    ast.unparse(node)
    for node in ast.walk(integration_test)
    if isinstance(node, ast.ListComp) and any(isinstance(gen, ast.comprehension) and gen.is_async for gen in node.generators)
])
print("integration_test_timeout_calls:", [
    ast.unparse(node)
    for node in ast.walk(integration_test)
    if isinstance(node, ast.Call)
    and (
        (isinstance(node.func, ast.Attribute) and node.func.attr in {"wait_for", "timeout"})
        or (isinstance(node.func, ast.Name) and node.func.id == "wait_for")
    )
])
PY

Repository: GetStream/Vision-Agents

Length of output: 641


Bound the integration test and WebSocket receive path.

send_iter yields TTSOutputChunk objects, so the assertions are valid. The integration test and ByteDance receive operations have no timeout. Add explicit timeouts to prevent stalled runs.

Comment on lines +40 to +46
api_key = (
api_key
or os.environ.get("BYTEDANCE_API_KEY")
or os.environ.get("BYTEPLUS_API_KEY")
)
app_key = app_key or os.environ.get("BYTEDANCE_APP_KEY")
access_key = access_key or os.environ.get("BYTEDANCE_ACCESS_KEY")

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Preserve explicit legacy credentials over environment API keys.

When a caller passes app_key and access_key without api_key, BYTEDANCE_API_KEY or BYTEPLUS_API_KEY still populates api_key. headers() then selects X-Api-Key and ignores the explicit legacy credentials. This can authenticate requests with the wrong account or cause authentication failures.

Resolve the explicit credential scheme first. Use environment values only when the caller did not provide a credential scheme. Add a test with an API-key environment variable and explicit legacy credentials.

Suggested fix
-        api_key = (
-            api_key
-            or os.environ.get("BYTEDANCE_API_KEY")
-            or os.environ.get("BYTEPLUS_API_KEY")
-        )
-        app_key = app_key or os.environ.get("BYTEDANCE_APP_KEY")
-        access_key = access_key or os.environ.get("BYTEDANCE_ACCESS_KEY")
+        if api_key is not None:
+            pass
+        elif app_key is not None or access_key is not None:
+            app_key = app_key or os.environ.get("BYTEDANCE_APP_KEY")
+            access_key = access_key or os.environ.get("BYTEDANCE_ACCESS_KEY")
+        else:
+            api_key = (
+                os.environ.get("BYTEDANCE_API_KEY")
+                or os.environ.get("BYTEPLUS_API_KEY")
+            )
+            app_key = os.environ.get("BYTEDANCE_APP_KEY")
+            access_key = os.environ.get("BYTEDANCE_ACCESS_KEY")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
api_key = (
api_key
or os.environ.get("BYTEDANCE_API_KEY")
or os.environ.get("BYTEPLUS_API_KEY")
)
app_key = app_key or os.environ.get("BYTEDANCE_APP_KEY")
access_key = access_key or os.environ.get("BYTEDANCE_ACCESS_KEY")
if api_key is not None:
pass
elif app_key is not None or access_key is not None:
app_key = app_key or os.environ.get("BYTEDANCE_APP_KEY")
access_key = access_key or os.environ.get("BYTEDANCE_ACCESS_KEY")
else:
api_key = (
os.environ.get("BYTEDANCE_API_KEY")
or os.environ.get("BYTEPLUS_API_KEY")
)
app_key = os.environ.get("BYTEDANCE_APP_KEY")
access_key = os.environ.get("BYTEDANCE_ACCESS_KEY")

Comment on lines +145 to +156
async def _listen(self):
ws = self._ws
if ws is None:
return
try:
async for message in ws:
if isinstance(message, (bytes, bytearray)):
self._handle_message(_v3.parse_response(bytes(message)))
except websockets.ConnectionClosedOK:
pass
except websockets.ConnectionClosedError as e:
self._emit_error_event(e, context="listen")

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle non-connection errors in the listener task.

_v3.parse_response and _handle_message can raise on a malformed or unexpected frame (gzip/JSON decode, unknown message type). That exception ends _listen with no log record. It then surfaces in close() at Line 215, where only asyncio.CancelledError is caught, so close() raises before self._ws.close() runs and the socket leaks.

Log the failure in the listener and treat any listener exception as terminal in close().

Proposed fix
         except websockets.ConnectionClosedOK:
             pass
         except websockets.ConnectionClosedError as e:
             self._emit_error_event(e, context="listen")
+        except (ValueError, KeyError, OSError) as e:
+            logger.exception("ByteDance STT listener failed")
+            self._emit_error_event(e, context="listen")

And in close():

             self._listen_task.cancel()
             try:
                 await self._listen_task
             except asyncio.CancelledError:
                 pass
+            except (ValueError, KeyError, OSError):
+                logger.exception("ByteDance STT listener terminated with an error")
             self._listen_task = None

Source: Coding guidelines

Comment on lines +79 to +85
async def _ensure_connection(self) -> websockets.ClientConnection:
if self._ws is not None:
return self._ws

async with self._connect_lock:
if self._ws is not None:
return self._ws

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

websockets 15.0.1 ClientConnection state attribute State.OPEN check

💡 Result:

In the websockets library (version 15.0.1), the state attribute on a ClientConnection object returns an enum value representing the connection state [1][2]. While you can programmatically check if the connection is in the OPEN state by comparing the attribute against the State.OPEN enum [3], the official documentation explicitly discourages this approach [1][4]. The state attribute is provided primarily for completeness and debugging purposes [1][2]. Instead of manually checking connection state, the library recommends the EAFP (Easier to Ask for Forgiveness than Permission) principle: call methods like recv or send and handle the ConnectionClosed exception when the connection is no longer usable [1][4]. You can import the necessary components from the library as follows: from websockets.asyncio.client import ClientConnection from websockets.protocol import State # Discouraged: checking state directly if connection.state is State.OPEN: pass # Recommended: try the operation and handle the exception try: await connection.send("hello") except ConnectionClosed: # Handle the closed connection pass The State enum includes four members: CONNECTING, OPEN, CLOSING, and CLOSED [3][5]. Because state transitions occur asynchronously, a direct check may lead to race conditions where the state changes immediately after your check, which is why handling exceptions remains the robust practice [1][6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
file=$(fd -t f 'tts\.py$' | grep '^plugins/bytedance/vision_agents/plugins/bytedance/tts\.py$' || true)
printf '%s\n' "$file"
sed -n '1,180p' "$file"
printf '\n--- dependency declarations ---\n'
rg -n -i 'websockets|bytedance|tts' pyproject.toml poetry.lock requirements*.txt uv.lock setup.cfg setup.py 2>/dev/null || true
printf '\n--- related symbols ---\n'
rg -n '_ensure_connection|_ws|_on_disconnected|stream_audio|ConnectionClosed|ClientConnection' plugins/bytedance/vision_agents/plugins/bytedance

Repository: GetStream/Vision-Agents

Length of output: 23678


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- tts.py lifecycle and receive code ---'
sed -n '145,215p' plugins/bytedance/vision_agents/plugins/bytedance/tts.py
printf '%s\n' '--- realtime.py connection handling ---'
sed -n '120,185p' plugins/bytedance/vision_agents/plugins/bytedance/realtime.py
sed -n '235,275p' plugins/bytedance/vision_agents/plugins/bytedance/realtime.py
printf '%s\n' '--- stt.py connection handling ---'
sed -n '85,165p' plugins/bytedance/vision_agents/plugins/bytedance/stt.py
sed -n '195,230p' plugins/bytedance/vision_agents/plugins/bytedance/stt.py
printf '%s\n' '--- tests and package metadata ---'
fd -t f . plugins/bytedance | sort | rg 'test|pyproject|tts'
rg -n -C 3 'websockets|dependencies|ConnectionClosed|_ensure_connection' plugins/bytedance

Repository: GetStream/Vision-Agents

Length of output: 22716


🏁 Script executed:

#!/bin/bash
set -eu
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
curl -fsSL https://raw.githubusercontent.com/python-websockets/websockets/15.0.1/src/websockets/asyncio/connection.py -o "$tmp/connection.py"
curl -fsSL https://raw.githubusercontent.com/python-websockets/websockets/15.0.1/src/websockets/protocol.py -o "$tmp/protocol.py"
curl -fsSL https://raw.githubusercontent.com/python-websockets/websockets/15.0.1/src/websockets/__init__.py -o "$tmp/init.py"
printf '%s\n' '--- ClientConnection state and send behavior ---'
rg -n -C 5 'class ClientConnection|def state|state:|async def send|ConnectionClosed' "$tmp/connection.py" "$tmp/protocol.py"
printf '%s\n' '--- public exports ---'
rg -n 'protocol|State|ClientConnection|ConnectionClosed' "$tmp/init.py"
printf '%s\n' '--- standalone stale-cache control-flow probe ---'
python3 - <<'PY'
class ClosedSocket:
    def __init__(self):
        self.send_calls = 0

    async def send(self, _message):
        self.send_calls += 1
        raise RuntimeError("ConnectionClosed")

async def ensure_connection(ws):
    if ws is not None:
        return ws
    return "new connection"

import asyncio
ws = ClosedSocket()
result = asyncio.run(ensure_connection(ws))
try:
    asyncio.run(result.send(b"request"))
except RuntimeError as error:
    print(f"cached object returned: {result is ws}")
    print(f"send failure: {error}")
    print(f"send calls: {result.send_calls}")
PY

Repository: GetStream/Vision-Agents

Length of output: 27600


Reset the cached connection after ConnectionClosed.

When the server closes an idle connection, _ws remains non-None. _ensure_connection then returns the closed socket, and subsequent stream_audio calls raise websockets.ConnectionClosed. Clear _ws and call _on_disconnected() when send or receive detects closure so the next call can reconnect. Do not rely only on state is State.OPEN; the websockets API recommends handling ConnectionClosed because state checks are racy.

Comment on lines +88 to +107
ws = await websockets.connect(
self._ws_url,
additional_headers=headers,
max_size=10 * 1024 * 1024,
)
await ws.send(
_v3.build_event_message(
_v3.MsgType.FULL_CLIENT_REQUEST,
_v3.EventType.START_CONNECTION,
payload=b"{}",
)
)
raw = await ws.recv()
raw_bytes = raw if isinstance(raw, bytes) else raw.encode()
started = _v3.parse_response(raw_bytes)
if started.event != _v3.EventType.CONNECTION_STARTED:
await ws.close()
raise RuntimeError(
f"ByteDance TTS handshake failed: {started.event} {started.payload}"
)

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Both WebSocket clients open connections without timeouts and leak the socket on setup failure. The STT and TTS clients duplicate the same connection-setup pattern: websockets.connect (and, for TTS, the handshake recv) waits without a bound, and a failure after the connection is established leaves the socket open.

  • plugins/bytedance/vision_agents/plugins/bytedance/tts.py#L88-L107: wrap websockets.connect and ws.recv() in asyncio.wait_for, and close ws on every failure path, not only the event-mismatch branch.
  • plugins/bytedance/vision_agents/plugins/bytedance/stt.py#L97-L117: wrap websockets.connect in asyncio.wait_for, and assign self._ws only after the config frame is sent so a failure closes the socket and does not leave _connection_ready unset.

Consider extracting the shared connect-and-handshake logic into a helper in _v3 or _auth so both clients get the same timeout and cleanup behavior.

📍 Affects 2 files
  • plugins/bytedance/vision_agents/plugins/bytedance/tts.py#L88-L107 (this comment)
  • plugins/bytedance/vision_agents/plugins/bytedance/stt.py#L97-L117

Comment on lines +160 to +168
async for message in ws:
if self._stop_event.is_set() or generation != self._generation:
return
if not isinstance(message, (bytes, bytearray)):
continue

parsed = _v3.parse_response(bytes(message))
if parsed.session_id is not None and parsed.session_id != session_id:
continue

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Frame demultiplexing drops audio when sessions overlap.

_receive_audio iterates the shared connection directly and discards any frame whose session_id does not match. If a second stream_audio call starts before the first generator finishes, whichever generator reads first consumes and drops the other session's audio frames. That audio is lost permanently.

Either serialize sessions with a lock, or read the socket in one background task and dispatch frames to per-session queues.

Comment on lines +189 to +191
async def stop_audio(self) -> None:
self._stop_event.set()
self._generation += 1

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.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

stop_audio does not cancel the session on the server.

stop_audio sets a local flag and bumps _generation. The server keeps synthesizing the remaining text and keeps sending TTS_RESPONSE frames. Two consequences:

  1. Provider usage continues to accrue after the interruption.
  2. The stale frames stay queued on the shared connection and are read by the next session's generator.

Track the active session_id and send FINISH_SESSION (or the cancel event, if _v3.EventType defines one) for it.

Proposed direction
     async def stop_audio(self) -> None:
         self._stop_event.set()
         self._generation += 1
+        ws, session_id = self._ws, self._active_session_id
+        if ws is not None and session_id is not None:
+            try:
+                await ws.send(
+                    _v3.build_event_message(
+                        _v3.MsgType.FULL_CLIENT_REQUEST,
+                        _v3.EventType.FINISH_SESSION,
+                        payload=b"{}",
+                        session_id=session_id,
+                    )
+                )
+            except websockets.ConnectionClosed:
+                pass
+        self._active_session_id = None

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