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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ httpx<1.0.0,>=0.27.0
2
+ livekit-api<2.0.0,>=1.0.0
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ pytest-asyncio>=0.23
7
+ black>=22.0
@@ -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