cutpro 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cutpro/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ from ._client import AsyncCutPro, CutPro, CutProError, Transport, __version__, proxy_request, urllib_transport, verify_webhook
2
+ from .types import (
3
+ AspectRatio,
4
+ CreateGenerationRequest,
5
+ Generation,
6
+ GenerationFile,
7
+ GenerationMode,
8
+ GenerationOutput,
9
+ GenerationRequest,
10
+ Media,
11
+ Model,
12
+ ModelList,
13
+ PriceList,
14
+ PublicModel,
15
+ PublicPrice,
16
+ Quote,
17
+ Voice,
18
+ )
19
+
20
+ __all__ = [
21
+ "AspectRatio",
22
+ "AsyncCutPro",
23
+ "CreateGenerationRequest",
24
+ "CutPro",
25
+ "CutProError",
26
+ "Generation",
27
+ "GenerationFile",
28
+ "GenerationMode",
29
+ "GenerationOutput",
30
+ "GenerationRequest",
31
+ "Media",
32
+ "Model",
33
+ "ModelList",
34
+ "PriceList",
35
+ "PublicModel",
36
+ "PublicPrice",
37
+ "Quote",
38
+ "Transport",
39
+ "Voice",
40
+ "__version__",
41
+ "proxy_request",
42
+ "urllib_transport",
43
+ "verify_webhook",
44
+ ]
cutpro/_client.py ADDED
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import re
10
+ import shutil
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Optional, Tuple, Union
18
+
19
+ from .types import (
20
+ CreateGenerationRequest,
21
+ DownloadLink,
22
+ Generation,
23
+ GenerationRequest,
24
+ Media,
25
+ ModelEndpoint,
26
+ ModelKind,
27
+ ModelList,
28
+ PriceList,
29
+ Quote,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from typing_extensions import Unpack
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ API_ORIGIN = "https://api.cut.pro"
38
+ API_PATH = "/api/v1"
39
+ PRICES_PATH = "/public/generations/models"
40
+ MAX_SERVER_WAIT_SECONDS = 60
41
+ DEFAULT_WAIT_TIMEOUT = 600.0
42
+ # Longer than the 60 seconds a create or a get may be held by the server.
43
+ DEFAULT_TIMEOUT = 90.0
44
+ MAX_RETRIES = 2
45
+ RETRY_BASE_SECONDS = 0.5
46
+ RETRYABLE = frozenset({408, 425, 429, 500, 502, 503, 504})
47
+ WEBHOOK_TOLERANCE_SECONDS = 300
48
+ DOWNLOAD_CHUNK_BYTES = 1 << 20
49
+ # The proxy only forwards JSON requests; uploads go from your server with the client.
50
+ MAX_PROXY_BODY_BYTES = 1 << 20
51
+ LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
52
+ # What `proxy_request()` forwards: everything a browser app needs to price, start and follow a generation, nothing that reads the account.
53
+ PROXIED = (
54
+ ("GET", re.compile(r"^/models(/featured)?$")),
55
+ ("GET", re.compile(r"^/prices$")),
56
+ ("POST", re.compile(r"^/generations(/quote)?$")),
57
+ ("GET", re.compile(r"^/generations/[\w-]+$")),
58
+ )
59
+
60
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Mapping[str, str], bytes]]
61
+ """(method, url, headers, body, timeout) -> (status, response headers, body). Swap it to use another HTTP library or to stub the API in tests."""
62
+
63
+
64
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
65
+ # A redirect would carry the key to wherever it points, so it is answered as the error it is instead.
66
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
67
+ return None
68
+
69
+
70
+ _opener = urllib.request.build_opener(_NoRedirect)
71
+
72
+
73
+ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> Tuple[int, Mapping[str, str], bytes]:
74
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
75
+ try:
76
+ with _opener.open(request, timeout=timeout) as response:
77
+ return int(response.status), dict(response.headers), response.read()
78
+ except urllib.error.HTTPError as error:
79
+ return error.code, dict(error.headers or {}), error.read()
80
+
81
+
82
+ class CutProError(Exception):
83
+ """An API error: `code` is stable (switch on it), `status` is the HTTP status, or 0 when the client raised it."""
84
+
85
+ def __init__(self, code: str, status: int, extra: Optional[Dict[str, Any]] = None) -> None:
86
+ super().__init__(f"{status} {code}" if status else code)
87
+ self.code = code
88
+ self.status = status
89
+ self.extra = extra
90
+
91
+
92
+ def _secure_base(base_url: str) -> str:
93
+ parsed = urllib.parse.urlsplit(base_url)
94
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in LOCAL_HOSTS):
95
+ raise CutProError("INSECURE_BASE_URL", 0, {"base_url": base_url})
96
+ return base_url.rstrip("/")
97
+
98
+
99
+ def _retry_after(headers: Mapping[str, str], attempt: int) -> float:
100
+ value = next((v for k, v in headers.items() if k.lower() == "retry-after"), "")
101
+ try:
102
+ return min(max(float(value), 0.0), 30.0)
103
+ except ValueError:
104
+ return RETRY_BASE_SECONDS * 2**attempt
105
+
106
+
107
+ def _quote_id(id: str) -> str:
108
+ return urllib.parse.quote(id, safe="")
109
+
110
+
111
+ class CutPro:
112
+ """Synchronous client. Thread safe: it keeps no connection state."""
113
+
114
+ def __init__(
115
+ self,
116
+ api_key: Optional[str] = None,
117
+ *,
118
+ base_url: Optional[str] = None,
119
+ workspace_id: Optional[str] = None,
120
+ timeout: float = DEFAULT_TIMEOUT,
121
+ max_retries: int = MAX_RETRIES,
122
+ transport: Optional[Transport] = None,
123
+ ) -> None:
124
+ self._api_key = api_key if api_key is not None else os.environ.get("CUTPRO_API_KEY")
125
+ self._direct = base_url is None
126
+ self.base_url = _secure_base(base_url or f"{API_ORIGIN}{API_PATH}")
127
+ self.workspace_id = workspace_id
128
+ self.timeout = timeout
129
+ self.max_retries = max_retries
130
+ self.transport = transport or urllib_transport
131
+ self.models = Models(self)
132
+ self.generations = Generations(self)
133
+ self.media = MediaLibrary(self)
134
+
135
+ def __repr__(self) -> str:
136
+ return f"CutPro(base_url={self.base_url!r}, api_key={'set' if self._api_key else 'missing'})"
137
+
138
+ def _request(
139
+ self,
140
+ method: str,
141
+ path: str,
142
+ *,
143
+ query: Optional[Mapping[str, Union[str, int, None]]] = None,
144
+ json_body: Optional[Mapping[str, Any]] = None,
145
+ body: Optional[bytes] = None,
146
+ headers: Optional[Mapping[str, str]] = None,
147
+ auth: bool = True,
148
+ retry: bool = True,
149
+ timeout: Optional[float] = None,
150
+ ) -> Any:
151
+ # The public price list lives outside /api/v1; a proxy serves it at /prices.
152
+ url = f"{API_ORIGIN}{PRICES_PATH}" if path == "/prices" and self._direct else f"{self.base_url}{path}"
153
+ params = {key: str(value) for key, value in (query or {}).items() if value is not None}
154
+ if params:
155
+ url = f"{url}?{urllib.parse.urlencode(params)}"
156
+ sent = {"Accept": "application/json", "User-Agent": f"cutpro-python/{__version__}", **(headers or {})}
157
+ if auth and self._api_key:
158
+ sent["X-Api-Key"] = self._api_key
159
+ if auth and self.workspace_id:
160
+ sent["X-Workspace-Id"] = self.workspace_id
161
+ if json_body is not None:
162
+ sent["Content-Type"] = "application/json"
163
+ body = json.dumps(json_body).encode()
164
+
165
+ attempt = 0
166
+ while True:
167
+ try:
168
+ status, response_headers, raw = self.transport(method, url, sent, body, timeout or self.timeout)
169
+ except (OSError, TimeoutError):
170
+ if not retry or attempt >= self.max_retries:
171
+ raise
172
+ time.sleep(RETRY_BASE_SECONDS * 2**attempt)
173
+ attempt += 1
174
+ continue
175
+ if status in RETRYABLE and retry and attempt < self.max_retries:
176
+ time.sleep(_retry_after(response_headers, attempt))
177
+ attempt += 1
178
+ continue
179
+ break
180
+ try:
181
+ payload = json.loads(raw) if raw else None
182
+ except ValueError:
183
+ payload = None
184
+ if status >= 400:
185
+ error = payload if isinstance(payload, dict) else {}
186
+ code = error.get("code")
187
+ raise CutProError(code if isinstance(code, str) else "UNEXPECTED_RESPONSE", status, error.get("extra"))
188
+ return payload
189
+
190
+
191
+ class Models:
192
+ def __init__(self, client: CutPro) -> None:
193
+ self._client = client
194
+
195
+ def list(self, *, kind: Optional[ModelKind] = None, endpoint: Optional[ModelEndpoint] = None, q: Optional[str] = None, id: Optional[str] = None) -> ModelList:
196
+ """Every model with its modes, resolutions, seconds, voices and prices in credits, dollars and reais."""
197
+ return self._client._request("GET", "/models", query={"kind": kind, "endpoint": endpoint, "q": q, "id": id})
198
+
199
+ def featured(self, *, kind: Optional[ModelKind] = None, endpoint: Optional[ModelEndpoint] = None) -> ModelList:
200
+ """Only the strongest models of each kind."""
201
+ return self._client._request("GET", "/models/featured", query={"kind": kind, "endpoint": endpoint})
202
+
203
+ def prices(self) -> PriceList:
204
+ """Public price list of the image, video and voice models in credits, dollars and reais. Needs no key."""
205
+ return self._client._request("GET", "/prices", auth=False)
206
+
207
+
208
+ class Generations:
209
+ def __init__(self, client: CutPro) -> None:
210
+ self._client = client
211
+
212
+ def quote(self, **request: Unpack[GenerationRequest]) -> Quote:
213
+ """The exact total of a request, in credits. Free, generates nothing."""
214
+ return self._client._request("POST", "/generations/quote", json_body=request)
215
+
216
+ def create(self, *, idempotency_key: Optional[str] = None, wait: Optional[int] = None, **request: Unpack[CreateGenerationRequest]) -> Generation:
217
+ """Starts a generation. Without `credits`, it quotes first and agrees to that total. Safe to retry: the idempotency key never charges twice."""
218
+ credits = request.pop("credits", None)
219
+ webhook_url = request.pop("webhook_url", None)
220
+ if credits is None:
221
+ credits = int(self._client._request("POST", "/generations/quote", json_body=request)["credits"])
222
+ body: Dict[str, Any] = {**request, "credits": credits}
223
+ if webhook_url:
224
+ body["webhook_url"] = webhook_url
225
+ return self._client._request(
226
+ "POST",
227
+ "/generations",
228
+ query={"wait": wait},
229
+ json_body=body,
230
+ headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())},
231
+ timeout=self._client.timeout + (wait or 0),
232
+ )
233
+
234
+ def get(self, id: str, *, wait: Optional[int] = None) -> Generation:
235
+ return self._client._request("GET", f"/generations/{_quote_id(id)}", query={"wait": wait}, timeout=self._client.timeout + (wait or 0))
236
+
237
+ def wait(self, id: str, *, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT) -> Generation:
238
+ """Polls until the generation is `ready` or `failed` and returns it either way. Each poll is held by the server for up to 60 seconds."""
239
+ deadline = time.monotonic() + timeout
240
+ while True:
241
+ generation = self.get(id, wait=min(MAX_SERVER_WAIT_SECONDS, max(0, int(deadline - time.monotonic()))))
242
+ if generation["status"] != "processing":
243
+ return generation
244
+ if time.monotonic() + interval >= deadline:
245
+ raise CutProError("WAIT_TIMEOUT", 0, {"id": id})
246
+ time.sleep(interval)
247
+
248
+ def generate(self, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT, **request: Unpack[CreateGenerationRequest]) -> Generation:
249
+ """Quotes, creates and waits. Raises `CutProError` with the generation's `error_code` when it fails."""
250
+ started = self.create(idempotency_key=idempotency_key, wait=min(MAX_SERVER_WAIT_SECONDS, int(timeout)), **request)
251
+ generation = self.wait(started["id"], interval=interval, timeout=timeout) if started["status"] == "processing" else started
252
+ if generation["status"] == "failed":
253
+ raise CutProError(generation["error_code"] or "GENERATION_FAILED", 0, {"id": generation["id"]})
254
+ return generation
255
+
256
+ def download(self, id: str) -> DownloadLink:
257
+ """Full quality link, valid for 6 hours. After downloading, the credits can no longer be asked back."""
258
+ return self._client._request("GET", f"/generations/{_quote_id(id)}/download")
259
+
260
+ def download_to(self, id: str, path: Union[str, Path]) -> Path:
261
+ """Streams the full quality file to `path` without holding it in memory, and returns the path."""
262
+ url = self.download(id)["url"]
263
+ if urllib.parse.urlsplit(url).scheme != "https":
264
+ raise CutProError("INSECURE_DOWNLOAD_URL", 0, {"id": id})
265
+ target = Path(path)
266
+ partial = target.with_name(f"{target.name}.part")
267
+ with urllib.request.urlopen(url, timeout=self._client.timeout) as response, partial.open("wb") as file:
268
+ shutil.copyfileobj(response, file, DOWNLOAD_CHUNK_BYTES)
269
+ partial.replace(target)
270
+ return target
271
+
272
+ def webhook_secret(self) -> str:
273
+ """The secret that signs this key's webhooks, for `verify_webhook()`. Keep it on the server."""
274
+ return str(self._client._request("GET", "/generations/webhook-secret")["secret"])
275
+
276
+
277
+ class MediaLibrary:
278
+ def __init__(self, client: CutPro) -> None:
279
+ self._client = client
280
+
281
+ def upload(
282
+ self,
283
+ data: Union[bytes, str, Path, None] = None,
284
+ *,
285
+ filename: Optional[str] = None,
286
+ content_type: Optional[str] = None,
287
+ url: Optional[str] = None,
288
+ ) -> Media:
289
+ """Uploads an image, video or sound (bytes or a file path, up to 100 MB), or reads one from a public `url`, for `image_ids`, `audio_ids` or `video_id`."""
290
+ if url is not None:
291
+ return self._client._request("POST", "/generations/media", json_body={"url": url})
292
+ if data is None:
293
+ raise ValueError("Pass the file bytes, a file path or url=.")
294
+ if not isinstance(data, bytes):
295
+ path = Path(data)
296
+ filename = filename or path.name
297
+ data = path.read_bytes()
298
+ name = (filename or "upload").replace('"', "").replace("\r", "").replace("\n", "")
299
+ kind = content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
300
+ boundary = uuid.uuid4().hex
301
+ body = (
302
+ f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\nContent-Type: {kind}\r\n\r\n'.encode()
303
+ + data
304
+ + f"\r\n--{boundary}--\r\n".encode()
305
+ )
306
+ return self._client._request(
307
+ "POST", "/generations/media", body=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, retry=False
308
+ )
309
+
310
+
311
+ class AsyncCutPro:
312
+ """Asyncio client with the same methods as `CutPro`. Each call runs in a worker thread and waiting never blocks the event loop."""
313
+
314
+ def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
315
+ self.sync = CutPro(api_key, **options)
316
+ self.models = _AsyncModels(self.sync)
317
+ self.generations = _AsyncGenerations(self.sync)
318
+ self.media = _AsyncMedia(self.sync)
319
+
320
+ def __repr__(self) -> str:
321
+ return f"Async{self.sync!r}"
322
+
323
+
324
+ class _AsyncModels:
325
+ def __init__(self, client: CutPro) -> None:
326
+ self._sync = client.models
327
+
328
+ async def list(self, **filters: Any) -> ModelList:
329
+ return await asyncio.to_thread(self._sync.list, **filters)
330
+
331
+ async def featured(self, **filters: Any) -> ModelList:
332
+ return await asyncio.to_thread(self._sync.featured, **filters)
333
+
334
+ async def prices(self) -> PriceList:
335
+ return await asyncio.to_thread(self._sync.prices)
336
+
337
+
338
+ class _AsyncGenerations:
339
+ def __init__(self, client: CutPro) -> None:
340
+ self._sync = client.generations
341
+
342
+ async def quote(self, **request: Unpack[GenerationRequest]) -> Quote:
343
+ return await asyncio.to_thread(lambda: self._sync.quote(**request))
344
+
345
+ async def create(self, *, idempotency_key: Optional[str] = None, wait: Optional[int] = None, **request: Unpack[CreateGenerationRequest]) -> Generation:
346
+ return await asyncio.to_thread(lambda: self._sync.create(idempotency_key=idempotency_key, wait=wait, **request))
347
+
348
+ async def get(self, id: str, *, wait: Optional[int] = None) -> Generation:
349
+ return await asyncio.to_thread(self._sync.get, id, wait=wait)
350
+
351
+ async def wait(self, id: str, *, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT) -> Generation:
352
+ deadline = time.monotonic() + timeout
353
+ while True:
354
+ generation = await self.get(id, wait=min(MAX_SERVER_WAIT_SECONDS, max(0, int(deadline - time.monotonic()))))
355
+ if generation["status"] != "processing":
356
+ return generation
357
+ if time.monotonic() + interval >= deadline:
358
+ raise CutProError("WAIT_TIMEOUT", 0, {"id": id})
359
+ await asyncio.sleep(interval)
360
+
361
+ async def generate(self, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT, **request: Unpack[CreateGenerationRequest]) -> Generation:
362
+ started = await self.create(idempotency_key=idempotency_key, wait=min(MAX_SERVER_WAIT_SECONDS, int(timeout)), **request)
363
+ generation = await self.wait(started["id"], interval=interval, timeout=timeout) if started["status"] == "processing" else started
364
+ if generation["status"] == "failed":
365
+ raise CutProError(generation["error_code"] or "GENERATION_FAILED", 0, {"id": generation["id"]})
366
+ return generation
367
+
368
+ async def download(self, id: str) -> DownloadLink:
369
+ return await asyncio.to_thread(self._sync.download, id)
370
+
371
+ async def download_to(self, id: str, path: Union[str, Path]) -> Path:
372
+ return await asyncio.to_thread(self._sync.download_to, id, path)
373
+
374
+ async def webhook_secret(self) -> str:
375
+ return await asyncio.to_thread(self._sync.webhook_secret)
376
+
377
+
378
+ class _AsyncMedia:
379
+ def __init__(self, client: CutPro) -> None:
380
+ self._sync = client.media
381
+
382
+ async def upload(self, data: Union[bytes, str, Path, None] = None, **options: Any) -> Media:
383
+ return await asyncio.to_thread(lambda: self._sync.upload(data, **options))
384
+
385
+
386
+ def verify_webhook(raw_body: Union[bytes, str], signature: Optional[str], secret: str, *, tolerance: int = WEBHOOK_TOLERANCE_SECONDS) -> bool:
387
+ """True when `CutPro-Signature` signs this exact raw body with your secret and is under five minutes old. Pass the body before any JSON parsing."""
388
+ parts = dict(part.split("=", 1) for part in (signature or "").split(",") if "=" in part)
389
+ try:
390
+ timestamp = int(parts.get("t", ""))
391
+ except ValueError:
392
+ return False
393
+ if abs(time.time() - timestamp) > tolerance:
394
+ return False
395
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
396
+ expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
397
+ return hmac.compare_digest(expected, parts.get("v1", ""))
398
+
399
+
400
+ def proxy_request(
401
+ method: str,
402
+ path: str,
403
+ *,
404
+ body: Optional[bytes] = None,
405
+ headers: Optional[Mapping[str, str]] = None,
406
+ query: str = "",
407
+ api_key: Optional[str] = None,
408
+ transport: Optional[Transport] = None,
409
+ ) -> Tuple[int, bytes]:
410
+ """
411
+ Forwards a browser app's call from your own route to Cut.Pro with the key added on the server, so the page never sees it.
412
+ `path` is what follows your route's prefix, such as `/generations/quote`. Check your own session before calling it: every forwarded call spends your credits.
413
+ Returns (status, JSON body) for FastAPI, Flask, Django or any framework.
414
+ """
415
+ key = api_key or os.environ.get("CUTPRO_API_KEY")
416
+ if not key:
417
+ return 500, b'{"code":"API_KEY_MISSING"}'
418
+ if not any(allowed == method and pattern.match(path) for allowed, pattern in PROXIED):
419
+ return 404, b'{"code":"NOT_FOUND"}'
420
+ if body is not None and len(body) > MAX_PROXY_BODY_BYTES:
421
+ return 413, b'{"code":"PAYLOAD_TOO_LARGE"}'
422
+ target = f"{API_ORIGIN}{PRICES_PATH}" if path == "/prices" else f"{API_ORIGIN}{API_PATH}{path}"
423
+ if query:
424
+ target = f"{target}?{query.lstrip('?')}"
425
+ sent = {"Accept": "application/json", "X-Api-Key": key}
426
+ for name, value in (headers or {}).items():
427
+ if name.lower() in ("content-type", "idempotency-key"):
428
+ sent[name] = value
429
+ status, _, raw = (transport or urllib_transport)(method, target, sent, body if method != "GET" else None, DEFAULT_TIMEOUT)
430
+ return status, raw
cutpro/py.typed ADDED
File without changes
cutpro/types.py ADDED
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Literal, Optional, TypedDict, Union
4
+
5
+ GenerationKind = Literal["image", "video", "speech", "music", "audio", "avatar", "model3d", "tool", "videoedit", "story"]
6
+ ModelKind = Literal["image", "video", "speech", "music", "audio", "avatar", "model3d", "tool", "videoedit", "story", "text", "embedding"]
7
+ ModelEndpoint = Literal["generations", "chat_completions", "embeddings"]
8
+ GenerationMode = Literal["text", "image", "frames", "references", "edit", "control"]
9
+ AspectRatio = Literal["21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16"]
10
+ Preset = Literal["fast", "balanced", "best"]
11
+ GenerationStatus = Literal["processing", "ready", "failed"]
12
+ OptionValue = Union[str, int, float, bool, List[str]]
13
+
14
+
15
+ class GenerationRequest(TypedDict, total=False):
16
+ kind: Literal["image", "video", "speech"]
17
+ model: str
18
+ preset: Preset
19
+ mode: GenerationMode
20
+ resolution: str
21
+ prompt: str
22
+ options: Dict[str, OptionValue]
23
+ seconds: int
24
+ aspect_ratio: AspectRatio
25
+ audio: bool
26
+ voice: str
27
+ image_ids: List[str]
28
+ image_urls: List[str]
29
+ audio_ids: List[str]
30
+ audio_urls: List[str]
31
+ video_id: str
32
+ orientation: Literal["image", "video"]
33
+ seed: int
34
+
35
+
36
+ class CreateGenerationRequest(GenerationRequest, total=False):
37
+ credits: int
38
+ webhook_url: str
39
+
40
+
41
+ class Quote(TypedDict):
42
+ credits: float
43
+ model: str
44
+ kind: GenerationKind
45
+ mode: Optional[GenerationMode]
46
+ resolution: Optional[str]
47
+ seconds: Optional[float]
48
+ aspect_ratio: Optional[AspectRatio]
49
+ audio: Optional[bool]
50
+ warnings: List[str]
51
+
52
+
53
+ class GenerationOutput(TypedDict):
54
+ image_id: Optional[str]
55
+ video_id: Optional[str]
56
+ audio_id: Optional[str]
57
+ url: str
58
+ thumbnail_url: Optional[str]
59
+
60
+
61
+ class GenerationFile(TypedDict):
62
+ kind: Literal["image", "video", "audio", "file"]
63
+ url: str
64
+ media_type: Optional[str]
65
+ bytes: int
66
+ media_id: Optional[str]
67
+ file_name: Optional[str]
68
+
69
+
70
+ class Generation(TypedDict):
71
+ id: str
72
+ kind: GenerationKind
73
+ status: GenerationStatus
74
+ model: str
75
+ model_name: str
76
+ mode: Optional[GenerationMode]
77
+ resolution: Optional[str]
78
+ seconds: Optional[float]
79
+ aspect_ratio: Optional[AspectRatio]
80
+ voice: Optional[str]
81
+ prompt: Optional[str]
82
+ audio: Optional[bool]
83
+ voice_name: Optional[str]
84
+ image_ids: List[str]
85
+ audio_ids: List[str]
86
+ video_id: Optional[str]
87
+ seed: Optional[int]
88
+ credits_charged: float
89
+ error_code: Optional[str]
90
+ edit_id: Optional[str]
91
+ output: Optional[GenerationOutput]
92
+ outputs: List[GenerationFile]
93
+ text: Optional[str]
94
+ options: Optional[Dict[str, OptionValue]]
95
+ expected_seconds: Optional[float]
96
+ feedback: Optional[Literal["up", "down"]]
97
+ feedback_reason: Optional[str]
98
+ refund_status: Optional[Literal["requested", "approved", "denied"]]
99
+ downloaded: bool
100
+ removed: bool
101
+ created_at: str
102
+
103
+
104
+ class ModelPricing(TypedDict):
105
+ unit: str
106
+ credits: float
107
+ mode: Optional[GenerationMode]
108
+ resolution: Optional[str]
109
+ silent_credits: Optional[float]
110
+
111
+
112
+ class Voice(TypedDict):
113
+ id: str
114
+ name: str
115
+ style: Optional[str]
116
+ gender: Optional[Literal["female", "male"]]
117
+ age: Optional[Literal["young", "mature"]]
118
+ language: Optional[str]
119
+ preview_url: Optional[str]
120
+ portrait_url: Optional[str]
121
+
122
+
123
+ class Model(TypedDict):
124
+ id: str
125
+ name: str
126
+ maker: str
127
+ kind: ModelKind
128
+ endpoint: ModelEndpoint
129
+ featured: bool
130
+ pricing: List[ModelPricing]
131
+ priced_by_request: bool
132
+ modes: List[GenerationMode]
133
+ resolutions: List[str]
134
+ seconds: List[float]
135
+ aspect_ratios: List[AspectRatio]
136
+ audio: Optional[Literal["always", "optional"]]
137
+ max_images: int
138
+ max_audios: int
139
+ max_video_seconds: Optional[float]
140
+ voices: List[Voice]
141
+ typical_seconds: Optional[float]
142
+ success_rate: Optional[float]
143
+
144
+
145
+ class PresetModel(TypedDict):
146
+ id: Preset
147
+ model: str
148
+ resolution: str
149
+
150
+
151
+ class Presets(TypedDict):
152
+ video: List[PresetModel]
153
+ image: List[PresetModel]
154
+ speech: List[PresetModel]
155
+
156
+
157
+ class ModelList(TypedDict):
158
+ models: List[Model]
159
+ presets: Presets
160
+ prompt_max: int
161
+ max_running: int
162
+ improve_credits: float
163
+
164
+
165
+ class Money(TypedDict):
166
+ credits: float
167
+ usd: float
168
+ brl: float
169
+
170
+
171
+ class PublicPrice(Money):
172
+ mode: str
173
+ resolution: str
174
+ silent: Optional[Money]
175
+ lowest: bool
176
+
177
+
178
+ class PublicModel(TypedDict):
179
+ id: str
180
+ name: str
181
+ maker: str
182
+ kind: Literal["video", "image", "speech"]
183
+ unit: Literal["second", "image", "1k_characters"]
184
+ modes: List[str]
185
+ resolutions: List[str]
186
+ seconds: List[int]
187
+ aspect_ratios: List[str]
188
+ audio: Optional[str]
189
+ max_images: int
190
+ max_video_seconds: Optional[float]
191
+ prices: List[PublicPrice]
192
+
193
+
194
+ class CreditPrice(TypedDict):
195
+ usd: float
196
+ brl: float
197
+
198
+
199
+ class PriceList(TypedDict):
200
+ updated_at: str
201
+ credit: CreditPrice
202
+ models: List[PublicModel]
203
+
204
+
205
+ class Media(TypedDict):
206
+ id: str
207
+ kind: Literal["image", "video", "audio"]
208
+ seconds: Optional[float]
209
+
210
+
211
+ class DownloadLink(TypedDict):
212
+ url: str
@@ -0,0 +1,263 @@
1
+ Metadata-Version: 2.5
2
+ Name: cutpro
3
+ Version: 0.1.0
4
+ Summary: Cut.Pro API client for Python: Seedance, Wan, Kling, Veo, Nano Banana, Seedream, GPT Image and Gemini TTS with one key, priced per second, image or 1,000 characters
5
+ Project-URL: Homepage, https://cut.pro/docs/en/generations
6
+ Project-URL: Source, https://github.com/getcutpro/sdk-python
7
+ Project-URL: Issues, https://github.com/getcutpro/sdk-python/issues
8
+ Project-URL: Prices, https://cut.pro/en/models
9
+ Author: Cut.Pro
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai,api,cutpro,gemini-tts,gpt-image,image-generation,image-to-video,kling,nano-banana,sdk,seedance,seedance-api,seedream,text-to-speech,text-to-video,veo,video-generation,wan
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Multimedia :: Video
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Cut.Pro Python SDK: Seedance, Wan, Kling, Veo and Nano Banana API in Python
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/cutpro)](https://pypi.org/project/cutpro/) [![license](https://img.shields.io/pypi/l/cutpro)](LICENSE)
31
+
32
+ One API key for video, image and voice models: Seedance 2.5, Wan 3, Kling O3, Veo 3.1, Gemini Omni Flash, Nano Banana Pro, Seedream 5, Qwen Image, GPT Image, Grok Imagine, MiniMax and Gemini TTS voices. Priced per second of video, per image or per 1,000 characters of voice, in reais and dollars: see the [live price list](https://cut.pro/en/models). Pay with Pix or card; companies get an invoice.
33
+
34
+ Python 3.9 to 3.13, standard library only (no dependencies), fully typed (`py.typed`), sync and asyncio. A pure wheel, so it installs the same way in any environment:
35
+
36
+ ```bash
37
+ pip install cutpro # pip, virtualenv, Anaconda and Miniconda environments
38
+ uv add cutpro # uv
39
+ poetry add cutpro # Poetry
40
+ pdm add cutpro # PDM
41
+ pipenv install cutpro # Pipenv
42
+ ```
43
+
44
+ ## Quickstart
45
+
46
+ Create a key at [cut.pro/studio/me/api-keys](https://cut.pro/studio/me/api-keys) and set `CUTPRO_API_KEY`. Every account starts with free credits.
47
+
48
+ ```python
49
+ from cutpro import CutPro
50
+
51
+ cutpro = CutPro() # reads CUTPRO_API_KEY
52
+
53
+ video = cutpro.generations.generate(
54
+ model="bytedance/seedance-2.5",
55
+ prompt="a golden retriever surfing a wave at sunset, cinematic",
56
+ seconds=5,
57
+ resolution="720p",
58
+ aspect_ratio="16:9",
59
+ )
60
+ print(video["output"]["url"], video["credits_charged"], "credits")
61
+ ```
62
+
63
+ `generate()` quotes the request, starts it with the quoted total and waits until it is ready. Each step is also its own call.
64
+
65
+ ## Seedance API in Python, step by step
66
+
67
+ ```python
68
+ import uuid
69
+
70
+ quote = cutpro.generations.quote(model="bytedance/seedance-2.5", prompt="a dog surfing", seconds=5)
71
+ print(quote["credits"], "credits") # show the price and wait for a yes
72
+
73
+ started = cutpro.generations.create(
74
+ model="bytedance/seedance-2.5",
75
+ prompt="a dog surfing",
76
+ seconds=5,
77
+ credits=quote["credits"],
78
+ idempotency_key=str(uuid.uuid4()),
79
+ )
80
+
81
+ done = cutpro.generations.wait(started["id"], timeout=600)
82
+ if done["status"] == "ready":
83
+ print(done["output"]["url"])
84
+ ```
85
+
86
+ - `create()` without `credits` quotes first and agrees to that total. With `credits`, the price is checked on the server: if it changed, nothing is charged and you get `PRICE_CHANGED` with the new total in `extra["credits"]`.
87
+ - `idempotency_key`: repeating the same request with the same key within 24 hours never charges twice.
88
+ - `webhook_url="https://..."` gets a signed POST when the generation ends, instead of waiting.
89
+
90
+ ## Wan 3 image to video
91
+
92
+ ```python
93
+ clip = cutpro.generations.generate(
94
+ model="alibaba/wan-3",
95
+ mode="image",
96
+ image_urls=["https://your-cdn.com/product.jpg"],
97
+ prompt="the camera slowly orbits the product, soft studio light",
98
+ seconds=5,
99
+ resolution="720p",
100
+ )
101
+ ```
102
+
103
+ Local file? `media = cutpro.media.upload(open("product.jpg", "rb").read(), filename="product.jpg", content_type="image/jpeg")` and send `image_ids=[media["id"]]`.
104
+
105
+ ## Nano Banana Pro image with references
106
+
107
+ ```python
108
+ image = cutpro.generations.generate(
109
+ model="google/nano-banana-pro",
110
+ mode="references",
111
+ image_urls=["https://your-cdn.com/person.jpg", "https://your-cdn.com/jacket.jpg"],
112
+ prompt="the person from the first image wearing the jacket from the second, street photo",
113
+ aspect_ratio="4:5",
114
+ )
115
+ ```
116
+
117
+ ## Gemini TTS voice
118
+
119
+ ```python
120
+ voices = cutpro.models.list(id="google/gemini-3.1-flash-tts")["models"][0]["voices"]
121
+ print([voice["id"] for voice in voices])
122
+
123
+ voice = cutpro.generations.generate(model="google/gemini-3.1-flash-tts", voice="Kore", prompt="Welcome to Cut.Pro. Your video is ready.")
124
+ print(voice["output"]["url"]) # MP3
125
+ ```
126
+
127
+ ## Models and prices
128
+
129
+ ```python
130
+ catalog = cutpro.models.list(kind="video", endpoint="generations")
131
+ featured = cutpro.models.featured(kind="image")
132
+
133
+ # Public price list, no key needed: credits, dollars and reais per unit.
134
+ prices = CutPro().models.prices()
135
+ ```
136
+
137
+ | Kind | Model ids |
138
+ |---|---|
139
+ | Video | [`bytedance/seedance-2.5`](https://cut.pro/en/models/seedance-2-5), [`bytedance/seedance-2`](https://cut.pro/en/models/seedance-2), [`alibaba/wan-3`](https://cut.pro/en/models/wan-3), [`kling/kling-o3-pro`](https://cut.pro/en/models/kling-o3-pro), [`kling/kling-3-pro`](https://cut.pro/en/models/kling-3-pro), [`google/veo-3.1`](https://cut.pro/en/models/veo-3-1), [`google/gemini-omni-flash-1.1`](https://cut.pro/en/models/gemini-omni-flash-1-1), [`xai/grok-imagine-video-1.5`](https://cut.pro/en/models/grok-imagine-video-1-5), [`minimax/h3`](https://cut.pro/en/models/h3) |
140
+ | Image | [`google/nano-banana-pro`](https://cut.pro/en/models/nano-banana-pro), [`bytedance/seedream-5-pro`](https://cut.pro/en/models/seedream-5-pro), [`alibaba/qwen-image-3-pro`](https://cut.pro/en/models/qwen-image-3-pro), [`openai/gpt-image-2.5`](https://cut.pro/en/models/gpt-image-2-5), [`xai/grok-imagine-image-2`](https://cut.pro/en/models/grok-imagine-image-2) |
141
+ | Voice | [`google/gemini-3.1-flash-tts`](https://cut.pro/en/models/gemini-3-1-flash-tts), [`minimax/speech-2.8-hd`](https://cut.pro/en/models/speech-2-8-hd) |
142
+
143
+ The full catalog is in `models.list()` and at [cut.pro/en/models](https://cut.pro/en/models). Prices change: read them from the API, never hard-code them.
144
+
145
+ ## Errors
146
+
147
+ ```python
148
+ from cutpro import CutProError
149
+
150
+ try:
151
+ cutpro.generations.generate(model="bytedance/seedance-2.5", prompt="a dog surfing", seconds=3)
152
+ except CutProError as error:
153
+ if error.code == "INSUFFICIENT_CREDITS":
154
+ print("Top up at", error.extra["top_up_url"])
155
+ elif error.code == "SECONDS_INVALID":
156
+ print("Allowed seconds:", error.extra["allowed"])
157
+ else:
158
+ raise
159
+ ```
160
+
161
+ `code` is stable, `status` is the HTTP status and `extra` has what to act on. `status` is `0` for errors raised by the client: `WAIT_TIMEOUT`, `INSECURE_BASE_URL`, `INSECURE_DOWNLOAD_URL`, or a generation that ended `failed` (its `error_code` becomes `code`, and the credits come back on their own). Full list in the [error catalog](https://cut.pro/docs/en/errors).
162
+
163
+ ## asyncio
164
+
165
+ `AsyncCutPro` has the same methods. Waiting never blocks the event loop, so FastAPI, aiohttp or a Jupyter notebook can run many generations at once.
166
+
167
+ ```python
168
+ import asyncio
169
+ from cutpro import AsyncCutPro
170
+
171
+ async def main() -> None:
172
+ cutpro = AsyncCutPro()
173
+ videos = await asyncio.gather(*(cutpro.generations.generate(model="bytedance/seedance-2-fast", prompt=p, seconds=5) for p in ["a dog surfing", "a cat skating"]))
174
+ print([video["output"]["url"] for video in videos])
175
+
176
+ asyncio.run(main())
177
+ ```
178
+
179
+ ## Download to a file
180
+
181
+ ```python
182
+ cutpro.generations.download_to(video["id"], "dog.mp4") # streams to disk, never holds the whole file in memory
183
+ ```
184
+
185
+ ## Webhooks
186
+
187
+ ```python
188
+ from cutpro import verify_webhook
189
+
190
+ secret = cutpro.generations.webhook_secret() # store it with your other secrets
191
+
192
+ # In your webhook route, with the raw body before any JSON parsing:
193
+ if not verify_webhook(raw_body, headers.get("CutPro-Signature"), secret):
194
+ return 401
195
+ ```
196
+
197
+ It checks the HMAC of the exact body, refuses signatures older than five minutes and compares in constant time.
198
+
199
+ ## A browser app on your Python server
200
+
201
+ The key spends credits, so it never goes to a page. `proxy_request` forwards only what a generation needs (models, prices, quote, create, get) with the key added on your server. With FastAPI:
202
+
203
+ ```python
204
+ from fastapi import FastAPI, Request, Response
205
+ from cutpro import proxy_request
206
+
207
+ app = FastAPI()
208
+
209
+ @app.api_route("/api/cutpro/{path:path}", methods=["GET", "POST"])
210
+ async def cutpro_proxy(path: str, request: Request) -> Response:
211
+ if not is_signed_in(request): # whoever reaches this route spends your credits
212
+ return Response(status_code=401)
213
+ status, body = proxy_request(request.method, f"/{path}", body=await request.body(), headers=dict(request.headers), query=request.url.query)
214
+ return Response(body, status_code=status, media_type="application/json")
215
+ ```
216
+
217
+ The page uses the JavaScript SDK with `new CutPro({ baseURL: "/api/cutpro" })`.
218
+
219
+ ## Options
220
+
221
+ ```python
222
+ CutPro(
223
+ api_key="...", # defaults to CUTPRO_API_KEY
224
+ workspace_id="...", # only for a key that reaches more than one workspace
225
+ base_url="https://api.cut.pro/api/v1",
226
+ timeout=90.0, # seconds per HTTP request
227
+ max_retries=2,
228
+ transport=my_transport, # (method, url, headers, body, timeout) -> (status, headers, body): plug in httpx or requests, or stub it in tests
229
+ )
230
+ ```
231
+
232
+ ## Security and reliability
233
+
234
+ - The key only travels in the `X-Api-Key` header, never in a URL, and redirects are refused so it never follows one to another host. `repr(client)` never shows it.
235
+ - `base_url` must be `https`, except on localhost.
236
+ - `generations.create` sends an `Idempotency-Key` (a fresh UUID unless you pass one), so a retry or a repeated call never charges twice.
237
+ - Network errors, `429` and `5xx` are retried twice with backoff, honoring `Retry-After`; uploads are never retried.
238
+ - Waiting uses the server's long poll instead of hammering it.
239
+
240
+ ## Links
241
+
242
+ - [Generation guide](https://cut.pro/docs/en/generations) and [API reference](https://cut.pro/docs/reference)
243
+ - [Node SDK](https://github.com/getcutpro/sdk-js), [Vercel AI SDK provider](https://github.com/getcutpro/ai-sdk-provider), [n8n node](https://github.com/getcutpro/n8n-nodes-cutpro), [examples](https://github.com/getcutpro/examples)
244
+
245
+ ## Em português
246
+
247
+ Instala igual com pip, uv, Poetry, PDM, Pipenv e em ambientes Anaconda ou Miniconda (`pip install cutpro`). Tem cliente síncrono e assíncrono (`AsyncCutPro`), verificação de webhook e proxy para usar no navegador sem expor a chave.
248
+
249
+ SDK oficial da Cut.Pro para Python: uma chave de API para Seedance 2.5, Wan 3, Kling O3, Veo 3.1, Nano Banana Pro, Seedream 5, GPT Image, Grok Imagine, MiniMax e vozes do Gemini TTS. Preço por segundo de vídeo, por imagem ou por 1.000 caracteres de voz, em reais: veja a [tabela de preços ao vivo](https://cut.pro/pt-BR/models). Pague com Pix ou cartão, com nota fiscal para empresas. Toda conta começa com créditos grátis.
250
+
251
+ ```python
252
+ from cutpro import CutPro
253
+
254
+ cutpro = CutPro() # lê CUTPRO_API_KEY
255
+ voz = cutpro.generations.generate(model="google/gemini-3.1-flash-tts", voice="Kore", prompt="Olá! Seu vídeo está pronto.")
256
+ print(voz["output"]["url"])
257
+ ```
258
+
259
+ Crie a chave em [cut.pro/studio/me/api-keys](https://cut.pro/studio/me/api-keys). Guia em português: [cut.pro/docs/generations](https://cut.pro/docs/generations). Páginas dos modelos: [Seedance 2.5](https://cut.pro/pt-BR/models/seedance-2-5), [Wan 3](https://cut.pro/pt-BR/models/wan-3), [Nano Banana Pro](https://cut.pro/pt-BR/models/nano-banana-pro), [Gemini 3.1 Flash TTS](https://cut.pro/pt-BR/models/gemini-3-1-flash-tts).
260
+
261
+ ## License
262
+
263
+ MIT
@@ -0,0 +1,8 @@
1
+ cutpro/__init__.py,sha256=omgfoWGG85ZKipYLHqRu1L_UHoSozYLZrGnsrxiX8wk,860
2
+ cutpro/_client.py,sha256=oe-_xxDhXhMDcqXP2zuJyzoMjG5lK7LuTA9J1vcvdWs,19307
3
+ cutpro/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ cutpro/types.py,sha256=raxX-mUBz6GGdKROwC5vEe8i6at6_mVy6od5_iEEmTU,5156
5
+ cutpro-0.1.0.dist-info/METADATA,sha256=GioTrhejP7d4gkG5OBjOkAftJqQcRnLpeMotGv32BzI,12205
6
+ cutpro-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ cutpro-0.1.0.dist-info/licenses/LICENSE,sha256=uYa0hjp2TQGA9FHrifYlkOIzz6hHpNZnwzc1HXYKg2U,1064
8
+ cutpro-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cut.Pro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.