Metadata-Version: 2.5
Name: routershift
Version: 0.3.4
Summary: Official Python SDK for the RouterShift API — one API for 100+ AI models
Project-URL: Homepage, https://routershift.com
Project-URL: Documentation, https://routershift.com/docs
Project-URL: Repository, https://github.com/routershift/routershift
Author-email: RouterShift <dev@routershift.com>
License: MIT
Keywords: ai,anthropic,api-gateway,llm,openai,routing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.9
Requires-Dist: httpx>=0.25.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest-asyncio; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Provides-Extra: openai
Requires-Dist: openai>=1.0.0; extra == 'openai'
Description-Content-Type: text/markdown

# RouterShift Python SDK

The official Python SDK for [RouterShift](https://routershift.com), an enterprise multi-protocol AI API gateway. Model availability and pricing are runtime data; call `client.models.list()` for the catalog available to your key.

## Installation

Install the 0.3.4 candidate wheel from RouterShift's website:

```bash
python -m pip install https://routershift.com/downloads/sdk/routershift-0.3.4-py3-none-any.whl
```

For OpenAI-compatible usage:

```bash
python -m pip install "routershift[openai] @ https://routershift.com/downloads/sdk/routershift-0.3.4-py3-none-any.whl"
```

Download checksums are listed in [SHA256SUMS.txt](https://routershift.com/downloads/sdk/SHA256SUMS.txt).
This website download does not mean version 0.3.4 has been published to PyPI.
Registry publication is a separate SDK release step.

## Quick Start

### Chat Completions (OpenAI-compatible)

```python
import os
from routershift import RouterShift

client = RouterShift(api_key=os.environ["ROUTERSHIFT_API_KEY"])

# Inspect the catalog, then choose a chat-capable model authorized for your key.
print([model.id for model in client.models.list()])
model = os.environ["ROUTERSHIFT_MODEL"]

response = client.chat.completions.create(
    model=model,
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Explain quantum computing in one paragraph."},
    ],
    max_tokens=200,
)

print(response.choices[0].message.content)
```

Set `ROUTERSHIFT_API_KEY` and `ROUTERSHIFT_MODEL` before running. The model
catalog may include non-chat models; do not automatically call its first entry.

### Streaming

```python
stream = client.chat.completions.create(
    model=model,
    messages=[{"role": "user", "content": "Tell me a story."}],
    stream=True,
)

for chunk in stream:
    if not chunk.choices:
        continue
    content = (chunk.choices[0].delta or {}).get("content")
    if content:
        print(content, end="")
```

### Runtime model catalog

```python
# List models authorized for this API key
models = client.models.list()
for m in models[:5]:
    print(f"{m.id} — {m.display_name}")
```

### OpenAI-style Chat Interface

```python
# Change this:
# from openai import OpenAI
# client = OpenAI()

# To this:
from routershift import RouterShift
client = RouterShift(api_key=os.environ["ROUTERSHIFT_API_KEY"])
```

The client uses an OpenAI-style chat interface, not every OpenAI SDK resource or
response type. In particular, streaming `delta` values are dictionaries and
`models.list()` returns a list. Use the standard OpenAI SDK with RouterShift's
`/v1` base URL when your application depends on its SDK-specific types.

The retained Anthropic-compatible `messages.create` helper is JSON-only in this
release. Passing `stream=True` fails before any paid request is sent; use
`chat.completions.create(..., stream=True)` for the supported HTTP SSE path.

## Text, HTTP Omni and audio (0.3.4 candidate)

### Multimodal chat over HTTP

`chat.completions.create` accepts text, content-part arrays and tool messages.
Native request parameters such as `tools`, `tool_choice`, `metadata`,
`max_completion_tokens`, `modalities`, `audio` and `stream_options` are forwarded
to the existing HTTP chat endpoint. The gateway still validates the model,
adapter, allowed input/output modes and metering contract.

```python
from routershift import RequestOptions

response = client.chat.completions.create(
    model=os.environ["ROUTERSHIFT_HTTP_OMNI_MODEL"],
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "Describe this image aloud."},
            {"type": "image_url", "image_url": {
                "url": os.environ["ROUTERSHIFT_INPUT_IMAGE_URL"],
            }},
        ],
    }],
    modalities=["text", "audio"],
    audio={"voice": os.environ["ROUTERSHIFT_AUDIO_VOICE"], "format": "wav"},
    request_options=RequestOptions(timeout=120, max_retries=0),
)
message = response.choices[0].message
if message is not None:
    print(message.content)
    if message.audio:
        print(message.audio.get("transcript"))
        # message.audio["data"] contains the returned base64 audio, when present.
```

Choose an accepted **HTTP** model whose input and output modalities match the
example. For a model that accepts inline audio, an input content part may be
`{"type": "input_audio", "input_audio": {"data": "BASE64_AUDIO", "format": "wav"}}`.
Image/audio fields do not imply that every model or cross-protocol adapter can
process them. An Omni model name alone does not establish HTTP audio support.

Responses preserve `content` as a string, content-part array or `None`, plus
`audio`, tool calls, reasoning fields and extra metadata. `message.audio` and
streaming `delta` are dictionaries. Usage details, including audio/text token
partitions, remain available through `response.usage.model_dump()`; the SDK does
not calculate their prices. SSE chunks retain audio deltas and the final usage
chunk. Structured JSON responses and SSE chunks also expose `_request_id` from
the gateway's `X-Request-ID` header while preserving any native `request_id`
field. The existing streaming interface is HTTP SSE, not a Realtime session.

The SSE reader supports standard event framing, multiline `data` fields and
heartbeat comments. It rejects malformed JSON, explicit error events and
incomplete EOFs instead of silently returning a partial success. EOF is accepted
without `[DONE]` only after every observed choice reports `finish_reason`.
`RequestOptions.max_response_bytes` bounds the streamed response when request
options are supplied.

### Speech generation and file transcription

```python
from pathlib import Path

speech = client.audio.speech(
    model=os.environ["ROUTERSHIFT_SPEECH_MODEL"],
    input="Hello from RouterShift.",
    voice=os.environ["ROUTERSHIFT_AUDIO_VOICE"],
    response_format="mp3",
    request_options=RequestOptions(timeout=120, max_response_bytes=16 * 1024 * 1024),
)
Path("speech.mp3").write_bytes(speech)

transcription = client.audio.transcribe(
    model=os.environ["ROUTERSHIFT_TRANSCRIPTION_MODEL"],
    file=Path("input.wav").read_bytes(),
    filename="input.wav",
    language="en",
    response_format="json",
    temperature=0,
    request_options=RequestOptions(timeout=120),
)
print(transcription.text)

url_transcription = client.audio.transcribe_url(
    model=os.environ["ROUTERSHIFT_URL_ASR_MODEL"],
    file_url="https://media.example/selected-recording.wav",
    audio_format="wav",
    response_format="json",
    request_options=RequestOptions(timeout=120),
)
print(url_transcription.text)
```

`audio.speech` sends `POST /v1/audio/speech` and returns the original binary
bytes. It does not return a media URL or require another generation to save the
result. Native speech JSON parameters can be provided as extra keyword arguments
where the Core adapter supports them. Legacy defaults remain `voice="alloy"`,
`response_format="mp3"` and `speed=1.0`; pass `None` to omit an individual default.
For example, native `audio_config.format`/`speech_rate` may require
`response_format=None, speed=None` to avoid conflicting format/speed controls.
Streaming speech is outside this bytes-returning method.

The local source candidate implements `audio.speech_with_metadata` for an independently
accepted native Seed Audio route, available synchronously and with `await` on
`RouterShiftAsync`. This is not proof that downloaded/published packages contain
the method or that a live product is certified. The ordinary `audio.speech`
method still returns binary `bytes`; it rejects `response_mode="json"` before
submission, so use the named method instead:

```python
generated = client.audio.speech_with_metadata(
    model=os.environ["ROUTERSHIFT_SEED_AUDIO_MODEL"],
    input="Say hello naturally.",
    response_format="mp3",
    audio_config={"enable_subtitle": True},
)
print(len(generated.data), generated.content_type)
print(generated.subtitle, generated.subtitles_omitted)
# Async: generated = await async_client.audio.speech_with_metadata(...)
```

The method sends one `response_mode="json"` request and returns
`AudioSpeechMetadataResponse` with decoded `data: bytes` and whitelisted optional
metadata. No generic voice is supplied by default. Native subtitle sentences
and nested words keep integer `start_time`/`end_time` in milliseconds,
`timestamp_unit="milliseconds"` and `timing_basis="native_audio"`; timestamps
are not rescaled. Missing or malformed optional metadata is omitted without
discarding valid audio. Optional `playback_duration` is presentation metadata,
not a bill: Core's existing Seed contract uses native `original_duration`, not
post-processed playback duration. That billable field is not returned here.
`_billing_status`, when present, is taken only from the actual
`X-Billing-Status` HTTP header; generated output does not prove settlement.
The existing bounded-read, cancellation and redirect controls apply, with no
automatic retry, fallback POST, download or file write.

This is not a native Bailian URL-TTS method. That separate local REST-only candidate
returns `audio_url` rather than embedded audio and requires a future dedicated
SDK URL surface. The metadata method does not download provider URLs.

`audio.transcribe` sends the actual file bytes using multipart
`POST /v1/audio/transcriptions`. The current Core contract forwards `language`,
`response_format` and `temperature`; it does not promise OpenAI parameters such
as `prompt` or `timestamp_granularities`. `json`/`verbose_json` return a Pydantic
`TranscriptionResponse` with `.text` and preserved extra fields; `text`, `srt`
and `vtt` return a string, when supported by the model. Format parsing does not
depend on a JSON Content-Type header.

Native `segments` and `words` remain preserved extra fields in JSON responses.
Their timestamps and media `duration` are model output, not financial usage or
proof of settlement; no missing timestamps are invented. `audio.speech` retains
its original bytes return contract.

`audio.transcribe_url` sends one JSON request to `POST /v1/transcriptions` with
only `model`, an absolute HTTPS `file_url`, optional `language` / `audio_format`,
and `response_format` (default `json`). The SDK validates but never downloads the
reference. Core and the selected route validate the remote audio and format;
embedded URL credentials and non-HTTPS references are rejected. Both synchronous
and asynchronous clients expose this method. Realtime WebSocket sessions remain
outside these SDK methods.

### Audio transport controls and cancellation

Speech, both transcription methods and embeddings are each submitted once,
including after a 429, 5xx, redirect or lost connection.
`RequestOptions(max_retries=...)` cannot enable retries for these paid
submissions or for image/video generation. Existing non-streaming text chat and
messages retain their configured retry behavior; use `max_retries=0` for a
single chat attempt, especially when requesting multimodal output. Streaming
chat is never automatically replayed. All these requests use the same API key
and existing Core authorization, wallet, metering and settlement.

Audio responses are read incrementally with a default **16 MiB** limit, including
responses without Content-Length. Set `max_response_bytes` to another positive
integer when needed. An oversized response stops the local read; it does not
undo work already accepted by the gateway. HTTP errors retain
`RouterShiftError.request_id` when Core supplies `X-Request-ID`. Keep that ID for
diagnosis instead of automatically submitting the paid request again.

The same audio and chat methods are available with `await` on
`RouterShiftAsync`. `RequestOptions(cancel_event=event)` accepts a
`threading.Event` or `asyncio.Event`; a pre-set event prevents dispatch.
Synchronous audio cancellation is checked between response chunks; a blocked
socket operation remains subject to the HTTP timeout. Asynchronous event
cancellation and ordinary Python `task.cancel()` stop in-flight HTTP work and
close its response. `RequestCancelledError` reports event cancellation. This is
local cancellation, not a provider cancellation or refund operation.

## Images and video (0.3.4 candidate)

These resources call the existing RouterShift data plane with the same API key,
model authorization, routing, metering and settlement as HTTP. The SDK does not
maintain prices, wallets, model lists or a task queue. Choose a model enabled for
the requested operation from your current catalog; an advertised model name or
an installed SDK does not establish native-mode acceptance.

Use the website's 0.3.4 wheel for these resources:

```bash
python -m pip install https://routershift.com/downloads/sdk/routershift-0.3.4-py3-none-any.whl
```

### Images: synchronous result or existing asynchronous task

```python
from routershift import ImageTaskResponse, RequestOptions

result = client.images.generate(
    model=os.environ["ROUTERSHIFT_IMAGE_MODEL"],
    prompt="A quiet riverside at sunrise",
    request_options=RequestOptions(timeout=120),
)
if isinstance(result, ImageTaskResponse):
    print("Save this task ID before waiting:", result.id)
    result = client.images.poll(result.id, interval=5, timeout=300)

if isinstance(result, ImageTaskResponse):
    # released or reconciliation_required is an actual terminal gateway state.
    print(result.id, result.status)
else:
    for image in result.data:
        print(image.url or image.b64_json)
```

`images.generate` submits `POST /v1/images/generations`. Where enabled by the
gateway, `async_=True` sends the wire field `"async": true`; the accepted result
contains `id`, `status` and optionally `status_url`. `images.retrieve(id)` uses
`GET /v1/images/tasks/{id}`. It returns either that pending/terminal task shape or
the final image response containing `data`; final output need not contain a task
ID or `created`. Image polling stops on `data`, `released` or
`reconciliation_required`, preserving those states for the caller.
The parallel MCP adapter uses different result field names: an asynchronous
image's REST/Python `id` becomes MCP `structuredContent.task_id`, and a usable
REST/Python `data[0].url` becomes MCP `structuredContent.outputs[0].url`.

Native JSON fields such as `image`, `seed`, `watermark`, `response_format` and
`sequential_image_generation_options` may be passed as keyword arguments in
the SDK request to RouterShift. SDK pass-through does not mean the selected
gateway adapter forwards or honors a field upstream. The local candidate's
native Qwen Image 3.0/Pro route also maps `seed`, `watermark`, `prompt_extend`,
`enable_thinking` and `prompt_extend_mode`. Image `seed` is 0–2147483647;
extension mode is `direct` or `agent`, and `agent` only supports text-to-image,
not reference-image editing. Thinking applies only when the provider enables
prompt extension; it does not turn that switch on. Explicit `False` and valid
`seed=0` are preserved; `None` omits the option and retains provider defaults.
Direct JSON requests must not send `null` for these five Qwen options.
The native route still returns URLs, not `response_format=b64_json`.
Check the deployed gateway, installed SDK and exact route's accepted mode before
relying on these fields. Response metadata and nested image fields are retained by Pydantic
(`result.model_dump()`). Image streaming,
multipart edits/uploads and local-file download helpers are outside this release.

**0.3.1 behavior correction:** omitted `n`, `size`, `quality` and `style` are now
omitted from the HTTP body. Earlier versions injected defaults specific to some
image models. Supply `n=1, size="1024x1024", quality="standard", style="vivid"`
explicitly if your existing application depends on those values.

### Video tasks

```python
video = client.videos.create(
    model=os.environ["ROUTERSHIFT_VIDEO_MODEL"],
    prompt="A slow camera move over a river",
    duration=5,
)
print("Save this task ID before waiting:", video.task_id)
video = client.videos.poll(video.task_id, interval=5, timeout=300)
if video.status in ("completed", "succeeded") and video.video_url:
    print(video.video_url)  # Reference, not a downloaded file or billing receipt.
else:
    print(video.task_id, video.status)  # Inspect the original task, not a new POST.
```

`videos.create` uses **`POST /v1/video/generations`** and
`videos.retrieve(task_id)` uses **`GET /v1/video/tasks/{id}`**. The wire response
uses `task_id`, `status`, optional `video_url`, `thumbnail` and `error`; native
usage fields are retained. Polling recognizes `completed`, `succeeded`, `failed`,
`cancelled`, `canceled` and `expired`, without changing the returned status.
`generate_audio` defaults to `None`, so the SDK omits it. On accepted native
Wan 3.0 video/prime routes (`wan3.0-video` and `wan3.0-video-prime`), RouterShift
maps `False` to `parameters.audio=false` (silent video)
and `True` to `parameters.audio=true` (video with audio). Omission preserves the
provider default, currently audio on for these Wan 3.0 models. Accepted native Ark Seedance
routes use their own `generate_audio` field. Unknown video routes must not be
assumed to support this option; explicit values are rejected before dispatch.

The current gateway request fields are `model`, `prompt`, `duration`, `size`,
`fps`, `n`, `negative_prompt`, `image_url`, `media` (objects with `type` and `url`),
`ratio` and native Wan 3.0 video/prime or Ark Seedance `generate_audio`.
Native Wan 3.0 also maps `seed` (-1 or 0–2147483647), `watermark` and
`prompt_extend`; `file`/`link` media requires extension to be true or omitted.
The current Ark Seedance contract accepts `watermark` but rejects explicit
`seed` and `prompt_extend`. Explicit `False` and valid `seed=0` are preserved;
`None` omits the option without adding a client default. Model-specific validation
stays in the gateway. Video requests do
not promise arbitrary upstream-native fields. An image/video task ID belongs to
its specific resource; the older `async_tasks` resource is for asynchronous chat.

REST, SDK and MCP expose the same requests, not separate capability approvals.
Advanced video editing, extension and specialist media operations for KernelWake
remain offline contract development, not enabled runtime or commercially
accepted capabilities. This phase permits configuration and unpaid development;
later joint acceptance is still required. Never automatically retry an uncertain
generation POST; query the original task when its ID is available.

For independently accepted Seedream 5.0 Pro and Seedance 2.5 routes, a bounded
multi-reference image request can feed a video first frame. `image` and
`response_format` are preserved native JSON keyword fields. `2048x1152` is a
Seedream Pro pixel-size example, not a default or a size accepted by every image
model:

```python
import os
from routershift import ImageResponse, ImageTaskResponse, RouterShift

client = RouterShift(api_key=os.environ["ROUTERSHIFT_API_KEY"])
generated = client.images.generate(
    model="YOUR_ACCEPTED_SEEDREAM_MODEL_CODE",
    prompt="Combine these three non-portrait landscapes into one scene",
    image=[
        "https://media.example/landscape-1.png",
        "https://media.example/landscape-2.png",
        "https://media.example/landscape-3.png",
    ],
    n=1,
    size="2048x1152",
    response_format="url",
)
if isinstance(generated, ImageTaskResponse):
    generated = client.images.poll(generated.id)
if not isinstance(generated, ImageResponse) or not generated.data or not generated.data[0].url:
    raise RuntimeError("The original image request has no usable URL; inspect its task/status")
video = client.videos.create(
    model="YOUR_ACCEPTED_SEEDANCE_MODEL_CODE",
    prompt="A slow camera move through this landscape",
    image_url=generated.data[0].url,
    duration=10,
    size="720p",
    ratio="adaptive",
    generate_audio=False,
    n=1,
)
print(video.task_id)
print(client.videos.retrieve(video.task_id).status)  # Query this task, not another POST.
```

The current Ark adapter maps top-level `image_url` to `first_frame`; do not also
specify a `media` first frame. The normal data-plane JSON request limit is
**2 MiB total**. Source and result URLs must be readable by the selected
upstream and may expire. On HTTP failure, retain
`RouterShiftError.status_code`, `.error_code` and `.request_id` without logging
media, prompts or signed URLs. `UPSTREAM_ERROR` alone cannot identify a URL,
parameter, account-permission or content-review cause; an initially rejected
POST has no task ID to invent. Do not resubmit an unknown outcome. Ark's
[portrait material rules](https://docs.volcengine.com/docs/ark/seedance-portrait-asset-guide?lang=zh)
do not treat a three-photo image edit as trusted Seedream text-to-image output.
One RouterShift Key does not guarantee both routes use the same upstream account;
account-scoped `asset://` material requires separate verified authorization.

For a **separately accepted native Qwen Image 3.0 async mode** and an **accepted
Wan 3.0 image-to-video mode**, use the same public resource methods. Qwen image
editing accepts 1–3 nonempty reference URLs. Set `async_=True` only when that
exact route and mode have been accepted; omit it for synchronous Qwen and handle
the immediate image `data`. The current native Qwen route returns URL output,
not `b64_json`. Its optional controls are described above and still require the
deployed route and execution mode to support them:

```python
import os
from routershift import ImageResponse, ImageTaskResponse, RouterShift

client = RouterShift(api_key=os.environ["ROUTERSHIFT_API_KEY"])
submitted = client.images.generate(
    model=os.environ["ROUTERSHIFT_QWEN_IMAGE_MODEL"],
    prompt="Combine these three non-portrait landscapes into one scene",
    image=[
        "https://media.example/landscape-1.png",
        "https://media.example/landscape-2.png",
        "https://media.example/landscape-3.png",
    ],
    n=1,
    size="1024x1024",
    async_=True,
)
if isinstance(submitted, ImageTaskResponse):
    print("Save image task ID:", submitted.id)
observed = client.images.retrieve(submitted.id) if isinstance(submitted, ImageTaskResponse) else submitted
if not isinstance(observed, ImageResponse) or not observed.data or not observed.data[0].url:
    raise RuntimeError("No completed image URL yet; query the original task later, never resubmit the POST")
video = client.videos.create(
    model=os.environ["ROUTERSHIFT_WAN_VIDEO_MODEL"],
    prompt="A slow camera movement through the landscape",
    image_url=observed.data[0].url,
    duration=5,
    size="720p",
    n=1,
)
print("Save video task ID:", video.task_id)
status = client.videos.retrieve(video.task_id)  # One GET; repeat only this read if pending.
if status.status in ("completed", "succeeded") and status.video_url:
    print(status.video_url)
else:
    print(status.task_id, status.status)  # Failed/unknown/pending is not a new POST.
```

The model environment variables must contain this key's authorized RouterShift
model codes, not supplier model IDs. Confirm both route/mode acceptances, wallet
balance and key budget before charged submissions; a visible catalog code alone
does not prove them. Omitting Python's `image` keyword selects text-to-image;
explicitly passing `image=None` or `image=[]` sends JSON `image:null` or
`image:[]`, which the native Qwen route rejects rather than treating as an
omitted image. On accepted native Wan 3.0 video/prime routes, `generate_audio=False` requests a
silent video; omission retains its current audio-on default. A task GET
failure means its status is unavailable, not that accepted work failed. Retain
safe `RouterShiftError.status_code`, `.error_code` and `.request_id` for support;
do not log provider messages, prompts, signed media URLs or keys. Neither a
timeout nor a missing output URL makes another paid POST safe.

### Waiting, retry policy and asynchronous Python

Price estimates are read-only: `as_of` is the lookup time, not a price-lock
validity period or a reservation. Accepted work settles against its frozen
price contract and verified native actual usage, not requested duration or a
later price update. Frozen rates do not fix the final amount. Missing or
conflicting usage requires reconciliation, not a guessed charge or automatic
refund. Recover the original task; do not submit another generation. Execution
success, settlement and media availability are separate facts; use platform
billing records for charges.

Image generation and video creation are each sent **once**, even when the
client's `max_retries` is larger. A connection error or lost response does not
prove the provider rejected the work. Keep any returned task ID and query that
task; do not automatically resubmit. Standalone `retrieve` calls retain bounded
GET retry behavior. `poll` uses single-attempt GETs, a monotonic timeout and
positive finite intervals; transport errors are surfaced to the caller.
Treat every lost, incomplete or transport-failed generation POST as an unknown
submission outcome, regardless of the exception type. A task response without
a usable ID raises `RouterShiftError` with the available request ID; inspect
RouterShift logs rather than inventing an ID or submitting again.
For an explicit status check, prefer `retrieve(task_id)`; Python image polling
stops on `data` or released/reconciliation_required/failed/cancelled/canceled,
while video polling stops on completed/succeeded/failed/cancelled/canceled/expired/released/reconciliation_required. A status
outside those terminal sets can reach the bounded polling timeout; inspect the
saved task ID rather than assuming failure, refund or a safe new submission.

The retained `async_tasks.create_chat` helper also submits its paid task once.
It preserves content parts and extended request fields and accepts explicit
`request_options`; its status reads remain retryable GET requests.

HTTP API errors retain the gateway's `X-Request-ID` as
`RouterShiftError.request_id` when the header is available. Keep it for support
and recovery diagnosis; it is a correlation identifier, not an idempotency key
or a replacement for the image/video task ID.

`TaskPollingTimeoutError.task_id` and `TaskPollingCancelledError.task_id` retain
the ID for subsequent retrieval. Stop synchronous waits with
`cancel_event=threading.Event()`; setting that event interrupts the polling sleep.
An in-flight synchronous HTTP request is limited by the remaining request
timeout and is checked for cancellation when it returns. HTTPX timeouts bound
individual network operations, so a synchronous in-flight request can exceed the
overall polling timeout. Neither local cancellation nor a waiting timeout
cancels provider work, releases a reservation or requests a refund.

`RouterShiftAsync` offers the same methods with `await`:

```python
import asyncio
from routershift import RouterShiftAsync

async def generate_video():
    async with RouterShiftAsync(api_key=os.environ["ROUTERSHIFT_API_KEY"]) as client:
        task = await client.videos.create(
            model=os.environ["ROUTERSHIFT_VIDEO_MODEL"], prompt="A sunrise over water",
        )
        print("Save this task ID before waiting:", task.task_id)
        return await client.videos.poll(task.task_id, timeout=300)

# asyncio.run(generate_video()) submits a billable generation when enabled.
```

Asynchronous polling also accepts `cancel_event=asyncio.Event()`. Setting that
event or cancelling the Python polling task interrupts an in-flight GET; the overall async deadline also
wraps that request. No client performs automatic output downloads or follows a
returned `status_url`; task retrieval stays on the configured data-plane origin.

## Features

- **Chat Completions** — Text and accepted HTTP multimodal input/output, including SSE audio deltas and usage
- **Audio** — Single-attempt binary speech and actual multipart transcription, with bounded reads and cancellation
- **Data-plane only** — No console-session, wallet, or account-management methods
- **Type-safe** — Full Pydantic models for all request/response types
- **Retry policy** — Existing non-streaming chat/messages and read methods retain configured retries; embeddings, audio/image/video creation, asynchronous chat submission and streaming are not automatically replayed
- **Async support** — Full async client via `RouterShiftAsync`

## API Reference

Full API reference at [routershift.com/docs](https://routershift.com/docs).

## Source development

Asynchronous chat status/list responses retain `status` and include read-only
`execution_status`, `billing_status`, `reason_code`, `next_action` and
`retry_allowed: false`. `completed` means result-ready, not billing settled.
Missing billing facts remain `unknown`; querying never runs billing recovery
or authorizes another submission. Image `_billing_status` metadata is copied
only from an allowlisted actual `X-Billing-Status` response header, never from
successful output or a JSON claim. Retain the task/request ID for review.

To install from a local checkout:

```bash
python -m pip install /absolute/path/to/routershift/sdks/python
```

For SDK development, install its declared test dependencies from the repository:

```bash
python -m pip install -e "./sdks/python[dev]"
python -m pytest sdks/python/tests
```
