vocalstack 1.0.0a0__tar.gz → 1.0.0a1__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,8 @@
1
+ # Packaging: hatchling reads the nearest .gitignore when it builds the sdist.
2
+ .venv/
3
+ dist/
4
+ build/
5
+ __pycache__/
6
+ *.egg-info/
7
+ .env
8
+ .env.*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VocalStack
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.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalstack
3
- Version: 1.0.0a0
3
+ Version: 1.0.0a1
4
4
  Summary: VocalStack speech API SDK (asyncio): one client grouped by resource, typed models from the API contract, realtime WebSocket streaming.
5
5
  Project-URL: Homepage, https://vocalstack.com
6
- License: MIT
6
+ License-Expression: MIT
7
+ License-File: LICENSE
7
8
  Requires-Python: >=3.10
8
9
  Requires-Dist: httpx>=0.27
9
10
  Requires-Dist: pydantic>=2.7
@@ -32,11 +33,13 @@ async def main():
32
33
  asyncio.run(main())
33
34
  ```
34
35
 
35
- <!-- sdk-docs: generated by `make sdk-docs` from the guides; edit those, not this -->
36
+ <!-- sdk-docs: generated from the VocalStack API guides; edits here are overwritten -->
36
37
 
37
38
  ## Install
38
39
 
39
- Not on PyPI yet. The API serves its own builds to every account: [https://api.vocalstack.com/docs/guides/quickstart.md](https://api.vocalstack.com/docs/guides/quickstart.md) has the install command for the current build (it sends your API key to that host only).
40
+ ```sh
41
+ pip install vocalstack
42
+ ```
40
43
 
41
44
  ## Guides
42
45
 
@@ -48,6 +51,7 @@ Not on PyPI yet. The API serves its own builds to every account: [https://api.vo
48
51
  - [Go live](https://github.com/vocalstack/python-sdk/blob/main/docs/go-live.md): Publish live audio to a stored transcription, and put it on a broadcast link listeners open. Control is HTTP; audio is one WebSocket.
49
52
  - [Listen](https://github.com/vocalstack/python-sdk/blob/main/docs/listen.md): Follow a recording as it happens, in any language: subscribe, catch up from a snapshot, then apply live frames in order.
50
53
  - [Live translation and spoken translations](https://github.com/vocalstack/python-sdk/blob/main/docs/translation-and-voices.md): Translate a stream as it is spoken, and have each translated sentence spoken back in a voice. Text frames and audio clips, over the same socket.
54
+ - [Connect an app with one click](https://github.com/vocalstack/python-sdk/blob/main/docs/connect-an-app.md): Let the people who use your app connect their VocalStack account with one Authenticate click — OAuth 2.0 authorization code with PKCE, where the access token is an API key — instead of pasting a key.
51
55
  - [Errors, retries and limits](https://github.com/vocalstack/python-sdk/blob/main/docs/errors-and-limits.md): One error envelope everywhere; what to retry (429, 503 unavailable) and how to make a retry safe (Idempotency-Key); credit, plan limits and the 402s.
52
56
 
53
57
  <!-- /sdk-docs -->
@@ -113,5 +117,5 @@ async with VocalStack(api_key=key) as vs:
113
117
  `status`, `retryable`, `details` (quota numbers, code suggestions), `headers`
114
118
  and `retry_after`.
115
119
 
116
- Verified against the language-agnostic conformance suite (`make conformance`)
117
- and against a running API (`make sdk-e2e`). For agents: `SKILL.md`.
120
+ Verified against a language-agnostic conformance suite shared by every
121
+ VocalStack SDK and against a running API. For agents: `SKILL.md`.
@@ -20,11 +20,13 @@ async def main():
20
20
  asyncio.run(main())
21
21
  ```
22
22
 
23
- <!-- sdk-docs: generated by `make sdk-docs` from the guides; edit those, not this -->
23
+ <!-- sdk-docs: generated from the VocalStack API guides; edits here are overwritten -->
24
24
 
25
25
  ## Install
26
26
 
27
- Not on PyPI yet. The API serves its own builds to every account: [https://api.vocalstack.com/docs/guides/quickstart.md](https://api.vocalstack.com/docs/guides/quickstart.md) has the install command for the current build (it sends your API key to that host only).
27
+ ```sh
28
+ pip install vocalstack
29
+ ```
28
30
 
29
31
  ## Guides
30
32
 
@@ -36,6 +38,7 @@ Not on PyPI yet. The API serves its own builds to every account: [https://api.vo
36
38
  - [Go live](https://github.com/vocalstack/python-sdk/blob/main/docs/go-live.md): Publish live audio to a stored transcription, and put it on a broadcast link listeners open. Control is HTTP; audio is one WebSocket.
37
39
  - [Listen](https://github.com/vocalstack/python-sdk/blob/main/docs/listen.md): Follow a recording as it happens, in any language: subscribe, catch up from a snapshot, then apply live frames in order.
38
40
  - [Live translation and spoken translations](https://github.com/vocalstack/python-sdk/blob/main/docs/translation-and-voices.md): Translate a stream as it is spoken, and have each translated sentence spoken back in a voice. Text frames and audio clips, over the same socket.
41
+ - [Connect an app with one click](https://github.com/vocalstack/python-sdk/blob/main/docs/connect-an-app.md): Let the people who use your app connect their VocalStack account with one Authenticate click — OAuth 2.0 authorization code with PKCE, where the access token is an API key — instead of pasting a key.
39
42
  - [Errors, retries and limits](https://github.com/vocalstack/python-sdk/blob/main/docs/errors-and-limits.md): One error envelope everywhere; what to retry (429, 503 unavailable) and how to make a retry safe (Idempotency-Key); credit, plan limits and the 402s.
40
43
 
41
44
  <!-- /sdk-docs -->
@@ -101,5 +104,5 @@ async with VocalStack(api_key=key) as vs:
101
104
  `status`, `retryable`, `details` (quota numbers, code suggestions), `headers`
102
105
  and `retry_after`.
103
106
 
104
- Verified against the language-agnostic conformance suite (`make conformance`)
105
- and against a running API (`make sdk-e2e`). For agents: `SKILL.md`.
107
+ Verified against a language-agnostic conformance suite shared by every
108
+ VocalStack SDK and against a running API. For agents: `SKILL.md`.
@@ -31,16 +31,6 @@ python3 -m venv .venv && . .venv/bin/activate # system Pythons refuse a bare p
31
31
  pip install vocalstack # Python 3.10 or newer
32
32
  ```
33
33
 
34
- Before the package is published, any account installs
35
- the private build its environment serves: the API reference's Install section
36
- (`<base>/docs`, or Developers -> API reference in the app) and `<base>/sdk/latest`
37
- give the exact commands, which send `VOCALSTACK_API_KEY` to that host only.
38
- Or install it from a local checkout of the repository:
39
-
40
- ```sh
41
- pip install -e /absolute/path/to/transcribe/sdks/python
42
- ```
43
-
44
34
  Live transcription (program 4) also needs `ffmpeg` on PATH.
45
35
 
46
36
  Speaker numbers start at 0. A speaker of `-1` means speaker labels were off
@@ -57,8 +47,7 @@ for that transcription, so print the text without a speaker.
57
47
  - Scopes: `read` (read transcripts, search, export), `write` and
58
48
  `recordings:publish` (create transcriptions, stream live audio), or `*`.
59
49
  - Base URL: `https://api.vocalstack.com` (the default). Override it with
60
- `VOCALSTACK_BASE_URL`: `http://127.0.0.1:8090` for a local server,
61
- `https://api.e2e.vocalstack.com` for e2e.
50
+ `VOCALSTACK_BASE_URL` (for example, a local or staging server).
62
51
 
63
52
  Every program below opens the client the same way, inside `async def main()`
64
53
  run with `asyncio.run(main())` (a `None` base URL means the default):
@@ -203,8 +192,9 @@ its `Retry-After`, twice by default (`max_retries` on the client).
203
192
  |---|---|---|
204
193
  | 401 | `invalid_token`, `key_expired`, `key_revoked` | Check `VOCALSTACK_API_KEY`. Use the replacement key, or create a new one. |
205
194
  | 403 | `forbidden` | Usually a missing scope: the message names it, and `e.headers["www-authenticate"]` lists the scopes the route needs. A suspended key or account gets `suspended`. |
206
- | 402 | `insufficient_credit` | The account's credit is out. `details`: `balance`, `plan`, `topUpUrl`, and `required` when the cost was known up front. Add credit; `await vs.billing.get()` shows the balance. |
207
- | 409 | `payment_method_required` | Auto top-up (`vs.billing.set_auto_top_up`) and the portal (`vs.billing.portal()`) need a payment first (`vs.billing.create_checkout(kind="topup", amount=25, accept_terms=True, waive_withdrawal=True)` opens one; a signed-in person's token, never an API key). |
195
+ | 403 | `terms_acceptance_required` | The account has not accepted the current Terms of Service and Privacy Policy: someone signs in to the Dashboard and accepts them (an organisation's keys work once an owner or admin has). Not retryable until then. |
196
+ | 402 | `insufficient_credit` | The account's credit is out, or does not cover the transcription: every transcription is billed at least the plan's minimum (`rates.minimumSeconds`, 5 minutes). `details`: `balance`, `plan`, `topUpUrl`, and `required` when the cost was known up front. Add credit; `await vs.billing.get()` shows the balance. |
197
+ | 409 | `payment_method_required` | Auto top-up (`vs.billing.set_auto_top_up`) and the portal (`vs.billing.portal()`) need a payment first (`vs.billing.create_checkout(kind="topup", amount=25, accept_terms=True)` opens one; a signed-in person's token, never an API key). |
208
198
  | 402 | `quota_exceeded` | The plan's transcriptions-at-once limit is reached. `details`: `metric` (`concurrent_transcriptions`), `limit`, `used`, `plan`, `topUpUrl`. |
209
199
  | 429 | `rate_limited` | Retried automatically; if it still fails, wait `e.retry_after` seconds. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
210
200
  | 503 | `unavailable` | The service is waking (its database was asleep). Retried automatically after `Retry-After` (2 s) on calls that are safe to repeat: GET, HEAD, DELETE, or any call with `idempotency_key=`. A create without a key is not retried: retry it yourself, with an `idempotency_key` so it cannot run twice. A realtime socket refused this way raises `unavailable`; a bare 503 there is `capacity_exceeded`. |
@@ -1,13 +1,14 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling>=1.27"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vocalstack"
7
- version = "1.0.0a0"
7
+ version = "1.0.0a1"
8
8
  description = "VocalStack speech API SDK (asyncio): one client grouped by resource, typed models from the API contract, realtime WebSocket streaming."
9
9
  readme = "README.md"
10
- license = { text = "MIT" }
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
11
12
  requires-python = ">=3.10"
12
13
  dependencies = [
13
14
  "httpx>=0.27",
@@ -28,4 +29,4 @@ packages = ["src/vocalstack"]
28
29
 
29
30
  # An explicit list: nothing else from the repository goes into the sdist.
30
31
  [tool.hatch.build.targets.sdist]
31
- only-include = ["src/vocalstack", "README.md", "SKILL.md", "skills", "pyproject.toml"]
32
+ only-include = ["src/vocalstack", "LICENSE", "README.md", "SKILL.md", "skills", "pyproject.toml"]
@@ -31,16 +31,6 @@ python3 -m venv .venv && . .venv/bin/activate # system Pythons refuse a bare p
31
31
  pip install vocalstack # Python 3.10 or newer
32
32
  ```
33
33
 
34
- Before the package is published, any account installs
35
- the private build its environment serves: the API reference's Install section
36
- (`<base>/docs`, or Developers -> API reference in the app) and `<base>/sdk/latest`
37
- give the exact commands, which send `VOCALSTACK_API_KEY` to that host only.
38
- Or install it from a local checkout of the repository:
39
-
40
- ```sh
41
- pip install -e /absolute/path/to/transcribe/sdks/python
42
- ```
43
-
44
34
  Live transcription (program 4) also needs `ffmpeg` on PATH.
45
35
 
46
36
  Speaker numbers start at 0. A speaker of `-1` means speaker labels were off
@@ -57,8 +47,7 @@ for that transcription, so print the text without a speaker.
57
47
  - Scopes: `read` (read transcripts, search, export), `write` and
58
48
  `recordings:publish` (create transcriptions, stream live audio), or `*`.
59
49
  - Base URL: `https://api.vocalstack.com` (the default). Override it with
60
- `VOCALSTACK_BASE_URL`: `http://127.0.0.1:8090` for a local server,
61
- `https://api.e2e.vocalstack.com` for e2e.
50
+ `VOCALSTACK_BASE_URL` (for example, a local or staging server).
62
51
 
63
52
  Every program below opens the client the same way, inside `async def main()`
64
53
  run with `asyncio.run(main())` (a `None` base URL means the default):
@@ -203,8 +192,9 @@ its `Retry-After`, twice by default (`max_retries` on the client).
203
192
  |---|---|---|
204
193
  | 401 | `invalid_token`, `key_expired`, `key_revoked` | Check `VOCALSTACK_API_KEY`. Use the replacement key, or create a new one. |
205
194
  | 403 | `forbidden` | Usually a missing scope: the message names it, and `e.headers["www-authenticate"]` lists the scopes the route needs. A suspended key or account gets `suspended`. |
206
- | 402 | `insufficient_credit` | The account's credit is out. `details`: `balance`, `plan`, `topUpUrl`, and `required` when the cost was known up front. Add credit; `await vs.billing.get()` shows the balance. |
207
- | 409 | `payment_method_required` | Auto top-up (`vs.billing.set_auto_top_up`) and the portal (`vs.billing.portal()`) need a payment first (`vs.billing.create_checkout(kind="topup", amount=25, accept_terms=True, waive_withdrawal=True)` opens one; a signed-in person's token, never an API key). |
195
+ | 403 | `terms_acceptance_required` | The account has not accepted the current Terms of Service and Privacy Policy: someone signs in to the Dashboard and accepts them (an organisation's keys work once an owner or admin has). Not retryable until then. |
196
+ | 402 | `insufficient_credit` | The account's credit is out, or does not cover the transcription: every transcription is billed at least the plan's minimum (`rates.minimumSeconds`, 5 minutes). `details`: `balance`, `plan`, `topUpUrl`, and `required` when the cost was known up front. Add credit; `await vs.billing.get()` shows the balance. |
197
+ | 409 | `payment_method_required` | Auto top-up (`vs.billing.set_auto_top_up`) and the portal (`vs.billing.portal()`) need a payment first (`vs.billing.create_checkout(kind="topup", amount=25, accept_terms=True)` opens one; a signed-in person's token, never an API key). |
208
198
  | 402 | `quota_exceeded` | The plan's transcriptions-at-once limit is reached. `details`: `metric` (`concurrent_transcriptions`), `limit`, `used`, `plan`, `topUpUrl`. |
209
199
  | 429 | `rate_limited` | Retried automatically; if it still fails, wait `e.retry_after` seconds. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
210
200
  | 503 | `unavailable` | The service is waking (its database was asleep). Retried automatically after `Retry-After` (2 s) on calls that are safe to repeat: GET, HEAD, DELETE, or any call with `idempotency_key=`. A create without a key is not retried: retry it yourself, with an `idempotency_key` so it cannot run twice. A realtime socket refused this way raises `unavailable`; a bare 503 there is `capacity_exceeded`. |
@@ -30,7 +30,7 @@ from .errors import VocalStackError
30
30
  from .realtime import RealtimeTranscript
31
31
  from .realtime import RealtimeSession, pace_realtime
32
32
 
33
- __version__ = "1.0.0a0"
33
+ __version__ = "1.0.0a1"
34
34
 
35
35
  __all__ = [
36
36
  "DEFAULT_BASE_URL",
@@ -8,7 +8,7 @@ from typing import Any, Callable, Dict, Mapping, Optional, Union
8
8
 
9
9
  import httpx
10
10
 
11
- from .errors import from_envelope
11
+ from .errors import from_envelope, from_oauth
12
12
 
13
13
  DEFAULT_BASE_URL = "https://api.vocalstack.com"
14
14
 
@@ -103,6 +103,8 @@ class Transport:
103
103
  headers: Optional[Mapping[str, str]] = None,
104
104
  idempotency_key: Optional[str] = None,
105
105
  anonymous: bool = False,
106
+ no_retry: bool = False,
107
+ oauth_errors: bool = False,
106
108
  ) -> httpx.Response:
107
109
  h: Dict[str, str] = {"accept": "application/json"}
108
110
  if headers:
@@ -135,8 +137,8 @@ class Transport:
135
137
  body = resp.json()
136
138
  except (json.JSONDecodeError, ValueError):
137
139
  pass
138
- err = from_envelope(body, resp.status_code, resp.headers)
139
- if retries(resp.status_code, err.code, method, idempotency_key) and attempt < self.max_retries:
140
+ err = (from_oauth if oauth_errors else from_envelope)(body, resp.status_code, resp.headers)
141
+ if not no_retry and retries(resp.status_code, err.code, method, idempotency_key) and attempt < self.max_retries:
140
142
  wait = err.retry_after
141
143
  if wait is None and err.code == "unavailable":
142
144
  wait = UNAVAILABLE_WAIT_S
@@ -14,7 +14,7 @@ class _Base(BaseModel):
14
14
 
15
15
  Access = Literal["public", "private"]
16
16
 
17
- ErrorCode = Literal["invalid_audio", "invalid_options", "invalid_token", "capacity_exceeded", "slow_consumer", "engine_unavailable", "internal", "session_expired", "forbidden", "broadcast_archived", "not_found", "conflict", "invalid_state", "password_required", "rate_limited", "preview_pending", "language_unavailable", "suspended", "key_expired", "key_revoked", "key_from_browser", "insufficient_credit", "payment_method_required", "payment_method_in_use", "billing_details_required", "promo_code_invalid", "auto_top_up_off", "range_too_long", "quota_exceeded", "idempotency_key_reused", "method_not_allowed", "payload_too_large", "unsupported_media_type", "invalid_body", "timeout", "unavailable"]
17
+ ErrorCode = Literal["invalid_audio", "invalid_options", "invalid_token", "capacity_exceeded", "slow_consumer", "engine_unavailable", "internal", "session_expired", "forbidden", "broadcast_archived", "not_found", "conflict", "invalid_state", "password_required", "rate_limited", "preview_pending", "language_unavailable", "suspended", "key_expired", "key_revoked", "key_from_browser", "insufficient_credit", "usage_cap_reached", "payment_method_required", "terms_acceptance_required", "payment_method_in_use", "billing_details_required", "promo_code_invalid", "auto_top_up_off", "range_too_long", "quota_exceeded", "idempotency_key_reused", "method_not_allowed", "payload_too_large", "unsupported_media_type", "invalid_body", "timeout", "unavailable"]
18
18
 
19
19
  AudioEncoding = Literal["pcm_f32le"]
20
20
 
@@ -46,7 +46,9 @@ IngestState = Literal["connecting", "streaming", "reconnecting", "stopped", "fai
46
46
 
47
47
  InputKind = Literal["live", "file", "url", "stream"]
48
48
 
49
- JobState = Literal["queued", "fetching", "transcribing", "done", "failed", "cropping"]
49
+ QueueReason = Literal["concurrency"]
50
+
51
+ JobState = Literal["queued", "fetching", "transcribing", "done", "failed", "cropping", "stopped"]
50
52
 
51
53
  LinkKind = Literal["live", "file", "unknown"]
52
54
 
@@ -63,6 +65,11 @@ SentenceStatus = Literal["open", "closed", "dropped"]
63
65
  VoiceStatusState = Literal["preparing", "unavailable"]
64
66
 
65
67
 
68
+ class AccountClosing(_Base):
69
+ at: str
70
+ unspent_paid_credit_refunded: bool = Field(alias="unspentPaidCreditRefunded")
71
+
72
+
66
73
  class Actor(_Base):
67
74
  api_key_id: Optional[str] = Field(default=None, alias="apiKeyId")
68
75
  display_name: Optional[str] = Field(default=None, alias="displayName")
@@ -77,7 +84,6 @@ class WordAlt(_Base):
77
84
  class Word(_Base):
78
85
  alts: Optional[List["WordAlt"]] = None
79
86
  end: float
80
- llm: Optional[bool] = None
81
87
  prob: Optional[float] = None
82
88
  recognized: Optional[str] = None
83
89
  script: Optional[bool] = None
@@ -166,6 +172,7 @@ class MoneyRange(_Base):
166
172
  class AutoTopUpChoices(_Base):
167
173
  amount_range: "MoneyRange" = Field(alias="amountRange")
168
174
  amounts: List[float]
175
+ auto_topup_max_per_day: int = Field(alias="autoTopupMaxPerDay")
169
176
  threshold_range: "MoneyRange" = Field(alias="thresholdRange")
170
177
  thresholds: List[float]
171
178
 
@@ -178,8 +185,23 @@ class AutoTopUpSettings(_Base):
178
185
  threshold: Optional[float] = None
179
186
 
180
187
 
188
+ class SettingLowering(_Base):
189
+ """A lowering of a billing setting that changes the account's terms (the
190
+ days data is kept after a downgrade, the auto top-up daily limit),
191
+ told with at least 30 days' notice (docs/billing-price-changes.md §6)."""
192
+
193
+ after_date: Optional[str] = Field(default=None, alias="afterDate")
194
+ applies_at: str = Field(alias="appliesAt")
195
+ refund_deadline: str = Field(alias="refundDeadline")
196
+ refund_window_open: bool = Field(alias="refundWindowOpen")
197
+ renewal_on: Optional[str] = Field(default=None, alias="renewalOn")
198
+ to: int
199
+
200
+
181
201
  class AutoTopUpView(_Base):
182
202
  amount: Optional[float] = None
203
+ auto_topup_max_per_day: int = Field(alias="autoTopupMaxPerDay")
204
+ auto_topup_max_per_day_lowering: Optional["SettingLowering"] = Field(default=None, alias="autoTopupMaxPerDayLowering")
183
205
  available: bool
184
206
  enabled: bool
185
207
  last: Optional["AutoTopUpAttempt"] = None
@@ -207,6 +229,13 @@ class CardView(_Base):
207
229
  last4: str
208
230
 
209
231
 
232
+ class DowngradeKeepDays(_Base):
233
+ """The account's days data is kept after a downgrade (Terms §14)."""
234
+
235
+ days: int
236
+ lowering: Optional["SettingLowering"] = None
237
+
238
+
210
239
  class Limits2(_Base):
211
240
  """The limits of a version. `null` is unlimited."""
212
241
 
@@ -244,16 +273,32 @@ class MonthlyCreditView(_Base):
244
273
  used: float
245
274
 
246
275
 
276
+ class PriceChange(_Base):
277
+ from_: float = Field(alias="from")
278
+ interval: str
279
+ to: float
280
+
281
+
247
282
  class Rates(_Base):
248
- """The rates of a version, in dollars per hour."""
283
+ """The rate card of a version: dollars per hour, and the least a
284
+ transcription is billed."""
249
285
 
250
286
  file_per_hour: float = Field(alias="filePerHour")
251
287
  live_per_hour: float = Field(alias="livePerHour")
288
+ minimum_seconds: int = Field(alias="minimumSeconds")
252
289
 
253
290
 
254
291
  class PendingChange(_Base):
292
+ after_date: Optional[str] = Field(default=None, alias="afterDate")
255
293
  applies_at: str = Field(alias="appliesAt")
294
+ below_zero_limit_changes: bool = Field(alias="belowZeroLimitChanges")
295
+ cancel_before: Optional[str] = Field(default=None, alias="cancelBefore")
296
+ changes: str
297
+ price: Optional["PriceChange"] = None
256
298
  rates: "Rates"
299
+ refund_deadline: Optional[str] = Field(default=None, alias="refundDeadline")
300
+ refund_window_open: bool = Field(alias="refundWindowOpen")
301
+ renewal_on: Optional[str] = Field(default=None, alias="renewalOn")
257
302
 
258
303
 
259
304
  class PendingPayment(_Base):
@@ -278,37 +323,61 @@ class PlanRef(_Base):
278
323
  class NextPayment(_Base):
279
324
  amount: float
280
325
  at: str
326
+ cancel_before: str = Field(alias="cancelBefore")
281
327
 
282
328
 
283
329
  class PendingInterval(_Base):
284
- applies_at: str = Field(alias="appliesAt")
330
+ applies_at: Optional[str] = Field(default=None, alias="appliesAt")
331
+ cancel_before: Optional[str] = Field(default=None, alias="cancelBefore")
285
332
  interval: str
286
333
 
287
334
 
335
+ class ScheduledEnd(_Base):
336
+ at: str
337
+ reason: str
338
+ return_plan: Optional[str] = Field(default=None, alias="returnPlan")
339
+ unused_period_refunded: bool = Field(alias="unusedPeriodRefunded")
340
+
341
+
342
+ class SwitchPrices(_Base):
343
+ month: Optional[float] = None
344
+ year: Optional[float] = None
345
+
346
+
288
347
  class SubscriptionView(_Base):
289
348
  cancel_at_period_end: bool = Field(alias="cancelAtPeriodEnd")
349
+ cancel_reason: Optional[str] = Field(default=None, alias="cancelReason")
290
350
  current_period_end: Optional[str] = Field(default=None, alias="currentPeriodEnd")
291
351
  current_period_start: Optional[str] = Field(default=None, alias="currentPeriodStart")
292
352
  interval: str
293
353
  next_payment: Optional["NextPayment"] = Field(default=None, alias="nextPayment")
294
354
  past_due_since: Optional[str] = Field(default=None, alias="pastDueSince")
295
355
  pending_interval: Optional["PendingInterval"] = Field(default=None, alias="pendingInterval")
356
+ return_plan: Optional[str] = Field(default=None, alias="returnPlan")
357
+ scheduled_end: Optional["ScheduledEnd"] = Field(default=None, alias="scheduledEnd")
296
358
  status: str
359
+ switch_prices: "SwitchPrices" = Field(alias="switchPrices")
360
+ yearly_opt_in: bool = Field(alias="yearlyOptIn")
297
361
 
298
362
 
299
363
  class BillingView(_Base):
300
364
  """`GET /v1/billing`: the account as the Dashboard shows it. The plan's
301
- debt cap (the grace amount) is never on it: `inGrace` says whether
365
+ below-zero limit (its amount) is never on it: `inBelowZero` says whether
302
366
  running transcriptions go on below zero, never how far."""
303
367
 
368
+ account_closing: Optional["AccountClosing"] = Field(default=None, alias="accountClosing")
304
369
  account_created: Optional[str] = Field(default=None, alias="accountCreated")
305
370
  auto_top_up: "AutoTopUpView" = Field(alias="autoTopUp")
306
371
  balance: float
372
+ below_zero_limit_changes_at: Optional[str] = Field(default=None, alias="belowZeroLimitChangesAt")
373
+ below_zero_limit_on: bool = Field(alias="belowZeroLimitOn")
307
374
  can_start: bool = Field(alias="canStart")
375
+ can_start_live: bool = Field(alias="canStartLive")
308
376
  card: Optional["CardView"] = None
377
+ downgrade_keep_days: "DowngradeKeepDays" = Field(alias="downgradeKeepDays")
309
378
  enabled: bool
310
379
  has_paid: bool = Field(alias="hasPaid")
311
- in_grace: bool = Field(alias="inGrace")
380
+ in_below_zero: bool = Field(alias="inBelowZero")
312
381
  limits: Optional["Limits2"] = None
313
382
  limits_usage: Optional["LimitsUsage"] = Field(default=None, alias="limitsUsage")
314
383
  low_balance_alert: "LowBalanceAlertView" = Field(alias="lowBalanceAlert")
@@ -321,6 +390,7 @@ running transcriptions go on below zero, never how far."""
321
390
  rates: Optional["Rates"] = None
322
391
  retention_deadline: Optional[str] = Field(default=None, alias="retentionDeadline")
323
392
  start_block: Optional[str] = Field(default=None, alias="startBlock")
393
+ start_block_live: Optional[str] = Field(default=None, alias="startBlockLive")
324
394
  subscription: Optional["SubscriptionView"] = None
325
395
  top_up_url: str = Field(alias="topUpUrl")
326
396
 
@@ -363,7 +433,6 @@ design's settings sheet): what a recording made in it starts with."""
363
433
 
364
434
  confidence: Optional["ConfidencePolicy"] = None
365
435
  latency_mode: Optional[LatencyMode] = Field(default=None, alias="latencyMode")
366
- llm_fix: Optional[bool] = Field(default=None, alias="llmFix")
367
436
  max_duration_s: Optional[int] = Field(default=None, alias="maxDurationS")
368
437
  max_pause_s: Optional[int] = Field(default=None, alias="maxPauseS")
369
438
  silence_auto_pause_s: Optional[int] = Field(default=None, alias="silenceAutoPauseS")
@@ -452,7 +521,10 @@ class RecordingCharge(_Base):
452
521
  """A charge on the wire (`GET /v1/billing/charges`)."""
453
522
 
454
523
  amount: float
524
+ audio_seconds: float = Field(alias="audioSeconds")
525
+ covered_seconds: float = Field(alias="coveredSeconds")
455
526
  kind: str
527
+ minimum_applied: bool = Field(alias="minimumApplied")
456
528
  name: Optional[str] = None
457
529
  opened_at: str = Field(alias="openedAt")
458
530
  rate_per_hour: float = Field(alias="ratePerHour")
@@ -583,6 +655,7 @@ class CreateCheckout(_Base):
583
655
  kind: str
584
656
  plan_id: Optional[str] = Field(default=None, alias="planId")
585
657
  promo_code: Optional[str] = Field(default=None, alias="promoCode")
658
+ # Deprecated and ignored: `acceptTerms` is the one consent.
586
659
  waive_withdrawal: Optional[bool] = Field(default=None, alias="waiveWithdrawal")
587
660
 
588
661
 
@@ -616,7 +689,6 @@ class SettingsPatch(_Base):
616
689
 
617
690
  confidence: Optional["ConfidencePolicy"] = None
618
691
  latency_mode: Optional[LatencyMode] = Field(default=None, alias="latencyMode")
619
- llm_fix: Optional[bool] = Field(default=None, alias="llmFix")
620
692
  max_duration_s: Optional[int] = Field(default=None, alias="maxDurationS")
621
693
  max_pause_s: Optional[int] = Field(default=None, alias="maxPauseS")
622
694
  silence_auto_pause_s: Optional[int] = Field(default=None, alias="silenceAutoPauseS")
@@ -715,6 +787,13 @@ class JobProgress(_Base):
715
787
  total_s: Optional[float] = Field(default=None, alias="totalS")
716
788
 
717
789
 
790
+ class JobQueue(_Base):
791
+ """Why a `queued` job is waiting, and where it stands (`Job.queue`)."""
792
+
793
+ position: int
794
+ reason: QueueReason
795
+
796
+
718
797
  class KnownVoice(_Base):
719
798
  """One voice a realtime session may recognise a speaker as.
720
799
 
@@ -773,8 +852,16 @@ class LedgerPage(_Base):
773
852
  next_cursor: Optional[str] = Field(default=None, alias="nextCursor")
774
853
 
775
854
 
855
+ class PageMedia(_Base):
856
+ """A recording found on a page."""
857
+
858
+ title: Optional[str] = None
859
+ url: str
860
+
861
+
776
862
  class LinkProbe(_Base):
777
863
  kind: LinkKind
864
+ media: Optional[List["PageMedia"]] = None
778
865
  ok: bool
779
866
  problem: Optional[str] = None
780
867
  source: str
@@ -820,11 +907,13 @@ class Payment(_Base):
820
907
 
821
908
  amount: float
822
909
  at: str
910
+ business_purchase: bool = Field(alias="businessPurchase")
823
911
  description: str
824
912
  document: Optional[str] = None
825
913
  id: str
826
914
  kind: str
827
915
  status: str
916
+ tax_id: Optional[str] = Field(default=None, alias="taxId")
828
917
 
829
918
 
830
919
  class PaymentDocument(_Base):
@@ -865,9 +954,9 @@ class PlanListing(_Base):
865
954
  """One plan on the price list: its current version's rates, prices,
866
955
  credit, limits and features."""
867
956
 
957
+ below_zero_limit_on: bool = Field(alias="belowZeroLimitOn")
868
958
  billed_externally: bool = Field(alias="billedExternally")
869
959
  features: Any
870
- grace_on_zero: bool = Field(alias="graceOnZero")
871
960
  id: str
872
961
  kind: str
873
962
  limits: "Limits2"
@@ -883,7 +972,7 @@ class PlanList(_Base):
883
972
  """`GET /v1/billing/plans`."""
884
973
 
885
974
  auto_top_up: "AutoTopUpChoices" = Field(alias="autoTopUp")
886
- downgrade_grace_days: int = Field(alias="downgradeGraceDays")
975
+ downgrade_keep_days: int = Field(alias="downgradeKeepDays")
887
976
  enabled: bool
888
977
  low_balance_alert: "LowBalanceAlertChoices" = Field(alias="lowBalanceAlert")
889
978
  plans: List["PlanListing"]
@@ -1140,7 +1229,6 @@ class SessionOptions(_Base):
1140
1229
  identify_voices: Optional[List["KnownVoice"]] = Field(default=None, alias="identifyVoices")
1141
1230
  input: Optional["InputOptions"] = None
1142
1231
  latency_mode: Optional[LatencyMode] = Field(default=None, alias="latencyMode")
1143
- llm_fix: Optional[bool] = Field(default=None, alias="llmFix")
1144
1232
  sentences: Optional["SentenceOptions"] = None
1145
1233
  speaker_labels: Optional[SpeakerLabels] = Field(default=None, alias="speakerLabels")
1146
1234
  spoken_language: Optional[str] = Field(default=None, alias="spokenLanguage")
@@ -1153,7 +1241,10 @@ class SessionOptions(_Base):
1153
1241
  class SetInterval(_Base):
1154
1242
  """`POST /v1/billing/subscription/interval`."""
1155
1243
 
1244
+ accept_terms: Optional[bool] = Field(default=None, alias="acceptTerms")
1245
+ expected_price: Optional[float] = Field(default=None, alias="expectedPrice")
1156
1246
  interval: str
1247
+ no_new_withdrawal_period_shown: Optional[bool] = Field(default=None, alias="noNewWithdrawalPeriodShown")
1157
1248
 
1158
1249
 
1159
1250
  class ShareLink(_Base):
@@ -1241,6 +1332,7 @@ class TranscriptionJob(_Base):
1241
1332
  peaks: Optional[List[float]] = None
1242
1333
  preview: Optional[bool] = None
1243
1334
  progress: "JobProgress"
1335
+ queue: Optional["JobQueue"] = None
1244
1336
  recording_id: str = Field(alias="recordingId")
1245
1337
  source: Optional["SourceInfo"] = None
1246
1338
  started_at: Optional[str] = Field(default=None, alias="startedAt")
@@ -1314,6 +1406,36 @@ class UploadRecording(_Base):
1314
1406
  url: Optional[str] = None
1315
1407
 
1316
1408
 
1409
+ class UsageCharge(_Base):
1410
+ """One charge as the usage report counts it (`GET /v1/billing/usage/charges`):
1411
+ a settled `charge` ledger entry, a `charge_correction` (money given
1412
+ back: `correction`, a negative amount), or a charge still open at its
1413
+ current amount."""
1414
+
1415
+ amount: float
1416
+ correction: bool
1417
+ date: str
1418
+ kind: str
1419
+ name: Optional[str] = None
1420
+ opened_at: str = Field(alias="openedAt")
1421
+ rate_per_hour: float = Field(alias="ratePerHour")
1422
+ realtime_session_id: Optional[str] = Field(default=None, alias="realtimeSessionId")
1423
+ recording_id: Optional[str] = Field(default=None, alias="recordingId")
1424
+ seconds: float
1425
+ source: str
1426
+ state: str
1427
+ transcription_id: Optional[str] = Field(default=None, alias="transcriptionId")
1428
+
1429
+
1430
+ class UsageChargePage(_Base):
1431
+ """`GET /v1/billing/usage/charges`."""
1432
+
1433
+ charges: List["UsageCharge"]
1434
+ count: int
1435
+ next_cursor: Optional[str] = Field(default=None, alias="nextCursor")
1436
+ total: float
1437
+
1438
+
1317
1439
  class UsageRow(_Base):
1318
1440
  """One day, source and kind."""
1319
1441
 
@@ -1396,6 +1518,14 @@ class VoiceTrack(_Base):
1396
1518
  word_times: Optional[Dict[str, List[List[float]]]] = Field(default=None, alias="wordTimes")
1397
1519
 
1398
1520
 
1521
+ class YearlyOptIn(_Base):
1522
+ """`POST /v1/billing/subscription/yearly-opt-in`."""
1523
+
1524
+ accept_terms: Optional[bool] = Field(default=None, alias="acceptTerms")
1525
+ expected_price: Optional[float] = Field(default=None, alias="expectedPrice")
1526
+ no_new_withdrawal_period_shown: Optional[bool] = Field(default=None, alias="noNewWithdrawalPeriodShown")
1527
+
1528
+
1399
1529
  class CaptureSourceMic(_Base):
1400
1530
  kind: Literal["mic"] = "mic"
1401
1531
 
@@ -1417,7 +1547,6 @@ class Cancel(_Base):
1417
1547
 
1418
1548
  class SessionUpdate(_Base):
1419
1549
  latency_mode: Optional[LatencyMode] = Field(default=None, alias="latencyMode")
1420
- llm_fix: Optional[bool] = Field(default=None, alias="llmFix")
1421
1550
  speaker_labels: Optional[SpeakerLabels] = Field(default=None, alias="speakerLabels")
1422
1551
  spoken_language: Optional[str] = Field(default=None, alias="spokenLanguage")
1423
1552
  translate: Optional[List[str]] = None
@@ -30,6 +30,7 @@ from .realtime import Realtime
30
30
  from .resources import (
31
31
  Billing,
32
32
  Broadcasts,
33
+ Connect,
33
34
  Corrections,
34
35
  Events,
35
36
  Jobs,
@@ -88,6 +89,7 @@ class VocalStack:
88
89
  self.voices = Voices(t)
89
90
  self.search = Search(t)
90
91
  self.me = Me(t)
92
+ self.connect = Connect(t)
91
93
  self.billing = Billing(t)
92
94
  self.languages = Languages(t)
93
95
  self.rtmp = Rtmp(t)
@@ -81,3 +81,17 @@ def from_envelope(
81
81
  return VocalStackError(
82
82
  "internal", f"unexpected error (status {status})", status=status, headers=headers
83
83
  )
84
+
85
+
86
+ def from_oauth(
87
+ body: Any, status: Optional[int] = None, headers: Optional[Mapping[str, str]] = None
88
+ ) -> VocalStackError:
89
+ """A token endpoint's ``400 { error, error_description }`` (RFC 6749
90
+ §5.2) as a :class:`VocalStackError` whose ``code`` is the ``error``
91
+ (``invalid_request``, ``invalid_grant``, ``unsupported_grant_type``);
92
+ any other answer goes through the envelope."""
93
+ if status == 400 and isinstance(body, dict) and isinstance(body.get("error"), str):
94
+ return VocalStackError(
95
+ body["error"], body.get("error_description") or body["error"], status=status, headers=headers
96
+ )
97
+ return from_envelope(body, status, headers)
@@ -373,11 +373,11 @@ class Recordings(_Group):
373
373
  return _one(m.SummaryStatus, data)
374
374
 
375
375
  async def export(
376
- self, id: str, format: str, *, lang: Optional[str] = None, share_code: Optional[str] = None
376
+ self, id: str, format: str, *, lang: Optional[str] = None, share_code: Optional[str] = None, listener_token: Optional[str] = None
377
377
  ) -> bytes:
378
- """An export file: ``json``, ``srt``, ``xlsx`` or ``pdf``; ``lang`` makes it read in that language."""
378
+ """An export file: ``json``, ``srt``, ``xlsx`` or ``pdf``; ``lang`` makes it read in that language. ``listener_token``: a broadcast listener's, once the recording has ended."""
379
379
  resp = await self._t.raw(
380
- "GET", self._path(id, "/export"), headers={"accept": "*/*"}, query={"format": format, "lang": lang, "shareCode": share_code}
380
+ "GET", self._path(id, "/export"), headers={"accept": "*/*"}, query={"format": format, "lang": lang, "shareCode": share_code, "listenerToken": listener_token}
381
381
  )
382
382
  return resp.content
383
383
 
@@ -472,7 +472,7 @@ class Recordings(_Group):
472
472
  return await websockets.connect(url, max_size=None, additional_headers=headers)
473
473
 
474
474
 
475
- _TERMINAL = {"done", "failed", "cropping"}
475
+ _TERMINAL = {"done", "failed", "stopped", "cropping"}
476
476
 
477
477
 
478
478
  class Jobs(_Group):
@@ -701,6 +701,31 @@ class Me(_Group):
701
701
  return await self._t.json("GET", "/v1/me")
702
702
 
703
703
 
704
+ class Connect(_Group):
705
+ """``vs.connect``: one-click Connect, on the app's server."""
706
+
707
+ async def token(self, *, code: str, code_verifier: str, redirect_uri: str) -> Dict[str, Any]:
708
+ """Redeem the ``code`` the dashboard sent to the app's ``redirect_uri``
709
+ for an API key (OAuth 2.0 authorization code + PKCE): ``code_verifier``
710
+ is the verifier whose ``BASE64URL(SHA256(verifier))`` was the link's
711
+ ``code_challenge``, ``redirect_uri`` exactly the link's. Returns the
712
+ wire's ``{ access_token, token_type, key_id, scope, account }``;
713
+ ``access_token`` is the key, shown once. No credential is sent, even
714
+ when the client has one, and the call is never retried: the code
715
+ works once. A refusal raises :class:`VocalStackError` whose ``code``
716
+ is OAuth's ``error`` (``invalid_grant``: an unknown, spent or expired
717
+ code, or a verifier or ``redirect_uri`` that does not match)."""
718
+ form = {
719
+ "grant_type": "authorization_code",
720
+ "code": code,
721
+ "code_verifier": code_verifier,
722
+ "redirect_uri": redirect_uri,
723
+ }
724
+ return await self._t.json(
725
+ "POST", "/v1/connect/token", data=form, anonymous=True, no_retry=True, oauth_errors=True
726
+ )
727
+
728
+
704
729
  class Billing(_Group):
705
730
  """``vs.billing``: the account's plan, credit balance and limits, the
706
731
  price list, the ledger and the per-recording charges."""
@@ -723,9 +748,9 @@ class Billing(_Group):
723
748
 
724
749
  async def create_checkout(self, *, idempotency_key: Optional[str] = None, **fields: Any) -> m.CheckoutView:
725
750
  """Open a checkout at the payment provider (``kind="topup", amount=``
726
- or ``kind="subscribe", plan_id=, interval=``, with ``accept_terms=True``
727
- and ``waive_withdrawal=True``); send the customer to ``url``. A
728
- signed-in person only, never an API key."""
751
+ or ``kind="subscribe", plan_id=, interval=``, with ``accept_terms=True``:
752
+ the Terms and the request that the purchase start now); send the
753
+ customer to ``url``. A signed-in person only, never an API key."""
729
754
  body = _body(m.CreateCheckout, fields)
730
755
  return _one(m.CheckoutView, await self._t.json("POST", "/v1/billing/checkouts", json_body=body, idempotency_key=idempotency_key))
731
756
 
@@ -760,10 +785,19 @@ class Billing(_Group):
760
785
  return _one(m.LowBalanceAlertView, await self._t.json("PUT", "/v1/billing/low-balance-alert", json_body=body))
761
786
 
762
787
  async def set_interval(self, **fields: Any) -> m.SubscriptionView:
763
- """Renew ``interval="month"`` or ``"year"`` from the next period."""
788
+ """Renew ``interval="month"`` or ``"year"`` from the next period, at
789
+ ``subscription.switch_prices`` (needs ``accept_terms=True`` and
790
+ ``no_new_withdrawal_period_shown=True``)."""
764
791
  body = _body(m.SetInterval, fields)
765
792
  return _one(m.SubscriptionView, await self._t.json("POST", "/v1/billing/subscription/interval", json_body=body))
766
793
 
794
+ async def opt_in_yearly(self, **fields: Any) -> m.SubscriptionView:
795
+ """Keep the subscription by switching to yearly when monthly billing
796
+ is withdrawn (``subscription.yearly_opt_in``); needs
797
+ ``accept_terms=True`` and ``no_new_withdrawal_period_shown=True``."""
798
+ body = _body(m.YearlyOptIn, fields)
799
+ return _one(m.SubscriptionView, await self._t.json("POST", "/v1/billing/subscription/yearly-opt-in", json_body=body))
800
+
767
801
  async def unset_interval(self) -> m.SubscriptionView:
768
802
  """Take back a scheduled interval switch."""
769
803
  return _one(m.SubscriptionView, await self._t.json("DELETE", "/v1/billing/subscription/interval"))
@@ -772,6 +806,21 @@ class Billing(_Group):
772
806
  """What the account spent per day (``YYYY-MM-DD`` in ``tz``), source and kind."""
773
807
  return _one(m.UsageReport, await self._t.json("GET", "/v1/billing/usage", query={"startDate": start_date, "endDate": end_date, "tz": tz}))
774
808
 
809
+ async def usage_charges(
810
+ self,
811
+ *,
812
+ start_date: Optional[str] = None,
813
+ end_date: Optional[str] = None,
814
+ tz: Optional[str] = None,
815
+ kind: Optional[str] = None,
816
+ source: Optional[str] = None,
817
+ cursor: Optional[str] = None,
818
+ limit: Optional[int] = None,
819
+ ) -> m.UsageChargePage:
820
+ """The usage report's charges one by one, newest first; ``kind`` (``file``/``live``) and ``source`` (``dashboard`` or a key id) filter, ``count`` and ``total`` cover every match."""
821
+ query = {"startDate": start_date, "endDate": end_date, "tz": tz, "kind": kind, "source": source, "cursor": cursor, "limit": limit}
822
+ return _one(m.UsageChargePage, await self._t.json("GET", "/v1/billing/usage/charges", query=query))
823
+
775
824
  async def payments(self, *, cursor: Optional[str] = None, limit: Optional[int] = None) -> m.PaymentPage:
776
825
  """The payments history, newest first."""
777
826
  return _one(m.PaymentPage, await self._t.json("GET", "/v1/billing/payments", query={"cursor": cursor, "limit": limit}))
@@ -1,9 +0,0 @@
1
- # Packaging: hatchling copies the nearest .gitignore into the sdist, so this
2
- # one stands in for the monorepo root's.
3
- .venv/
4
- dist/
5
- build/
6
- __pycache__/
7
- *.egg-info/
8
- .env
9
- .env.*