Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
148 changes: 148 additions & 0 deletions .github/download_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/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 _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(d.name)
community = plugins_root / "community"
if community.is_dir():
for d in sorted(community.glob("livekit-plugins-*")):
if d.is_dir():
names.append(d.name)
return names


def _fetch_json(url: str) -> dict | None:
try:
req = urllib.request.Request(url, headers={"User-Agent": "livekit-stats/1.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except Exception:
return None


def _fetch_daily(package: str) -> tuple[str, dict[str, int]]:
"""Fetch daily download counts excluding mirrors."""
data = _fetch_json(
f"https://pypistats.org/api/packages/{package}/overall?mirrors=false"
)
daily: dict[str, int] = {}
if data:
for row in data.get("data", []):
daily[row["date"]] = row["downloads"]
return package, daily


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 main() -> None:
packages = [
"livekit",
"livekit-api",
"livekit-protocol",
"livekit-agents",
] + _iter_plugin_names()

today = datetime.now().date()

# WoW: last 7d vs previous 7d
wow_cur_end = (today - timedelta(days=1)).isoformat()
wow_cur_start = (today - timedelta(days=7)).isoformat()
wow_prev_end = (today - timedelta(days=8)).isoformat()
wow_prev_start = (today - timedelta(days=14)).isoformat()

# MoM: last 30d vs previous 30d
mom_cur_end = wow_cur_end
mom_cur_start = (today - timedelta(days=30)).isoformat()
mom_prev_end = (today - timedelta(days=31)).isoformat()
mom_prev_start = (today - timedelta(days=60)).isoformat()

# QoQ: last 90d vs previous 90d
qoq_cur_end = wow_cur_end
qoq_cur_start = (today - timedelta(days=90)).isoformat()
qoq_prev_end = (today - timedelta(days=91)).isoformat()
qoq_prev_start = (today - timedelta(days=180)).isoformat()

# fetch all packages in parallel
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)
wow = _growth_pct(last_7d, prev_7d)

last_30d = _sum_range(daily, mom_cur_start, mom_cur_end)
prev_30d = _sum_range(daily, mom_prev_start, mom_prev_end)
mom = _growth_pct(last_30d, prev_30d)

last_90d = _sum_range(daily, qoq_cur_start, qoq_cur_end)
prev_90d = _sum_range(daily, qoq_prev_start, qoq_prev_end)
qoq = _growth_pct(last_90d, prev_90d)

results.append((pkg, last_7d, last_30d, last_90d, wow, mom, qoq))

# sort by last 7d downloads
results.sort(key=lambda r: r[1], reverse=True)

hdr = (
f"{'Package':<40} {'Last 7d':>9} {'Last 30d':>10} {'Last 90d':>10}"
f" {'WoW':>7} {'MoM':>7} {'QoQ':>7}"
)
print(f"\nPyPI Download Stats — without mirrors (as of {today})")
print(hdr)
print("-" * len(hdr))
for pkg, last_7d, last_30d, last_90d, wow, mom, qoq in results:
print(
f"{pkg:<40} {last_7d:>9,} {last_30d:>10,} {last_90d:>10,}"
f" {_fmt_growth(wow):>7} {_fmt_growth(mom):>7} {_fmt_growth(qoq):>7}"
)

# top growers by each metric
for label, idx, min_prev in [("WoW", 4, 500), ("MoM", 5, 2000), ("QoQ", 6, 5000)]:
# filter: need enough previous-period volume and positive growth
prev_idx = {4: 2, 5: 3, 6: 4} # map growth idx -> prev period volume field
# use last_7d/last_30d/last_90d as proxy for "has enough volume"
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" {pkg:<40} {g:+.0f}%")


if __name__ == "__main__":
main()
87 changes: 83 additions & 4 deletions .github/update_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,83 @@

BUMP_ORDER: Dict[str, int] = {"patch": 0, "minor": 1, "major": 2}


def _iter_plugin_dirs(plugins_root: pathlib.Path) -> list[pathlib.Path]:
"""Return all plugin directories (maintained + community)."""
dirs = list(plugins_root.glob("livekit-plugins-*"))
community = plugins_root / "community"
if community.is_dir():
dirs.extend(community.glob("livekit-plugins-*"))
return dirs


def _is_community_plugin(pdir: pathlib.Path) -> bool:
"""Check if a plugin directory is under the community folder."""
return "community" in pdir.parts


def _read_maintained_by(pdir: pathlib.Path) -> str | None:
"""Read the maintained_by value from a plugin's __init__.py or other source files."""
if "livekit-plugins-" not in pdir.name:
return None
plugin_name = pdir.name.split("livekit-plugins-")[1].replace("-", "_")
plugin_pkg = pdir / "livekit" / "plugins" / plugin_name
if not plugin_pkg.is_dir():
return None
# Check __init__.py first, then fall back to other files
init_file = plugin_pkg / "__init__.py"
files = [init_file] if init_file.exists() else []
files.extend(f for f in sorted(plugin_pkg.glob("*.py")) if f != init_file)
for py_file in files:
text = py_file.read_text()
m = re.search(r'maintained_by\s*=\s*["\'](\w+)["\']', text)
if m:
return m.group(1)
return None


def log_plugin_summary(plugins_root: pathlib.Path) -> None:
"""Log all discovered plugins grouped by maintainer and validate consistency."""
maintained = []
community = []
mismatches = []

for pdir in sorted(_iter_plugin_dirs(plugins_root)):
is_community_dir = _is_community_plugin(pdir)
declared = _read_maintained_by(pdir)

if is_community_dir:
community.append(pdir.name)
else:
maintained.append(pdir.name)

# Validate that declared maintained_by matches directory location
if declared is not None:
if is_community_dir and declared == "livekit":
mismatches.append(
f"{pdir.name}: in community/ but declares maintained_by='livekit'"
)
elif not is_community_dir and declared == "community":
mismatches.append(
f"{pdir.name}: in maintained dir but declares maintained_by='community'"
)

print(f"\n{_esc(1)}Discovered {len(maintained) + len(community)} plugins{_esc(0)}")
print(f" {_esc(32)}LiveKit-maintained ({len(maintained)}):{_esc(0)}")
for name in maintained:
print(f" {name}")
print(f" {_esc(33)}Community ({len(community)}):{_esc(0)}")
for name in community:
print(f" {name}")

if mismatches:
print(f"\n{_esc(31)}ERROR: maintained_by mismatch detected!{_esc(0)}")
for msg in mismatches:
print(f" {_esc(31)}{msg}{_esc(0)}")
raise SystemExit(1)

print()

def _esc(*codes: int) -> str:
return "\033[" + ";".join(str(c) for c in codes) + "m"

Expand Down Expand Up @@ -100,7 +177,7 @@ def bump_prerelease(cur: str, bump_type: str) -> str:

def update_plugins_pyproject_agents_version(new_agents_version: str) -> None:
plugins_root = pathlib.Path("livekit-plugins")
for pdir in plugins_root.glob("livekit-plugins-*"):
for pdir in _iter_plugin_dirs(plugins_root):
pyproject = pdir / "pyproject.toml"
if pyproject.exists():
old_text = pyproject.read_text()
Expand Down Expand Up @@ -157,7 +234,7 @@ def update_versions(changesets: Dict[str, Tuple[str, List[str]]]) -> None:
else:
print("Warning: No version.py or no bump info for livekit-agents.")

for pdir in plugins_root.glob("livekit-plugins-*"):
for pdir in _iter_plugin_dirs(plugins_root):
vf = pdir / "livekit" / "plugins" / pdir.name.split("livekit-plugins-")[1].replace("-", "_") / "version.py"
if vf.exists():
if pdir.name in changesets:
Expand Down Expand Up @@ -194,7 +271,7 @@ def update_versions_ignore_changesets(bump_type: str) -> None:
else:
print("Warning: No version.py found for livekit-agents.")

for pdir in plugins_root.glob("livekit-plugins-*"):
for pdir in _iter_plugin_dirs(plugins_root):
vf = pdir / "livekit" / "plugins" / pdir.name.split("livekit-plugins-")[1].replace("-", "_") / "version.py"
if vf.exists():
cur = read_version(vf)
Expand Down Expand Up @@ -227,7 +304,7 @@ def update_prerelease(prerelease_type: str) -> None:
else:
print("Warning: No version.py for livekit-agents.")

for pdir in plugins_root.glob("livekit-plugins-*"):
for pdir in _iter_plugin_dirs(plugins_root):
vf = pdir / "livekit" / "plugins" / pdir.name.split("livekit-plugins-")[1].replace("-", "_") / "version.py"
if vf.exists():
cur = read_version(vf)
Expand Down Expand Up @@ -274,6 +351,8 @@ def bump(pre: str, ignore_changesets: bool, bump_type: str) -> None:
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.
"""
log_plugin_summary(pathlib.Path("livekit-plugins"))

if pre == "none":
if ignore_changesets:
update_versions_ignore_changesets(bump_type)
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/download-stats.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Weekly Python Download Stats

on:
schedule:
- cron: "0 14 * * 1" # every Monday at 2pm UTC
workflow_dispatch:

jobs:
stats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Fetch download stats
run: python .github/download_stats.py > stats.txt 2>&1

- name: Post to Slack
env:
SLACK_WEBHOOK_URL: ${{ secrets.DOWNLOAD_STATS_SLACK_WEBHOOK }}
run: |
python -c "
import json, urllib.request

stats = open('stats.txt').read()
payload = json.dumps({'text': '📊 *Weekly Python PyPI Download Stats*\n\`\`\`\n' + stats + '\n\`\`\`'}).encode()

req = urllib.request.Request(
'${{ secrets.DOWNLOAD_STATS_SLACK_WEBHOOK }}',
data=payload,
headers={'Content-Type': 'application/json'},
)
resp = urllib.request.urlopen(req)
print(resp.status, resp.read().decode())
"
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,14 @@ livekit-agents/livekit/agents/
├── telemetry/ # OpenTelemetry traces and Prometheus metrics
└── utils/ # Audio processing, codecs, HTTP, async utilities

livekit-plugins/ # 50+ provider plugins (openai, anthropic, google, deepgram, etc.)
livekit-plugins/ # LiveKit-maintained provider plugins (openai, anthropic, google, etc.)
community/ # Community-contributed plugins (~48 providers)
tests/ # Test suite with mock implementations (fake_stt.py, fake_vad.py)
examples/ # Example agents and use cases
```

### Plugin System
Plugins in `livekit-plugins/` provide STT, TTS, LLM, and specialized services. Each plugin is a separate package following the pattern `livekit-plugins-<provider>`. Plugins register via the `Plugin` base class in `plugin.py`.
Plugins in `livekit-plugins/` (LiveKit-maintained) and `livekit-plugins/community/` (community-contributed) provide STT, TTS, LLM, and specialized services. Each plugin is a separate package following the pattern `livekit-plugins-<provider>`. Plugins register via the `Plugin` base class in `plugin.py` and declare their maintainer via the `maintained_by` field (`"livekit"` or `"community"`).

### Model Interface Pattern
STT, TTS, LLM, Realtime models have provider-agnostic interfaces with:
Expand Down
Loading
Loading