sixtydb 1.1.0__py3-none-any.whl
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.
- sixtydb/__init__.py +31 -0
- sixtydb/client.py +312 -0
- sixtydb/dialer.py +238 -0
- sixtydb/judge.py +214 -0
- sixtydb/music.py +180 -0
- sixtydb-1.1.0.dist-info/METADATA +224 -0
- sixtydb-1.1.0.dist-info/RECORD +9 -0
- sixtydb-1.1.0.dist-info/WHEEL +5 -0
- sixtydb-1.1.0.dist-info/top_level.txt +1 -0
sixtydb/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
60db Python SDK
|
|
3
|
+
Official Python client for the 60db API
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .client import SixtyDBClient
|
|
7
|
+
from .music import MusicResource
|
|
8
|
+
from .judge import JudgeResource, JudgeRubricsResource, JudgeRunsResource
|
|
9
|
+
from .dialer import (
|
|
10
|
+
DialerResource,
|
|
11
|
+
DialerNumbersResource,
|
|
12
|
+
DialerCallsResource,
|
|
13
|
+
DialerRecordingsResource,
|
|
14
|
+
DialerBillingResource,
|
|
15
|
+
DialerWebhooksResource,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "1.1.0"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"SixtyDBClient",
|
|
21
|
+
"MusicResource",
|
|
22
|
+
"JudgeResource",
|
|
23
|
+
"JudgeRubricsResource",
|
|
24
|
+
"JudgeRunsResource",
|
|
25
|
+
"DialerResource",
|
|
26
|
+
"DialerNumbersResource",
|
|
27
|
+
"DialerCallsResource",
|
|
28
|
+
"DialerRecordingsResource",
|
|
29
|
+
"DialerBillingResource",
|
|
30
|
+
"DialerWebhooksResource",
|
|
31
|
+
]
|
sixtydb/client.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
60db API Client
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from typing import Optional, Dict, Any, List, Callable
|
|
7
|
+
import json
|
|
8
|
+
import base64
|
|
9
|
+
|
|
10
|
+
from .music import MusicResource
|
|
11
|
+
from .dialer import DialerResource
|
|
12
|
+
from .judge import JudgeResource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SixtyDBClient:
|
|
16
|
+
"""Official 60db API client"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, api_key: str, base_url: str = "https://api.60db.ai"):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the 60db client
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: Your 60db API key
|
|
24
|
+
base_url: API base URL (default: https://api.60db.ai)
|
|
25
|
+
"""
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.base_url = base_url.rstrip('/')
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
self.session.headers.update({
|
|
30
|
+
'Authorization': f'Bearer {api_key}',
|
|
31
|
+
'Content-Type': 'application/json'
|
|
32
|
+
})
|
|
33
|
+
self.music = MusicResource(self)
|
|
34
|
+
self.dialer = DialerResource(self)
|
|
35
|
+
self.judge = JudgeResource(self)
|
|
36
|
+
|
|
37
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> Any:
|
|
38
|
+
"""Make HTTP request"""
|
|
39
|
+
url = f"{self.base_url}{endpoint}"
|
|
40
|
+
response = self.session.request(method, url, **kwargs)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
|
|
43
|
+
if kwargs.get('stream'):
|
|
44
|
+
return response
|
|
45
|
+
|
|
46
|
+
content_type = response.headers.get('content-type', '')
|
|
47
|
+
if 'application/json' in content_type:
|
|
48
|
+
return response.json()
|
|
49
|
+
return response.content
|
|
50
|
+
|
|
51
|
+
# TTS Methods
|
|
52
|
+
def text_to_speech(self, text: str, voice_id: Optional[str] = None, enhance: Optional[bool] = True, speed: Optional[float] = 1.0, **params) -> bytes:
|
|
53
|
+
"""
|
|
54
|
+
Convert text to speech
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
text: Text to convert
|
|
58
|
+
voice_id: Voice ID to use
|
|
59
|
+
enhance: Enable audio enhancement
|
|
60
|
+
speed: Speech speed (0.5 to 2.0)
|
|
61
|
+
**params: Additional parameters
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Audio data as bytes
|
|
65
|
+
"""
|
|
66
|
+
data = {'text': text, 'enhance': enhance, 'speed': speed, **params}
|
|
67
|
+
if voice_id:
|
|
68
|
+
data['voice_id'] = voice_id
|
|
69
|
+
|
|
70
|
+
response = self._request('POST', '/tts-synthesize', json=data)
|
|
71
|
+
|
|
72
|
+
if isinstance(response, bytes):
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
if isinstance(response, dict):
|
|
76
|
+
if response.get("success") and "backendResponse" in response:
|
|
77
|
+
backend = response["backendResponse"]
|
|
78
|
+
|
|
79
|
+
if isinstance(backend, dict) and backend.get("audio_base64"):
|
|
80
|
+
return base64.b64decode(backend["audio_base64"])
|
|
81
|
+
|
|
82
|
+
raise Exception(response.get("message", "Failed to generate audio"))
|
|
83
|
+
|
|
84
|
+
raise Exception("Unexpected TTS response format")
|
|
85
|
+
|
|
86
|
+
def text_to_speech_stream(
|
|
87
|
+
self,
|
|
88
|
+
text: str,
|
|
89
|
+
on_chunk: Callable[[bytes], None],
|
|
90
|
+
voice_id: Optional[str] = None,
|
|
91
|
+
on_complete: Optional[Callable[[], None]] = None,
|
|
92
|
+
on_error: Optional[Callable[[str], None]] = None,
|
|
93
|
+
**params
|
|
94
|
+
) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Stream text to speech with callbacks
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
text: Text to convert
|
|
100
|
+
on_chunk: Callback for audio chunks
|
|
101
|
+
voice_id: Voice ID to use
|
|
102
|
+
on_complete: Callback when complete
|
|
103
|
+
on_error: Callback for errors
|
|
104
|
+
**params: Additional parameters
|
|
105
|
+
"""
|
|
106
|
+
data = {'text': text, **params}
|
|
107
|
+
if voice_id:
|
|
108
|
+
data['voice_id'] = voice_id
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
response = requests.post(
|
|
112
|
+
f"{self.base_url}/tts-stream",
|
|
113
|
+
headers={'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json'},
|
|
114
|
+
json=data,
|
|
115
|
+
stream=True
|
|
116
|
+
)
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
|
|
119
|
+
buffer = ""
|
|
120
|
+
for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
|
|
121
|
+
if chunk:
|
|
122
|
+
buffer += chunk
|
|
123
|
+
lines = buffer.split('\n')
|
|
124
|
+
buffer = lines.pop()
|
|
125
|
+
|
|
126
|
+
for line in lines:
|
|
127
|
+
if not line.strip():
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
parsed = json.loads(line)
|
|
131
|
+
if parsed.get('type') == 'error':
|
|
132
|
+
if on_error:
|
|
133
|
+
on_error(parsed.get('message', 'Unknown error'))
|
|
134
|
+
elif 'result' in parsed and 'audioContent' in parsed['result']:
|
|
135
|
+
audio_bytes = base64.b64decode(parsed['result']['audioContent'])
|
|
136
|
+
on_chunk(audio_bytes)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
if on_complete:
|
|
141
|
+
on_complete()
|
|
142
|
+
except Exception as e:
|
|
143
|
+
if on_error:
|
|
144
|
+
on_error(str(e))
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
# STT Methods
|
|
148
|
+
def speech_to_text(
|
|
149
|
+
self,
|
|
150
|
+
audio_file,
|
|
151
|
+
language: Optional[str] = None,
|
|
152
|
+
diarize: Optional[bool] = None,
|
|
153
|
+
context: Optional[str] = None,
|
|
154
|
+
) -> Dict[str, Any]:
|
|
155
|
+
"""
|
|
156
|
+
Transcribe audio to text.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
audio_file: Path or file-like object for the audio to transcribe.
|
|
160
|
+
language: ISO 639-1 code, or None / "auto" to auto-detect.
|
|
161
|
+
diarize: Enable pyannote speaker diarization.
|
|
162
|
+
context: Free-form string describing the session / domain /
|
|
163
|
+
speakers / jargon. When supplied, the server runs a
|
|
164
|
+
background LLM pass and the response text is polished
|
|
165
|
+
(proper nouns, fillers removed, punctuation). Omit to skip
|
|
166
|
+
refinement.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
# If string path passed, open it
|
|
170
|
+
if isinstance(audio_file, str):
|
|
171
|
+
file_obj = open(audio_file, "rb")
|
|
172
|
+
filename = audio_file.split("\\")[-1]
|
|
173
|
+
else:
|
|
174
|
+
file_obj = audio_file
|
|
175
|
+
filename = getattr(audio_file, "name", "audio.mp3")
|
|
176
|
+
|
|
177
|
+
files = {
|
|
178
|
+
"audio": (filename, file_obj, "audio/mpeg")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
data = {}
|
|
182
|
+
if language:
|
|
183
|
+
data["language"] = language
|
|
184
|
+
if diarize:
|
|
185
|
+
data["diarize"] = "true"
|
|
186
|
+
if context and context.strip():
|
|
187
|
+
data["context"] = context.strip()
|
|
188
|
+
|
|
189
|
+
headers = {
|
|
190
|
+
"Authorization": f"Bearer {self.api_key}"
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
response = requests.post(
|
|
194
|
+
f"{self.base_url}/stt",
|
|
195
|
+
headers=headers,
|
|
196
|
+
files=files,
|
|
197
|
+
data=data
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
response.raise_for_status()
|
|
201
|
+
return response.json()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_languages(self) -> List[Dict[str, Any]]:
|
|
205
|
+
"""Get supported languages"""
|
|
206
|
+
return self._request('GET', '/stt/languages')
|
|
207
|
+
|
|
208
|
+
# Voice Methods
|
|
209
|
+
def get_voices(self) -> List[Dict[str, Any]]:
|
|
210
|
+
"""Get all voices"""
|
|
211
|
+
return self._request('GET', '/voices')
|
|
212
|
+
|
|
213
|
+
def get_voice(self, voice_id: str) -> Dict[str, Any]:
|
|
214
|
+
"""Get specific voice"""
|
|
215
|
+
return self._request('GET', f'/voices/{voice_id}')
|
|
216
|
+
|
|
217
|
+
def create_voice(self, name: str, files: List, **params) -> Dict[str, Any]:
|
|
218
|
+
"""Create custom voice"""
|
|
219
|
+
file_data = [('files', f) for f in files]
|
|
220
|
+
data = {'name': name, **params}
|
|
221
|
+
|
|
222
|
+
headers = {'Authorization': f'Bearer {self.api_key}'}
|
|
223
|
+
response = requests.post(
|
|
224
|
+
f"{self.base_url}/voices",
|
|
225
|
+
headers=headers,
|
|
226
|
+
files=file_data,
|
|
227
|
+
data=data
|
|
228
|
+
)
|
|
229
|
+
response.raise_for_status()
|
|
230
|
+
return response.json()
|
|
231
|
+
|
|
232
|
+
def update_voice(self, voice_id: str, **params) -> Dict[str, Any]:
|
|
233
|
+
"""Update voice"""
|
|
234
|
+
return self._request('PUT', f'/voices/{voice_id}', json=params)
|
|
235
|
+
|
|
236
|
+
def delete_voice(self, voice_id: str) -> None:
|
|
237
|
+
"""Delete voice"""
|
|
238
|
+
self._request('DELETE', f'/voices/{voice_id}')
|
|
239
|
+
|
|
240
|
+
# Auth Methods
|
|
241
|
+
def sign_up(self, email: str, password: str, **params) -> Dict[str, Any]:
|
|
242
|
+
"""Register new user"""
|
|
243
|
+
data = {'email': email, 'password': password, **params}
|
|
244
|
+
return self._request('POST', '/auth/signup', json=data)
|
|
245
|
+
|
|
246
|
+
def sign_in(self, email: str, password: str) -> Dict[str, Any]:
|
|
247
|
+
"""Login user"""
|
|
248
|
+
data = {'email': email, 'password': password}
|
|
249
|
+
return self._request('POST', '/auth/login', json=data)
|
|
250
|
+
|
|
251
|
+
def get_profile(self) -> Dict[str, Any]:
|
|
252
|
+
"""Get user profile"""
|
|
253
|
+
return self._request('GET', '/auth/me')
|
|
254
|
+
|
|
255
|
+
def update_profile(self, **params) -> Dict[str, Any]:
|
|
256
|
+
"""Update user profile"""
|
|
257
|
+
return self._request('PUT', '/auth/profile', json=params)
|
|
258
|
+
|
|
259
|
+
# Workspace Methods
|
|
260
|
+
def get_workspaces(self) -> List[Dict[str, Any]]:
|
|
261
|
+
"""Get all workspaces"""
|
|
262
|
+
return self._request('GET', '/workspaces')
|
|
263
|
+
|
|
264
|
+
def create_workspace(self, name: str, **params) -> Dict[str, Any]:
|
|
265
|
+
"""Create workspace"""
|
|
266
|
+
data = {'name': name, **params}
|
|
267
|
+
return self._request('POST', '/workspaces', json=data)
|
|
268
|
+
|
|
269
|
+
# Billing Methods
|
|
270
|
+
def get_plans(self) -> List[Dict[str, Any]]:
|
|
271
|
+
"""Get available plans"""
|
|
272
|
+
return self._request('GET', '/plans')
|
|
273
|
+
|
|
274
|
+
def get_current_plan(self) -> Dict[str, Any]:
|
|
275
|
+
"""Get current subscription"""
|
|
276
|
+
return self._request('GET', '/billing/current-plan')
|
|
277
|
+
|
|
278
|
+
def subscribe(self, plan_id: str) -> Dict[str, Any]:
|
|
279
|
+
"""Subscribe to plan"""
|
|
280
|
+
return self._request('POST', '/billing/subscribe', json={'plan_id': plan_id})
|
|
281
|
+
|
|
282
|
+
# Analytics
|
|
283
|
+
def get_usage(self) -> Dict[str, Any]:
|
|
284
|
+
"""Get usage statistics"""
|
|
285
|
+
return self._request('GET', '/analytics/usage')
|
|
286
|
+
|
|
287
|
+
# API Keys
|
|
288
|
+
def get_api_keys(self) -> List[Dict[str, Any]]:
|
|
289
|
+
"""Get all API keys"""
|
|
290
|
+
return self._request('GET', '/developer/api')
|
|
291
|
+
|
|
292
|
+
def create_api_key(self, name: str) -> Dict[str, Any]:
|
|
293
|
+
"""Create API key"""
|
|
294
|
+
return self._request('POST', '/developer/api', json={'name': name})
|
|
295
|
+
|
|
296
|
+
def delete_api_key(self, key_id: str) -> None:
|
|
297
|
+
"""Delete API key"""
|
|
298
|
+
self._request('DELETE', f'/developer/api/{key_id}')
|
|
299
|
+
|
|
300
|
+
# Webhooks
|
|
301
|
+
def get_webhooks(self) -> List[Dict[str, Any]]:
|
|
302
|
+
"""Get all webhooks"""
|
|
303
|
+
return self._request('GET', '/webhooks')
|
|
304
|
+
|
|
305
|
+
def create_webhook(self, url: str, events: List[str], **params) -> Dict[str, Any]:
|
|
306
|
+
"""Create webhook"""
|
|
307
|
+
data = {'url': url, 'events': events, **params}
|
|
308
|
+
return self._request('POST', '/webhooks', json=data)
|
|
309
|
+
|
|
310
|
+
def delete_webhook(self, webhook_id: str) -> None:
|
|
311
|
+
"""Delete webhook"""
|
|
312
|
+
self._request('DELETE', f'/webhooks/{webhook_id}')
|
sixtydb/dialer.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dialer resource for the 60db API.
|
|
3
|
+
|
|
4
|
+
Not free: a number costs $6.00/month, calls cost $0.003/min. Most calls
|
|
5
|
+
need a finished trunk setup and approved KYC, both done in the dashboard.
|
|
6
|
+
|
|
7
|
+
`calls.reserve()` does NOT dial. It reserves and authorizes a call id
|
|
8
|
+
before a SIP client (softphone/PBX) sends its SIP INVITE.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DialerNumbersResource:
|
|
16
|
+
def __init__(self, client: "Any") -> None:
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
def search(self) -> Dict[str, Any]:
|
|
20
|
+
"""Search available numbers in the shared pool (no filters)."""
|
|
21
|
+
return self._client._request("GET", "/dialer/pool", timeout=60)
|
|
22
|
+
|
|
23
|
+
def buy(self, number: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
Buy a number. Charges $6.00 now, then $6.00/month.
|
|
26
|
+
|
|
27
|
+
Requires approved dialer KYC, completed inside the 60db app (not
|
|
28
|
+
exposed by this SDK) — otherwise returns 403 DIALER_KYC_REQUIRED,
|
|
29
|
+
DIALER_KYC_PENDING or DIALER_KYC_REJECTED.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
number: Specific E.164 number, or None for the next available one.
|
|
33
|
+
**params: assign_for ("sip"|"indian_number"), outbound_trunk_id,
|
|
34
|
+
inbound_trunk_id, channel_limit.
|
|
35
|
+
"""
|
|
36
|
+
body: Dict[str, Any] = {**params}
|
|
37
|
+
if number is not None:
|
|
38
|
+
body["number"] = number
|
|
39
|
+
return self._client._request(
|
|
40
|
+
"POST", "/dialer/pool/allocate", json=body, timeout=60
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def list(self) -> Dict[str, Any]:
|
|
44
|
+
"""List numbers owned by the workspace."""
|
|
45
|
+
return self._client._request("GET", "/dialer/numbers", timeout=60)
|
|
46
|
+
|
|
47
|
+
def release(self, e164: str) -> Dict[str, Any]:
|
|
48
|
+
"""Release a number, cancel its monthly rental."""
|
|
49
|
+
return self._client._request(
|
|
50
|
+
"DELETE", f"/dialer/numbers/{quote(e164, safe='')}", timeout=60
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def set_caller_id(self, e164: str) -> Dict[str, Any]:
|
|
54
|
+
"""Set the default caller id for outbound calls."""
|
|
55
|
+
return self._client._request(
|
|
56
|
+
"PUT", "/dialer/caller-id", json={"caller_id": e164}, timeout=60
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DialerCallsResource:
|
|
61
|
+
def __init__(self, client: "Any") -> None:
|
|
62
|
+
self._client = client
|
|
63
|
+
|
|
64
|
+
def list(
|
|
65
|
+
self, limit: Optional[int] = None, offset: Optional[int] = None
|
|
66
|
+
) -> Dict[str, Any]:
|
|
67
|
+
"""Call history (GET /dialer/logs). limit: 1-200, offset."""
|
|
68
|
+
params: Dict[str, Any] = {}
|
|
69
|
+
if limit is not None:
|
|
70
|
+
params["limit"] = limit
|
|
71
|
+
if offset is not None:
|
|
72
|
+
params["offset"] = offset
|
|
73
|
+
return self._client._request("GET", "/dialer/logs", params=params, timeout=60)
|
|
74
|
+
|
|
75
|
+
def get(self, call_id: str) -> Dict[str, Any]:
|
|
76
|
+
"""Get one call by id."""
|
|
77
|
+
return self._client._request(
|
|
78
|
+
"GET", f"/dialer/calls/{quote(call_id, safe='')}", timeout=60
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def live(self) -> Dict[str, Any]:
|
|
82
|
+
"""In-flight calls and recent CDRs."""
|
|
83
|
+
return self._client._request("GET", "/dialer/live", timeout=60)
|
|
84
|
+
|
|
85
|
+
def line_status(self) -> Dict[str, Any]:
|
|
86
|
+
"""Carrier line status."""
|
|
87
|
+
return self._client._request("GET", "/dialer/calls/line-status", timeout=60)
|
|
88
|
+
|
|
89
|
+
def reserve(
|
|
90
|
+
self,
|
|
91
|
+
to: str,
|
|
92
|
+
from_number: str,
|
|
93
|
+
sip_call_id: str,
|
|
94
|
+
direction: str = "outbound",
|
|
95
|
+
) -> Dict[str, Any]:
|
|
96
|
+
"""
|
|
97
|
+
Reserve and authorize a call id. Does NOT dial.
|
|
98
|
+
|
|
99
|
+
The call is placed by a SIP client (softphone/PBX) registered with
|
|
100
|
+
the workspace's SIP credentials from the dashboard; this only
|
|
101
|
+
pre-authorizes the call id before the client sends its SIP INVITE.
|
|
102
|
+
"""
|
|
103
|
+
body = {
|
|
104
|
+
"to": to,
|
|
105
|
+
"from": from_number,
|
|
106
|
+
"sip_call_id": sip_call_id,
|
|
107
|
+
"direction": direction,
|
|
108
|
+
}
|
|
109
|
+
return self._client._request("POST", "/dialer/calls", json=body, timeout=60)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DialerRecordingsResource:
|
|
113
|
+
def __init__(self, client: "Any") -> None:
|
|
114
|
+
self._client = client
|
|
115
|
+
|
|
116
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
117
|
+
"""List recordings. direction, status, e164, limit (1-200), offset."""
|
|
118
|
+
return self._client._request(
|
|
119
|
+
"GET", "/dialer/recordings", params=params, timeout=60
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def playback_url(self, recording_id: str) -> Dict[str, Any]:
|
|
123
|
+
"""Get a short-lived (~5 min) playback URL."""
|
|
124
|
+
return self._client._request(
|
|
125
|
+
"POST",
|
|
126
|
+
f"/dialer/recordings/{quote(recording_id, safe='')}/playback-url",
|
|
127
|
+
timeout=60,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def transcript(self, recording_id: str) -> Dict[str, Any]:
|
|
131
|
+
"""Get (or generate) a recording's transcript."""
|
|
132
|
+
return self._client._request(
|
|
133
|
+
"POST",
|
|
134
|
+
f"/dialer/recordings/{quote(recording_id, safe='')}/transcript",
|
|
135
|
+
timeout=60,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def delete(self, recording_id: str) -> Dict[str, Any]:
|
|
139
|
+
"""Queue a recording for deletion."""
|
|
140
|
+
return self._client._request(
|
|
141
|
+
"DELETE", f"/dialer/recordings/{quote(recording_id, safe='')}", timeout=60
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def set_enabled(self, enabled: bool) -> Dict[str, Any]:
|
|
145
|
+
"""Turn call recording on/off for outbound calls."""
|
|
146
|
+
return self._client._request(
|
|
147
|
+
"PUT", "/dialer/recording", json={"enabled": enabled}, timeout=60
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class DialerBillingResource:
|
|
152
|
+
def __init__(self, client: "Any") -> None:
|
|
153
|
+
self._client = client
|
|
154
|
+
|
|
155
|
+
def subscriptions(self) -> Dict[str, Any]:
|
|
156
|
+
"""List per-number subscriptions."""
|
|
157
|
+
return self._client._request("GET", "/dialer/subscriptions", timeout=60)
|
|
158
|
+
|
|
159
|
+
def usage(self, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
160
|
+
"""Balance and recent dialer charges. limit: 1-200 (default 50)."""
|
|
161
|
+
params: Dict[str, Any] = {}
|
|
162
|
+
if limit is not None:
|
|
163
|
+
params["limit"] = limit
|
|
164
|
+
return self._client._request(
|
|
165
|
+
"GET", "/dialer/usage", params=params, timeout=60
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class DialerWebhooksResource:
|
|
170
|
+
def __init__(self, client: "Any") -> None:
|
|
171
|
+
self._client = client
|
|
172
|
+
|
|
173
|
+
def list(self) -> Dict[str, Any]:
|
|
174
|
+
"""List webhooks, health and available event types."""
|
|
175
|
+
return self._client._request("GET", "/dialer/webhooks", timeout=60)
|
|
176
|
+
|
|
177
|
+
def create(self, **params: Any) -> Dict[str, Any]:
|
|
178
|
+
"""
|
|
179
|
+
Create a webhook.
|
|
180
|
+
|
|
181
|
+
Required: name, url (https), event_types (non-empty list).
|
|
182
|
+
Optional: description, status, timeout_ms, rate_limit_per_minute,
|
|
183
|
+
max_attempts, max_event_age_seconds.
|
|
184
|
+
|
|
185
|
+
The response includes `signing_secret` once; store it.
|
|
186
|
+
"""
|
|
187
|
+
return self._client._request("POST", "/dialer/webhooks", json=params, timeout=60)
|
|
188
|
+
|
|
189
|
+
def update(self, webhook_id: str, **params: Any) -> Dict[str, Any]:
|
|
190
|
+
"""Partially update a webhook."""
|
|
191
|
+
return self._client._request(
|
|
192
|
+
"PATCH",
|
|
193
|
+
f"/dialer/webhooks/{quote(webhook_id, safe='')}",
|
|
194
|
+
json=params,
|
|
195
|
+
timeout=60,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def delete(self, webhook_id: str) -> Dict[str, Any]:
|
|
199
|
+
"""Disable a webhook endpoint."""
|
|
200
|
+
return self._client._request(
|
|
201
|
+
"DELETE", f"/dialer/webhooks/{quote(webhook_id, safe='')}", timeout=60
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def rotate_secret(self, webhook_id: str) -> Dict[str, Any]:
|
|
205
|
+
"""Rotate the signing secret. Returns the new secret once."""
|
|
206
|
+
return self._client._request(
|
|
207
|
+
"POST",
|
|
208
|
+
f"/dialer/webhooks/{quote(webhook_id, safe='')}/rotate-secret",
|
|
209
|
+
timeout=60,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def test(self, webhook_id: str) -> Dict[str, Any]:
|
|
213
|
+
"""Send a test event to the webhook."""
|
|
214
|
+
return self._client._request(
|
|
215
|
+
"POST", f"/dialer/webhooks/{quote(webhook_id, safe='')}/test", timeout=60
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def deliveries(self, webhook_id: str) -> Dict[str, Any]:
|
|
219
|
+
"""List recent delivery attempts."""
|
|
220
|
+
return self._client._request(
|
|
221
|
+
"GET", f"/dialer/webhooks/{quote(webhook_id, safe='')}/deliveries", timeout=60
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class DialerResource:
|
|
226
|
+
"""Dialer endpoints, available at `client.dialer`."""
|
|
227
|
+
|
|
228
|
+
def __init__(self, client: "Any") -> None:
|
|
229
|
+
self._client = client
|
|
230
|
+
self.numbers = DialerNumbersResource(client)
|
|
231
|
+
self.calls = DialerCallsResource(client)
|
|
232
|
+
self.recordings = DialerRecordingsResource(client)
|
|
233
|
+
self.billing = DialerBillingResource(client)
|
|
234
|
+
self.webhooks = DialerWebhooksResource(client)
|
|
235
|
+
|
|
236
|
+
def status(self) -> Dict[str, Any]:
|
|
237
|
+
"""Dialer provisioning status for the workspace."""
|
|
238
|
+
return self._client._request("GET", "/dialer/status", timeout=60)
|
sixtydb/judge.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Judge resource for the 60db API.
|
|
3
|
+
|
|
4
|
+
Ask a pinned classifier a question about your content. It never generates
|
|
5
|
+
text: every answer is a probability distribution over an answer space you
|
|
6
|
+
defined, which is what makes results comparable and sortable in a way prose
|
|
7
|
+
never is.
|
|
8
|
+
|
|
9
|
+
Two shapes:
|
|
10
|
+
* evaluate() — a rubric of up to 32 questions asked about one shared
|
|
11
|
+
`state` (a transcript, a ticket, a generated answer).
|
|
12
|
+
* extract() — one conversational turn labelled with an intent and an
|
|
13
|
+
operation, plus entity spans as Unicode code-point offsets.
|
|
14
|
+
|
|
15
|
+
Billed per input token. The upstream re-encodes the shared state once per
|
|
16
|
+
question, so a 10-question rubric costs ten times the transcript — keep
|
|
17
|
+
rubrics tight on long content.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JudgeRubricsResource:
|
|
25
|
+
"""Saved rubrics — build once, re-run by id. `client.judge.rubrics`."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, client: "Any") -> None:
|
|
28
|
+
self._client = client
|
|
29
|
+
|
|
30
|
+
def list(self) -> Any:
|
|
31
|
+
"""Your own rubrics plus every rubric shared with the workspace."""
|
|
32
|
+
return self._client._request("GET", "/judge/rubrics")["data"]
|
|
33
|
+
|
|
34
|
+
def create(
|
|
35
|
+
self,
|
|
36
|
+
name: str,
|
|
37
|
+
questions: Dict[str, Any],
|
|
38
|
+
description: Optional[str] = None,
|
|
39
|
+
model: Optional[str] = None,
|
|
40
|
+
shared: bool = False,
|
|
41
|
+
) -> Dict[str, Any]:
|
|
42
|
+
"""
|
|
43
|
+
Save a rubric.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
name: Unique per workspace.
|
|
47
|
+
questions: Map of answer key -> question. Each question is
|
|
48
|
+
{"type": "choice"|"score"|"noul", "instructions": ..., "criteria": ...}.
|
|
49
|
+
shared: Publish to the whole workspace. Owner/admin only.
|
|
50
|
+
"""
|
|
51
|
+
body: Dict[str, Any] = {"name": name, "questions": questions, "shared": shared}
|
|
52
|
+
if description is not None:
|
|
53
|
+
body["description"] = description
|
|
54
|
+
if model is not None:
|
|
55
|
+
body["model"] = model
|
|
56
|
+
return self._client._request("POST", "/judge/rubrics", json=body)["data"]
|
|
57
|
+
|
|
58
|
+
def get(self, rubric_id: str) -> Dict[str, Any]:
|
|
59
|
+
return self._client._request("GET", f"/judge/rubrics/{quote(rubric_id)}")["data"]
|
|
60
|
+
|
|
61
|
+
def update(self, rubric_id: str, **params: Any) -> Dict[str, Any]:
|
|
62
|
+
"""Patch only the fields you pass — the rest are left alone."""
|
|
63
|
+
return self._client._request("PATCH", f"/judge/rubrics/{quote(rubric_id)}", json=params)["data"]
|
|
64
|
+
|
|
65
|
+
def delete(self, rubric_id: str) -> None:
|
|
66
|
+
"""Soft delete. Past runs keep their link, so history stays readable."""
|
|
67
|
+
self._client._request("DELETE", f"/judge/rubrics/{quote(rubric_id)}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class JudgeRunsResource:
|
|
71
|
+
"""Run history and the review queue. `client.judge.runs`."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, client: "Any") -> None:
|
|
74
|
+
self._client = client
|
|
75
|
+
|
|
76
|
+
def list(
|
|
77
|
+
self,
|
|
78
|
+
limit: int = 25,
|
|
79
|
+
offset: int = 0,
|
|
80
|
+
kind: Optional[str] = None,
|
|
81
|
+
rubric_id: Optional[str] = None,
|
|
82
|
+
needs_review: bool = False,
|
|
83
|
+
) -> Dict[str, Any]:
|
|
84
|
+
"""
|
|
85
|
+
Saved runs, newest first.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
kind: "evaluate" or "extract".
|
|
89
|
+
needs_review: Only runs the judge was under 85% sure about —
|
|
90
|
+
the upstream's own escalation line, and the queue worth a
|
|
91
|
+
person's attention.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
{"runs": [...], "pagination": {...}}
|
|
95
|
+
"""
|
|
96
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
97
|
+
if kind:
|
|
98
|
+
params["kind"] = kind
|
|
99
|
+
if rubric_id:
|
|
100
|
+
params["rubric_id"] = rubric_id
|
|
101
|
+
if needs_review:
|
|
102
|
+
params["needs_review"] = "true"
|
|
103
|
+
body = self._client._request("GET", "/judge/evaluations", params=params)
|
|
104
|
+
return {"runs": body["data"], "pagination": body.get("pagination", {})}
|
|
105
|
+
|
|
106
|
+
def get(self, run_id: str) -> Dict[str, Any]:
|
|
107
|
+
"""One run including exactly what was sent and what came back."""
|
|
108
|
+
return self._client._request("GET", f"/judge/evaluations/{quote(run_id)}")["data"]
|
|
109
|
+
|
|
110
|
+
def delete(self, run_id: str) -> None:
|
|
111
|
+
self._client._request("DELETE", f"/judge/evaluations/{quote(run_id)}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class JudgeResource:
|
|
115
|
+
"""Judge endpoints, available at `client.judge`."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, client: "Any") -> None:
|
|
118
|
+
self._client = client
|
|
119
|
+
self.rubrics = JudgeRubricsResource(client)
|
|
120
|
+
self.runs = JudgeRunsResource(client)
|
|
121
|
+
|
|
122
|
+
def evaluate(
|
|
123
|
+
self,
|
|
124
|
+
state: Any,
|
|
125
|
+
questions: Optional[Dict[str, Any]] = None,
|
|
126
|
+
rubric_id: Optional[str] = None,
|
|
127
|
+
model: Optional[str] = None,
|
|
128
|
+
save: bool = True,
|
|
129
|
+
label: Optional[str] = None,
|
|
130
|
+
) -> Dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Run a rubric over content.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
state: What every question is asked about — text, or any JSON
|
|
136
|
+
structure. Each question sees this and nothing else.
|
|
137
|
+
questions: Inline rubric. Pass this OR rubric_id, never both.
|
|
138
|
+
rubric_id: Run a saved rubric instead.
|
|
139
|
+
save: False bills the run but keeps it out of history.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
{"answers": {...}, "summary": {...}, "min_confidence": float|None,
|
|
143
|
+
"usage": {...}, "credits_charged": float, ...}
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
requests.HTTPError: 400 invalid rubric, 402 out of credits,
|
|
147
|
+
429 judge busy, 503 unavailable. A failed run is refunded
|
|
148
|
+
automatically — nothing is charged for an answer you did
|
|
149
|
+
not get.
|
|
150
|
+
"""
|
|
151
|
+
body: Dict[str, Any] = {"state": state}
|
|
152
|
+
if questions is not None:
|
|
153
|
+
body["questions"] = questions
|
|
154
|
+
if rubric_id is not None:
|
|
155
|
+
body["rubric_id"] = rubric_id
|
|
156
|
+
if model is not None:
|
|
157
|
+
body["model"] = model
|
|
158
|
+
if label is not None:
|
|
159
|
+
body["label"] = label
|
|
160
|
+
if not save:
|
|
161
|
+
body["save"] = False
|
|
162
|
+
return self._client._request("POST", "/judge/evaluate", json=body)["data"]
|
|
163
|
+
|
|
164
|
+
def extract(
|
|
165
|
+
self,
|
|
166
|
+
text: str,
|
|
167
|
+
schema: Dict[str, Any],
|
|
168
|
+
profile: Optional[str] = None,
|
|
169
|
+
budget_ms: Optional[int] = None,
|
|
170
|
+
save: bool = True,
|
|
171
|
+
label: Optional[str] = None,
|
|
172
|
+
) -> Dict[str, Any]:
|
|
173
|
+
"""
|
|
174
|
+
Classify one conversational turn and pull its entity spans.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
text: The turn. Max 2,048 Unicode code points.
|
|
178
|
+
schema: {"intents": {...}, "operations": {...}, "entities": {...},
|
|
179
|
+
and optionally "responsePaths": {...}}. Each is a map of label
|
|
180
|
+
name -> description; the description is what the model reads.
|
|
181
|
+
Supplying responsePaths selects the v2 model.
|
|
182
|
+
profile: "generic" (default) or "medical".
|
|
183
|
+
|
|
184
|
+
Note:
|
|
185
|
+
Entity `start`/`end` are Unicode CODE POINT offsets, not Python
|
|
186
|
+
string indices for astral characters. Slice with
|
|
187
|
+
``list(text)[start:end]`` if the turn may contain emoji.
|
|
188
|
+
"""
|
|
189
|
+
body: Dict[str, Any] = {"text": text, "schema": schema}
|
|
190
|
+
if profile is not None:
|
|
191
|
+
body["profile"] = profile
|
|
192
|
+
if budget_ms is not None:
|
|
193
|
+
body["budget_ms"] = budget_ms
|
|
194
|
+
if label is not None:
|
|
195
|
+
body["label"] = label
|
|
196
|
+
if not save:
|
|
197
|
+
body["save"] = False
|
|
198
|
+
return self._client._request("POST", "/judge/extract", json=body)["data"]
|
|
199
|
+
|
|
200
|
+
def models(self) -> Dict[str, Any]:
|
|
201
|
+
"""Model names evaluate() accepts. Authoritative — do not hardcode."""
|
|
202
|
+
return self._client._request("GET", "/judge/models")["data"]
|
|
203
|
+
|
|
204
|
+
def usage(self, period: str = "current_month") -> Dict[str, Any]:
|
|
205
|
+
"""Net JUDGE_* spend and saved-run counts.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
period: "current_month", "last_30_days" or "all_time".
|
|
209
|
+
"""
|
|
210
|
+
return self._client._request("GET", "/judge/usage", params={"period": period})["data"]
|
|
211
|
+
|
|
212
|
+
def health(self) -> Dict[str, Any]:
|
|
213
|
+
"""Upstream readiness, model revisions, and which upstream is in use."""
|
|
214
|
+
return self._client._request("GET", "/judge/health")["data"]
|
sixtydb/music.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Music (Songs) resource for the 60db API.
|
|
3
|
+
|
|
4
|
+
Free during beta. Each create() call makes 1 song; only one generation
|
|
5
|
+
runs at a time per workspace (a second create while one is running
|
|
6
|
+
returns HTTP 429).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MusicResource:
|
|
16
|
+
"""Music (song generation) endpoints, available at `client.music`."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: "Any") -> None:
|
|
19
|
+
self._client = client
|
|
20
|
+
|
|
21
|
+
def create(
|
|
22
|
+
self,
|
|
23
|
+
prompt: str,
|
|
24
|
+
lyrics_file: Optional[Any] = None,
|
|
25
|
+
**params: Any,
|
|
26
|
+
) -> Dict[str, Any]:
|
|
27
|
+
"""
|
|
28
|
+
Create a song (async, returns immediately with a "submitted" song).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
prompt: Style (advanced mode) or description (simple mode). Required.
|
|
32
|
+
lyrics_file: Optional path or file-like object for a .txt lyrics
|
|
33
|
+
upload (sent as multipart, replaces `lyrics`).
|
|
34
|
+
**params: mode, lyrics, lyrics_prompt, instrumental, vocal_gender,
|
|
35
|
+
voice_id, negative_tags, tags, target_duration, seed (string),
|
|
36
|
+
title.
|
|
37
|
+
"""
|
|
38
|
+
data: Dict[str, Any] = {"prompt": prompt, **params}
|
|
39
|
+
|
|
40
|
+
if lyrics_file is not None:
|
|
41
|
+
opened = False
|
|
42
|
+
if isinstance(lyrics_file, str):
|
|
43
|
+
file_obj = open(lyrics_file, "rb")
|
|
44
|
+
filename = os.path.basename(lyrics_file)
|
|
45
|
+
opened = True
|
|
46
|
+
else:
|
|
47
|
+
file_obj = lyrics_file
|
|
48
|
+
filename = getattr(lyrics_file, "name", "lyrics.txt")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
files = {"lyrics_file": (filename, file_obj, "text/plain")}
|
|
52
|
+
# Multipart requests must not send the session's default
|
|
53
|
+
# JSON content-type header.
|
|
54
|
+
headers = {"Authorization": f"Bearer {self._client.api_key}"}
|
|
55
|
+
import requests
|
|
56
|
+
|
|
57
|
+
def _stringify(v: Any) -> str:
|
|
58
|
+
if isinstance(v, bool):
|
|
59
|
+
return "true" if v else "false"
|
|
60
|
+
return str(v)
|
|
61
|
+
|
|
62
|
+
response = requests.post(
|
|
63
|
+
f"{self._client.base_url}/songs",
|
|
64
|
+
headers=headers,
|
|
65
|
+
data={k: _stringify(v) for k, v in data.items() if v is not None},
|
|
66
|
+
files=files,
|
|
67
|
+
timeout=60,
|
|
68
|
+
)
|
|
69
|
+
response.raise_for_status()
|
|
70
|
+
return response.json()
|
|
71
|
+
finally:
|
|
72
|
+
if opened:
|
|
73
|
+
file_obj.close()
|
|
74
|
+
|
|
75
|
+
return self._client._request("POST", "/songs", json=data, timeout=60)
|
|
76
|
+
|
|
77
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
78
|
+
"""List songs (paged, newest first). limit, offset, q, status, type, liked, sort."""
|
|
79
|
+
return self._client._request("GET", "/songs", params=params, timeout=60)
|
|
80
|
+
|
|
81
|
+
def get(self, song_id: str) -> Dict[str, Any]:
|
|
82
|
+
"""Get one song (SongDetail, includes lyrics)."""
|
|
83
|
+
return self._client._request(
|
|
84
|
+
"GET", f"/songs/{quote(song_id, safe='')}", timeout=60
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def update(
|
|
88
|
+
self,
|
|
89
|
+
song_id: str,
|
|
90
|
+
liked: Optional[bool] = None,
|
|
91
|
+
played: Optional[bool] = None,
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
"""Update flags on a song. At least one of liked/played is required."""
|
|
94
|
+
body: Dict[str, Any] = {}
|
|
95
|
+
if liked is not None:
|
|
96
|
+
body["liked"] = liked
|
|
97
|
+
if played is not None:
|
|
98
|
+
body["played"] = played
|
|
99
|
+
return self._client._request(
|
|
100
|
+
"PATCH", f"/songs/{quote(song_id, safe='')}", json=body, timeout=60
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def delete(self, song_id: str) -> Dict[str, Any]:
|
|
104
|
+
"""Move a song to trash (soft delete)."""
|
|
105
|
+
return self._client._request(
|
|
106
|
+
"DELETE", f"/songs/{quote(song_id, safe='')}", timeout=60
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def download(self, song_id: str) -> bytes:
|
|
110
|
+
"""Download the MP3 file for a song. Returns raw bytes."""
|
|
111
|
+
result = self._client._request(
|
|
112
|
+
"GET", f"/songs/{quote(song_id, safe='')}/download", timeout=120
|
|
113
|
+
)
|
|
114
|
+
if isinstance(result, bytes):
|
|
115
|
+
return result
|
|
116
|
+
raise Exception("Expected binary audio response for song download")
|
|
117
|
+
|
|
118
|
+
def wait_for_completion(
|
|
119
|
+
self, song_id: str, interval: float = 4, timeout: float = 600
|
|
120
|
+
) -> Dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Poll GET /songs/{id} until status is "succeeded" or "failed".
|
|
123
|
+
|
|
124
|
+
Returns the unwrapped song object. Raises TimeoutError on timeout.
|
|
125
|
+
"""
|
|
126
|
+
started_at = time.time()
|
|
127
|
+
while True:
|
|
128
|
+
response = self.get(song_id)
|
|
129
|
+
song = response.get("data", response) if isinstance(response, dict) else response
|
|
130
|
+
status = song.get("status") if isinstance(song, dict) else None
|
|
131
|
+
if status in ("succeeded", "failed"):
|
|
132
|
+
return song
|
|
133
|
+
if time.time() - started_at >= timeout:
|
|
134
|
+
raise TimeoutError(f"Timed out waiting for song {song_id} to complete")
|
|
135
|
+
time.sleep(interval)
|
|
136
|
+
|
|
137
|
+
def list_voices(self) -> Dict[str, Any]:
|
|
138
|
+
"""List voices usable for song creation: `{catalog, mine}`."""
|
|
139
|
+
return self._client._request("GET", "/songs/voices", timeout=60)
|
|
140
|
+
|
|
141
|
+
def create_voice(self, name: str, file: Any) -> Dict[str, Any]:
|
|
142
|
+
"""
|
|
143
|
+
Save a voice from an audio clip (multipart).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
name: Voice name (<=200 chars).
|
|
147
|
+
file: Path or file-like object for the reference audio
|
|
148
|
+
(wav/flac/mp3/m4a/ogg, 3-30s, <=10MB).
|
|
149
|
+
"""
|
|
150
|
+
opened = False
|
|
151
|
+
if isinstance(file, str):
|
|
152
|
+
file_obj = open(file, "rb")
|
|
153
|
+
filename = os.path.basename(file)
|
|
154
|
+
opened = True
|
|
155
|
+
else:
|
|
156
|
+
file_obj = file
|
|
157
|
+
filename = getattr(file, "name", "voice.wav")
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
import requests
|
|
161
|
+
|
|
162
|
+
files = {"voice_reference": (filename, file_obj, "audio/wav")}
|
|
163
|
+
data = {"name": name, "consent": "true"}
|
|
164
|
+
headers = {"Authorization": f"Bearer {self._client.api_key}"}
|
|
165
|
+
response = requests.post(
|
|
166
|
+
f"{self._client.base_url}/songs/voices",
|
|
167
|
+
headers=headers,
|
|
168
|
+
data=data,
|
|
169
|
+
files=files,
|
|
170
|
+
timeout=60,
|
|
171
|
+
)
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
return response.json()
|
|
174
|
+
finally:
|
|
175
|
+
if opened:
|
|
176
|
+
file_obj.close()
|
|
177
|
+
|
|
178
|
+
def health(self) -> Dict[str, Any]:
|
|
179
|
+
"""Song generation service status: `{ready, status}`."""
|
|
180
|
+
return self._client._request("GET", "/songs/health", timeout=60)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sixtydb
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Official 60db SDK for Python
|
|
5
|
+
Author: 60db
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: 60db,tts,stt,voice,ai
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: requests>=2.28.0
|
|
19
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
20
|
+
|
|
21
|
+
# 60db
|
|
22
|
+
|
|
23
|
+
Official 60db SDK for Python
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install sixtydb
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from sixtydb import SixtyDBClient
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch_and_display_voices(client: SixtyDBClient) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Fetch all available voices and print their details.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
str: Selected voice_id
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
response = client.get_voices()
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
print("Error fetching voices:", exc)
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
if not response.get("success"):
|
|
51
|
+
raise Exception(response.get("message"))
|
|
52
|
+
|
|
53
|
+
voices = response["data"]["built_in_voices"]
|
|
54
|
+
|
|
55
|
+
if not voices:
|
|
56
|
+
raise Exception("No voices available.")
|
|
57
|
+
|
|
58
|
+
print("\nAvailable Voices:\n" + "-" * 40)
|
|
59
|
+
|
|
60
|
+
for voice in voices:
|
|
61
|
+
print(
|
|
62
|
+
f"Name: {voice.get('name')} | "
|
|
63
|
+
f"Voice ID: {voice.get('voice_id')} | "
|
|
64
|
+
f"Language: {voice.get('language_name')} | "
|
|
65
|
+
f"Gender: {voice.get('gender')}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return voices[0]["voice_id"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def generate_speech(client: SixtyDBClient, voice_id: str) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Generate speech and save to file.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
audio = client.text_to_speech(
|
|
77
|
+
text="Hello, world!",
|
|
78
|
+
voice_id=voice_id,
|
|
79
|
+
speed=1.0
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
with open("output.mp3", "wb") as file:
|
|
83
|
+
file.write(audio)
|
|
84
|
+
|
|
85
|
+
print("\nAudio saved to output.mp3")
|
|
86
|
+
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
print("Error generating speech:", exc)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> None:
|
|
93
|
+
try:
|
|
94
|
+
client = SixtyDBClient("your-api-key")
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
print("Error creating client:", exc)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
voice_id = fetch_and_display_voices(client)
|
|
101
|
+
generate_speech(client, voice_id)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
print("Operation failed:", exc)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## API Methods
|
|
113
|
+
|
|
114
|
+
### Text-to-Speech
|
|
115
|
+
- `text_to_speech(text, voice_id=None, **params)` – Convert text to speech
|
|
116
|
+
|
|
117
|
+
### Speech-to-Text
|
|
118
|
+
- `speech_to_text(audio_file, language=None)` – Transcribe audio
|
|
119
|
+
- `get_languages()` – Get supported languages
|
|
120
|
+
|
|
121
|
+
### Voices
|
|
122
|
+
- `get_voices()` – List all voices
|
|
123
|
+
|
|
124
|
+
### Music
|
|
125
|
+
Free during beta (no credit check, nothing charged). Each `create()` call
|
|
126
|
+
makes **1 song**, and only **one generation runs at a time per workspace**
|
|
127
|
+
(a second `create()` while one is running returns 429). A song takes about
|
|
128
|
+
1.5-2.5 minutes.
|
|
129
|
+
- `music.create(prompt, lyrics_file=None, **params)` – Create a song (async)
|
|
130
|
+
- `music.list(**params)` – List songs (limit, offset, q, status, type, liked, sort)
|
|
131
|
+
- `music.get(song_id)` – Get a song (includes lyrics)
|
|
132
|
+
- `music.update(song_id, liked=None, played=None)` – Update flags
|
|
133
|
+
- `music.delete(song_id)` – Move a song to trash
|
|
134
|
+
- `music.download(song_id)` – Download the MP3 (bytes)
|
|
135
|
+
- `music.wait_for_completion(song_id, interval=4, timeout=600)` – Poll until succeeded/failed
|
|
136
|
+
- `music.list_voices()` – List catalog + saved voices
|
|
137
|
+
- `music.create_voice(name, file)` – Save a voice from an audio clip
|
|
138
|
+
- `music.health()` – Song service status
|
|
139
|
+
|
|
140
|
+
### Judge
|
|
141
|
+
|
|
142
|
+
Scores content against a rubric you define, and parses conversational turns.
|
|
143
|
+
A classifier, not a generator — answers are probability distributions over
|
|
144
|
+
your own options.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
run = client.judge.evaluate(
|
|
148
|
+
state=call_transcript,
|
|
149
|
+
questions={
|
|
150
|
+
"tone": {"type": "choice",
|
|
151
|
+
"criteria": {"professional": "Calm", "dismissive": "Brushes off",
|
|
152
|
+
"unknown": "Not clear"}}, # always add an escape hatch
|
|
153
|
+
"satisfaction": {"type": "score", "criteria": ["Angry", "Neutral", "Happy"]},
|
|
154
|
+
"resolved": {"type": "noul"},
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
run["answers"]["tone"]["choice"] # 'dismissive'
|
|
158
|
+
run["answers"]["satisfaction"]["score"] # 1.87 — weighted, not an index
|
|
159
|
+
run["answers"]["resolved"]["noul"] # 0.28 — probability of true, not a confidence
|
|
160
|
+
run["min_confidence"] # under 0.85 → send to a human
|
|
161
|
+
|
|
162
|
+
rubric = client.judge.rubrics.create(name="Call QA", questions=questions)
|
|
163
|
+
client.judge.evaluate(state=transcript, rubric_id=rubric["id"])
|
|
164
|
+
|
|
165
|
+
turn = client.judge.extract(
|
|
166
|
+
text="move my 10am appointment to friday",
|
|
167
|
+
schema={"intents": {"booking": "Wants to change a booking"},
|
|
168
|
+
"operations": {"reschedule": "Move it"},
|
|
169
|
+
"entities": {"date": "A calendar date"}},
|
|
170
|
+
)
|
|
171
|
+
turn["intent"]["label"] # 'booking'
|
|
172
|
+
turn["entities"] # [{'label': 'date', 'text': 'friday', 'start': 28, 'end': 34, ...}]
|
|
173
|
+
|
|
174
|
+
client.judge.runs.list(needs_review=True) # the low-confidence review queue
|
|
175
|
+
client.judge.models() # authoritative model list
|
|
176
|
+
client.judge.usage("current_month") # spend and run counts
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Entity offsets are Unicode code points** — slice `list(text)[start:end]`,
|
|
180
|
+
not `text[start:end]`, or an emoji earlier in the turn shifts every span.
|
|
181
|
+
|
|
182
|
+
**Pricing:** per input token; the shared state is re-encoded once per
|
|
183
|
+
question. Context is 8K tokens **per question**. Failed runs are refunded.
|
|
184
|
+
|
|
185
|
+
### Dialer
|
|
186
|
+
Not free: a number costs **$6.00/month**, calls cost **$0.003/min**. Most
|
|
187
|
+
calls need a finished trunk setup (dashboard) and approved dialer KYC
|
|
188
|
+
(also done in the dashboard, not exposed by this SDK). `calls.reserve()`
|
|
189
|
+
does **not** dial — it only pre-authorizes a call id for a SIP client
|
|
190
|
+
(softphone/PBX) to use.
|
|
191
|
+
- `dialer.status()` – Provisioning status
|
|
192
|
+
- `dialer.numbers.search()` / `.buy(number=None, **params)` / `.list()` / `.release(e164)` / `.set_caller_id(e164)` – `buy()` requires approved KYC done in the 60db app, otherwise 403 `DIALER_KYC_REQUIRED`/`DIALER_KYC_PENDING`/`DIALER_KYC_REJECTED`; charges $6.00 now, then $6.00/month
|
|
193
|
+
- `dialer.calls.list(limit=None, offset=None)` / `.get(call_id)` / `.live()` / `.line_status()` / `.reserve(to, from_number, sip_call_id, direction="outbound")` – `reserve()` does not dial
|
|
194
|
+
- `dialer.recordings.list(**params)` / `.playback_url(id)` / `.transcript(id)` / `.delete(id)` / `.set_enabled(enabled)`
|
|
195
|
+
- `dialer.billing.subscriptions()` / `.usage(limit=None)`
|
|
196
|
+
- `dialer.webhooks.list()` / `.create(**params)` / `.update(id, **params)` / `.delete(id)` / `.rotate_secret(id)` / `.test(id)` / `.deliveries(id)`
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Example: Speech to Text
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
from sixtydb import SixtyDBClient
|
|
204
|
+
|
|
205
|
+
client = SixtyDBClient("your-api-key")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
with open("output.mp3", "rb") as file:
|
|
209
|
+
result = client.speech_to_text(file, language="en")
|
|
210
|
+
|
|
211
|
+
if result.get("success"):
|
|
212
|
+
print("Transcription:", result["data"]["text"])
|
|
213
|
+
else:
|
|
214
|
+
print("STT Error:", result.get("message"))
|
|
215
|
+
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
print("Error during transcription:", exc)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sixtydb/__init__.py,sha256=dxgyfVT4AiNQx2PFh7GOiLA7n9tO5sqmvUHqEolCf7g,709
|
|
2
|
+
sixtydb/client.py,sha256=YqyYQLDIjEHr3AjrDlexuDdcatAWhyG4eji6ClpIoe0,10780
|
|
3
|
+
sixtydb/dialer.py,sha256=Nadla0LijLUGIgqsjhcakNXpOq4_b9ig3JBDA0pYgwE,8643
|
|
4
|
+
sixtydb/judge.py,sha256=g9bJlEXX7aX0JQ8KOk0-KYC5lArvOqBrJNqjDNKixIA,8065
|
|
5
|
+
sixtydb/music.py,sha256=bjmJCtPl3JK74Y7jUHyGcRUy2lKyqQHPQIDFJ-3DerA,6604
|
|
6
|
+
sixtydb-1.1.0.dist-info/METADATA,sha256=67mJpEhMrO66auQoQve2g78zpapfeFnEHG4pTeZrbZ0,7356
|
|
7
|
+
sixtydb-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
sixtydb-1.1.0.dist-info/top_level.txt,sha256=oTqInjPZSaLsdxC7ZTMhDgJNYQmLpO8nyMbLglr7z4I,8
|
|
9
|
+
sixtydb-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sixtydb
|