Metadata-Version: 2.4
Name: sume-client
Version: 0.1.0
Summary: Sume Open API Python SDK
License-Expression: MIT
License-File: LICENSE
License-File: NOTICE
Author: Sume Labs
Author-email: dev@sume.com
Requires-Python: >=3.8
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
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: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Requires-Dist: httpx (>=0.21.0,<0.29.0)
Project-URL: Documentation, https://open.sume.com/docs
Project-URL: Homepage, https://open.sume.com
Project-URL: Repository, https://github.com/sumelabs/sume-client
Description-Content-Type: text/markdown

# Sume Python Client

The official Python SDK for the [Sume Open API](https://open.sume.com/docs), with a synchronous and an asynchronous client.

## Installation

sume-client is not on PyPI yet. Install it from GitHub:

```bash
pip install git+https://github.com/sumelabs/sume-client.git
```

## Authentication

Before using the client, set your API key as an environment variable:

```bash
export SUME_API_KEY="sume_live_..."
```

Get your key from [open.sume.com → API keys](https://open.sume.com/api-keys). The client sends it as `Authorization: Bearer <key>` to `https://api.open.sume.com`.

## Quick Start

**Synchronous:**

```python
import sume_client

result = sume_client.subscribe(
    'gpt-image-2',
    arguments={
        'prompt': 'A serene lake at sunset with mountains',
        'quality': 'low',
        'aspect_ratio': '16:9',
    }
)

print(result['result']['artifacts'][0]['url'])
```

**Asynchronous:**

```python
import asyncio

import sume_client

async def main():
    result = await sume_client.subscribe_async(
        'gpt-image-2',
        arguments={
            'prompt': 'A serene lake at sunset with mountains',
            'quality': 'low',
            'aspect_ratio': '16:9',
        }
    )

    print(result['result']['artifacts'][0]['url'])

asyncio.run(main())
```

`gpt-image-2` at `quality: 'low'` costs under a cent. [List open models](https://open.sume.com/docs/reference/catalog/list) gives every model's price, and [Estimate a price](https://open.sume.com/docs/reference/catalog/estimate) prices a request before you send it.

## Usage Patterns

### Pattern 1: Simple Submit and Wait

`subscribe()` submits the job and returns when it has ended:

**Synchronous:**

```python
import sume_client

result = sume_client.subscribe(
    'gpt-image-2',
    arguments={
        'prompt': 'A serene lake at sunset with mountains',
        'quality': 'low',
        'aspect_ratio': '16:9',
    }
)

print(result['result']['artifacts'][0]['url'])
```

**Asynchronous:**

```python
import asyncio

import sume_client

async def main():
    result = await sume_client.subscribe_async(
        'gpt-image-2',
        arguments={
            'prompt': 'A serene lake at sunset with mountains',
            'quality': 'low',
            'aspect_ratio': '16:9',
        }
    )

    print(result['result']['artifacts'][0]['url'])

asyncio.run(main())
```

### Pattern 2: Submit and Track Progress

`submit()` returns a request controller at once, and `poll_request_status()` yields each status until the job ends. `webhook_url` is optional: Sume POSTs the outcome to that public HTTPS URL when the job ends ([Webhooks](https://open.sume.com/docs/webhooks)).

**Synchronous:**

```python
import sume_client

request_controller = sume_client.submit(
    'gpt-image-2',
    arguments={
        'prompt': 'Football ball',
        'quality': 'low',
        'aspect_ratio': '16:9',
    },
    webhook_url='https://example.com/webhook'
)

for status in request_controller.poll_request_status():
    if isinstance(status, sume_client.Queued):
        print('Queued')
    elif isinstance(status, sume_client.InProgress):
        print('In progress')
    elif isinstance(status, sume_client.Completed):
        print('Completed')
    elif isinstance(status, (sume_client.Failed, sume_client.NSFW, sume_client.Cancelled)):
        print('Oops!')

result = request_controller.get()
print(result['result']['artifacts'][0]['url'])
```

**Asynchronous:**

```python
import asyncio

import sume_client

async def main():
    request_controller = await sume_client.submit_async(
        'gpt-image-2',
        arguments={
            'prompt': 'Football ball',
            'quality': 'low',
            'aspect_ratio': '16:9',
        },
        webhook_url='https://example.com/webhook'
    )

    async for status in request_controller.poll_request_status():
        if isinstance(status, sume_client.Queued):
            print('Queued')
        elif isinstance(status, sume_client.InProgress):
            print('In progress')
        elif isinstance(status, sume_client.Completed):
            print('Completed')
        elif isinstance(status, (sume_client.Failed, sume_client.NSFW, sume_client.Cancelled)):
            print('Oops!')

    result = await request_controller.get()
    print(result['result']['artifacts'][0]['url'])

asyncio.run(main())
```

### Pattern 3: Submit with Callbacks

`on_enqueue` gets the request ID once the job exists, and `on_queue_update` gets every status:

**Synchronous:**

```python
import sume_client

def on_enqueue(request_id):
    print(f'Request {request_id} was enqueued')

def on_status_update(status):
    print(f'Status: {status}')

result = sume_client.subscribe(
    'gpt-image-2',
    arguments={
        'prompt': 'A serene lake at sunset with mountains',
        'quality': 'low',
        'aspect_ratio': '16:9',
    },
    on_enqueue=on_enqueue,
    on_queue_update=on_status_update
)
```

**Asynchronous:**

```python
import asyncio

import sume_client

def on_enqueue(request_id):
    print(f'Request {request_id} was enqueued')

def on_status_update(status):
    print(f'Status: {status}')

async def main():
    await sume_client.subscribe_async(
        'gpt-image-2',
        arguments={
            'prompt': 'A serene lake at sunset with mountains',
            'quality': 'low',
            'aspect_ratio': '16:9',
        },
        on_enqueue=on_enqueue,
        on_queue_update=on_status_update
    )

asyncio.run(main())
```

### Pattern 4: Manage Existing Requests

Keep the request controller, or only the request ID, and check, cancel or wait for the job later:

**Synchronous:**

```python
import sume_client

request_controller = sume_client.submit(
    'gpt-image-2',
    arguments={
        'prompt': 'A serene lake at sunset with mountains',
        'quality': 'low',
        'aspect_ratio': '16:9',
    },
    webhook_url='https://example.com/webhook'
)

status = request_controller.status()

try:
    request_controller.cancel()
except sume_client.SumeClientError as error:
    print(error.code)

result = request_controller.get()
```

`status()` checks the status once, `cancel()` asks Sume to cancel the job, and `get()` waits for the job to end and returns its result.

Or by request ID:

```python
import sume_client

request_id = 'job_7513b52d-3669-4ffe-b317-8b02000715eb'

status = sume_client.status(request_id=request_id)

try:
    sume_client.cancel(request_id=request_id)
except sume_client.SumeClientError as error:
    print(error.code)

result = sume_client.result(request_id=request_id)
```

**Asynchronous:**

```python
import asyncio

import sume_client

async def main():
    request_controller = await sume_client.submit_async(
        'gpt-image-2',
        arguments={
            'prompt': 'A serene lake at sunset with mountains',
            'quality': 'low',
            'aspect_ratio': '16:9',
        },
        webhook_url='https://example.com/webhook'
    )

    status = await request_controller.status()

    try:
        await request_controller.cancel()
    except sume_client.SumeClientError as error:
        print(error.code)

    result = await request_controller.get()

asyncio.run(main())
```

Or by request ID:

```python
import asyncio

import sume_client

async def main():
    request_id = 'job_7513b52d-3669-4ffe-b317-8b02000715eb'

    status = await sume_client.status_async(request_id=request_id)

    try:
        await sume_client.cancel_async(request_id=request_id)
    except sume_client.SumeClientError as error:
        print(error.code)

    result = await sume_client.result_async(request_id=request_id)

asyncio.run(main())
```

Sume cancels a job only before generation starts, and the image and video routers hand the job to the model inside `submit()`. So `cancel()` usually raises `SumeClientError` with code `job_generation_already_started`, or `job_not_cancelable` once the job has ended. A canceled job is refunded.

## File Uploads

Put a local file in Sume storage and pass its URL to a model. Each upload returns a public HTTPS URL on `media.sume.com` that you pass in a model's input field, such as `image_urls`, `image_url`, `video_url` or `audio_url` ([Input media](https://open.sume.com/docs/input-media)). Uploading is free.

### Upload bytes

```python
import sume_client

image_path = 'path/to/example.jpeg'
content_type = 'image/jpeg'

with open(image_path, 'rb') as f:
    data = f.read()

url = sume_client.upload(data, content_type)
```

### Upload by path

```python
import sume_client

image_path = 'path/to/example.jpeg'

url = sume_client.upload_file(image_path)
```

### Upload PIL Images

```python
from PIL import Image
import sume_client

image = Image.open('example.jpeg')
url = sume_client.upload_image(image, format='jpeg')
```

### Async Uploads

Each upload function has an `_async` twin:

```python
import sume_client

url = await sume_client.upload_async(data, content_type='image/jpeg')

url = await sume_client.upload_file_async('path/to/example.jpeg')

url = await sume_client.upload_image_async(image, format='jpeg')
```

### Use an upload

```python
import sume_client

url = sume_client.upload_file('path/to/example.jpeg')

result = sume_client.subscribe(
    'seedance-2.5',
    arguments={
        'prompt': 'The camera slowly pushes in',
        'image_url': url,
        'duration': 5,
        'resolution': '480p',
    }
)

print(result['result']['artifacts'][0]['url'])
```

## Models and Arguments

The first argument is a model id from the open catalog, such as `gpt-image-2`, `nano-banana-pro`, `seedance-2.5` or `kling-3`. [List open models](https://open.sume.com/docs/reference/catalog/list) (`GET https://api.open.sume.com/v1/open/catalog`, no key needed) lists them all.

The client reads the model's `kind` once per client from `GET /v1/open/catalog/{model_id}`. Image models go to `POST /v1/image-router/generate`; video, lip-sync and caption models go to `POST /v1/video-router/generate`. `arguments` are the model's own fields (`prompt`, `quality`, `aspect_ratio`, `duration`, `resolution`, `image_url` and so on), described in [Generate an image](https://open.sume.com/docs/reference/images/generate) and [Generate a video](https://open.sume.com/docs/reference/video/generate).

## Results

`subscribe()`, `result()` and `get()` wait for the job to end and return the response's `data`:

- A completed job returns its result from `GET /v1/jobs/{id}/result`: `result['status']` is `COMPLETED`, and `result['result']['artifacts']` lists the outputs, each with a `url`, a `type` and a `content_type`.
- A failed or canceled job returns its final status from `GET /v1/jobs/{id}/status`: `result['status']` is `FAILED` or `CANCELED`, and `result['job']['error']` says why. Its price is refunded.

A request the model refuses at once raises `SumeClientError` from `submit()` or `subscribe()` instead, for example code `provider_submission_failed`.

## Statuses

`status()` and `poll_request_status()` return one of these classes. `DONE_STATUSES` holds the last four.

| Class | Job's `sume_status` |
|---|---|
| `Queued` | `queued` |
| `InProgress` | `processing` |
| `Completed` | `completed` |
| `Failed` | `failed` |
| `NSFW` | `failed`, with `error.public_reason` `content_policy_rejected` |
| `Cancelled` | `canceled` |

Polling asks for the status every 2 seconds, the API's `recommended_poll_interval_seconds`. Pass `poll_request_status(delay=...)` to change it.

## Errors

Every answer outside 2xx raises `sume_client.SumeClientError`. Its message is the API's message, with the failing fields of an `invalid_request`. `status_code`, `code`, `request_id` and `details` carry the rest of the [error](https://open.sume.com/docs/errors):

```python
import sume_client

try:
    sume_client.subscribe('gpt-image-2', arguments={'prompt': 'A lake', 'quality': 'low'})
except sume_client.SumeClientError as error:
    print(error.status_code, error.code, error.request_id, error)
```

A missing key raises `sume_client.CredentialsMissedError` on the first call.

A request to the API is tried up to 3 times when the answer is 408, 429, 500, 502, 503 or 504, unless the error says `retryable: false`, and when the connection fails or times out. The client waits 0.2 s and then 0.4 s between tries, or longer when the API's `retry-after` asks for it (up to 60 s). After the last try, a connection failure raises the `httpx` error. Every generate request carries a new `Idempotency-Key`, and its retries reuse it, so the client's own retries never create a second, billed job.

The upload `PUT` goes to storage once, without your key. If storage refuses it, `SumeClientError` names the storage error code and leaves out the presigned URL.

## Clients

The module functions use two default clients, `sume_client.sync_client` and `sume_client.async_client`. Make your own to set the key, host or timeout in code:

```python
from sume_client import AsyncClient, SyncClient

client = SyncClient(api_key='sume_live_...', base_url='https://api.open.sume.com', timeout=90.0)
result = client.subscribe('gpt-image-2', arguments={'prompt': 'A lake', 'quality': 'low'})

async_client = AsyncClient(api_key='sume_live_...')
```

Every call goes to `base_url`. The client builds the job URLs from it and the request ID, and does not follow the hosts in API responses.

`AsyncClient` keeps one connection pool per event loop, so one client can serve several `asyncio.run()` calls and several threads.

## Coming from higgsfield-client

sume-client keeps [higgsfield-client](https://github.com/higgsfield-ai/higgsfield-client)'s module functions, clients, request controllers, status classes and retry strategies, with the same arguments. What changes:

| | higgsfield-client | sume-client |
|---|---|---|
| Package | `higgsfield_client` | `sume_client` |
| Host | `https://api.higgsfield.ai` | `https://api.open.sume.com` |
| Key | `HF_KEY`, or `HF_API_KEY` + `HF_API_SECRET` | `SUME_API_KEY` |
| Header | `Authorization: Key <key>` | `Authorization: Bearer <key>` |
| First argument | an application path | a model id |
| Webhook | `?hf_webhook=` | `mode: webhook` and `webhook_url` in the body |
| Result | the status response | the result's `data` (`result['result']['artifacts']`) |
| Error | `HiggsfieldClientError` | `SumeClientError`, with `status_code`, `code`, `request_id`, `details` |
| Upload | `content_type` | `content_type` and `size_bytes` |
| Polling | every 0.5 s | every 2 s |
| Cancel | a queued request | a job the model has not started; router jobs start inside `submit()` |
| Agent API | `client.agents` | not available: Open keys call the model API only |

## Development

```bash
pip install -e . pytest pytest-asyncio pytest-mock pillow
pytest
```

The tests run against a fake Sume API and spend nothing.

## License

MIT. See [LICENSE](LICENSE). [NOTICE](NOTICE) credits higgsfield-client (Apache-2.0), whose public interface this client follows.

