feat: Livekit bare no recording nor pipeline

This commit is contained in:
Juan
2026-04-01 13:54:26 -05:00
parent b570d202dc
commit 6d84794e36
30 changed files with 1724 additions and 37 deletions

View File

@@ -42,6 +42,7 @@ dependencies = [
"pydantic>=2.12.5",
"aiosmtplib>=3.0.0",
"email-validator>=2.0.0",
"livekit-api>=1.1.0",
]
[dependency-groups]

View File

@@ -15,6 +15,7 @@ from reflector.metrics import metrics_init
from reflector.settings import settings
from reflector.views.config import router as config_router
from reflector.views.daily import router as daily_router
from reflector.views.livekit import router as livekit_router
from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router
from reflector.views.rtc_offer import router as rtc_offer_router
@@ -112,6 +113,7 @@ app.include_router(config_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")
app.include_router(daily_router, prefix="/v1/daily")
app.include_router(livekit_router, prefix="/v1/livekit")
if auth_router:
app.include_router(auth_router, prefix="/v1")
add_pagination(app)

View File

@@ -0,0 +1,12 @@
"""
LiveKit API Module — thin wrapper around the livekit-api SDK.
"""
from .client import LiveKitApiClient
from .webhooks import create_webhook_receiver, verify_webhook
__all__ = [
"LiveKitApiClient",
"create_webhook_receiver",
"verify_webhook",
]

View File

@@ -0,0 +1,177 @@
"""
LiveKit API client wrapping the official livekit-api Python SDK.
Handles room management, access tokens, and Track Egress for
per-participant audio recording to S3-compatible storage.
"""
from datetime import timedelta
from livekit.api import (
AccessToken,
CreateRoomRequest,
DeleteRoomRequest,
DirectFileOutput,
EgressInfo,
ListEgressRequest,
ListParticipantsRequest,
LiveKitAPI,
Room,
S3Upload,
StopEgressRequest,
TrackEgressRequest,
VideoGrants,
)
class LiveKitApiClient:
"""Thin wrapper around LiveKitAPI for Reflector's needs."""
def __init__(
self,
url: str,
api_key: str,
api_secret: str,
s3_bucket: str | None = None,
s3_region: str | None = None,
s3_access_key: str | None = None,
s3_secret_key: str | None = None,
s3_endpoint: str | None = None,
):
self._url = url
self._api_key = api_key
self._api_secret = api_secret
self._s3_bucket = s3_bucket
self._s3_region = s3_region or "us-east-1"
self._s3_access_key = s3_access_key
self._s3_secret_key = s3_secret_key
self._s3_endpoint = s3_endpoint
self._api = LiveKitAPI(url=url, api_key=api_key, api_secret=api_secret)
# ── Room management ──────────────────────────────────────────
async def create_room(
self,
name: str,
empty_timeout: int = 300,
max_participants: int = 0,
) -> Room:
"""Create a LiveKit room.
Args:
name: Room name (unique identifier).
empty_timeout: Seconds to keep room alive after last participant leaves.
max_participants: 0 = unlimited.
"""
req = CreateRoomRequest(
name=name,
empty_timeout=empty_timeout,
max_participants=max_participants,
)
return await self._api.room.create_room(req)
async def delete_room(self, room_name: str) -> None:
await self._api.room.delete_room(DeleteRoomRequest(room=room_name))
async def list_participants(self, room_name: str):
resp = await self._api.room.list_participants(
ListParticipantsRequest(room=room_name)
)
return resp.participants
# ── Access tokens ────────────────────────────────────────────
def create_access_token(
self,
room_name: str,
participant_identity: str,
participant_name: str | None = None,
can_publish: bool = True,
can_subscribe: bool = True,
room_admin: bool = False,
ttl_seconds: int = 86400,
) -> str:
"""Generate a JWT access token for a participant."""
token = AccessToken(
api_key=self._api_key,
api_secret=self._api_secret,
)
token.identity = participant_identity
token.name = participant_name or participant_identity
token.ttl = timedelta(seconds=ttl_seconds)
token.with_grants(
VideoGrants(
room_join=True,
room=room_name,
can_publish=can_publish,
can_subscribe=can_subscribe,
room_admin=room_admin,
)
)
return token.to_jwt()
# ── Track Egress (per-participant audio recording) ───────────
def _build_s3_upload(self) -> S3Upload:
"""Build S3Upload config for egress output."""
if not all([self._s3_bucket, self._s3_access_key, self._s3_secret_key]):
raise ValueError(
"S3 storage not configured for LiveKit egress. "
"Set LIVEKIT_STORAGE_AWS_* environment variables."
)
kwargs = {
"access_key": self._s3_access_key,
"secret": self._s3_secret_key,
"bucket": self._s3_bucket,
"region": self._s3_region,
"force_path_style": True, # Required for Garage/MinIO
}
if self._s3_endpoint:
kwargs["endpoint"] = self._s3_endpoint
return S3Upload(**kwargs)
async def start_track_egress(
self,
room_name: str,
track_sid: str,
s3_filepath: str,
) -> EgressInfo:
"""Start Track Egress for a single audio track (writes OGG/Opus to S3).
Args:
room_name: LiveKit room name.
track_sid: Track SID to record.
s3_filepath: S3 key path for the output file.
"""
req = TrackEgressRequest(
room_name=room_name,
track_id=track_sid,
file=DirectFileOutput(
filepath=s3_filepath,
s3=self._build_s3_upload(),
),
)
return await self._api.egress.start_track_egress(req)
async def list_egress(self, room_name: str | None = None) -> list[EgressInfo]:
req = ListEgressRequest()
if room_name:
req.room_name = room_name
resp = await self._api.egress.list_egress(req)
return list(resp.items)
async def stop_egress(self, egress_id: str) -> EgressInfo:
return await self._api.egress.stop_egress(
StopEgressRequest(egress_id=egress_id)
)
# ── Cleanup ──────────────────────────────────────────────────
async def close(self):
await self._api.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -0,0 +1,35 @@
"""
LiveKit webhook verification and event parsing.
LiveKit signs webhooks using the API secret as a JWT.
The WebhookReceiver from the SDK handles verification.
"""
from livekit.api import TokenVerifier, WebhookEvent, WebhookReceiver
from reflector.logger import logger
def create_webhook_receiver(api_key: str, api_secret: str) -> WebhookReceiver:
"""Create a WebhookReceiver for verifying LiveKit webhook signatures."""
return WebhookReceiver(
token_verifier=TokenVerifier(api_key=api_key, api_secret=api_secret)
)
def verify_webhook(
receiver: WebhookReceiver,
body: str | bytes,
auth_header: str,
) -> WebhookEvent | None:
"""Verify and parse a LiveKit webhook event.
Returns the parsed WebhookEvent if valid, None if verification fails.
"""
if isinstance(body, bytes):
body = body.decode("utf-8")
try:
return receiver.receive(body, auth_header)
except Exception as e:
logger.warning("LiveKit webhook verification failed", error=str(e))
return None

View File

@@ -1,5 +1,6 @@
from typing import Literal
Platform = Literal["whereby", "daily"]
Platform = Literal["whereby", "daily", "livekit"]
WHEREBY_PLATFORM: Platform = "whereby"
DAILY_PLATFORM: Platform = "daily"
LIVEKIT_PLATFORM: Platform = "livekit"

View File

@@ -195,6 +195,23 @@ class Settings(BaseSettings):
DAILY_WEBHOOK_UUID: str | None = (
None # Webhook UUID for this environment. Not used by production code
)
# LiveKit integration (self-hosted open-source video platform)
LIVEKIT_URL: str | None = (
None # e.g. ws://livekit:7880 (internal) or wss://livekit.example.com
)
LIVEKIT_API_KEY: str | None = None
LIVEKIT_API_SECRET: str | None = None
LIVEKIT_WEBHOOK_SECRET: str | None = None # Defaults to API_SECRET if not set
# LiveKit egress S3 storage (Track Egress writes per-participant audio here)
LIVEKIT_STORAGE_AWS_BUCKET_NAME: str | None = None
LIVEKIT_STORAGE_AWS_REGION: str | None = None
LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
LIVEKIT_STORAGE_AWS_ENDPOINT_URL: str | None = None # For Garage/MinIO
# Public URL for LiveKit (used in frontend room_url, e.g. wss://livekit.example.com)
LIVEKIT_PUBLIC_URL: str | None = None
# Platform Configuration
DEFAULT_VIDEO_PLATFORM: Platform = DAILY_PLATFORM

View File

@@ -57,6 +57,22 @@ def get_source_storage(platform: str) -> Storage:
aws_secret_access_key=settings.WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY,
)
elif platform == "livekit":
if (
settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID
and settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY
and settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME
):
from reflector.storage.storage_aws import AwsStorage
return AwsStorage(
aws_bucket_name=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
aws_region=settings.LIVEKIT_STORAGE_AWS_REGION or "us-east-1",
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
aws_endpoint_url=settings.LIVEKIT_STORAGE_AWS_ENDPOINT_URL,
)
return get_transcripts_storage()

View File

@@ -1,7 +1,7 @@
from reflector.settings import settings
from reflector.storage import get_dailyco_storage, get_whereby_storage
from ..schemas.platform import WHEREBY_PLATFORM, Platform
from ..schemas.platform import LIVEKIT_PLATFORM, WHEREBY_PLATFORM, Platform
from .base import VideoPlatformClient, VideoPlatformConfig
from .registry import get_platform_client
@@ -44,6 +44,27 @@ def get_platform_config(platform: Platform) -> VideoPlatformConfig:
s3_region=daily_storage.region,
aws_role_arn=daily_storage.role_credential,
)
elif platform == LIVEKIT_PLATFORM:
if not settings.LIVEKIT_URL:
raise ValueError(
"LIVEKIT_URL is required when platform='livekit'. "
"Set LIVEKIT_URL environment variable."
)
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
raise ValueError(
"LIVEKIT_API_KEY and LIVEKIT_API_SECRET are required when platform='livekit'. "
"Set LIVEKIT_API_KEY and LIVEKIT_API_SECRET environment variables."
)
return VideoPlatformConfig(
api_key=settings.LIVEKIT_API_KEY,
webhook_secret=settings.LIVEKIT_WEBHOOK_SECRET
or settings.LIVEKIT_API_SECRET,
api_url=settings.LIVEKIT_URL,
s3_bucket=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
s3_region=settings.LIVEKIT_STORAGE_AWS_REGION,
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
aws_access_key_secret=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
)
else:
raise ValueError(f"Unknown platform: {platform}")

View File

@@ -0,0 +1,175 @@
"""
LiveKit video platform client for Reflector.
Self-hosted, open-source alternative to Daily.co.
Uses Track Egress for per-participant audio recording (no composite video).
"""
from datetime import datetime, timezone
from urllib.parse import urlencode
from uuid import uuid4
from reflector.db.rooms import Room
from reflector.livekit_api.client import LiveKitApiClient
from reflector.livekit_api.webhooks import create_webhook_receiver, verify_webhook
from reflector.logger import logger
from reflector.settings import settings
from ..schemas.platform import Platform
from ..utils.string import NonEmptyString
from .base import ROOM_PREFIX_SEPARATOR, VideoPlatformClient
from .models import MeetingData, SessionData, VideoPlatformConfig
class LiveKitClient(VideoPlatformClient):
PLATFORM_NAME: Platform = "livekit"
TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
def __init__(self, config: VideoPlatformConfig):
super().__init__(config)
self._api_client = LiveKitApiClient(
url=config.api_url or "",
api_key=config.api_key,
api_secret=config.webhook_secret, # LiveKit uses API secret for both auth and webhooks
s3_bucket=config.s3_bucket,
s3_region=config.s3_region,
s3_access_key=config.aws_access_key_id,
s3_secret_key=config.aws_access_key_secret,
s3_endpoint=settings.LIVEKIT_STORAGE_AWS_ENDPOINT_URL,
)
self._webhook_receiver = create_webhook_receiver(
api_key=config.api_key,
api_secret=config.webhook_secret,
)
async def create_meeting(
self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room
) -> MeetingData:
"""Create a LiveKit room for this meeting.
LiveKit rooms are created explicitly via API. A new room is created
for each Reflector meeting (same pattern as Daily.co).
"""
now = datetime.now(timezone.utc)
timestamp = now.strftime(self.TIMESTAMP_FORMAT)
room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}"
# Calculate empty_timeout from end_date (seconds until expiry)
# Ensure end_date is timezone-aware for subtraction
end_date_aware = (
end_date if end_date.tzinfo else end_date.replace(tzinfo=timezone.utc)
)
remaining = int((end_date_aware - now).total_seconds())
empty_timeout = max(300, min(remaining, 86400)) # 5 min to 24 hours
lk_room = await self._api_client.create_room(
name=room_name,
empty_timeout=empty_timeout,
)
logger.info(
"LiveKit room created",
room_name=lk_room.name,
room_sid=lk_room.sid,
empty_timeout=empty_timeout,
)
# room_url includes the server URL + room name as query param.
# The join endpoint in rooms.py appends the token as another query param.
# Frontend parses: ws://host:7880?room=<name>&token=<jwt>
public_url = settings.LIVEKIT_PUBLIC_URL or settings.LIVEKIT_URL or ""
room_url = f"{public_url}?{urlencode({'room': lk_room.name})}"
return MeetingData(
meeting_id=lk_room.sid or str(uuid4()),
room_name=lk_room.name,
room_url=room_url,
host_room_url=room_url,
platform=self.PLATFORM_NAME,
extra_data={"livekit_room_sid": lk_room.sid},
)
async def get_room_sessions(self, room_name: str) -> list[SessionData]:
"""Get current participants in a LiveKit room.
For historical sessions, we rely on webhook-stored data (same as Daily).
This returns currently-connected participants.
"""
try:
participants = await self._api_client.list_participants(room_name)
return [
SessionData(
session_id=p.sid,
started_at=datetime.fromtimestamp(
p.joined_at if p.joined_at else 0, tz=timezone.utc
),
ended_at=None, # Still active
)
for p in participants
if p.sid # Skip empty entries
]
except Exception as e:
logger.debug(
"Could not list LiveKit participants (room may not exist)",
room_name=room_name,
error=str(e),
)
return []
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
# LiveKit doesn't have a logo upload concept; handled in frontend theming
return True
def verify_webhook_signature(
self, body: bytes, signature: str, timestamp: str | None = None
) -> bool:
"""Verify LiveKit webhook signature.
LiveKit sends the JWT in the Authorization header. The `signature`
param here receives the Authorization header value.
"""
event = verify_webhook(self._webhook_receiver, body, signature)
return event is not None
def create_access_token(
self,
room_name: str,
participant_identity: str,
participant_name: str | None = None,
is_admin: bool = False,
) -> str:
"""Generate a LiveKit access token for a participant."""
return self._api_client.create_access_token(
room_name=room_name,
participant_identity=participant_identity,
participant_name=participant_name,
room_admin=is_admin,
)
async def start_track_egress(
self,
room_name: str,
track_sid: str,
s3_filepath: str,
):
"""Start Track Egress for a single audio track."""
return await self._api_client.start_track_egress(
room_name=room_name,
track_sid=track_sid,
s3_filepath=s3_filepath,
)
async def list_egress(self, room_name: str | None = None):
return await self._api_client.list_egress(room_name=room_name)
async def stop_egress(self, egress_id: str):
return await self._api_client.stop_egress(egress_id=egress_id)
async def close(self):
await self._api_client.close()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -1,6 +1,11 @@
from typing import Dict, Type
from ..schemas.platform import DAILY_PLATFORM, WHEREBY_PLATFORM, Platform
from ..schemas.platform import (
DAILY_PLATFORM,
LIVEKIT_PLATFORM,
WHEREBY_PLATFORM,
Platform,
)
from .base import VideoPlatformClient, VideoPlatformConfig
_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
@@ -26,10 +31,12 @@ def get_available_platforms() -> list[Platform]:
def _register_builtin_platforms():
from .daily import DailyClient # noqa: PLC0415
from .livekit import LiveKitClient # noqa: PLC0415
from .whereby import WherebyClient # noqa: PLC0415
register_platform(WHEREBY_PLATFORM, WherebyClient)
register_platform(DAILY_PLATFORM, DailyClient)
register_platform(LIVEKIT_PLATFORM, LiveKitClient)
_register_builtin_platforms()

View File

@@ -0,0 +1,190 @@
"""LiveKit webhook handler.
Processes LiveKit webhook events for participant tracking and
Track Egress recording completion.
LiveKit sends webhooks as POST requests with JWT authentication
in the Authorization header.
"""
from fastapi import APIRouter, HTTPException, Request
from reflector.db.meetings import meetings_controller
from reflector.livekit_api.webhooks import create_webhook_receiver, verify_webhook
from reflector.logger import logger as _logger
from reflector.settings import settings
router = APIRouter()
logger = _logger.bind(platform="livekit")
# Module-level receiver, lazily initialized on first webhook
_webhook_receiver = None
def _get_webhook_receiver():
global _webhook_receiver
if _webhook_receiver is None:
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
raise ValueError("LiveKit not configured")
_webhook_receiver = create_webhook_receiver(
api_key=settings.LIVEKIT_API_KEY,
api_secret=settings.LIVEKIT_WEBHOOK_SECRET or settings.LIVEKIT_API_SECRET,
)
return _webhook_receiver
@router.post("/webhook")
async def livekit_webhook(request: Request):
"""Handle LiveKit webhook events.
LiveKit webhook events include:
- participant_joined / participant_left
- egress_started / egress_updated / egress_ended
- room_started / room_finished
- track_published / track_unpublished
"""
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
raise HTTPException(status_code=500, detail="LiveKit not configured")
body = await request.body()
auth_header = request.headers.get("Authorization", "")
receiver = _get_webhook_receiver()
event = verify_webhook(receiver, body, auth_header)
if event is None:
logger.warning(
"Invalid LiveKit webhook signature",
has_auth=bool(auth_header),
has_body=bool(body),
)
raise HTTPException(status_code=401, detail="Invalid webhook signature")
event_type = event.event
match event_type:
case "participant_joined":
await _handle_participant_joined(event)
case "participant_left":
await _handle_participant_left(event)
case "egress_started":
await _handle_egress_started(event)
case "egress_ended":
await _handle_egress_ended(event)
case "room_started":
logger.info(
"Room started",
room_name=event.room.name if event.room else None,
)
case "room_finished":
logger.info(
"Room finished",
room_name=event.room.name if event.room else None,
)
case "track_published" | "track_unpublished":
logger.debug(
f"Track event: {event_type}",
room_name=event.room.name if event.room else None,
participant=event.participant.identity if event.participant else None,
)
case _:
logger.debug(
"Unhandled LiveKit webhook event",
event_type=event_type,
)
return {"status": "ok"}
async def _handle_participant_joined(event):
room_name = event.room.name if event.room else None
participant = event.participant
if not room_name or not participant:
logger.warning("participant_joined: missing room or participant data")
return
meeting = await meetings_controller.get_by_room_name(room_name)
if not meeting:
logger.warning("participant_joined: meeting not found", room_name=room_name)
return
logger.info(
"Participant joined",
meeting_id=meeting.id,
room_name=room_name,
participant_identity=participant.identity,
participant_sid=participant.sid,
)
async def _handle_participant_left(event):
room_name = event.room.name if event.room else None
participant = event.participant
if not room_name or not participant:
logger.warning("participant_left: missing room or participant data")
return
meeting = await meetings_controller.get_by_room_name(room_name)
if not meeting:
logger.warning("participant_left: meeting not found", room_name=room_name)
return
logger.info(
"Participant left",
meeting_id=meeting.id,
room_name=room_name,
participant_identity=participant.identity,
participant_sid=participant.sid,
)
async def _handle_egress_started(event):
egress = event.egress_info
room_name = egress.room_name if egress else None
logger.info(
"Egress started",
room_name=room_name,
egress_id=egress.egress_id if egress else None,
)
async def _handle_egress_ended(event):
"""Handle Track Egress completion — trigger multitrack processing."""
egress = event.egress_info
if not egress:
logger.warning("egress_ended: no egress info in payload")
return
room_name = egress.room_name
# Check egress status
# EGRESS_COMPLETE = 3, EGRESS_FAILED = 4
status = egress.status
if status == 4: # EGRESS_FAILED
logger.error(
"Egress failed",
room_name=room_name,
egress_id=egress.egress_id,
error=egress.error,
)
return
# Extract output file info from egress results
file_results = list(egress.file_results)
logger.info(
"Egress ended",
room_name=room_name,
egress_id=egress.egress_id,
status=status,
num_files=len(file_results),
filenames=[f.filename for f in file_results] if file_results else [],
)
# Track Egress produces one file per egress request.
# The multitrack pipeline will be triggered separately once all tracks
# for a room are collected (via periodic polling or explicit trigger).
# TODO: Implement track collection and pipeline trigger

View File

@@ -598,4 +598,22 @@ async def rooms_join_meeting(
meeting = meeting.model_copy()
meeting.room_url = add_query_param(meeting.room_url, "t", token)
elif meeting.platform == "livekit":
client = create_platform_client(meeting.platform)
participant_identity = user_id or f"anon-{meeting_id[:8]}"
participant_name = (
getattr(user, "name", None) or participant_identity
if user
else participant_identity
)
token = client.create_access_token(
room_name=meeting.room_name,
participant_identity=participant_identity,
participant_name=participant_name,
is_admin=user_id == room.user_id if user_id else False,
)
meeting = meeting.model_copy()
# For LiveKit, room_url is the WS URL; token goes as a query param
meeting.room_url = add_query_param(meeting.room_url, "token", token)
return meeting

View File

@@ -83,7 +83,11 @@ def build_beat_schedule(
else:
logger.info("Daily.co beat tasks disabled (no DAILY_API_KEY)")
_any_platform = _whereby_enabled or _daily_enabled
_livekit_enabled = bool(settings.LIVEKIT_API_KEY and settings.LIVEKIT_URL)
if _livekit_enabled:
logger.info("LiveKit platform detected")
_any_platform = _whereby_enabled or _daily_enabled or _livekit_enabled
if _any_platform:
beat_schedule["process_meetings"] = {
"task": "reflector.worker.process.process_meetings",

40
server/uv.lock generated
View File

@@ -1805,6 +1805,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" },
]
[[package]]
name = "livekit-api"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "livekit-protocol" },
{ name = "protobuf" },
{ name = "pyjwt" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/0a/ad3cce124e608c056d6390244ec4dd18c8a4b5f055693a95831da2119af7/livekit_api-1.1.0.tar.gz", hash = "sha256:f94c000534d3a9b506e6aed2f35eb88db1b23bdea33bb322f0144c4e9f73934e", size = 16649, upload-time = "2025-12-02T19:37:11.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/b9/8d8515e3e0e629ab07d399cf858b8fc7e0a02bbf6384a6592b285264b4b9/livekit_api-1.1.0-py3-none-any.whl", hash = "sha256:bfc1c2c65392eb3f580a2c28108269f0e79873f053578a677eee7bb1de8aa8fb", size = 19620, upload-time = "2025-12-02T19:37:10.075Z" },
]
[[package]]
name = "livekit-protocol"
version = "1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/ca/d15e2a2cc8c8aa4ba621fe5f9ffd1806d88ac91c7b8fa4c09a3c0304dd92/livekit_protocol-1.1.3.tar.gz", hash = "sha256:cb4948d2513e81d91583f4a795bf80faa9026cedda509c5714999c7e33564287", size = 88746, upload-time = "2026-03-18T05:25:43.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/0e/f3d3e48628294df4559cffd0f8e1adf030127029e5a8da9beff9979090a0/livekit_protocol-1.1.3-py3-none-any.whl", hash = "sha256:fdae5640e064ab6549ec3d62d8bac75a3ef44d7ea73716069b419cbe8b360a5c", size = 107498, upload-time = "2026-03-18T05:25:42.077Z" },
]
[[package]]
name = "llama-cloud"
version = "0.1.35"
@@ -3364,6 +3393,7 @@ dependencies = [
{ name = "httpx" },
{ name = "icalendar" },
{ name = "jsonschema" },
{ name = "livekit-api" },
{ name = "llama-index" },
{ name = "llama-index-llms-openai-like" },
{ name = "openai" },
@@ -3445,6 +3475,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.24.1" },
{ name = "icalendar", specifier = ">=6.0.0" },
{ name = "jsonschema", specifier = ">=4.23.0" },
{ name = "livekit-api", specifier = ">=1.1.0" },
{ name = "llama-index", specifier = ">=0.12.52" },
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },
{ name = "openai", specifier = ">=1.59.7" },
@@ -4399,6 +4430,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
]
[[package]]
name = "types-protobuf"
version = "6.32.1.20260221"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"