typecast-python 0.3.7__tar.gz → 0.3.9__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.
Files changed (19) hide show
  1. {typecast_python-0.3.7 → typecast_python-0.3.9}/PKG-INFO +14 -10
  2. {typecast_python-0.3.7 → typecast_python-0.3.9}/README.md +1 -1
  3. {typecast_python-0.3.7 → typecast_python-0.3.9}/pyproject.toml +20 -13
  4. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/__init__.py +2 -0
  5. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/async_client.py +91 -18
  6. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/client.py +91 -17
  7. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/models/__init__.py +2 -0
  8. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/models/tts.py +6 -4
  9. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/models/voices.py +20 -5
  10. {typecast_python-0.3.7 → typecast_python-0.3.9}/.gitignore +0 -0
  11. {typecast_python-0.3.7 → typecast_python-0.3.9}/LICENSE +0 -0
  12. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/_user_agent.py +0 -0
  13. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/_voice_clone.py +0 -0
  14. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/composer.py +0 -0
  15. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/conf.py +0 -0
  16. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/exceptions.py +0 -0
  17. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/models/error.py +0 -0
  18. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/models/subscription.py +0 -0
  19. {typecast_python-0.3.7 → typecast_python-0.3.9}/src/typecast/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typecast-python
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Official Typecast Python SDK - Convert text to lifelike speech using AI-powered voices
5
5
  Project-URL: Homepage, https://typecast.ai
6
6
  Project-URL: Documentation, https://typecast.ai/docs/overview
@@ -217,26 +217,30 @@ Classifier: Intended Audience :: Developers
217
217
  Classifier: License :: OSI Approved :: Apache Software License
218
218
  Classifier: Operating System :: OS Independent
219
219
  Classifier: Programming Language :: Python :: 3
220
+ Classifier: Programming Language :: Python :: 3.8
221
+ Classifier: Programming Language :: Python :: 3.9
222
+ Classifier: Programming Language :: Python :: 3.10
220
223
  Classifier: Programming Language :: Python :: 3.11
221
224
  Classifier: Programming Language :: Python :: 3.12
222
225
  Classifier: Programming Language :: Python :: 3.13
226
+ Classifier: Programming Language :: Python :: 3.14
223
227
  Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
224
228
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
225
- Requires-Python: >=3.11
226
- Requires-Dist: aiohttp>=3.14.0
229
+ Requires-Python: <3.15,>=3.8
230
+ Requires-Dist: aiohttp>=3.9.0
227
231
  Requires-Dist: pydantic>=2.0.0
228
232
  Requires-Dist: requests>=2.28.0
229
233
  Requires-Dist: typing-extensions>=4.0.0
230
234
  Provides-Extra: dev
231
235
  Requires-Dist: aioresponses>=0.7.6; extra == 'dev'
232
- Requires-Dist: black>=23.0.0; extra == 'dev'
233
- Requires-Dist: flake8>=6.0.0; extra == 'dev'
236
+ Requires-Dist: black<25.0.0,>=23.0.0; extra == 'dev'
237
+ Requires-Dist: flake8<6.0.0,>=5.0.0; extra == 'dev'
234
238
  Requires-Dist: isort>=5.0.0; extra == 'dev'
235
- Requires-Dist: mypy>=1.0.0; extra == 'dev'
236
- Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
237
- Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
239
+ Requires-Dist: mypy<1.11.0,>=1.0.0; extra == 'dev'
240
+ Requires-Dist: pytest-asyncio<1.0.0,>=0.21.0; extra == 'dev'
241
+ Requires-Dist: pytest-cov<6.0.0,>=5.0.0; extra == 'dev'
238
242
  Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
239
- Requires-Dist: pytest>=7.0.0; extra == 'dev'
243
+ Requires-Dist: pytest<9.0.0,>=7.0.0; extra == 'dev'
240
244
  Description-Content-Type: text/markdown
241
245
 
242
246
  <div align="center">
@@ -250,7 +254,7 @@ Convert text to lifelike speech using AI-powered voices
250
254
  [![PyPI version](https://img.shields.io/pypi/v/typecast-python.svg?style=flat-square)](https://pypi.org/project/typecast-python/)
251
255
  [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg?style=flat-square)](../docs/coverage-policy.md)
252
256
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg?style=flat-square)](LICENSE)
253
- [![Python](https://img.shields.io/badge/Python-3.9+-3776ab.svg?style=flat-square&logo=python&logoColor=white)](https://www.python.org/)
257
+ [![Python](https://img.shields.io/badge/Python-3.8--3.14-3776ab.svg?style=flat-square&logo=python&logoColor=white)](https://www.python.org/)
254
258
 
255
259
  [Documentation](https://typecast.ai/docs) | [API Reference](https://typecast.ai/docs/api-reference) | [Get API Key](https://typecast.ai/developers/api/api-key)
256
260
 
@@ -9,7 +9,7 @@ Convert text to lifelike speech using AI-powered voices
9
9
  [![PyPI version](https://img.shields.io/pypi/v/typecast-python.svg?style=flat-square)](https://pypi.org/project/typecast-python/)
10
10
  [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg?style=flat-square)](../docs/coverage-policy.md)
11
11
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg?style=flat-square)](LICENSE)
12
- [![Python](https://img.shields.io/badge/Python-3.9+-3776ab.svg?style=flat-square&logo=python&logoColor=white)](https://www.python.org/)
12
+ [![Python](https://img.shields.io/badge/Python-3.8--3.14-3776ab.svg?style=flat-square&logo=python&logoColor=white)](https://www.python.org/)
13
13
 
14
14
  [Documentation](https://typecast.ai/docs) | [API Reference](https://typecast.ai/docs/api-reference) | [Get API Key](https://typecast.ai/developers/api/api-key)
15
15
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "typecast-python"
7
- version = "0.3.7"
7
+ version = "0.3.9"
8
8
  description = "Official Typecast Python SDK - Convert text to lifelike speech using AI-powered voices"
9
9
  authors = [
10
10
  {name = "Neosapience", email = "help@typecast.ai"}
@@ -14,22 +14,26 @@ maintainers = [
14
14
  ]
15
15
  license = {file = "LICENSE"}
16
16
  readme = "README.md"
17
- requires-python = ">=3.11"
17
+ requires-python = ">=3.8,<3.15"
18
18
  keywords = ["typecast", "tts", "text-to-speech", "ai", "voice", "audio", "speech-synthesis", "voice-generation"]
19
19
  classifiers = [
20
20
  "Development Status :: 3 - Alpha",
21
21
  "Intended Audience :: Developers",
22
22
  "License :: OSI Approved :: Apache Software License",
23
23
  "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.8",
25
+ "Programming Language :: Python :: 3.9",
26
+ "Programming Language :: Python :: 3.10",
24
27
  "Programming Language :: Python :: 3.11",
25
28
  "Programming Language :: Python :: 3.12",
26
29
  "Programming Language :: Python :: 3.13",
30
+ "Programming Language :: Python :: 3.14",
27
31
  "Topic :: Software Development :: Libraries :: Python Modules",
28
32
  "Topic :: Multimedia :: Sound/Audio :: Speech",
29
33
  "Operating System :: OS Independent",
30
34
  ]
31
35
  dependencies = [
32
- "aiohttp>=3.14.0",
36
+ "aiohttp>=3.9.0",
33
37
  "requests>=2.28.0",
34
38
  "pydantic>=2.0.0",
35
39
  "typing-extensions>=4.0.0",
@@ -37,14 +41,14 @@ dependencies = [
37
41
 
38
42
  [project.optional-dependencies]
39
43
  dev = [
40
- "pytest>=7.0.0",
41
- "pytest-cov>=7.0.0",
44
+ "pytest>=7.0.0,<9.0.0",
45
+ "pytest-cov>=5.0.0,<6.0.0",
42
46
  "pytest-mock>=3.14.0",
43
- "pytest-asyncio>=0.21.0",
47
+ "pytest-asyncio>=0.21.0,<1.0.0",
44
48
  "aioresponses>=0.7.6",
45
- "black>=23.0.0",
46
- "flake8>=6.0.0",
47
- "mypy>=1.0.0",
49
+ "black>=23.0.0,<25.0.0",
50
+ "flake8>=5.0.0,<6.0.0",
51
+ "mypy>=1.0.0,<1.11.0",
48
52
  "isort>=5.0.0",
49
53
  ]
50
54
 
@@ -86,18 +90,21 @@ multi_line_output = 3
86
90
 
87
91
  [dependency-groups]
88
92
  dev = [
89
- "pytest>=7.0.0",
90
- "pytest-cov>=7.0.0",
93
+ "pytest>=7.0.0,<9.0.0",
94
+ "pytest-cov>=5.0.0,<6.0.0",
91
95
  "pytest-mock>=3.14.0",
92
- "pytest-asyncio>=0.21.0",
96
+ "pytest-asyncio>=0.21.0,<1.0.0",
93
97
  "aioresponses>=0.7.6",
94
- "python-dotenv>=1.1.1",
98
+ "python-dotenv>=1.0.0,<2.0.0",
95
99
  "ruff>=0.14.0",
96
100
  ]
97
101
 
98
102
  [tool.pytest.ini_options]
99
103
  asyncio_mode = "auto"
100
104
  testpaths = ["tests"]
105
+ markers = [
106
+ "e2e: end-to-end tests against the real Typecast API (require credentials)",
107
+ ]
101
108
  filterwarnings = [
102
109
  "ignore::DeprecationWarning",
103
110
  ]
@@ -19,6 +19,7 @@ from .models import (
19
19
  OutputStream,
20
20
  PlanTier,
21
21
  Prompt,
22
+ RecommendedVoice,
22
23
  SubscriptionResponse,
23
24
  TTSRequest,
24
25
  TTSRequestStream,
@@ -48,6 +49,7 @@ __all__ = [
48
49
  "OutputStream",
49
50
  "PlanTier",
50
51
  "Prompt",
52
+ "RecommendedVoice",
51
53
  "SubscriptionResponse",
52
54
  "TTSRequest",
53
55
  "TTSRequestStream",
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  from pathlib import Path
3
5
  from typing import AsyncIterator, BinaryIO, Optional, Union
@@ -29,6 +31,7 @@ from .models import (
29
31
  CustomVoice,
30
32
  LanguageCode,
31
33
  Output,
34
+ RecommendedVoice,
32
35
  SubscriptionResponse,
33
36
  TTSModel,
34
37
  TTSPrompt,
@@ -62,34 +65,48 @@ class AsyncTypecast:
62
65
  ... f.write(response.audio_data)
63
66
  """
64
67
 
65
- def __init__(self, host: Optional[str] = None, api_key: Optional[str] = None):
68
+ def __init__(
69
+ self,
70
+ host: Optional[str] = None,
71
+ api_key: Optional[str] = None,
72
+ session: Optional[aiohttp.ClientSession] = None,
73
+ ):
66
74
  """Initialize the async Typecast client.
67
75
 
68
76
  Args:
69
77
  host: API host URL. Defaults to TYPECAST_API_HOST env var
70
78
  or 'https://api.typecast.ai'.
71
79
  api_key: API key for authentication. Defaults to TYPECAST_API_KEY env var.
80
+ session: Optional externally-managed aiohttp.ClientSession. When provided,
81
+ __aenter__ will not create a new session and __aexit__ will not close it
82
+ (the caller owns its lifecycle). Auth headers (`X-API-KEY`, `User-Agent`)
83
+ are attached per-request via `_request_headers()`.
72
84
 
73
85
  Raises:
74
- ValueError: If no API key is provided and TYPECAST_API_KEY is not set.
86
+ ValueError: If no API key is provided and TYPECAST_API_KEY is not set
87
+ for the default host.
75
88
  """
76
89
  self.host = conf.get_host(host)
77
90
  self.api_key = conf.get_api_key(api_key)
78
91
  if not self.api_key and conf.is_default_host(self.host):
79
92
  raise ValueError("API key is required for the default Typecast API host")
80
- self.session: Optional[aiohttp.ClientSession] = None
93
+ self._owns_session = session is None
94
+ self.session: Optional[aiohttp.ClientSession] = session
81
95
 
82
96
  async def __aenter__(self):
83
- # Auth header at session scope; per-request Content-Type is set by aiohttp
84
- # (json= auto-sets application/json, data=FormData() auto-sets multipart).
85
- headers = {"User-Agent": aiohttp_user_agent(self.host)}
86
- if self.api_key:
87
- headers["X-API-KEY"] = self.api_key
88
- self.session = aiohttp.ClientSession(headers=headers)
97
+ # When an external session is injected, do not create a new one.
98
+ # Per-request auth headers are attached via _request_headers() on each call.
99
+ if self.session is None:
100
+ headers = {"User-Agent": aiohttp_user_agent(self.host)}
101
+ if self.api_key:
102
+ headers["X-API-KEY"] = self.api_key
103
+ self.session = aiohttp.ClientSession(headers=headers)
89
104
  return self
90
105
 
91
106
  async def __aexit__(self, exc_type, exc_val, exc_tb):
92
- if self.session:
107
+ # Only close sessions we created. External sessions are caller-owned;
108
+ # leave the reference intact so the client can be re-entered.
109
+ if self.session and self._owns_session:
93
110
  await self.session.close()
94
111
 
95
112
  def _handle_error(self, status_code: int, response_text: str):
@@ -114,6 +131,20 @@ class AsyncTypecast:
114
131
  status_code=status_code,
115
132
  )
116
133
 
134
+ def _request_headers(self) -> Optional[dict]:
135
+ """Headers to attach to each individual request.
136
+
137
+ For owned sessions, auth is set at session scope, so return None and let
138
+ aiohttp use the session headers. For external sessions, the session has
139
+ no auth headers, so we attach X-API-KEY and User-Agent per-request.
140
+ """
141
+ if self._owns_session:
142
+ return None
143
+ headers = {"User-Agent": aiohttp_user_agent(self.host)}
144
+ if self.api_key:
145
+ headers["X-API-KEY"] = self.api_key
146
+ return headers
147
+
117
148
  async def text_to_speech(self, request: TTSRequest) -> TTSResponse:
118
149
  """Convert text to speech asynchronously.
119
150
 
@@ -135,7 +166,9 @@ class AsyncTypecast:
135
166
  raise TypecastError("Client session not initialized. Use async with.")
136
167
  endpoint = "/v1/text-to-speech"
137
168
  async with self.session.post(
138
- f"{self.host}{endpoint}", json=request.model_dump(exclude_none=True)
169
+ f"{self.host}{endpoint}",
170
+ json=request.model_dump(exclude_none=True),
171
+ headers=self._request_headers(),
139
172
  ) as response:
140
173
  if response.status != 200:
141
174
  error_text = await response.text()
@@ -177,7 +210,8 @@ class AsyncTypecast:
177
210
  seed=seed,
178
211
  )
179
212
  )
180
- await asyncio.to_thread(output_path.write_bytes, response.audio_data)
213
+ loop = asyncio.get_running_loop()
214
+ await loop.run_in_executor(None, output_path.write_bytes, response.audio_data)
181
215
  return response
182
216
 
183
217
  async def text_to_speech_stream(
@@ -218,12 +252,13 @@ class AsyncTypecast:
218
252
  f"{self.host}{endpoint}",
219
253
  json=request.model_dump(exclude_none=True),
220
254
  timeout=stream_timeout,
255
+ headers=self._request_headers(),
221
256
  ) as response:
222
257
  if response.status != 200:
223
258
  error_text = await response.text()
224
259
  self._handle_error(response.status, error_text)
225
260
 
226
- async for chunk in response.content.iter_chunked(chunk_size):
261
+ async for chunk in response.content.iter_chunked(chunk_size): # pragma: no branch
227
262
  yield chunk
228
263
 
229
264
  async def text_to_speech_with_timestamps(
@@ -261,6 +296,7 @@ class AsyncTypecast:
261
296
  f"{self.host}{endpoint}",
262
297
  json=request.model_dump(exclude_none=True),
263
298
  params=params,
299
+ headers=self._request_headers(),
264
300
  ) as response:
265
301
  if response.status != 200:
266
302
  text = await response.text()
@@ -310,6 +346,7 @@ class AsyncTypecast:
310
346
  f"{self.host}/v1/voices/clone",
311
347
  data=form,
312
348
  timeout=timeout,
349
+ headers=self._request_headers(),
313
350
  ) as response:
314
351
  if response.status != 200:
315
352
  text = await response.text()
@@ -334,6 +371,7 @@ class AsyncTypecast:
334
371
  async with self.session.delete(
335
372
  f"{self.host}/v1/voices/{quote(voice_id, safe='')}",
336
373
  timeout=timeout,
374
+ headers=self._request_headers(),
337
375
  ) as response:
338
376
  if response.status not in (200, 204):
339
377
  text = await response.text()
@@ -360,7 +398,9 @@ class AsyncTypecast:
360
398
  params["model"] = model
361
399
 
362
400
  async with self.session.get(
363
- f"{self.host}{endpoint}", params=params
401
+ f"{self.host}{endpoint}",
402
+ params=params,
403
+ headers=self._request_headers(),
364
404
  ) as response:
365
405
  if response.status != 200:
366
406
  error_text = await response.text()
@@ -388,7 +428,9 @@ class AsyncTypecast:
388
428
  raise TypecastError("Client session not initialized. Use async with.")
389
429
  endpoint = f"/v1/voices/{voice_id}"
390
430
 
391
- async with self.session.get(f"{self.host}{endpoint}") as response:
431
+ async with self.session.get(
432
+ f"{self.host}{endpoint}", headers=self._request_headers()
433
+ ) as response:
392
434
  if response.status != 200:
393
435
  error_text = await response.text()
394
436
  self._handle_error(response.status, error_text)
@@ -425,7 +467,9 @@ class AsyncTypecast:
425
467
  params[key] = getattr(value, "value", value)
426
468
 
427
469
  async with self.session.get(
428
- f"{self.host}{endpoint}", params=params
470
+ f"{self.host}{endpoint}",
471
+ params=params,
472
+ headers=self._request_headers(),
429
473
  ) as response:
430
474
  if response.status != 200:
431
475
  error_text = await response.text()
@@ -452,7 +496,9 @@ class AsyncTypecast:
452
496
  if not self.session:
453
497
  raise TypecastError("Client session not initialized. Use async with.")
454
498
  endpoint = "/v1/users/me/subscription"
455
- async with self.session.get(f"{self.host}{endpoint}") as response:
499
+ async with self.session.get(
500
+ f"{self.host}{endpoint}", headers=self._request_headers()
501
+ ) as response:
456
502
  if response.status != 200:
457
503
  error_text = await response.text()
458
504
  self._handle_error(response.status, error_text)
@@ -475,10 +521,37 @@ class AsyncTypecast:
475
521
  raise TypecastError("Client session not initialized. Use async with.")
476
522
  endpoint = f"/v2/voices/{voice_id}"
477
523
 
478
- async with self.session.get(f"{self.host}{endpoint}") as response:
524
+ async with self.session.get(
525
+ f"{self.host}{endpoint}", headers=self._request_headers()
526
+ ) as response:
479
527
  if response.status != 200:
480
528
  error_text = await response.text()
481
529
  self._handle_error(response.status, error_text)
482
530
 
483
531
  data = await response.json()
484
532
  return VoiceV2Response.model_validate(data)
533
+
534
+ async def recommend_voices(
535
+ self, query: str, count: int = 5
536
+ ) -> list[RecommendedVoice]:
537
+ """Recommend voices from a text description.
538
+
539
+ Recommendation results only include ``voice_id``, ``voice_name``, and
540
+ ``score``. Use ``voice_v2`` or ``voices_v2`` to fetch detailed metadata
541
+ for returned voice IDs.
542
+ """
543
+ if count < 1 or count > 10:
544
+ raise ValueError("count must be between 1 and 10")
545
+ if not self.session:
546
+ raise TypecastError("Client session not initialized. Use async with.")
547
+
548
+ async with self.session.get(
549
+ f"{self.host}/v1/voices/recommendations",
550
+ params={"query": query, "count": count},
551
+ ) as response:
552
+ if response.status != 200:
553
+ error_text = await response.text()
554
+ self._handle_error(response.status, error_text)
555
+
556
+ data = await response.json()
557
+ return [RecommendedVoice.model_validate(item) for item in data]
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from pathlib import Path
2
4
  from typing import BinaryIO, Iterator, Optional, Union
3
5
  from urllib.parse import quote
@@ -26,6 +28,7 @@ from .models import (
26
28
  CustomVoice,
27
29
  LanguageCode,
28
30
  Output,
31
+ RecommendedVoice,
29
32
  SubscriptionResponse,
30
33
  TTSModel,
31
34
  TTSPrompt,
@@ -96,13 +99,22 @@ class Typecast:
96
99
  ... f.write(response.audio_data)
97
100
  """
98
101
 
99
- def __init__(self, host: Optional[str] = None, api_key: Optional[str] = None):
102
+ def __init__(
103
+ self,
104
+ host: Optional[str] = None,
105
+ api_key: Optional[str] = None,
106
+ session: Optional[requests.Session] = None,
107
+ ):
100
108
  """Initialize the Typecast client.
101
109
 
102
110
  Args:
103
111
  host: API host URL. Defaults to TYPECAST_API_HOST env var
104
112
  or 'https://api.typecast.ai'.
105
113
  api_key: API key for authentication. Defaults to TYPECAST_API_KEY env var.
114
+ session: Optional externally-managed requests.Session. When provided,
115
+ the client will not create a new session nor close it; auth headers
116
+ (`X-API-KEY`, `User-Agent`) are attached per-request via
117
+ `_request_headers()`.
106
118
 
107
119
  Raises:
108
120
  ValueError: If no API key is provided and TYPECAST_API_KEY is not set.
@@ -111,14 +123,18 @@ class Typecast:
111
123
  self.api_key = conf.get_api_key(api_key)
112
124
  if not self.api_key and conf.is_default_host(self.host):
113
125
  raise ValueError("API key is required for the default Typecast API host")
114
- self.session = requests.Session()
115
- headers = {
116
- "Content-Type": "application/json",
117
- "User-Agent": requests_user_agent(self.host),
118
- }
119
- if self.api_key:
120
- headers["X-API-KEY"] = self.api_key
121
- self.session.headers.update(headers)
126
+ self._owns_session = session is None
127
+ if session is not None:
128
+ self.session = session
129
+ else:
130
+ self.session = requests.Session()
131
+ headers = {
132
+ "Content-Type": "application/json",
133
+ "User-Agent": requests_user_agent(self.host),
134
+ }
135
+ if self.api_key:
136
+ headers["X-API-KEY"] = self.api_key
137
+ self.session.headers.update(headers)
122
138
 
123
139
  def _handle_error(self, status_code: int, response_text: str):
124
140
  """Handle HTTP error responses with specific exception types."""
@@ -142,6 +158,20 @@ class Typecast:
142
158
  status_code=status_code,
143
159
  )
144
160
 
161
+ def _request_headers(self) -> Optional[dict]:
162
+ """Headers to attach to each individual request.
163
+
164
+ For owned sessions, auth is set at session scope, so return None and let
165
+ requests use the session headers. For external sessions, the session has
166
+ no auth headers, so we attach X-API-KEY and User-Agent per-request.
167
+ """
168
+ if self._owns_session:
169
+ return None
170
+ headers = {"User-Agent": requests_user_agent(self.host)}
171
+ if self.api_key:
172
+ headers["X-API-KEY"] = self.api_key
173
+ return headers
174
+
145
175
  def text_to_speech(self, request: TTSRequest) -> TTSResponse:
146
176
  """Convert text to speech.
147
177
 
@@ -160,7 +190,9 @@ class Typecast:
160
190
  """
161
191
  endpoint = "/v1/text-to-speech"
162
192
  response = self.session.post(
163
- f"{self.host}{endpoint}", json=request.model_dump(exclude_none=True)
193
+ f"{self.host}{endpoint}",
194
+ json=request.model_dump(exclude_none=True),
195
+ headers=self._request_headers(),
164
196
  )
165
197
  if response.status_code != 200:
166
198
  self._handle_error(response.status_code, response.text)
@@ -252,6 +284,7 @@ class Typecast:
252
284
  json=request.model_dump(exclude_none=True),
253
285
  stream=True,
254
286
  timeout=(10, 300),
287
+ headers=self._request_headers(),
255
288
  )
256
289
  if response.status_code != 200:
257
290
  error_text = response.text
@@ -299,6 +332,7 @@ class Typecast:
299
332
  json=request.model_dump(exclude_none=True),
300
333
  params=params,
301
334
  timeout=(10, 300),
335
+ headers=self._request_headers(),
302
336
  )
303
337
  if response.status_code != 200:
304
338
  self._handle_error(response.status_code, response.text)
@@ -335,12 +369,19 @@ class Typecast:
335
369
  }
336
370
  data = {"name": name, "model": model_str}
337
371
  # Remove the session-level Content-Type so requests can set the
338
- # correct multipart/form-data boundary for this request.
372
+ # correct multipart/form-data boundary for this request. For external
373
+ # sessions, also attach the per-request auth headers (X-API-KEY,
374
+ # User-Agent); _request_headers() returns None for owned sessions, so
375
+ # owned-session behavior is unchanged.
376
+ headers = {"Content-Type": None}
377
+ per_request = self._request_headers()
378
+ if per_request:
379
+ headers.update(per_request)
339
380
  response = self.session.post(
340
381
  f"{self.host}/v1/voices/clone",
341
382
  files=files,
342
383
  data=data,
343
- headers={"Content-Type": None},
384
+ headers=headers,
344
385
  timeout=(10, 300),
345
386
  )
346
387
  if response.status_code != 200:
@@ -361,6 +402,7 @@ class Typecast:
361
402
  response = self.session.delete(
362
403
  f"{self.host}/v1/voices/{quote(voice_id, safe='')}",
363
404
  timeout=(10, 60),
405
+ headers=self._request_headers(),
364
406
  )
365
407
  if response.status_code not in (200, 204):
366
408
  self._handle_error(response.status_code, response.text)
@@ -383,7 +425,9 @@ class Typecast:
383
425
  if model:
384
426
  params["model"] = model
385
427
 
386
- response = self.session.get(f"{self.host}{endpoint}", params=params)
428
+ response = self.session.get(
429
+ f"{self.host}{endpoint}", params=params, headers=self._request_headers()
430
+ )
387
431
 
388
432
  if response.status_code != 200:
389
433
  self._handle_error(response.status_code, response.text)
@@ -406,7 +450,9 @@ class Typecast:
406
450
  This method is deprecated. Use voices_v2() for enhanced metadata.
407
451
  """
408
452
  endpoint = f"/v1/voices/{voice_id}"
409
- response = self.session.get(f"{self.host}{endpoint}")
453
+ response = self.session.get(
454
+ f"{self.host}{endpoint}", headers=self._request_headers()
455
+ )
410
456
 
411
457
  if response.status_code != 200:
412
458
  self._handle_error(response.status_code, response.text)
@@ -440,7 +486,9 @@ class Typecast:
440
486
  for key, value in filter_dict.items():
441
487
  params[key] = getattr(value, "value", value)
442
488
 
443
- response = self.session.get(f"{self.host}{endpoint}", params=params)
489
+ response = self.session.get(
490
+ f"{self.host}{endpoint}", params=params, headers=self._request_headers()
491
+ )
444
492
 
445
493
  if response.status_code != 200:
446
494
  self._handle_error(response.status_code, response.text)
@@ -462,7 +510,9 @@ class Typecast:
462
510
  InternalServerError: On server-side failures.
463
511
  """
464
512
  endpoint = "/v1/users/me/subscription"
465
- response = self.session.get(f"{self.host}{endpoint}")
513
+ response = self.session.get(
514
+ f"{self.host}{endpoint}", headers=self._request_headers()
515
+ )
466
516
  if response.status_code != 200:
467
517
  self._handle_error(response.status_code, response.text)
468
518
  return SubscriptionResponse.model_validate(response.json())
@@ -480,9 +530,33 @@ class Typecast:
480
530
  NotFoundError: If the voice ID does not exist.
481
531
  """
482
532
  endpoint = f"/v2/voices/{voice_id}"
483
- response = self.session.get(f"{self.host}{endpoint}")
533
+ response = self.session.get(
534
+ f"{self.host}{endpoint}", headers=self._request_headers()
535
+ )
484
536
 
485
537
  if response.status_code != 200:
486
538
  self._handle_error(response.status_code, response.text)
487
539
 
488
540
  return VoiceV2Response.model_validate(response.json())
541
+
542
+ def recommend_voices(self, query: str, count: int = 5) -> list[RecommendedVoice]:
543
+ """Recommend voices from a text description.
544
+
545
+ Recommendation results only include ``voice_id``, ``voice_name``, and
546
+ ``score``. Use ``voice_v2`` or ``voices_v2`` to fetch detailed metadata
547
+ for returned voice IDs.
548
+
549
+ Args:
550
+ query: Text description of the desired voice.
551
+ count: Number of recommendations to return, from 1 to 10. Defaults to 5.
552
+ """
553
+ if count < 1 or count > 10:
554
+ raise ValueError("count must be between 1 and 10")
555
+
556
+ response = self.session.get(
557
+ f"{self.host}/v1/voices/recommendations",
558
+ params={"query": query, "count": count},
559
+ )
560
+ if response.status_code != 200:
561
+ self._handle_error(response.status_code, response.text)
562
+ return [RecommendedVoice.model_validate(item) for item in response.json()]
@@ -23,6 +23,7 @@ from .voices import (
23
23
  CustomVoice,
24
24
  GenderEnum,
25
25
  ModelInfo,
26
+ RecommendedVoice,
26
27
  UseCaseEnum,
27
28
  VoicesResponse,
28
29
  VoicesV2Filter,
@@ -46,6 +47,7 @@ __all__ = [
46
47
  "PlanTier",
47
48
  "Prompt",
48
49
  "PresetPrompt",
50
+ "RecommendedVoice",
49
51
  "SmartPrompt",
50
52
  "SubscriptionResponse",
51
53
  "TTSModel",
@@ -1,5 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  from enum import Enum
2
- from typing import Literal, Optional, Union
4
+ from typing import List, Literal, Optional, Union
3
5
 
4
6
  from pydantic import BaseModel, ConfigDict, Field, model_validator
5
7
 
@@ -330,7 +332,7 @@ def _group_into_cues(
330
332
  word_mode=True: parts are joined with a single space.
331
333
  word_mode=False: parts are concatenated directly.
332
334
 
333
- Returns list[(text, start, end)] tuples.
335
+ Returns List[(text, start, end)] tuples.
334
336
  """
335
337
  cues = []
336
338
  cur_text_parts = []
@@ -404,11 +406,11 @@ class TTSWithTimestampsResponse(BaseModel):
404
406
  audio: str = Field(description="Base64-encoded audio bytes.")
405
407
  audio_format: Literal["wav", "mp3"] = Field(description="Audio encoding format.")
406
408
  audio_duration: float = Field(description="Length of audio in seconds.")
407
- words: Optional[list[AlignmentSegmentWord]] = Field(
409
+ words: Optional[List[AlignmentSegmentWord]] = Field(
408
410
  default=None,
409
411
  description="Word-level timestamps; null when granularity=char.",
410
412
  )
411
- characters: Optional[list[AlignmentSegmentCharacter]] = Field(
413
+ characters: Optional[List[AlignmentSegmentCharacter]] = Field(
412
414
  default=None,
413
415
  description="Character-level timestamps; null when granularity=word.",
414
416
  )
@@ -1,5 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  from enum import Enum
2
- from typing import Optional
4
+ from typing import List, Optional
3
5
 
4
6
  from pydantic import BaseModel, Field
5
7
 
@@ -12,7 +14,7 @@ class VoicesResponse(BaseModel):
12
14
  voice_id: str
13
15
  voice_name: str
14
16
  model: str
15
- emotions: list[str]
17
+ emotions: List[str]
16
18
 
17
19
 
18
20
  class GenderEnum(str, Enum):
@@ -54,7 +56,7 @@ class ModelInfo(BaseModel):
54
56
  """Model information with supported emotions"""
55
57
 
56
58
  version: TTSModel
57
- emotions: list[str]
59
+ emotions: List[str]
58
60
 
59
61
 
60
62
  class VoiceV2Response(BaseModel):
@@ -62,10 +64,23 @@ class VoiceV2Response(BaseModel):
62
64
 
63
65
  voice_id: str
64
66
  voice_name: str
65
- models: list[ModelInfo]
67
+ models: List[ModelInfo]
66
68
  gender: Optional[GenderEnum] = None
67
69
  age: Optional[AgeEnum] = None
68
- use_cases: Optional[list[str]] = None
70
+ use_cases: Optional[List[str]] = None
71
+
72
+
73
+ class RecommendedVoice(BaseModel):
74
+ """Recommended voice result.
75
+
76
+ Recommendation results only include the matched voice ID, voice name, and
77
+ similarity score. Use ``voice_v2`` or ``voices_v2`` to fetch detailed voice
78
+ metadata for a returned ``voice_id``.
79
+ """
80
+
81
+ voice_id: str
82
+ voice_name: str
83
+ score: float
69
84
 
70
85
 
71
86
  class VoicesV2Filter(BaseModel):
File without changes