rymi 1.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.
- rymi-1.1.0/LICENSE +21 -0
- rymi-1.1.0/PKG-INFO +54 -0
- rymi-1.1.0/README.md +28 -0
- rymi-1.1.0/pyproject.toml +38 -0
- rymi-1.1.0/rymi/__init__.py +27 -0
- rymi-1.1.0/rymi/client.py +76 -0
- rymi-1.1.0/rymi/resources/agents.py +159 -0
- rymi-1.1.0/rymi/resources/billing.py +13 -0
- rymi-1.1.0/rymi/resources/calls.py +74 -0
- rymi-1.1.0/rymi/resources/keys.py +16 -0
- rymi-1.1.0/rymi/resources/numbers.py +24 -0
- rymi-1.1.0/rymi/resources/telephony.py +20 -0
- rymi-1.1.0/rymi/resources/templates.py +12 -0
- rymi-1.1.0/rymi/resources/webhooks.py +46 -0
- rymi-1.1.0/rymi.egg-info/PKG-INFO +54 -0
- rymi-1.1.0/rymi.egg-info/SOURCES.txt +19 -0
- rymi-1.1.0/rymi.egg-info/dependency_links.txt +1 -0
- rymi-1.1.0/rymi.egg-info/requires.txt +5 -0
- rymi-1.1.0/rymi.egg-info/top_level.txt +1 -0
- rymi-1.1.0/setup.cfg +4 -0
- rymi-1.1.0/tests/test_sdk.py +78 -0
rymi-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rymi AI
|
|
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.
|
rymi-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rymi
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: The official Python REST API wrapper for Rymi.
|
|
5
|
+
Author-email: Rymi AI <engineering@rymi.live>
|
|
6
|
+
Project-URL: Homepage, https://rymi.live
|
|
7
|
+
Project-URL: Documentation, https://rymi.live/docs
|
|
8
|
+
Project-URL: Source, https://github.com/rymi-ai/rymi
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.25.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: responses>=0.23.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Rymi Python SDK
|
|
28
|
+
|
|
29
|
+
The official Python wrapper for the Rymi Voice AI REST API. Manage agents (incl. multi-language and model-stack config), phone numbers, calls, knowledge sources, evaluations, usage, and templates, plus webhook verification.
|
|
30
|
+
|
|
31
|
+
Resources: `agents` · `calls` · `numbers` · `telephony` · `keys` · `billing` · `templates` · `webhooks`.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install rymi
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from rymi import Rymi
|
|
44
|
+
|
|
45
|
+
# Automatically picks up RYMI_API_KEY from environment
|
|
46
|
+
client = Rymi()
|
|
47
|
+
|
|
48
|
+
# Or pass explicitly
|
|
49
|
+
# client = Rymi(api_key="rymi_live_...")
|
|
50
|
+
|
|
51
|
+
agents = client.agents.list()
|
|
52
|
+
for agent in agents["agents"]:
|
|
53
|
+
print(agent['name'])
|
|
54
|
+
```
|
rymi-1.1.0/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Rymi Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python wrapper for the Rymi Voice AI REST API. Manage agents (incl. multi-language and model-stack config), phone numbers, calls, knowledge sources, evaluations, usage, and templates, plus webhook verification.
|
|
4
|
+
|
|
5
|
+
Resources: `agents` · `calls` · `numbers` · `telephony` · `keys` · `billing` · `templates` · `webhooks`.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install rymi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from rymi import Rymi
|
|
18
|
+
|
|
19
|
+
# Automatically picks up RYMI_API_KEY from environment
|
|
20
|
+
client = Rymi()
|
|
21
|
+
|
|
22
|
+
# Or pass explicitly
|
|
23
|
+
# client = Rymi(api_key="rymi_live_...")
|
|
24
|
+
|
|
25
|
+
agents = client.agents.list()
|
|
26
|
+
for agent in agents["agents"]:
|
|
27
|
+
print(agent['name'])
|
|
28
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rymi"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "The official Python REST API wrapper for Rymi."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Rymi AI", email = "engineering@rymi.live" }
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"requests>=2.25.0"
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://rymi.live"
|
|
31
|
+
Documentation = "https://rymi.live/docs"
|
|
32
|
+
Source = "https://github.com/rymi-ai/rymi"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=7.0.0",
|
|
37
|
+
"responses>=0.23.0"
|
|
38
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from .client import RymiClient, RymiError
|
|
3
|
+
from .resources.agents import AgentsResource
|
|
4
|
+
from .resources.calls import CallsResource
|
|
5
|
+
from .resources.numbers import NumbersResource
|
|
6
|
+
from .resources.telephony import TelephonyResource
|
|
7
|
+
from .resources.keys import KeysResource
|
|
8
|
+
from .resources.billing import BillingResource
|
|
9
|
+
from .resources.templates import TemplatesResource
|
|
10
|
+
from .resources.webhooks import WebhooksUtility
|
|
11
|
+
|
|
12
|
+
class Rymi:
|
|
13
|
+
"""The official Python SDK for the Rymi Voice API."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
|
16
|
+
self._client = RymiClient(api_key=api_key, base_url=base_url)
|
|
17
|
+
|
|
18
|
+
self.agents = AgentsResource(self._client)
|
|
19
|
+
self.calls = CallsResource(self._client)
|
|
20
|
+
self.numbers = NumbersResource(self._client)
|
|
21
|
+
self.telephony = TelephonyResource(self._client)
|
|
22
|
+
self.keys = KeysResource(self._client)
|
|
23
|
+
self.billing = BillingResource(self._client)
|
|
24
|
+
self.templates = TemplatesResource(self._client)
|
|
25
|
+
self.webhooks = WebhooksUtility()
|
|
26
|
+
|
|
27
|
+
__all__ = ["Rymi", "RymiError"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import requests
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
class RymiError(Exception):
|
|
7
|
+
"""Custom exception raised for Rymi API Errors."""
|
|
8
|
+
def __init__(self, message: str, status: Optional[int] = None, code: Optional[str] = None):
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.status = status
|
|
11
|
+
self.code = code
|
|
12
|
+
|
|
13
|
+
class RymiClient:
|
|
14
|
+
"""Core HTTP Client for the Rymi API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
|
17
|
+
self.api_key = api_key or os.environ.get("RYMI_API_KEY")
|
|
18
|
+
if not self.api_key:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
"The Rymi API Key must be set either by passing `api_key` to the client "
|
|
21
|
+
"or setting the `RYMI_API_KEY` environment variable."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
self.base_url = (base_url or "https://api.rymi.live/v1").rstrip("/")
|
|
25
|
+
|
|
26
|
+
self.session = requests.Session()
|
|
27
|
+
self.session.headers.update({
|
|
28
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
29
|
+
"Accept": "application/json",
|
|
30
|
+
"User-Agent": "rymi-python/1.0.0"
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
def request(self, method: str, path: str, json: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
34
|
+
url = f"{self.base_url}{path if path.startswith('/') else '/' + path}"
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
response = self.session.request(method, url, json=json, params=params)
|
|
38
|
+
|
|
39
|
+
# Attempt to parse JSON regardless of status code to check for error details
|
|
40
|
+
data = None
|
|
41
|
+
if "application/json" in response.headers.get("Content-Type", ""):
|
|
42
|
+
try:
|
|
43
|
+
data = response.json()
|
|
44
|
+
except ValueError:
|
|
45
|
+
data = response.text
|
|
46
|
+
else:
|
|
47
|
+
data = response.text
|
|
48
|
+
|
|
49
|
+
if not response.ok:
|
|
50
|
+
message = response.reason
|
|
51
|
+
code = None
|
|
52
|
+
|
|
53
|
+
if isinstance(data, dict):
|
|
54
|
+
message = data.get("error", message)
|
|
55
|
+
code = data.get("code")
|
|
56
|
+
elif isinstance(data, str) and data:
|
|
57
|
+
message = data
|
|
58
|
+
|
|
59
|
+
raise RymiError(message, status=response.status_code, code=code)
|
|
60
|
+
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
except requests.exceptions.RequestException as e:
|
|
64
|
+
raise RymiError(f"Network Error: {str(e)}")
|
|
65
|
+
|
|
66
|
+
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
67
|
+
return self.request("GET", path, params=params)
|
|
68
|
+
|
|
69
|
+
def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any:
|
|
70
|
+
return self.request("POST", path, json=json)
|
|
71
|
+
|
|
72
|
+
def put(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any:
|
|
73
|
+
return self.request("PUT", path, json=json)
|
|
74
|
+
|
|
75
|
+
def delete(self, path: str) -> Any:
|
|
76
|
+
return self.request("DELETE", path)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from rymi.client import RymiClient
|
|
3
|
+
|
|
4
|
+
class AgentsResource:
|
|
5
|
+
"""Manage AI Agents."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client: RymiClient):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
11
|
+
"""Retrieve a list of all your AI Agents."""
|
|
12
|
+
return self.client.get("/agents", params=params or None)
|
|
13
|
+
|
|
14
|
+
def retrieve(self, agent_id: str) -> Dict[str, Any]:
|
|
15
|
+
"""Retrieve a single AI Agent by its unique ID."""
|
|
16
|
+
return self.client.get(f"/agents/{agent_id}")
|
|
17
|
+
|
|
18
|
+
def create(self, name: str, system_prompt: Optional[str] = None, voice: Optional[str] = None, language: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
|
|
19
|
+
"""Create a new AI Agent."""
|
|
20
|
+
prompt = kwargs.pop("prompt", None)
|
|
21
|
+
payload = {"name": name}
|
|
22
|
+
if system_prompt or prompt:
|
|
23
|
+
payload["system_prompt"] = system_prompt or prompt
|
|
24
|
+
if voice:
|
|
25
|
+
payload["voice"] = voice
|
|
26
|
+
if language:
|
|
27
|
+
payload["language"] = language
|
|
28
|
+
payload.update(kwargs)
|
|
29
|
+
|
|
30
|
+
return self.client.post("/agents", json=payload)
|
|
31
|
+
|
|
32
|
+
def update(self, agent_id: str, name: Optional[str] = None, system_prompt: Optional[str] = None, voice: Optional[str] = None, language: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
|
|
33
|
+
"""Update an existing AI Agent."""
|
|
34
|
+
prompt = kwargs.pop("prompt", None)
|
|
35
|
+
payload = {}
|
|
36
|
+
if name: payload["name"] = name
|
|
37
|
+
if system_prompt or prompt: payload["system_prompt"] = system_prompt or prompt
|
|
38
|
+
if voice: payload["voice"] = voice
|
|
39
|
+
if language: payload["language"] = language
|
|
40
|
+
payload.update(kwargs)
|
|
41
|
+
|
|
42
|
+
return self.client.put(f"/agents/{agent_id}", json=payload)
|
|
43
|
+
|
|
44
|
+
def delete(self, agent_id: str) -> Dict[str, Any]:
|
|
45
|
+
"""Delete an AI Agent permanently."""
|
|
46
|
+
return self.client.delete(f"/agents/{agent_id}")
|
|
47
|
+
|
|
48
|
+
def llm_options(self) -> Dict[str, Any]:
|
|
49
|
+
"""Retrieve model and voice catalog options."""
|
|
50
|
+
return self.client.get("/agents/llm-options")
|
|
51
|
+
|
|
52
|
+
def generate(self, prompt: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
53
|
+
"""Generate a structured agent draft from a plain-English brief."""
|
|
54
|
+
payload: Dict[str, Any] = {"prompt": prompt}
|
|
55
|
+
if options:
|
|
56
|
+
payload["options"] = options
|
|
57
|
+
return self.client.post("/agents/generate", json=payload)
|
|
58
|
+
|
|
59
|
+
def list_calls(self, agent_id: str, limit: int = 50, offset: int = 0, status: Optional[str] = None) -> Dict[str, Any]:
|
|
60
|
+
"""List calls made with a specific agent."""
|
|
61
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
62
|
+
if status:
|
|
63
|
+
params["status"] = status
|
|
64
|
+
return self.client.get(f"/agents/{agent_id}/calls", params=params)
|
|
65
|
+
|
|
66
|
+
def clone(self, agent_id: str) -> Dict[str, Any]:
|
|
67
|
+
"""Duplicate an existing agent. The copy gets ' (Copy)' appended to its name."""
|
|
68
|
+
return self.client.post(f"/agents/{agent_id}/clone", json={})
|
|
69
|
+
|
|
70
|
+
def validate_publish(self, agent_id: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
|
|
71
|
+
"""Check whether an agent is ready to go live without persisting changes."""
|
|
72
|
+
payload: Dict[str, Any] = {}
|
|
73
|
+
if agent_id:
|
|
74
|
+
payload["agent_id"] = agent_id
|
|
75
|
+
payload.update(kwargs)
|
|
76
|
+
return self.client.post("/agents/validate-publish", json=payload)
|
|
77
|
+
|
|
78
|
+
def apply_changes(self, current_config: Dict[str, Any], changes: list, mode: str = "edit", lenient: bool = False) -> Dict[str, Any]:
|
|
79
|
+
"""Validate and resolve a flat key/value change-set. Does NOT persist — follow up with update()."""
|
|
80
|
+
return self.client.post("/agents/apply-changes", json={
|
|
81
|
+
"currentConfig": current_config,
|
|
82
|
+
"changes": changes,
|
|
83
|
+
"mode": mode,
|
|
84
|
+
"lenient": lenient,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
def enrich_company(self, company_name: str, website_url: str) -> Dict[str, Any]:
|
|
88
|
+
"""Auto-generate a company description from a website URL using AI + Google Search."""
|
|
89
|
+
return self.client.post("/agents/enrich-company", json={
|
|
90
|
+
"companyName": company_name,
|
|
91
|
+
"websiteUrl": website_url,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
def preview_stack(
|
|
95
|
+
self,
|
|
96
|
+
supported_languages: List[str],
|
|
97
|
+
agent_role: Optional[str] = None,
|
|
98
|
+
language: Optional[str] = None,
|
|
99
|
+
current_provider_config: Optional[Dict[str, Any]] = None,
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""Resolve the per-language model stack (STT/LLM/TTS), blockers, warnings, and
|
|
102
|
+
required role upgrades without saving. The concierge (realtime) role is not
|
|
103
|
+
supported by this endpoint."""
|
|
104
|
+
payload: Dict[str, Any] = {"supported_languages": supported_languages}
|
|
105
|
+
if agent_role:
|
|
106
|
+
payload["agent_role"] = agent_role
|
|
107
|
+
if language is not None:
|
|
108
|
+
payload["language"] = language
|
|
109
|
+
if current_provider_config is not None:
|
|
110
|
+
payload["current_provider_config"] = current_provider_config
|
|
111
|
+
return self.client.post("/agents/stack-preview", json=payload)
|
|
112
|
+
|
|
113
|
+
def list_knowledge_sources(self, agent_id: str) -> Dict[str, Any]:
|
|
114
|
+
"""List the knowledge sources (RAG context) attached to an agent."""
|
|
115
|
+
return self.client.get(f"/agents/{agent_id}/knowledge-sources")
|
|
116
|
+
|
|
117
|
+
def add_knowledge_source(
|
|
118
|
+
self,
|
|
119
|
+
agent_id: str,
|
|
120
|
+
kind: str,
|
|
121
|
+
title: str,
|
|
122
|
+
text: Optional[str] = None,
|
|
123
|
+
url: Optional[str] = None,
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
"""Add a knowledge source from raw text (kind='text') or a URL (kind='url')."""
|
|
126
|
+
payload: Dict[str, Any] = {"kind": kind, "title": title}
|
|
127
|
+
if text is not None:
|
|
128
|
+
payload["text"] = text
|
|
129
|
+
if url is not None:
|
|
130
|
+
payload["url"] = url
|
|
131
|
+
return self.client.post(f"/agents/{agent_id}/knowledge-sources", json=payload)
|
|
132
|
+
|
|
133
|
+
def delete_knowledge_source(self, agent_id: str, source_id: str) -> Dict[str, Any]:
|
|
134
|
+
"""Delete a knowledge source from an agent."""
|
|
135
|
+
return self.client.delete(f"/agents/{agent_id}/knowledge-sources/{source_id}")
|
|
136
|
+
|
|
137
|
+
def list_changes(self, agent_id: str, since: Optional[str] = None) -> Dict[str, Any]:
|
|
138
|
+
"""List recorded configuration changes for an agent (optionally since an ISO timestamp)."""
|
|
139
|
+
params = {"since": since} if since else None
|
|
140
|
+
return self.client.get(f"/agents/{agent_id}/changes", params=params)
|
|
141
|
+
|
|
142
|
+
def undo_change(self, agent_id: str, change_id: str) -> Dict[str, Any]:
|
|
143
|
+
"""Undo a single recorded configuration change, reverting that field to its previous value."""
|
|
144
|
+
return self.client.post(f"/agents/{agent_id}/changes/{change_id}/undo", json={})
|
|
145
|
+
|
|
146
|
+
def run_evals(self, agent_id: str, mode: Optional[str] = None) -> Dict[str, Any]:
|
|
147
|
+
"""Run the evaluation suite for an agent. mode='synthetic' (default) or 'live'."""
|
|
148
|
+
path = f"/agents/{agent_id}/evals/run"
|
|
149
|
+
if mode:
|
|
150
|
+
path += f"?mode={mode}"
|
|
151
|
+
return self.client.post(path, json={})
|
|
152
|
+
|
|
153
|
+
def list_eval_runs(self, agent_id: str) -> Dict[str, Any]:
|
|
154
|
+
"""List previous evaluation runs for an agent."""
|
|
155
|
+
return self.client.get(f"/agents/{agent_id}/evals/runs")
|
|
156
|
+
|
|
157
|
+
def get_eval_run(self, agent_id: str, run_id: str) -> Dict[str, Any]:
|
|
158
|
+
"""Retrieve a single evaluation run, including per-scenario scores."""
|
|
159
|
+
return self.client.get(f"/agents/{agent_id}/evals/runs/{run_id}")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
from rymi.client import RymiClient
|
|
3
|
+
|
|
4
|
+
class BillingResource:
|
|
5
|
+
"""Account usage. Voice balance is reported in MINUTES, not dollars."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client: RymiClient):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def usage_summary(self) -> Dict[str, Any]:
|
|
11
|
+
"""Lane-aware usage summary: remaining voice-runtime minutes, Studio AI unit
|
|
12
|
+
usage, and post-call intelligence usage."""
|
|
13
|
+
return self.client.get("/billing/usage-summary")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from rymi.client import RymiClient
|
|
3
|
+
|
|
4
|
+
class CallsResource:
|
|
5
|
+
"""Manage Outbound Calls."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client: RymiClient):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
11
|
+
"""Retrieve a list of all call records."""
|
|
12
|
+
return self.client.get("/calls", params=params or None)
|
|
13
|
+
|
|
14
|
+
def active(self, **params: Any) -> Dict[str, Any]:
|
|
15
|
+
"""Retrieve active in-progress calls."""
|
|
16
|
+
return self.client.get("/calls/active", params=params or None)
|
|
17
|
+
|
|
18
|
+
def retrieve(self, call_id: str) -> Dict[str, Any]:
|
|
19
|
+
"""Retrieve a single call record and transcript."""
|
|
20
|
+
return self.client.get(f"/calls/{call_id}")
|
|
21
|
+
|
|
22
|
+
def create(
|
|
23
|
+
self,
|
|
24
|
+
agent_id: str,
|
|
25
|
+
participants: Optional[List[Dict[str, Any]]] = None,
|
|
26
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
27
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
28
|
+
post_call: Optional[Dict[str, Any]] = None,
|
|
29
|
+
to: Optional[str] = None,
|
|
30
|
+
from_number: Optional[str] = None
|
|
31
|
+
) -> Dict[str, Any]:
|
|
32
|
+
"""Create a WebRTC/PSTN call using the participant-first API."""
|
|
33
|
+
if participants is None and to:
|
|
34
|
+
participants = [{
|
|
35
|
+
"transport": "pstn",
|
|
36
|
+
"identity": to,
|
|
37
|
+
"from_number": from_number
|
|
38
|
+
}]
|
|
39
|
+
|
|
40
|
+
payload = {
|
|
41
|
+
"agent_id": agent_id,
|
|
42
|
+
"participants": participants or []
|
|
43
|
+
}
|
|
44
|
+
if metadata:
|
|
45
|
+
payload["metadata"] = metadata
|
|
46
|
+
if variables:
|
|
47
|
+
payload["variables"] = variables
|
|
48
|
+
if post_call:
|
|
49
|
+
payload["post_call"] = post_call
|
|
50
|
+
|
|
51
|
+
return self.client.post("/calls", json=payload)
|
|
52
|
+
|
|
53
|
+
def batch(self, agent_id: str, to: List[str], from_number: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
|
|
54
|
+
"""Queue a batch of outbound PSTN recipients."""
|
|
55
|
+
payload = {"agent_id": agent_id, "to": to}
|
|
56
|
+
if from_number:
|
|
57
|
+
payload["from_number"] = from_number
|
|
58
|
+
payload.update(kwargs)
|
|
59
|
+
return self.client.post("/calls/batch", json=payload)
|
|
60
|
+
|
|
61
|
+
def summary(self, call_id: str) -> Dict[str, Any]:
|
|
62
|
+
return self.client.get(f"/calls/{call_id}/summary")
|
|
63
|
+
|
|
64
|
+
def transcript(self, call_id: str) -> Dict[str, Any]:
|
|
65
|
+
return self.client.get(f"/calls/{call_id}/transcript")
|
|
66
|
+
|
|
67
|
+
def recording(self, call_id: str) -> Dict[str, Any]:
|
|
68
|
+
return self.client.get(f"/calls/{call_id}/recording")
|
|
69
|
+
|
|
70
|
+
def reprocess(self, call_id: str) -> Dict[str, Any]:
|
|
71
|
+
return self.client.post(f"/calls/{call_id}/reprocess")
|
|
72
|
+
|
|
73
|
+
def queue_stats(self) -> Dict[str, Any]:
|
|
74
|
+
return self.client.get("/calls/queue/stats")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
from rymi.client import RymiClient
|
|
3
|
+
|
|
4
|
+
class KeysResource:
|
|
5
|
+
"""Read-only access to publishable (browser-safe) keys.
|
|
6
|
+
|
|
7
|
+
Creating or revoking keys mints/destroys credentials and changes standing
|
|
8
|
+
config, so those remain dashboard actions and are not exposed here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, client: RymiClient):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def list_publishable(self) -> Dict[str, Any]:
|
|
15
|
+
"""List publishable keys and their agent/channel scoping. Returns key prefixes only, never full secrets."""
|
|
16
|
+
return self.client.get("/keys/publishable")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
from rymi.client import RymiClient
|
|
3
|
+
|
|
4
|
+
class NumbersResource:
|
|
5
|
+
"""Manage already-owned BYOC phone numbers registered with Rymi."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client: RymiClient):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
11
|
+
"""Retrieve a list of all assigned phone numbers."""
|
|
12
|
+
return self.client.get("/numbers", params=params or None)
|
|
13
|
+
|
|
14
|
+
def register(self, number: str, agent_id: Optional[str] = None) -> Dict[str, Any]:
|
|
15
|
+
payload: Dict[str, Any] = {"number": number}
|
|
16
|
+
if agent_id is not None:
|
|
17
|
+
payload["agent_id"] = agent_id
|
|
18
|
+
return self.client.post("/numbers", json=payload)
|
|
19
|
+
|
|
20
|
+
def attach(self, number: str, agent_id: str) -> Dict[str, Any]:
|
|
21
|
+
return self.client.post(f"/numbers/{number}/attach", json={"agent_id": agent_id})
|
|
22
|
+
|
|
23
|
+
def remove(self, number: str) -> Dict[str, Any]:
|
|
24
|
+
return self.client.delete(f"/numbers/{number}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
from rymi.client import RymiClient
|
|
3
|
+
|
|
4
|
+
class TelephonyResource:
|
|
5
|
+
"""Inspect the connected telephony carrier.
|
|
6
|
+
|
|
7
|
+
Connecting/disconnecting a carrier requires carrier credentials and changes a
|
|
8
|
+
standing integration, so it is a dashboard action and not exposed here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, client: RymiClient):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def status(self) -> Dict[str, Any]:
|
|
15
|
+
"""Report whether a carrier is connected, and which provider/account."""
|
|
16
|
+
return self.client.get("/telephony/status")
|
|
17
|
+
|
|
18
|
+
def numbers(self) -> Dict[str, Any]:
|
|
19
|
+
"""List numbers available on the connected telephony carrier account."""
|
|
20
|
+
return self.client.get("/telephony/numbers")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
from rymi.client import RymiClient
|
|
3
|
+
|
|
4
|
+
class TemplatesResource:
|
|
5
|
+
"""Published agent templates."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client: RymiClient):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def list(self) -> Dict[str, Any]:
|
|
11
|
+
"""List published agent templates. Use a template's `defaults` as the starting config for agents.create()."""
|
|
12
|
+
return self.client.get("/agent-templates")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
class WebhooksUtility:
|
|
6
|
+
"""Secure Utility for Webhook Signature Verification."""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def verify_signature(
|
|
10
|
+
payload: str,
|
|
11
|
+
signature_header: str,
|
|
12
|
+
timestamp_header: str,
|
|
13
|
+
webhook_secret: str,
|
|
14
|
+
tolerance_millis: int = 300000 # 5 minutes
|
|
15
|
+
) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Verifies that a webhook received by your backend was genuinely dispatched by Rymi.
|
|
18
|
+
|
|
19
|
+
Rymi endpoints secure webhooks by computing an HMAC-SHA256 signature
|
|
20
|
+
of the raw JSON body concatenated with the timestamp using your Webhook Secret.
|
|
21
|
+
"""
|
|
22
|
+
# 1. Check for Replay Attacks
|
|
23
|
+
try:
|
|
24
|
+
timestamp = int(timestamp_header)
|
|
25
|
+
except ValueError:
|
|
26
|
+
raise ValueError("Invalid Rymi-Timestamp header format.")
|
|
27
|
+
|
|
28
|
+
current_time = int(time.time() * 1000)
|
|
29
|
+
if (current_time - timestamp) > tolerance_millis:
|
|
30
|
+
raise ValueError("Webhook timestamp is too old. Possible replay attack.")
|
|
31
|
+
|
|
32
|
+
# 2. Prepare the verification string
|
|
33
|
+
signature_payload = f"{timestamp}.{payload}"
|
|
34
|
+
|
|
35
|
+
# 3. Compute expected HMAC
|
|
36
|
+
expected_signature = hmac.new(
|
|
37
|
+
webhook_secret.encode('utf-8'),
|
|
38
|
+
signature_payload.encode('utf-8'),
|
|
39
|
+
hashlib.sha256
|
|
40
|
+
).hexdigest()
|
|
41
|
+
|
|
42
|
+
# 4. Secure string comparison
|
|
43
|
+
if not hmac.compare_digest(signature_header, expected_signature):
|
|
44
|
+
raise ValueError("Invalid Webhook Signature. The secret might be incorrect.")
|
|
45
|
+
|
|
46
|
+
return True
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rymi
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: The official Python REST API wrapper for Rymi.
|
|
5
|
+
Author-email: Rymi AI <engineering@rymi.live>
|
|
6
|
+
Project-URL: Homepage, https://rymi.live
|
|
7
|
+
Project-URL: Documentation, https://rymi.live/docs
|
|
8
|
+
Project-URL: Source, https://github.com/rymi-ai/rymi
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.25.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: responses>=0.23.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Rymi Python SDK
|
|
28
|
+
|
|
29
|
+
The official Python wrapper for the Rymi Voice AI REST API. Manage agents (incl. multi-language and model-stack config), phone numbers, calls, knowledge sources, evaluations, usage, and templates, plus webhook verification.
|
|
30
|
+
|
|
31
|
+
Resources: `agents` · `calls` · `numbers` · `telephony` · `keys` · `billing` · `templates` · `webhooks`.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install rymi
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from rymi import Rymi
|
|
44
|
+
|
|
45
|
+
# Automatically picks up RYMI_API_KEY from environment
|
|
46
|
+
client = Rymi()
|
|
47
|
+
|
|
48
|
+
# Or pass explicitly
|
|
49
|
+
# client = Rymi(api_key="rymi_live_...")
|
|
50
|
+
|
|
51
|
+
agents = client.agents.list()
|
|
52
|
+
for agent in agents["agents"]:
|
|
53
|
+
print(agent['name'])
|
|
54
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
rymi/__init__.py
|
|
5
|
+
rymi/client.py
|
|
6
|
+
rymi.egg-info/PKG-INFO
|
|
7
|
+
rymi.egg-info/SOURCES.txt
|
|
8
|
+
rymi.egg-info/dependency_links.txt
|
|
9
|
+
rymi.egg-info/requires.txt
|
|
10
|
+
rymi.egg-info/top_level.txt
|
|
11
|
+
rymi/resources/agents.py
|
|
12
|
+
rymi/resources/billing.py
|
|
13
|
+
rymi/resources/calls.py
|
|
14
|
+
rymi/resources/keys.py
|
|
15
|
+
rymi/resources/numbers.py
|
|
16
|
+
rymi/resources/telephony.py
|
|
17
|
+
rymi/resources/templates.py
|
|
18
|
+
rymi/resources/webhooks.py
|
|
19
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rymi
|
rymi-1.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import pytest
|
|
4
|
+
import responses
|
|
5
|
+
import hmac
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from rymi import Rymi, RymiError
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def rymi_client():
|
|
12
|
+
os.environ["RYMI_API_KEY"] = "rymi_test_123"
|
|
13
|
+
return Rymi()
|
|
14
|
+
|
|
15
|
+
@responses.activate
|
|
16
|
+
def test_client_authentication(rymi_client):
|
|
17
|
+
responses.add(
|
|
18
|
+
responses.GET,
|
|
19
|
+
"https://api.rymi.live/v1/agents",
|
|
20
|
+
json={"agents": [{"id": "agent_1"}], "total": 1, "offset": 0, "limit": 50},
|
|
21
|
+
status=200
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
agents = rymi_client.agents.list()
|
|
25
|
+
assert len(agents["agents"]) == 1
|
|
26
|
+
assert agents["agents"][0]["id"] == "agent_1"
|
|
27
|
+
|
|
28
|
+
# Verify headers
|
|
29
|
+
req = responses.calls[0].request
|
|
30
|
+
assert req.headers["Authorization"] == "Bearer rymi_test_123"
|
|
31
|
+
assert req.headers["Accept"] == "application/json"
|
|
32
|
+
|
|
33
|
+
@responses.activate
|
|
34
|
+
def test_client_error_handling(rymi_client):
|
|
35
|
+
responses.add(
|
|
36
|
+
responses.POST,
|
|
37
|
+
"https://api.rymi.live/v1/calls",
|
|
38
|
+
json={"error": "Agent not found", "code": "not_found"},
|
|
39
|
+
status=404
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
with pytest.raises(RymiError) as exc_info:
|
|
43
|
+
rymi_client.calls.create(agent_id="bad_id", to="+123", from_number="+123")
|
|
44
|
+
|
|
45
|
+
assert "Agent not found" in str(exc_info.value)
|
|
46
|
+
assert exc_info.value.status == 404
|
|
47
|
+
assert exc_info.value.code == "not_found"
|
|
48
|
+
|
|
49
|
+
def test_webhook_signature_verification(rymi_client):
|
|
50
|
+
secret = "whsec_test_secret"
|
|
51
|
+
payload = json.dumps({"type": "call.ended", "call_id": "call_123"})
|
|
52
|
+
timestamp = str(int(time.time() * 1000))
|
|
53
|
+
|
|
54
|
+
# Generate valid signature
|
|
55
|
+
signature_payload = f"{timestamp}.{payload}"
|
|
56
|
+
signature = hmac.new(
|
|
57
|
+
secret.encode('utf-8'),
|
|
58
|
+
signature_payload.encode('utf-8'),
|
|
59
|
+
hashlib.sha256
|
|
60
|
+
).hexdigest()
|
|
61
|
+
|
|
62
|
+
# Should pass
|
|
63
|
+
assert rymi_client.webhooks.verify_signature(payload, signature, timestamp, secret) is True
|
|
64
|
+
|
|
65
|
+
# Should fail bad signature
|
|
66
|
+
with pytest.raises(ValueError, match="Invalid Webhook Signature"):
|
|
67
|
+
rymi_client.webhooks.verify_signature(payload, "bad_signature", timestamp, secret)
|
|
68
|
+
|
|
69
|
+
# Should fail replay attack
|
|
70
|
+
old_timestamp = str(int(time.time() * 1000) - 600000) # 10 mins ago
|
|
71
|
+
old_signature = hmac.new(
|
|
72
|
+
secret.encode('utf-8'),
|
|
73
|
+
f"{old_timestamp}.{payload}".encode('utf-8'),
|
|
74
|
+
hashlib.sha256
|
|
75
|
+
).hexdigest()
|
|
76
|
+
|
|
77
|
+
with pytest.raises(ValueError, match="Possible replay attack"):
|
|
78
|
+
rymi_client.webhooks.verify_signature(payload, old_signature, old_timestamp, secret)
|