Metadata-Version: 2.5
Name: addisai
Version: 0.10.0
Summary: Official Addis AI SDK for Python — voice (TTS), realtime voice streaming, chat, speech-to-text, and translation.
Project-URL: Homepage, https://addisassistant.com
Project-URL: Repository, https://github.com/Addis-AI-Org/addisai-py
Project-URL: Issues, https://github.com/Addis-AI-Org/addisai-py/issues
Author: Addis AI
License-Expression: MIT
License-File: LICENSE
Keywords: addis,addisai,afan-oromo,ai,amharic,llm,speech-to-text,text-to-speech,translation,tts,voice
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.8
Requires-Dist: httpx<1,>=0.24
Provides-Extra: dev
Requires-Dist: mypy>=1.6; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Provides-Extra: realtime
Requires-Dist: websocket-client<2,>=1.8; extra == 'realtime'
Description-Content-Type: text/markdown

# addisai (Python)

The official [Addis AI](https://addisassistant.com) SDK for Python — voice (text‑to‑speech), chat/LLM with system prompts, personas and function calling, speech‑to‑text, and translation, with examples for **Amharic (`am`)**, **Afaan Oromo (`om`)**, and **Tigrinya (`ti`)**.

Mirrors the [Node.js SDK](https://github.com/Addis-AI-Org/addisai-js) surface, Pythonic and `snake_case`.

```bash
pip install addisai
```

Requires Python 3.8+.

## Quickstart

```python
from addisai import AddisAI

addis = AddisAI()  # reads ADDIS_API_KEY from the environment

# Text-to-speech
clip = addis.voice.generate(voice_id="am-hamen", text="ሰላም ለዓለም።", language="am")
clip.to_file("welcome.mp3")

# Chat
res = addis.chat.completions.create(
    language="am",
    system="Answer in concise bullet points.",
    messages=[{"role": "user", "content": "ስለ አዲስ አበባ ንገረኝ"}],
)
print(res["choices"][0]["message"]["content"])
```

## Configuration

```python
addis = AddisAI(
    api_key="...",        # or set ADDIS_API_KEY
    timeout=60.0,          # seconds (voice.generate raises this to >= 95s)
    max_retries=2,         # backoff on 408/409/425/429/5xx
)
```

The key is read from `api_key=` or `ADDIS_API_KEY`, never logged, and redacted in `repr()`. The SDK only talks to the Addis AI API (`https://api.addisassistant.com`) and refuses raw backend hosts.

## Languages

| Language | Code | Example voice | Voice ID |
| --- | --- | --- | --- |
| Amharic | `am` | Hamen | `am-hamen` |
| Afaan Oromo | `om` | Bikila | `om-bikila` |
| Tigrinya | `ti` | Berhane | `ti-berhane` |

Voice generation and real-time sessions support these three languages. Other
language codes are rejected locally before any API request or socket connection.
Use a matching language and voice ID. `voices.list` returns this three-language
catalog so you can choose another available voice.

Tigrinya ships seven native voices: `ti-berhane` (default), `ti-senait`, `ti-haben`,
`ti-rahel`, `ti-filimon`, `ti-danait` and `ti-yonas`. Tigrinya is also accepted by
chat (`language="ti"`), speech-to-text and translation below.

## Voice (text‑to‑speech)

```python
clip = addis.voice.generate(
    voice_id="am-hamen",
    text="ሰላም።",
    language="am",
    output_format="mp3_44100",                 # mp3_44100 | wav_44100 | pcm_16000
    voice_settings={"speed": 50, "stability": 50, "similarity": 50, "style": 0},
)
clip.audio_url
clip.usage          # {"credits_used": ..., "currency": "ETB", ...}
data = clip.content()   # bytes
clip.to_file("out.mp3")
```

Idempotency keys are generated automatically and reused across retries so a retry is never double‑billed. Pass `client_request_id=` for full control.

```python
addis.voices.list(language="am", gender="female")
addis.voice.estimate(voice_id="am-hamen", text="ሰላም", language="am")
addis.voice.usage()
for clip in addis.voice.clips.list(language="am"):  # auto-paginates
    print(clip.id)
addis.voice.clips.delete("clip_123")
```

### Real-time voice

```python
from uuid import uuid4

# Select a matching pair from the Languages table.
voice_id, language = "ti-berhane", "ti"
text = "ሰላም፣ እንቋዕ ናብ ኣዲስ ኤኣይ ብደሓን መጻእኩም።"

# HTTP streaming: no extra dependency needed.
audio = addis.voice.stream(
    voice_id=voice_id, language=language, text=text,
    client_request_id=str(uuid4()),
)
audio.to_file("speech.mp3")
print(audio.metadata["usage"])

# Persistent sockets: pip install "addisai[realtime]"
with addis.realtime.connect(voice_id=voice_id, language=language) as voice:
    with open("speech.mp3", "wb") as output:
        for chunk in voice.speak(text, str(uuid4())):
            output.write(chunk)
    print(voice.last_completion["usage"])
```

`realtime.create_session()` creates a scoped, one-use ticket valid for 60 seconds;
`connect_realtime(ticket)` opens its socket without a developer key. Keep keys on
your application server. Sessions last up to 10 minutes, allow one utterance at
a time, and carry a cumulative text budget of up to 5,000 characters. Cancellation
mutes delivery; an already-started synthesis completes and is billed.

Use `append()` and `commit()` to buffer a complete generated utterance before
synthesis. For early WAV pieces, select `audio_format="wav_mp3"`, iterate raw
`audio.delta` events and use `decode_realtime_audio(event)`; play each piece
according to its `format`. `speak()` yields concatenatable MP3 and requires an
`mp3` session. Use any matching voice and language pair from the Languages table.
HTTP streams support MP3 only. Keep request IDs stable when recovering a failed
request in a new session to avoid a second charge; use a fresh ID for each new
utterance.

See [examples/realtime.py](examples/realtime.py).

Run the same example with any of the documented languages:

```bash
ADDIS_VOICE_LANGUAGE=am ADDIS_VOICE_ID=am-hamen python examples/realtime.py "ሰላም፣ እንኳን ወደ አዲስ ኤአይ በደህና መጡ።"
ADDIS_VOICE_LANGUAGE=om ADDIS_VOICE_ID=om-bikila python examples/realtime.py "Nagaa, gara Addis AI baga nagaan dhuftan."
ADDIS_VOICE_LANGUAGE=ti ADDIS_VOICE_ID=ti-berhane python examples/realtime.py "ሰላም፣ እንቋዕ ናብ ኣዲስ ኤኣይ ብደሓን መጻእኩም።"
```

## Batches (half price, within 24 hours)

Send many text to speech requests at once. They run when demand is low, every
batch finishes within 24 hours, and only finished requests are billed, at half
the standard price.

```python
batch = client.batches.create_from_requests(
    [
        # Tigrinya text, e.g. "ሎሚ ጽቡቕ መዓልቲ እዩ።"
        {"custom_id": f"chapter-{i}", "text": text, "voice_id": "ti-berhane", "language": "ti"}
        for i, text in enumerate(chapters, start=1)
    ],
    metadata={"project": "audiobook"},
)

done = client.batches.wait_for(batch["id"])  # polls every 30 s
for result in client.batches.results(done["id"]):
    if result["status"] == "succeeded":
        audio = client.files.content(result["response"]["audio_file_id"])
        # save audio
    else:
        print(result["custom_id"], result["error"])
```

Batches support the same languages and voices as `voice.generate`: Amharic
(`am`), Afaan Oromo (`om`) and Tigrinya (`ti`, with any of its seven voices).
Each request's `voice_id` must match its `language`; other languages raise
`AddisAIError` before anything is uploaded.

Already have a JSONL file (`{"custom_id": "...", "body": {...}}` per line)? Use
`client.files.upload("batch.jsonl")` then `client.batches.create(input_file_id=...)`.
`client.batches.cancel(batch_id)` stops queued requests; running ones finish and
are billed.

## Chat / LLM

```python
res = addis.chat.completions.create(
    language="am",
    system="Be concise.",
    persona="You are RecipeBot by AcmeCorp.",
    messages=[{"role": "user", "content": "የእንጀራ አሰራር አስተምረኝ"}],
    temperature=0.7,
    max_tokens=1200,
)
```

> The model is selected by Addis AI; responses report the id `addis-1-alef`.

Chat accepts `am`, `om`, and `ti` for text, attachments, and audio input.

### Function calling

```python
def get_order_status(args):
    return {"status": "shipped", "eta": "tomorrow"}

final = addis.chat.run_tools(
    language="am",
    messages=[{"role": "user", "content": "Check order 123 and summarize it."}],
    tools=[{
        "type": "function",
        "function": {
            "name": "get_order_status",
            "description": "Fetch order status by order ID.",
            "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]},
            "callable": get_order_status,
        },
    }],
)
print(final["choices"][0]["message"]["content"])
```

### Streaming (beta)

`stream=True` returns a `ChatStream` — iterate for chat-completion chunk dicts, or use the accumulators:

```python
stream = addis.chat.completions.create(
    language="am", messages=[{"role": "user", "content": "Capital?"}], stream=True
)
for chunk in stream:
    print(chunk["choices"][0]["delta"].get("content", ""), end="", flush=True)

# or, instead of iterating:
text = addis.chat.completions.create(language="am", messages=[...], stream=True).final_text()
completion = addis.chat.completions.create(language="am", messages=[...], stream=True).final_completion()
```

Streaming is beta and not available with tools.

## Addis Scribe

Scribe transcribes **Amharic**. Choose `backend="standard"` (default) or
`backend="turbo"`; both bill the same character rate.
Save a request ID before sending audio; recover it after an interrupted response.

```python
from addisai import AddisAI, ulid

request_id = ulid()
with AddisAI() as addis:
    with open("speech.wav", "rb") as audio:
        result = addis.scribe.transcribe(audio=audio, backend="standard", request_id=request_id)
    print(result["text"], result["usage"]["credits_used"])
    # Recovery returns the original settled result without another charge:
    # recovered = addis.scribe.recover(request_id)
    with open("speech.wav", "rb") as audio:
        with addis.scribe.stream(audio=audio, request_id=ulid()) as stream:
            for event in stream:
                if event["type"] == "transcript.partial":
                    print(event["text"])
                elif event["type"] == "transcript.completed":
                    print(event["data"]["usage"])
```

### Word timestamps and captions

Pass `timestamps="word"` to `transcribe()` to receive `words` and caption-ready
`segments` (times in seconds from the start of the file). Timestamps cost nothing
extra. `addisai.to_srt()` and `addisai.to_vtt()` format the segments locally, without
another API call, wrapping each cue at 42 characters per line and at most 2 lines.

```python
from addisai import AddisAI, to_srt, to_vtt, ulid

with AddisAI() as addis:
    with open("speech.wav", "rb") as audio:
        result = addis.scribe.transcribe(audio=audio, timestamps="word", request_id=ulid())
print(result["words"][0])  # {'text': 'ሰላም', 'start': 18.9, 'end': 19.52}
with open("speech.srt", "w", encoding="utf-8") as f:
    f.write(to_srt(result))
with open("speech.vtt", "w", encoding="utf-8") as f:
    f.write(to_vtt(result))
# 1
# 00:00:18,800 --> 00:00:21,800
# ሰላም ወዳጆቻችን እንዴት ከረማችሁ ዛሬ እንግዲህ እንግዳ አድርጌ
# ያቀረኩላችሁ
```

Timestamps are available for completed uploads only: `stream()` rejects
`timestamps="word"` locally, and live sessions do not return timestamps.
`to_srt()`/`to_vtt()` raise `AddisAIError` if the result has no `segments`; they also
work on the dict returned by `recover()` when the original request used timestamps.

#### Speaker labels

Pass `speakers=True` with `backend="turbo"` to label who is talking. It turns on
word timestamps, costs nothing extra, and adds a `speaker` number to every word and
segment plus a `speakers` count on the result. Speakers are numbered 1, 2, … in the
order they first talk; a word that could not be attributed has `speaker: None`.
A new caption cue starts whenever the speaker changes. `to_srt()` starts each labelled
cue with `Speaker N: `, and `to_vtt()` marks it with a `<v Speaker N>` voice tag.

```python
with AddisAI() as addis:
    with open("interview.wav", "rb") as audio:
        result = addis.scribe.transcribe(audio=audio, backend="turbo", speakers=True,
                                         request_id=ulid())
print(result["speakers"])  # 2
with open("interview.srt", "w", encoding="utf-8") as f:
    f.write(to_srt(result))
# 1
# 00:00:00,600 --> 00:00:01,600
# Speaker 1: ሰላም ወዳጆቻችን
#
# 2
# 00:00:01,700 --> 00:00:03,000
# Speaker 2: እንዴት ናችሁ
```

Good to know:

- Speaker labels need `backend="turbo"`; other backends fail locally before any
  request. `stream()` and live sessions do not support them.
- On simulated Amharic conversations, 97.8% of words got the right speaker.
- Labels work best with 2 to 4 people and get less accurate when people talk over
  each other.
- Labels are "Speaker 1", "Speaker 2" and so on, not names.

### Long audio (up to three hours)

Files longer than one upload allows (up to **three hours**, 512 MB) go through a job:
`transcribe_long()` uploads the file, waits while the API cuts it at pauses, transcribes the
pieces and stitches them into one transcript with one speaker numbering and one bill, then
returns the settled result in the same shape as `transcribe()` (plus `chunks`, the number of
pieces). Jobs run on the turbo backend and return word timestamps by default. A file of at most
95 MiB goes up in one request; a larger one is sent as 4 MiB parts, resuming from the parts the
server already holds after a dropped connection. `on_progress` receives each poll's progress.

```python
from addisai import AddisAI, to_srt, ulid

request_id = ulid()
with AddisAI() as addis:
    result = addis.scribe.transcribe_long(
        audio="meeting.m4a", speakers=True, request_id=request_id,
        on_progress=lambda p: print(f"{p.get('processed_seconds', 0):.0f}/{p.get('audio_seconds') or '?'} s"))
print(result["speakers"], result["chunks"], result["usage"]["credits_used"])
with open("meeting.srt", "w", encoding="utf-8") as f:
    f.write(to_srt(result))
# Interrupted? The same request_id returns the result once it is done:
# result = addis.scribe.recover(request_id)
```

`addis.scribe.jobs` exposes the steps (`upload`, `create`, `put_part`, `complete`, `status`,
`wait`, `cancel`) for callers that upload from one process and wait in another. Paid steps are never
retried automatically; at most two jobs run per account at once (`429 BUSY`).

Ctrl-C (`KeyboardInterrupt`) during the upload cancels the job, so it does not hold one of those
two slots; an upload left idle for 30 minutes stops counting on its own. Once the job is queued,
an interrupt only stops the waiting. `addis.scribe.jobs.cancel(request_id)` stops a job yourself
(nothing is charged): an upload or queued job is removed and its request ID is free again; a
processing one ends with `REQUEST_CANCELLED`.

### Live audio

Install `pip install "addisai[realtime]"` for `addis.scribe.connect(request_id=...)`.
Read its event iterator while calling `send_audio(frame)` with **raw 16 kHz mono
PCM16 little-endian** (3,200 bytes per 100 ms), then call `finish()` and read the
settled completion. Send and receive concurrently for continuous microphone audio.
`completion` includes the final transcript and usage. `create_session` issues a
scoped one-use ticket; exported `connect_scribe(session)` opens that ticket's
socket without account credentials in its URL. `capabilities()` returns model
limits and `usage()` returns your wallet balance and current rate.

HTTP uploads accept 95 MiB and up to one hour of audio (speaker labels included); a live session takes up to 15 minutes;
`transcribe_long` takes up to three hours as a job.
`capabilities()` returns the current limits (`max_upload_bytes`, `max_upload_seconds`, `max_live_seconds`).
A long upload may answer "still processing"; `transcribe` then polls until the result is ready.
File streams emit partials after upload;
WebSockets accept audio incrementally. Both default to the turbo backend: HTTP with 1120ms chunks,
sockets with 320ms. Tickets expire after 60 seconds.
Only final transcript UTF-16 character units are billed; partials have no separate
charge. Paid requests/ticket creation are not automatically retried. Disconnecting
or closing a stream can still bill accepted audio. Recover settled results for 24
hours; pending settlement remains recoverable. New audio/settings need a new ID.

See the [Scribe guide](https://docs.addisassistant.com/docs/capabilities/speech-to-text)
for complete live PCM, recovery and billing examples.

## Speech‑to‑text & translation

```python
with open("call.wav", "rb") as f:
    t = addis.speech.transcribe(audio=f, language="am")  # am|om|ti|en|ha|sw
print(t["text"])

out = addis.translate.create(text="Hello", source="en", target="am")  # am|om|ti|en
print(out["text"])
```

Tigrinya translates to and from Amharic, Afaan Oromo and English.
Speech recognition uses `/api/v2/stt`; confidence may be `None` (always for Tigrinya).

## Legacy audio (deprecated)

```python
# ⚠️ deprecated — use addis.voice.generate
out = addis.legacy.audio.generate(text="ሰላም", language="am")
out.to_file("legacy.wav")

# streaming bridge — yields audio bytes, handles both legacy encodings
audio = addis.legacy.audio.stream(text="ሰላም", language="am")
audio.to_file("legacy.wav")        # or: data = audio.read()
```

## Errors

```python
from addisai import InsufficientCreditsError, RateLimitError, APIError

try:
    addis.voice.generate(voice_id="am-hamen", text="ሰላም", language="am")
except InsufficientCreditsError as e:
    show_top_up(e.available_balance)
except RateLimitError as e:
    sleep(e.retry_after or 1)
except APIError as e:
    print(e.status, e.code, e.details)
```

## Not yet in the Python SDK

An async client (`AsyncAddisAI`) is the one planned follow‑up. Everything else — including streaming — is at parity with the Node SDK.

## Live smoke test

`scripts/smoke.py` exercises the real API end-to-end. It is **not** a PR gate —
the `smoke` workflow runs on manual dispatch and a daily schedule, only where the
repo secret `ADDIS_SMOKE_API_KEY` (a low-balance sandbox key) is set. Run locally
with `ADDIS_API_KEY=<key> python scripts/smoke.py`.

## License

MIT
