Metadata-Version: 2.5
Name: martini-client
Version: 0.4.0
Summary: The official Python client for the Martini API: generations, workflow runs, asset uploads, and the projects and canvases they land in.
Project-URL: Homepage, https://www.martini.film
Project-URL: Documentation, https://www.martini.film/docs/api
Project-URL: Source, https://github.com/c47-inc/martini/tree/main/packages/martini-python
Author-email: Martini <support@martini.film>
License-Expression: MIT
License-File: LICENSE
Keywords: api,generation,martini,sdk,video
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.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Description-Content-Type: text/markdown

# `martini-client`

The official Python client for the Martini API: generate images and video, run saved workflows, upload reusable
media, and find the projects and canvases they land in. Sync and async, fully typed, one dependency (`httpx`).

```bash
pip install martini-client
```

Requires Python 3.10 or later.

```python
from martini_client import Martini

martini = Martini()  # reads MARTINI_API_KEY

generation = martini.generations.subscribe(
    "bytedance/seedance-2.5/text-to-video",
    input={
        "prompt": "A practical miniature moon base at blue hour.",
        "duration": "auto",
        "resolution": "720p",
    },
    idempotency_key="shot-42",
)

print(generation.result()["video"]["url"], generation.olive_cost)  # raises GenerationFailedError if it failed
```

`subscribe()` returns whatever the generation settled as — inspect `generation.succeeded`, `.output`, and `.error`
when you would rather branch than raise; `result()` is the typed accessor (the `concurrent.futures` idiom).

## For coding agents

The whole contract in one place. If you are an agent integrating Martini, this section is enough.

- **Credentials.** `MARTINI_API_KEY` (`mtn_live_…`) in the environment; the key is a server-side secret. Confirm it
  with `martini.me()`; its `key_permissions` says what the key may do (`generations:write`, `workflows:read`, …;
  `write` implies `read`), so check it before a batch rather than after the first `API_KEY_PERMISSION_MISSING`.
  Point at a staging or local API with `MARTINI_API_URL`.
- **What you can call.** `martini.models()` lists the endpoint aliases this key can invoke, with pricing; pass
  `.endpoint` values to `generations.submit()`. Hard-coding an alias from `martini_client.ENDPOINTS` also works.
- **One call to generate.** `martini.generations.subscribe(endpoint, input={...})` submits and polls until the
  generation settles. It **returns** a `Generation` whose `status` is `completed`, `failed`, or `cancelled` — it
  does not raise on failure. Call `generation.result()` for the output (typed; raises `GenerationFailedError`
  with the `error` attached if it failed), or inspect `succeeded` / `output` / `error` to branch instead.
- **Model input** is a plain dict in the model's own field names (`prompt`, `image_url`, `duration`, ...). The
  server validates strictly: an unknown field is `UNSUPPORTED_INPUT`, a bad value `INVALID_INPUT`. The
  `TypedDict`s in `martini_client.types` spell out every field per endpoint; annotate the dict with one to have
  a type checker catch mistakes before the request.
- **Always pass `idempotency_key`** for anything you might retry; re-submitting with the same key returns the same
  generation instead of charging twice, and it makes the request safe for the client to retry on transport errors.
- **Errors** are `MartiniAPIError` subclasses keyed by HTTP status (`RateLimitError`, `InsufficientOlivesError`,
  `NotFoundError`, ...), each carrying the stable `code`. Retry only `RateLimitError` (after `retry_after`
  seconds) and `ServerError`; never retry validation, authentication, or balance errors.
- **Workflows.** `martini.workflows.subscribe(workflow_id, bins={...}, variables={...})` runs a saved workflow and
  returns `WorkflowSettled(run, results)`; `results.ready` are downloadable outputs with presigned `url`s that
  expire after `expires_in` seconds — fetch them promptly.
- **Where things land.** Without a `project_id`, generations and uploads go to the key's default project and runs
  to the workspace's Workflow runs project. To target a project the user works in, `martini.projects.list()`
  (optionally `query="Pilot"`) lists the organization's projects the key's user can see, each with `can_edit`;
  `martini.projects.canvases(project_id)` lists its canvases, `.default` being the one a generation lands on
  when no `canvas_id` is given.
- **Media in.** `martini.assets.upload("file.mp4")` returns an `Asset`; put `asset.url` in `image_url`,
  `video_urls`, and so on. Or pass public HTTPS URLs / `data:` URLs directly in those fields.

## Generations

`martini.generations` is the Martini-native generation API (`/v1/generations`). One resource describes a
generation from draft to terminal state; `output` is on the same object once `status == "completed"`, so you
poll one thing.

```python
submitted = martini.generations.submit(
    "nano-banana-2",
    input={"prompt": "A graphite storyboard frame of a desert observatory.", "resolution": "1K"},
    project_id=project_id,  # optional: override the key's default project
    idempotency_key="frame-042",  # optional but recommended
)
current = martini.generations.status(submitted.id)  # output is here once completed
settled = martini.generations.wait(submitted.id)  # poll until completed | failed | cancelled
cancelled = martini.generations.cancel(submitted.id)  # 409 REQUEST_ALREADY_TERMINAL once settled
```

`subscribe()` is `submit()` + `wait()`. Both `wait()` and `subscribe()` accept `poll_interval` (seconds, default 2,
backing off to 15), `timeout` (default 30 minutes; raises `MartiniTimeoutError`, the generation keeps running on
Martini), and `on_update` (called with every observation).

### Drafts

`mode="draft"` creates an editable canvas node without charging or contacting a provider. Start it later; the
run uses the draft's latest settings from the Martini canvas.

```python
draft = martini.generations.submit("nano-banana-2", input={"prompt": "…"}, mode="draft")
print(draft.status, draft.links.generate)  # "draft", "/v1/generations/<id>/generate"
started = martini.generations.generate(draft.id)  # -> pending
```

A draft never settles on its own; `wait()` on one raises `DraftNotStartedError` pointing you at `generate()`.

### The `Generation` object

| Attribute | Meaning |
| --- | --- |
| `id` | Public request id; also the canvas asset the generation lands on. |
| `endpoint` | The alias it was submitted against. |
| `status` | `draft`, `pending`, `running`, `completed`, `failed`, or `cancelled`. `is_terminal`, `succeeded` are convenience properties. |
| `output` | The model's output in its fal field names once completed (`output["video"]["url"]`, `output["images"][0]["url"]`); otherwise `None`. URLs are durable Martini storage URLs. `result()` returns it typed, or raises. |
| `error` | `ErrorDetail(code, message)` when failed or cancelled; otherwise `None`. |
| `olive_cost` | Snapshotted charge in olives; `0` when the generation runs on your organization's own key; `None` only while a concurrent idempotent submit is still being priced. |
| `provider_key_source` | `"martini"` (charged in olives) or `"byok"` (your organization's own provider key: the provider bills you, no olives). `None` for a draft. `Model.provider_key_source` says which applies to an endpoint before you submit. |
| `warnings` | Non-blocking input advisories (unbound reference images). The generation was still accepted. |
| `martini` | `GenerationPlacement(preview_url, project_id, canvas_id, asset_id)` — open `preview_url` to see it in Martini. |
| `created_at`, `started_at`, `completed_at` | ISO timestamps. |

### Typed inputs

With a literal endpoint, `submit()`/`subscribe()` narrow `input` to that endpoint's `TypedDict` (your editor
completes the fields) and type the returned `output`. To have `mypy`/`pyright` reject a misspelled field or an
out-of-range value as well, annotate the input with its `TypedDict` — an unannotated dict literal is accepted
as a plain mapping, and the server rejects it at submit time instead (`UNSUPPORTED_INPUT`, `INVALID_INPUT`):

```python
from martini_client.types import Seedance25ReferenceToVideoInput

input: Seedance25ReferenceToVideoInput = {
    "prompt": "@Image1 is the lead. Inside @Image2, @Image1 turns to camera. Follow the move from @Video1.",
    "image_urls": [character_url, location_url],
    "video_urls": [camera_guide_url],
    "duration": "8",
    "resolution": "720p",
}
generation = martini.generations.subscribe("bytedance/seedance-2.5/reference-to-video", input=input)
```

Reference arrays are positional: `image_urls[0]` is `@Image1`, `video_urls[0]` is `@Video1`, `audio_urls[0]` is
`@Audio1`. Bare names do not bind media. Multiple video references and every audio reference must be bound;
an unbound image is accepted with an advisory in `warnings`.

### Discovering models

```python
catalog = martini.models()
for model in catalog.with_capability("generate-video"):
    print(model.endpoint, model.billable_unit, model.model.pricing.display, model.pricing)
```

Only aliases your key can invoke are listed, with your organization's negotiated pricing rules when any (`model.pricing.rules`; `multiplier` is the legacy factor view).

## Workflows and runs

A **saved workflow** is your workspace's reusable, versioned machinery; a **placed workflow** is a copy on a
canvas. A **run** is one execution.

```python
workflows = martini.workflows.list()  # saved; .list(all=True) adds placed ones
workflow = workflows.find("Script to Video")  # by name (case-insensitive) or id
print(workflow.inputs.bins, workflow.inputs.variables)

script = martini.assets.upload("script.pdf.png")  # inputs are assets; upload first

settled = martini.workflows.subscribe(
    workflow.id,
    bins={"Script": [script.asset_id]},  # deliberate replacement; omit bins to keep saved presets
    variables={"Tone": "deadpan"},
    fingerprint=workflow.fingerprint,  # refuse to run if the machinery changed since you read it
    idempotency_key="job-7",
)
for output in settled.results.ready:
    print(output.filename, output.url)  # presigned; valid for output.expires_in seconds
```

`workflows.run()` starts without waiting; `runs.status(run_id)` (with `activity=True` for each step's event trail),
`runs.results(run_id)` (the takes settled so far, at any time), and `runs.wait(run_id)` poll an existing run;
`runs.resume(run_id)` retries a `failed` run's failed step with the same inputs (`ConflictError` for any other status). `subscribe()` and
`wait()` return a `failed` or `cancelled` run with its `error` rather than raising; `.raise_for_status()` turns
it into a `WorkflowRunFailedError`. `olive_budget` caps generation spend; omit it to let Martini arm the rail from
the estimate. Runs started here auto-approve.

## Projects and canvases

A **project** is where generations, uploads, and runs land; a **canvas** is one board inside it. `martini.projects`
lists the organization's projects the key's user can see — the same set the MCP connector's `get_projects` shows
them — so an id picked here works wherever a `project_id` or `canvas_id` is accepted.

```python
projects = martini.projects.list(query="Pilot")  # ranked: exact id or name, then prefix, contains, every word
pilot = projects.find("Pilot")  # by name (case-insensitive) or id; .editable keeps the ones a run can go to
canvases = martini.projects.canvases(pilot.id)
canvas = canvases.default  # where the project opens, and where a generation without canvas_id lands

martini.generations.submit("nano-banana-2", input={"prompt": "…"}, project_id=pilot.id, canvas_id=canvas.id)
```

`list()` takes `query` (a name or id, or a fragment of either), `exact_name=True` (keep exact name matches only),
and `limit` (1–50, default 20); `ProjectList.truncated` says whether more matched. Each `Project` carries
`can_edit` — `False` means the user can only view it, and naming it for a run raises `PROJECT_WRITE_FORBIDDEN` —
and `open_in_martini`, a link to it in the app. A project of another organization, or one the user cannot read,
raises `NotFoundError` with `PROJECT_NOT_FOUND` from `canvases()` (never a 403, so ids cannot be probed). A project
whose document cannot be opened raises `ConflictError` with `PROJECT_DOCUMENT_TOO_LARGE` (durable; see Errors) or
`ServerError` with `PROJECT_DOCUMENT_UNAVAILABLE` (retry).

## Assets

```python
asset = martini.assets.upload("reference.mp4")  # path, bytes, or a binary file object
asset = martini.assets.upload(data, filename="ref.png", content_type="image/png")
asset = martini.assets.upload("ref.png", project_id=..., canvas_id=...)  # another project you can edit
asset = martini.assets.upload("ref.png", wait=False)  # return after complete; status queued/processing
```

`upload()` is prepare → PUT directly to storage → complete → poll until processed. Limits: 10 MB JPEG/PNG/WebP,
30 MB MP4, 15 MB MP3/WAV. When direct storage is unavailable it raises `StorageUnavailableError` — it does not
switch transports on its own; `martini.assets.upload_base64(...)` (same arguments) is the explicit alternative.
The lower-level steps are exposed too: `prepare_upload(files)`, `complete_upload(asset_ids)`, `get(asset_id)`,
`wait(asset_id)`.

## Tools: every MCP tool with the API key

`martini.tools` exposes the Martini MCP server's whole tool surface over `/v1/tools`, without an MCP session or
OAuth. `list()` returns the manifest of the tools the key can invoke — each tool's name, description, the JSON
Schema of its arguments, and its annotations — and `call(name, arguments)` runs one tool as the key's user,
returning the tool's JSON result with the MCP envelope removed. This is the surface the `martini` CLI is generated
from; use it directly when a typed wrapper does not exist yet.

```python
manifest = martini.tools.list()
tool = manifest.get("get_asset")  # tool.input_schema, tool.annotations.read_only, ...

asset = martini.tools.call("get_asset", {"assetId": asset_id}, select=["asset.id", "asset.status"])
```

`select` keeps only the listed dotted field paths (through arrays: `nodes.id`), applied on the server. Tools that
return an image or audio block answer with a `media` list (`{type, mimeType, data}`, base64). A tool failure raises
the status-keyed error with the tool's own `code` (`ASSET_NOT_READY` as `ConflictError`, `INVALID_ARGUMENTS` as
`InvalidRequestError`, `TOOL_NOT_FOUND` as `NotFoundError` — also the answer for the workflows tools when the key's
user has no access to them). Every key can call this surface; the generation-API grant is not required.

## Billing: the balance before a batch

`martini.billing.balance()` (`GET /v1/billing/balance`) is the pre-flight before submitting work: the organization's
olive balance, in olives only — never money, never what Martini paid a provider. Every key may call it; it needs no
extra permission and no enterprise grant. The answer is typed on `kind`: an API key gets an `OrganizationBalance`,
a workflow run's key gets a `RunBalance` with the run's own budget and a single `covered` bit — a run never learns
its organization's numbers.

```python
from martini_client import OrganizationBalance

balance = martini.billing.balance()
if isinstance(balance, OrganizationBalance) and not balance.covers(len(jobs) * 200):
    raise SystemExit("not enough olives for this batch")
# balance.olives: Olives(available, granted, included, purchased, used_this_period, reserved, unlimited, resets_at),
#   or None when balance.billing_enabled is False (this deployment charges nothing)
# balance.plan: BalancePlan(id, status, period_end); balance.as_of: when the numbers were read
```

`covers(olives)` is true when billing is disabled, when the organization is `unlimited`, or when `available` — the
figure the next charge is checked against, with in-flight reservation holds netted out — is at least the amount.
`reserved` is the size of those holds (`None` where the deployment records none). A `RunBalance.budget` carries
`source` beside its numbers: `"explicit"` for a budget the caller passed, `"auto"` when Martini arms the rail per
action. The number is cached for 10 seconds per organization; `as_of` shows its age. `503 BALANCE_UNAVAILABLE` means the
billing system did not answer; the client retries it honouring `Retry-After`, and a `ServerError` that still
surfaces should be waited out, never read as zero. An account key (`martini login` approved "as you") passes
`organization_id=` to choose one of its organizations; omitted, it reads the personal one.

### The reporting routes

The rest of `martini.billing` is the Billing API proper — an enterprise feature enabled per organization, read with a
key that holds `billing:read` (a workflow run key is refused with `RUN_KEY_NOT_ALLOWED`). Without the grant every call
raises `AccessDeniedError` with code `BILLING_API_NOT_ENABLED`. Every figure is olives the organization spent; the
only money is the organization's own (`olive_value_usd`, an invoice `total`), and no response names a provider.

```python
account = martini.billing.account()  # plan, olives, auto_top_up, budget, olive_value_usd
pricing = martini.billing.pricing(model_id="kling-v3")  # the organization's price card
costs = martini.billing.costs(bucket="week", group_by=["project", "model"])
page = martini.billing.transactions(types=["generation", "refund"], limit=1000)
invoices = martini.billing.invoices()
link = martini.billing.invoice_download(invoices.invoices[0].id)  # link.url, good for link.expires_in seconds
```

- `account()` (`GET /v1/billing/account`) → `BillingAccount`: the status call — plan, the balance's `Olives`,
  `AutoTopUp`, the organization-wide `Budget` (`None` when there is none), and `olive_value_usd`.
- `pricing(model_id=None)` (`GET /v1/billing/pricing`) → `Pricing`: every model the organization can generate
  with as a `ModelPrice` whose `kind` is `"card"` (a `PriceCard` of finished olive figures: `olives_per_unit`,
  `resolution_tiers`, `by_quality`, `surcharges`, …), `"quote"` (only the generate-time quote prices it), or
  `"direct"` (settled outside Martini); plus `agent_work` rates (`AgentWorkRate`) as olives per million tokens or
  milliseconds, by the chat model id the picker shows.
- `costs(since=, until=, bucket=, group_by=, project_ids=, api_key_ids=, user_ids=, models=, origins=, types=, limit=, cursor=)`
  (`GET /v1/billing/costs`) → `Costs`: spend as calendar buckets (`"day"` | `"week"` | `"month"`, UTC) over
  `[since, until)` — default the trailing 30 days, at most 366 — each holding one `CostCell` per group (up to two of
  `project`, `model`, `actor`, `user`, `apiKey`, `origin`, `type`, `billingMode`) with `olives`, `refunded_olives`,
  `net_olives`, and `generations`. Pages run over buckets; pass `next_cursor` back as `cursor=` with the same filters.
  Instants are RFC 3339 strings or aware `datetime`s.
- `transactions(since=, until=, types=, project_id=, api_key_id=, user_id=, generation_id=, limit=, cursor=)`
  (`GET /v1/billing/transactions`) → `Transactions`: the ledger, newest first, keyset-paginated (default 100 rows, at
  most 1000). Charges are negative, grants and refunds positive; a generation row names the model the user chose and
  the surface it came from.
- `invoices()` (`GET /v1/billing/invoices`) → `Invoices`, and `invoice_download(invoice_id)`
  (`GET /v1/billing/invoices/{invoiceId}/download`) → `InvoiceDownload`: the organization's invoices, and a fresh PDF
  link good for `expires_in` seconds — ask again rather than storing it. A draft has no PDF yet (`NotFoundError`,
  `INVOICE_NOT_FOUND`).

Every `/v1/billing` answer carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (120 requests
per sliding minute per key, the balance included); past the allowance the client retries on `Retry-After` and then
raises `RateLimitError` (`RATE_LIMITED`). A parameter the route does not accept raises `InvalidRequestError` with
`INVALID_QUERY`, a cursor it did not issue with `INVALID_CURSOR`.

### Budgets

A budget is a hard olive limit on one scope — the organization, a project, a member, or an API key — per
`lifetime` or per calendar `month` (UTC). A charge that would take the scope past it is refused with 402 before any
work starts — on `POST /v1/generations` as `SPEND_CAP_EXCEEDED`, the Generation API's one cap code, with the message
naming the scope and its limit. Budgets are the one thing the Billing API writes; the writes need a key that holds
`billing:write` (the reads, `billing:read`).

```python
from martini_client import BudgetAlert

budget = martini.billing.create_budget(
    scope_type="project",
    scope_id=project.id,
    amount_olives=5000,
    period="month",
    alerts=[BudgetAlert(at_olives=4000)],  # email the organization's admins once per period
)
for b in martini.billing.budgets().budgets:  # the organization-wide one first
    print(b.scope.type, b.scope.name, b.status.remaining_olives, b.status.blocked)
martini.billing.update_budget(budget.id, amount_olives=6000)  # or alerts=[...] to replace them, [] to clear
martini.billing.delete_budget(budget.id)  # the project is no longer capped
```

- `budgets()` (`GET /v1/billing/budgets`) → `Budgets`, and `budget(budget_id)` (`GET /v1/billing/budgets/{budgetId}`)
  → `Budget`: each with its `status` — `spent_olives`, `reserved_olives`, `remaining_olives` in the current period,
  and `blocked` when the next charge would be refused. The organization-wide budget is also `account().budget`; a
  key's own is `me().key_budget`.
- `create_budget(scope_type=, scope_id=, amount_olives=, period=, alerts=)` (`POST /v1/billing/budgets`) → `Budget`.
  `scope_id` is the project, member (user), or key id, and is left out for the organization. One budget per scope:
  `ConflictError` with `BUDGET_EXISTS` says to change that one instead (find it in `budgets()`); a scope that
  is not the organization's is `NotFoundError` with `BUDGET_SCOPE_NOT_FOUND`; a body the server rejects is
  `InvalidRequestError` with `BUDGET_INVALID`.
- `update_budget(budget_id, amount_olives=, alerts=)` (`PATCH /v1/billing/budgets/{budgetId}`) → `Budget`: pass
  one or both; `alerts` replaces the list. `delete_budget(budget_id)` (`DELETE /v1/billing/budgets/{budgetId}`) →
  `BudgetDeleted`. An unknown id is `NotFoundError` with `BUDGET_NOT_FOUND`.

The writes are never retried on their own.

## Errors

Every non-2xx answer raises a subclass of `MartiniAPIError` chosen by HTTP status; branch on the class, then on
the stable `code` when you need the exact cause.

| Class | Status | Typical codes |
| --- | --- | --- |
| `InvalidRequestError` | 400, 422 | `INVALID_REQUEST`, `INVALID_INPUT`, `UNSUPPORTED_INPUT`, `INVALID_MEDIA`, `MEDIA_TOO_LARGE`, `WORKFLOW_BIN_REQUIRED`, `WORKFLOW_VARIABLE_NOT_FOUND` |
| `AuthenticationError` | 401 | `INVALID_API_KEY`, `API_KEY_REVOKED` |
| `InsufficientOlivesError` | 402 | `INSUFFICIENT_OLIVES`, `SPEND_CAP_EXCEEDED` (a spend cap or budget refused the generation; the message names which) |
| `AccessDeniedError` | 403 | `API_KEY_PERMISSION_MISSING` (`error.body["required"]` names the permission the key lacks), `PROJECT_ACCESS_DENIED`, `PROJECT_WRITE_FORBIDDEN`, `GENERATION_API_DISABLED`, `WORKFLOWS_NOT_ENABLED` |
| `NotFoundError` | 404 | `ENDPOINT_NOT_REGISTERED`, `REQUEST_NOT_FOUND`, `PROJECT_NOT_FOUND`, `CANVAS_NOT_FOUND`, `WORKFLOW_NOT_FOUND`, `WORKFLOW_RUN_NOT_FOUND` |
| `ConflictError` | 409 | `REQUEST_ALREADY_TERMINAL`, `DRAFT_ALREADY_GENERATING`, `WORKFLOW_CHANGED`, `PROJECT_DOCUMENT_TOO_LARGE`, `BUDGET_EXISTS` |
| `RateLimitError` | 429 | `RATE_LIMITED` — `retry_after` carries the server's hint in seconds |
| `ServerError` | 5xx | `GENERATION_SUBMISSION_FAILED`, `GENERATION_API_ACCESS_UNAVAILABLE`, `PROJECT_DOCUMENT_UNAVAILABLE` |

```python
from martini_client import ConflictError, RateLimitError

try:
    run = martini.workflows.run(workflow.id, bins={"References": [asset_id]}, fingerprint=fingerprint)
except ConflictError as error:
    if error.code == "WORKFLOW_CHANGED":
        ...  # re-read the workflow; error.body["fingerprint"] is the current one
except RateLimitError as error:
    time.sleep(error.retry_after or 10)
```

`error.body` holds the full JSON for fields beyond `error`/`code` (the `inputs` a workflow accepts, the current
`fingerprint`). Outside the API: `MartiniTransportError` (no response after retries), `MartiniTimeoutError`
(a `wait`/`subscribe` outlasted `timeout`), `AssetUploadError`/`StorageUnavailableError` (uploads),
`GenerationFailedError`/`WorkflowRunFailedError` (from `raise_for_status()`), `ConfigurationError` (bad
constructor arguments). All derive from `MartiniError`.

`PROJECT_DOCUMENT_TOO_LARGE` (409) is durable, not transient: the project cannot generate, and its canvases cannot
be listed, until support rebuilds its document. Surface the message and stop.

## Configuration

```python
martini = Martini(
    api_key="mtn_live_…",  # default: MARTINI_API_KEY
    base_url="https://api.martini.film",  # default: MARTINI_API_URL, then production; HTTPS except localhost
    api_version="2026-08-13",  # X-Martini-API-Version; default is the version this release targets
    timeout=60.0,  # seconds per request
    max_retries=2,  # for GETs and idempotent submits, on transport errors / 429 / 5xx
    http_client=httpx.Client(...),  # optional: proxies, custom transports; you own its lifetime
)
```

Retries back off exponentially with jitter (0.5 s, 1 s, 2 s, … capped at 8 s) and honour `Retry-After`. A
`POST` without an `idempotency_key` is never retried automatically — the server could not tell a retry from a
second request. `with Martini() as martini:` closes the connection pool on exit.

### Async

```python
from martini_client import AsyncMartini

async with AsyncMartini() as martini:
    generation = await martini.generations.subscribe("nano-banana-2", input={"prompt": "…"})
```

Same methods, same arguments, `await`ed.

## Supported methods

- `me()`, `models()`
- `generations.submit()`, `.status()`, `.cancel()`, `.generate()`, `.wait()`, `.subscribe()`
- `projects.list()`, `.canvases()`
- `workflows.list()`, `.get()`, `.create()`, `.run()`, `.subscribe()`
- `runs.status()`, `.results()`, `.wait()`
- `assets.upload()`, `.upload_base64()`, `.prepare_upload()`, `.complete_upload()`, `.get()`, `.wait()`
- `tools.list()`, `.call()`
- `billing.balance()`, `.account()`, `.pricing()`, `.costs()`, `.transactions()`, `.invoices()`, `.invoice_download()`,
  `.budgets()`, `.budget()`, `.create_budget()`, `.update_budget()`, `.delete_budget()`

Wire reference: [Generation API](https://www.martini.film/docs/api),
[Workflows API](https://www.martini.film/docs/workflows), and [Billing API](https://www.martini.film/docs/billing).
The JavaScript client is
[`@martini-film/client`](https://www.npmjs.com/package/@martini-film/client).

The API key is a server-side secret. Do not embed it in notebooks you share, client-side code, or public
environment variables.

### Workflow warnings

Workflow run, summary, results, and Action responses carry `warnings` separately
from errors. Each entry has `code`, `message`, and `action_id`.
A completed run can have warnings; polling and success checks still use the
existing status values. Warnings accumulate across attempts and do not clear
errors or replace required outputs. Older responses without warnings parse as an empty list.


### Saved presets and run destinations

Use the saved workflow ID to run its saved definition and defaults. Omit `bins` to keep saved presets;
only bins marked `required` in discovery need caller input. `defaultSource` is `saved_preset`, `saved_files`,
or `null` when no default exists. Python exposes these as `required` and `default_source`.
Explicit bin arrays replace contents; `[]` empties a media bin. JSON file inputs still require files. Omitted variables keep their saved values.

`projectId` (`project_id` in Python) only chooses the destination for a fresh execution canvas. Omit it to use
Workflow runs. It never selects a source workflow or adopts the destination project's characters. Do not fetch
assets from a placed copy to reconstruct saved presets. `openInMartini` opens the execution copy, so its URL may
differ from the source project's URL.
