chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:38 +08:00
commit 309eedff77
1201 changed files with 210635 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
* @livekit/agent-devs
+107
View File
@@ -0,0 +1,107 @@
name: Bug Report
description: Report a bug with the framework
labels: bug
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this report! Please add as much detail as possible so we can help you.
- type: textarea
id: bug_description
attributes:
label: Bug Description
description: "A clear and detailed description of what the bug and current behavior is."
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: Expected Behavior
description: "What is the expected behavior?"
validations:
required: true
- type: textarea
id: reproduction_steps
attributes:
label: Reproduction Steps
description: "Steps to reproduce this behavior, preferably with a minimal reproducible example"
value: |
1.
2.
3.
...
- Sample code snippet, or a GitHub Gist link -
render: bash
validations:
required: true
- type: markdown
attributes:
value: "Environment Details"
- type: input
id: operating_system
attributes:
label: Operating System
placeholder: "e.g. Windows 11, macOS Tahoe"
validations:
required: true
- type: input
id: pipeline_setup
attributes:
label: Models Used
description: "Your STT/LLM/TTS or RealtimeModel setup"
placeholder: "e.g. Deepgram Nova-3, OpenAI GPT-4.1, Cartesia Sonic-2"
validations:
required: false
- type: textarea
id: package_versions
attributes:
label: Package Versions
description: "Relevant package versions"
placeholder: |
livekit==
livekit-agents==
livekit-api==
...
render: bash
validations:
required: true
- type: textarea
id: problematic_ids
attributes:
label: Session/Room/Call IDs
description: "LiveKit Cloud related IDs to track particular sessions or SIP calls."
placeholder: |
roomID: RM_
SIP Call ID: SCL_
sessionID: ...
- type: textarea
id: proposed_solution
attributes:
label: Proposed Solution
description: "Suggest a solution to the bug if possible."
render: python
- type: textarea
id: additional_context
attributes:
label: Additional Context
description: "Add any other context about the problem."
- type: textarea
id: file_attachments
attributes:
label: Screenshots and Recordings
description: "If applicable, add screenshots or recording links to help explain your problem."
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: LiveKit Developer Community
url: https://community.livekit.io/
about: Ask and answer questions
@@ -0,0 +1,43 @@
name: Feature Request
description: Suggest a new feature or capability
labels: enhancement
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this request! Please add as much detail as possible so we can help you.
Before submitting, please check to ensure that a similar issue doesnt already exist to avoid duplicates.
- type: dropdown
attributes:
label: Feature Type
description: "How important is this feature?"
options:
- Nice to have
- Would make my life easier
- I cannot use LiveKit without it
validations:
required: true
- type: textarea
id: feature_description
attributes:
label: Feature Description
description: "A clear and concise description of what you want to happen and why."
validations:
required: true
- type: textarea
id: alternative_workarounds
attributes:
label: Workarounds / Alternatives
description: "A description of any alternative solutions or workarounds you've considered/implemented."
- type: textarea
id: additional_context
attributes:
label: Additional Context
description: "Add any other context or screenshots about the feature request here."
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Fetch PyPI download stats for all LiveKit packages and show popularity/growth."""
import concurrent.futures
import json
import pathlib
import sys
import urllib.request
from datetime import datetime, timedelta
def _read_pypi_name(plugin_dir: pathlib.Path) -> str:
"""Read the actual PyPI package name from pyproject.toml or setup.py."""
import re
pyproject = plugin_dir / "pyproject.toml"
if pyproject.exists():
m = re.search(r'^name\s*=\s*"([^"]+)"', pyproject.read_text(), re.MULTILINE)
if m:
return m.group(1)
setup_py = plugin_dir / "setup.py"
if setup_py.exists():
m = re.search(r'name\s*=\s*"([^"]+)"', setup_py.read_text())
if m:
return m.group(1)
return plugin_dir.name
def _iter_plugin_names() -> list[str]:
plugins_root = pathlib.Path(__file__).parent.parent / "livekit-plugins"
names = []
for d in sorted(plugins_root.glob("livekit-plugins-*")):
if d.is_dir():
names.append(_read_pypi_name(d))
return names
def _fetch_daily(package: str, retries: int = 3) -> tuple[str, dict[str, int]]:
"""Fetch daily download counts excluding mirrors, with retries."""
import time
for attempt in range(retries):
try:
req = urllib.request.Request(
f"https://pypistats.org/api/packages/{package}/overall?mirrors=false",
headers={"User-Agent": "livekit-stats/1.0"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
daily = {row["date"]: row["downloads"] for row in data.get("data", [])}
if daily:
return package, daily
except Exception:
pass
if attempt < retries - 1:
time.sleep(1)
return package, {}
def _sum_range(daily: dict[str, int], start: str, end: str) -> int:
return sum(count for date, count in daily.items() if start <= date <= end)
def _growth_pct(current: int, previous: int) -> float | None:
if previous <= 0:
return None
return (current - previous) / previous * 100
def _fmt_growth(val: float | None) -> str:
return f"{val:+.0f}%" if val is not None else ""
def _fmt_downloads(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.0f}K"
return str(n)
def _short_name(pkg: str) -> str:
if pkg.startswith("livekit-plugins-"):
return "plugins-" + pkg[len("livekit-plugins-"):]
return pkg
def _fetch_all() -> list[tuple[str, int, int, int, float | None, float | None, float | None]]:
packages = [
"livekit",
"livekit-api",
"livekit-protocol",
"livekit-agents",
"livekit-blingfire",
"livekit-blockguard",
"livekit-durable",
] + _iter_plugin_names()
today = datetime.now().date()
wow_cur_end = (today - timedelta(days=1)).isoformat()
wow_cur_start = (today - timedelta(days=7)).isoformat()
wow_prev_start = (today - timedelta(days=14)).isoformat()
wow_prev_end = (today - timedelta(days=8)).isoformat()
mom_cur_start = (today - timedelta(days=30)).isoformat()
mom_prev_start = (today - timedelta(days=60)).isoformat()
mom_prev_end = (today - timedelta(days=31)).isoformat()
qoq_cur_start = (today - timedelta(days=90)).isoformat()
qoq_prev_start = (today - timedelta(days=180)).isoformat()
qoq_prev_end = (today - timedelta(days=91)).isoformat()
print(f"Fetching stats for {len(packages)} packages...", file=sys.stderr)
all_daily: dict[str, dict[str, int]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(_fetch_daily, pkg): pkg for pkg in packages}
for fut in concurrent.futures.as_completed(futures):
pkg, daily = fut.result()
all_daily[pkg] = daily
results = []
for pkg in packages:
daily = all_daily[pkg]
last_7d = _sum_range(daily, wow_cur_start, wow_cur_end)
prev_7d = _sum_range(daily, wow_prev_start, wow_prev_end)
last_30d = _sum_range(daily, mom_cur_start, wow_cur_end)
prev_30d = _sum_range(daily, mom_prev_start, mom_prev_end)
last_90d = _sum_range(daily, qoq_cur_start, wow_cur_end)
prev_90d = _sum_range(daily, qoq_prev_start, qoq_prev_end)
results.append((
pkg, last_7d, last_30d, last_90d,
_growth_pct(last_7d, prev_7d),
_growth_pct(last_30d, prev_30d),
_growth_pct(last_90d, prev_90d),
))
results.sort(key=lambda r: r[1], reverse=True)
return results
def main() -> None:
results = _fetch_all()
today = datetime.now().date()
w = 24 # name column width
print(f"PyPI Stats (no mirrors) — {today}")
print(f"{'Package':<{w}} {'7d':>6} {'30d':>6} {'90d':>6} {'WoW':>5} {'MoM':>5} {'QoQ':>5}")
print("-" * (w + 38))
for pkg, last_7d, last_30d, last_90d, wow, mom, qoq in results:
name = _short_name(pkg)
if len(name) > w:
name = name[: w - 1] + ""
print(
f"{name:<{w}} {_fmt_downloads(last_7d):>6} {_fmt_downloads(last_30d):>6}"
f" {_fmt_downloads(last_90d):>6} {_fmt_growth(wow):>5} {_fmt_growth(mom):>5}"
f" {_fmt_growth(qoq):>5}"
)
for label, idx, min_prev in [("WoW", 4, 500), ("MoM", 5, 2000), ("QoQ", 6, 5000)]:
prev_idx = {4: 1, 5: 2, 6: 3} # map growth idx -> current period volume field
growers = [
(r[0], r[idx]) for r in results
if r[idx] is not None and r[idx] > 0 and r[prev_idx[idx]] >= min_prev
]
growers.sort(key=lambda x: x[1], reverse=True)
if growers:
print(f"\nFastest growing ({label}):")
for pkg, g in growers[:10]:
print(f" {_short_name(pkg):<{w}} {g:+.0f}%")
if __name__ == "__main__":
main()
+219
View File
@@ -0,0 +1,219 @@
import pathlib
import re
import click
from packaging.version import Version
import colorama
from typing import Dict
colorama.init()
def _iter_plugin_dirs(plugins_root: pathlib.Path) -> list[pathlib.Path]:
"""Return all plugin directories."""
return list(plugins_root.glob("livekit-plugins-*"))
def _read_pypi_name(pdir: pathlib.Path) -> str:
"""Read the actual PyPI package name from pyproject.toml or setup.py."""
pyproject = pdir / "pyproject.toml"
if pyproject.exists():
m = re.search(r'^name\s*=\s*"([^"]+)"', pyproject.read_text(), re.MULTILINE)
if m:
return m.group(1)
setup_py = pdir / "setup.py"
if setup_py.exists():
m = re.search(r'name\s*=\s*"([^"]+)"', setup_py.read_text())
if m:
return m.group(1)
return pdir.name
def _esc(*codes: int) -> str:
return "\033[" + ";".join(str(c) for c in codes) + "m"
def read_version(f: pathlib.Path) -> str:
"""Read __version__ = \"X.Y.Z\" from a Python file."""
text = f.read_text()
m = re.search(r'__version__\s*=\s*[\'"]([^\'"]+)[\'"]', text)
if not m:
raise ValueError(f"could not find __version__ in {f}")
return m.group(1)
def write_new_version(f: pathlib.Path, new_version: str) -> None:
"""Substitute a new __version__ = \"X.Y.Z\" in place of the existing one."""
text = f.read_text()
new_text = re.sub(
r'__version__\s*=\s*[\'"][^\'"]*[\'"]',
f'__version__ = "{new_version}"',
text,
count=1
)
f.write_text(new_text)
def bump_version(cur: str, bump_type: str) -> str:
v = Version(cur)
if bump_type == "release":
return v.base_version
if bump_type == "patch":
return f"{v.major}.{v.minor}.{v.micro + 1}"
if bump_type == "minor":
return f"{v.major}.{v.minor + 1}.0"
if bump_type == "major":
return f"{v.major + 1}.0.0"
raise ValueError(f"unknown bump type: {bump_type}")
def bump_prerelease(cur: str, bump_type: str) -> str:
v = Version(cur)
base = v.base_version
if bump_type == "dev":
next_dev = (v.dev + 1) if v.dev is not None else 0
return f"{base}.dev{next_dev}"
elif bump_type == "rc":
if v.pre and v.pre[0] == "rc":
next_rc = v.pre[1] + 1
else:
next_rc = 1
return f"{base}.rc{next_rc}"
else:
raise ValueError(f"unknown prerelease bump type: {bump_type}")
def update_plugins_pyproject_agents_version(new_agents_version: str) -> None:
plugins_root = pathlib.Path("livekit-plugins")
for pdir in _iter_plugin_dirs(plugins_root):
pyproject = pdir / "pyproject.toml"
if pyproject.exists():
old_text = pyproject.read_text()
pattern = r'("livekit-agents(?:\[.*?\])?\s*[=><!~]+\s*)([\w\.\-]+)(?=")'
def replacer(m: re.Match) -> str:
return f"{m.group(1)}{new_agents_version}"
new_text = re.sub(pattern, replacer, old_text)
if new_text != old_text:
pyproject.write_text(new_text)
print(f"Updated pyproject.toml in {pdir.name} to use livekit-agents {new_agents_version}")
def update_agents_pyproject_optional_dependencies(plugin_versions: Dict[str, str]) -> None:
agents_pyproject = pathlib.Path("livekit-agents/pyproject.toml")
if not agents_pyproject.exists():
print("Warning: livekit-agents/pyproject.toml not found")
return
old_text = agents_pyproject.read_text()
new_text = old_text
for pypi_name, new_version in plugin_versions.items():
if pypi_name.startswith("livekit-plugins-"):
# Match any dep key that references this PyPI package name
pattern = rf'(\w[\w-]*\s*=\s*\["{re.escape(pypi_name)}>=)([\w\.\-]+)(\"])'
def replacer(m: re.Match, _new_version=new_version) -> str:
return f"{m.group(1)}{_new_version}{m.group(3)}"
updated_text = re.sub(pattern, replacer, new_text)
if updated_text != new_text:
new_text = updated_text
print(f"Updated optional dependency {pypi_name} to version {new_version}")
if new_text != old_text:
agents_pyproject.write_text(new_text)
print("Updated livekit-agents/pyproject.toml optional-dependencies")
def update_versions(bump_type: str) -> None:
agents_ver = pathlib.Path("livekit-agents/livekit/agents/version.py")
plugins_root = pathlib.Path("livekit-plugins")
new_agents_version = None
plugin_versions = {}
if agents_ver.exists():
cur = read_version(agents_ver)
new = bump_version(cur, bump_type)
print(f"livekit-agents: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}")
write_new_version(agents_ver, new)
new_agents_version = new
else:
print("Warning: No version.py found for livekit-agents.")
for pdir in _iter_plugin_dirs(plugins_root):
vf = pdir / "livekit" / "plugins" / pdir.name.split("livekit-plugins-")[1].replace("-", "_") / "version.py"
pypi_name = _read_pypi_name(pdir)
if vf.exists():
cur = read_version(vf)
new = bump_version(cur, bump_type)
print(f"{pypi_name}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}")
write_new_version(vf, new)
plugin_versions[pypi_name] = new
else:
print(f"Warning: version.py not found for {pypi_name} at {vf}")
if new_agents_version:
update_plugins_pyproject_agents_version(new_agents_version)
if plugin_versions:
update_agents_pyproject_optional_dependencies(plugin_versions)
def update_prerelease(prerelease_type: str) -> None:
agents_ver = pathlib.Path("livekit-agents/livekit/agents/version.py")
plugins_root = pathlib.Path("livekit-plugins")
new_agents_version = None
plugin_versions = {}
if agents_ver.exists():
cur = read_version(agents_ver)
new = bump_prerelease(cur, prerelease_type)
print(f"livekit-agents: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}")
write_new_version(agents_ver, new)
new_agents_version = new
else:
print("Warning: No version.py for livekit-agents.")
for pdir in _iter_plugin_dirs(plugins_root):
vf = pdir / "livekit" / "plugins" / pdir.name.split("livekit-plugins-")[1].replace("-", "_") / "version.py"
pypi_name = _read_pypi_name(pdir)
if vf.exists():
cur = read_version(vf)
new_v = bump_prerelease(cur, prerelease_type)
print(f"{pypi_name}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new_v}{_esc(0)}")
write_new_version(vf, new_v)
plugin_versions[pypi_name] = new_v
else:
print(f"Warning: version.py not found for {pypi_name} at {vf}")
if new_agents_version:
update_plugins_pyproject_agents_version(new_agents_version)
if plugin_versions:
update_agents_pyproject_optional_dependencies(plugin_versions)
@click.command("bump")
@click.option(
"--pre",
type=click.Choice(["rc", "dev", "none"]),
default="none",
help="Pre-release type. Use 'none' for normal bump, or 'rc'/'dev' for pre-release."
)
@click.option(
"--bump-type",
type=click.Choice(["patch", "minor", "major", "release"]),
default="patch",
help="Type of version bump to apply to every package. Use 'release' to strip pre-release suffixes. Defaults to patch."
)
def bump(pre: str, bump_type: str) -> None:
"""
Single command to do either normal or pre-release bumps.
For a normal release (with --pre=none), every package is bumped with the specified
--bump-type. For pre-release bumps (--pre=rc or --pre=dev), it updates the current
versions to a new RC or DEV version. In both cases, plugin pyproject.toml references
for 'livekit-agents' will be updated if that version changes.
"""
if pre == "none":
update_versions(bump_type)
else:
update_prerelease(pre)
if __name__ == "__main__":
bump()
+25
View File
@@ -0,0 +1,25 @@
# .github/workflows/assign-reviewers.yml
name: Auto-assign reviewers
permissions:
contents: read
pull-requests: write
on:
pull_request:
types: [opened, reopened, ready_for_review]
branches: [main]
jobs:
assign:
if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.REVIEW_TOKEN }}
script: |
await github.rest.pulls.requestReviewers({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
team_reviewers: ['agent-devs']
});
+161
View File
@@ -0,0 +1,161 @@
name: Build package
permissions:
contents: read
actions: write
on:
workflow_call:
inputs:
package:
required: true
type: string
artifact_name:
required: true
type: string
workflow_dispatch:
inputs:
package:
description: "Name of the package to build"
required: true
default: "livekit-agents"
artifact_name:
description: "Artifact name for the distribution package"
required: true
default: "build-artifact"
jobs:
build_plugins:
runs-on: ubuntu-latest
if: inputs.package != 'livekit-blockguard' && inputs.package != 'livekit-durable'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
submodules: true
lfs: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Build package
run: uv build --package ${{inputs.package}}
- name: Upload distribution package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ inputs.artifact_name }}
path: dist/
build_durable:
if: inputs.package == 'livekit-durable'
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
archs: x86_64
- os: namespace-profile-default-arm64
archs: aarch64
- os: windows-latest
archs: AMD64
- os: macos-latest
archs: x86_64 arm64
defaults:
run:
working-directory: livekit-plugins/livekit-durable
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.11"
- name: Install cibuildwheel
run: |
python -m pip install --upgrade pip
pip install cibuildwheel
- name: Build wheels
run: cibuildwheel --output-dir dist
env:
CIBW_BUILD_VERBOSITY: 3
CIBW_ARCHS: ${{ matrix.archs }}
- name: Upload distribution package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ inputs.artifact_name }}-${{ matrix.os }}
path: livekit-plugins/livekit-durable/dist/
build_blockguard:
if: inputs.package == 'livekit-blockguard'
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
archs: x86_64
- os: namespace-profile-default-arm64
archs: aarch64
- os: windows-latest
archs: AMD64
- os: macos-latest
archs: x86_64 arm64
defaults:
run:
working-directory: livekit-plugins/livekit-blockguard
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.11"
- name: Install cibuildwheel
run: |
python -m pip install --upgrade pip
pip install cibuildwheel
- name: Build wheels
run: cibuildwheel --output-dir dist
env:
CIBW_BUILD_VERBOSITY: 3
CIBW_ARCHS: ${{ matrix.archs }}
- name: Upload distribution package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ inputs.artifact_name }}-${{ matrix.os }}
path: livekit-plugins/livekit-blockguard/dist/
merge_artifacts:
if: ${{ always() && (inputs.package == 'livekit-blockguard' || inputs.package == 'livekit-durable') }}
runs-on: ubuntu-latest
needs: [build_blockguard, build_durable]
steps:
- name: Download all platform wheels
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: ${{ inputs.artifact_name }}-*
path: all_dists
merge-multiple: true
- run: ls -R all_dists
- name: Upload unified wheel artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ inputs.artifact_name }}
path: all_dists/
+78
View File
@@ -0,0 +1,78 @@
name: CI
on:
push:
branches: [main, 0.x]
paths-ignore:
- '**/*.md'
pull_request:
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.10"
- name: Install the project
run: uv sync --all-extras --dev
- name: Ruff
run: uv run ruff check --output-format=github .
continue-on-error: true
id: ruff-check
- name: Check format
run: uv run ruff format --check .
continue-on-error: true
id: ruff-format
- name: Report ruff errors
if: steps.ruff-check.outcome == 'failure' || steps.ruff-format.outcome == 'failure'
run: |
echo "::error::Ruff checks failed. Please run 'make fix' locally to automatically fix formatting and linting issues."
exit 1
type-check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.13"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}
- name: Install the project
run: uv sync --all-extras --dev
- name: Cache mypy
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .mypy_cache
key: mypy-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}-${{ github.run_id }}
restore-keys: |
mypy-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}-
mypy-${{ matrix.python-version }}-
- name: Check Types
run: uv run python scripts/check_types.py
+168
View File
@@ -0,0 +1,168 @@
name: Deploy Examples
on:
# Manual runs deploy from the branch picked in the Actions "Use workflow
# from" selector — no input needed.
workflow_dispatch:
# Called by publish.yml after a release to deploy the published commit.
workflow_call:
inputs:
ref:
description: "Commit/branch/tag to deploy. Defaults to the triggering ref."
type: string
required: false
permissions:
contents: read
jobs:
deploy:
name: Deploy ${{ matrix.example }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
example: [healthcare, survey, frontdesk, drive-thru, inference, avatar, hotel_receptionist]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.ref || github.ref }}
# Fetch Git-LFS assets (e.g. examples/drive-thru/bg_noise.mp3) so the
# real binaries — not pointer files — get uploaded as the build context
# and copied into the image via `COPY . .`. This checkout authenticates
# with GITHUB_TOKEN, so its LFS fetch is allowed (unlike pip's anonymous
# git clone). Without this the agent crashes at runtime decoding a
# 131-byte pointer as audio.
lfs: true
- name: Install LiveKit CLI
run: |
curl -sSL https://get.livekit.io/cli | bash
lk --version
- name: Add LiveKit Cloud project
env:
LIVEKIT_URL: ${{ secrets.LIVEKIT_EXAMPLES_URL }}
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_EXAMPLES_API_KEY }}
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_EXAMPLES_API_SECRET }}
run: |
lk project add examples \
--url "$LIVEKIT_URL" \
--api-key "$LIVEKIT_API_KEY" \
--api-secret "$LIVEKIT_API_SECRET" \
--default
- name: Regenerate livekit.toml from playground.yaml
run: |
python3 -m pip install --quiet pyyaml
python3 <<'PY'
import yaml
from pathlib import Path
data = yaml.safe_load(Path("examples/playground.yaml").read_text())
subdomain = data["project"]["subdomain"]
slug = "${{ matrix.example }}"
entry = data["examples"][slug]
toml = (
"[project]\n"
f' subdomain = "{subdomain}"\n'
"\n"
"[agent]\n"
f' id = "{entry["agent_id"]}"\n'
)
Path(f"examples/{slug}/livekit.toml").write_text(toml)
print(f"wrote examples/{slug}/livekit.toml")
PY
- name: Pin livekit-* requirements to the deployed ref
working-directory: examples/${{ matrix.example }}
env:
# Build the agent against the code at the ref we're deploying,
# not the latest release on PyPI. Without this, a branch deploy
# would silently run against the published livekit-agents instead
# of the branch's own SDK changes. Mirror the checkout ref above,
# using ref_name so it's a plain branch/tag pip can resolve.
DEPLOY_REF: ${{ inputs.ref || github.ref_name }}
run: |
python3 <<'PY'
import os, re
from pathlib import Path
REF = os.environ["DEPLOY_REF"]
REPO = "git+https://github.com/livekit/agents.git"
REPO_ROOT = Path(os.environ["GITHUB_WORKSPACE"])
# The package name (and optional [extras]) at the start of a
# requirement, e.g. "livekit-agents[evals]>=1.5.7".
REQUIREMENT = re.compile(r"^(?P<name>[A-Za-z0-9._-]+)(?P<extras>\[.*\])?")
def monorepo_path(name):
"""Path of `name` within this repo, or None if it lives elsewhere.
We only pin packages that actually exist in the checkout. The
directory basename is the distribution name for every package
here (livekit-agents at the root, everything else under
livekit-plugins/). Anything not found (livekit rtc,
livekit-blingfire, livekit-local-inference, …) keeps its
PyPI pin.
"""
for rel in (name, f"livekit-plugins/{name}"):
if (REPO_ROOT / rel).is_dir():
return rel
return None
def pin_to_ref(line):
"""Repoint an in-repo requirement at the deployed git ref.
External requirements, comments and blanks pass through
untouched (the regex doesn't match a leading '#' or '').
"""
match = REQUIREMENT.match(line.strip())
path = monorepo_path(match["name"]) if match else None
if path is None:
return line
return f"{match['name']}{match['extras'] or ''} @ {REPO}@{REF}#subdirectory={path}"
requirements = Path("requirements.txt")
pinned = [pin_to_ref(line) for line in requirements.read_text().splitlines()]
requirements.write_text("\n".join(pinned) + "\n")
print(f"--- requirements.txt pinned to {REF} ---")
print(requirements.read_text())
PY
- name: Build secrets file
working-directory: examples/${{ matrix.example }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LEMONSLICE_API_KEY: ${{ secrets.LEMONSLICE_API_KEY }}
run: |
: > .env.deploy
# Register the agent under a deterministic name so the playground
# can dispatch to it explicitly. Matches the slug in playground.yaml.
echo "LIVEKIT_AGENT_NAME=${{ matrix.example }}" >> .env.deploy
case "${{ matrix.example }}" in
healthcare)
keys="OPENAI_API_KEY"
;;
avatar)
keys="LEMONSLICE_API_KEY"
;;
*)
keys=""
;;
esac
for k in $keys; do
val="${!k}"
if [ -n "$val" ]; then
echo "${k}=${val}" >> .env.deploy
fi
done
- name: Deploy ${{ matrix.example }}
working-directory: examples/${{ matrix.example }}
run: |
args=()
if [ -s .env.deploy ]; then
args+=(--secrets-file .env.deploy)
fi
lk agent deploy "${args[@]}" .
+83
View File
@@ -0,0 +1,83 @@
name: Weekly Python Download Stats
on:
schedule:
- cron: "0 14 * * 1" # every Monday at 2pm UTC
workflow_dispatch:
permissions:
contents: read
jobs:
stats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Generate report
run: python .github/download_stats.py > stats.txt
- name: Post to Slack
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CHANNEL: ${{ vars.DOWNLOAD_STATS_SLACK_CHANNEL }}
run: |
python3 << 'PYEOF'
import json, os, urllib.request
from datetime import datetime
token = os.environ["SLACK_BOT_TOKEN"]
channel = os.environ["CHANNEL"]
today = datetime.now().strftime("%Y-%m-%d")
def slack_api(method, payload):
req = urllib.request.Request(
f"https://slack.com/api/{method}",
data=json.dumps(payload).encode() if isinstance(payload, dict) else payload,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
if isinstance(payload, dict) else {"Authorization": f"Bearer {token}"},
)
return json.loads(urllib.request.urlopen(req).read())
# 1. Post summary message
resp = slack_api("chat.postMessage", {
"channel": channel,
"text": f":python: *Weekly Python PyPI Download Stats* — {today}",
})
assert resp["ok"], f"chat.postMessage failed: {resp.get('error')}"
thread_ts = resp["ts"]
# 2. Get upload URL
file_size = os.path.getsize("stats.txt")
form_data = f"filename=pypi-stats-{today}.txt&length={file_size}".encode()
req = urllib.request.Request(
"https://slack.com/api/files.getUploadURLExternal",
data=form_data,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
resp = json.loads(urllib.request.urlopen(req).read())
assert resp["ok"], f"getUploadURLExternal failed: {resp.get('error')}"
upload_url = resp["upload_url"]
file_id = resp["file_id"]
# 3. Upload file content
import subprocess
subprocess.run(["curl", "-s", "-X", "POST", upload_url, "-F", "file=@stats.txt"], check=True)
# 4. Complete upload in thread
resp = slack_api("files.completeUploadExternal", {
"files": [{"id": file_id, "title": f"pypi-stats-{today}.txt"}],
"channel_id": channel,
"thread_ts": thread_ts,
"initial_comment": "Full report",
})
assert resp["ok"], f"completeUploadExternal failed: {resp.get('error')}"
print("Posted to Slack successfully")
PYEOF
+83
View File
@@ -0,0 +1,83 @@
name: evals
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
actions: write
jobs:
evals:
# don't run tests for PRs on forks
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
strategy:
fail-fast: false
matrix:
example: [frontdesk, drive-thru]
runs-on: ubuntu-latest
name: ${{ matrix.example }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install the project
run: uv sync --all-extras --dev
- name: Setup Google credentials
if: matrix.example == 'google'
shell: bash
run: |
printf '%s' '${{ secrets.GOOGLE_CREDENTIALS_JSON }}' > tests/google.json
- name: Run tests
shell: bash
env:
LIVEKIT_EVALS_VERBOSE: 1
PLUGIN: ${{ matrix.example }}
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ELEVEN_API_KEY: ${{ secrets.ELEVEN_API_KEY }}
CARTESIA_API_KEY: ${{ secrets.CARTESIA_API_KEY }}
AZURE_SPEECH_KEY: ${{ secrets.AZURE_SPEECH_KEY }}
AZURE_SPEECH_REGION: ${{ secrets.AZURE_SPEECH_REGION }} # nit: doesn't have to be secret
GOOGLE_CREDENTIALS_JSON: ${{ secrets.GOOGLE_CREDENTIALS_JSON }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
ASSEMBLYAI_API_KEY: ${{ secrets.ASSEMBLYAI_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
PLAYHT_API_KEY: ${{ secrets.PLAYHT_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
PLAYHT_USER_ID: ${{ secrets.PLAYHT_USER_ID }}
RIME_API_KEY: ${{ secrets.RIME_API_KEY }}
SPEECHMATICS_API_KEY: ${{ secrets.SPEECHMATICS_API_KEY }}
GOOGLE_APPLICATION_CREDENTIALS: tests/google.json
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
NEUPHONIC_API_KEY: ${{ secrets.NEUPHONIC_API_KEY }}
RESEMBLE_API_KEY: ${{ secrets.RESEMBLE_API_KEY }}
SPEECHIFY_API_KEY: ${{ secrets.SPEECHIFY_API_KEY }}
HUME_API_KEY: ${{ secrets.HUME_API_KEY }}
SPITCH_API_KEY: ${{ secrets.SPITCH_API_KEY }}
LMNT_API_KEY: ${{ secrets.LMNT_API_KEY }}
INWORLD_API_KEY: ${{ secrets.INWORLD_API_KEY }}
run: uv run pytest examples/${{matrix.example}}/test_agent.py -s
+57
View File
@@ -0,0 +1,57 @@
name: Publish docs
on:
workflow_dispatch:
workflow_call:
secrets:
DOCS_DEPLOY_AWS_ACCESS_KEY: {}
DOCS_DEPLOY_AWS_API_SECRET: {}
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
submodules: true
lfs: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install package, plugins, and docs dependencies
run: |
uv sync --all-extras --group docs
- name: Build HTML Docs
run: |
uv run --active pdoc --skip-errors --html --output-dir=docs livekit
- name: Build Markdown Docs
run: |
uv run --active python .github/convert_html_docs.py docs/ docs-md -v --check-links
- name: S3 Upload
run: |
aws s3 sync --delete docs/ s3://livekit-docs/python
aws s3 sync --delete docs-md/ s3://livekit-docs/python-md
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DOCS_DEPLOY_AWS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOCS_DEPLOY_AWS_API_SECRET }}
AWS_DEFAULT_REGION: "us-east-1"
- name: Expire cloudfront cache
run: |
aws cloudfront create-invalidation --distribution-id EJJ40KLJ3TRY9 --paths "/python/*" "/python-md/*"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DOCS_DEPLOY_AWS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOCS_DEPLOY_AWS_API_SECRET }}
AWS_DEFAULT_REGION: "us-east-1"
+291
View File
@@ -0,0 +1,291 @@
name: Publish to PyPI
on:
workflow_dispatch:
inputs:
version:
description: "What to publish"
type: choice
required: true
options:
- "patch (1.5.1 → 1.5.2)"
- "minor (1.5.1 → 1.6.0)"
- "major (1.5.1 → 2.0.0)"
- "patch-rc (1.5.1 → 1.5.2.rc1)"
- "minor-rc (1.5.1 → 1.6.0.rc1)"
- "major-rc (1.5.1 → 2.0.0.rc1)"
- "next-rc (.rc1 → .rc2)"
- "promote (1.6.0.rc2 → 1.6.0)"
- "republish (retry failed publish, no version bump)"
branch:
description: "Branch to publish from (default: main)"
type: string
required: false
default: "main"
pull_request:
types: [closed]
permissions:
contents: write
pull-requests: write
id-token: write
actions: write
jobs:
# ── Create a version bump PR ────────────────────────────────
bump:
name: Create release PR
if: |
github.event_name == 'workflow_dispatch'
&& !startsWith(inputs.version, 'republish')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.branch }}
- name: Guard non-main branches
env:
VERSION: ${{ inputs.version }}
BRANCH: ${{ inputs.branch }}
run: |
key=$(echo "$VERSION" | awk '{print $1}')
if [ "$BRANCH" != "main" ]; then
case "$key" in
*-rc|next-rc) ;; # allowed
*) echo "::error::Only RC releases are allowed from non-main branches (got '$key' on '$BRANCH')"; exit 1 ;;
esac
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.10"
- name: Install dependencies
run: pip install click packaging pyyaml colorama
- name: Bump versions
env:
VERSION: ${{ inputs.version }}
run: |
key=$(echo "$VERSION" | awk '{print $1}')
case "$key" in
patch|minor|major)
python .github/update_versions.py --bump-type "$key"
;;
patch-rc|minor-rc|major-rc)
bump="${key%-rc}"
python .github/update_versions.py --bump-type "$bump"
python .github/update_versions.py --pre rc
;;
next-rc)
python .github/update_versions.py --pre rc
;;
promote)
python .github/update_versions.py --bump-type release
;;
esac
- name: Read new version
id: version
run: |
version=$(grep -m1 '__version__' livekit-agents/livekit/agents/version.py | sed 's/.*"\(.*\)".*/\1/')
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "New version: $version"
- name: Close existing release PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr list --state open --json number,headRefName \
--jq '.[] | select(.headRefName | startswith("release/v")) | .number' | while read -r pr; do
echo "Superseding release PR #$pr"
gh pr comment "$pr" --body "Superseded by a new release."
gh pr close "$pr" --delete-branch || true
done
- name: Create release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
BASE_BRANCH: ${{ inputs.branch }}
run: |
branch="release/v${VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git add -A
git commit -m "v${VERSION}"
git push --force origin "$branch"
gh pr create \
--base "$BASE_BRANCH" \
--head "$branch" \
--title "livekit-agents@${VERSION}" \
--body "Merging this PR will publish all packages as **${VERSION}** to PyPI."
# ── Discover, build, publish ────────────────────────────────
# Triggered by: merging a release PR OR republish dispatch
discover:
name: Discover packages
if: |
(github.event_name == 'pull_request'
&& github.event.pull_request.merged == true
&& startsWith(github.event.pull_request.head.ref, 'release/v'))
|| (github.event_name == 'workflow_dispatch'
&& startsWith(inputs.version, 'republish'))
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.list.outputs.packages }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.branch || github.event.pull_request.merge_commit_sha }}
- name: List publishable packages
id: list
run: |
packages=()
packages+=("livekit-agents")
for dir in livekit-plugins/livekit-plugins-*; do
if [ ! -d "$dir" ]; then continue; fi
name=""
if [ -f "$dir/pyproject.toml" ]; then
name=$(grep -m1 '^name\s*=\s*"' "$dir/pyproject.toml" | sed 's/.*"\(.*\)".*/\1/')
fi
if [ -z "$name" ] && [ -f "$dir/setup.py" ]; then
name=$(grep -m1 'name\s*=\s*"' "$dir/setup.py" | sed 's/.*"\(.*\)".*/\1/')
fi
if [ -z "$name" ]; then
name=$(basename "$dir")
fi
packages+=("$name")
done
json=$(printf '%s\n' "${packages[@]}" | jq -R . | jq -sc .)
echo "packages=$json" >> "$GITHUB_OUTPUT"
echo "Will publish: $json"
tag:
name: Tag release
needs: discover
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- name: Create git tag
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
# HEAD_REF is release/v1.5.2 → extract 1.5.2
version="${HEAD_REF#release/v}"
tag="livekit-agents@${version}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$tag"
git push origin "$tag"
build:
name: Build ${{ matrix.package }}
needs: discover
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.discover.outputs.packages) }}
uses: ./.github/workflows/build.yml
with:
package: ${{ matrix.package }}
artifact_name: dist-${{ matrix.package }}
publish:
name: Publish ${{ matrix.package }}
needs: [discover, build]
if: always() && needs.discover.result == 'success'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 10
matrix:
package: ${{ fromJson(needs.discover.outputs.packages) }}
environment: pypi
permissions:
id-token: write
steps:
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: dist-${{ matrix.package }}
path: dist/
- name: List distributions
run: ls -la dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
docs:
name: Publish docs
needs: [publish]
if: always() && needs.publish.result == 'success'
uses: ./.github/workflows/publish-docs.yml
secrets: inherit
deploy-examples:
name: Deploy examples
needs: [publish]
if: always() && needs.publish.result == 'success'
uses: ./.github/workflows/deploy-examples.yml
with:
ref: ${{ inputs.branch || github.event.pull_request.merge_commit_sha }}
secrets: inherit
dispatch-downstream-bumps:
name: Bump livekit-agents in downstream repos
needs: [publish]
# Only fire for stable releases merged via a release/vX.Y.Z PR.
# RC head refs look like release/v1.5.13rc1 and won't match the regex below.
# Republish retries should use internal-actions' workflow_dispatch fallback.
if: |
always()
&& needs.publish.result == 'success'
&& github.event_name == 'pull_request'
&& github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Resolve stable version from head ref
id: version
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
version="${HEAD_REF#release/v}"
if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::notice::Head ref '$HEAD_REF' is not a stable release — skipping downstream bump."
exit 0
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Dispatch to internal-actions
if: steps.version.outputs.version != ''
env:
GH_TOKEN: ${{ secrets.SYNC_DISPATCH_TOKEN }}
TARGET_REPO: ${{ vars.TARGET_REPO }}
VERSION: ${{ steps.version.outputs.version }}
run: |
gh api -X POST "repos/${TARGET_REPO}/dispatches" \
-f event_type="livekit-agents-published" \
-f client_payload[version]="$VERSION" \
-f client_payload[source_repo]="${{ github.repository }}" \
-f client_payload[source_sha]="${{ github.event.pull_request.merge_commit_sha }}"
+41
View File
@@ -0,0 +1,41 @@
name: Release gate
on:
pull_request:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted, dismissed]
permissions:
pull-requests: read
jobs:
release-gate:
name: Release gate
runs-on: ubuntu-latest
steps:
- name: Check release PR requirements
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
if [[ "$HEAD_REF" != release/v* ]]; then
echo "Not a release PR, skipping"
exit 0
fi
if [ "$PR_AUTHOR" != "github-actions[bot]" ]; then
echo "::error::Release PRs must be created by the publish workflow, not by '$PR_AUTHOR'"
exit 1
fi
approvals=$(gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" \
--jq '[group_by(.user.login)[] | sort_by(.submitted_at) | last | select(.state == "APPROVED") | .user.login] | length')
echo "Approvals: $approvals"
if [ "$approvals" -lt 2 ]; then
echo "::error::Release PRs require at least 2 approvals (got $approvals)"
exit 1
fi
+55
View File
@@ -0,0 +1,55 @@
name: test-realtime
on:
workflow_dispatch:
inputs:
branch:
description: "Branch or revision to test"
required: false
type: string
default: "main"
pull_request:
paths:
- "livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/realtime/**"
- "livekit-plugins/livekit-plugins-xai/livekit/plugins/xai/realtime/**"
- "tests/test_realtime/**"
- ".github/workflows/test-realtime.yml"
jobs:
test-realtime:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch != '' && github.event.inputs.branch || github.ref }}
lfs: true
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
version: "latest"
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Run Realtime tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_DEPLOYMENT: ${{ secrets.AZURE_OPENAI_DEPLOYMENT }}
AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
# XAI_API_KEY not set — xAI rate-limits GitHub Actions IPs (429 on ws handshake)
run: |
uv run pytest --realtime -v -s --tb=long -p no:xdist
+271
View File
@@ -0,0 +1,271 @@
name: Test STT
on:
workflow_dispatch:
inputs:
branch:
description: "Branch or revision to test"
required: false
type: string
default: "main"
pr_number:
description: "PR number to post results to (optional)"
required: false
type: string
default: ""
pull_request:
paths:
- "tests/test_stt.py"
- ".github/workflows/test-stt.yml"
issue_comment:
types: [created]
jobs:
slash-command-dispatch:
if: github.event_name == 'issue_comment' && github.event.issue.pull_request
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
actions: write
steps:
- name: Get PR details and check authorization
id: pr-info
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const comment = context.payload.comment.body.trim();
const isCommand = /^\/test-stt(\s|$)/.test(comment);
if (!isCommand) {
core.setOutput('is_command', 'false');
return;
}
// Get PR details
const prResponse = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number,
});
const pr = prResponse.data;
const commenter = context.payload.comment.user.login;
// Check if commenter is an organization member (not just a collaborator)
let isAuthorized = false;
try {
// First check if the repo owner is an organization
const orgResponse = await github.rest.orgs.get({
org: context.repo.owner,
});
// If it's an organization, check if user is a member
if (orgResponse.data) {
try {
await github.rest.orgs.checkMembershipForUser({
org: context.repo.owner,
username: commenter,
});
isAuthorized = true;
console.log(`${commenter} is an organization member`);
} catch (memberError) {
// User is not an organization member
isAuthorized = false;
console.log(`${commenter} is not an organization member`);
}
}
} catch (orgError) {
// Repo owner is not an organization (it's a user account)
// In this case, check if commenter is the repo owner
if (commenter === context.repo.owner) {
isAuthorized = true;
console.log(`${commenter} is the repository owner`);
} else {
isAuthorized = false;
console.log(`${commenter} is not authorized (repo is user-owned, not org-owned)`);
}
}
if (!isAuthorized) {
console.log(`Slash command rejected: ${commenter} is not an organization member`);
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: '-1'
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: '❌ `/test-stt` command is only available for organization members.'
});
core.setOutput('is_command', 'false');
return;
}
core.setOutput('is_command', 'true');
core.setOutput('pr_number', pr.number.toString());
core.setOutput('branch', pr.head.ref);
console.log(`Slash command /test-stt detected for PR #${pr.number} by ${commenter}`);
- name: Trigger workflow
if: steps.pr-info.outputs.is_command == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'test-stt.yml',
ref: 'main',
inputs: {
pr_number: '${{ steps.pr-info.outputs.pr_number }}',
branch: 'refs/pull/${{ steps.pr-info.outputs.pr_number }}/head'
}
});
console.log('Workflow triggered successfully');
// Add reaction to comment
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket'
});
test-stt:
if: github.event_name != 'issue_comment'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch != '' && github.event.inputs.branch || github.ref }}
fetch-depth: 0
lfs: true
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
version: "latest"
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install dependencies
run: |
uv sync --all-extras --dev
- name: Setup Google credentials
shell: bash
run: |
printf '%s' '${{ secrets.GOOGLE_STT_CREDENTIALS_JSON }}' > ${{ github.workspace }}/tests/google.json
- name: Run STT tests
env:
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
ASSEMBLYAI_API_KEY: ${{ secrets.ASSEMBLYAI_API_KEY }}
SPEECHMATICS_API_KEY: ${{ secrets.SPEECHMATICS_API_KEY }}
ELEVEN_API_KEY: ${{ secrets.ELEVEN_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKSAI_API_KEY }}
GLADIA_API_KEY: ${{ secrets.GLADIA_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CARTESIA_API_KEY: ${{ secrets.CARTESIA_API_KEY }}
GRADIUM_API_KEY: ${{ secrets.GRADIUM_API_KEY }}
SONIOX_API_KEY: ${{ secrets.SONIOX_API_KEY }}
GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/tests/google.json
AZURE_SPEECH_KEY: ${{ secrets.AZURE_SPEECH_KEY }}
AZURE_SPEECH_REGION: ${{ secrets.AZURE_SPEECH_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: ${{ secrets.AWS_REGION }}
SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }}
run: |
uv run pytest -n 3 -v --stt --tb=long --junitxml=test-results.xml 2>&1 | tee test-output.txt || true
- name: Cleanup Google credentials
if: always()
shell: bash
run: |
rm -f ${{ github.workspace }}/tests/google.json
- name: Generate test summary
if: always()
id: test-summary
run: |
python3 scripts/generate_test_summary.py test-results.xml -o test-summary.md
- name: Post results to PR
if: always() && (github.event_name == 'pull_request' || github.event.inputs.pr_number != '')
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const fs = require('fs');
const summary = fs.readFileSync('test-summary.md', 'utf8');
let prNumber;
if ('${{ github.event_name }}' === 'pull_request') {
prNumber = ${{ github.event.pull_request.number || 0 }};
} else if ('${{ github.event.inputs.pr_number }}') {
prNumber = parseInt('${{ github.event.inputs.pr_number }}');
} else {
prNumber = null;
}
if (!prNumber || isNaN(prNumber)) {
console.log('No valid PR number, skipping comment');
return;
}
const body = `${summary}\n\n---\n*Triggered by workflow run [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('## STT Test Results')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body
});
console.log('Updated existing comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body
});
console.log('Created new comment');
}
- name: Add to job summary
if: always()
run: |
cat test-summary.md >> $GITHUB_STEP_SUMMARY
+199
View File
@@ -0,0 +1,199 @@
name: tests
on:
push:
branches:
- main
paths-ignore:
- '**/*.md'
pull_request:
branches:
- main
paths-ignore:
- '**/*.md'
workflow_dispatch:
permissions:
contents: read
actions: write
jobs:
blockguard-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Build and install blockguard
run: pip install livekit-plugins/livekit-blockguard/
- name: Install pytest
run: pip install pytest
- name: Run blockguard tests
working-directory: livekit-plugins/livekit-blockguard
run: python -m pytest test_blockguard.py -v
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install the project
run: uv sync --all-extras --dev
- name: Download NLTK data
run: uv run python -c "import nltk; nltk.download('punkt_tab')"
- name: Run tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: make unit-tests
evaluation:
# don't run tests for PRs on forks
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install the project
run: uv sync --all-extras --dev
- name: Run evals
env:
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
run: uv run pytest --evals
tests:
# don't run tests for PRs on forks
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
strategy:
fail-fast: false
matrix:
plugin:
[
cartesia,
deepgram,
elevenlabs,
groq,
openai,
inworld,
]
runs-on: ubuntu-latest
name: livekit-plugins-${{ matrix.plugin }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Setup Google credentials
if: matrix.plugin == 'google'
shell: bash
run: |
printf '%s' '${{ secrets.GOOGLE_CREDENTIALS_JSON }}' > tests/google.json
- name: Run tests
shell: bash
env:
PLUGIN: ${{ matrix.plugin }}
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
ELEVEN_API_KEY: ${{ secrets.ELEVEN_API_KEY }}
CARTESIA_API_KEY: ${{ secrets.CARTESIA_API_KEY }}
AZURE_SPEECH_KEY: ${{ secrets.AZURE_SPEECH_KEY }}
AZURE_SPEECH_REGION: ${{ secrets.AZURE_SPEECH_REGION }} # nit: doesn't have to be secret
GOOGLE_CREDENTIALS_JSON: ${{ secrets.GOOGLE_CREDENTIALS_JSON }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
ASSEMBLYAI_API_KEY: ${{ secrets.ASSEMBLYAI_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
PLAYHT_API_KEY: ${{ secrets.PLAYHT_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
PLAYHT_USER_ID: ${{ secrets.PLAYHT_USER_ID }}
RIME_API_KEY: ${{ secrets.RIME_API_KEY }}
SPEECHMATICS_API_KEY: ${{ secrets.SPEECHMATICS_API_KEY }}
GOOGLE_APPLICATION_CREDENTIALS: tests/google.json
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
NEUPHONIC_API_KEY: ${{ secrets.NEUPHONIC_API_KEY }}
RESEMBLE_API_KEY: ${{ secrets.RESEMBLE_API_KEY }}
SPEECHIFY_API_KEY: ${{ secrets.SPEECHIFY_API_KEY }}
HUME_API_KEY: ${{ secrets.HUME_API_KEY }}
SPITCH_API_KEY: ${{ secrets.SPITCH_API_KEY }}
LMNT_API_KEY: ${{ secrets.LMNT_API_KEY }}
INWORLD_API_KEY: ${{ secrets.INWORLD_API_KEY }}
SONIOX_API_KEY: ${{ secrets.SONIOX_API_KEY }}
working-directory: tests
run: make test
- name: Upload LiveKit dump
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lk-dump-${{ matrix.plugin }}
path: lk_dump/**
if-no-files-found: ignore
retention-days: 1
aggregate-dumps:
if: always()
needs: tests
runs-on: ubuntu-latest
steps:
- name: download all dump shards
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: lk-dump-*
path: all_lk_dump
merge-multiple: true
- name: create tarball
id: tar
run: |
if [ -d "all_lk_dump" ] && [ "$(ls -A all_lk_dump)" ]; then
tar -czf lk-dump-all.tar.gz -C all_lk_dump .
echo "created=true" >> "$GITHUB_OUTPUT"
else
echo "No dump shards found, skipping tarball creation."
echo "created=false" >> "$GITHUB_OUTPUT"
fi
- name: upload merged dump
if: steps.tar.outputs.created == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lk-dump-all
path: lk-dump-all.tar.gz
retention-days: 5