Metadata-Version: 2.4
Name: fotohub
Version: 1.10.0
Summary: Official Python SDK for the FOTOhub AI Platform — image, video, music, 3D generation, chat/LLM, and more
Project-URL: Homepage, https://fotohub.app
Project-URL: Documentation, https://docs.fotohub.app/sdk/python
Project-URL: Repository, https://github.com/fotohubapp/sdk-python
Project-URL: Issues, https://github.com/fotohubapp/sdk-python/issues
Author-email: FOTOhub <dev@fotohub.app>
License-Expression: MIT
License-File: LICENSE
Keywords: ai,fotohub,image-generation,llm,music-generation,video-generation
Classifier: Development Status :: 5 - Production/Stable
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: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.24
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: mypy>=1.5; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest-httpx>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1; extra == 'dev'
Description-Content-Type: text/markdown

<p align="center">
  <img src="https://static.fotohub.app/brand/fotohub-logo-dark.png" alt="FOTOhub" width="280" />
</p>

<p align="center">
  <strong>Official Python SDK for the FOTOhub AI Platform</strong>
</p>

<p align="center">
  <a href="https://pypi.org/project/fotohub/"><img src="https://img.shields.io/pypi/v/fotohub?color=blue&label=PyPI" alt="PyPI Version" /></a>
  <a href="https://pypi.org/project/fotohub/"><img src="https://img.shields.io/pypi/pyversions/fotohub" alt="Python Versions" /></a>
  <a href="https://github.com/fotohubapp/sdk-python/blob/main/LICENSE"><img src="https://img.shields.io/github/license/fotohubapp/sdk-python" alt="License" /></a>
  <a href="https://pypi.org/project/fotohub/"><img src="https://img.shields.io/pypi/dm/fotohub?color=green" alt="Downloads" /></a>
  <a href="https://docs.fotohub.app/sdk/python"><img src="https://img.shields.io/badge/docs-fotohub.app-blue" alt="Documentation" /></a>
</p>

<p align="center">
  Generate images, videos, music, and chat with LLMs — all through a single, unified Python client.<br/>
  Supports 80+ AI models from 10+ providers with built-in credit management.
</p>

---

## Features

- **Image Generation** — 25+ models including SeedDream 5.0, Flux, Imagen, Gemini, GPT Image, and more
- **IDA Q 1.0** — FOTOhub's proprietary self-hosted image model, submitted and polled for you
- **Video Generation** — Veo, Seedance and more; the call blocks and returns the finished URL
- **Music, SFX and Speech** — AI-generated music, sound effects and TTS
- **Virtual Try-On** — dress a person photo in a garment, or a full top + bottom outfit in one call
- **Chat / LLM** — OpenAI-shaped chat completions, plus premium Claude-class models
- **Gabriel AI** — routes a natural-language request to the right feature and model
- **3D Generation** — image-to-mesh and text-to-mesh jobs with polling helpers
- **Stability Tools** — upscale, erase, inpaint, outpaint, recolor, style transfer
- **Billing** — balance, credits, pricing, top-ups, transactions and invoices in USD
- **Webhooks** — register, test and inspect delivery logs
- **Translation** — multi-language translation
- **Sync + Async** — both synchronous and asynchronous clients included
- **Automatic Retries** — exponential backoff with configurable retry logic
- **Fully Typed** — complete type annotations and a `py.typed` marker

## Installation

```bash
pip install fotohub
```

Requires Python 3.9 or higher.

## Quick Start

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

# Generate an image
result = client.generate_image(prompt="A mountain landscape at golden hour")
print(result["images"][0])
```

Every method returns the API's JSON as a plain `dict` — index it, don't use
attribute access.

## Authentication

Get your API key from [fotohub.app/settings/api](https://fotohub.app/settings/api).

```python
from fotohub import FotoHub

# Option 1: Pass directly
client = FotoHub(api_key="fh_live_...")

# Option 2: Environment variable
# export FOTOHUB_API_KEY=fh_live_...
client = FotoHub()
```

The SDK authenticates via both `Authorization: Bearer` and `x-api-key` headers.

## Usage Examples

### Image Generation

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

# Basic generation (default: seedream-5-0-260128)
result = client.generate_image(
    prompt="A serene Japanese garden with cherry blossoms",
)
print(result["images"][0])

# Advanced options
result = client.generate_image(
    prompt="Cyberpunk cityscape at night, neon reflections on wet streets",
    model="flux-2-pro",
    width=1280,
    height=720,
    num_images=2,
    negative_prompt="blurry, low quality",
    seed=42,
)

for url in result["images"]:
    print(url)
print(f"{result['credits_used']} credits, ${result['billing']['usd_charged']}")
```

`guidance_scale` and `steps` are not accepted by this endpoint — passing them
raises `TypeError` in the SDK. Prefer `aspect_ratio` over manual width/height;
the API picks dimensions the chosen model actually supports.

**IDA Q 1.0** (FOTOhub's own model) runs on a single-GPU queue, so it has its
own method that submits and polls for you — 30 s at `1K`, up to ~3.5 min at `2K`:

```python
result = client.generate_ida_q(
    prompt="Portret kobiety w świetle porannym",   # any language
    aspect_ratio="4:3",
    image_size="1.5K",
)
print(result["images"][0])
```

### Video Generation

Video generation is **synchronous**: the request stays open until the render
finishes, so the returned dict already carries the finished `video_url`. There is
no job to poll. Raise `timeout` on the client if your model is a slow one.

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...", timeout=600.0)

result = client.generate_video(
    prompt="A drone shot flying over a tropical beach at sunrise",
    model="veo-3.1-generate-001",
    duration=5,
    aspect_ratio="16:9",
)
print(result["video_url"])
print(f"{result['credits_used']} credits")
```

`wait_for_video()` still exists but is deprecated — it emits a
`DeprecationWarning` and returns the dict you pass it unchanged.

**Image-to-Video:**

```python
result = client.generate_video(
    prompt="Camera slowly zooms in, subtle parallax motion",
    image_url="https://example.com/photo.jpg",
)
```

### Seedance — long clips and video editing

Seedance is the one video family that runs **asynchronously**: the API answers
`202` with a `job_id` instead of a finished video, so `generate_video()` cannot
consume it. Use `generate_seedance()`, which submits the job and then polls until
it finishes.

`seedance-2-5` renders **4–30 seconds in a single clip** — the longest we offer —
and its audio track costs nothing extra. The trade-off is resolution: **480p and
720p only**, so if you need 1080p or 4K stay on `seedance-2-0-pro` (4–15s).

```python
result = client.generate_seedance(
    prompt="A lone hiker crossing a snowfield, wind picking up, wide drone shot",
    duration=30,
    resolution="720p",
    generate_audio=True,        # free
)
print(result["video_url"])
print(f"{result['credits_used']} credits")   # ~435 for 30s @ 720p
```

Rates are per second: **14.5 credits/s at 720p**, **6.4 at 480p**. Draft at 480p
(a 5s test costs 32 credits) and re-render the take you like at 720p.

**Edit or extend an existing video** — pass `reference_videos` and
`duration=-1` to keep the source length:

```python
edited = client.generate_seedance(
    prompt="Make it golden hour, warmer light on the subject's face",
    reference_videos=["https://example.com/clip.mp4"],
    duration=-1,
)
```

A video reference bills at the higher **17.6 credits/s** (720p) because the
source frames are charged as input.

**Face consistency** — register a portrait once, then reuse the asset id:

```python
asset = client.register_video_asset("https://example.com/face.jpg")
result = client.generate_seedance(
    prompt="The same woman walking through a night market, neon reflections",
    asset_ids=[asset["asset_id"]],
    duration=10,
)
```

`generate_seedance()` raises `fotohub.TimeoutError` if the job is still running
when `timeout` (default 1800s) expires — the message carries the `job_id` so you
can keep polling — and `FotoHubError` if the job fails. Note that
`fotohub.TimeoutError` is a `FotoHubError` subclass, **not** Python's built-in
`TimeoutError`; import it as
`from fotohub.exceptions import TimeoutError as FotoHubTimeoutError` if the
distinction matters. `AsyncFotoHub` exposes the same two methods with `await`.

### Music, SFX and Speech

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

track = client.generate_music(
    prompt="Upbeat electronic track with heavy bass and synth arpeggios",
    duration=30,          # integer seconds, capped at 300
    genre="electronic",
    instrumental=True,
)
print(track["audio_url"], track["duration"], track["credits_used"])

sfx = client.generate_sfx(prompt="Heavy wooden door slamming shut", duration=5)
print(sfx["audio_url"])

speech = client.generate_speech(text="Dzień dobry!", language="pl", speed=1.0)
print(speech["audio_url"])
```

Music costs 5 credits up to 30 s, 10 up to 60 s, 25 beyond that.

### Virtual Try-On

A try-on is a job, not a blocking call: a render takes about 11 seconds, so you submit and then wait.

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

job = client.tryon(
    person_image_url="https://example.com/person.jpg",
    garment_image_url="https://example.com/shirt.png",
    category="tops",              # "tops" | "bottoms" | "one-pieces"
    garment_photo_type="flat-lay",
)
result = client.wait_for_tryon(job["job_id"])
print(result["images"][0])
```

Pass `garments` to dress a top **and** a bottom in one job. The API applies the top first, feeds
that render into the second pass, and charges 3 credits instead of 4:

```python
job = client.tryon(
    person_image_url="https://example.com/person.jpg",
    garments=[
        {"garment_image_url": "https://example.com/tee.png", "category": "tops"},
        {"garment_id": "0f1e2d3c-...", "category": "bottoms"},   # or a catalogue id
    ],
)
result = client.wait_for_tryon(job["job_id"], timeout=60)

# If the second pass failed, the top-only render still comes back and one credit
# is refunded — so check before calling it a finished outfit.
partial = (result.get("metadata") or {}).get("partial_failure")
if partial:
    print(f"The {partial['slot']} is missing:", result["images"][0])
```

Exactly one top plus one bottom is required — two tops, three garments, or a `one-pieces` in the
array are rejected with `400`. Hats and shoes are not supported by the model at all.

### Chat Completions (OpenAI-Compatible)

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

response = client.chat(
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Explain quantum computing in simple terms."},
    ],
    model="gpt-4o",
    temperature=0.7,
    max_tokens=1000,
)
print(response["choices"][0]["message"]["content"])
print(response["credits_used"])
```

`chat()` accepts exactly four model IDs — `gemini-flash` (default),
`gemini-pro`, `gpt-4o`, `claude-sonnet`. Anything else is rejected with `400`.
For premium Claude-class models use `chat_claude()` or `chat_bedrock()`, which
take a full model ID and a `system=` prompt.

`credits_used` is the authoritative charge; `usage` is passed through from the
provider and can be `{}`.

### Streaming Chat

> **`chat(stream=True)` is not supported and raises `ValueError`.**
> `/v1/ai/chat/completions` accepts the flag for OpenAI compatibility and then
> ignores it, returning one complete JSON body. The stream iterator would find no
> SSE frames in that body and yield nothing while the request was still billed,
> so the SDK refuses before sending.

The one streaming endpoint on the platform is `POST /v1/ai/agent/stream`. It has
no SDK wrapper yet — call it over plain HTTP. Frames carry a `type`
(`text_delta`, `tool_use`, `done`, `error`) and the stream ends at `data: [DONE]`:

```python
import json
import os

import requests

resp = requests.post(
    "https://apis.fotohub.app/v1/ai/agent/stream",
    headers={"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"},
    json={
        "model": "claude-sonnet-4.6",
        "messages": [{"role": "user", "content": "Write a short poem about the sea."}],
    },
    stream=True,
)
resp.raise_for_status()

for line in resp.iter_lines():
    if not line:
        continue
    payload = line.decode("utf-8")
    if not payload.startswith("data: "):
        continue
    data = payload[6:]
    if data == "[DONE]":          # the only reliable terminator
        break
    frame = json.loads(data)
    if frame["type"] == "text_delta":
        print(frame["text"], end="", flush=True)
    elif frame["type"] == "error":
        raise RuntimeError(frame["message"])
print()
```

Two things to know about that endpoint: the `done` frame is **optional** (it is
omitted when the turn produced no tokens, and replaced by `error` when
generation succeeded but settlement failed), and **abandoning the stream still
bills you** — the server settles the tokens it already generated.

### Gabriel AI (Model Routing)

Gabriel returns a routing *decision*, not a completion: it tells you which
feature and model to use, and you make that call yourself. An API key is
required.

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

decision = client.gabriel_classify(
    "I want to create a logo for my coffee shop",
    language="en",
    context={"user_tier": "pro"},
)
print(decision["action"])             # route | answer | workflow | error
print(decision.get("target"))         # the feature to send the user to
print(decision.get("model_selected"))
print(decision.get("credits_estimated"))

# Autocomplete as the user types, and idle suggestions for a dashboard:
print(client.gabriel_suggest("make me a log", tab="image"))
print(client.gabriel_recommend(credits_remaining=40))
```

### Storage & S3 Buckets

Dedicated S3 buckets live under `/v1/storage/s3/*` and are **not wrapped by this
SDK** — call them over plain HTTP for now:

```python
import httpx

api = httpx.Client(
    base_url="https://apis.fotohub.app",
    headers={"Authorization": "Bearer fh_live_..."},
)

buckets = api.get("/v1/storage/s3/buckets").json()

upload = api.post(
    f"/v1/storage/s3/buckets/{buckets[0]['id']}/objects/presign-upload",
    json={"key": "images/photo.jpg", "content_type": "image/jpeg", "expires_in": 3600},
).json()
# PUT your bytes to upload["url"]
```

### Translation

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

result = client.translate("Hello, how are you?", "pl")
print(result["translated_text"])   # "Cześć, jak się masz?"
print(result["source_language"])   # "auto"
```

`text` and `target_language` are positional. On a provider timeout the endpoint
returns your input text unchanged rather than an error — compare against the
input if that matters to you.

### Billing & Usage

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

balance = client.get_balance()
print(balance["credits"]["remaining_4h"], "credits left in this 4h window")
print(balance["wallet"]["balance"], balance["wallet"]["currency"])   # USD

print(client.get_transactions(page=1, page_size=20))
print(client.get_invoices())
print(client.estimate_cost([{"type": "generate_image", "model": "flux-2-pro", "count": 10}]))
```

Per-endpoint analytics live at `GET /v1/usage` (JWT auth, fixed 30-day window)
and have no SDK helper — see the
[Usage & Analytics docs](https://docs.fotohub.app/api/usage-analytics).

**Topping up.** Either take a fixed package or name your own amount:

```python
for pkg in client.get_topup_packages():
    # The slugs are historical (topup-50 is now the $15 package) — read
    # amount_usd, never the number in the slug.
    print(pkg["slug"], pkg["amount_usd"], f"+{pkg['bonus_pct']}% bonus credits")

session = client.create_topup("topup-100")       # a package — this one is $25
print(session["checkout_url"])

session = client.topup_wallet(40.0)              # an arbitrary amount, $10–$15000
print(session["checkout_url"])
```

The wallet is denominated in **USD**. A customer in Poland can still pay in
złoty — pass `pay_currency="pln"` and Stripe offers BLIK, card and bank transfer
while the wallet is credited the USD amount you asked for:

```python
session = client.topup_wallet(40.0, pay_currency="pln")
```

## Async Client

Every method is available as an async variant via `AsyncFotoHub`:

```python
import asyncio
from fotohub import AsyncFotoHub

async def main():
    async with AsyncFotoHub(api_key="fh_live_...") as client:
        # Generate images concurrently
        results = await asyncio.gather(
            client.generate_image(prompt="A sunset over mountains"),
            client.generate_image(prompt="A forest in morning mist"),
            client.generate_image(prompt="An ocean wave at golden hour"),
            return_exceptions=True,
        )
        for r in results:
            if isinstance(r, Exception):
                print(f"failed: {r}")
            else:
                print(r["images"][0])

asyncio.run(main())
```

`return_exceptions=True` matters here: without it, one failed generation cancels
the gather while the others keep running server-side — and you are still billed
for them.

`await client.chat(..., stream=True)` raises `ValueError` on the async client
too, for the same reason as the sync one.

## Error Handling

The SDK raises typed exceptions for all error conditions:

```python
from fotohub import (
    FotoHub,
    AuthError,
    InsufficientCreditsError,
    RateLimitError,
    ValidationError,
    ServerError,
    TimeoutError,
    VideoJobTimeoutError,
)

client = FotoHub(api_key="fh_live_...")

try:
    result = client.generate_image(prompt="test")
except AuthError as e:
    # Invalid or missing API key (HTTP 401/403)
    print(f"Authentication failed: {e}")
except InsufficientCreditsError as e:
    # Not enough credits (HTTP 402)
    print(f"Out of credits: {e}")
except RateLimitError as e:
    # Too many requests (HTTP 429)
    print(f"Rate limited. Retry after {e.retry_after}s")
except ValidationError as e:
    # Invalid parameters (HTTP 400/422)
    print(f"Invalid request: {e.errors}")
except ServerError as e:
    # Server error (HTTP 5xx)
    print(f"Server error: {e}")
except TimeoutError as e:
    # Request timed out
    print(f"Timed out: {e}")
except VideoJobTimeoutError as e:
    # Video polling exceeded max_wait
    print(f"Video job {e.job_id} timed out")
```

### Exception Hierarchy

| Exception | HTTP Status | Description |
|-----------|-------------|-------------|
| `FotoHubError` | Any | Base exception for all SDK errors |
| `AuthError` | 401, 403 | Invalid or missing API key |
| `InsufficientCreditsError` | 402 | Account lacks sufficient credits |
| `RateLimitError` | 429 | Rate limit exceeded |
| `ValidationError` | 400, 422 | Invalid request parameters |
| `ServerError` | 5xx | Server-side error |
| `TimeoutError` | — | Request timed out or connection failed |
| `VideoJobTimeoutError` | — | Video polling exceeded `max_wait` (legacy — video is synchronous now) |

All exceptions include `status_code` and `response_body` attributes for debugging.

`InsufficientCreditsError.credits_required` / `.credits_available` and
`ValidationError.errors` are populated only when the response carries those keys.
The API returns FastAPI's `{"detail": "..."}` envelope, so in practice they are
`None` / `[]` — read `str(e)` or `e.response_body` for the real reason.

Two failure modes that are **not** exceptions and need an explicit check:

- **`chat(stream=True)`** raises `ValueError` before sending — see
  [Streaming Chat](#streaming-chat).
- **A partially failed try-on outfit completes rather than fails.** The top-only
  render comes back, one credit is refunded, and
  `result["metadata"]["partial_failure"]` says which slot is missing.

## Configuration

```python
from fotohub import FotoHub

client = FotoHub(
    api_key="fh_live_...",
    base_url="https://apis.fotohub.app",  # Custom API endpoint
    timeout=120.0,                         # Request timeout (seconds)
    max_retries=3,                         # Max retry attempts
)
```

### Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `FOTOHUB_API_KEY` | API key for authentication | — |
| `FOTOHUB_BASE_URL` | Override API base URL | `https://apis.fotohub.app` |

### Retry Behavior

The SDK automatically retries on transient failures:

- **HTTP 429** — Rate limit (respects `Retry-After` header)
- **HTTP 500, 502, 503, 504** — Server errors
- **Connection timeouts** — Network failures

Backoff schedule: `0.5s → 1s → 2s → 4s → ...` (capped at 30s).

### Idempotency — retries do not double-charge

Retrying a call that spends credits is only safe if the server can tell the
retry apart from a new request. The dangerous case is a `504` or a timeout that
arrives *after* the generation already started: the work is running and will be
billed, but the client sees a failure.

Since **1.10.0** the SDK sends an `X-Idempotency-Key` on every request that can
charge you, and every retry of one call reuses that same key. The API replays
the original result instead of running the operation again, so a call that
retried three times is still charged once. Nothing to configure.

```python
# All three attempts inside this call share one idempotency key.
result = client.generate_image(prompt="Product photo")
```

Two separate calls always get two different keys, even with identical
arguments — asking twice means you want two generations, and collapsing them
would lose one you paid for.

That scoping is deliberate, and it is also the limit of what the SDK can do for
you: it protects the retries *inside* one call. If you need de-duplication
across process restarts — a job queue that may redeliver the same work after a
crash — the SDK cannot know two runs are the same job, so call the endpoint over
HTTP with your own stable key (derive it from the job id):

```python
import os
import requests

requests.post(
    "https://apis.fotohub.app/v1/ai/generate/image",
    headers={
        "Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}",
        "Content-Type": "application/json",
        "X-Idempotency-Key": f"job-{job_id}",  # stable across restarts
    },
    json={"prompt": "Product photo"},
    timeout=120,
)
```

A `409` on a request carrying a key means your own earlier attempt is still in
flight; the SDK waits and collects its result rather than reporting a failure.
Streaming endpoints (`chat`, `gabriel`, TTS, story) are excluded, because a
stream cannot be buffered and replayed.

## Context Managers

Both clients support context managers for automatic resource cleanup:

```python
# Sync
with FotoHub(api_key="fh_live_...") as client:
    result = client.generate_image(prompt="test")

# Async
async with AsyncFotoHub(api_key="fh_live_...") as client:
    result = await client.generate_image(prompt="test")
```

## API Reference

### `FotoHub` / `AsyncFotoHub`

On `AsyncFotoHub` every method below is a coroutine. The two classes are
otherwise identical, with one exception: `gabriel_stream()` is sync-only.

**Generation**

| Method | Description |
|--------|-------------|
| `generate_image(prompt, *, model, width, height, aspect_ratio, num_images, negative_prompt, style, seed)` | Generate images from text |
| `generate_ida_q(prompt, *, aspect_ratio, image_size, num_images, seed, poll_interval, timeout)` | IDA Q 1.0 — submits and polls to completion |
| `edit_image(image_url, prompt, *, mode, mask_url, model)` | Inpaint, remove background, upscale |
| `remove_background(image_url)` / `upscale_image(image_url, *, scale)` | `edit_image` convenience wrappers |
| `generate_video(prompt, *, model, duration, aspect_ratio, image_url, resolution)` | Generate a video — blocks, returns `video_url`. Not for Seedance |
| `generate_seedance(prompt, *, model, duration, resolution, generate_audio, reference_videos, asset_ids, ...)` | Seedance 4–30s — submits and polls to completion |
| `register_video_asset(image_url)` | Register a face for Seedance `asset_ids` — free |
| `generate_music(prompt, *, model, duration, genre, mood, tempo, instrumental)` | Generate music from text |
| `generate_sfx(prompt, *, duration)` | Generate a sound effect |
| `generate_speech(text, *, voice_id, model, language, speed, pitch)` | Text to speech |
| `transcribe(audio_url, *, language)` | Speech to text |
| `generate_3d(mode, model, *, image, prompt, quality, format, options)` | Start a 3D mesh job |
| `get_3d_status(job_id)` / `wait_for_3d(job_id, ...)` | Poll a 3D job |
| `tryon(person_image_url, *, garment_image_url, garment_id, category, garments, ...)` | Start a virtual try-on job |
| `get_tryon_status(job_id)` / `wait_for_tryon(job_id, ...)` | Poll a try-on job |

**Language**

| Method | Description |
|--------|-------------|
| `chat(messages, *, model, temperature, max_tokens)` | Chat completion. `stream=True` raises `ValueError` |
| `chat_claude(messages, *, model, temperature, max_tokens, system)` | Premium Claude-class chat |
| `chat_bedrock(messages, *, model, temperature, max_tokens, system)` | Chat via Bedrock |
| `analyze_image(image_url, *, features)` | Vision analysis |
| `enhance_prompt(prompt, *, style)` | Rewrite a prompt for image models |
| `translate(text, target_language, *, source_language)` | Translate text |
| `gabriel_classify(prompt, *, language, context, enhance_prompt)` | Route a request to a feature + model |
| `gabriel_stream(prompt, *, language, context)` | Same, streamed as SSE frames |
| `gabriel_suggest(partial, *, tab, page)` | Autocomplete suggestions |
| `gabriel_recommend(*, page, credits_remaining, has_brand, recent_actions)` | Idle recommendations |

**Stability tools**

`stability_tools()`, `stability_run(tool_id, image_base64, ...)`, and the named
wrappers `stability_upscale`, `stability_remove_background`, `stability_erase`,
`stability_inpaint`, `stability_outpaint`, `stability_search_replace`,
`stability_recolor`, `stability_style_transfer`.

**Billing, tiers and webhooks** — all amounts in USD

| Method | Description |
|--------|-------------|
| `get_balance()` | Tier, credit counters, wallet balance, overage |
| `get_credits()` | Credit breakdown and per-operation costs |
| `get_pricing()` / `get_plans()` | Public price catalogue, subscription plans |
| `estimate_cost(operations)` | Price a batch before running it |
| `get_topup_packages()` / `create_topup(package)` | Wallet top-up catalogue and checkout |
| `get_wallet()` / `topup_wallet(amount_usd, *, pay_currency)` | Wallet state and an arbitrary-amount top-up |
| `get_transactions(*, page, page_size, type_filter)` / `get_invoices()` | History |
| `set_overage_limit(hard_limit_usd, *, project_id)` | Hard monthly overage cap |
| `get_tier_catalog()` / `get_current_tier()` / `compare_tiers()` / `subscribe_tier(slug)` | API tier plans |
| `apply_enterprise(company_name, contact_email, expected_usage, use_case, *, notes)` | Enterprise enquiry |
| `list_webhooks()` / `create_webhook(name, url, events, *, headers)` / `update_webhook(id, **kw)` / `delete_webhook(id)` / `test_webhook(id)` / `get_webhook_logs(id)` | Webhook management |

`topup_wallet(pay_currency="pln")` keeps the wallet in USD while letting a Polish
customer pay at Stripe in PLN (BLIK, card, bank transfer).

Storage (`/v1/storage/s3/*`) and per-endpoint analytics (`GET /v1/usage`) have no
SDK wrappers yet — call them over HTTP.

## Type Safety

The SDK ships a `py.typed` marker and full annotations on every parameter.
Responses are returned as `dict[str, Any]` — the API's JSON, unwrapped:

```python
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_...")

result = client.generate_image(prompt="test")
url: str = result["images"][0]
credits: float = result["credits_used"]
charged: float = result["billing"]["usd_charged"]
```

`fotohub.models` also ships Pydantic v2 models (`ImageGenerationResponse`,
`ImageResult`, …), but **no client method returns them** — they are not exported
from the package root and are kept only for callers that want to validate a
payload themselves.

## Requirements

| Dependency | Version |
|-----------|---------|
| Python | >= 3.9 |
| httpx | >= 0.24 |
| pydantic | >= 2.0 |

## Contributing

We welcome contributions! To get started:

```bash
# Clone the repository
git clone https://github.com/fotohubapp/sdk-python.git
cd sdk-python

# Install development dependencies
pip install -e ".[dev]"

# Run tests
pytest

# Run linting
ruff check .

# Run type checking
mypy fotohub/
```

Please ensure all tests pass and type checks are clean before submitting a pull request.

## Links

- [Documentation](https://docs.fotohub.app/sdk/python)
- [API Reference](https://docs.fotohub.app/api)
- [FOTOhub Platform](https://fotohub.app)
- [Changelog](https://github.com/fotohubapp/sdk-python/releases)
- [Issue Tracker](https://github.com/fotohubapp/sdk-python/issues)

## License

MIT License. See [LICENSE](LICENSE) for details.
