moss-voice-server 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.
- moss_voice_server-0.1.0/LICENSE.txt +21 -0
- moss_voice_server-0.1.0/PKG-INFO +154 -0
- moss_voice_server-0.1.0/README.md +127 -0
- moss_voice_server-0.1.0/pyproject.toml +49 -0
- moss_voice_server-0.1.0/setup.cfg +4 -0
- moss_voice_server-0.1.0/src/moss_voice_server/__init__.py +10 -0
- moss_voice_server-0.1.0/src/moss_voice_server/_client.py +205 -0
- moss_voice_server-0.1.0/src/moss_voice_server/_sync.py +64 -0
- moss_voice_server-0.1.0/src/moss_voice_server/py.typed +0 -0
- moss_voice_server-0.1.0/src/moss_voice_server.egg-info/PKG-INFO +154 -0
- moss_voice_server-0.1.0/src/moss_voice_server.egg-info/SOURCES.txt +13 -0
- moss_voice_server-0.1.0/src/moss_voice_server.egg-info/dependency_links.txt +1 -0
- moss_voice_server-0.1.0/src/moss_voice_server.egg-info/requires.txt +7 -0
- moss_voice_server-0.1.0/src/moss_voice_server.egg-info/top_level.txt +1 -0
- moss_voice_server-0.1.0/tests/test_public_api.py +111 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 InferEdge Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-voice-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Backend token generation for Moss voice agents. Mint LiveKit JWTs server-side so clients never see API secrets.
|
|
5
|
+
Author-email: Moss Team <support@moss.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/InferEdge-Inc/moss
|
|
8
|
+
Project-URL: Repository, https://github.com/InferEdge-Inc/moss
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE.txt
|
|
20
|
+
Requires-Dist: httpx<1.0.0,>=0.27.0
|
|
21
|
+
Requires-Dist: livekit-api<2.0.0,>=1.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
25
|
+
Requires-Dist: black>=22.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# moss-voice-server
|
|
29
|
+
|
|
30
|
+
Backend token generation for Moss voice agents. Mints short-lived LiveKit
|
|
31
|
+
participant JWTs server-side so your API secrets never reach the browser.
|
|
32
|
+
|
|
33
|
+
A Node.js equivalent is available as [`@moss-tools/voice-server`](https://www.npmjs.com/package/@moss-tools/voice-server) on npm.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
Browser clients shouldn't hold LiveKit API secrets. The recommended flow:
|
|
38
|
+
|
|
39
|
+
1. Your frontend asks **your backend** for a join token.
|
|
40
|
+
2. Your backend calls `MossVoiceServer.create_participant_token(...)` and
|
|
41
|
+
returns the signed JWT + server URL.
|
|
42
|
+
3. The frontend connects to LiveKit with that token.
|
|
43
|
+
|
|
44
|
+
This package handles steps 1–2 from Python (FastAPI, Flask, Django, etc.).
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install moss-voice-server
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.10+.
|
|
53
|
+
|
|
54
|
+
## Usage — async (FastAPI, asyncio)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import os
|
|
58
|
+
from contextlib import asynccontextmanager
|
|
59
|
+
from fastapi import FastAPI
|
|
60
|
+
from moss_voice_server import MossVoiceServer, ParticipantInfo
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def lifespan(app: FastAPI):
|
|
65
|
+
app.state.voice_server = await MossVoiceServer.create(
|
|
66
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
67
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
68
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
69
|
+
)
|
|
70
|
+
yield
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
app = FastAPI(lifespan=lifespan)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.post("/voice/token")
|
|
77
|
+
async def issue_token(user_id: str, room: str) -> dict[str, str]:
|
|
78
|
+
server: MossVoiceServer = app.state.voice_server
|
|
79
|
+
token = server.create_participant_token(
|
|
80
|
+
ParticipantInfo(identity=user_id),
|
|
81
|
+
room_name=room,
|
|
82
|
+
)
|
|
83
|
+
return {"token": token, "url": server.get_server_url()}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage — sync (Flask, Django)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import os
|
|
90
|
+
from moss_voice_server import MossVoiceServerSync, ParticipantInfo
|
|
91
|
+
|
|
92
|
+
voice_server = MossVoiceServerSync.create(
|
|
93
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
94
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
95
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
token = voice_server.create_participant_token(
|
|
99
|
+
ParticipantInfo(identity="user_123", name="Jane"),
|
|
100
|
+
room_name="support_456",
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## API
|
|
105
|
+
|
|
106
|
+
### `MossVoiceServer.create(project_id, project_key, voice_agent_id, api_url=...)`
|
|
107
|
+
|
|
108
|
+
Async factory. Fetches credentials from the Moss API and returns an initialized
|
|
109
|
+
instance. Raises ``RuntimeError`` on network failure or invalid credentials.
|
|
110
|
+
|
|
111
|
+
### `MossVoiceServerSync.create(...)`
|
|
112
|
+
|
|
113
|
+
Same arguments, sync. Use in Flask/Django.
|
|
114
|
+
|
|
115
|
+
### `server.create_participant_token(participant, room_name, agent_name=None) -> str`
|
|
116
|
+
|
|
117
|
+
Mint a signed JWT for ``participant`` to join ``room_name``. Token TTL is
|
|
118
|
+
15 minutes. Pass ``agent_name`` only when you need to override LiveKit's
|
|
119
|
+
default dispatch rules.
|
|
120
|
+
|
|
121
|
+
### `server.get_server_url() -> str`
|
|
122
|
+
|
|
123
|
+
LiveKit WebSocket URL the client should connect to.
|
|
124
|
+
|
|
125
|
+
### `server.get_agent_name() -> str`
|
|
126
|
+
|
|
127
|
+
Configured voice agent name.
|
|
128
|
+
|
|
129
|
+
### `ParticipantInfo(identity, name=None, metadata=None, attributes=None)`
|
|
130
|
+
|
|
131
|
+
Identity payload for the token. ``identity`` is required and must be unique
|
|
132
|
+
per user.
|
|
133
|
+
|
|
134
|
+
## Environment variables
|
|
135
|
+
|
|
136
|
+
The library accepts credentials as arguments. By convention, applications
|
|
137
|
+
typically read them from these env vars:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
MOSS_PROJECT_ID=<your project id>
|
|
141
|
+
MOSS_PROJECT_KEY=<your project key>
|
|
142
|
+
MOSS_VOICE_AGENT_ID=<your voice agent id>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pip install -e ".[dev]"
|
|
149
|
+
pytest
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# moss-voice-server
|
|
2
|
+
|
|
3
|
+
Backend token generation for Moss voice agents. Mints short-lived LiveKit
|
|
4
|
+
participant JWTs server-side so your API secrets never reach the browser.
|
|
5
|
+
|
|
6
|
+
A Node.js equivalent is available as [`@moss-tools/voice-server`](https://www.npmjs.com/package/@moss-tools/voice-server) on npm.
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
Browser clients shouldn't hold LiveKit API secrets. The recommended flow:
|
|
11
|
+
|
|
12
|
+
1. Your frontend asks **your backend** for a join token.
|
|
13
|
+
2. Your backend calls `MossVoiceServer.create_participant_token(...)` and
|
|
14
|
+
returns the signed JWT + server URL.
|
|
15
|
+
3. The frontend connects to LiveKit with that token.
|
|
16
|
+
|
|
17
|
+
This package handles steps 1–2 from Python (FastAPI, Flask, Django, etc.).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install moss-voice-server
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Python 3.10+.
|
|
26
|
+
|
|
27
|
+
## Usage — async (FastAPI, asyncio)
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import os
|
|
31
|
+
from contextlib import asynccontextmanager
|
|
32
|
+
from fastapi import FastAPI
|
|
33
|
+
from moss_voice_server import MossVoiceServer, ParticipantInfo
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def lifespan(app: FastAPI):
|
|
38
|
+
app.state.voice_server = await MossVoiceServer.create(
|
|
39
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
40
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
41
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
42
|
+
)
|
|
43
|
+
yield
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
app = FastAPI(lifespan=lifespan)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.post("/voice/token")
|
|
50
|
+
async def issue_token(user_id: str, room: str) -> dict[str, str]:
|
|
51
|
+
server: MossVoiceServer = app.state.voice_server
|
|
52
|
+
token = server.create_participant_token(
|
|
53
|
+
ParticipantInfo(identity=user_id),
|
|
54
|
+
room_name=room,
|
|
55
|
+
)
|
|
56
|
+
return {"token": token, "url": server.get_server_url()}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage — sync (Flask, Django)
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import os
|
|
63
|
+
from moss_voice_server import MossVoiceServerSync, ParticipantInfo
|
|
64
|
+
|
|
65
|
+
voice_server = MossVoiceServerSync.create(
|
|
66
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
67
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
68
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
token = voice_server.create_participant_token(
|
|
72
|
+
ParticipantInfo(identity="user_123", name="Jane"),
|
|
73
|
+
room_name="support_456",
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
### `MossVoiceServer.create(project_id, project_key, voice_agent_id, api_url=...)`
|
|
80
|
+
|
|
81
|
+
Async factory. Fetches credentials from the Moss API and returns an initialized
|
|
82
|
+
instance. Raises ``RuntimeError`` on network failure or invalid credentials.
|
|
83
|
+
|
|
84
|
+
### `MossVoiceServerSync.create(...)`
|
|
85
|
+
|
|
86
|
+
Same arguments, sync. Use in Flask/Django.
|
|
87
|
+
|
|
88
|
+
### `server.create_participant_token(participant, room_name, agent_name=None) -> str`
|
|
89
|
+
|
|
90
|
+
Mint a signed JWT for ``participant`` to join ``room_name``. Token TTL is
|
|
91
|
+
15 minutes. Pass ``agent_name`` only when you need to override LiveKit's
|
|
92
|
+
default dispatch rules.
|
|
93
|
+
|
|
94
|
+
### `server.get_server_url() -> str`
|
|
95
|
+
|
|
96
|
+
LiveKit WebSocket URL the client should connect to.
|
|
97
|
+
|
|
98
|
+
### `server.get_agent_name() -> str`
|
|
99
|
+
|
|
100
|
+
Configured voice agent name.
|
|
101
|
+
|
|
102
|
+
### `ParticipantInfo(identity, name=None, metadata=None, attributes=None)`
|
|
103
|
+
|
|
104
|
+
Identity payload for the token. ``identity`` is required and must be unique
|
|
105
|
+
per user.
|
|
106
|
+
|
|
107
|
+
## Environment variables
|
|
108
|
+
|
|
109
|
+
The library accepts credentials as arguments. By convention, applications
|
|
110
|
+
typically read them from these env vars:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
MOSS_PROJECT_ID=<your project id>
|
|
114
|
+
MOSS_PROJECT_KEY=<your project key>
|
|
115
|
+
MOSS_VOICE_AGENT_ID=<your voice agent id>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install -e ".[dev]"
|
|
122
|
+
pytest
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moss-voice-server"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Backend token generation for Moss voice agents. Mint LiveKit JWTs server-side so clients never see API secrets."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE.txt"]
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "Moss Team", email = "support@moss.dev"}
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27.0,<1.0.0",
|
|
28
|
+
"livekit-api>=1.0.0,<2.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=7.0",
|
|
34
|
+
"pytest-asyncio>=0.23",
|
|
35
|
+
"black>=22.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/InferEdge-Inc/moss"
|
|
40
|
+
Repository = "https://github.com/InferEdge-Inc/moss"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-dir]
|
|
46
|
+
"" = "src"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.package-data]
|
|
49
|
+
moss_voice_server = ["py.typed"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Moss Voice Server — backend token generation for Moss voice agents.
|
|
2
|
+
|
|
3
|
+
Mint LiveKit participant JWTs server-side so the API secret never reaches the
|
|
4
|
+
browser. Mirrors the Node ``@moss-tools/voice-server`` package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._client import MossVoiceServer, ParticipantInfo
|
|
8
|
+
from ._sync import MossVoiceServerSync
|
|
9
|
+
|
|
10
|
+
__all__ = ["MossVoiceServer", "MossVoiceServerSync", "ParticipantInfo"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Async ``MossVoiceServer`` — mints LiveKit JWTs from Moss-issued credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from livekit.api import AccessToken, RoomAgentDispatch, RoomConfiguration, VideoGrants
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://service.usemoss.dev"
|
|
14
|
+
_CREDENTIALS_PATH = "/api/voice-agent/get-voice-agent"
|
|
15
|
+
_TOKEN_TTL = timedelta(minutes=15)
|
|
16
|
+
_FETCH_TIMEOUT_SECONDS = 10.0
|
|
17
|
+
_CREDENTIAL_FIELDS = (
|
|
18
|
+
"voice_agent_url",
|
|
19
|
+
"voice_agent_api_key",
|
|
20
|
+
"voice_agent_api_secret",
|
|
21
|
+
"voice_agent_name",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ParticipantInfo:
|
|
27
|
+
"""Identity and optional metadata for the participant the token is minted for."""
|
|
28
|
+
|
|
29
|
+
identity: str
|
|
30
|
+
name: Optional[str] = None
|
|
31
|
+
metadata: Optional[str] = None
|
|
32
|
+
attributes: Optional[dict[str, str]] = field(default=None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class _Credentials:
|
|
37
|
+
voice_agent_url: str
|
|
38
|
+
voice_agent_api_key: str
|
|
39
|
+
voice_agent_api_secret: str
|
|
40
|
+
voice_agent_name: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _MossVoiceServerBase:
|
|
44
|
+
"""Shared sync surface for both async and sync flavors of the server.
|
|
45
|
+
|
|
46
|
+
Subclasses are responsible for populating ``self._credentials`` via either
|
|
47
|
+
an async or sync fetch implementation. Everything else — validation,
|
|
48
|
+
request URL building, token minting — is sync and lives here.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
project_id: str,
|
|
54
|
+
project_key: str,
|
|
55
|
+
voice_agent_id: str,
|
|
56
|
+
api_url: str = DEFAULT_API_URL,
|
|
57
|
+
) -> None:
|
|
58
|
+
if not project_id or not project_key or not voice_agent_id:
|
|
59
|
+
raise ValueError("project_id, project_key, and voice_agent_id are required")
|
|
60
|
+
self._project_id = project_id
|
|
61
|
+
self._project_key = project_key
|
|
62
|
+
self._voice_agent_id = voice_agent_id
|
|
63
|
+
self._api_url = api_url.rstrip("/")
|
|
64
|
+
self._credentials: Optional[_Credentials] = None
|
|
65
|
+
|
|
66
|
+
def get_server_url(self) -> str:
|
|
67
|
+
"""LiveKit WebSocket URL the client should connect to."""
|
|
68
|
+
return self._require_credentials().voice_agent_url
|
|
69
|
+
|
|
70
|
+
def get_agent_name(self) -> str:
|
|
71
|
+
"""Configured voice agent name."""
|
|
72
|
+
return self._require_credentials().voice_agent_name
|
|
73
|
+
|
|
74
|
+
def create_participant_token(
|
|
75
|
+
self,
|
|
76
|
+
participant: ParticipantInfo,
|
|
77
|
+
room_name: str,
|
|
78
|
+
agent_name: Optional[str] = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Mint a signed LiveKit JWT for a participant joining ``room_name``.
|
|
81
|
+
|
|
82
|
+
The token is valid for 15 minutes. Pass ``agent_name`` only to override
|
|
83
|
+
the default agent; otherwise LiveKit dispatch rules handle agent joining.
|
|
84
|
+
"""
|
|
85
|
+
creds = self._require_credentials()
|
|
86
|
+
|
|
87
|
+
token = (
|
|
88
|
+
AccessToken(creds.voice_agent_api_key, creds.voice_agent_api_secret)
|
|
89
|
+
.with_identity(participant.identity)
|
|
90
|
+
.with_ttl(_TOKEN_TTL)
|
|
91
|
+
.with_grants(
|
|
92
|
+
VideoGrants(
|
|
93
|
+
room=room_name,
|
|
94
|
+
room_join=True,
|
|
95
|
+
can_publish=True,
|
|
96
|
+
can_publish_data=True,
|
|
97
|
+
can_subscribe=True,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
if participant.name is not None:
|
|
102
|
+
token = token.with_name(participant.name)
|
|
103
|
+
if participant.metadata is not None:
|
|
104
|
+
token = token.with_metadata(participant.metadata)
|
|
105
|
+
if participant.attributes is not None:
|
|
106
|
+
token = token.with_attributes(participant.attributes)
|
|
107
|
+
|
|
108
|
+
if agent_name:
|
|
109
|
+
token = token.with_room_config(
|
|
110
|
+
RoomConfiguration(agents=[RoomAgentDispatch(agent_name=agent_name)])
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return token.to_jwt()
|
|
114
|
+
|
|
115
|
+
def _credentials_endpoint(self) -> str:
|
|
116
|
+
return f"{self._api_url}{_CREDENTIALS_PATH}?voice_agent_id={quote(self._voice_agent_id)}"
|
|
117
|
+
|
|
118
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
119
|
+
return {
|
|
120
|
+
"X-Project-Id": self._project_id,
|
|
121
|
+
"X-Project-Key": self._project_key,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def _require_credentials(self) -> _Credentials:
|
|
125
|
+
if self._credentials is None:
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
f"{type(self).__name__} not initialized — call {type(self).__name__}.create()"
|
|
128
|
+
)
|
|
129
|
+
return self._credentials
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MossVoiceServer(_MossVoiceServerBase):
|
|
133
|
+
"""Backend client that mints LiveKit participant tokens for voice agents.
|
|
134
|
+
|
|
135
|
+
Fetch credentials once at app startup via :meth:`create`, then mint per-user
|
|
136
|
+
tokens on demand. Credentials never leave the server.
|
|
137
|
+
|
|
138
|
+
Example::
|
|
139
|
+
|
|
140
|
+
server = await MossVoiceServer.create(
|
|
141
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
142
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
143
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
url = server.get_server_url()
|
|
147
|
+
token = server.create_participant_token(
|
|
148
|
+
ParticipantInfo(identity="user_123", name="John"),
|
|
149
|
+
room_name="support_room_456",
|
|
150
|
+
)
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
async def create(
|
|
155
|
+
cls,
|
|
156
|
+
project_id: str,
|
|
157
|
+
project_key: str,
|
|
158
|
+
voice_agent_id: str,
|
|
159
|
+
api_url: str = DEFAULT_API_URL,
|
|
160
|
+
) -> "MossVoiceServer":
|
|
161
|
+
"""Create and initialize a server instance by fetching credentials."""
|
|
162
|
+
instance = cls(project_id, project_key, voice_agent_id, api_url)
|
|
163
|
+
await instance._initialize()
|
|
164
|
+
return instance
|
|
165
|
+
|
|
166
|
+
async def _initialize(self) -> None:
|
|
167
|
+
try:
|
|
168
|
+
async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT_SECONDS) as client:
|
|
169
|
+
response = await client.get(
|
|
170
|
+
self._credentials_endpoint(),
|
|
171
|
+
headers=self._auth_headers(),
|
|
172
|
+
)
|
|
173
|
+
self._credentials = _parse_credentials_response(response)
|
|
174
|
+
except httpx.TimeoutException as exc:
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
"Failed to initialize MossVoiceServer: request timed out after 10 seconds"
|
|
177
|
+
) from exc
|
|
178
|
+
except httpx.HTTPError as exc:
|
|
179
|
+
raise RuntimeError(f"Failed to initialize MossVoiceServer: {exc}") from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_credentials_response(response: httpx.Response) -> _Credentials:
|
|
183
|
+
if response.status_code >= 400:
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
f"Failed to fetch credentials: {response.status_code} {response.text}"
|
|
186
|
+
)
|
|
187
|
+
try:
|
|
188
|
+
data = response.json()
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
f"Moss API returned non-JSON response: {response.text!r}"
|
|
192
|
+
) from exc
|
|
193
|
+
if not isinstance(data, dict):
|
|
194
|
+
raise RuntimeError(f"Moss API returned unexpected response shape: {data!r}")
|
|
195
|
+
missing = [field for field in _CREDENTIAL_FIELDS if field not in data]
|
|
196
|
+
if missing:
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
f"Moss API response missing field(s): {', '.join(missing)}"
|
|
199
|
+
)
|
|
200
|
+
return _Credentials(
|
|
201
|
+
voice_agent_url=data["voice_agent_url"],
|
|
202
|
+
voice_agent_api_key=data["voice_agent_api_key"],
|
|
203
|
+
voice_agent_api_secret=data["voice_agent_api_secret"],
|
|
204
|
+
voice_agent_name=data["voice_agent_name"],
|
|
205
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Sync wrapper for ``MossVoiceServer`` — for Flask, Django, and other sync frameworks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._client import (
|
|
8
|
+
DEFAULT_API_URL,
|
|
9
|
+
ParticipantInfo,
|
|
10
|
+
_FETCH_TIMEOUT_SECONDS,
|
|
11
|
+
_MossVoiceServerBase,
|
|
12
|
+
_parse_credentials_response,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MossVoiceServerSync(_MossVoiceServerBase):
|
|
17
|
+
"""Sync flavor of :class:`MossVoiceServer`.
|
|
18
|
+
|
|
19
|
+
Initialization performs a single blocking HTTP request. After that, token
|
|
20
|
+
minting is purely CPU work (JWT signing).
|
|
21
|
+
|
|
22
|
+
Example::
|
|
23
|
+
|
|
24
|
+
server = MossVoiceServerSync.create(
|
|
25
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
26
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
27
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
token = server.create_participant_token(
|
|
31
|
+
ParticipantInfo(identity="user_123"),
|
|
32
|
+
room_name="support_room_456",
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def create(
|
|
38
|
+
cls,
|
|
39
|
+
project_id: str,
|
|
40
|
+
project_key: str,
|
|
41
|
+
voice_agent_id: str,
|
|
42
|
+
api_url: str = DEFAULT_API_URL,
|
|
43
|
+
) -> "MossVoiceServerSync":
|
|
44
|
+
instance = cls(project_id, project_key, voice_agent_id, api_url)
|
|
45
|
+
instance._initialize_sync()
|
|
46
|
+
return instance
|
|
47
|
+
|
|
48
|
+
def _initialize_sync(self) -> None:
|
|
49
|
+
try:
|
|
50
|
+
with httpx.Client(timeout=_FETCH_TIMEOUT_SECONDS) as client:
|
|
51
|
+
response = client.get(
|
|
52
|
+
self._credentials_endpoint(),
|
|
53
|
+
headers=self._auth_headers(),
|
|
54
|
+
)
|
|
55
|
+
self._credentials = _parse_credentials_response(response)
|
|
56
|
+
except httpx.TimeoutException as exc:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"Failed to initialize MossVoiceServerSync: request timed out after 10 seconds"
|
|
59
|
+
) from exc
|
|
60
|
+
except httpx.HTTPError as exc:
|
|
61
|
+
raise RuntimeError(f"Failed to initialize MossVoiceServerSync: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["MossVoiceServerSync", "ParticipantInfo"]
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-voice-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Backend token generation for Moss voice agents. Mint LiveKit JWTs server-side so clients never see API secrets.
|
|
5
|
+
Author-email: Moss Team <support@moss.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/InferEdge-Inc/moss
|
|
8
|
+
Project-URL: Repository, https://github.com/InferEdge-Inc/moss
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE.txt
|
|
20
|
+
Requires-Dist: httpx<1.0.0,>=0.27.0
|
|
21
|
+
Requires-Dist: livekit-api<2.0.0,>=1.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
25
|
+
Requires-Dist: black>=22.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# moss-voice-server
|
|
29
|
+
|
|
30
|
+
Backend token generation for Moss voice agents. Mints short-lived LiveKit
|
|
31
|
+
participant JWTs server-side so your API secrets never reach the browser.
|
|
32
|
+
|
|
33
|
+
A Node.js equivalent is available as [`@moss-tools/voice-server`](https://www.npmjs.com/package/@moss-tools/voice-server) on npm.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
Browser clients shouldn't hold LiveKit API secrets. The recommended flow:
|
|
38
|
+
|
|
39
|
+
1. Your frontend asks **your backend** for a join token.
|
|
40
|
+
2. Your backend calls `MossVoiceServer.create_participant_token(...)` and
|
|
41
|
+
returns the signed JWT + server URL.
|
|
42
|
+
3. The frontend connects to LiveKit with that token.
|
|
43
|
+
|
|
44
|
+
This package handles steps 1–2 from Python (FastAPI, Flask, Django, etc.).
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install moss-voice-server
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.10+.
|
|
53
|
+
|
|
54
|
+
## Usage — async (FastAPI, asyncio)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import os
|
|
58
|
+
from contextlib import asynccontextmanager
|
|
59
|
+
from fastapi import FastAPI
|
|
60
|
+
from moss_voice_server import MossVoiceServer, ParticipantInfo
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def lifespan(app: FastAPI):
|
|
65
|
+
app.state.voice_server = await MossVoiceServer.create(
|
|
66
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
67
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
68
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
69
|
+
)
|
|
70
|
+
yield
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
app = FastAPI(lifespan=lifespan)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.post("/voice/token")
|
|
77
|
+
async def issue_token(user_id: str, room: str) -> dict[str, str]:
|
|
78
|
+
server: MossVoiceServer = app.state.voice_server
|
|
79
|
+
token = server.create_participant_token(
|
|
80
|
+
ParticipantInfo(identity=user_id),
|
|
81
|
+
room_name=room,
|
|
82
|
+
)
|
|
83
|
+
return {"token": token, "url": server.get_server_url()}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage — sync (Flask, Django)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import os
|
|
90
|
+
from moss_voice_server import MossVoiceServerSync, ParticipantInfo
|
|
91
|
+
|
|
92
|
+
voice_server = MossVoiceServerSync.create(
|
|
93
|
+
project_id=os.environ["MOSS_PROJECT_ID"],
|
|
94
|
+
project_key=os.environ["MOSS_PROJECT_KEY"],
|
|
95
|
+
voice_agent_id=os.environ["MOSS_VOICE_AGENT_ID"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
token = voice_server.create_participant_token(
|
|
99
|
+
ParticipantInfo(identity="user_123", name="Jane"),
|
|
100
|
+
room_name="support_456",
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## API
|
|
105
|
+
|
|
106
|
+
### `MossVoiceServer.create(project_id, project_key, voice_agent_id, api_url=...)`
|
|
107
|
+
|
|
108
|
+
Async factory. Fetches credentials from the Moss API and returns an initialized
|
|
109
|
+
instance. Raises ``RuntimeError`` on network failure or invalid credentials.
|
|
110
|
+
|
|
111
|
+
### `MossVoiceServerSync.create(...)`
|
|
112
|
+
|
|
113
|
+
Same arguments, sync. Use in Flask/Django.
|
|
114
|
+
|
|
115
|
+
### `server.create_participant_token(participant, room_name, agent_name=None) -> str`
|
|
116
|
+
|
|
117
|
+
Mint a signed JWT for ``participant`` to join ``room_name``. Token TTL is
|
|
118
|
+
15 minutes. Pass ``agent_name`` only when you need to override LiveKit's
|
|
119
|
+
default dispatch rules.
|
|
120
|
+
|
|
121
|
+
### `server.get_server_url() -> str`
|
|
122
|
+
|
|
123
|
+
LiveKit WebSocket URL the client should connect to.
|
|
124
|
+
|
|
125
|
+
### `server.get_agent_name() -> str`
|
|
126
|
+
|
|
127
|
+
Configured voice agent name.
|
|
128
|
+
|
|
129
|
+
### `ParticipantInfo(identity, name=None, metadata=None, attributes=None)`
|
|
130
|
+
|
|
131
|
+
Identity payload for the token. ``identity`` is required and must be unique
|
|
132
|
+
per user.
|
|
133
|
+
|
|
134
|
+
## Environment variables
|
|
135
|
+
|
|
136
|
+
The library accepts credentials as arguments. By convention, applications
|
|
137
|
+
typically read them from these env vars:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
MOSS_PROJECT_ID=<your project id>
|
|
141
|
+
MOSS_PROJECT_KEY=<your project key>
|
|
142
|
+
MOSS_VOICE_AGENT_ID=<your voice agent id>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pip install -e ".[dev]"
|
|
149
|
+
pytest
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/moss_voice_server/__init__.py
|
|
5
|
+
src/moss_voice_server/_client.py
|
|
6
|
+
src/moss_voice_server/_sync.py
|
|
7
|
+
src/moss_voice_server/py.typed
|
|
8
|
+
src/moss_voice_server.egg-info/PKG-INFO
|
|
9
|
+
src/moss_voice_server.egg-info/SOURCES.txt
|
|
10
|
+
src/moss_voice_server.egg-info/dependency_links.txt
|
|
11
|
+
src/moss_voice_server.egg-info/requires.txt
|
|
12
|
+
src/moss_voice_server.egg-info/top_level.txt
|
|
13
|
+
tests/test_public_api.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moss_voice_server
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Smoke tests for the public API surface.
|
|
2
|
+
|
|
3
|
+
Integration tests against the live Moss API require real credentials and are
|
|
4
|
+
not run here. The token-minting logic is covered by a fake-response unit test.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import moss_voice_server
|
|
15
|
+
from moss_voice_server import MossVoiceServer, MossVoiceServerSync, ParticipantInfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_public_api_exports() -> None:
|
|
19
|
+
assert sorted(moss_voice_server.__all__) == [
|
|
20
|
+
"MossVoiceServer",
|
|
21
|
+
"MossVoiceServerSync",
|
|
22
|
+
"ParticipantInfo",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_required_args_validation() -> None:
|
|
27
|
+
with pytest.raises(ValueError):
|
|
28
|
+
MossVoiceServer(project_id="", project_key="k", voice_agent_id="v")
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
MossVoiceServerSync(project_id="p", project_key="", voice_agent_id="v")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_missing_credential_field_raises_descriptive_error(
|
|
34
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
35
|
+
) -> None:
|
|
36
|
+
# Response missing voice_agent_api_secret
|
|
37
|
+
bad_response = {
|
|
38
|
+
"voice_agent_url": "wss://example.livekit.cloud",
|
|
39
|
+
"voice_agent_api_key": "APItest",
|
|
40
|
+
"voice_agent_name": "moss-agent",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
transport = httpx.MockTransport(lambda _: httpx.Response(200, json=bad_response))
|
|
44
|
+
original_client = httpx.Client
|
|
45
|
+
|
|
46
|
+
def patched_client(*args: object, **kwargs: object) -> httpx.Client:
|
|
47
|
+
kwargs["transport"] = transport
|
|
48
|
+
return original_client(*args, **kwargs) # type: ignore[arg-type]
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(httpx, "Client", patched_client)
|
|
51
|
+
|
|
52
|
+
with pytest.raises(RuntimeError, match="voice_agent_api_secret"):
|
|
53
|
+
MossVoiceServerSync.create(project_id="p", project_key="k", voice_agent_id="v")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_sync_class_does_not_expose_async_create() -> None:
|
|
57
|
+
# MossVoiceServerSync.create must be a plain sync classmethod — not async.
|
|
58
|
+
import inspect
|
|
59
|
+
|
|
60
|
+
assert not inspect.iscoroutinefunction(MossVoiceServerSync.create)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_create_participant_token_signs_jwt(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
64
|
+
fake_creds = {
|
|
65
|
+
"voice_agent_url": "wss://example.livekit.cloud",
|
|
66
|
+
"voice_agent_api_key": "APItest",
|
|
67
|
+
"voice_agent_api_secret": "secret_value_long_enough_for_hmac",
|
|
68
|
+
"voice_agent_name": "moss-agent",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
72
|
+
assert request.headers["X-Project-Id"] == "p"
|
|
73
|
+
assert request.headers["X-Project-Key"] == "k"
|
|
74
|
+
assert "voice_agent_id=v" in str(request.url)
|
|
75
|
+
return httpx.Response(200, json=fake_creds)
|
|
76
|
+
|
|
77
|
+
transport = httpx.MockTransport(handler)
|
|
78
|
+
|
|
79
|
+
# Patch httpx.Client to use the mock transport (sync path)
|
|
80
|
+
original_client = httpx.Client
|
|
81
|
+
|
|
82
|
+
def patched_client(*args: object, **kwargs: object) -> httpx.Client:
|
|
83
|
+
kwargs["transport"] = transport
|
|
84
|
+
return original_client(*args, **kwargs) # type: ignore[arg-type]
|
|
85
|
+
|
|
86
|
+
monkeypatch.setattr(httpx, "Client", patched_client)
|
|
87
|
+
|
|
88
|
+
server = MossVoiceServerSync.create(project_id="p", project_key="k", voice_agent_id="v")
|
|
89
|
+
|
|
90
|
+
assert server.get_server_url() == "wss://example.livekit.cloud"
|
|
91
|
+
assert server.get_agent_name() == "moss-agent"
|
|
92
|
+
|
|
93
|
+
token = server.create_participant_token(
|
|
94
|
+
ParticipantInfo(identity="user_123", name="Jane"),
|
|
95
|
+
room_name="room_abc",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert isinstance(token, str)
|
|
99
|
+
# JWT is three base64url segments separated by dots
|
|
100
|
+
assert token.count(".") == 2
|
|
101
|
+
|
|
102
|
+
# Decode payload (middle segment) — base64url, no padding
|
|
103
|
+
import base64
|
|
104
|
+
|
|
105
|
+
payload_segment = token.split(".")[1]
|
|
106
|
+
payload_segment += "=" * (-len(payload_segment) % 4)
|
|
107
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_segment))
|
|
108
|
+
assert payload["sub"] == "user_123"
|
|
109
|
+
assert payload["name"] == "Jane"
|
|
110
|
+
assert payload["video"]["room"] == "room_abc"
|
|
111
|
+
assert payload["video"]["roomJoin"] is True
|