diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 97c7395..6e15f3b 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -11,14 +11,14 @@ Link to the issue(s) this PR addresses (e.g., `#123`).
- Mention any updates to documentation or tests.
## Testing
-- Describe how you tested your changes (e.g., ran `python -m cookbook.main`).
+- Describe how you tested your changes (e.g., ran `python -m cookbook`).
- Confirm that all tests pass and no linting errors exist (`./fix-code.sh`).
## Checklist
- [ ] Code follows style guidelines (`ruff`, `isort`, and optionally `black`).
- [ ] Tests added or updated in the `cookbook` module.
- [ ] Documentation updated (e.g., `README.md` or `docs/`).
-- [ ] All tests pass (`python -m cookbook.main`).
+- [ ] All tests pass (`python -m cookbook`).
- [ ] No linting errors (`ruff check .` and `isort . --check-only`).
## Additional Notes
diff --git a/.gitignore b/.gitignore
index ab61f2f..c2906d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,9 @@ cover/
*.mo
*.pot
+# Cookbook test-suite artifacts
+cookbook_output/
+
# Django stuff:
*.log
local_settings.py
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 527ee21..f5a5ec6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -64,7 +64,7 @@ To contribute, you'll need:
5. **Run the Cookbook**:
Verify your setup by running the test suite:
```bash
- python -m cookbook.main
+ python -m cookbook
```
This executes the Djelia SDK Cookbook, testing translation, transcription, and TTS features.
@@ -120,7 +120,7 @@ Ready to contribute code or documentation? Here's how:
5. **Test Your Changes**:
Run the cookbook to verify your changes:
```bash
- python -m cookbook.main
+ python -m cookbook
```
Ensure all tests pass and no new errors are introduced.
@@ -161,7 +161,7 @@ All contributions should include tests to ensure reliability:
- Add new tests for new features or bug fixes in the `cookbook` directory.
- Verify tests pass with:
```bash
- python -m cookbook.main
+ python -m cookbook
```
- If adding new dependencies, update `setup.py` (under `extras_require["test"]`) and `dev-requirements.txt`.
diff --git a/README.md b/README.md
index 45bfb9e..094ebb4 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,7 @@ First, let's see what languages we can work with.
Simple and straightforward get your supported languages and print them:
```python
-supported_languages = djelia_client.translation.get_supported_languages()
+supported_languages = djelia_client.translations.list_languages()
for lang in supported_languages:
print(f"{lang.name}: {lang.code}")
```
@@ -141,7 +141,7 @@ import asyncio
async def get_languages_test():
async with djelia_async_client as client:
- supported_languages = await client.translation.get_supported_languages()
+ supported_languages = await client.translations.list_languages()
for lang in supported_languages:
print(f"{lang.name}: {lang.code}")
@@ -152,14 +152,12 @@ asyncio.run(get_languages_test())
Let's translate some text between beautiful 🇲🇱 languages and others. Feel free to try different language combinations!
+With the OpenAI-style API you pass the fields straight to `.create()` — no need to build a request object:
+
```python
-from djelia.models import TranslationRequest, Language, Versions
+from djelia.models import Language, Versions
-request = TranslationRequest(
- text="Hello, how are you today?",
- source=Language.ENGLISH,
- target=Language.BAMBARA
-)
+text = "Hello, how are you today?"
```
##
Synchronous
@@ -170,8 +168,13 @@ Create that translation and see what you get:
from djelia.models import TranslationResponse
try:
- response_sync: TranslationResponse = djelia_client.translation.translate(request=request, version=Versions.v1)
- print(f"Original: {request.text}")
+ response_sync: TranslationResponse = djelia_client.translations.create(
+ text=text,
+ source=Language.ENGLISH,
+ target=Language.BAMBARA,
+ model=Versions.v1,
+ )
+ print(f"Original: {text}")
print(f"Translation: {response_sync.text}")
except Exception as e:
print(f"Translation error: {e}")
@@ -185,8 +188,13 @@ Async translation because why wait around? Let's do it bro !
async def translate_async():
async with djelia_async_client as client:
try:
- response_async: TranslationResponse = await client.translation.translate(request=request, version=Versions.v1)
- print(f"Original: {request.text}")
+ response_async: TranslationResponse = await client.translations.create(
+ text=text,
+ source=Language.ENGLISH,
+ target=Language.BAMBARA,
+ model=Versions.v1,
+ )
+ print(f"Original: {text}")
print(f"Translation: {response_async.text}")
return response_async
except Exception as e:
@@ -209,9 +217,9 @@ Let's transcribe some audio files. Make sure you have an audio file ready check
from djelia.models import Versions
try:
- transcription = djelia_client.transcription.transcribe(
- audio_file=audio_file_path,
- version=Versions.v2
+ transcription = djelia_client.audio.transcriptions.create(
+ file=audio_file_path,
+ model=Versions.v2
)
print(f"Transcribed {len(transcription)} segments:")
for segment in transcription:
@@ -228,9 +236,9 @@ For the async enthusiasts: (like me, I ❤️ it)
async def transcribe_async():
async with djelia_async_client as client:
try:
- transcription = await client.transcription.transcribe(
- audio_file=audio_file_path,
- version=Versions.v2
+ transcription = await client.audio.transcriptions.create(
+ file=audio_file_path,
+ model=Versions.v2
)
print(f"Transcribed {len(transcription)} segments:")
for segment in transcription:
@@ -252,10 +260,10 @@ print("Streaming transcription (showing first 3 segments)...")
segment_count = 0
try:
- for segment in djelia_client.transcription.transcribe(
- audio_file=audio_file_path,
+ for segment in djelia_client.audio.transcriptions.create(
+ file=audio_file_path,
stream=True,
- version=Versions.v2
+ model=Versions.v2
):
segment_count += 1
print(f"Segment {segment_count}: [{segment.start:.2f}s]: {segment.text}")
@@ -275,10 +283,10 @@ Async streaming because realtime is awesome: (bro, I'm telling you, one second
async def stream_transcribe_async():
async with djelia_async_client as client:
try:
- stream = await client.transcription.transcribe(
- audio_file=audio_file_path,
+ stream = await client.audio.transcriptions.create(
+ file=audio_file_path,
stream=True,
- version=Versions.v2
+ model=Versions.v2
)
segment_count = 0
async for segment in stream:
@@ -302,10 +310,10 @@ Want to transcribe and translate to French in one go? We've got you covered!
```python
try:
- french_transcription = djelia_client.transcription.transcribe(
- audio_file=audio_file_path,
+ french_transcription = djelia_client.audio.transcriptions.create(
+ file=audio_file_path,
translate_to_french=True,
- version=Versions.v2
+ model=Versions.v2
)
print(f"French translation: {french_transcription.text}")
except Exception as e:
@@ -318,10 +326,10 @@ except Exception as e:
async def transcribe_french_async():
async with djelia_async_client as client:
try:
- french_transcription = await client.transcription.transcribe(
- audio_file=audio_file_path,
+ french_transcription = await client.audio.transcriptions.create(
+ file=audio_file_path,
translate_to_french=True,
- version=Versions.v2
+ model=Versions.v2
)
print(f"French translation: {french_transcription.text}")
except Exception as e:
@@ -339,12 +347,7 @@ Let's make some beautiful voices! Choose between numbered speakers or describe e
Classic approach with speaker IDs (0-4). Simple and effective!
```python
-from djelia.models import TTSRequest
-
-tts_request_v1 = TTSRequest(
- text="Aw ni ce, i ka kɛnɛ wa?", # "Hello, how are you?" in Bambara
- speaker=1 # Choose from 0, 1, 2, 3, or 4
-)
+bambara_text = "Aw ni ce, i ka kɛnɛ wa?" # "Hello, how are you?" in Bambara
```
## Synchronous
@@ -353,10 +356,11 @@ Generate that audio and save it:
```python
try:
- audio_file_v1 = djelia_client.tts.text_to_speech(
- request=tts_request_v1,
+ audio_file_v1 = djelia_client.audio.speech.create(
+ input=bambara_text,
+ voice=1, # Choose from 0, 1, 2, 3, or 4
output_file="hello_v1.wav",
- version=Versions.v1
+ model=Versions.v1
)
print(f"Audio saved to: {audio_file_v1}")
except Exception as e:
@@ -371,10 +375,11 @@ Async audio generation:
async def generate_audio_v1_async():
async with djelia_async_client as client:
try:
- audio_file_v1 = await client.tts.text_to_speech(
- request=tts_request_v1,
+ audio_file_v1 = await client.audio.speech.create(
+ input=bambara_text,
+ voice=1,
output_file="hello_v1_async.wav",
- version=Versions.v1
+ model=Versions.v1
)
print(f"Audio saved to: {audio_file_v1}")
except Exception as e:
@@ -387,14 +392,10 @@ asyncio.run(generate_audio_v1_async())
This is where it gets fun! Describe exactly how you want the voice to sound, but make sure to include one of the supported speakers: Moussa, Sekou, or Seydou.
-```python
-from djelia.models import TTSRequestV2
+For v2 you pass a natural-language `description` (which must name a supported speaker) instead of a numeric `voice`:
-tts_request_v2 = TTSRequestV2(
- text="Aw ni ce, i ka kɛnɛ wa?",
- description="Seydou speaks with a warm, welcoming tone", # Must include Moussa, Sekou, or Seydou
- chunk_size=1.0 # Control speech pacing (0.1 - 2.0)
-)
+```python
+v2_description = "Seydou speaks with a warm, welcoming tone" # Must include Moussa, Sekou, or Seydou
```
> **Note:** The description field must include one of the supported speakers. For example, "Moussa speaks with a warm tone" is valid, but "Natural tone" will raise an error.
@@ -405,10 +406,12 @@ Create natural sounding speech:
```python
try:
- audio_file_v2 = djelia_client.tts.text_to_speech(
- request=tts_request_v2,
+ audio_file_v2 = djelia_client.audio.speech.create(
+ input=bambara_text,
+ description=v2_description,
+ chunk_size=1.0, # Control speech pacing (0.1 - 2.0)
output_file="hello_v2.wav",
- version=Versions.v2
+ model=Versions.v2
)
print(f"Natural audio saved to: {audio_file_v2}")
except Exception as e:
@@ -423,10 +426,12 @@ Async natural speech generation:
async def generate_natural_audio_async():
async with djelia_async_client as client:
try:
- audio_file_v2 = await client.tts.text_to_speech(
- request=tts_request_v2,
+ audio_file_v2 = await client.audio.speech.create(
+ input=bambara_text,
+ description=v2_description,
+ chunk_size=1.0,
output_file="hello_v2_async.wav",
- version=Versions.v2
+ model=Versions.v2
)
print(f"Natural audio saved to: {audio_file_v2}")
except Exception as e:
@@ -440,11 +445,8 @@ asyncio.run(generate_natural_audio_async())
Realtime audio generation! Get chunks as they're created (v2 only).
```python
-streaming_tts_request = TTSRequestV2(
- text="An filɛ ni ye yɔrɔ minna ni an ye an sigi ka a layɛ yala an bɛ ka baara min kɛ ɛsike a kɛlen don ka Ɲɛ wa, ...............", # a very long text
- description="Seydou speaks clearly and naturally",
- chunk_size=1.0
-)
+long_text = "An filɛ ni ye yɔrɔ minna ni an ye an sigi ka a layɛ yala an bɛ ka baara min kɛ ɛsike a kɛlen don ka Ɲɛ wa, ..............." # a very long text
+streaming_description = "Seydou speaks clearly and naturally"
```
> **Note:** By default, the SDK may process multiple chunks (e.g., up to 5 in some configurations). This example limits to 5 chunks for consistency, but you can adjust the limit based on your application needs.
@@ -460,11 +462,13 @@ total_bytes = 0
max_chunks = 5
try:
- for chunk in djelia_client.tts.text_to_speech(
- request=streaming_tts_request,
+ for chunk in djelia_client.audio.speech.create(
+ input=long_text,
+ description=streaming_description,
+ chunk_size=1.0,
output_file="streamed_audio.wav",
stream=True,
- version=Versions.v2
+ model=Versions.v2
):
chunk_count += 1
total_bytes += len(chunk)
@@ -485,11 +489,13 @@ Async streaming TTS because realtime is the future (oops, actually it's today
async def stream_tts_async():
async with djelia_async_client as client:
try:
- stream = await client.tts.text_to_speech(
- request=streaming_tts_request,
+ stream = await client.audio.speech.create(
+ input=long_text,
+ description=streaming_description,
+ chunk_size=1.0,
output_file="streamed_audio_async.wav",
stream=True,
- version=Versions.v2
+ model=Versions.v2
)
chunk_count = 0
total_bytes = 0
@@ -521,9 +527,9 @@ print(f"Available versions: {[str(v) for v in Versions.all_versions()]}")
# Use specific version
try:
- transcription = djelia_client.transcription.transcribe(
- audio_file=audio_file_path,
- version=Versions.v2
+ transcription = djelia_client.audio.transcriptions.create(
+ file=audio_file_path,
+ model=Versions.v2
)
print(f"Transcribed {len(transcription)} segments")
except Exception as e:
@@ -536,24 +542,23 @@ Run multiple API operations concurrently using `asyncio.gather` with the async c
```python
import asyncio
-from djelia.models import TranslationRequest, Language, TTSRequestV2, Versions
+from djelia.models import Language, Versions
async def parallel_operations():
async with DjeliaAsync(api_key=api_key) as client:
try:
- translation_request = TranslationRequest(
- text="Hello", source=Language.ENGLISH, target=Language.BAMBARA
- )
- tts_request = TTSRequestV2(
- text="Aw ni ce, i ka kɛnɛ wa?",
- description="Moussa speaks with a clear tone",
- chunk_size=1.0
- )
-
results = await asyncio.gather(
- client.translation.translate(translation_request, version=Versions.v1),
- client.transcription.transcribe(audio_file_path, version=Versions.v2),
- client.tts.text_to_speech(tts_request, output_file="parallel_tts.wav", version=Versions.v2),
+ client.translations.create(
+ text="Hello", source=Language.ENGLISH, target=Language.BAMBARA, model=Versions.v1
+ ),
+ client.audio.transcriptions.create(file=audio_file_path, model=Versions.v2),
+ client.audio.speech.create(
+ input="Aw ni ce, i ka kɛnɛ wa?",
+ description="Moussa speaks with a clear tone",
+ chunk_size=1.0,
+ output_file="parallel_tts.wav",
+ model=Versions.v2,
+ ),
return_exceptions=True
)
@@ -576,7 +581,12 @@ The Djelia SDK provides specific exception classes to handle errors gracefully.
from djelia.utils.exceptions import AuthenticationError, APIError, ValidationError, LanguageError, SpeakerError
try:
- response = djelia_client.translation.translate(request=request, version=Versions.v1)
+ response = djelia_client.translations.create(
+ text="Hello, how are you today?",
+ source=Language.ENGLISH,
+ target=Language.BAMBARA,
+ model=Versions.v1,
+ )
print(f"Translation: {response.text}")
except AuthenticationError as e:
print(f"Authentication error (check API key): {e}")
@@ -602,25 +612,35 @@ Check logs for detailed errors, and ensure your `.env` file includes a valid `DJ
## Explore the Djelia SDK Cookbook
-Want to take your Djelia SDK skills to the next level? Check out the **Djelia SDK Cookbook** for a comprehensive example that puts it all together! The cookbook demonstrates:
+Want to take your Djelia SDK skills to the next level? The **Djelia SDK Cookbook** is an interactive, `rich` + `typer` powered test suite that exercises the whole OpenAI-style API. It demonstrates:
-- **Full Test Suite**: Run synchronous and asynchronous tests for translation, transcription, and TTS, with detailed summaries.
-- **Error Handling**: Robust try-except blocks and logging to catch and debug issues.
+- **Interactive Test Suite**: Pick which groups to run (translation, transcription, TTS, streaming, parallel) and whether to run the sync tests, the async tests, or both.
+- **Live Metrics**: Each API call is timed and printed the moment it completes, followed by a per-test operations table (input → response, kind, latency) and a final pass/fail summary.
- **Configuration Management**: Load API keys and audio paths from a `.env` file with validation.
- **Advanced Features**: Parallel API operations, version management, and streaming capabilities.
-- **Modular Design**: Organized code structure for easy customization.
+- **Tidy Output**: Generated audio is written under `cookbook_output/` and cleaned up automatically (pass `--keep-audio` to keep it).
To run the cookbook, clone the repository, install dependencies, and execute:
```bash
git clone https://github.com/djelia-org/djelia-python-sdk.git
-pip install git+https://github.com/djelia-org/djelia-python-sdk.git python-dotenv
-
cd djelia-python-sdk
-python -m cookbook.main
+pip install -e ".[cookbook]" # installs the SDK plus typer, rich, python-dotenv
+
+# Run everything (sync + async):
+python -m cookbook
+
+# List the available test groups:
+python -m cookbook --list
+
+# Run only a couple of groups, sync-only:
+python -m cookbook -g translation -g tts --mode sync
+
+# Pick groups and mode from an interactive menu:
+python -m cookbook --interactive
```
-Make sure your `.env` file includes `DJELIA_API_KEY` and `TEST_AUDIO_FILE`. The cookbook is perfect for developers who want a ready-to-use template for building real-world applications with the Djelia SDK.
+Make sure your `.env` file includes `DJELIA_API_KEY` (and optionally `TEST_AUDIO_FILE`). The cookbook is perfect for developers who want a ready-to-use template for building real-world applications with the Djelia SDK.
## Wrapping Up
diff --git a/cookbook/__main__.py b/cookbook/__main__.py
new file mode 100644
index 0000000..f611323
--- /dev/null
+++ b/cookbook/__main__.py
@@ -0,0 +1,186 @@
+from __future__ import annotations
+
+import enum
+import os
+import sys
+from typing import List, Optional, Tuple
+
+import typer
+from rich.prompt import Confirm, Prompt
+from rich.table import Table
+
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from .config import Config
+from .cookbook import GROUP_ORDER, GROUPS, DjeliaTestSuite
+from .utils import Reporter, banner, console, render_summary
+
+app = typer.Typer(
+ add_completion=False,
+ help="Interactive test suite for the Djelia Python SDK (OpenAI-style API).",
+)
+
+
+class Mode(str, enum.Enum):
+ sync = "sync"
+ async_ = "async"
+ both = "both"
+
+
+def _groups_table() -> Table:
+ table = Table(
+ title="Available test groups", header_style="bold", border_style="dim"
+ )
+ table.add_column("#", justify="right", style="cyan")
+ table.add_column("Group")
+ table.add_column("Label")
+ table.add_column("Sync", justify="center")
+ table.add_column("Async", justify="center")
+ for i, key in enumerate(GROUP_ORDER, 1):
+ spec = GROUPS[key]
+ table.add_row(
+ str(i),
+ key,
+ str(spec["label"]),
+ "✓" if spec["sync"] else "—",
+ "✓" if spec["async"] else "—",
+ )
+ return table
+
+
+def _select_interactively() -> Tuple[List[str], str]:
+ console.print(_groups_table())
+ console.print(
+ "[dim]Enter group numbers separated by commas (e.g. 1,3,4), "
+ "or press Enter for all.[/dim]"
+ )
+ raw = Prompt.ask("Groups", default="all")
+
+ if raw.strip().lower() in ("", "all"):
+ groups = list(GROUP_ORDER)
+ else:
+ groups = []
+ for token in raw.split(","):
+ token = token.strip()
+ if token.isdigit() and 1 <= int(token) <= len(GROUP_ORDER):
+ groups.append(GROUP_ORDER[int(token) - 1])
+ elif token in GROUPS:
+ groups.append(token)
+ else:
+ console.print(f"[yellow]Ignoring unknown group: {token}[/]")
+
+ mode = Prompt.ask("Mode", choices=["sync", "async", "both"], default="both")
+ return groups, mode
+
+
+@app.command()
+def run(
+ group: List[str] = typer.Option(
+ [],
+ "--group",
+ "-g",
+ help=f"Test group to run (repeatable). One of: {', '.join(GROUP_ORDER)}.",
+ ),
+ mode: Mode = typer.Option(
+ Mode.both, "--mode", "-m", help="Run sync tests, async tests, or both."
+ ),
+ interactive: bool = typer.Option(
+ False, "--interactive", "-i", help="Pick groups and mode from a menu."
+ ),
+ list_groups: bool = typer.Option(
+ False, "--list", "-l", help="List available test groups and exit."
+ ),
+ api_key: Optional[str] = typer.Option(
+ None,
+ "--api-key",
+ envvar="DJELIA_API_KEY",
+ help="Djelia API key.",
+ show_default=False,
+ ),
+ audio: Optional[str] = typer.Option(
+ None, "--audio", "-a", help="Path to the audio file used for transcription."
+ ),
+ output_dir: str = typer.Option(
+ "cookbook_output",
+ "--output-dir",
+ "-o",
+ help="Where generated audio is written.",
+ ),
+ keep_audio: bool = typer.Option(
+ False, "--keep-audio", help="Keep generated audio instead of cleaning it up."
+ ),
+ max_segments: int = typer.Option(
+ 3, "--max-segments", help="Streaming transcription segments to display."
+ ),
+ max_chunks: int = typer.Option(
+ 5, "--max-chunks", help="Streaming TTS chunks to display."
+ ),
+):
+ """Run the Djelia SDK test suite."""
+ if list_groups:
+ console.print(_groups_table())
+ raise typer.Exit()
+
+ banner()
+
+ config = Config.load()
+ if api_key:
+ config.api_key = api_key
+ if audio:
+ config.audio_file_path = audio
+ config.output_dir = output_dir
+ config.keep_audio = keep_audio
+ config.max_stream_segments = max_segments
+ config.max_stream_chunks = max_chunks
+
+ mode_value = "async" if mode == Mode.async_ else mode.value
+
+ if interactive:
+ groups, mode_value = _select_interactively()
+ elif group:
+ groups = []
+ for g in group:
+ if g not in GROUPS:
+ console.print(f"[red]Unknown group:[/] {g}")
+ raise typer.Exit(1)
+ groups.append(g)
+ else:
+ groups = list(GROUP_ORDER)
+
+ reporter = Reporter(console)
+ if not config.api_key:
+ console.print("[red]DJELIA_API_KEY is not set[/] (use --api-key or a .env file).")
+ raise typer.Exit(1)
+
+ try:
+ suite = DjeliaTestSuite(config, reporter)
+ except Exception as exc: # noqa: BLE001 - surface setup errors cleanly
+ console.print(f"[red]Failed to initialize the client:[/] {exc}")
+ raise typer.Exit(1)
+
+ if not suite.validate():
+ console.print("[red]Setup validation failed. Fix the issues above.[/]")
+ raise typer.Exit(1)
+
+ if not groups:
+ console.print("[yellow]No groups selected. Nothing to do.[/]")
+ raise typer.Exit()
+
+ try:
+ suite.run(groups, mode_value)
+ except KeyboardInterrupt:
+ console.print("\n[yellow]Interrupted by user.[/]")
+
+ render_summary(reporter)
+
+ if any(r.status == "fail" for r in reporter.results):
+ if Confirm.ask("\n[dim]Show tracebacks for failures?[/]", default=False):
+ for r in reporter.results:
+ if r.status == "fail" and r.error:
+ console.print(f"\n[red bold]{r.name}[/]")
+ console.print(r.error)
+ raise typer.Exit(1)
+
+
+if __name__ == "__main__":
+ app()
diff --git a/cookbook/config.py b/cookbook/config.py
index eccd334..27ca117 100644
--- a/cookbook/config.py
+++ b/cookbook/config.py
@@ -1,15 +1,26 @@
import os
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from typing import List, Optional
from dotenv import load_dotenv
+# The Bambara greeting reused across every TTS test.
+DEFAULT_TTS_TEXT = "Aw ni ce, i ka kɛnɛ wa?"
+DEFAULT_SPEAKERS = ["Moussa", "Sekou", "Seydou"]
+
@dataclass
class Config:
- api_key: str | None = None
+ """Runtime configuration for the Djelia SDK test suite."""
+
+ api_key: Optional[str] = None
audio_file_path: str = "audio.wav"
+ output_dir: str = "cookbook_output"
+ keep_audio: bool = False
max_stream_segments: int = 3
max_stream_chunks: int = 5
+ tts_text: str = DEFAULT_TTS_TEXT
+ speakers: List[str] = field(default_factory=lambda: list(DEFAULT_SPEAKERS))
@classmethod
def load(cls) -> "Config":
diff --git a/cookbook/cookbook.py b/cookbook/cookbook.py
index 3e2fc62..dab3951 100644
--- a/cookbook/cookbook.py
+++ b/cookbook/cookbook.py
@@ -1,701 +1,473 @@
+from __future__ import annotations
+
import asyncio
-import logging
import os
-import traceback
+from pathlib import Path
+from typing import Dict, List, Optional
from uuid import uuid4
from djelia import Djelia, DjeliaAsync
-from djelia.models import (Language, SupportedLanguageSchema,
- TranslationRequest, TranslationResponse, TTSRequest,
- TTSRequestV2, Versions)
+from djelia.models import Language, Versions
from .config import Config
-from .utils import (ConsoleColor, handle_transcription_result, print_error,
- print_info, print_success, print_summary, process_result)
+from .utils import Reporter
# ================================================
-# Djelia Cookbook
+# Test registry
# ================================================
-
-class DjeliaCookbook:
- def __init__(self, config: Config):
+GROUP_ORDER: List[str] = [
+ "translation",
+ "transcription",
+ "stream-transcription",
+ "tts",
+ "stream-tts",
+ "version",
+ "parallel",
+]
+
+GROUPS: Dict[str, Dict[str, Optional[str]]] = {
+ "translation": {
+ "label": "Translation",
+ "sync": "translation_sync",
+ "async": "translation_async",
+ },
+ "transcription": {
+ "label": "Transcription",
+ "sync": "transcription_sync",
+ "async": "transcription_async",
+ },
+ "stream-transcription": {
+ "label": "Streaming transcription",
+ "sync": "streaming_transcription_sync",
+ "async": "streaming_transcription_async",
+ },
+ "tts": {
+ "label": "Text-to-speech",
+ "sync": "tts_sync",
+ "async": "tts_async",
+ },
+ "stream-tts": {
+ "label": "Streaming TTS",
+ "sync": "streaming_tts_sync",
+ "async": "streaming_tts_async",
+ },
+ "version": {
+ "label": "Version management",
+ "sync": "version_management",
+ "async": None,
+ },
+ "parallel": {
+ "label": "Parallel operations",
+ "sync": None,
+ "async": "parallel_operations",
+ },
+}
+
+
+def _short(text: str, limit: int = 32) -> str:
+ text = " ".join(text.split())
+ return text if len(text) <= limit else text[: limit - 1] + "…"
+
+
+def _transcription_detail(transcription) -> str:
+ if isinstance(transcription, list):
+ if transcription:
+ return f"{len(transcription)} segments · '{_short(transcription[0].text)}'"
+ return "0 segments"
+ if hasattr(transcription, "text"):
+ return f"'{_short(transcription.text)}'"
+ return "unexpected format"
+
+
+def _filesize(path: str) -> str:
+ try:
+ return f"{os.path.getsize(path):,} bytes"
+ except OSError:
+ return "written"
+
+
+class DjeliaTestSuite:
+ """Exercises the OpenAI-style Djelia SDK surface and reports live metrics."""
+
+ def __init__(self, config: Config, reporter: Reporter):
self.config = config
+ self.reporter = reporter
self.sync_client = Djelia(api_key=config.api_key)
self.async_client = DjeliaAsync(api_key=config.api_key)
- self.test_results = {}
- self.translation_samples = [
+ self.output_dir = Path(config.output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+ self._generated: List[Path] = []
+
+ self.samples = [
("Hello, how are you?", Language.ENGLISH, Language.BAMBARA),
("Bonjour, comment allez-vous?", Language.FRENCH, Language.BAMBARA),
("Good morning", Language.ENGLISH, Language.FRENCH),
]
- self.bambara_tts_text = "Aw ni ce, i ka kɛnɛ wa?"
- self.supported_speakers = ["Moussa", "Sekou", "Seydou"]
-
- # ------------------------------
- # Setup Validation
- # ------------------------------
-
- def validate_setup(self) -> bool:
- valid = True
-
- if not self.config.api_key:
- print_error("DJELIA_API_KEY not found")
- valid = False
-
- if not os.path.exists(self.config.audio_file_path):
- print_error(f"Audio file not found: {self.config.audio_file_path}")
- valid = False
-
- if valid:
- print_success("API Key and audio file loaded")
-
- return valid
-
- # ------------------------------
- # Translation Tests
- # ------------------------------
-
- def test_translation_sync(self) -> None:
- test_name = "Sync Translation"
- print(
- f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS TRANSLATION':^60}{ConsoleColor.RESET}"
- )
-
- try:
- languages: list[SupportedLanguageSchema] = (
- self.sync_client.translation.get_supported_languages()
- )
- print_success(f"Supported languages: {len(languages)}")
-
- for text, source, target in self.translation_samples:
- request = TranslationRequest(text=text, source=source, target=target)
- response: TranslationResponse = self.sync_client.translation.translate(
- request=request, version=Versions.v1
- )
- print_success(
- f"{source.value} → {target.value}: "
- f"'{text}' → '{ConsoleColor.YELLOW}{response.text}{ConsoleColor.RESET}'"
- )
-
- self.test_results[test_name] = (
- "Success",
- f"{len(self.translation_samples)} translations",
- )
-
- except Exception as e:
- print_error(f"Translation error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- async def test_translation_async(self) -> None:
- test_name = "Async Translation"
- print(
- f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS TRANSLATION':^60}{ConsoleColor.RESET}"
- )
-
- async with self.async_client as client:
- try:
- languages = await client.translation.get_supported_languages()
- print_success(f"Supported languages (async): {len(languages)}")
-
- for text, source, target in self.translation_samples:
- request = TranslationRequest(
- text=text, source=source, target=target
- )
- response = await client.translation.translate(
- request=request, version=Versions.v1
- )
- print_success(
- f"{source.value} → {target.value} (async): "
- f"'{text}' → '{ConsoleColor.YELLOW}{response.text}{ConsoleColor.RESET}'"
- )
-
- self.test_results[test_name] = (
- "Success",
- f"{len(self.translation_samples)} translations",
+ self.tts_text = config.tts_text
+ self.speakers = config.speakers
+
+ # ---------
+ # Helpers #
+ # --------
+ def _out(self, name: str) -> str:
+ path = self.output_dir / name
+ self._generated.append(path)
+ return str(path)
+
+ def cleanup(self) -> None:
+ if self.config.keep_audio:
+ if self._generated:
+ self.reporter.info(
+ f"kept {len(self._generated)} audio file(s) in {self.output_dir}/"
)
-
- except Exception as e:
- print_error(f"Async translation error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- # ------------------------------
- # Transcription Tests
- # ------------------------------
-
- def test_transcription_sync(self) -> None:
- test_name = "Sync Transcription"
- print(
- f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS TRANSCRIPTION':^60}{ConsoleColor.RESET}"
- )
-
- if not os.path.exists(self.config.audio_file_path):
- print_error("Audio file missing")
- self.test_results[test_name] = ("Failed", "Missing audio file")
return
-
- for version in [Versions.v1, Versions.v2]:
+ removed = 0
+ for path in self._generated:
try:
- print_info(f"Testing non-streaming v{version.value}")
- transcription = self.sync_client.transcription.transcribe(
- self.config.audio_file_path, version=version
- )
- handle_transcription_result(transcription, f"v{version.value}")
-
- if version == Versions.v2:
- print_info("Testing French translation")
- transcription_fr = self.sync_client.transcription.transcribe(
- self.config.audio_file_path,
- translate_to_french=True,
- version=version,
- )
- print_success(
- f"French translation (v{version.value}): "
- f"'{ConsoleColor.YELLOW}{transcription_fr.text}{ConsoleColor.RESET}'"
- )
-
- self.test_results[test_name] = (
- "Success",
- f"v{version.value} completed",
- )
-
- except Exception as e:
- print_error(f"Transcription error (v{version.value}): {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- async def test_transcription_async(self) -> None:
- test_name = "Async Transcription"
- print(
- f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS TRANSCRIPTION':^60}{ConsoleColor.RESET}"
- )
-
+ path.unlink()
+ removed += 1
+ except OSError:
+ pass
+ try:
+ self.output_dir.rmdir()
+ except OSError:
+ pass
+ if removed:
+ self.reporter.info(f"cleaned up {removed} generated audio file(s)")
+
+ def validate(self) -> bool:
+ ok = True
+ if not self.config.api_key:
+ self.reporter.fail("DJELIA_API_KEY is not set")
+ ok = False
if not os.path.exists(self.config.audio_file_path):
- print_error("Audio file missing")
- self.test_results[test_name] = ("Failed", "Missing audio file")
- return
-
- async with self.async_client as client:
- for version in [Versions.v1, Versions.v2]:
- try:
- print_info(f"Testing non-streaming v{version.value}")
- transcription = await client.transcription.transcribe(
- self.config.audio_file_path, version=version
- )
- handle_transcription_result(
- transcription, f"v{version.value} (async)"
+ self.reporter.fail(f"audio file not found: {self.config.audio_file_path}")
+ ok = False
+ if ok:
+ self.reporter.ok("API key and audio file present")
+ return ok
+
+ # --------------
+ # Translation #
+ # -------------
+ def translation_sync(self) -> None:
+ with self.reporter.case("Translation · sync", "translation") as case:
+ with case.op("languages", "supported-languages") as op:
+ languages = self.sync_client.translations.list_languages()
+ op.set(f"{len(languages)} languages")
+ for text, source, target in self.samples:
+ with case.op("translate", f"{source.value}→{target.value}") as op:
+ response = self.sync_client.translations.create(
+ text=text, source=source, target=target, model=Versions.v1
)
-
- if version == Versions.v2:
- print_info("Testing French translation")
- transcription_fr = await client.transcription.transcribe(
- self.config.audio_file_path,
- translate_to_french=True,
- version=version,
- )
- print_success(
- f"French translation (async): "
- f"'{ConsoleColor.YELLOW}{transcription_fr.text}{ConsoleColor.RESET}'"
+ op.set(f"'{_short(text)}' → '{_short(response.text)}'")
+ case.done(f"{len(self.samples)} translations · {len(languages)} languages")
+
+ async def translation_async(self) -> None:
+ with self.reporter.case("Translation · async", "translation") as case:
+ async with self.async_client as client:
+ with case.op("languages", "supported-languages") as op:
+ languages = await client.translations.list_languages()
+ op.set(f"{len(languages)} languages")
+ for text, source, target in self.samples:
+ with case.op("translate", f"{source.value}→{target.value}") as op:
+ response = await client.translations.create(
+ text=text, source=source, target=target, model=Versions.v1
)
-
- self.test_results[test_name] = (
- "Success",
- f"v{version.value} completed",
+ op.set(f"'{_short(text)}' → '{_short(response.text)}'")
+ case.done(f"{len(self.samples)} translations · {len(languages)} languages")
+
+ # --------------
+ # Transcription #
+ # --------------
+ def transcription_sync(self) -> None:
+ with self.reporter.case("Transcription · sync", "transcription") as case:
+ for version in (Versions.v1, Versions.v2):
+ with case.op("transcribe", f"v{version.value}") as op:
+ transcription = self.sync_client.audio.transcriptions.create(
+ file=self.config.audio_file_path, model=version
)
-
- except Exception as e:
- print_error(f"Async transcription error (v{version.value}): {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- # ------------------------------
- # Streaming Transcription
- # ------------------------------
-
- def test_streaming_transcription_sync(self) -> None:
- test_name = "Sync Streaming Transcription"
- print(
- f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS STREAMING TRANSCRIPTION':^60}{ConsoleColor.RESET}"
- )
-
- if not os.path.exists(self.config.audio_file_path):
- print_error("Audio file missing")
- self.test_results[test_name] = ("Failed", "Missing audio file")
- return
-
- for version in [Versions.v1, Versions.v2]:
- try:
- print_info(f"Testing streaming v{version.value}")
- segment_count = 0
-
- for segment in self.sync_client.transcription.transcribe(
- self.config.audio_file_path, stream=True, version=version
- ):
- segment_count += 1
- print_success(
- f"Segment {segment_count}: "
- f"{segment.start:.2f}s-{segment.end:.2f}s: "
- f"'{ConsoleColor.YELLOW}{segment.text}{ConsoleColor.RESET}'"
- )
-
- if segment_count >= self.config.max_stream_segments:
- print_info(
- f"Showing first {self.config.max_stream_segments} segments"
- )
- break
-
- print_success(f"Streaming complete: {segment_count} segments")
-
+ op.set(_transcription_detail(transcription))
if version == Versions.v2:
- print_info("Testing streaming French translation")
- segment_count = 0
-
- for segment in self.sync_client.transcription.transcribe(
- self.config.audio_file_path,
- stream=True,
- translate_to_french=True,
- version=version,
- ):
- segment_count += 1
- text = segment.text
- print_success(
- f"French Segment {segment_count}: "
- f"'{ConsoleColor.YELLOW}{text}{ConsoleColor.RESET}'"
+ with case.op("transcribe", "v2 → french") as op:
+ fr = self.sync_client.audio.transcriptions.create(
+ file=self.config.audio_file_path,
+ translate_to_french=True,
+ model=version,
)
-
- if segment_count >= self.config.max_stream_segments:
- print_info(
- f"Showing first {self.config.max_stream_segments} segments"
- )
- break
-
- print_success(
- f"French streaming complete: {segment_count} segments"
- )
-
- self.test_results[test_name] = (
- "Success",
- f"v{version.value} completed",
- )
-
- except Exception as e:
- print_error(f"Streaming error (v{version.value}): {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- async def test_streaming_transcription_async(self) -> None:
- test_name = "Async Streaming Transcription"
- print(
- f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS STREAMING TRANSCRIPTION':^60}{ConsoleColor.RESET}"
- )
-
- if not os.path.exists(self.config.audio_file_path):
- print_error("Audio file missing")
- self.test_results[test_name] = ("Failed", "Missing audio file")
- return
-
- async with self.async_client as client:
- for version in [Versions.v1, Versions.v2]:
- try:
- print_info(f"Testing streaming v{version.value}")
- segment_count = 0
-
- generator = await client.transcription.transcribe(
- self.config.audio_file_path, stream=True, version=version
- )
-
- async for segment in generator:
- segment_count += 1
- print_success(
- f"Segment {segment_count}: "
- f"{segment.start:.2f}s-{segment.end:.2f}s: "
- f"'{ConsoleColor.YELLOW}{segment.text}{ConsoleColor.RESET}'"
+ op.set(f"'{_short(fr.text)}'")
+ case.done("v1 + v2 (with french)")
+
+ async def transcription_async(self) -> None:
+ with self.reporter.case("Transcription · async", "transcription") as case:
+ async with self.async_client as client:
+ for version in (Versions.v1, Versions.v2):
+ with case.op("transcribe", f"v{version.value}") as op:
+ transcription = await client.audio.transcriptions.create(
+ file=self.config.audio_file_path, model=version
)
-
- if segment_count >= self.config.max_stream_segments:
- print_info(
- f"Showing first {self.config.max_stream_segments} segments"
+ op.set(_transcription_detail(transcription))
+ if version == Versions.v2:
+ with case.op("transcribe", "v2 → french") as op:
+ fr = await client.audio.transcriptions.create(
+ file=self.config.audio_file_path,
+ translate_to_french=True,
+ model=version,
)
+ op.set(f"'{_short(fr.text)}'")
+ case.done("v1 + v2 (with french)")
+
+ # -----------------------
+ # Streaming transcription #
+ # -----------------------
+ def streaming_transcription_sync(self) -> None:
+ with self.reporter.case(
+ "Streaming transcription · sync", "stream-transcription"
+ ) as case:
+ limit = self.config.max_stream_segments
+ for version in (Versions.v1, Versions.v2):
+ with case.op("stream", f"transcribe v{version.value}") as op:
+ count = 0
+ for segment in self.sync_client.audio.transcriptions.create(
+ file=self.config.audio_file_path, stream=True, model=version
+ ):
+ count += 1
+ case.note(
+ f"seg {count} {segment.start:.2f}-{segment.end:.2f}s: "
+ f"{_short(segment.text)}"
+ )
+ if count >= limit:
break
-
- print_success(f"Streaming complete: {segment_count} segments")
-
- if version == Versions.v2:
- print_info("Testing streaming French translation")
- segment_count = 0
-
- generator = await client.transcription.transcribe(
- self.config.audio_file_path,
- stream=True,
- translate_to_french=True,
- version=version,
+ op.set(f"{count} segments (first {limit})")
+ case.done("v1 + v2 streamed")
+
+ async def streaming_transcription_async(self) -> None:
+ with self.reporter.case(
+ "Streaming transcription · async", "stream-transcription"
+ ) as case:
+ limit = self.config.max_stream_segments
+ async with self.async_client as client:
+ for version in (Versions.v1, Versions.v2):
+ with case.op("stream", f"transcribe v{version.value}") as op:
+ count = 0
+ generator = await client.audio.transcriptions.create(
+ file=self.config.audio_file_path, stream=True, model=version
)
-
async for segment in generator:
- segment_count += 1
- text = segment.text
- print_success(
- f"French Segment {segment_count}: "
- f"'{ConsoleColor.YELLOW}{text}{ConsoleColor.RESET}'"
+ count += 1
+ case.note(
+ f"seg {count} {segment.start:.2f}-{segment.end:.2f}s: "
+ f"{_short(segment.text)}"
)
-
- if segment_count >= self.config.max_stream_segments:
- print_info(
- f"Showing first {self.config.max_stream_segments} segments"
- )
+ if count >= limit:
break
-
- print_success(
- f"French streaming complete: {segment_count} segments"
- )
-
- self.test_results[test_name] = (
- "Success",
- f"v{version.value} completed",
- )
-
- except Exception as e:
- print_error(f"Async streaming error (v{version.value}): {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- # ------------------------------
- # TTS Tests
- # ------------------------------
-
- def test_tts_sync(self) -> None:
- test_name = "Sync TTS"
- print(
- f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS TEXT-TO-SPEECH':^60}{ConsoleColor.RESET}"
- )
-
- try:
- tts_request_v1 = TTSRequest(text=self.bambara_tts_text, speaker=1)
- audio_file_v1 = self.sync_client.tts.text_to_speech(
- request=tts_request_v1,
- output_file=f"tts_sync_v1_{uuid4().hex}.wav",
- version=Versions.v1,
- )
- print_success(
- f"TTS v1 saved: {ConsoleColor.BLUE}{audio_file_v1}{ConsoleColor.RESET}"
- )
-
- for speaker in self.supported_speakers:
- tts_request_v2 = TTSRequestV2(
- text=self.bambara_tts_text,
- description=f"{speaker} speaks with natural tone",
- chunk_size=1.0,
- )
- audio_file_v2 = self.sync_client.tts.text_to_speech(
- request=tts_request_v2,
- output_file=f"tts_sync_v2_{speaker}_{uuid4().hex}.wav",
- version=Versions.v2,
- )
- print_success(
- f"TTS v2 ({speaker}): {ConsoleColor.BLUE}{audio_file_v2}{ConsoleColor.RESET}"
- )
-
- self.test_results[test_name] = (
- "Success",
- f"{len(self.supported_speakers)} speakers",
- )
-
- except Exception as e:
- print_error(f"TTS error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- async def test_tts_async(self) -> None:
- test_name = "Async TTS"
- print(
- f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS TEXT-TO-SPEECH':^60}{ConsoleColor.RESET}"
- )
-
- async with self.async_client as client:
- try:
- tts_request_v1 = TTSRequest(text=self.bambara_tts_text, speaker=1)
- audio_file_v1 = await client.tts.text_to_speech(
- request=tts_request_v1,
- output_file=f"tts_async_v1_{uuid4().hex}.wav",
- version=Versions.v1,
- )
- print_success(
- f"TTS v1 saved: {ConsoleColor.BLUE}{audio_file_v1}{ConsoleColor.RESET}"
+ op.set(f"{count} segments (first {limit})")
+ case.done("v1 + v2 streamed")
+
+ # ---------------
+ # Text-to-speech #
+ # ---------------
+ def tts_sync(self) -> None:
+ with self.reporter.case("Text-to-speech · sync", "tts") as case:
+ with case.op("tts", "v1") as op:
+ path = self.sync_client.audio.speech.create(
+ input=self.tts_text,
+ voice=1,
+ output_file=self._out(f"tts_sync_v1_{uuid4().hex}.wav"),
+ model=Versions.v1,
)
-
- for speaker in self.supported_speakers:
- tts_request_v2 = TTSRequestV2(
- text=self.bambara_tts_text,
+ op.set(_filesize(path))
+ for speaker in self.speakers:
+ with case.op("tts", f"v2 {speaker}") as op:
+ path = self.sync_client.audio.speech.create(
+ input=self.tts_text,
description=f"{speaker} speaks with natural tone",
- chunk_size=0.5,
+ output_file=self._out(
+ f"tts_sync_v2_{speaker}_{uuid4().hex}.wav"
+ ),
+ model=Versions.v2,
)
- audio_file_v2 = await client.tts.text_to_speech(
- request=tts_request_v2,
- output_file=f"tts_async_v2_{speaker}_{uuid4().hex}.wav",
- version=Versions.v2,
+ op.set(_filesize(path))
+ case.done(f"v1 + {len(self.speakers)} v2 speakers")
+
+ async def tts_async(self) -> None:
+ with self.reporter.case("Text-to-speech · async", "tts") as case:
+ async with self.async_client as client:
+ with case.op("tts", "v1") as op:
+ path = await client.audio.speech.create(
+ input=self.tts_text,
+ voice=1,
+ output_file=self._out(f"tts_async_v1_{uuid4().hex}.wav"),
+ model=Versions.v1,
)
- print_success(
- f"TTS v2 ({speaker}): {ConsoleColor.BLUE}{audio_file_v2}{ConsoleColor.RESET}"
- )
-
- self.test_results[test_name] = (
- "Success",
- f"{len(self.supported_speakers)} speakers",
- )
-
- except Exception as e:
- print_error(f"Async TTS error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- # ------------------------------
- # Streaming TTS
- # ------------------------------
-
- def test_streaming_tts_sync(self) -> None:
- test_name = "Sync Streaming TTS"
- print(
- f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS STREAMING TTS':^60}{ConsoleColor.RESET}"
- )
-
- try:
- tts_request = TTSRequestV2(
- text=self.bambara_tts_text,
- description=f"{self.supported_speakers[0]} speaks with natural conversational tone",
- )
- chunk_count = 0
- total_bytes = 0
-
- for audio_chunk in self.sync_client.tts.text_to_speech(
- request=tts_request,
- output_file=f"stream_tts_sync_{uuid4().hex}.wav",
- stream=True,
- version=Versions.v2,
- ):
- chunk_count += 1
- total_bytes += len(audio_chunk)
- print_success(f"Chunk {chunk_count}: {len(audio_chunk):,} bytes")
-
- if chunk_count >= self.config.max_stream_chunks:
- print_info(f"Showing first {self.config.max_stream_chunks} chunks")
- break
-
- print_success(
- f"Streaming complete: {chunk_count} chunks, "
- f"{ConsoleColor.BLUE}{total_bytes:,} bytes{ConsoleColor.RESET}"
- )
- self.test_results[test_name] = ("Success", f"{chunk_count} chunks")
-
- except Exception as e:
- print_error(f"Streaming TTS error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- async def test_streaming_tts_async(self) -> None:
- test_name = "Async Streaming TTS"
- print(
- f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS STREAMING TTS':^60}{ConsoleColor.RESET}"
- )
-
- async with self.async_client as client:
- try:
- tts_request = TTSRequestV2(
- text=self.bambara_tts_text,
- description=f"{self.supported_speakers[0]} speaks with clear natural tone",
- )
- chunk_count = 0
- total_bytes = 0
-
- generator = await client.tts.text_to_speech(
- request=tts_request,
- output_file=f"stream_tts_async_{uuid4().hex}.wav",
- stream=True,
- version=Versions.v2,
- )
-
- async for audio_chunk in generator:
- chunk_count += 1
- total_bytes += len(audio_chunk)
- print_success(f"Chunk {chunk_count}: {len(audio_chunk):,} bytes")
-
- if chunk_count >= self.config.max_stream_chunks:
- print_info(
- f"Showing first {self.config.max_stream_chunks} chunks"
+ op.set(_filesize(path))
+ for speaker in self.speakers:
+ with case.op("tts", f"v2 {speaker}") as op:
+ path = await client.audio.speech.create(
+ input=self.tts_text,
+ description=f"{speaker} speaks with natural tone",
+ output_file=self._out(
+ f"tts_async_v2_{speaker}_{uuid4().hex}.wav"
+ ),
+ model=Versions.v2,
)
+ op.set(_filesize(path))
+ case.done(f"v1 + {len(self.speakers)} v2 speakers")
+
+ # --------------------------
+ # Streaming text-to-speech #
+ # --------------------------
+ def streaming_tts_sync(self) -> None:
+ with self.reporter.case("Streaming TTS · sync", "stream-tts") as case:
+ limit = self.config.max_stream_chunks
+ with case.op("stream", "tts v2") as op:
+ chunks = 0
+ total = 0
+ for chunk in self.sync_client.audio.speech.create(
+ input=self.tts_text,
+ description=f"{self.speakers[0]} speaks with natural conversational tone",
+ output_file=self._out(f"stream_tts_sync_{uuid4().hex}.wav"),
+ stream=True,
+ model=Versions.v2,
+ ):
+ chunks += 1
+ total += len(chunk)
+ case.note(f"chunk {chunks}: {len(chunk):,} bytes")
+ if chunks >= limit:
break
-
- print_success(
- f"Streaming complete: {chunk_count} chunks, "
- f"{ConsoleColor.BLUE}{total_bytes:,} bytes{ConsoleColor.RESET}"
- )
- self.test_results[test_name] = ("Success", f"{chunk_count} chunks")
-
- except Exception as e:
- print_error(f"Async streaming TTS error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- # ------------------------------
- # Advanced Features
- # ------------------------------
-
- async def test_parallel_operations(self) -> None:
- test_name = "Parallel Operations"
- print(
- f"{ConsoleColor.CYAN}\n{'PARALLEL API OPERATIONS':^60}{ConsoleColor.RESET}"
- )
-
- async with self.async_client as client:
- try:
- print_info("Executing parallel operations...")
-
- translation_request = TranslationRequest(
- text="Hello", source=Language.ENGLISH, target=Language.BAMBARA
+ op.set(f"{chunks} chunks · {total:,} bytes")
+ case.done(f"{chunks} chunks streamed")
+
+ async def streaming_tts_async(self) -> None:
+ with self.reporter.case("Streaming TTS · async", "stream-tts") as case:
+ limit = self.config.max_stream_chunks
+ async with self.async_client as client:
+ with case.op("stream", "tts v2") as op:
+ chunks = 0
+ total = 0
+ generator = await client.audio.speech.create(
+ input=self.tts_text,
+ description=f"{self.speakers[0]} speaks with clear natural tone",
+ output_file=self._out(f"stream_tts_async_{uuid4().hex}.wav"),
+ stream=True,
+ model=Versions.v2,
+ )
+ async for chunk in generator:
+ chunks += 1
+ total += len(chunk)
+ case.note(f"chunk {chunks}: {len(chunk):,} bytes")
+ if chunks >= limit:
+ break
+ op.set(f"{chunks} chunks · {total:,} bytes")
+ case.done(f"{chunks} chunks streamed")
+
+ # --------------------
+ # Version management #
+ # -------------------
+ def version_management(self) -> None:
+ with self.reporter.case("Version management · sync", "version") as case:
+ self.reporter.info(f"latest version: {Versions.latest()}")
+ self.reporter.info(
+ f"available: {[str(v) for v in Versions.all_versions()]}"
+ )
+ with case.op("translate", "v1") as op:
+ result = self.sync_client.translations.create(
+ text="Hello world",
+ source=Language.ENGLISH,
+ target=Language.BAMBARA,
+ model=Versions.v1,
)
-
- tts_request = TTSRequestV2(
- text=self.bambara_tts_text,
- description=f"{self.supported_speakers[0]} speaks with clear speaking tone",
+ op.set(f"'{_short(result.text)}'")
+ for version in (Versions.v1, Versions.v2):
+ with case.op("transcribe", f"v{version.value}") as op:
+ transcription = self.sync_client.audio.transcriptions.create(
+ file=self.config.audio_file_path, model=version
+ )
+ op.set(_transcription_detail(transcription))
+ with case.op("tts", "v1") as op:
+ path = self.sync_client.audio.speech.create(
+ input=self.tts_text,
+ voice=1,
+ output_file=self._out(f"tts_v1_{uuid4().hex}.wav"),
+ model=Versions.v1,
)
-
- results = await asyncio.gather(
- client.translation.get_supported_languages(),
- client.translation.translate(
- translation_request, version=Versions.v1
- ),
- client.transcription.transcribe(
- self.config.audio_file_path, version=Versions.v1
- ),
- client.tts.text_to_speech(
- tts_request,
- output_file=f"parallel_tts_{uuid4().hex}.wav",
- version=Versions.v2,
- ),
- return_exceptions=True,
+ op.set(_filesize(path))
+ with case.op("tts", "v2") as op:
+ path = self.sync_client.audio.speech.create(
+ input=self.tts_text,
+ description=f"{self.speakers[0]} speaks with natural tone",
+ output_file=self._out(f"tts_v2_{uuid4().hex}.wav"),
+ model=Versions.v2,
)
-
- print(f"\n{ConsoleColor.CYAN}Parallel Results:{ConsoleColor.RESET}")
- process_result("Languages", results[0])
- process_result("Translation", results[1])
- process_result("Transcription", results[2])
- process_result("TTS Output", results[3])
-
- if all(not isinstance(r, Exception) for r in results):
- self.test_results[test_name] = (
- "Success",
- "All operations completed",
+ op.set(_filesize(path))
+ case.done("v1/v2 across translate, transcribe, tts")
+
+ # ---------------------
+ # Parallel operations #
+ # ---------------------
+ async def parallel_operations(self) -> None:
+ with self.reporter.case("Parallel operations · async", "parallel") as case:
+ async with self.async_client as client:
+ with case.op("gather", "4 concurrent ops") as op:
+ results = await asyncio.gather(
+ client.translations.list_languages(),
+ client.translations.create(
+ text="Hello",
+ source=Language.ENGLISH,
+ target=Language.BAMBARA,
+ model=Versions.v1,
+ ),
+ client.audio.transcriptions.create(
+ file=self.config.audio_file_path, model=Versions.v1
+ ),
+ client.audio.speech.create(
+ input=self.tts_text,
+ description=f"{self.speakers[0]} speaks with clear speaking tone",
+ output_file=self._out(f"parallel_tts_{uuid4().hex}.wav"),
+ model=Versions.v2,
+ ),
+ return_exceptions=True,
)
- else:
- failed = [
- type(r).__name__ for r in results if isinstance(r, Exception)
- ]
- self.test_results[test_name] = (
- "Failed",
- f"Errors: {', '.join(failed)}",
+ succeeded = sum(
+ 1 for r in results if not isinstance(r, Exception)
)
+ op.set(f"{succeeded}/{len(results)} succeeded")
+
+ names = ["languages", "translation", "transcription", "tts"]
+ failed = []
+ for name, result in zip(names, results):
+ if isinstance(result, Exception):
+ case.note(f"[red]{name}: {result}[/]")
+ failed.append(name)
+ else:
+ case.note(f"{name}: {type(result).__name__}")
+ if failed:
+ raise RuntimeError(f"failed: {', '.join(failed)}")
+ case.done("4 concurrent operations")
+
+ # ---------------
+ # Orchestration #
+ # ---------------
+ def run(self, groups: List[str], mode: str) -> None:
+ selected = [g for g in GROUP_ORDER if g in groups]
+
+ for key in selected:
+ method = GROUPS[key]["sync"]
+ if mode in ("sync", "both") and method:
+ getattr(self, method)()
+
+ async_methods = [
+ GROUPS[key]["async"]
+ for key in selected
+ if mode in ("async", "both") and GROUPS[key]["async"]
+ ]
+ if async_methods:
+ asyncio.run(self._run_async(async_methods))
- except Exception as e:
- print_error(f"Parallel operations error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- def test_version_management(self) -> None:
- test_name = "Version Management"
- print(f"{ConsoleColor.CYAN}\n{'VERSION MANAGEMENT':^60}{ConsoleColor.RESET}")
-
- print_success(
- f"Latest version: {ConsoleColor.YELLOW}{Versions.latest()}{ConsoleColor.RESET}"
- )
- print_success(
- f"Available versions: {ConsoleColor.GRAY}{[str(v) for v in Versions.all_versions()]}"
- )
-
- try:
- request = TranslationRequest(
- text="Hello world", source=Language.ENGLISH, target=Language.BAMBARA
- )
- result = self.sync_client.translation.translate(
- request=request, version=Versions.v1
- )
- print_success(
- f"Translation v1: '{ConsoleColor.YELLOW}{result.text}{ConsoleColor.RESET}'"
- )
-
- for version in [Versions.v1, Versions.v2]:
- transcription = self.sync_client.transcription.transcribe(
- self.config.audio_file_path, version=version
- )
- handle_transcription_result(transcription, f"v{version.value}")
-
- tts_v1 = self.sync_client.tts.text_to_speech(
- request=TTSRequest(text=self.bambara_tts_text, speaker=1),
- output_file=f"tts_v1_{uuid4().hex}.wav",
- version=Versions.v1,
- )
- print_success(f"TTS v1: {ConsoleColor.BLUE}{tts_v1}{ConsoleColor.RESET}")
-
- tts_v2 = self.sync_client.tts.text_to_speech(
- request=TTSRequestV2(
- text=self.bambara_tts_text,
- description=f"{self.supported_speakers[0]} speaks with natural tone",
- ),
- output_file=f"tts_v2_{uuid4().hex}.wav",
- version=Versions.v2,
- )
- print_success(f"TTS v2: {ConsoleColor.BLUE}{tts_v2}{ConsoleColor.RESET}")
-
- self.test_results[test_name] = ("Success", "All version tests completed")
-
- except Exception as e:
- print_error(f"Version test error: {e}")
- self.test_results[test_name] = ("Failed", str(e))
- logging.error(f"Traceback:\n{traceback.format_exc()}")
-
- # ------------------------------
- # Main Execution
- # ------------------------------
- def run(self) -> None:
- print(f"\n{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}")
- print(
- f"{ConsoleColor.YELLOW}{'DJELIA SDK DEVELOPER COOKBOOK':^60}{ConsoleColor.RESET}"
- )
- print(f"{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}")
-
- logging.basicConfig(
- filename="djelia_cookbook.log",
- level=logging.ERROR,
- format="%(asctime)s - %(levelname)s - %(message)s",
- )
-
- if not self.validate_setup():
- return
+ self.cleanup()
- try:
- self.test_translation_sync()
- self.test_transcription_sync()
- self.test_tts_sync()
- self.test_streaming_transcription_sync()
- self.test_streaming_tts_sync()
- self.test_version_management()
-
- asyncio.run(self.test_translation_async())
- asyncio.run(self.test_transcription_async())
- asyncio.run(self.test_tts_async())
- asyncio.run(self.test_streaming_transcription_async())
- asyncio.run(self.test_streaming_tts_async())
- asyncio.run(self.test_parallel_operations())
-
- print_summary(self.test_results)
-
- except KeyboardInterrupt:
- print_error("Execution interrupted by user")
- self.test_results["Overall"] = ("Failed", "Interrupted")
- print_summary(self.test_results)
- except Exception as e:
- print_error(f"Unexpected error: {str(e)}")
- logging.error(f"Unhandled exception:\n{traceback.format_exc()}")
- self.test_results["Overall"] = ("Failed", "Runtime error")
- print_summary(self.test_results)
+ async def _run_async(self, method_names: List[str]) -> None:
+ for name in method_names:
+ await getattr(self, name)()
diff --git a/cookbook/main.py b/cookbook/main.py
deleted file mode 100644
index 1c4bf27..0000000
--- a/cookbook/main.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import os
-import sys
-
-sys.path.append(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-) # not really necessary but I will keep it, just au cas ou
-
-from .config import Config
-from .cookbook import DjeliaCookbook
-
-
-def main():
- config = Config.load()
- cookbook = DjeliaCookbook(config)
- cookbook.run()
-
-
-if __name__ == "__main__":
- main()
diff --git a/cookbook/utils.py b/cookbook/utils.py
index de6c675..7421531 100644
--- a/cookbook/utils.py
+++ b/cookbook/utils.py
@@ -1,75 +1,272 @@
-import logging
+from __future__ import annotations
-from djelia.models import FrenchTranscriptionResponse, TranscriptionSegment
+import time
+import traceback
+from contextlib import contextmanager
+from dataclasses import dataclass, field
+from typing import Iterator, List, Optional
-# ================================================
-# Console Utilities
-# ================================================
+from rich.box import ROUNDED
+from rich.console import Console
+from rich.panel import Panel
+from rich.rule import Rule
+from rich.table import Table
+from rich.text import Text
+
+console = Console()
+STATUS_STYLE = {"pass": "green", "fail": "red", "skip": "yellow"}
+STATUS_ICON = {"pass": "✓", "fail": "✗", "skip": "–"}
-class ConsoleColor:
- GREEN = "\033[32m"
- RED = "\033[31m"
- YELLOW = "\033[33m"
- CYAN = "\033[36m"
- PURPLE = "\033[35m"
- BLUE = "\033[34m"
- GRAY = "\033[37m"
- RESET = "\033[0m"
+# Colour per operation kind, echoed in the live line and the metrics table.
+KIND_STYLE = {
+ "languages": "magenta",
+ "translate": "cyan",
+ "transcribe": "blue",
+ "stream": "yellow",
+ "tts": "green",
+ "gather": "white",
+}
# ================================================
-# Utility Functions
+# Data model
# ================================================
-def print_success(message: str) -> None:
- print(f"{ConsoleColor.GREEN}✓ {message}{ConsoleColor.RESET}")
+@dataclass
+class OpEvent:
+ """A single timed SDK call within a test case."""
+
+ kind: str
+ label: str
+ seconds: float
+ detail: str = ""
+ ok: bool = True
+
+@dataclass
+class TestResult:
+ name: str
+ group: str
+ status: str # "pass" | "fail" | "skip"
+ detail: str = ""
+ seconds: float = 0.0
+ events: List[OpEvent] = field(default_factory=list)
+ error: Optional[str] = None # traceback, when status == "fail"
-def print_error(message: str) -> None:
- print(f"{ConsoleColor.RED}✗ {message}{ConsoleColor.RESET}")
- logging.error(message)
+class _OpHandle:
+ """Mutable handle a test body fills in while an operation is running."""
-def print_info(message: str) -> None:
- print(f"{ConsoleColor.GRAY}ℹ {message}{ConsoleColor.RESET}")
+ def __init__(self, kind: str, label: str) -> None:
+ self.kind = kind
+ self.label = label
+ self.detail = ""
+ self.ok = True
+ def set(self, detail: str) -> None:
+ self.detail = detail
-def print_summary(test_results: dict) -> None:
- print(f"\n{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}")
- print(f"{'Test Summary':^60}")
- print(f"{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}")
- print(f"{'Test':<40} {'Status':<10} {'Details'}")
- print(f"{ConsoleColor.GRAY}{'-' * 60}{ConsoleColor.RESET}")
- for test, (status, details) in sorted(test_results.items()):
- color = ConsoleColor.GREEN if status == "Success" else ConsoleColor.RED
- print(f"{test:<40} {color}{status:<10}{ConsoleColor.RESET} {details}")
+class Case:
+ """One test case: owns its timed operations and reports live as they finish."""
+ def __init__(self, name: str, group: str, console: Console) -> None:
+ self.name = name
+ self.group = group
+ self.console = console
+ self.events: List[OpEvent] = []
+ self.status = "pass"
+ self.detail = ""
+ self.seconds = 0.0
+ self.error: Optional[str] = None
-def handle_transcription_result(
- transcription: list[TranscriptionSegment] | FrenchTranscriptionResponse,
- version_info: str,
-) -> None:
- if isinstance(transcription, list) and transcription:
- print_success(f"Transcription {version_info}: {len(transcription)} segments")
- segment = transcription[0]
- print_success(
- f"Sample: {segment.start:.1f}s-{segment.end:.1f}s: "
- f"'{ConsoleColor.YELLOW}{segment.text}{ConsoleColor.RESET}'"
+ @contextmanager
+ def op(self, kind: str, label: str) -> Iterator[_OpHandle]:
+ """Time an SDK call and print a live line the moment it completes."""
+ handle = _OpHandle(kind, label)
+ t0 = time.perf_counter()
+ try:
+ yield handle
+ except Exception:
+ handle.ok = False
+ raise
+ finally:
+ seconds = time.perf_counter() - t0
+ self.events.append(
+ OpEvent(kind, handle.label, seconds, handle.detail, handle.ok)
+ )
+ self._live(kind, handle, seconds)
+
+ def _live(self, kind: str, handle: _OpHandle, seconds: float) -> None:
+ style = KIND_STYLE.get(kind, "white")
+ icon = "[green]✓[/]" if handle.ok else "[red]✗[/]"
+ detail = f" {handle.detail}" if handle.detail else ""
+ self.console.print(
+ f" {icon} [{style}]{kind:<10}[/] {handle.label:<22} "
+ f"[dim]{seconds:6.2f}s[/]{detail}"
)
- elif hasattr(transcription, "text"):
- print_success(
- f"Transcription {version_info}: "
- f"'{ConsoleColor.YELLOW}{transcription.text}{ConsoleColor.RESET}'"
+
+ def note(self, message: str) -> None:
+ """A live, untimed progress line (e.g. a streamed segment)."""
+ self.console.print(f" [dim]· {message}[/]")
+
+ def done(self, detail: str) -> None:
+ self.detail = detail
+
+
+class Reporter:
+ """Runs test cases, streams live metrics, and collects results."""
+
+ def __init__(self, console: Console = console) -> None:
+ self.console = console
+ self.results: List[TestResult] = []
+
+ def section(self, title: str, style: str = "cyan") -> None:
+ self.console.print()
+ self.console.print(Rule(f"[bold {style}]{title}[/]", style=style))
+
+ def ok(self, message: str) -> None:
+ self.console.print(f" [green]✓[/] {message}")
+
+ def info(self, message: str) -> None:
+ self.console.print(f" [dim]…[/] [dim]{message}[/]")
+
+ def fail(self, message: str) -> None:
+ self.console.print(f" [red]✗[/] {message}")
+
+ def record(self, result: TestResult) -> None:
+ self.results.append(result)
+
+ @contextmanager
+ def case(self, name: str, group: str) -> Iterator[Case]:
+ """Time a test, catch failures, render its metrics table in real time."""
+ self.section(name)
+ case = Case(name, group, self.console)
+ t0 = time.perf_counter()
+ try:
+ yield case
+ except Exception as exc: # noqa: BLE001 - report every failure
+ case.status = "fail"
+ case.detail = str(exc)
+ case.error = traceback.format_exc()
+ self.fail(f"{name}: {exc}")
+ case.seconds = time.perf_counter() - t0
+ self._render_case_metrics(case)
+ self.record(
+ TestResult(
+ name=case.name,
+ group=case.group,
+ status=case.status,
+ detail=case.detail,
+ seconds=case.seconds,
+ events=case.events,
+ error=case.error,
+ )
)
- else:
- print_error(f"Unexpected result format for {version_info}")
+ def _render_case_metrics(self, case: Case) -> None:
+ if not case.events:
+ return
+ table = Table(
+ box=ROUNDED,
+ border_style="dim",
+ header_style="bold",
+ title=f"[dim]{case.name} — operations[/dim]",
+ title_justify="left",
+ expand=True,
+ )
+ table.add_column("Operation")
+ table.add_column("Kind")
+ table.add_column("Result")
+ table.add_column("Time", justify="right")
+ for ev in case.events:
+ style = KIND_STYLE.get(ev.kind, "white")
+ marker = "" if ev.ok else "[red]✗ [/]"
+ table.add_row(
+ f"{marker}{ev.label}",
+ f"[{style}]{ev.kind}[/]",
+ ev.detail or "—",
+ f"{ev.seconds:.2f}s",
+ )
+ self.console.print(table)
-def process_result(name: str, result: object) -> None:
- if isinstance(result, Exception):
- print_error(f"{name}: {str(result)}")
- else:
- print_success(f"{name}: Received {type(result).__name__}")
+ measured = sum(e.seconds for e in case.events)
+ slowest = max(case.events, key=lambda e: e.seconds)
+ self.console.print(
+ f" [dim]measured {measured:.2f}s of {case.seconds:.2f}s · "
+ f"slowest: {slowest.label} ({slowest.seconds:.2f}s)[/dim]"
+ )
+
+
+# ================================================
+# Rendering helpers
+# ================================================
+
+
+def banner() -> None:
+ console.print(
+ Panel(
+ Text("Djelia SDK Test Suite", justify="center", style="bold yellow"),
+ subtitle="[dim]OpenAI-style API · translation · transcription · TTS[/dim]",
+ border_style="cyan",
+ box=ROUNDED,
+ )
+ )
+
+
+def render_summary(reporter: Reporter) -> None:
+ results = reporter.results
+ if not results:
+ console.print("[dim]No tests were run.[/dim]")
+ return
+
+ table = Table(
+ title="[bold]Test Summary[/bold]",
+ header_style="bold",
+ border_style="dim",
+ box=ROUNDED,
+ expand=True,
+ )
+ table.add_column("Test")
+ table.add_column("Group", style="dim")
+ table.add_column("Ops", justify="right")
+ table.add_column("Status", justify="center")
+ table.add_column("Time", justify="right")
+ table.add_column("Details")
+
+ for r in results:
+ style = STATUS_STYLE.get(r.status, "white")
+ icon = STATUS_ICON.get(r.status, "?")
+ table.add_row(
+ r.name,
+ r.group,
+ str(len(r.events)) if r.events else "—",
+ f"[{style}]{icon} {r.status}[/]",
+ f"{r.seconds:.2f}s",
+ r.detail or "—",
+ )
+
+ console.print()
+ console.print(table)
+
+ passed = sum(1 for r in results if r.status == "pass")
+ failed = sum(1 for r in results if r.status == "fail")
+ skipped = sum(1 for r in results if r.status == "skip")
+ total_time = sum(r.seconds for r in results)
+ total_ops = sum(len(r.events) for r in results)
+
+ summary = (
+ f"[green]{passed} passed[/]"
+ + (f" · [red]{failed} failed[/]" if failed else "")
+ + (f" · [yellow]{skipped} skipped[/]" if skipped else "")
+ + f" [dim]· {total_ops} API calls in {total_time:.2f}s[/dim]"
+ )
+ console.print(
+ Panel(
+ summary,
+ border_style="green" if not failed else "red",
+ box=ROUNDED,
+ )
+ )
diff --git a/djelia/models/models.py b/djelia/models/models.py
index 8bfde9b..52f142e 100644
--- a/djelia/models/models.py
+++ b/djelia/models/models.py
@@ -22,6 +22,40 @@ def latest(cls):
def all_versions(cls):
return list(cls)
+ @classmethod
+ def from_value(cls, value: "Versions | int | str") -> "Versions":
+ """Normalize a model/version identifier (enum, int, or str like "v2") to a Versions."""
+ if isinstance(value, cls):
+ return value
+ if isinstance(value, bool):
+ raise ValueError(
+ ErrorsMessage.invalid_version.format(
+ value, [str(v) for v in cls]
+ )
+ )
+ if isinstance(value, int):
+ candidate = value
+ elif isinstance(value, str):
+ normalized = value.strip().lower().lstrip("v")
+ if not normalized.isdigit():
+ raise ValueError(
+ ErrorsMessage.invalid_version.format(
+ value, [str(v) for v in cls]
+ )
+ )
+ candidate = int(normalized)
+ else:
+ raise ValueError(
+ ErrorsMessage.invalid_version.format(value, [str(v) for v in cls])
+ )
+
+ try:
+ return cls(candidate)
+ except ValueError:
+ raise ValueError(
+ ErrorsMessage.invalid_version.format(value, [str(v) for v in cls])
+ )
+
def __str__(self):
return f"v{self.value}"
@@ -113,4 +147,8 @@ class ErrorsMessage:
)
tts_v1_request_error: str = "TTSRequest required for V1"
tts_v2_request_error: str = "TTSRequestV2 required for V2"
+ tts_v2_description_required: str = (
+ "A 'description' naming a supported speaker is required for TTS V2"
+ )
tts_streaming_compatibility: str = "Streaming is only available for TTS V2"
+ invalid_version: str = "Invalid model/version {!r}, expected one of {}"
diff --git a/djelia/src/client/client.py b/djelia/src/client/client.py
index f044cd2..0c55c7d 100644
--- a/djelia/src/client/client.py
+++ b/djelia/src/client/client.py
@@ -7,28 +7,41 @@
from djelia.config.settings import Settings
from djelia.src.auth import Auth
-from djelia.src.services import (TTS, AsyncTranscription, AsyncTranslation,
- AsyncTTS, Transcription, Translation)
+from djelia.src.services import (TTS, AsyncAudio, AsyncTranscription,
+ AsyncTranslation, AsyncTranslations, AsyncTTS,
+ Audio, Transcription, Translation,
+ Translations)
from djelia.utils.errors import api_exception, general_exception
+def _build_settings(
+ api_key: Union[str, None], base_url: Union[str, None]
+) -> Settings:
+ """Resolve settings, letting explicit args override environment variables.
+
+ Fields use validation aliases, so init overrides must be passed by alias.
+ """
+ overrides = {}
+ if api_key is not None:
+ overrides["DJELIA_API_KEY"] = api_key
+ if base_url is not None:
+ overrides["BASE_URL"] = base_url
+ return Settings(**overrides)
+
+
class Djelia:
def __init__(
self, api_key: Union[str, None] = None, base_url: Union[str, None] = None
):
- self.settings = None
- if base_url is None:
- self.settings = Settings()
- self.base_url = self.settings.base_url
- else:
- self.base_url = base_url
-
- if api_key is None:
- self.settings = Settings()
- self.auth = Auth(self.settings.djelia_api_key)
- else:
- self.auth = Auth(api_key=api_key)
+ self.settings = _build_settings(api_key, base_url)
+ self.base_url = self.settings.base_url
+ self.auth = Auth(self.settings.djelia_api_key)
+ # OpenAI-style resources
+ self.translations = Translations(self)
+ self.audio = Audio(self)
+
+ # Deprecated aliases (kept for backward compatibility)
self.translation = Translation(self)
self.transcription = Transcription(self)
self.tts = TTS(self)
@@ -61,19 +74,15 @@ class DjeliaAsync:
def __init__(
self, api_key: Union[str, None] = None, base_url: Union[str, None] = None
):
- self.settings = None
- if base_url is None:
- self.settings = Settings()
- self.base_url = self.settings.base_url
- else:
- self.base_url = self.base_url
-
- if api_key is None:
- self.settings = Settings()
- self.auth = Auth(self.settings.djelia_api_key)
- else:
- self.auth = Auth(api_key=api_key)
+ self.settings = _build_settings(api_key, base_url)
+ self.base_url = self.settings.base_url
+ self.auth = Auth(self.settings.djelia_api_key)
+
+ # OpenAI-style resources
+ self.translations = AsyncTranslations(self)
+ self.audio = AsyncAudio(self)
+ # Deprecated aliases (kept for backward compatibility)
self.translation = AsyncTranslation(self)
self.transcription = AsyncTranscription(self)
self.tts = AsyncTTS(self)
diff --git a/djelia/src/services/__init__.py b/djelia/src/services/__init__.py
index e37ffd1..60200ce 100644
--- a/djelia/src/services/__init__.py
+++ b/djelia/src/services/__init__.py
@@ -1,8 +1,21 @@
-from .transcription import AsyncTranscription, Transcription
-from .translation import AsyncTranslation, Translation
-from .tts import TTS, AsyncTTS
+from .audio import AsyncAudio, Audio
+from .transcription import (AsyncTranscription, AsyncTranscriptions,
+ Transcription, Transcriptions)
+from .translation import (AsyncTranslation, AsyncTranslations, Translation,
+ Translations)
+from .tts import TTS, AsyncSpeech, AsyncTTS, Speech
__all__ = [
+ # OpenAI-style resources
+ "Translations",
+ "AsyncTranslations",
+ "Audio",
+ "AsyncAudio",
+ "Transcriptions",
+ "AsyncTranscriptions",
+ "Speech",
+ "AsyncSpeech",
+ # Deprecated aliases
"Transcription",
"AsyncTranscription",
"Translation",
diff --git a/djelia/src/services/audio.py b/djelia/src/services/audio.py
new file mode 100644
index 0000000..bd7af16
--- /dev/null
+++ b/djelia/src/services/audio.py
@@ -0,0 +1,21 @@
+from djelia.src.services.transcription import (AsyncTranscriptions,
+ Transcriptions)
+from djelia.src.services.tts import AsyncSpeech, Speech
+
+
+class Audio:
+ """OpenAI-style audio namespace: ``client.audio.transcriptions`` and
+ ``client.audio.speech``."""
+
+ def __init__(self, client):
+ self.transcriptions = Transcriptions(client)
+ self.speech = Speech(client)
+
+
+class AsyncAudio:
+ """OpenAI-style async audio namespace: ``client.audio.transcriptions`` and
+ ``client.audio.speech``."""
+
+ def __init__(self, client):
+ self.transcriptions = AsyncTranscriptions(client)
+ self.speech = AsyncSpeech(client)
diff --git a/djelia/src/services/transcription.py b/djelia/src/services/transcription.py
index df83210..9b26519 100644
--- a/djelia/src/services/transcription.py
+++ b/djelia/src/services/transcription.py
@@ -1,5 +1,6 @@
import json
import os
+import warnings
from collections.abc import AsyncGenerator, Generator
from typing import BinaryIO
@@ -10,33 +11,29 @@
TranscriptionSegment, Versions)
-class Transcription:
+class Transcriptions:
+ """OpenAI-style transcription resource: ``client.audio.transcriptions``."""
+
def __init__(self, client):
self.client = client
- def transcribe(
+ def create(
self,
- audio_file: str | BinaryIO,
- translate_to_french: bool | None = False,
- stream: bool | None = False,
- version: Versions | None = Versions.v2,
+ *,
+ file: str | BinaryIO,
+ model: Versions | int | str = Versions.v2,
+ translate_to_french: bool = False,
+ stream: bool = False,
) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | Generator:
- if not stream:
- try:
- params = {Params.translate_to_french: str(translate_to_french).lower()}
- if isinstance(audio_file, str):
- with open(audio_file, "rb") as f:
- files = {Params.file: f}
- response = self.client._make_request(
- method=DjeliaRequest.transcribe.method,
- endpoint=DjeliaRequest.transcribe.endpoint.format(
- version.value
- ),
- files=files,
- params=params,
- )
- else:
- files = {Params.file: audio_file}
+ version = Versions.from_value(model)
+ if stream:
+ return self._stream(file, translate_to_french, version)
+
+ try:
+ params = {Params.translate_to_french: str(translate_to_french).lower()}
+ if isinstance(file, str):
+ with open(file, "rb") as f:
+ files = {Params.file: f}
response = self.client._make_request(
method=DjeliaRequest.transcribe.method,
endpoint=DjeliaRequest.transcribe.endpoint.format(
@@ -45,48 +42,53 @@ def transcribe(
files=files,
params=params,
)
+ else:
+ files = {Params.file: file}
+ response = self.client._make_request(
+ method=DjeliaRequest.transcribe.method,
+ endpoint=DjeliaRequest.transcribe.endpoint.format(version.value),
+ files=files,
+ params=params,
+ )
+ except OSError as e:
+ raise OSError(ErrorsMessage.ioerror_read.format(str(e)))
- except OSError as e:
- raise OSError(ErrorsMessage.ioerror_read.format(str(e)))
-
- data = response.json()
- return (
- FrenchTranscriptionResponse(**data)
- if translate_to_french
- else [TranscriptionSegment(**segment) for segment in data]
- )
-
- else:
- return self._stream_transcribe(audio_file, translate_to_french, version)
+ data = response.json()
+ return (
+ FrenchTranscriptionResponse(**data)
+ if translate_to_french
+ else [TranscriptionSegment(**segment) for segment in data]
+ )
- def _stream_transcribe(
+ def _stream(
self,
- audio_file: str | BinaryIO,
- translate_to_french: bool = False,
- version: Versions | None = Versions.v2,
+ file: str | BinaryIO,
+ translate_to_french: bool,
+ version: Versions,
) -> Generator[TranscriptionSegment | FrenchTranscriptionResponse, None, None]:
try:
params = {Params.translate_to_french: str(translate_to_french).lower()}
- if isinstance(audio_file, str):
- with open(audio_file, "rb") as f:
+ if isinstance(file, str):
+ with open(file, "rb") as f:
files = {Params.file: f}
response = self.client._make_request(
- method=DjeliaRequest.transcribe.method,
- endpoint=DjeliaRequest.transcribe.endpoint.format(
+ method=DjeliaRequest.transcribe_stream.method,
+ endpoint=DjeliaRequest.transcribe_stream.endpoint.format(
version.value
),
files=files,
params=params,
)
else:
- files = {Params.file: audio_file}
+ files = {Params.file: file}
response = self.client._make_request(
method=DjeliaRequest.transcribe_stream.method,
- endpoint=DjeliaRequest.transcribe.endpoint.format(version.value),
+ endpoint=DjeliaRequest.transcribe_stream.endpoint.format(
+ version.value
+ ),
files=files,
params=params,
)
-
except OSError as e:
raise OSError(ErrorsMessage.ioerror_read.format(str(e)))
@@ -111,65 +113,65 @@ def _stream_transcribe(
continue
-class AsyncTranscription:
+class AsyncTranscriptions:
+ """OpenAI-style async transcription resource: ``client.audio.transcriptions``."""
+
def __init__(self, client):
self.client = client
- async def transcribe(
+ async def create(
self,
- audio_file: str | BinaryIO,
- translate_to_french: bool | None = False,
- stream: bool | None = False,
- version: Versions | None = Versions.v2,
+ *,
+ file: str | BinaryIO,
+ model: Versions | int | str = Versions.v2,
+ translate_to_french: bool = False,
+ stream: bool = False,
) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | AsyncGenerator:
- if not stream:
- try:
- data = aiohttp.FormData()
- if isinstance(audio_file, str):
- with open(audio_file, "rb") as f:
- data.add_field(
- Params.file, f.read(), filename=os.path.basename(audio_file)
- )
- else:
+ version = Versions.from_value(model)
+ if stream:
+ return self._stream(file, translate_to_french, version)
+
+ try:
+ data = aiohttp.FormData()
+ if isinstance(file, str):
+ with open(file, "rb") as f:
data.add_field(
- Params.file, audio_file.read(), filename=Params.filename
+ Params.file, f.read(), filename=os.path.basename(file)
)
+ else:
+ data.add_field(Params.file, file.read(), filename=Params.filename)
- params = {Params.translate_to_french: str(translate_to_french).lower()}
- response_data = await self.client._make_request(
- method=DjeliaRequest.transcribe.method,
- endpoint=DjeliaRequest.transcribe.endpoint.format(version.value),
- data=data,
- params=params,
- )
-
- except OSError as e:
- raise OSError(ErrorsMessage.ioerror_read.format(str(e)))
-
- return (
- FrenchTranscriptionResponse(**response_data)
- if translate_to_french
- else [TranscriptionSegment(**segment) for segment in response_data]
+ params = {Params.translate_to_french: str(translate_to_french).lower()}
+ response_data = await self.client._make_request(
+ method=DjeliaRequest.transcribe.method,
+ endpoint=DjeliaRequest.transcribe.endpoint.format(version.value),
+ data=data,
+ params=params,
)
+ except OSError as e:
+ raise OSError(ErrorsMessage.ioerror_read.format(str(e)))
- else:
- return self._stream_transcribe(audio_file, translate_to_french, version)
+ return (
+ FrenchTranscriptionResponse(**response_data)
+ if translate_to_french
+ else [TranscriptionSegment(**segment) for segment in response_data]
+ )
- async def _stream_transcribe(
+ async def _stream(
self,
- audio_file: str | BinaryIO,
- translate_to_french: bool = False,
- version: Versions | None = Versions.v2,
+ file: str | BinaryIO,
+ translate_to_french: bool,
+ version: Versions,
) -> AsyncGenerator[TranscriptionSegment | FrenchTranscriptionResponse, None]:
try:
data = aiohttp.FormData()
- if isinstance(audio_file, str):
- with open(audio_file, "rb") as f:
+ if isinstance(file, str):
+ with open(file, "rb") as f:
data.add_field(
- Params.file, f.read(), filename=os.path.basename(audio_file)
+ Params.file, f.read(), filename=os.path.basename(file)
)
else:
- data.add_field(Params.file, audio_file.read(), filename=Params.filename)
+ data.add_field(Params.file, file.read(), filename=Params.filename)
params = {Params.translate_to_french: str(translate_to_french).lower()}
response = await self.client._make_streaming_request(
@@ -189,7 +191,6 @@ async def _stream_transcribe(
line_str = line.decode("utf-8").strip()
if line_str:
segment_data = json.loads(line_str)
-
if isinstance(segment_data, list):
for segment in segment_data:
yield (
@@ -210,7 +211,6 @@ async def _stream_transcribe(
response_text = await response.text()
if response_text.strip():
segment_data = json.loads(response_text)
-
if isinstance(segment_data, list):
for segment in segment_data:
yield (
@@ -226,7 +226,6 @@ async def _stream_transcribe(
)
except Exception:
pass
-
except Exception as e:
raise e
finally:
@@ -236,3 +235,57 @@ async def _stream_transcribe(
except Exception:
# there is a know issue here due to the server response
pass
+
+
+class Transcription:
+ """Deprecated. Use ``client.audio.transcriptions`` instead."""
+
+ def __init__(self, client):
+ self.client = client
+
+ def transcribe(
+ self,
+ audio_file: str | BinaryIO,
+ translate_to_french: bool | None = False,
+ stream: bool | None = False,
+ version: Versions | None = Versions.v2,
+ ) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | Generator:
+ warnings.warn(
+ "Transcription.transcribe() is deprecated; "
+ "use client.audio.transcriptions.create(file=...) instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return self.client.audio.transcriptions.create(
+ file=audio_file,
+ model=version,
+ translate_to_french=bool(translate_to_french),
+ stream=bool(stream),
+ )
+
+
+class AsyncTranscription:
+ """Deprecated. Use ``client.audio.transcriptions`` instead."""
+
+ def __init__(self, client):
+ self.client = client
+
+ async def transcribe(
+ self,
+ audio_file: str | BinaryIO,
+ translate_to_french: bool | None = False,
+ stream: bool | None = False,
+ version: Versions | None = Versions.v2,
+ ) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | AsyncGenerator:
+ warnings.warn(
+ "AsyncTranscription.transcribe() is deprecated; "
+ "use client.audio.transcriptions.create(file=...) instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return await self.client.audio.transcriptions.create(
+ file=audio_file,
+ model=version,
+ translate_to_french=bool(translate_to_french),
+ stream=bool(stream),
+ )
diff --git a/djelia/src/services/translation.py b/djelia/src/services/translation.py
index 5b0a294..12b1a68 100644
--- a/djelia/src/services/translation.py
+++ b/djelia/src/services/translation.py
@@ -1,12 +1,16 @@
-from djelia.models import (DjeliaRequest, SupportedLanguageSchema,
+import warnings
+
+from djelia.models import (DjeliaRequest, Language, SupportedLanguageSchema,
TranslationRequest, TranslationResponse, Versions)
-class Translation:
+class Translations:
+ """OpenAI-style translation resource: ``client.translations``."""
+
def __init__(self, client):
self.client = client
- def get_supported_languages(self) -> list[SupportedLanguageSchema]:
+ def list_languages(self) -> list[SupportedLanguageSchema]:
response = self.client._make_request(
method=DjeliaRequest.get_supported_languages.method,
endpoint=DjeliaRequest.get_supported_languages.endpoint.format(
@@ -15,25 +19,31 @@ def get_supported_languages(self) -> list[SupportedLanguageSchema]:
)
return [SupportedLanguageSchema(**lang) for lang in response.json()]
- def translate(
+ def create(
self,
- request: TranslationRequest,
- version: Versions | None = Versions.v1.value,
+ *,
+ text: str,
+ source: Language | str,
+ target: Language | str,
+ model: Versions | int | str = Versions.v1,
) -> TranslationResponse:
- data = request.dict()
+ version = Versions.from_value(model)
+ request = TranslationRequest(text=text, source=source, target=target)
response = self.client._make_request(
method=DjeliaRequest.translate.method,
endpoint=DjeliaRequest.translate.endpoint.format(version.value),
- json=data,
+ json=request.dict(),
)
return TranslationResponse(**response.json())
-class AsyncTranslation:
+class AsyncTranslations:
+ """OpenAI-style async translation resource: ``client.translations``."""
+
def __init__(self, client):
self.client = client
- async def get_supported_languages(self) -> list[SupportedLanguageSchema]:
+ async def list_languages(self) -> list[SupportedLanguageSchema]:
data = await self.client._make_request(
method=DjeliaRequest.get_supported_languages.method,
endpoint=DjeliaRequest.get_supported_languages.endpoint.format(
@@ -42,13 +52,87 @@ async def get_supported_languages(self) -> list[SupportedLanguageSchema]:
)
return [SupportedLanguageSchema(**lang) for lang in data]
- async def translate(
- self, request: TranslationRequest, version: Versions | None = Versions.v1
+ async def create(
+ self,
+ *,
+ text: str,
+ source: Language | str,
+ target: Language | str,
+ model: Versions | int | str = Versions.v1,
) -> TranslationResponse:
- request_data = request.dict()
+ version = Versions.from_value(model)
+ request = TranslationRequest(text=text, source=source, target=target)
data = await self.client._make_request(
method=DjeliaRequest.translate.method,
endpoint=DjeliaRequest.translate.endpoint.format(version.value),
- json=request_data,
+ json=request.dict(),
)
return TranslationResponse(**data)
+
+
+class Translation:
+ """Deprecated. Use ``client.translations`` instead."""
+
+ def __init__(self, client):
+ self.client = client
+
+ def get_supported_languages(self) -> list[SupportedLanguageSchema]:
+ warnings.warn(
+ "Translation.get_supported_languages() is deprecated; "
+ "use client.translations.list_languages() instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return self.client.translations.list_languages()
+
+ def translate(
+ self,
+ request: TranslationRequest,
+ version: Versions | None = Versions.v1,
+ ) -> TranslationResponse:
+ warnings.warn(
+ "Translation.translate() is deprecated; "
+ "use client.translations.create(text=..., source=..., target=...) instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return self.client.translations.create(
+ text=request.text,
+ source=request.source,
+ target=request.target,
+ model=version,
+ )
+
+
+class AsyncTranslation:
+ """Deprecated. Use ``client.translations`` instead."""
+
+ def __init__(self, client):
+ self.client = client
+
+ async def get_supported_languages(self) -> list[SupportedLanguageSchema]:
+ warnings.warn(
+ "AsyncTranslation.get_supported_languages() is deprecated; "
+ "use client.translations.list_languages() instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return await self.client.translations.list_languages()
+
+ async def translate(
+ self,
+ request: TranslationRequest,
+ version: Versions | None = Versions.v1,
+ ) -> TranslationResponse:
+ warnings.warn(
+ "AsyncTranslation.translate() is deprecated; "
+ "use client.translations.create(text=..., source=..., target=...) instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return await self.client.translations.create(
+ text=request.text,
+ source=request.source,
+ target=request.target,
+ model=version,
+ )
diff --git a/djelia/src/services/tts.py b/djelia/src/services/tts.py
index 03e9981..fbe4bc0 100644
--- a/djelia/src/services/tts.py
+++ b/djelia/src/services/tts.py
@@ -1,78 +1,99 @@
+import warnings
from collections.abc import AsyncGenerator, Generator
-# from djelia.config.settings import VALID_SPEAKER_IDS, VALID_TTS_V2_SPEAKERS
from djelia.models import (DjeliaRequest, ErrorsMessage, TTSRequest,
TTSRequestV2, Versions)
from djelia.utils.exceptions import SpeakerError
-class TTS:
+def _build_request(
+ client,
+ version: Versions,
+ input: str,
+ voice: int | None,
+ description: str | None,
+ chunk_size: float,
+) -> TTSRequest | TTSRequestV2:
+ """Build and validate the underlying TTS request for the given version."""
+ if version == Versions.v1:
+ if voice is None:
+ voice = client.settings.default_speaker_id
+ request = TTSRequest(text=input, speaker=voice)
+ if request.speaker not in client.settings.valid_speaker_ids:
+ raise SpeakerError(
+ ErrorsMessage.speaker_id_error.format(
+ client.settings.valid_speaker_ids, request.speaker
+ )
+ )
+ return request
+
+ if description is None:
+ raise ValueError(ErrorsMessage.tts_v2_description_required)
+ request = TTSRequestV2(text=input, description=description, chunk_size=chunk_size)
+ speaker_found = any(
+ speaker.lower() in request.description.lower()
+ for speaker in client.settings.valid_tts_v2_speakers
+ )
+ if not speaker_found:
+ raise SpeakerError(
+ ErrorsMessage.speaker_description_error.format(
+ client.settings.valid_tts_v2_speakers
+ )
+ )
+ return request
+
+
+class Speech:
+ """OpenAI-style text-to-speech resource: ``client.audio.speech``."""
+
def __init__(self, client):
self.client = client
- def text_to_speech(
+ def create(
self,
- request: TTSRequest | TTSRequestV2,
+ *,
+ input: str,
+ voice: int | None = None,
+ description: str | None = None,
+ model: Versions | int | str = Versions.v1,
+ chunk_size: float = 1.0,
+ stream: bool = False,
output_file: str | None = None,
- stream: bool | None = False,
- version: Versions | None = Versions.v1,
) -> bytes | str | Generator:
- if version == Versions.v1:
- if not isinstance(request, TTSRequest):
- raise ValueError(ErrorsMessage.tts_v1_request_error)
- if request.speaker not in self.client.settings.valid_speaker_ids:
- raise SpeakerError(
- ErrorsMessage.speaker_id_error.format(
- self.client.settings.valid_speaker_ids, request.speaker
- )
- )
- else:
- if not isinstance(request, TTSRequestV2):
- raise ValueError(ErrorsMessage.tts_v2_request_error)
- speaker_found = any(
- speaker.lower() in request.description.lower()
- for speaker in self.client.settings.valid_tts_v2_speakers
- )
- if not speaker_found:
- raise SpeakerError(
- ErrorsMessage.speaker_description_error.format(
- self.client.settings.valid_tts_v2_speakers
- )
- )
-
- if not stream:
- data = request.dict()
- response = self.client._make_request(
- method=DjeliaRequest.tts.method,
- endpoint=DjeliaRequest.tts.endpoint.format(version.value),
- json=data,
- )
+ version = Versions.from_value(model)
+ request = _build_request(
+ self.client, version, input, voice, description, chunk_size
+ )
- if output_file:
- try:
- with open(output_file, "wb") as f:
- f.write(response.content)
- return output_file
- except OSError as e:
- raise OSError(ErrorsMessage.ioerror_save.format(str(e)))
- else:
- return response.content
- else:
+ if stream:
if version == Versions.v1:
- raise ValueError(ErrorsMessage.tts_streaming_conpatibility)
- return self._stream_text_to_speech(request, output_file, version)
+ raise ValueError(ErrorsMessage.tts_streaming_compatibility)
+ return self._stream(request, output_file, version)
- def _stream_text_to_speech(
+ response = self.client._make_request(
+ method=DjeliaRequest.tts.method,
+ endpoint=DjeliaRequest.tts.endpoint.format(version.value),
+ json=request.dict(),
+ )
+ if output_file:
+ try:
+ with open(output_file, "wb") as f:
+ f.write(response.content)
+ return output_file
+ except OSError as e:
+ raise OSError(ErrorsMessage.ioerror_save.format(str(e)))
+ return response.content
+
+ def _stream(
self,
request: TTSRequestV2,
- output_file: str | None = None,
- version: Versions | None = Versions.v2,
+ output_file: str | None,
+ version: Versions,
) -> Generator[bytes, None, None]:
- data = request.dict()
response = self.client._make_request(
method=DjeliaRequest.tts_stream.method,
endpoint=DjeliaRequest.tts_stream.endpoint.format(version.value),
- json=data,
+ json=request.dict(),
stream=True,
)
@@ -91,74 +112,57 @@ def _stream_text_to_speech(
raise OSError(ErrorsMessage.ioerror_save.format(str(e)))
-class AsyncTTS:
+class AsyncSpeech:
+ """OpenAI-style async text-to-speech resource: ``client.audio.speech``."""
+
def __init__(self, client):
self.client = client
- async def text_to_speech(
+ async def create(
self,
- request: TTSRequest | TTSRequestV2,
+ *,
+ input: str,
+ voice: int | None = None,
+ description: str | None = None,
+ model: Versions | int | str = Versions.v1,
+ chunk_size: float = 1.0,
+ stream: bool = False,
output_file: str | None = None,
- stream: bool | None = False,
- version: Versions | None = Versions.v1,
) -> bytes | str | AsyncGenerator:
- if version == Versions.v1:
- if not isinstance(request, TTSRequest):
- raise ValueError(ErrorsMessage.tts_v1_request_error)
- if request.speaker not in self.client.settings.valid_speaker_ids:
- raise SpeakerError(
- ErrorsMessage.speaker_id_error.format(
- self.client.settings.valid_speaker_ids, request.speaker
- )
- )
- else:
- if not isinstance(request, TTSRequestV2):
- raise ValueError(ErrorsMessage.tts_v2_request_error)
- speaker_found = any(
- speaker.lower() in request.description.lower()
- for speaker in self.client.settings.valid_tts_v2_speakers
- )
- if not speaker_found:
- raise SpeakerError(
- ErrorsMessage.speaker_description_error.format(
- self.client.settings.valid_tts_v2_speakers
- )
- )
-
- if not stream:
- request_data = request.dict()
- content = await self.client._make_request(
- method=DjeliaRequest.tts.method,
- endpoint=DjeliaRequest.tts.endpoint.format(version.value),
- json=request_data,
- )
+ version = Versions.from_value(model)
+ request = _build_request(
+ self.client, version, input, voice, description, chunk_size
+ )
- if output_file:
- try:
- with open(output_file, "wb") as f:
- f.write(content)
- return output_file
- except OSError as e:
- raise OSError(ErrorsMessage.ioerror_save.format(str(e)))
- else:
- return content
- else:
+ if stream:
if version == Versions.v1:
- raise ValueError(ErrorsMessage.tts_streaming_conpatibility)
- # FIXED: Remove 'await' here - async generators should not be awaited when returned
- return self._stream_text_to_speech(request, output_file, version)
+ raise ValueError(ErrorsMessage.tts_streaming_compatibility)
+ return self._stream(request, output_file, version)
+
+ content = await self.client._make_request(
+ method=DjeliaRequest.tts.method,
+ endpoint=DjeliaRequest.tts.endpoint.format(version.value),
+ json=request.dict(),
+ )
+ if output_file:
+ try:
+ with open(output_file, "wb") as f:
+ f.write(content)
+ return output_file
+ except OSError as e:
+ raise OSError(ErrorsMessage.ioerror_save.format(str(e)))
+ return content
- async def _stream_text_to_speech(
+ async def _stream(
self,
request: TTSRequestV2,
- output_file: str | None = None,
- version: Versions | None = Versions.v2,
+ output_file: str | None,
+ version: Versions,
) -> AsyncGenerator[bytes, None]:
- request_data = request.dict()
response = await self.client._make_streaming_request(
method=DjeliaRequest.tts_stream.method,
endpoint=DjeliaRequest.tts_stream.endpoint.format(version.value),
- json=request_data,
+ json=request.dict(),
)
audio_chunks = []
@@ -177,3 +181,87 @@ async def _stream_text_to_speech(
f.write(chunk)
except OSError as e:
raise OSError(ErrorsMessage.ioerror_save.format(str(e)))
+
+
+class TTS:
+ """Deprecated. Use ``client.audio.speech`` instead."""
+
+ def __init__(self, client):
+ self.client = client
+
+ def text_to_speech(
+ self,
+ request: TTSRequest | TTSRequestV2,
+ output_file: str | None = None,
+ stream: bool | None = False,
+ version: Versions | None = Versions.v1,
+ ) -> bytes | str | Generator:
+ warnings.warn(
+ "TTS.text_to_speech() is deprecated; "
+ "use client.audio.speech.create(input=...) instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ version = Versions.from_value(version)
+ if version == Versions.v1:
+ if not isinstance(request, TTSRequest):
+ raise ValueError(ErrorsMessage.tts_v1_request_error)
+ return self.client.audio.speech.create(
+ input=request.text,
+ voice=request.speaker,
+ model=version,
+ stream=bool(stream),
+ output_file=output_file,
+ )
+ if not isinstance(request, TTSRequestV2):
+ raise ValueError(ErrorsMessage.tts_v2_request_error)
+ return self.client.audio.speech.create(
+ input=request.text,
+ description=request.description,
+ chunk_size=request.chunk_size,
+ model=version,
+ stream=bool(stream),
+ output_file=output_file,
+ )
+
+
+class AsyncTTS:
+ """Deprecated. Use ``client.audio.speech`` instead."""
+
+ def __init__(self, client):
+ self.client = client
+
+ async def text_to_speech(
+ self,
+ request: TTSRequest | TTSRequestV2,
+ output_file: str | None = None,
+ stream: bool | None = False,
+ version: Versions | None = Versions.v1,
+ ) -> bytes | str | AsyncGenerator:
+ warnings.warn(
+ "AsyncTTS.text_to_speech() is deprecated; "
+ "use client.audio.speech.create(input=...) instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ version = Versions.from_value(version)
+ if version == Versions.v1:
+ if not isinstance(request, TTSRequest):
+ raise ValueError(ErrorsMessage.tts_v1_request_error)
+ return await self.client.audio.speech.create(
+ input=request.text,
+ voice=request.speaker,
+ model=version,
+ stream=bool(stream),
+ output_file=output_file,
+ )
+ if not isinstance(request, TTSRequestV2):
+ raise ValueError(ErrorsMessage.tts_v2_request_error)
+ return await self.client.audio.speech.create(
+ input=request.text,
+ description=request.description,
+ chunk_size=request.chunk_size,
+ model=version,
+ stream=bool(stream),
+ output_file=output_file,
+ )
diff --git a/djelia/utils/errors.py b/djelia/utils/errors.py
index 7ede6c7..a258edc 100644
--- a/djelia/utils/errors.py
+++ b/djelia/utils/errors.py
@@ -26,9 +26,13 @@ class CodeStatusExceptions:
def api_exception(code: int, error: Exception) -> Exception:
- return CodeStatusExceptions.exceptions.get(code, APIError)(
- ExceptionMessage.messages.get(code, ExceptionMessage.default.format(str(error)))
+ message = ExceptionMessage.messages.get(
+ code, ExceptionMessage.default.format(str(error))
)
+ exception_cls = CodeStatusExceptions.exceptions.get(code, APIError)
+ if issubclass(exception_cls, APIError):
+ return exception_cls(status_code=code, message=message)
+ return exception_cls(message)
def general_exception(error: Exception) -> Exception:
diff --git a/docs/api.md b/docs/api.md
index 1e85fd1..6416b76 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -10,12 +10,14 @@ The Djelia Python SDK interacts with the Djelia API at `https://djelia.cloud/api
| Endpoint | Method | Description | SDK Method |
|----------|--------|-------------|------------|
-| `/api/v{version}/models/translate/supported-languages` | GET | List supported languages | `translation.get_supported_languages()` |
-| `/api/v{version}/models/translate` | POST | Translate text | `translation.translate()` |
-| `/api/v{version}/models/transcribe` | POST | Transcribe audio | `transcription.transcribe(stream=False)` |
-| `/api/v{version}/models/transcribe/stream` | POST | Stream transcription | `transcription.transcribe(stream=True)` |
-| `/api/v{version}/models/tts` | POST | Generate TTS audio | `tts.text_to_speech(stream=False)` |
-| `/api/v{version}/models/tts/stream` | POST | Stream TTS audio | `tts.text_to_speech(stream=True)` |
+| `/api/v{version}/models/translate/supported-languages` | GET | List supported languages | `translations.list_languages()` |
+| `/api/v{version}/models/translate` | POST | Translate text | `translations.create()` |
+| `/api/v{version}/models/transcribe` | POST | Transcribe audio | `audio.transcriptions.create(stream=False)` |
+| `/api/v{version}/models/transcribe/stream` | POST | Stream transcription | `audio.transcriptions.create(stream=True)` |
+| `/api/v{version}/models/tts` | POST | Generate TTS audio | `audio.speech.create(stream=False)` |
+| `/api/v{version}/models/tts/stream` | POST | Stream TTS audio | `audio.speech.create(stream=True)` |
+
+> The pre-`2.0` methods (`translation.translate()`, `transcription.transcribe()`, `tts.text_to_speech()`) still work but are deprecated and emit a `DeprecationWarning`.
### Rate Limits
- Contact [support@djelia.cloud](mailto:support@djelia.cloud) for rate limit details.
diff --git a/setup.py b/setup.py
index e15ec21..24e1918 100644
--- a/setup.py
+++ b/setup.py
@@ -8,9 +8,9 @@
setup(
name="djelia",
- version="1.1.1",
+ version="2.1.1",
author="Djelia",
- author_email="support@djelia.cloud",
+ author_email="sudoping01@gmail.com",
description="Djelia Python SDK - Advanced AI for African Languages",
long_description=long_description,
long_description_content_type="text/markdown",
@@ -67,6 +67,11 @@
"pytest-asyncio>=0.18.0",
"python-dotenv>=0.19.0",
],
+ "cookbook": [
+ "typer>=0.12.0",
+ "rich>=13.0.0",
+ "python-dotenv>=0.19.0",
+ ],
},
keywords=[
"djelia",