cuddler 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- cuddler-0.1.0/MANIFEST.in +1 -0
- cuddler-0.1.0/PKG-INFO +131 -0
- cuddler-0.1.0/README.md +120 -0
- cuddler-0.1.0/cuddler/__init__.py +3 -0
- cuddler-0.1.0/cuddler/__main__.py +3 -0
- cuddler-0.1.0/cuddler/cli.py +74 -0
- cuddler-0.1.0/cuddler/client.py +213 -0
- cuddler-0.1.0/cuddler.egg-info/PKG-INFO +131 -0
- cuddler-0.1.0/cuddler.egg-info/SOURCES.txt +16 -0
- cuddler-0.1.0/cuddler.egg-info/dependency_links.txt +1 -0
- cuddler-0.1.0/cuddler.egg-info/entry_points.txt +2 -0
- cuddler-0.1.0/cuddler.egg-info/requires.txt +1 -0
- cuddler-0.1.0/cuddler.egg-info/top_level.txt +1 -0
- cuddler-0.1.0/pyproject.toml +19 -0
- cuddler-0.1.0/setup.cfg +4 -0
- cuddler-0.1.0/tests/recovery-fixtures.json +54 -0
- cuddler-0.1.0/tests/test_client.py +246 -0
cuddler-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
include tests/recovery-fixtures.json
|
cuddler-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
cuddler-0.1.0/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Cuddler Platform SDK
|
|
2
|
+
|
|
3
|
+
Release candidate; npm/PyPI publication is pending registry authentication and release verification.
|
|
4
|
+
Source is maintained in the private Cuddler repository; no public source repository is required.
|
|
5
|
+
|
|
6
|
+
This client, intended to run in a developer's server environment, uses the native USD Platform API at `https://api.cuddler.ai/v1`.
|
|
7
|
+
It supports video, images, speech, music, models, voices, balance, usage and the
|
|
8
|
+
create/get/complete upload protocol. Media transfer is explicit: use the returned
|
|
9
|
+
signed URL without an Authorization header. No provider key is required.
|
|
10
|
+
|
|
11
|
+
## TypeScript / JavaScript
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import Cuddler from '@cuddler/platform';
|
|
15
|
+
const client = new Cuddler({ apiKey: process.env.CUDDLER_API_KEY });
|
|
16
|
+
const quote = await client.videos.estimate({
|
|
17
|
+
model: 'seedance-2.5', mode: 'text', variant: 'default',
|
|
18
|
+
duration: 4, resolution: '480p', aspect_ratio: '16:9', generate_audio: false,
|
|
19
|
+
});
|
|
20
|
+
const job = await client.videos.createAndWait({
|
|
21
|
+
model: 'seedance-2.5', mode: 'text', duration: 4, resolution: '480p',
|
|
22
|
+
aspect_ratio: '16:9', generate_audio: false,
|
|
23
|
+
prompt: 'An original blue clay robot waving.', max_usd: '1.00',
|
|
24
|
+
}, { idempotencyKey: 'my-persisted-operation-001' });
|
|
25
|
+
if (job.status !== 'succeeded') throw new Error(job.status);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`images`, `speech`, and `music` have the same create/retrieve/list/estimate/wait methods.
|
|
29
|
+
Cancellation is available for videos, speech and music; images cannot be canceled.
|
|
30
|
+
The server's current catalog and quotes determine available models and parameters.
|
|
31
|
+
TypeScript request/response types come from the repository's generated OpenAPI contract.
|
|
32
|
+
|
|
33
|
+
## Python
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from cuddler import Cuddler
|
|
37
|
+
with Cuddler() as client: # reads CUDDLER_API_KEY
|
|
38
|
+
models = client.models.list()
|
|
39
|
+
job = client.videos.retrieve('vid_REPLACE_WITH_YOUR_JOB_ID')
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Python uses `create_and_wait`, `idempotency_key`, `timeout` and `poll_interval`.
|
|
43
|
+
It is a synchronous httpx client; use a worker thread when calling it from an async app.
|
|
44
|
+
|
|
45
|
+
## Recovery and spending
|
|
46
|
+
|
|
47
|
+
- Every generation requires a positive decimal-string `max_usd`.
|
|
48
|
+
- Each new create gets a fresh key. Persist and explicitly reuse a key and identical
|
|
49
|
+
request body for a durable business operation; otherwise another create is a new job.
|
|
50
|
+
- POST calls are never retried automatically, including estimates and cancel calls.
|
|
51
|
+
- On uncertain submission, `CuddlerError.idempotencyKey` (Python: `idempotency_key`)
|
|
52
|
+
carries the original key and `outcomeUnknown` / `outcome_unknown` is true.
|
|
53
|
+
Reconcile or replay that exact operation; do not create a new key blindly.
|
|
54
|
+
- GET retries respect Retry-After (at most two retries by default). A delay over
|
|
55
|
+
60 seconds returns the error to the caller rather than retrying early.
|
|
56
|
+
- Waiting returns terminal failed/canceled jobs as well as successes. A polling timeout
|
|
57
|
+
includes `jobId` / `job_id`; resume with retrieve/wait, never with a new create.
|
|
58
|
+
An in-flight HTTP read may finish after the polling deadline.
|
|
59
|
+
- Keys remain server-side. The SDK does not write credentials, prompts or replay records
|
|
60
|
+
to disk. Applications are responsible for durable operation identity and recovery.
|
|
61
|
+
|
|
62
|
+
## CLI
|
|
63
|
+
|
|
64
|
+
Use `CUDDLER_API_KEY` in the environment; API keys are not accepted as command arguments.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npx @cuddler/platform models
|
|
68
|
+
npx @cuddler/platform videos create --json request.json --idempotency-key episode-001 --wait
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The CLI exits nonzero for failed/canceled jobs and emits safe recovery metadata on errors.
|
|
72
|
+
|
|
73
|
+
The Python distribution also installs `cuddler-platform`; it does not require Node.js.
|
|
74
|
+
For a local candidate wheel (PyPI installation becomes available after publication):
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
python -m pip install ./cuddler-0.1.0-py3-none-any.whl
|
|
78
|
+
cuddler-platform --help
|
|
79
|
+
python -m cuddler models
|
|
80
|
+
python -m cuddler videos create --json request.json --idempotency-key episode-001 --wait
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Both distributions use the same command name. Install Python into a virtual environment,
|
|
84
|
+
or use `python -m cuddler` to select it explicitly. The commands, JSON result output and
|
|
85
|
+
safe recovery error fields match the npm CLI. Use `--json` for private prompt content
|
|
86
|
+
so it does not appear in shell history or process arguments. File transfer uses the
|
|
87
|
+
returned signed URL without sending the API credential to the media host.
|
|
88
|
+
|
|
89
|
+
## Build and release boundary
|
|
90
|
+
|
|
91
|
+
The npm source package has a private workspace name because the platform web app already
|
|
92
|
+
uses `@cuddler/platform` internally. `node scripts/build.mjs` produces a separate publishable
|
|
93
|
+
`dist` manifest named `@cuddler/platform`, with only SDK code, contract types and docs.
|
|
94
|
+
The Python source is in `packages/platform-sdk-python-public` and builds the package `cuddler`.
|
|
95
|
+
The existing private adapter cores in Draft #5979 remain separate and unchanged.
|
|
96
|
+
|
|
97
|
+
The SDK and CLI only implement API types, HTTP requests, queries, polling, error handling
|
|
98
|
+
and recovery of the original operation. Model scheduling, server authorization, quota and
|
|
99
|
+
settlement decisions, and provider credentials remain in the service. Published packages
|
|
100
|
+
must run without access to this private repository and must not contain private backend
|
|
101
|
+
implementation, runtime credentials or user data. JavaScript and Python client files in
|
|
102
|
+
distributed packages remain inspectable; a private source repository does not hide them.
|
|
103
|
+
|
|
104
|
+
### Distribution terms and publication status
|
|
105
|
+
|
|
106
|
+
SDK and CLI packages are distributed under the MIT License. Keep the bundled LICENSE
|
|
107
|
+
and copyright notice when copying or redistributing the software. The development
|
|
108
|
+
repository remains private; this does not restrict the rights granted for the SDK
|
|
109
|
+
files included in the packages. The license covers the distributed SDK, not private
|
|
110
|
+
backend code, service access, account credentials or API usage charges.
|
|
111
|
+
|
|
112
|
+
Both LICENSE files, the JavaScript source and generated package metadata, and Python
|
|
113
|
+
pyproject.toml declare MIT. Before release, rebuild and inspect both archives and record
|
|
114
|
+
their hashes against the private source commit. Public source hosting and npm provenance
|
|
115
|
+
are not prerequisites for this phase. Installers need no private-repository access.
|
|
116
|
+
|
|
117
|
+
Registry identities, package permissions and release authorization must be verified.
|
|
118
|
+
After publication, verify clean installation of both SDKs and CLIs from the official npm
|
|
119
|
+
and PyPI registries. Reuse matching live acceptance evidence; local builds are not proof
|
|
120
|
+
of registry publication. Do not add paid generation tasks for these documentation changes.
|
|
@@ -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
|
|
@@ -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,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
cuddler/__init__.py
|
|
6
|
+
cuddler/__main__.py
|
|
7
|
+
cuddler/cli.py
|
|
8
|
+
cuddler/client.py
|
|
9
|
+
cuddler.egg-info/PKG-INFO
|
|
10
|
+
cuddler.egg-info/SOURCES.txt
|
|
11
|
+
cuddler.egg-info/dependency_links.txt
|
|
12
|
+
cuddler.egg-info/entry_points.txt
|
|
13
|
+
cuddler.egg-info/requires.txt
|
|
14
|
+
cuddler.egg-info/top_level.txt
|
|
15
|
+
tests/recovery-fixtures.json
|
|
16
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx<0.29,>=0.28.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cuddler
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cuddler"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Cuddler Platform client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["httpx>=0.28.1,<0.29"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
cuddler-platform = "cuddler.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
include = ["cuddler*"]
|
cuddler-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cases": [
|
|
3
|
+
{
|
|
4
|
+
"kind": "videos",
|
|
5
|
+
"id": "vid_SYNTHETIC000000000000",
|
|
6
|
+
"body": {
|
|
7
|
+
"model": "fixture-model",
|
|
8
|
+
"prompt": "synthetic fixture",
|
|
9
|
+
"max_usd": "0.50"
|
|
10
|
+
},
|
|
11
|
+
"idempotencyKey": "fixture-videos-001"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"kind": "images",
|
|
15
|
+
"id": "img_SYNTHETIC000000000000",
|
|
16
|
+
"body": {
|
|
17
|
+
"model": "fixture-model",
|
|
18
|
+
"prompt": "synthetic fixture",
|
|
19
|
+
"max_usd": "0.50"
|
|
20
|
+
},
|
|
21
|
+
"idempotencyKey": "fixture-images-001"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"kind": "speech",
|
|
25
|
+
"id": "aud_SYNTHETIC000000000000",
|
|
26
|
+
"body": {
|
|
27
|
+
"model": "fixture-model",
|
|
28
|
+
"prompt": "synthetic fixture",
|
|
29
|
+
"max_usd": "0.50"
|
|
30
|
+
},
|
|
31
|
+
"idempotencyKey": "fixture-speech-001"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"kind": "music",
|
|
35
|
+
"id": "mus_SYNTHETIC000000000000",
|
|
36
|
+
"body": {
|
|
37
|
+
"model": "fixture-model",
|
|
38
|
+
"prompt": "synthetic fixture",
|
|
39
|
+
"max_usd": "0.50"
|
|
40
|
+
},
|
|
41
|
+
"idempotencyKey": "fixture-music-001"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"errors": [
|
|
45
|
+
{
|
|
46
|
+
"status": 401,
|
|
47
|
+
"code": "unauthorized"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"status": 409,
|
|
51
|
+
"code": "idempotency_conflict"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import sysconfig
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
import httpx
|
|
13
|
+
from cuddler import Cuddler, CuddlerError
|
|
14
|
+
from cuddler.cli import main
|
|
15
|
+
|
|
16
|
+
KEY = "cud_sk_live_SYNTHETIC0000000000000000"
|
|
17
|
+
JOB = "vid_SYNTHETIC000000000000"
|
|
18
|
+
BODY = {"model": "seedance-2.5", "prompt": "synthetic", "duration": 4, "max_usd": "1"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClientTests(unittest.TestCase):
|
|
22
|
+
def client(self, handler, **kwargs):
|
|
23
|
+
client = Cuddler(KEY, transport=httpx.MockTransport(handler), **kwargs)
|
|
24
|
+
self.addCleanup(client.close)
|
|
25
|
+
return client
|
|
26
|
+
|
|
27
|
+
def test_four_resources_and_recovery_identity(self):
|
|
28
|
+
requests = []
|
|
29
|
+
def handle(request):
|
|
30
|
+
requests.append(request)
|
|
31
|
+
prefix = {"videos": "vid", "images": "img", "speech": "aud", "music": "mus"}[request.url.path.split("/")[-1]]
|
|
32
|
+
return httpx.Response(200, json={"id": prefix + "_SYNTHETIC000000000000", "status": "queued"})
|
|
33
|
+
client = self.client(handle)
|
|
34
|
+
for kind in ("videos", "images", "speech", "music"):
|
|
35
|
+
getattr(client, kind).create(BODY)
|
|
36
|
+
self.assertEqual(len({r.headers["idempotency-key"] for r in requests}), 4)
|
|
37
|
+
for _ in range(2):
|
|
38
|
+
client.videos.create(BODY, idempotency_key="operation-123")
|
|
39
|
+
self.assertEqual(requests[-1].headers["idempotency-key"], requests[-2].headers["idempotency-key"])
|
|
40
|
+
self.assertTrue(all(r.url.host == "api.cuddler.ai" for r in requests))
|
|
41
|
+
self.assertFalse(hasattr(client.images, "cancel"))
|
|
42
|
+
|
|
43
|
+
def test_lost_response_never_resubmits(self):
|
|
44
|
+
requests = []
|
|
45
|
+
def handle(request):
|
|
46
|
+
requests.append(request)
|
|
47
|
+
raise httpx.ReadTimeout(KEY)
|
|
48
|
+
with self.assertRaises(CuddlerError) as caught:
|
|
49
|
+
self.client(handle).videos.create(BODY)
|
|
50
|
+
self.assertEqual(len(requests), 1)
|
|
51
|
+
error = caught.exception
|
|
52
|
+
self.assertTrue(error.outcome_unknown)
|
|
53
|
+
self.assertTrue(error.idempotency_key.startswith("sdk-"))
|
|
54
|
+
self.assertNotIn(KEY, str(error))
|
|
55
|
+
self.assertNotIn(KEY, json.dumps(vars(error)))
|
|
56
|
+
|
|
57
|
+
def test_read_retries_obey_retry_after(self):
|
|
58
|
+
requests, sleeps = [], []
|
|
59
|
+
def handle(request):
|
|
60
|
+
requests.append(request)
|
|
61
|
+
return httpx.Response(429, json={"code": "rate_limited"}, headers={"Retry-After": "3"}) if len(requests) == 1 else httpx.Response(200, json={"data": []})
|
|
62
|
+
self.client(handle, sleep=sleeps.append).models.list()
|
|
63
|
+
self.assertEqual(sleeps, [3])
|
|
64
|
+
self.assertEqual(len(requests), 2)
|
|
65
|
+
|
|
66
|
+
def test_terminal_failure_and_wait_only_get(self):
|
|
67
|
+
methods = []
|
|
68
|
+
def handle(request):
|
|
69
|
+
methods.append(request.method)
|
|
70
|
+
return httpx.Response(200, json={"id": JOB, "status": "failed" if len(methods) == 3 else "running"})
|
|
71
|
+
job = self.client(handle, sleep=lambda _: None).videos.create_and_wait(BODY)
|
|
72
|
+
self.assertEqual(job["status"], "failed")
|
|
73
|
+
self.assertEqual(methods, ["POST", "GET", "GET"])
|
|
74
|
+
|
|
75
|
+
def test_caps_and_ids_fail_before_http(self):
|
|
76
|
+
def handle(_):
|
|
77
|
+
self.fail("unexpected request")
|
|
78
|
+
client = self.client(handle)
|
|
79
|
+
for cap in (None, 1, "0", "-1", "1.0000001"):
|
|
80
|
+
with self.assertRaises(CuddlerError):
|
|
81
|
+
client.images.create(BODY | {"max_usd": cap})
|
|
82
|
+
with self.assertRaises(CuddlerError):
|
|
83
|
+
client.videos.retrieve("../balance")
|
|
84
|
+
|
|
85
|
+
def test_redirect_is_not_followed_and_errors_do_not_include_server_prose(self):
|
|
86
|
+
requests = []
|
|
87
|
+
def handle(request):
|
|
88
|
+
requests.append(request)
|
|
89
|
+
return httpx.Response(302, json={"code": KEY, "message": BODY["prompt"]}, headers={"Location": "https://example.org"})
|
|
90
|
+
with self.assertRaises(CuddlerError) as caught:
|
|
91
|
+
self.client(handle).models.list()
|
|
92
|
+
self.assertEqual(len(requests), 1)
|
|
93
|
+
self.assertEqual(caught.exception.code, "http_error")
|
|
94
|
+
|
|
95
|
+
def test_invalid_wait_options_and_interrupted_poll_recovery(self):
|
|
96
|
+
methods = []
|
|
97
|
+
def handle(request):
|
|
98
|
+
methods.append(request.method)
|
|
99
|
+
if request.method == "POST":
|
|
100
|
+
return httpx.Response(200, json={"id": JOB, "status": "queued"})
|
|
101
|
+
raise httpx.ReadTimeout("interrupted")
|
|
102
|
+
client = self.client(handle, max_retries=0)
|
|
103
|
+
with self.assertRaises(CuddlerError):
|
|
104
|
+
client.videos.create_and_wait(BODY, timeout=0)
|
|
105
|
+
self.assertEqual(methods, [])
|
|
106
|
+
with self.assertRaises(CuddlerError) as caught:
|
|
107
|
+
client.videos.create_and_wait(BODY)
|
|
108
|
+
self.assertEqual(caught.exception.job_id, JOB)
|
|
109
|
+
self.assertFalse(caught.exception.outcome_unknown)
|
|
110
|
+
self.assertEqual(methods, ["POST", "GET"])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_shared_media_recovery_fixtures(self):
|
|
114
|
+
fixture_path = Path(__file__).with_name("recovery-fixtures.json")
|
|
115
|
+
fixtures = json.loads(fixture_path.read_text())
|
|
116
|
+
for fixture in fixtures["cases"]:
|
|
117
|
+
with self.subTest(kind=fixture["kind"]):
|
|
118
|
+
calls = []
|
|
119
|
+
def handle(request):
|
|
120
|
+
calls.append(request.method)
|
|
121
|
+
suffix = "/" + fixture["id"] if request.method == "GET" else ""
|
|
122
|
+
self.assertEqual(request.url.path, "/v1/" + fixture["kind"] + suffix)
|
|
123
|
+
if request.method == "POST":
|
|
124
|
+
self.assertEqual(request.headers["Idempotency-Key"], fixture["idempotencyKey"])
|
|
125
|
+
self.assertEqual(json.loads(request.content), fixture["body"])
|
|
126
|
+
if len(calls) == 1:
|
|
127
|
+
raise httpx.ReadError("synthetic lost response")
|
|
128
|
+
return httpx.Response(200, json={"id": fixture["id"], "status": "queued" if request.method == "POST" else "failed"})
|
|
129
|
+
resource = getattr(self.client(handle), fixture["kind"])
|
|
130
|
+
with self.assertRaises(CuddlerError) as caught:
|
|
131
|
+
resource.create(fixture["body"], idempotency_key=fixture["idempotencyKey"])
|
|
132
|
+
self.assertEqual(caught.exception.code, "network_error")
|
|
133
|
+
self.assertTrue(caught.exception.outcome_unknown)
|
|
134
|
+
self.assertEqual(caught.exception.idempotency_key, fixture["idempotencyKey"])
|
|
135
|
+
self.assertEqual(len(calls), 1)
|
|
136
|
+
self.assertEqual(resource.create(fixture["body"], idempotency_key=fixture["idempotencyKey"])["id"], fixture["id"])
|
|
137
|
+
self.assertEqual(resource.wait(fixture["id"])["status"], "failed")
|
|
138
|
+
self.assertEqual(calls, ["POST", "POST", "GET"])
|
|
139
|
+
for fixture in fixtures["errors"]:
|
|
140
|
+
with self.subTest(status=fixture["status"]):
|
|
141
|
+
calls = []
|
|
142
|
+
def handle(request):
|
|
143
|
+
calls.append(request.method)
|
|
144
|
+
return httpx.Response(fixture["status"], json={"code": fixture["code"]})
|
|
145
|
+
with self.assertRaises(CuddlerError) as caught:
|
|
146
|
+
self.client(handle).videos.create(BODY, idempotency_key="operation-123")
|
|
147
|
+
self.assertEqual(caught.exception.code, fixture["code"])
|
|
148
|
+
self.assertEqual(caught.exception.status, fixture["status"])
|
|
149
|
+
self.assertFalse(caught.exception.outcome_unknown)
|
|
150
|
+
self.assertEqual(calls, ["POST"])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CliTests(unittest.TestCase):
|
|
154
|
+
def invoke(self, args, handler=None):
|
|
155
|
+
def unexpected(_request):
|
|
156
|
+
self.fail("unexpected HTTP request")
|
|
157
|
+
client = Cuddler(KEY, transport=httpx.MockTransport(handler or unexpected),
|
|
158
|
+
sleep=lambda _: None, max_retries=0)
|
|
159
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
160
|
+
with patch("cuddler.cli.Cuddler", return_value=client), redirect_stdout(stdout), redirect_stderr(stderr):
|
|
161
|
+
try:
|
|
162
|
+
code = main(args)
|
|
163
|
+
finally:
|
|
164
|
+
client.close()
|
|
165
|
+
return code, stdout.getvalue(), stderr.getvalue()
|
|
166
|
+
|
|
167
|
+
def test_installed_console_and_module_help_without_credentials(self):
|
|
168
|
+
script = Path(sysconfig.get_path("scripts")) / ("cuddler-platform.exe" if os.name == "nt" else "cuddler-platform")
|
|
169
|
+
env = {k: v for k, v in os.environ.items() if k != "CUDDLER_API_KEY"}
|
|
170
|
+
for command in ([str(script), "--help"], [sys.executable, "-m", "cuddler", "--help"]):
|
|
171
|
+
with self.subTest(command=command):
|
|
172
|
+
result = subprocess.run(command, capture_output=True, text=True, env=env, timeout=10)
|
|
173
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
174
|
+
self.assertIn("--idempotency-key", result.stdout)
|
|
175
|
+
self.assertEqual(result.stderr, "")
|
|
176
|
+
|
|
177
|
+
def test_models_and_original_job_reads(self):
|
|
178
|
+
paths = []
|
|
179
|
+
def handle(request):
|
|
180
|
+
paths.append(request.url.path)
|
|
181
|
+
self.assertEqual(request.method, "GET")
|
|
182
|
+
return httpx.Response(200, json={"data": []} if request.url.path.endswith("models") else {"id": JOB, "status": "succeeded"})
|
|
183
|
+
for args in (["models"], ["videos", "get", JOB]):
|
|
184
|
+
code, stdout, stderr = self.invoke(args, handle)
|
|
185
|
+
self.assertEqual(code, 0)
|
|
186
|
+
self.assertIsInstance(json.loads(stdout), dict)
|
|
187
|
+
self.assertEqual(stderr, "")
|
|
188
|
+
self.assertEqual(paths, ["/v1/models", "/v1/videos/" + JOB])
|
|
189
|
+
|
|
190
|
+
def test_shared_four_media_create_and_wait(self):
|
|
191
|
+
fixtures = json.loads(Path(__file__).with_name("recovery-fixtures.json").read_text())
|
|
192
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
193
|
+
request_file = Path(directory) / "request.json"
|
|
194
|
+
for fixture in fixtures["cases"]:
|
|
195
|
+
with self.subTest(kind=fixture["kind"]):
|
|
196
|
+
request_file.write_text(json.dumps(fixture["body"]))
|
|
197
|
+
methods = []
|
|
198
|
+
def handle(request):
|
|
199
|
+
methods.append(request.method)
|
|
200
|
+
if request.method == "POST":
|
|
201
|
+
self.assertEqual(json.loads(request.content), fixture["body"])
|
|
202
|
+
self.assertEqual(request.headers["Idempotency-Key"], fixture["idempotencyKey"])
|
|
203
|
+
return httpx.Response(200, json={"id": fixture["id"], "status": "queued" if request.method == "POST" else "failed"})
|
|
204
|
+
code, stdout, stderr = self.invoke([fixture["kind"], "create", "--json", str(request_file),
|
|
205
|
+
"--idempotency-key", fixture["idempotencyKey"], "--wait"], handle)
|
|
206
|
+
self.assertEqual(code, 1)
|
|
207
|
+
self.assertEqual(json.loads(stdout)["id"], fixture["id"])
|
|
208
|
+
self.assertEqual(stderr, "")
|
|
209
|
+
self.assertEqual(methods, ["POST", "GET"])
|
|
210
|
+
|
|
211
|
+
def test_uncertain_create_is_once_and_emits_only_recovery_metadata(self):
|
|
212
|
+
requests = []
|
|
213
|
+
def handle(request):
|
|
214
|
+
requests.append(request)
|
|
215
|
+
raise httpx.ReadTimeout(KEY + BODY["prompt"])
|
|
216
|
+
code, stdout, stderr = self.invoke(["videos", "create", "--model", BODY["model"],
|
|
217
|
+
"--prompt", BODY["prompt"], "--duration", "4", "--max-usd", "1",
|
|
218
|
+
"--idempotency-key", "operation-123"], handle)
|
|
219
|
+
self.assertEqual(len(requests), 1)
|
|
220
|
+
self.assertEqual(json.loads(requests[0].content), BODY)
|
|
221
|
+
self.assertEqual(code, 1)
|
|
222
|
+
self.assertEqual(stdout, "")
|
|
223
|
+
self.assertEqual(json.loads(stderr), {"code": "network_error", "status": None,
|
|
224
|
+
"idempotencyKey": "operation-123", "outcomeUnknown": True, "jobId": None})
|
|
225
|
+
self.assertNotIn(KEY, stderr)
|
|
226
|
+
self.assertNotIn(BODY["prompt"], stderr)
|
|
227
|
+
|
|
228
|
+
def test_invalid_input_never_calls_api_or_echoes_secrets(self):
|
|
229
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
230
|
+
request_file = Path(directory) / "request.json"
|
|
231
|
+
request_file.write_text('{"prompt": "' + KEY)
|
|
232
|
+
cases = [["models", "--api-key", KEY], ["videos", "create", "--json", str(request_file)],
|
|
233
|
+
["videos", "create", "--duration", "NaN"], ["videos", "get", "../balance"],
|
|
234
|
+
["videos", "create", "--max-usd", "0"], ["videos", "create", "extra"]]
|
|
235
|
+
for args in cases:
|
|
236
|
+
with self.subTest(args=args):
|
|
237
|
+
code, stdout, stderr = self.invoke(args)
|
|
238
|
+
self.assertEqual(code, 1)
|
|
239
|
+
self.assertEqual(stdout, "")
|
|
240
|
+
self.assertNotIn(KEY, stderr)
|
|
241
|
+
self.assertNotIn(str(request_file), stderr)
|
|
242
|
+
self.assertIn("code", json.loads(stderr))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
unittest.main()
|