cuddler 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.
cuddler/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .client import Cuddler, CuddlerError
2
+
3
+ __all__ = ["Cuddler", "CuddlerError"]
cuddler/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
cuddler/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """Command-line access to the public client; credentials stay in the environment."""
2
+ import argparse
3
+ import json
4
+ import math
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from .client import Cuddler, CuddlerError
9
+
10
+
11
+ class _Parser(argparse.ArgumentParser):
12
+ def error(self, _message):
13
+ # argparse's default error includes raw arguments, which may be sensitive.
14
+ raise ValueError("invalid_command_or_input")
15
+
16
+
17
+ def main(argv=None):
18
+ parser = _Parser(prog="cuddler-platform", add_help=False, allow_abbrev=False)
19
+ parser.add_argument("command", nargs="*")
20
+ parser.add_argument("-h", "--help", action="store_true")
21
+ for name in ("json", "prompt", "model", "duration", "resolution", "max-usd", "idempotency-key"):
22
+ parser.add_argument("--" + name)
23
+ parser.add_argument("--wait", action="store_true")
24
+ try:
25
+ args = parser.parse_args(argv)
26
+ if args.help or not args.command:
27
+ print("CUDDLER_API_KEY must be set in the environment.\n"
28
+ "cuddler-platform models\n"
29
+ "cuddler-platform videos|images|speech|music create --json request.json [--wait]\n"
30
+ "cuddler-platform videos|images|speech|music get ID\n"
31
+ "Reuse --idempotency-key with exactly the same JSON after an uncertain create.")
32
+ return 0
33
+
34
+ command = args.command
35
+ body = None
36
+ if command != ["models"]:
37
+ if (command[0] not in ("videos", "images", "speech", "music") or
38
+ not ((len(command) == 2 and command[1] == "create") or
39
+ (len(command) == 3 and command[1] == "get"))):
40
+ raise CuddlerError("invalid_command")
41
+ if command[1] == "create":
42
+ body = json.loads(Path(args.json).read_text(encoding="utf-8")) if args.json else {}
43
+ if not isinstance(body, dict):
44
+ raise ValueError("invalid_command_or_input")
45
+ for name in ("model", "prompt", "resolution", "max_usd"):
46
+ value = getattr(args, name)
47
+ if value is not None:
48
+ body[name] = value
49
+ if args.duration is not None:
50
+ duration = float(args.duration)
51
+ if not math.isfinite(duration):
52
+ raise ValueError("invalid_command_or_input")
53
+ body["duration"] = int(duration) if duration.is_integer() else duration
54
+
55
+ with Cuddler() as client:
56
+ if command == ["models"]:
57
+ result = client.models.list()
58
+ elif command[1] == "get":
59
+ result = getattr(client, command[0]).retrieve(command[2])
60
+ else:
61
+ resource = getattr(client, command[0])
62
+ create = resource.create_and_wait if args.wait else resource.create
63
+ result = create(body, idempotency_key=args.idempotency_key)
64
+ print(json.dumps(result, indent=2))
65
+ return 1 if result.get("status") in ("failed", "canceled") else 0
66
+ except CuddlerError as error:
67
+ print(json.dumps({"code": error.code, "status": error.status,
68
+ "idempotencyKey": error.idempotency_key,
69
+ "outcomeUnknown": error.outcome_unknown,
70
+ "jobId": error.job_id}), file=sys.stderr)
71
+ return 1
72
+ except (ValueError, TypeError, OSError):
73
+ print(json.dumps({"code": "invalid_command_or_input"}), file=sys.stderr)
74
+ return 1
cuddler/client.py ADDED
@@ -0,0 +1,213 @@
1
+ """Thin synchronous REST client with one-shot writes and explicit recovery keys."""
2
+ from datetime import datetime, timezone
3
+ from email.utils import parsedate_to_datetime
4
+ import json
5
+ import math
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+
11
+ import httpx
12
+
13
+ ORIGIN = "https://api.cuddler.ai/v1"
14
+ TERMINAL = {"succeeded", "failed", "canceled"}
15
+ RETRYABLE = {408, 429, 500, 502, 503, 504}
16
+ CODES = {"invalid_request", "unauthorized", "forbidden", "not_found", "insufficient_balance",
17
+ "idempotency_conflict", "not_cancelable", "content_blocked", "rate_limited", "queue_full",
18
+ "daily_limit_reached", "model_not_enabled", "organization_suspended", "unavailable",
19
+ "upstream_error", "internal_error"}
20
+
21
+
22
+ class CuddlerError(Exception):
23
+ """No raw response, prompt, credential or transport exception is retained."""
24
+ def __init__(self, code, *, status=None, idempotency_key=None, outcome_unknown=False, job_id=None):
25
+ super().__init__(code)
26
+ self.code, self.status = code, status
27
+ self.idempotency_key, self.outcome_unknown = idempotency_key, outcome_unknown
28
+ self.job_id = job_id
29
+
30
+
31
+ def identity(value, prefix):
32
+ if not isinstance(value, str) or not re.fullmatch(prefix + r"_[A-Za-z0-9]{12,40}", value):
33
+ raise CuddlerError("invalid_resource_id")
34
+ return value
35
+
36
+
37
+ def retry_delay(header, attempt):
38
+ if header:
39
+ try:
40
+ value = float(header) if re.fullmatch(r"\d+(?:\.\d+)?", header) else (
41
+ parsedate_to_datetime(header) - datetime.now(timezone.utc)).total_seconds()
42
+ if math.isfinite(value):
43
+ return max(0, value)
44
+ except (ValueError, TypeError, OverflowError):
45
+ pass
46
+ return 0.5 * 2 ** attempt
47
+
48
+
49
+ def validate_wait(timeout, poll_interval):
50
+ if not math.isfinite(timeout) or timeout <= 0 or not math.isfinite(poll_interval) or poll_interval < 0.1:
51
+ raise CuddlerError("invalid_wait_options")
52
+
53
+
54
+ class Resource:
55
+ def __init__(self, client, path, prefix):
56
+ self._client, self._path, self._prefix = client, path, prefix
57
+
58
+ def create(self, body, *, idempotency_key=None):
59
+ return self._client._create(self._path, body, idempotency_key, self._prefix)
60
+
61
+ def retrieve(self, job_id):
62
+ return self._client._call("GET", self._path + "/" + identity(job_id, self._prefix))
63
+
64
+ def estimate(self, body):
65
+ return self._client._call("POST", self._path + "/estimate", body=body)
66
+
67
+ def list(self, **query):
68
+ return self._client._call("GET", self._path, query=query)
69
+
70
+ def wait(self, job_id, *, timeout=1200, poll_interval=2):
71
+ identity(job_id, self._prefix)
72
+ validate_wait(timeout, poll_interval)
73
+ deadline = time.monotonic() + timeout
74
+ while time.monotonic() < deadline:
75
+ try:
76
+ job = self.retrieve(job_id)
77
+ except CuddlerError as error:
78
+ error.job_id = job_id
79
+ raise
80
+ if job.get("id") != job_id or job.get("status") not in TERMINAL | {"queued", "running"}:
81
+ raise CuddlerError("invalid_response", job_id=job_id)
82
+ if job["status"] in TERMINAL:
83
+ return job
84
+ self._client._sleep(min(poll_interval, max(0, deadline - time.monotonic())))
85
+ raise CuddlerError("wait_timeout", job_id=job_id)
86
+
87
+ def create_and_wait(self, body, *, idempotency_key=None, timeout=1200, poll_interval=2):
88
+ validate_wait(timeout, poll_interval)
89
+ job = self.create(body, idempotency_key=idempotency_key)
90
+ return self.wait(job["id"], timeout=timeout, poll_interval=poll_interval)
91
+
92
+
93
+ class CancelableResource(Resource):
94
+ def cancel(self, job_id):
95
+ return self._client._call("POST", self._path + "/" + identity(job_id, self._prefix) + "/cancel")
96
+
97
+
98
+ class ReadResource:
99
+ def __init__(self, client, path):
100
+ self._client, self._path = client, path
101
+
102
+ def list(self, **query):
103
+ return self._client._call("GET", self._path, query=query)
104
+
105
+
106
+ class Balance:
107
+ def __init__(self, client):
108
+ self._client = client
109
+
110
+ def retrieve(self):
111
+ return self._client._call("GET", "/balance")
112
+
113
+
114
+ class Uploads:
115
+ def __init__(self, client):
116
+ self._client = client
117
+
118
+ def create(self, body, *, idempotency_key=None):
119
+ return self._client._create("/uploads", body, idempotency_key, "upl", require_cap=False)
120
+
121
+ def retrieve(self, upload_id):
122
+ return self._client._call("GET", "/uploads/" + identity(upload_id, "upl"))
123
+
124
+ def complete(self, upload_id):
125
+ return self._client._call("POST", "/uploads/" + identity(upload_id, "upl") + "/complete")
126
+
127
+
128
+ class Cuddler:
129
+ def __init__(self, api_key=None, *, timeout=30, max_retries=2, transport=None, sleep=time.sleep):
130
+ key = api_key if api_key is not None else os.environ.get("CUDDLER_API_KEY")
131
+ if not isinstance(key, str) or not re.fullmatch(r"cud_sk_(live|test)_[A-Za-z0-9_-]{12,200}", key):
132
+ raise CuddlerError("invalid_api_key")
133
+ if not math.isfinite(timeout) or not 0 < timeout <= 120 or type(max_retries) is not int or not 0 <= max_retries <= 5:
134
+ raise CuddlerError("invalid_client_options")
135
+ self.__key = key
136
+ self.__http = httpx.Client(timeout=timeout, follow_redirects=False, transport=transport, trust_env=False)
137
+ self.__retries, self._sleep = max_retries, sleep
138
+ self.videos = CancelableResource(self, "/videos", "vid")
139
+ self.images = Resource(self, "/images", "img")
140
+ self.speech = CancelableResource(self, "/speech", "aud")
141
+ self.music = CancelableResource(self, "/music", "mus")
142
+ self.models, self.voices = ReadResource(self, "/models"), ReadResource(self, "/voices")
143
+ self.usage, self.balance, self.uploads = ReadResource(self, "/usage"), Balance(self), Uploads(self)
144
+
145
+ def close(self):
146
+ self.__http.close()
147
+
148
+ def __enter__(self):
149
+ return self
150
+
151
+ def __exit__(self, *_):
152
+ self.close()
153
+
154
+ def _call(self, method, path, *, body=None, query=None, idempotency_key=None):
155
+ headers = {"Authorization": "Bearer " + self.__key, "Accept": "application/json"}
156
+ if idempotency_key:
157
+ headers["Idempotency-Key"] = idempotency_key
158
+ try:
159
+ encoded = None if body is None else json.dumps(body, allow_nan=False).encode()
160
+ except (TypeError, ValueError):
161
+ raise CuddlerError("invalid_request", idempotency_key=idempotency_key) from None
162
+ if encoded is not None:
163
+ headers["Content-Type"] = "application/json"
164
+ for attempt in range(self.__retries + 1):
165
+ try:
166
+ with self.__http.stream(method, ORIGIN + path, headers=headers, content=encoded, params=query) as response:
167
+ if method == "GET" and response.status_code in RETRYABLE and attempt < self.__retries:
168
+ wait = retry_delay(response.headers.get("retry-after"), attempt)
169
+ if wait <= 60:
170
+ response.close()
171
+ self._sleep(wait)
172
+ continue
173
+ chunks, size = [], 0
174
+ for chunk in response.iter_bytes():
175
+ size += len(chunk)
176
+ if size > 2 * 1024 * 1024:
177
+ raise CuddlerError("response_too_large", idempotency_key=idempotency_key,
178
+ outcome_unknown=method != "GET")
179
+ chunks.append(chunk)
180
+ try:
181
+ data = json.loads(b"".join(chunks))
182
+ except (ValueError, UnicodeError):
183
+ raise CuddlerError("invalid_response", status=response.status_code,
184
+ idempotency_key=idempotency_key, outcome_unknown=method != "GET") from None
185
+ if not isinstance(data, dict):
186
+ raise CuddlerError("invalid_response", idempotency_key=idempotency_key,
187
+ outcome_unknown=method != "GET")
188
+ if not response.is_success:
189
+ code = data.get("code")
190
+ raise CuddlerError(code if isinstance(code, str) and code in CODES else "http_error",
191
+ status=response.status_code, idempotency_key=idempotency_key,
192
+ outcome_unknown=method != "GET" and (response.status_code == 408 or response.status_code >= 500))
193
+ return data
194
+ except httpx.HTTPError:
195
+ if method == "GET" and attempt < self.__retries:
196
+ self._sleep(retry_delay(None, attempt))
197
+ continue
198
+ raise CuddlerError("network_error", idempotency_key=idempotency_key,
199
+ outcome_unknown=method != "GET") from None
200
+
201
+ def _create(self, path, body, key, prefix, require_cap=True):
202
+ key = key if key is not None else "sdk-" + str(uuid.uuid4())
203
+ if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", key):
204
+ raise CuddlerError("invalid_idempotency_key")
205
+ cap = body.get("max_usd") if isinstance(body, dict) else None
206
+ if require_cap and (not isinstance(cap, str) or not re.fullmatch(r"(?:0|[1-9]\d{0,8})(?:\.\d{1,6})?", cap) or float(cap) <= 0):
207
+ raise CuddlerError("max_usd_required", idempotency_key=key)
208
+ result = self._call("POST", path, body=body, idempotency_key=key)
209
+ try:
210
+ identity(result.get("id"), prefix)
211
+ except CuddlerError:
212
+ raise CuddlerError("invalid_response", idempotency_key=key, outcome_unknown=True) from None
213
+ return result
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: cuddler
3
+ Version: 0.1.0
4
+ Summary: Official Cuddler Platform client
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: httpx<0.29,>=0.28.1
10
+ Dynamic: license-file
11
+
12
+ # Cuddler Platform SDK
13
+
14
+ Release candidate; npm/PyPI publication is pending registry authentication and release verification.
15
+ Source is maintained in the private Cuddler repository; no public source repository is required.
16
+
17
+ This client, intended to run in a developer's server environment, uses the native USD Platform API at `https://api.cuddler.ai/v1`.
18
+ It supports video, images, speech, music, models, voices, balance, usage and the
19
+ create/get/complete upload protocol. Media transfer is explicit: use the returned
20
+ signed URL without an Authorization header. No provider key is required.
21
+
22
+ ## TypeScript / JavaScript
23
+
24
+ ```js
25
+ import Cuddler from '@cuddler/platform';
26
+ const client = new Cuddler({ apiKey: process.env.CUDDLER_API_KEY });
27
+ const quote = await client.videos.estimate({
28
+ model: 'seedance-2.5', mode: 'text', variant: 'default',
29
+ duration: 4, resolution: '480p', aspect_ratio: '16:9', generate_audio: false,
30
+ });
31
+ const job = await client.videos.createAndWait({
32
+ model: 'seedance-2.5', mode: 'text', duration: 4, resolution: '480p',
33
+ aspect_ratio: '16:9', generate_audio: false,
34
+ prompt: 'An original blue clay robot waving.', max_usd: '1.00',
35
+ }, { idempotencyKey: 'my-persisted-operation-001' });
36
+ if (job.status !== 'succeeded') throw new Error(job.status);
37
+ ```
38
+
39
+ `images`, `speech`, and `music` have the same create/retrieve/list/estimate/wait methods.
40
+ Cancellation is available for videos, speech and music; images cannot be canceled.
41
+ The server's current catalog and quotes determine available models and parameters.
42
+ TypeScript request/response types come from the repository's generated OpenAPI contract.
43
+
44
+ ## Python
45
+
46
+ ```python
47
+ from cuddler import Cuddler
48
+ with Cuddler() as client: # reads CUDDLER_API_KEY
49
+ models = client.models.list()
50
+ job = client.videos.retrieve('vid_REPLACE_WITH_YOUR_JOB_ID')
51
+ ```
52
+
53
+ Python uses `create_and_wait`, `idempotency_key`, `timeout` and `poll_interval`.
54
+ It is a synchronous httpx client; use a worker thread when calling it from an async app.
55
+
56
+ ## Recovery and spending
57
+
58
+ - Every generation requires a positive decimal-string `max_usd`.
59
+ - Each new create gets a fresh key. Persist and explicitly reuse a key and identical
60
+ request body for a durable business operation; otherwise another create is a new job.
61
+ - POST calls are never retried automatically, including estimates and cancel calls.
62
+ - On uncertain submission, `CuddlerError.idempotencyKey` (Python: `idempotency_key`)
63
+ carries the original key and `outcomeUnknown` / `outcome_unknown` is true.
64
+ Reconcile or replay that exact operation; do not create a new key blindly.
65
+ - GET retries respect Retry-After (at most two retries by default). A delay over
66
+ 60 seconds returns the error to the caller rather than retrying early.
67
+ - Waiting returns terminal failed/canceled jobs as well as successes. A polling timeout
68
+ includes `jobId` / `job_id`; resume with retrieve/wait, never with a new create.
69
+ An in-flight HTTP read may finish after the polling deadline.
70
+ - Keys remain server-side. The SDK does not write credentials, prompts or replay records
71
+ to disk. Applications are responsible for durable operation identity and recovery.
72
+
73
+ ## CLI
74
+
75
+ Use `CUDDLER_API_KEY` in the environment; API keys are not accepted as command arguments.
76
+
77
+ ```sh
78
+ npx @cuddler/platform models
79
+ npx @cuddler/platform videos create --json request.json --idempotency-key episode-001 --wait
80
+ ```
81
+
82
+ The CLI exits nonzero for failed/canceled jobs and emits safe recovery metadata on errors.
83
+
84
+ The Python distribution also installs `cuddler-platform`; it does not require Node.js.
85
+ For a local candidate wheel (PyPI installation becomes available after publication):
86
+
87
+ ```sh
88
+ python -m pip install ./cuddler-0.1.0-py3-none-any.whl
89
+ cuddler-platform --help
90
+ python -m cuddler models
91
+ python -m cuddler videos create --json request.json --idempotency-key episode-001 --wait
92
+ ```
93
+
94
+ Both distributions use the same command name. Install Python into a virtual environment,
95
+ or use `python -m cuddler` to select it explicitly. The commands, JSON result output and
96
+ safe recovery error fields match the npm CLI. Use `--json` for private prompt content
97
+ so it does not appear in shell history or process arguments. File transfer uses the
98
+ returned signed URL without sending the API credential to the media host.
99
+
100
+ ## Build and release boundary
101
+
102
+ The npm source package has a private workspace name because the platform web app already
103
+ uses `@cuddler/platform` internally. `node scripts/build.mjs` produces a separate publishable
104
+ `dist` manifest named `@cuddler/platform`, with only SDK code, contract types and docs.
105
+ The Python source is in `packages/platform-sdk-python-public` and builds the package `cuddler`.
106
+ The existing private adapter cores in Draft #5979 remain separate and unchanged.
107
+
108
+ The SDK and CLI only implement API types, HTTP requests, queries, polling, error handling
109
+ and recovery of the original operation. Model scheduling, server authorization, quota and
110
+ settlement decisions, and provider credentials remain in the service. Published packages
111
+ must run without access to this private repository and must not contain private backend
112
+ implementation, runtime credentials or user data. JavaScript and Python client files in
113
+ distributed packages remain inspectable; a private source repository does not hide them.
114
+
115
+ ### Distribution terms and publication status
116
+
117
+ SDK and CLI packages are distributed under the MIT License. Keep the bundled LICENSE
118
+ and copyright notice when copying or redistributing the software. The development
119
+ repository remains private; this does not restrict the rights granted for the SDK
120
+ files included in the packages. The license covers the distributed SDK, not private
121
+ backend code, service access, account credentials or API usage charges.
122
+
123
+ Both LICENSE files, the JavaScript source and generated package metadata, and Python
124
+ pyproject.toml declare MIT. Before release, rebuild and inspect both archives and record
125
+ their hashes against the private source commit. Public source hosting and npm provenance
126
+ are not prerequisites for this phase. Installers need no private-repository access.
127
+
128
+ Registry identities, package permissions and release authorization must be verified.
129
+ After publication, verify clean installation of both SDKs and CLIs from the official npm
130
+ and PyPI registries. Reuse matching live acceptance evidence; local builds are not proof
131
+ of registry publication. Do not add paid generation tasks for these documentation changes.
@@ -0,0 +1,10 @@
1
+ cuddler/__init__.py,sha256=l6zzOJCo9xTGP8sin5e34QErZChTlaM9FY-OVXdq0LA,81
2
+ cuddler/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ cuddler/cli.py,sha256=gKXQliJuHdzS_oYqfE5UrrfzUYPnTsTnZZJD4C2s6pg,3504
4
+ cuddler/client.py,sha256=PMmuXH2QditFoHxvaVt8T7KIeUcq7J6dI4PMeE_GMN4,9758
5
+ cuddler-0.1.0.dist-info/licenses/LICENSE,sha256=kxGEHMeUdQSuj4rDdM18R0JvCb3p-6aC23dF0OqToH0,1064
6
+ cuddler-0.1.0.dist-info/METADATA,sha256=V-BPT7uoFMB6WzvFEacbcSN7gNm7bcm-ON1BcXFs0bI,6703
7
+ cuddler-0.1.0.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
8
+ cuddler-0.1.0.dist-info/entry_points.txt,sha256=h_3O0W1Ki0Tc4kBsISUvweMsMTMzuLF2KYT9RiMQOc8,54
9
+ cuddler-0.1.0.dist-info/top_level.txt,sha256=vZy8-38jKGrfeAoj2ObmMdyaTIEoxTAIYz5x0gkXg4s,8
10
+ cuddler-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cuddler-platform = cuddler.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cuddler
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.
@@ -0,0 +1 @@
1
+ cuddler