livekit-plugins-anva 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- livekit_plugins_anva-0.1.0/.gitignore +6 -0
- livekit_plugins_anva-0.1.0/PKG-INFO +90 -0
- livekit_plugins_anva-0.1.0/README.md +70 -0
- livekit_plugins_anva-0.1.0/livekit/plugins/anva/__init__.py +26 -0
- livekit_plugins_anva-0.1.0/livekit/plugins/anva/avatar.py +237 -0
- livekit_plugins_anva-0.1.0/livekit/plugins/anva/log.py +3 -0
- livekit_plugins_anva-0.1.0/livekit/plugins/anva/version.py +1 -0
- livekit_plugins_anva-0.1.0/pyproject.toml +35 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: livekit-plugins-anva
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anva avatar plugin for LiveKit Agents: a lip-synced video avatar for your voice agent.
|
|
5
|
+
Project-URL: Homepage, https://anva.ai
|
|
6
|
+
Project-URL: Documentation, https://anva.ai/docs#livekit
|
|
7
|
+
Project-URL: Source, https://github.com/Anva-avatars/anva-sdk
|
|
8
|
+
Author-email: Anva <anva.ai.2026@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,anva,avatar,livekit,voice,webrtc
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Video
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: aiohttp>=3.9
|
|
18
|
+
Requires-Dist: livekit-agents>=1.8.2
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Anva avatars for LiveKit Agents
|
|
22
|
+
|
|
23
|
+
Give your LiveKit voice agent a face. The agent keeps its own STT, LLM and
|
|
24
|
+
TTS; Anva joins the room as a second participant and speaks the agent's words
|
|
25
|
+
with the lips in time, video and audio published together.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install livekit-plugins-anva
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from livekit import agents, rtc
|
|
33
|
+
from livekit.agents import Agent, AgentSession
|
|
34
|
+
from livekit.plugins import anva, deepgram, openai, silero
|
|
35
|
+
|
|
36
|
+
async def entrypoint(ctx: agents.JobContext):
|
|
37
|
+
session = AgentSession(
|
|
38
|
+
stt=deepgram.STT(), llm=openai.LLM(), tts=openai.TTS(), vad=silero.VAD.load(),
|
|
39
|
+
)
|
|
40
|
+
avatar = anva.AvatarSession(avatar_id="av_...") # or preset_id="..."
|
|
41
|
+
await avatar.start(session, room=ctx.room)
|
|
42
|
+
await session.start(agent=Agent(instructions="You are a friendly guide."), room=ctx.room)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Set `ANVA_API_KEY` (from anva.ai → API keys) and the usual `LIVEKIT_URL`,
|
|
46
|
+
`LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`. `ANVA_AVATAR_ID` can stand in for
|
|
47
|
+
`avatar_id`.
|
|
48
|
+
|
|
49
|
+
## What happens
|
|
50
|
+
|
|
51
|
+
1. `start()` mints a room token for the avatar participant (kind `agent`,
|
|
52
|
+
attribute `lk.publish_on_behalf` naming your agent) and asks Anva to create
|
|
53
|
+
an `avatar_only` session that joins your room with it. Anva answers once the
|
|
54
|
+
avatar is in the room with its video and audio tracks published.
|
|
55
|
+
2. The agent's audio output is replaced by LiveKit's `DataStreamAudioOutput`,
|
|
56
|
+
so speech goes to the avatar over the room's data stream rather than
|
|
57
|
+
straight to the room. Interruptions and playback receipts use LiveKit's
|
|
58
|
+
standard `lk.clear_buffer`, `lk.playback_started` and
|
|
59
|
+
`lk.playback_finished` RPCs.
|
|
60
|
+
3. `aclose()` (called for you when the job shuts down) ends the Anva session.
|
|
61
|
+
|
|
62
|
+
Billing is Anva's `avatar_only` rate per connected minute. The audio can be any
|
|
63
|
+
sample rate and channel count; Anva converts it to what its lip-sync takes.
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
| Argument | Meaning |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `avatar_id` / `preset_id` | Exactly one. A preset's voice and prompt do not apply: the agent speaks. |
|
|
70
|
+
| `api_key`, `api_url` | Anva API key and base URL (`https://anva.ai`). |
|
|
71
|
+
| `avatar_participant_identity`, `avatar_participant_name` | Defaults `anva-avatar`, `Anva avatar`. |
|
|
72
|
+
| `max_duration_seconds` | End the Anva session this long after it goes live. |
|
|
73
|
+
| `metadata` | Echoed on Anva's session and webhooks. |
|
|
74
|
+
| `conn_options` | LiveKit `APIConnectOptions`: retries and timeouts for the Anva call. |
|
|
75
|
+
|
|
76
|
+
A `400` from Anva (wrong mode, bad avatar, plan limits) raises `AnvaException`
|
|
77
|
+
without retrying; a `503 no_capacity` is retried after Anva's `Retry-After`.
|
|
78
|
+
|
|
79
|
+
## Front end
|
|
80
|
+
|
|
81
|
+
Your front end sees two agent participants. LiveKit's components pick the
|
|
82
|
+
avatar's tracks automatically; by hand, it is the participant whose
|
|
83
|
+
`lk.publish_on_behalf` attribute equals your agent's identity.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
livekit-server --dev &
|
|
89
|
+
LIVEKIT_TEST_URL=ws://127.0.0.1:7880 python -m pytest tests
|
|
90
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Anva avatars for LiveKit Agents
|
|
2
|
+
|
|
3
|
+
Give your LiveKit voice agent a face. The agent keeps its own STT, LLM and
|
|
4
|
+
TTS; Anva joins the room as a second participant and speaks the agent's words
|
|
5
|
+
with the lips in time, video and audio published together.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install livekit-plugins-anva
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from livekit import agents, rtc
|
|
13
|
+
from livekit.agents import Agent, AgentSession
|
|
14
|
+
from livekit.plugins import anva, deepgram, openai, silero
|
|
15
|
+
|
|
16
|
+
async def entrypoint(ctx: agents.JobContext):
|
|
17
|
+
session = AgentSession(
|
|
18
|
+
stt=deepgram.STT(), llm=openai.LLM(), tts=openai.TTS(), vad=silero.VAD.load(),
|
|
19
|
+
)
|
|
20
|
+
avatar = anva.AvatarSession(avatar_id="av_...") # or preset_id="..."
|
|
21
|
+
await avatar.start(session, room=ctx.room)
|
|
22
|
+
await session.start(agent=Agent(instructions="You are a friendly guide."), room=ctx.room)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Set `ANVA_API_KEY` (from anva.ai → API keys) and the usual `LIVEKIT_URL`,
|
|
26
|
+
`LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`. `ANVA_AVATAR_ID` can stand in for
|
|
27
|
+
`avatar_id`.
|
|
28
|
+
|
|
29
|
+
## What happens
|
|
30
|
+
|
|
31
|
+
1. `start()` mints a room token for the avatar participant (kind `agent`,
|
|
32
|
+
attribute `lk.publish_on_behalf` naming your agent) and asks Anva to create
|
|
33
|
+
an `avatar_only` session that joins your room with it. Anva answers once the
|
|
34
|
+
avatar is in the room with its video and audio tracks published.
|
|
35
|
+
2. The agent's audio output is replaced by LiveKit's `DataStreamAudioOutput`,
|
|
36
|
+
so speech goes to the avatar over the room's data stream rather than
|
|
37
|
+
straight to the room. Interruptions and playback receipts use LiveKit's
|
|
38
|
+
standard `lk.clear_buffer`, `lk.playback_started` and
|
|
39
|
+
`lk.playback_finished` RPCs.
|
|
40
|
+
3. `aclose()` (called for you when the job shuts down) ends the Anva session.
|
|
41
|
+
|
|
42
|
+
Billing is Anva's `avatar_only` rate per connected minute. The audio can be any
|
|
43
|
+
sample rate and channel count; Anva converts it to what its lip-sync takes.
|
|
44
|
+
|
|
45
|
+
## Options
|
|
46
|
+
|
|
47
|
+
| Argument | Meaning |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `avatar_id` / `preset_id` | Exactly one. A preset's voice and prompt do not apply: the agent speaks. |
|
|
50
|
+
| `api_key`, `api_url` | Anva API key and base URL (`https://anva.ai`). |
|
|
51
|
+
| `avatar_participant_identity`, `avatar_participant_name` | Defaults `anva-avatar`, `Anva avatar`. |
|
|
52
|
+
| `max_duration_seconds` | End the Anva session this long after it goes live. |
|
|
53
|
+
| `metadata` | Echoed on Anva's session and webhooks. |
|
|
54
|
+
| `conn_options` | LiveKit `APIConnectOptions`: retries and timeouts for the Anva call. |
|
|
55
|
+
|
|
56
|
+
A `400` from Anva (wrong mode, bad avatar, plan limits) raises `AnvaException`
|
|
57
|
+
without retrying; a `503 no_capacity` is retried after Anva's `Retry-After`.
|
|
58
|
+
|
|
59
|
+
## Front end
|
|
60
|
+
|
|
61
|
+
Your front end sees two agent participants. LiveKit's components pick the
|
|
62
|
+
avatar's tracks automatically; by hand, it is the participant whose
|
|
63
|
+
`lk.publish_on_behalf` attribute equals your agent's identity.
|
|
64
|
+
|
|
65
|
+
## Tests
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
livekit-server --dev &
|
|
69
|
+
LIVEKIT_TEST_URL=ws://127.0.0.1:7880 python -m pytest tests
|
|
70
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Anva avatars for LiveKit Agents.
|
|
2
|
+
|
|
3
|
+
from livekit.plugins import anva
|
|
4
|
+
|
|
5
|
+
avatar = anva.AvatarSession(avatar_id="av_...")
|
|
6
|
+
await avatar.start(session, room=ctx.room)
|
|
7
|
+
|
|
8
|
+
The avatar joins the room as its own participant and speaks whatever the
|
|
9
|
+
agent says, with the lips in time. The agent keeps its own STT, LLM and TTS.
|
|
10
|
+
"""
|
|
11
|
+
from .avatar import AnvaException, AvatarSession
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = ["AvatarSession", "AnvaException", "__version__"]
|
|
15
|
+
|
|
16
|
+
from livekit.agents import Plugin
|
|
17
|
+
|
|
18
|
+
from .log import logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnvaPlugin(Plugin):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Plugin.register_plugin(AnvaPlugin())
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""An Anva avatar as a LiveKit Agents avatar session.
|
|
2
|
+
|
|
3
|
+
The agent does what it already does: listens, thinks, speaks. Its speech goes
|
|
4
|
+
to the avatar participant over the room's data stream instead of straight to
|
|
5
|
+
the room, and the avatar publishes the face and the voice together, in sync.
|
|
6
|
+
On Anva's side that is an ``avatar_only`` session: Anva supplies no voice of
|
|
7
|
+
its own, only the lip-synced video for the audio it is given.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
|
|
18
|
+
from livekit import api, rtc
|
|
19
|
+
from livekit.agents import (
|
|
20
|
+
DEFAULT_API_CONNECT_OPTIONS,
|
|
21
|
+
NOT_GIVEN,
|
|
22
|
+
AgentSession,
|
|
23
|
+
APIConnectionError,
|
|
24
|
+
APIConnectOptions,
|
|
25
|
+
APIStatusError,
|
|
26
|
+
NotGivenOr,
|
|
27
|
+
get_job_context,
|
|
28
|
+
utils,
|
|
29
|
+
)
|
|
30
|
+
from livekit.agents.voice.avatar import AvatarSession as BaseAvatarSession, DataStreamAudioOutput
|
|
31
|
+
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
|
|
32
|
+
|
|
33
|
+
from .log import logger
|
|
34
|
+
|
|
35
|
+
DEFAULT_API_URL = "https://anva.ai"
|
|
36
|
+
DEFAULT_AVATAR_IDENTITY = "anva-avatar"
|
|
37
|
+
DEFAULT_AVATAR_NAME = "Anva avatar"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AnvaException(Exception):
|
|
41
|
+
"""Anva could not start or run the avatar."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AvatarSession(BaseAvatarSession):
|
|
45
|
+
"""An Anva avatar session.
|
|
46
|
+
|
|
47
|
+
Exactly one of ``avatar_id`` (an avatar from your Anva account) or
|
|
48
|
+
``preset_id`` (a saved preset, whose voice and prompt are ignored here
|
|
49
|
+
because the agent speaks) is required. The API key comes from
|
|
50
|
+
``api_key`` or ``ANVA_API_KEY``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
avatar_id: NotGivenOr[str] = NOT_GIVEN,
|
|
57
|
+
preset_id: NotGivenOr[str] = NOT_GIVEN,
|
|
58
|
+
api_url: NotGivenOr[str] = NOT_GIVEN,
|
|
59
|
+
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
60
|
+
avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN,
|
|
61
|
+
avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
|
|
62
|
+
max_duration_seconds: NotGivenOr[int] = NOT_GIVEN,
|
|
63
|
+
metadata: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
|
|
64
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
65
|
+
) -> None:
|
|
66
|
+
super().__init__()
|
|
67
|
+
self._avatar_id = avatar_id or os.getenv("ANVA_AVATAR_ID") or None
|
|
68
|
+
self._preset_id = preset_id or None
|
|
69
|
+
if bool(self._avatar_id) == bool(self._preset_id):
|
|
70
|
+
raise AnvaException("give exactly one of avatar_id (or ANVA_AVATAR_ID) and preset_id")
|
|
71
|
+
self._api_url = (api_url or os.getenv("ANVA_API_URL", DEFAULT_API_URL)).rstrip("/")
|
|
72
|
+
self._api_key = api_key or os.getenv("ANVA_API_KEY")
|
|
73
|
+
if not self._api_key:
|
|
74
|
+
raise AnvaException("api_key is required: pass it or set ANVA_API_KEY")
|
|
75
|
+
self._avatar_participant_identity = avatar_participant_identity or DEFAULT_AVATAR_IDENTITY
|
|
76
|
+
self._avatar_participant_name = avatar_participant_name or DEFAULT_AVATAR_NAME
|
|
77
|
+
self._max_duration_seconds = max_duration_seconds or None
|
|
78
|
+
self._metadata = metadata or None
|
|
79
|
+
self._conn_options = conn_options
|
|
80
|
+
self._http_session: aiohttp.ClientSession | None = None
|
|
81
|
+
self._owns_http_session = False
|
|
82
|
+
self._session_id: str | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def avatar_identity(self) -> str:
|
|
86
|
+
return self._avatar_participant_identity
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def provider(self) -> str:
|
|
90
|
+
return "anva"
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def session_id(self) -> str | None:
|
|
94
|
+
"""The Anva session behind this avatar, once started."""
|
|
95
|
+
return self._session_id
|
|
96
|
+
|
|
97
|
+
def _ensure_http_session(self) -> aiohttp.ClientSession:
|
|
98
|
+
if self._http_session is None:
|
|
99
|
+
try:
|
|
100
|
+
self._http_session = utils.http_context.http_session()
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
# Outside a job (a script, a test) there is no shared session.
|
|
103
|
+
self._http_session = aiohttp.ClientSession()
|
|
104
|
+
self._owns_http_session = True
|
|
105
|
+
return self._http_session
|
|
106
|
+
|
|
107
|
+
async def start(
|
|
108
|
+
self,
|
|
109
|
+
agent_session: AgentSession,
|
|
110
|
+
room: rtc.Room,
|
|
111
|
+
*,
|
|
112
|
+
livekit_url: NotGivenOr[str] = NOT_GIVEN,
|
|
113
|
+
livekit_api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
114
|
+
livekit_api_secret: NotGivenOr[str] = NOT_GIVEN,
|
|
115
|
+
) -> None:
|
|
116
|
+
await super().start(agent_session, room)
|
|
117
|
+
|
|
118
|
+
livekit_url = livekit_url or (os.getenv("LIVEKIT_URL") or NOT_GIVEN)
|
|
119
|
+
livekit_api_key = livekit_api_key or (os.getenv("LIVEKIT_API_KEY") or NOT_GIVEN)
|
|
120
|
+
livekit_api_secret = livekit_api_secret or (os.getenv("LIVEKIT_API_SECRET") or NOT_GIVEN)
|
|
121
|
+
if not livekit_url or not livekit_api_key or not livekit_api_secret:
|
|
122
|
+
raise AnvaException(
|
|
123
|
+
"livekit_url, livekit_api_key and livekit_api_secret must be set, "
|
|
124
|
+
"by argument or by the LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET variables"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
job_ctx = get_job_context(required=False)
|
|
128
|
+
local_identity = (
|
|
129
|
+
job_ctx.local_participant_identity if job_ctx is not None else room.local_participant.identity
|
|
130
|
+
)
|
|
131
|
+
# The avatar joins as an agent participant publishing on behalf of this
|
|
132
|
+
# agent: that attribute is how LiveKit front ends pair the two.
|
|
133
|
+
livekit_token = (
|
|
134
|
+
api.AccessToken(api_key=livekit_api_key, api_secret=livekit_api_secret)
|
|
135
|
+
.with_kind("agent")
|
|
136
|
+
.with_identity(self._avatar_participant_identity)
|
|
137
|
+
.with_name(self._avatar_participant_name)
|
|
138
|
+
.with_grants(api.VideoGrants(room_join=True, room=room.name))
|
|
139
|
+
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: local_identity})
|
|
140
|
+
.to_jwt()
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
logger.debug("starting Anva avatar session", extra={"room": room.name})
|
|
144
|
+
await self._create_session(livekit_url, livekit_token)
|
|
145
|
+
|
|
146
|
+
# The agent's speech now goes to the avatar, and the agent waits for
|
|
147
|
+
# the avatar's video before it speaks its first word.
|
|
148
|
+
agent_session.output.replace_audio_tail(
|
|
149
|
+
DataStreamAudioOutput(
|
|
150
|
+
room=room,
|
|
151
|
+
destination_identity=self._avatar_participant_identity,
|
|
152
|
+
wait_remote_track=rtc.TrackKind.KIND_VIDEO,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _session_body(self, livekit_url: str, livekit_token: str) -> dict[str, Any]:
|
|
157
|
+
body: dict[str, Any] = {
|
|
158
|
+
"service_mode": "avatar_only",
|
|
159
|
+
"livekit": {"url": livekit_url, "token": livekit_token},
|
|
160
|
+
}
|
|
161
|
+
if self._avatar_id:
|
|
162
|
+
body["avatar_id"] = self._avatar_id
|
|
163
|
+
else:
|
|
164
|
+
body["preset_id"] = self._preset_id
|
|
165
|
+
if self._max_duration_seconds:
|
|
166
|
+
body["max_duration_seconds"] = self._max_duration_seconds
|
|
167
|
+
if self._metadata:
|
|
168
|
+
body["metadata"] = self._metadata
|
|
169
|
+
return body
|
|
170
|
+
|
|
171
|
+
async def _create_session(self, livekit_url: str, livekit_token: str) -> None:
|
|
172
|
+
"""Ask Anva to join the room. Anva answers once the avatar is in it."""
|
|
173
|
+
assert self._api_key is not None
|
|
174
|
+
body = self._session_body(livekit_url, livekit_token)
|
|
175
|
+
last_error: Exception | None = None
|
|
176
|
+
for attempt in range(self._conn_options.max_retry):
|
|
177
|
+
try:
|
|
178
|
+
async with self._ensure_http_session().post(
|
|
179
|
+
f"{self._api_url}/api/v2/sessions",
|
|
180
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
181
|
+
json=body,
|
|
182
|
+
timeout=aiohttp.ClientTimeout(total=90, sock_connect=self._conn_options.timeout),
|
|
183
|
+
) as response:
|
|
184
|
+
if response.status == 201:
|
|
185
|
+
data = await response.json()
|
|
186
|
+
self._session_id = data.get("session_id")
|
|
187
|
+
logger.info(
|
|
188
|
+
"Anva avatar joined the room",
|
|
189
|
+
extra={"session_id": self._session_id, "livekit": data.get("livekit")},
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
text = await response.text()
|
|
193
|
+
# 4xx other than capacity is about the request; say so at once.
|
|
194
|
+
if 400 <= response.status < 500 and response.status not in (408, 429):
|
|
195
|
+
raise AnvaException(f"Anva refused the session ({response.status}): {text}")
|
|
196
|
+
retry_after = response.headers.get("Retry-After")
|
|
197
|
+
last_error = APIStatusError(
|
|
198
|
+
"Anva could not start the session", status_code=response.status, body=text
|
|
199
|
+
)
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Anva session not started yet",
|
|
202
|
+
extra={"status": response.status, "retry_after": retry_after, "body": text[:300]},
|
|
203
|
+
)
|
|
204
|
+
if attempt < self._conn_options.max_retry - 1:
|
|
205
|
+
await asyncio.sleep(
|
|
206
|
+
float(retry_after) if retry_after and retry_after.isdigit()
|
|
207
|
+
else self._conn_options.retry_interval
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
except AnvaException:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as e: # noqa: BLE001
|
|
213
|
+
last_error = e
|
|
214
|
+
logger.warning("could not reach the Anva API", extra={"error": str(e)})
|
|
215
|
+
if attempt < self._conn_options.max_retry - 1:
|
|
216
|
+
await asyncio.sleep(self._conn_options.retry_interval)
|
|
217
|
+
raise APIConnectionError(f"failed to start the Anva avatar session: {last_error}")
|
|
218
|
+
|
|
219
|
+
async def aclose(self) -> None:
|
|
220
|
+
# Ending the Anva session is what stops its billing; leaving the room
|
|
221
|
+
# alone would stop it too, a reconnect grace later.
|
|
222
|
+
if self._session_id and self._api_key:
|
|
223
|
+
try:
|
|
224
|
+
async with self._ensure_http_session().delete(
|
|
225
|
+
f"{self._api_url}/api/v2/sessions/{self._session_id}",
|
|
226
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
227
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
228
|
+
) as response:
|
|
229
|
+
if response.status >= 400:
|
|
230
|
+
logger.debug("Anva session already ended", extra={"status": response.status})
|
|
231
|
+
except Exception: # noqa: BLE001
|
|
232
|
+
logger.debug("could not end the Anva session", exc_info=True)
|
|
233
|
+
self._session_id = None
|
|
234
|
+
if self._owns_http_session and self._http_session is not None:
|
|
235
|
+
await self._http_session.close()
|
|
236
|
+
self._http_session = None
|
|
237
|
+
await super().aclose()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livekit-plugins-anva"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Anva avatar plugin for LiveKit Agents: a lip-synced video avatar for your voice agent."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Anva", email = "anva.ai.2026@gmail.com" }]
|
|
13
|
+
keywords = ["livekit", "agents", "avatar", "anva", "webrtc", "voice"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Video",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["livekit-agents>=1.8.2", "aiohttp>=3.9"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://anva.ai"
|
|
25
|
+
Documentation = "https://anva.ai/docs#livekit"
|
|
26
|
+
Source = "https://github.com/Anva-avatars/anva-sdk"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.version]
|
|
29
|
+
path = "livekit/plugins/anva/version.py"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["livekit"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["/livekit", "/README.md"]
|