Skip to content

Commit 6ae5a65

Browse files
committed
feat(livekit): add agents integration
Add LiveKit Agents instrumentation, e2e VCR coverage, and nox/CI wiring for the livekit-agents matrix. Also preserve OpenAI async streaming speech responses when tracing raw response calls.
1 parent b1dc8f5 commit 6ae5a65

30 files changed

Lines changed: 44987 additions & 1644 deletions

.github/workflows/checks.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,25 @@ jobs:
113113
dir="$HOME/.cache/braintrust/temporal-test-server"
114114
mkdir -p "$dir"
115115
echo "BRAINTRUST_TEMPORAL_TEST_SERVER_DIR=$dir" >> "$GITHUB_ENV"
116+
- name: Cache LiveKit server binaries
117+
if: runner.os == 'Linux'
118+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
119+
with:
120+
# The LiveKit Agents nox session downloads a pinned standalone
121+
# livekit-server binary here when livekit-server is not already on
122+
# PATH. Caching avoids repeated GitHub release downloads across CI shards.
123+
path: ~/.cache/braintrust/livekit-server
124+
key: livekit-server-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('py/noxfile.py') }}
125+
restore-keys: |
126+
livekit-server-${{ runner.os }}-${{ runner.arch }}-
127+
- name: Configure LiveKit server cache dir
128+
if: runner.os == 'Linux'
129+
shell: bash
130+
run: |
131+
set -euo pipefail
132+
dir="$HOME/.cache/braintrust/livekit-server"
133+
mkdir -p "$dir"
134+
echo "BRAINTRUST_LIVEKIT_SERVER_DIR=$dir" >> "$GITHUB_ENV"
116135
- name: Run nox tests (shard ${{ matrix.shard }}/6)
117136
shell: bash
118137
run: |

py/noxfile.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@
1212

1313
import functools
1414
import glob
15+
import hashlib
1516
import os
1617
import pathlib
18+
import platform
1719
import re
20+
import shutil
1821
import sys
22+
import tarfile
1923
import tempfile
24+
import urllib.request
2025

2126
from packaging.version import Version
2227

@@ -46,6 +51,54 @@
4651
_PROJECT_DIR = str(pathlib.Path(__file__).parent)
4752

4853

54+
def _ensure_livekit_server(session: nox.Session) -> str:
55+
"""Ensure a standalone livekit-server binary is available for LiveKit e2e tests."""
56+
existing = shutil.which("livekit-server")
57+
if existing:
58+
return os.path.dirname(existing)
59+
60+
system = platform.system().lower()
61+
machine = platform.machine().lower()
62+
arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(machine)
63+
if arch is None:
64+
session.skip(f"No pinned livekit-server binary for architecture {machine!r}")
65+
66+
if system != "linux":
67+
session.skip(
68+
"No pinned standalone livekit-server release asset is available for this platform; "
69+
"install livekit-server on PATH to run LiveKit e2e tests locally"
70+
)
71+
72+
cache_root = pathlib.Path(os.environ.get("BRAINTRUST_LIVEKIT_SERVER_DIR", ".nox/livekit-server"))
73+
install_dir = cache_root / LIVEKIT_SERVER_VERSION / f"{system}_{arch}"
74+
binary = install_dir / "livekit-server"
75+
if binary.exists():
76+
return str(install_dir.resolve())
77+
78+
install_dir.mkdir(parents=True, exist_ok=True)
79+
asset = f"livekit_{LIVEKIT_SERVER_VERSION}_{system}_{arch}.tar.gz"
80+
url = f"https://github.com/livekit/livekit/releases/download/v{LIVEKIT_SERVER_VERSION}/{asset}"
81+
archive = install_dir / asset
82+
expected_sha256 = LIVEKIT_SERVER_SHA256[f"{system}_{arch}"]
83+
session.log(f"Downloading {url}")
84+
urllib.request.urlretrieve(url, archive) # noqa: S310 - pinned public release asset for test infra.
85+
actual_sha256 = hashlib.sha256(archive.read_bytes()).hexdigest()
86+
if actual_sha256 != expected_sha256:
87+
archive.unlink(missing_ok=True)
88+
session.error(
89+
f"SHA256 mismatch for {asset}: expected {expected_sha256}, got {actual_sha256}. "
90+
"Refusing to extract downloaded livekit-server archive."
91+
)
92+
with tarfile.open(archive, "r:gz") as tar:
93+
if sys.version_info >= (3, 12):
94+
tar.extract("livekit-server", path=install_dir, filter="data")
95+
else:
96+
tar.extract("livekit-server", path=install_dir) # noqa: S202
97+
binary.chmod(0o755)
98+
archive.unlink()
99+
return str(install_dir.resolve())
100+
101+
49102
def _install_group_locked(session: nox.Session, *group_names: str) -> None:
50103
"""Install deps from one or more dependency groups using the lockfile.
51104
@@ -128,6 +181,11 @@ def _pinned_python_version():
128181

129182
SILENT_INSTALLS = True
130183
LATEST = "latest"
184+
LIVEKIT_SERVER_VERSION = "1.11.0"
185+
LIVEKIT_SERVER_SHA256 = {
186+
"linux_amd64": "3e76ed51ecdfefc3005e4257095dccd1ccc8f8b77517d9f2353de7906650b68b",
187+
"linux_arm64": "6741466bc12e75544338292ab2c1c02c02f3c626568230b5548fffc53e5a87ff",
188+
}
131189
ERROR_CODES = tuple(range(1, 256))
132190
INTERNAL_TEST_FLAGS = {"--wheel", "--disable-vcr"}
133191
GENERATED_LINT_EXCLUDES = {
@@ -266,6 +324,27 @@ def test_agno(session, version):
266324
_run_tests(session, f"{INTEGRATION_DIR}/agno/test_workflow.py", version=version)
267325

268326

327+
LIVEKIT_AGENTS_VERSIONS = _get_matrix_versions("livekit-agents")
328+
329+
330+
@nox.session()
331+
@nox.parametrize("version", LIVEKIT_AGENTS_VERSIONS, ids=LIVEKIT_AGENTS_VERSIONS)
332+
def test_livekit_agents(session, version):
333+
if sys.version_info >= (3, 14):
334+
session.skip("LiveKit Agents Silero VAD depends on onnxruntime, which does not ship Python 3.14 wheels")
335+
_install_test_deps(session)
336+
_install_matrix_dep(session, "livekit-agents", version)
337+
_install_group_locked(session, "test-livekit-agents")
338+
livekit_server_dir = _ensure_livekit_server(session)
339+
env = {
340+
"LIVEKIT_URL": os.environ.get("LIVEKIT_URL", "ws://localhost:7880"),
341+
"LIVEKIT_API_KEY": os.environ.get("LIVEKIT_API_KEY", "devkey"),
342+
"LIVEKIT_API_SECRET": os.environ.get("LIVEKIT_API_SECRET", "secret"),
343+
"PATH": f"{livekit_server_dir}{os.pathsep}{os.environ.get('PATH', '')}",
344+
}
345+
_run_tests(session, f"{INTEGRATION_DIR}/livekit_agents/test_livekit_agents.py", version=version, env=env)
346+
347+
269348
STRANDS_VERSIONS = _get_matrix_versions("strands-agents")
270349

271350

py/pyproject.toml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ test-langchain = [
157157
"langgraph==1.1.6",
158158
]
159159

160+
test-livekit-agents = [
161+
{include-group = "test"},
162+
"livekit-plugins-openai",
163+
"livekit-plugins-silero",
164+
# livekit-agents 1.3.x imports opentelemetry.sdk._logs.LogData, removed in newer SDKs.
165+
# This is a LiveKit runtime compatibility constraint, not a Braintrust OTel integration dependency.
166+
"opentelemetry-sdk<1.39",
167+
]
168+
160169
test-crewai = [
161170
{include-group = "test"},
162171
# CrewAI's no-network smoke test forces the LiteLLM fallback path via
@@ -212,6 +221,8 @@ lint = [
212221
"google-adk",
213222
"google-genai",
214223
"litellm>=1.83.10",
224+
"livekit-agents",
225+
"livekit-plugins-openai",
215226
"mistralai",
216227
"openai",
217228
"openai-agents",
@@ -263,12 +274,14 @@ conflicts = [
263274
{group = "test-agentscope"},
264275
{group = "test-strands"},
265276
{group = "test-langchain"},
277+
{group = "test-livekit-agents"},
266278
{group = "lint"},
267279
],
268-
# opentelemetry-sdk version conflicts (google-adk vs logfire).
280+
# opentelemetry-sdk version conflicts (google-adk/livekit vs logfire).
269281
[
270282
{group = "lint"},
271283
{group = "test-pydantic-ai-logfire"},
284+
{group = "test-livekit-agents"},
272285
],
273286
]
274287

@@ -309,6 +322,10 @@ latest = "openai-agents==0.17.1"
309322
latest = "litellm==1.83.14"
310323
"1.74.0" = "litellm==1.74.0"
311324

325+
[tool.braintrust.matrix.livekit-agents]
326+
latest = "livekit-agents==1.3.6"
327+
"1.3.1" = "livekit-agents==1.3.1"
328+
312329
[tool.braintrust.matrix.claude-agent-sdk]
313330
latest = "claude-agent-sdk==0.1.80"
314331
"0.1.10" = "claude-agent-sdk==0.1.10"
@@ -427,6 +444,7 @@ dspy = ["dspy"]
427444
google_genai = ["google-genai"]
428445
langchain = ["langchain-core"]
429446
litellm = ["litellm"]
447+
livekit_agents = ["livekit-agents"]
430448
llamaindex = ["llama-index-core"]
431449
mistral = ["mistralai"]
432450
openai = ["openai"]
@@ -449,6 +467,7 @@ dspy = "dspy"
449467
google-adk = "google.adk"
450468
google-genai = "google.genai"
451469
litellm = "litellm"
470+
livekit-agents = "livekit.agents"
452471
mistralai = "mistralai"
453472
openai = "openai"
454473
openai-agents = "agents"

py/scripts/bump-livekit-server.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env python3
2+
"""Update pinned livekit-server version and archive SHA256 hashes in noxfile.py."""
3+
4+
import argparse
5+
import hashlib
6+
import pathlib
7+
import re
8+
import urllib.request
9+
10+
11+
PROJECT_DIR = pathlib.Path(__file__).resolve().parents[1]
12+
NOXFILE = PROJECT_DIR / "noxfile.py"
13+
PLATFORMS = ("linux_amd64", "linux_arm64")
14+
15+
16+
def _asset_url(version: str, platform: str) -> str:
17+
system, arch = platform.split("_", 1)
18+
asset = f"livekit_{version}_{system}_{arch}.tar.gz"
19+
return f"https://github.com/livekit/livekit/releases/download/v{version}/{asset}"
20+
21+
22+
def _sha256_url(url: str) -> str:
23+
with urllib.request.urlopen(url, timeout=120) as response: # noqa: S310 - pinned GitHub release URL.
24+
digest = hashlib.sha256()
25+
while chunk := response.read(1024 * 1024):
26+
digest.update(chunk)
27+
return digest.hexdigest()
28+
29+
30+
def _replace_constants(contents: str, version: str, hashes: dict[str, str]) -> str:
31+
contents = re.sub(
32+
r'LIVEKIT_SERVER_VERSION = "[^"]+"',
33+
f'LIVEKIT_SERVER_VERSION = "{version}"',
34+
contents,
35+
count=1,
36+
)
37+
sha_block = (
38+
"LIVEKIT_SERVER_SHA256 = {\n"
39+
+ "".join(f' "{platform}": "{hashes[platform]}",\n' for platform in PLATFORMS)
40+
+ "}"
41+
)
42+
contents = re.sub(
43+
r'LIVEKIT_SERVER_SHA256 = \{\n(?: "[^"]+": "[0-9a-f]+",\n)+\}',
44+
sha_block,
45+
contents,
46+
count=1,
47+
)
48+
return contents
49+
50+
51+
def main() -> None:
52+
parser = argparse.ArgumentParser(description=__doc__)
53+
parser.add_argument("version", help="livekit-server version, e.g. 1.11.1")
54+
parser.add_argument("--dry-run", action="store_true", help="print the new constants without editing noxfile.py")
55+
args = parser.parse_args()
56+
57+
hashes = {}
58+
for platform in PLATFORMS:
59+
url = _asset_url(args.version, platform)
60+
print(f"Downloading {url}")
61+
hashes[platform] = _sha256_url(url)
62+
print(f"{platform}: {hashes[platform]}")
63+
64+
contents = NOXFILE.read_text()
65+
updated = _replace_constants(contents, args.version, hashes)
66+
67+
if args.dry_run:
68+
print()
69+
print(f'LIVEKIT_SERVER_VERSION = "{args.version}"')
70+
print("LIVEKIT_SERVER_SHA256 = {")
71+
for platform in PLATFORMS:
72+
print(f' "{platform}": "{hashes[platform]}",')
73+
print("}")
74+
return
75+
76+
if updated == contents:
77+
raise SystemExit("noxfile.py did not change; constants may not have matched expected format")
78+
79+
NOXFILE.write_text(updated)
80+
print(f"Updated {NOXFILE.relative_to(PROJECT_DIR)}")
81+
82+
83+
if __name__ == "__main__":
84+
main()

py/src/braintrust/auto.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
GoogleGenAIIntegration,
2121
LangChainIntegration,
2222
LiteLLMIntegration,
23+
LiveKitAgentsIntegration,
2324
LlamaIndexIntegration,
2425
MistralIntegration,
2526
OpenAIAgentsIntegration,
@@ -70,6 +71,7 @@ def auto_instrument(
7071
crewai: bool = True,
7172
strands: bool = True,
7273
temporal: bool = True,
74+
livekit_agents: bool = True,
7375
) -> dict[str, bool]:
7476
"""
7577
Auto-instrument supported AI/ML libraries for Braintrust tracing.
@@ -101,6 +103,7 @@ def auto_instrument(
101103
crewai: Enable CrewAI instrumentation (default: True)
102104
strands: Enable Strands Agents instrumentation (default: True)
103105
temporal: Enable Temporal instrumentation (default: True)
106+
livekit_agents: Enable LiveKit Agents instrumentation (default: True)
104107
105108
Returns:
106109
Dict mapping integration name to whether it was successfully instrumented.
@@ -188,6 +191,8 @@ def auto_instrument(
188191
results["strands"] = _instrument_integration(StrandsIntegration)
189192
if temporal:
190193
results["temporal"] = _instrument_integration(TemporalIntegration)
194+
if livekit_agents:
195+
results["livekit_agents"] = _instrument_integration(LiveKitAgentsIntegration)
191196

192197
return results
193198

py/src/braintrust/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .google_genai import GoogleGenAIIntegration
1111
from .langchain import LangChainIntegration
1212
from .litellm import LiteLLMIntegration
13+
from .livekit_agents import LiveKitAgentsIntegration
1314
from .llamaindex import LlamaIndexIntegration
1415
from .mistral import MistralIntegration
1516
from .openai import OpenAIIntegration
@@ -32,6 +33,7 @@
3233
"DSPyIntegration",
3334
"GoogleGenAIIntegration",
3435
"LiteLLMIntegration",
36+
"LiveKitAgentsIntegration",
3537
"LangChainIntegration",
3638
"LlamaIndexIntegration",
3739
"MistralIntegration",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Test auto_instrument for LiveKit Agents."""
2+
3+
import inspect
4+
5+
from braintrust.auto import auto_instrument
6+
from wrapt import FunctionWrapper
7+
8+
9+
def _is_braintrust_wrapped(target, attr: str) -> bool:
10+
return isinstance(inspect.getattr_static(target, attr, None), FunctionWrapper)
11+
12+
13+
# Import the provider classes before auto-instrumentation to verify setup handles
14+
# normal user import order in a fresh process.
15+
from livekit.agents import AgentSession # noqa: E402
16+
from livekit.agents.inference.llm import LLMStream # noqa: E402
17+
from livekit.agents.stt import STT # noqa: E402
18+
from livekit.agents.tts import TTS # noqa: E402
19+
from livekit.agents.voice import generation # noqa: E402
20+
from livekit.agents.voice.io import AudioOutput # noqa: E402
21+
22+
23+
assert not _is_braintrust_wrapped(AgentSession, "run")
24+
assert not _is_braintrust_wrapped(AgentSession, "_on_audio_output_changed")
25+
assert not _is_braintrust_wrapped(AgentSession, "_update_user_state")
26+
assert not isinstance(generation._execute_tools_task, FunctionWrapper)
27+
assert not _is_braintrust_wrapped(LLMStream, "_run")
28+
assert not _is_braintrust_wrapped(STT, "recognize")
29+
assert not _is_braintrust_wrapped(TTS, "synthesize")
30+
assert not _is_braintrust_wrapped(AudioOutput, "capture_frame")
31+
32+
results = auto_instrument()
33+
assert results.get("livekit_agents") is True
34+
assert _is_braintrust_wrapped(AgentSession, "run")
35+
assert _is_braintrust_wrapped(AgentSession, "_on_audio_output_changed")
36+
assert _is_braintrust_wrapped(AgentSession, "_update_user_state")
37+
assert isinstance(generation._execute_tools_task, FunctionWrapper)
38+
assert _is_braintrust_wrapped(LLMStream, "_run")
39+
assert _is_braintrust_wrapped(STT, "recognize")
40+
assert _is_braintrust_wrapped(TTS, "synthesize")
41+
assert _is_braintrust_wrapped(AudioOutput, "capture_frame")
42+
43+
# Idempotent.
44+
results2 = auto_instrument()
45+
assert results2.get("livekit_agents") is True
46+
47+
print("SUCCESS")

0 commit comments

Comments
 (0)