dreamlayer 0.4.0-beta.1 → 0.4.0-beta.3
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.
- package/README.md +59 -12
- package/dist/cli.js +157 -80
- package/dist/client.d.ts +5 -75
- package/dist/client.js +28 -133
- package/dist/render.d.ts +0 -8
- package/dist/render.js +0 -8
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ dreamlayer cutout <image> [--out file.png] # background removal
|
|
|
21
21
|
dreamlayer upscale <image> [--out file.png] # 2x
|
|
22
22
|
dreamlayer answer <conversation-id> <text> [--image file.png]
|
|
23
23
|
dreamlayer status <execution-id>
|
|
24
|
+
dreamlayer download <execution-id> --out file.png
|
|
24
25
|
dreamlayer balance # spends nothing
|
|
25
26
|
dreamlayer capabilities # spends nothing
|
|
26
27
|
```
|
|
@@ -77,28 +78,28 @@ dreamlayer balance --json
|
|
|
77
78
|
|
|
78
79
|
## Retries are safe if you reuse the key
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
Save a key before starting text generation. After an uncertain response, reuse that key
|
|
82
|
+
and the identical prompt/options, or check the existing execution first:
|
|
82
83
|
|
|
83
84
|
```bash
|
|
84
85
|
dreamlayer generate "a fox logo" --idempotency-key fox-001
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
##
|
|
88
|
+
## Continue a question from another client
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
The CLI's `generate`, `edit`, `cutout`, `upscale`, and `sprite` commands select their
|
|
91
|
+
operation explicitly. `generate "remove the background"` therefore remains a
|
|
92
|
+
text-to-image request; use `cutout image.png` to remove a background.
|
|
93
|
+
|
|
94
|
+
A conversational request made through the API or MCP can instead ask for missing
|
|
95
|
+
input. Continue that conversation from the CLI using its saved conversation ID:
|
|
90
96
|
|
|
91
97
|
```bash
|
|
92
|
-
dreamlayer
|
|
93
|
-
# Which image should I use? Upload or attach one, then respond.
|
|
94
|
-
# dreamlayer answer <id> "your answer" --image <file>
|
|
98
|
+
dreamlayer answer <conversation-id> "Use this image" --image reference.png
|
|
95
99
|
```
|
|
96
100
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Naming an operation avoids the round trip entirely, which is why `cutout`, `upscale`,
|
|
101
|
-
`edit`, and `generate` all do.
|
|
101
|
+
An answer that needs an image must attach one. Exit code 6 means the returned
|
|
102
|
+
conversation needs input; it does not mean a generation failed.
|
|
102
103
|
|
|
103
104
|
## Requirements
|
|
104
105
|
|
|
@@ -124,3 +125,49 @@ dreamlayer status EXECUTION_ID
|
|
|
124
125
|
Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes.
|
|
125
126
|
|
|
126
127
|
For affordability, compare the complete rounded quote in **credits** with `available`. One tenth of a credit is $0.017. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.1 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier.
|
|
128
|
+
|
|
129
|
+
## Automating recovery
|
|
130
|
+
|
|
131
|
+
Commands never prompt. `--json` sends results to stdout and structured errors to stderr,
|
|
132
|
+
including usage and transport errors. Exit 0 from `status` means the read succeeded;
|
|
133
|
+
inspect its `status` field to learn whether the execution completed.
|
|
134
|
+
|
|
135
|
+
After a lost download, recover the existing execution without generation:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
dreamlayer status EXECUTION_ID --json
|
|
139
|
+
dreamlayer download EXECUTION_ID --out recovered.png --json
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
All output commands refuse to overwrite existing files, directories, or symlinks.
|
|
143
|
+
Paid commands check `--out` before uploads or `/v1/execute`: an existing destination
|
|
144
|
+
returns `output_exists` (exit 1) without submitting or charging a new job. A missing or
|
|
145
|
+
unwritable parent returns `output_unavailable` (exit 1). Re-running a batch with the
|
|
146
|
+
same output paths therefore stops on completed files before paid work. Choose a new
|
|
147
|
+
path only for intentionally new work. The final write is still exclusive: if another
|
|
148
|
+
process creates the destination during generation, use the saved execution ID to
|
|
149
|
+
recover the completed output with `download`.
|
|
150
|
+
|
|
151
|
+
`download` also refuses to overwrite a file. Use `.zip` for sprite results. Generation errors
|
|
152
|
+
in JSON include the available execution ID and idempotency key for recovery. Treat them
|
|
153
|
+
as private identifiers. For file-based commands, rerunning uploads a new asset: the same
|
|
154
|
+
local file is not an identical API request. Prefer `status` and `download`, or the
|
|
155
|
+
[execution recovery guide](https://docs.dreamlayer.io/agent-api/jobs-and-events).
|
|
156
|
+
|
|
157
|
+
[API overview](https://docs.dreamlayer.io/agent-api) ·
|
|
158
|
+
[CLI guide](https://docs.dreamlayer.io/cli) ·
|
|
159
|
+
[MCP setup](https://docs.dreamlayer.io/mcp/index)
|
|
160
|
+
|
|
161
|
+
Local client failures are separate from API generation failures. `local_output_failed`
|
|
162
|
+
(exit 1) means the completed output could not be written; fix the destination and run
|
|
163
|
+
`download` with the saved execution ID. `download_failed` or `output_not_ready` (exit 5)
|
|
164
|
+
also require recovery of existing work, not a new generation. `retryable: true` means
|
|
165
|
+
retry the indicated recovery action, never blindly repeat a paid command.
|
|
166
|
+
`local_input_failed` (exit 1) means no readable input was supplied; missing credentials
|
|
167
|
+
use `authentication_failed` (exit 2). A cancelled run uses `execution_cancelled`, exit 4,
|
|
168
|
+
and a JSON error on stderr. Unknown client failures use `client_error`; they do not
|
|
169
|
+
prove that the server-side generation failed.
|
|
170
|
+
|
|
171
|
+
## Error handling updates in beta.3
|
|
172
|
+
|
|
173
|
+
Transport failures include a safe connection category without exposing URLs or credentials. Cancelled executions recommend a status check; download authentication failures retain exit code 2. Help flags are parsed as options so a literal `-h` option value is preserved.
|
package/dist/cli.js
CHANGED
|
@@ -1,25 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spriteCreditPrice } from "./client.js";
|
|
3
|
-
/**
|
|
4
|
-
* DreamLayer CLI.
|
|
5
|
-
*
|
|
6
|
-
* Generate and edit images from a terminal, over local files. The API is the same one
|
|
7
|
-
* the MCP server and the web app use, and it spends from the same credit balance.
|
|
8
|
-
*
|
|
9
|
-
* Exit codes are meaningful, so this composes in a script:
|
|
10
|
-
* 0 success
|
|
11
|
-
* 1 usage error
|
|
12
|
-
* 2 authentication or account problem (401, 403)
|
|
13
|
-
* 3 out of credits (402)
|
|
14
|
-
* 4 the request was rejected (409, 422)
|
|
15
|
-
* 5 temporary, worth retrying (429, 5xx)
|
|
16
|
-
* 6 the run ended asking a question instead of producing an image
|
|
17
|
-
*/
|
|
18
3
|
import { randomUUID } from "node:crypto";
|
|
19
|
-
import { openAsBlob, readFileSync } from "node:fs";
|
|
20
|
-
import { stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { constants, openAsBlob, readFileSync } from "node:fs";
|
|
5
|
+
import { access, lstat, stat, writeFile } from "node:fs/promises";
|
|
21
6
|
import path from "node:path";
|
|
22
|
-
import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
|
|
7
|
+
import { ApiError, InputValidationError, RecoveryRequiredError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
|
|
23
8
|
import { Progress, consume } from "./render.js";
|
|
24
9
|
const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
25
10
|
const USAGE = `dreamlayer - generate and edit images from your terminal
|
|
@@ -31,12 +16,14 @@ USAGE
|
|
|
31
16
|
dreamlayer upscale <image> [--out <file>]
|
|
32
17
|
dreamlayer sprite <image> --action <walk|run|idle> [--frames <7–100>] --max-credits <n> [--out <zip>]
|
|
33
18
|
dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
|
|
19
|
+
dreamlayer download <execution-id> --out <file>
|
|
34
20
|
dreamlayer status <execution-id>
|
|
35
21
|
dreamlayer balance
|
|
36
22
|
dreamlayer capabilities
|
|
37
23
|
|
|
38
24
|
OPTIONS
|
|
39
|
-
--out <file>
|
|
25
|
+
--out <file> New output file; never overwrites. Default: dreamlayer-<n>.png
|
|
26
|
+
Existing destinations are refused before paid submission.
|
|
40
27
|
--image <file> Attach an image when answering a question that asks for one
|
|
41
28
|
--aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
|
|
42
29
|
--action <name> Sprite preset: walk, run, idle (walk if no custom prompt)
|
|
@@ -45,10 +32,19 @@ OPTIONS
|
|
|
45
32
|
--frame-size <px> Square export: 32, 64, 128, 256, 512 (default), 720, 1080
|
|
46
33
|
--frames <n> Frame count: integer 7–100, default 12
|
|
47
34
|
--max-credits <n> Maximum approved charge for the sprite job
|
|
48
|
-
--json
|
|
35
|
+
--json JSON results on stdout; JSON errors on stderr
|
|
49
36
|
--quiet No progress on stderr
|
|
50
37
|
--idempotency-key <key> Reuse to retry safely after an uncertain response
|
|
51
38
|
|
|
39
|
+
EXIT CODES
|
|
40
|
+
0 success, 1 usage/local error, 2 authentication/access, 3 credits/quota,
|
|
41
|
+
4 permanent API failure, 5 temporary failure, 6 input required
|
|
42
|
+
|
|
43
|
+
AUTOMATION
|
|
44
|
+
Commands never prompt. Save a unique --idempotency-key before paid work.
|
|
45
|
+
After uncertainty, use status then download; do not start a replacement job.
|
|
46
|
+
CLI guide: https://docs.dreamlayer.io/cli
|
|
47
|
+
|
|
52
48
|
ENVIRONMENT
|
|
53
49
|
DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
|
|
54
50
|
DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
|
|
@@ -57,6 +53,61 @@ Image operations cost one credit. Sprite pricing is listed in capabilities. A ne
|
|
|
57
53
|
`;
|
|
58
54
|
class UsageError extends Error {
|
|
59
55
|
}
|
|
56
|
+
class CommandError extends Error {
|
|
57
|
+
reason;
|
|
58
|
+
exitCode;
|
|
59
|
+
retryable;
|
|
60
|
+
guidance;
|
|
61
|
+
constructor(reason, message, exitCode, retryable = false, guidance) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.reason = reason;
|
|
64
|
+
this.exitCode = exitCode;
|
|
65
|
+
this.retryable = retryable;
|
|
66
|
+
this.guidance = guidance;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function preflightOutput(target) {
|
|
70
|
+
let exists = false;
|
|
71
|
+
try {
|
|
72
|
+
await lstat(target);
|
|
73
|
+
exists = true;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code !== "ENOENT") {
|
|
77
|
+
throw new CommandError("output_unavailable", "The output destination could not be checked. No generation was submitted.", 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (exists) {
|
|
81
|
+
throw new CommandError("output_exists", "The output destination already exists. Refusing to overwrite; no generation was submitted.", 1, false, "Use the existing output or choose a new --out path for intentionally new work.");
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parent = path.dirname(path.resolve(target));
|
|
85
|
+
if (!(await stat(parent)).isDirectory())
|
|
86
|
+
throw new Error("not a directory");
|
|
87
|
+
await access(parent, constants.W_OK | constants.X_OK);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new CommandError("output_unavailable", "The output parent must be an existing writable directory. No generation was submitted.", 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function saveOutput(target, bytes) {
|
|
94
|
+
try {
|
|
95
|
+
await writeFile(target, bytes, { flag: "wx" });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new CommandError("local_output_failed", "The output could not be saved locally. Generation has already completed.", 1, true, "Fix the destination or choose a new path, then use dreamlayer download with the saved execution_id. Do not generate again.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function downloadOutput(api, url) {
|
|
102
|
+
try {
|
|
103
|
+
return await api.download(url);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof ApiError && [401, 403].includes(error.status))
|
|
107
|
+
throw error;
|
|
108
|
+
throw new CommandError("download_failed", "The completed output could not be downloaded.", 5, true, "Retry dreamlayer download with the saved execution_id. Do not generate again.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
60
111
|
function parseOptions(argv) {
|
|
61
112
|
const positional = [];
|
|
62
113
|
const options = {
|
|
@@ -67,11 +118,14 @@ function parseOptions(argv) {
|
|
|
67
118
|
aspect: "1:1",
|
|
68
119
|
json: false,
|
|
69
120
|
quiet: false,
|
|
121
|
+
help: false,
|
|
70
122
|
idempotencyKey: null,
|
|
71
123
|
};
|
|
72
124
|
for (let i = 0; i < argv.length; i += 1) {
|
|
73
125
|
const token = argv[i];
|
|
74
|
-
if (token === "--
|
|
126
|
+
if (token === "--help" || token === "-h")
|
|
127
|
+
options.help = true;
|
|
128
|
+
else if (token === "--json")
|
|
75
129
|
options.json = true;
|
|
76
130
|
else if (token === "--quiet")
|
|
77
131
|
options.quiet = true;
|
|
@@ -147,14 +201,11 @@ function parseOptions(argv) {
|
|
|
147
201
|
function client() {
|
|
148
202
|
const key = (process.env.DREAMLAYER_API_KEY ?? "").trim();
|
|
149
203
|
if (!key) {
|
|
150
|
-
throw new
|
|
151
|
-
" export DREAMLAYER_API_KEY=dlr_live_...\n" +
|
|
152
|
-
" Get a key at https://platform.dreamlayer.io");
|
|
204
|
+
throw new CommandError("authentication_failed", "DREAMLAYER_API_KEY is not set. Get a key at https://platform.dreamlayer.io", 2);
|
|
153
205
|
}
|
|
154
206
|
return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim());
|
|
155
207
|
}
|
|
156
208
|
const MAX_SOURCE_BYTES = 200 * 1024 * 1024;
|
|
157
|
-
/** Upload a local file; the server owns RAW, EXIF, alpha, and resize normalization. */
|
|
158
209
|
async function upload(api, file) {
|
|
159
210
|
const resolved = path.resolve(file);
|
|
160
211
|
let fileStat;
|
|
@@ -162,30 +213,38 @@ async function upload(api, file) {
|
|
|
162
213
|
fileStat = await stat(resolved);
|
|
163
214
|
}
|
|
164
215
|
catch {
|
|
165
|
-
throw new
|
|
216
|
+
throw new CommandError("local_input_failed", "The local input file could not be read.", 1);
|
|
166
217
|
}
|
|
167
218
|
if (fileStat.size > MAX_SOURCE_BYTES) {
|
|
168
219
|
throw new UsageError(`${file} is ${Math.round(fileStat.size / 1024 / 1024)} MB; the limit is 200 MB`);
|
|
169
220
|
}
|
|
170
|
-
|
|
221
|
+
let blob;
|
|
222
|
+
try {
|
|
223
|
+
blob = await openAsBlob(resolved);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
throw new CommandError("local_input_failed", "The local input file could not be read.", 1);
|
|
227
|
+
}
|
|
228
|
+
const asset = await api.uploadInput(blob, path.basename(resolved));
|
|
171
229
|
return asset.input_asset_id;
|
|
172
230
|
}
|
|
173
231
|
function defaultOut() {
|
|
174
232
|
return `dreamlayer-${Date.now()}.png`;
|
|
175
233
|
}
|
|
234
|
+
let recovery = {};
|
|
176
235
|
async function run(api, input, options) {
|
|
177
236
|
const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
|
|
178
237
|
const idempotencyKey = options.idempotencyKey ?? randomUUID();
|
|
238
|
+
recovery = { idempotency_key: idempotencyKey };
|
|
179
239
|
const outcome = await consume(api.follow(input, { idempotencyKey }), progress);
|
|
240
|
+
recovery.execution_id = outcome.execution_id;
|
|
180
241
|
if (outcome.question) {
|
|
181
242
|
progress.stop();
|
|
182
243
|
if (options.json) {
|
|
183
|
-
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
|
|
244
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`);
|
|
184
245
|
}
|
|
185
246
|
else {
|
|
186
247
|
process.stderr.write(`\nDreamLayer needs one more thing:\n ${outcome.question.text}\n\n`);
|
|
187
|
-
// The server's needs_input question always asks for an image, so point at the
|
|
188
|
-
// flag that can supply one rather than the bare form that will 422.
|
|
189
248
|
const wantsImage = /image/i.test(outcome.question.text);
|
|
190
249
|
process.stderr.write(`Answer it with:\n dreamlayer answer ${outcome.conversation_id} "your answer"` +
|
|
191
250
|
`${wantsImage ? " --image <file>" : ""}\n`);
|
|
@@ -199,22 +258,21 @@ async function run(api, input, options) {
|
|
|
199
258
|
if (terminal)
|
|
200
259
|
throw terminal;
|
|
201
260
|
}
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
261
|
+
if (outcome.status === "cancelled")
|
|
262
|
+
throw new CommandError("execution_cancelled", "The execution was cancelled.", 4);
|
|
263
|
+
if (outcome.status === "failed")
|
|
264
|
+
throw new CommandError("generation_failed", "The execution failed. Read canonical state for details.", 4);
|
|
265
|
+
throw new RecoveryRequiredError("The execution has no completed output yet. Read its saved state.");
|
|
207
266
|
}
|
|
208
267
|
progress.set("Downloading");
|
|
209
|
-
const bytes = await api
|
|
268
|
+
const bytes = await downloadOutput(api, outcome.asset.download_url);
|
|
210
269
|
const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut());
|
|
211
|
-
await
|
|
270
|
+
await saveOutput(target, bytes);
|
|
212
271
|
progress.stop();
|
|
213
272
|
if (options.json) {
|
|
214
|
-
process.stdout.write(`${JSON.stringify({ ...outcome, file: path.resolve(target) }, null, 2)}\n`);
|
|
273
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey, file: path.resolve(target) }, null, 2)}\n`);
|
|
215
274
|
}
|
|
216
275
|
else {
|
|
217
|
-
// The path on stdout and nothing else, so `$(dreamlayer generate ...)` is the file.
|
|
218
276
|
process.stdout.write(`${target}\n`);
|
|
219
277
|
}
|
|
220
278
|
return 0;
|
|
@@ -224,13 +282,6 @@ async function imageCommand(operation, prompt, file, options) {
|
|
|
224
282
|
const inputAssetId = await upload(api, file);
|
|
225
283
|
return run(api, { prompt, operation, input_asset_id: inputAssetId }, options);
|
|
226
284
|
}
|
|
227
|
-
/**
|
|
228
|
-
* Point a user at the job they may have paid for.
|
|
229
|
-
*
|
|
230
|
-
* Without this, a timed-out upscale left nothing to go on: no id, no command, and no
|
|
231
|
-
* key-authenticated way to check a balance. "It might have charged you, good luck" is
|
|
232
|
-
* not an acceptable end state for a paid call.
|
|
233
|
-
*/
|
|
234
285
|
function recoveryHint(error) {
|
|
235
286
|
const id = error !== null && typeof error === "object"
|
|
236
287
|
? error.partialOutcome
|
|
@@ -238,25 +289,6 @@ function recoveryHint(error) {
|
|
|
238
289
|
: null;
|
|
239
290
|
return id ? `The job may still be running. Check it with:\n dreamlayer status ${id}\n` : "";
|
|
240
291
|
}
|
|
241
|
-
/**
|
|
242
|
-
* Say so when this build and the server disagree about what exists.
|
|
243
|
-
*
|
|
244
|
-
* The MCP package solves this by asking the server at startup and shaping its tool
|
|
245
|
-
* schema from the answer. The CLI cannot: `ManagedOperation` is a compile-time union and
|
|
246
|
-
* `cutout` / `upscale` are compile-time commands, so deriving the list at runtime would
|
|
247
|
-
* buy consistency by giving up type safety at every call site.
|
|
248
|
-
*
|
|
249
|
-
* So it reports instead of adapting, and it does so HERE because `capabilities` is free,
|
|
250
|
-
* spends nothing, and is the command people are told to run first. Both directions are
|
|
251
|
-
* worth naming:
|
|
252
|
-
*
|
|
253
|
-
* - the server offers something this build cannot reach -> the user is missing a
|
|
254
|
-
* feature they are paying for and would never know
|
|
255
|
-
* - this build names something the server will not run -> the failure that shipped on
|
|
256
|
-
* 2026-08-21, where a call looked like a client bug rather than a version skew
|
|
257
|
-
*
|
|
258
|
-
* stderr, never stdout: `dreamlayer capabilities` is piped into jq.
|
|
259
|
-
*/
|
|
260
292
|
function warnIfOperationsDrifted(capabilities) {
|
|
261
293
|
const listed = capabilities.operations;
|
|
262
294
|
if (!Array.isArray(listed) || listed.some((o) => typeof o !== "string"))
|
|
@@ -295,6 +327,14 @@ async function main(argv) {
|
|
|
295
327
|
return 0;
|
|
296
328
|
}
|
|
297
329
|
const { positional, options } = parseOptions(rest);
|
|
330
|
+
if (options.help) {
|
|
331
|
+
process.stdout.write(USAGE);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
if (["generate", "edit", "cutout", "upscale", "sprite", "answer"].includes(command)) {
|
|
335
|
+
options.out ??= command === "sprite" ? `dreamlayer-${Date.now()}.zip` : defaultOut();
|
|
336
|
+
await preflightOutput(options.out);
|
|
337
|
+
}
|
|
298
338
|
switch (command) {
|
|
299
339
|
case "sprite": {
|
|
300
340
|
if (options.action && options.animationPrompt)
|
|
@@ -342,10 +382,6 @@ async function main(argv) {
|
|
|
342
382
|
if (!conversationId || !text)
|
|
343
383
|
throw new UsageError("answer needs a conversation id and text");
|
|
344
384
|
const api = client();
|
|
345
|
-
// A question that asks for an image cannot be answered with words alone: the
|
|
346
|
-
// server refuses a reply with no asset when the pending question required one.
|
|
347
|
-
// Without --image this command reached a clean 422 and the documented path
|
|
348
|
-
// dead-ended, telling the user to attach an image and offering no way to do it.
|
|
349
385
|
const input = options.image ? await upload(api, options.image) : undefined;
|
|
350
386
|
return run(api, {
|
|
351
387
|
respond: text,
|
|
@@ -353,6 +389,21 @@ async function main(argv) {
|
|
|
353
389
|
...(input ? { input_asset_id: input } : {}),
|
|
354
390
|
}, options);
|
|
355
391
|
}
|
|
392
|
+
case "download": {
|
|
393
|
+
const executionId = positional[0];
|
|
394
|
+
if (!executionId || positional.length !== 1 || !options.out)
|
|
395
|
+
throw new UsageError("download needs one execution id and --out <file>");
|
|
396
|
+
const api = client();
|
|
397
|
+
recovery = { execution_id: executionId };
|
|
398
|
+
const execution = await api.getExecution(executionId);
|
|
399
|
+
const assets = execution.image_job?.finished_assets;
|
|
400
|
+
if (execution.status !== "completed" || !Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string")
|
|
401
|
+
throw new CommandError("output_not_ready", "The execution has no single finished asset. Check status before downloading.", 5, true, "Read the saved execution; do not generate again.");
|
|
402
|
+
const bytes = await downloadOutput(api, assets[0].download_url);
|
|
403
|
+
await saveOutput(options.out, bytes);
|
|
404
|
+
process.stdout.write(options.json ? `${JSON.stringify({ execution_id: executionId, file: path.resolve(options.out), bytes: bytes.length })}\n` : `${options.out}\n`);
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
356
407
|
case "status": {
|
|
357
408
|
const executionId = positional[0];
|
|
358
409
|
if (!executionId)
|
|
@@ -392,6 +443,42 @@ main(process.argv.slice(2))
|
|
|
392
443
|
process.exitCode = code;
|
|
393
444
|
})
|
|
394
445
|
.catch((error) => {
|
|
446
|
+
const known = error instanceof CommandError ? error : error instanceof InputValidationError
|
|
447
|
+
? new CommandError("invalid_request", error.message, 1)
|
|
448
|
+
: error instanceof RecoveryRequiredError
|
|
449
|
+
? new CommandError("temporarily_unavailable", error.message, 5, true, "Use status and download for the saved execution. If no ID was received, replay identical inputs with the original idempotency key.") : null;
|
|
450
|
+
if (known) {
|
|
451
|
+
const partial = error.partialOutcome;
|
|
452
|
+
const identity = { ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) };
|
|
453
|
+
const envelope = { error: { code: "CLIENT_ERROR", reason: known.reason, message: known.message, retryable: known.retryable, request_id: null, guidance: known.guidance, ...identity } };
|
|
454
|
+
if (process.argv.slice(2).includes("--json"))
|
|
455
|
+
process.stderr.write(`${JSON.stringify(envelope)}\n`);
|
|
456
|
+
else {
|
|
457
|
+
process.stderr.write(`${known.message}\n${known.guidance ? known.guidance + "\n" : ""}`);
|
|
458
|
+
if (identity.execution_id) {
|
|
459
|
+
process.stderr.write(`Execution: ${identity.execution_id}\n dreamlayer status ${identity.execution_id}\n`);
|
|
460
|
+
if (["local_output_failed", "download_failed"].includes(known.reason))
|
|
461
|
+
process.stderr.write(` dreamlayer download ${identity.execution_id} --out <new-file>\n`);
|
|
462
|
+
}
|
|
463
|
+
if (identity.idempotency_key)
|
|
464
|
+
process.stderr.write(`Idempotency key: ${identity.idempotency_key}\n`);
|
|
465
|
+
}
|
|
466
|
+
process.exitCode = known.exitCode;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (process.argv.slice(2).includes("--json")) {
|
|
470
|
+
const partial = error?.partialOutcome;
|
|
471
|
+
const temporary = error instanceof StreamIdleError || error instanceof UploadTimeoutError;
|
|
472
|
+
const envelope = error instanceof ApiError ? error.toPublicEnvelope() : {
|
|
473
|
+
error: { code: error instanceof UsageError ? "VALIDATION_FAILED" : temporary ? "SERVICE_UNAVAILABLE" : "INTERNAL_ERROR",
|
|
474
|
+
reason: error instanceof UsageError ? "invalid_request" : temporary ? "temporarily_unavailable" : "client_error",
|
|
475
|
+
message: error instanceof UsageError ? "Check command arguments and local input or output files; use --help." : temporary ? "The request timed out. Check the saved execution before retrying." : "The command could not complete. Check saved execution state and local output access.",
|
|
476
|
+
retryable: temporary, request_id: null },
|
|
477
|
+
};
|
|
478
|
+
process.stderr.write(`${JSON.stringify({ error: { ...envelope.error, ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) } })}\n`);
|
|
479
|
+
process.exitCode = error instanceof ApiError ? exitCodeFor(error) : temporary ? 5 : 1;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
395
482
|
if (error instanceof UsageError) {
|
|
396
483
|
process.stderr.write(`${error.message}\n`);
|
|
397
484
|
process.exitCode = 1;
|
|
@@ -403,20 +490,10 @@ main(process.argv.slice(2))
|
|
|
403
490
|
: error.retryable
|
|
404
491
|
? "Temporary. Retry with --idempotency-key to avoid paying twice."
|
|
405
492
|
: "";
|
|
406
|
-
|
|
407
|
-
process.stderr.write(`${JSON.stringify(error.toPublicEnvelope())}\n`);
|
|
408
|
-
}
|
|
409
|
-
else {
|
|
410
|
-
process.stderr.write(`${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`);
|
|
411
|
-
}
|
|
493
|
+
process.stderr.write(`${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`);
|
|
412
494
|
process.exitCode = exitCodeFor(error);
|
|
413
495
|
return;
|
|
414
496
|
}
|
|
415
|
-
// A stream that went silent is retryable, and it is the failure MOST likely to
|
|
416
|
-
// have been charged for: the server may have finished the job we stopped listening
|
|
417
|
-
// to. It reached this generic branch as a bare DOMException, so it exited 1 with no
|
|
418
|
-
// guidance, and the --idempotency-key advice that exists precisely to prevent
|
|
419
|
-
// double payment never printed on the one case that needs it.
|
|
420
497
|
if (error instanceof StreamIdleError) {
|
|
421
498
|
process.stderr.write(`${error.message}\n`);
|
|
422
499
|
process.stderr.write("Temporary. Retry with --idempotency-key to avoid paying twice.\n");
|
package/dist/client.d.ts
CHANGED
|
@@ -1,49 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hosted client for the DreamLayer Agent API.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately a COPY of the same file in dreamlayer-mcp rather than a shared package.
|
|
5
|
-
* The alternative makes every capability three releases in strict order (client, then
|
|
6
|
-
* CLI, then MCP) instead of one release per repo. For a client over eight endpoints
|
|
7
|
-
* that trade is not worth the friction.
|
|
8
|
-
*
|
|
9
|
-
* Lifted from the DreamLayer runtime's TypeScript client, which is retired. The
|
|
10
|
-
* validation, error sanitisation, and origin hardening are kept verbatim because they
|
|
11
|
-
* were already correct; what changed is that the SSE reader is now wired to the hosted
|
|
12
|
-
* client instead of the dead local-proxy class, and that timeouts and an explicit
|
|
13
|
-
* redirect policy were added, which the original lacked.
|
|
14
|
-
*/
|
|
15
1
|
export type ManagedEventName = "started" | "thinking" | "progress" | "job" | "question" | "asset" | "done";
|
|
16
2
|
export type ManagedEvent = {
|
|
17
3
|
id: string | null;
|
|
18
4
|
event: ManagedEventName;
|
|
19
5
|
data: Record<string, unknown>;
|
|
20
6
|
};
|
|
21
|
-
/**
|
|
22
|
-
* Every operation the Agent API can execute.
|
|
23
|
-
*
|
|
24
|
-
* REQUIRES the gateway build that added `operation` to ExecuteRequest. Against an older
|
|
25
|
-
* deployment this field is rejected with 422 extra_forbidden, because the request model
|
|
26
|
-
* is closed. That is a sequencing constraint, not a reason to drop it: naming the
|
|
27
|
-
* operation is what stops a cutout or an upscale being re-read from the prompt and
|
|
28
|
-
* coming back as a clarifying question instead of an image.
|
|
29
|
-
*
|
|
30
|
-
* SATISFIED 2026-08-21. prodbeta176 carries the field in the gateway AND the dispatch
|
|
31
|
-
* in the workflow engine, which had been split across two releases: the gateway
|
|
32
|
-
* accepted `operation` from prodbeta174 while the half that acts on it was still on
|
|
33
|
-
* prodbeta172, so naming an operation returned 200 and was then inferred from prose
|
|
34
|
-
* anyway. Confirmed against the deployment, not the source: the live /openapi.json
|
|
35
|
-
* advertises exactly these four in both ExecuteRequest and ImageJobCreate.
|
|
36
|
-
*/
|
|
37
7
|
export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale", "sprite_sheet"];
|
|
38
|
-
/**
|
|
39
|
-
* Derived from the array above, not written twice.
|
|
40
|
-
*
|
|
41
|
-
* The first version of this declared the union by hand and pinned an array to it with
|
|
42
|
-
* `satisfies`. That catches a WRONG entry and not a MISSING one, because a shorter array
|
|
43
|
-
* still satisfies a wider union, so the exact drift this file exists to detect could
|
|
44
|
-
* slip through the check meant to prevent it. Deriving the type makes the array the
|
|
45
|
-
* single definition and the question unaskable.
|
|
46
|
-
*/
|
|
47
8
|
export type ManagedOperation = (typeof KNOWN_OPERATIONS)[number];
|
|
48
9
|
export type ManagedExecuteInput = {
|
|
49
10
|
prompt?: string;
|
|
@@ -51,7 +12,6 @@ export type ManagedExecuteInput = {
|
|
|
51
12
|
conversation_id?: string;
|
|
52
13
|
input_asset_id?: string;
|
|
53
14
|
aspect_ratio?: string;
|
|
54
|
-
/** Requires the gateway build that added it. See ManagedOperation. */
|
|
55
15
|
operation?: ManagedOperation;
|
|
56
16
|
options?: {
|
|
57
17
|
action?: "walk" | "run" | "idle";
|
|
@@ -62,6 +22,10 @@ export type ManagedExecuteInput = {
|
|
|
62
22
|
};
|
|
63
23
|
max_credits?: number;
|
|
64
24
|
};
|
|
25
|
+
export declare class InputValidationError extends Error {
|
|
26
|
+
}
|
|
27
|
+
export declare class RecoveryRequiredError extends Error {
|
|
28
|
+
}
|
|
65
29
|
export declare function spriteCreditPrice(frameCount: number): number;
|
|
66
30
|
export declare function validateSpriteInput(input: ManagedExecuteInput): void;
|
|
67
31
|
export type ManagedInputAsset = {
|
|
@@ -84,12 +48,8 @@ export declare class ApiError extends Error {
|
|
|
84
48
|
readonly requestId: string | null;
|
|
85
49
|
readonly code: string;
|
|
86
50
|
readonly reason: PublicErrorReason;
|
|
87
|
-
constructor(status: number, surface?: string, detail?: string | null,
|
|
88
|
-
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
89
|
-
requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
|
|
90
|
-
/** Whether retrying with the same idempotency key is worth doing. */
|
|
51
|
+
constructor(status: number, surface?: string, detail?: string | null, requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
|
|
91
52
|
get retryable(): boolean;
|
|
92
|
-
/** The same stable fields exposed by REST and MCP, with no private response text. */
|
|
93
53
|
toPublicEnvelope(): Record<string, unknown>;
|
|
94
54
|
}
|
|
95
55
|
export type ManagedBalance = {
|
|
@@ -98,14 +58,6 @@ export type ManagedBalance = {
|
|
|
98
58
|
available: number;
|
|
99
59
|
credit_usd: "0.17";
|
|
100
60
|
};
|
|
101
|
-
/**
|
|
102
|
-
* A stream that went silent, as distinct from a slow one.
|
|
103
|
-
*
|
|
104
|
-
* Thrown as a real error type because the CLI's exit codes and its retry advice are
|
|
105
|
-
* driven off the error, and a bare DOMException from AbortSignal fell through to the
|
|
106
|
-
* generic handler: exit 1 with no guidance, on the one failure most likely to have been
|
|
107
|
-
* charged for. See ManagedApiError.retryable.
|
|
108
|
-
*/
|
|
109
61
|
export declare class StreamIdleError extends Error {
|
|
110
62
|
readonly idleMs: number;
|
|
111
63
|
constructor();
|
|
@@ -115,36 +67,19 @@ export declare class UploadTimeoutError extends Error {
|
|
|
115
67
|
}
|
|
116
68
|
export declare function uploadTimeoutMs(bytes: number): number;
|
|
117
69
|
export declare function managedBalance(value: unknown): ManagedBalance;
|
|
118
|
-
/** Convert a terminal job failure into the same safe contract used by HTTP errors. */
|
|
119
70
|
export declare function terminalExecutionError(execution: ManagedExecution): ApiError | null;
|
|
120
|
-
/**
|
|
121
|
-
* Validate one sanitized event against the published contract.
|
|
122
|
-
*
|
|
123
|
-
* Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
|
|
124
|
-
* schema is that a field appearing where none is documented means something changed
|
|
125
|
-
* server-side that a client should not silently consume.
|
|
126
|
-
*/
|
|
127
71
|
export declare function managedEvent(event: string, id: string | null, value: unknown): ManagedEvent;
|
|
128
72
|
export declare class ManagedClient {
|
|
129
73
|
private readonly apiKey;
|
|
130
74
|
private readonly baseUrl;
|
|
131
75
|
private capabilitiesPromise;
|
|
132
76
|
constructor(apiKey: string, baseUrl?: string);
|
|
133
|
-
/**
|
|
134
|
-
* Run or continue an execution, yielding each validated event as it arrives.
|
|
135
|
-
*
|
|
136
|
-
* Streams rather than buffers. The Python server this replaces collected events into
|
|
137
|
-
* a list and threw the whole list away on overflow, taking the execution ID with it,
|
|
138
|
-
* so a caller could not even resume what it had already paid for.
|
|
139
|
-
*/
|
|
140
77
|
execute(input: ManagedExecuteInput, options: {
|
|
141
78
|
idempotencyKey: string;
|
|
142
79
|
}): AsyncGenerator<ManagedEvent>;
|
|
143
|
-
/** Follow a durable job across finite streams without submitting it twice. */
|
|
144
80
|
follow(input: ManagedExecuteInput, options: {
|
|
145
81
|
idempotencyKey: string;
|
|
146
82
|
}): AsyncGenerator<ManagedEvent>;
|
|
147
|
-
/** Resume a stream after a drop. Pass the last event id you actually processed. */
|
|
148
83
|
events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
|
|
149
84
|
getCapabilities(): Promise<Record<string, unknown>>;
|
|
150
85
|
getExecution(executionId: string): Promise<ManagedExecution>;
|
|
@@ -153,11 +88,6 @@ export declare class ManagedClient {
|
|
|
153
88
|
listConversations(): Promise<Array<Record<string, unknown>>>;
|
|
154
89
|
deleteConversation(conversationId: string): Promise<void>;
|
|
155
90
|
uploadInput(file: Blob, filename?: string): Promise<ManagedInputAsset>;
|
|
156
|
-
/**
|
|
157
|
-
* Fetch a finished asset. Follows redirects on purpose: large images are served
|
|
158
|
-
* straight from storage rather than proxied, so a client that refuses redirects
|
|
159
|
-
* receives the redirect instead of the image.
|
|
160
|
-
*/
|
|
161
91
|
download(url: string): Promise<Uint8Array>;
|
|
162
92
|
private parse;
|
|
163
93
|
private fetchStream;
|
package/dist/client.js
CHANGED
|
@@ -1,33 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hosted client for the DreamLayer Agent API.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately a COPY of the same file in dreamlayer-mcp rather than a shared package.
|
|
5
|
-
* The alternative makes every capability three releases in strict order (client, then
|
|
6
|
-
* CLI, then MCP) instead of one release per repo. For a client over eight endpoints
|
|
7
|
-
* that trade is not worth the friction.
|
|
8
|
-
*
|
|
9
|
-
* Lifted from the DreamLayer runtime's TypeScript client, which is retired. The
|
|
10
|
-
* validation, error sanitisation, and origin hardening are kept verbatim because they
|
|
11
|
-
* were already correct; what changed is that the SSE reader is now wired to the hosted
|
|
12
|
-
* client instead of the dead local-proxy class, and that timeouts and an explicit
|
|
13
|
-
* redirect policy were added, which the original lacked.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* Every operation the Agent API can execute.
|
|
17
|
-
*
|
|
18
|
-
* REQUIRES the gateway build that added `operation` to ExecuteRequest. Against an older
|
|
19
|
-
* deployment this field is rejected with 422 extra_forbidden, because the request model
|
|
20
|
-
* is closed. That is a sequencing constraint, not a reason to drop it: naming the
|
|
21
|
-
* operation is what stops a cutout or an upscale being re-read from the prompt and
|
|
22
|
-
* coming back as a clarifying question instead of an image.
|
|
23
|
-
*
|
|
24
|
-
* SATISFIED 2026-08-21. prodbeta176 carries the field in the gateway AND the dispatch
|
|
25
|
-
* in the workflow engine, which had been split across two releases: the gateway
|
|
26
|
-
* accepted `operation` from prodbeta174 while the half that acts on it was still on
|
|
27
|
-
* prodbeta172, so naming an operation returned 200 and was then inferred from prose
|
|
28
|
-
* anyway. Confirmed against the deployment, not the source: the live /openapi.json
|
|
29
|
-
* advertises exactly these four in both ExecuteRequest and ImageJobCreate.
|
|
30
|
-
*/
|
|
31
1
|
export const KNOWN_OPERATIONS = [
|
|
32
2
|
"text_to_image",
|
|
33
3
|
"image_to_image",
|
|
@@ -35,9 +5,13 @@ export const KNOWN_OPERATIONS = [
|
|
|
35
5
|
"upscale",
|
|
36
6
|
"sprite_sheet",
|
|
37
7
|
];
|
|
8
|
+
export class InputValidationError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export class RecoveryRequiredError extends Error {
|
|
11
|
+
}
|
|
38
12
|
export function spriteCreditPrice(frameCount) {
|
|
39
13
|
if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100)
|
|
40
|
-
throw new
|
|
14
|
+
throw new InputValidationError("frame_count must be an integer from 7 to 100");
|
|
41
15
|
const cents = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
|
|
42
16
|
return Math.ceil(cents * 10 / 17) / 10;
|
|
43
17
|
}
|
|
@@ -46,18 +20,18 @@ export function validateSpriteInput(input) {
|
|
|
46
20
|
return;
|
|
47
21
|
const options = input.options;
|
|
48
22
|
if (!options || (options.action === undefined) === (options.animation_prompt === undefined))
|
|
49
|
-
throw new
|
|
23
|
+
throw new InputValidationError("Sprite requests require exactly one of options.action or options.animation_prompt");
|
|
50
24
|
if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action))
|
|
51
|
-
throw new
|
|
25
|
+
throw new InputValidationError("Invalid sprite preset");
|
|
52
26
|
if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000))
|
|
53
|
-
throw new
|
|
27
|
+
throw new InputValidationError("animation_prompt must contain 1–4000 characters");
|
|
54
28
|
if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode))
|
|
55
|
-
throw new
|
|
29
|
+
throw new InputValidationError("animation_mode must be loop or once");
|
|
56
30
|
const price = spriteCreditPrice(options.frame_count ?? 12);
|
|
57
31
|
if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size))
|
|
58
|
-
throw new
|
|
32
|
+
throw new InputValidationError("frame_size must be 32, 64, 128, 256, 512, 720 or 1080");
|
|
59
33
|
if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100)
|
|
60
|
-
throw new
|
|
34
|
+
throw new InputValidationError(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
|
|
61
35
|
}
|
|
62
36
|
const DIRECT_INPUT_BYTES = 20 * 1024 * 1024;
|
|
63
37
|
const RASTER_INPUT_SUFFIXES = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
@@ -182,9 +156,7 @@ export class ApiError extends Error {
|
|
|
182
156
|
requestId;
|
|
183
157
|
code;
|
|
184
158
|
reason;
|
|
185
|
-
constructor(status, surface = "DreamLayer Agent API", detail = null,
|
|
186
|
-
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
187
|
-
requestId = null, code = null, reason = reasonForStatus(status)) {
|
|
159
|
+
constructor(status, surface = "DreamLayer Agent API", detail = null, requestId = null, code = null, reason = reasonForStatus(status)) {
|
|
188
160
|
const safeDetail = detail ?? PUBLIC_ERROR_SPECS[reason].message;
|
|
189
161
|
super(`${safeDetail || `${surface} request failed (${status})`}${requestId ? ` (request ${requestId})` : ""}`);
|
|
190
162
|
this.status = status;
|
|
@@ -194,11 +166,9 @@ export class ApiError extends Error {
|
|
|
194
166
|
this.code = code ?? defaultCode(reason);
|
|
195
167
|
this.reason = reason;
|
|
196
168
|
}
|
|
197
|
-
/** Whether retrying with the same idempotency key is worth doing. */
|
|
198
169
|
get retryable() {
|
|
199
170
|
return PUBLIC_ERROR_SPECS[this.reason].retryable;
|
|
200
171
|
}
|
|
201
|
-
/** The same stable fields exposed by REST and MCP, with no private response text. */
|
|
202
172
|
toPublicEnvelope() {
|
|
203
173
|
return {
|
|
204
174
|
error: {
|
|
@@ -211,14 +181,6 @@ export class ApiError extends Error {
|
|
|
211
181
|
};
|
|
212
182
|
}
|
|
213
183
|
}
|
|
214
|
-
/**
|
|
215
|
-
* A stream that went silent, as distinct from a slow one.
|
|
216
|
-
*
|
|
217
|
-
* Thrown as a real error type because the CLI's exit codes and its retry advice are
|
|
218
|
-
* driven off the error, and a bare DOMException from AbortSignal fell through to the
|
|
219
|
-
* generic handler: exit 1 with no guidance, on the one failure most likely to have been
|
|
220
|
-
* charged for. See ManagedApiError.retryable.
|
|
221
|
-
*/
|
|
222
184
|
export class StreamIdleError extends Error {
|
|
223
185
|
idleMs = STREAM_IDLE_TIMEOUT_MS;
|
|
224
186
|
constructor() {
|
|
@@ -234,9 +196,6 @@ export class UploadTimeoutError extends Error {
|
|
|
234
196
|
}
|
|
235
197
|
}
|
|
236
198
|
const ERROR_BODY_LIMIT = 16 * 1024;
|
|
237
|
-
/**
|
|
238
|
-
* A plain request: send, get a body back. Bounded work, so a total cap is right.
|
|
239
|
-
*/
|
|
240
199
|
const REQUEST_TIMEOUT_MS = 130_000;
|
|
241
200
|
const UPLOAD_MIN_BYTES_PER_SECOND = 256 * 1024;
|
|
242
201
|
const UPLOAD_MAX_TIMEOUT_MS = 15 * 60_000;
|
|
@@ -247,29 +206,7 @@ export function uploadTimeoutMs(bytes) {
|
|
|
247
206
|
}
|
|
248
207
|
return Math.min(Math.max(REQUEST_TIMEOUT_MS, 60_000 + Math.ceil(bytes / UPLOAD_MIN_BYTES_PER_SECOND) * 1000), UPLOAD_MAX_TIMEOUT_MS);
|
|
249
208
|
}
|
|
250
|
-
/**
|
|
251
|
-
* A STREAM is different, and conflating the two shipped a broken `upscale`.
|
|
252
|
-
*
|
|
253
|
-
* AbortSignal.timeout() caps TOTAL duration. An upscale of a 2048px image takes about
|
|
254
|
-
* 150s server-side, so a 130s total cap aborted every single one: a command that failed
|
|
255
|
-
* 100% of the time on a normal input, while the server had done the work and charged
|
|
256
|
-
* for it.
|
|
257
|
-
*
|
|
258
|
-
* Raising the number would fix upscale and break again on the next slower operation.
|
|
259
|
-
* The right question is not "how long may a job take" (unknowable, and it is the
|
|
260
|
-
* server's business) but "how long may we hear NOTHING before the connection is dead".
|
|
261
|
-
* The server sends `: keepalive` comments precisely so a client can tell those apart;
|
|
262
|
-
* a total-duration timeout throws that information away.
|
|
263
|
-
*
|
|
264
|
-
* So: idle timeout, reset on every byte received.
|
|
265
|
-
*/
|
|
266
209
|
const STREAM_IDLE_DEFAULT_MS = 90_000;
|
|
267
|
-
/**
|
|
268
|
-
* Overridable, within bounds. Two honest reasons rather than one: a test cannot wait
|
|
269
|
-
* 90 seconds to prove a timeout fires, and a user on a genuinely bad link may need
|
|
270
|
-
* longer. Clamped so a typo cannot disable the guard entirely or set it to zero, and
|
|
271
|
-
* an unparseable value falls back rather than becoming NaN, which would abort instantly.
|
|
272
|
-
*/
|
|
273
210
|
const STREAM_IDLE_TIMEOUT_MS = (() => {
|
|
274
211
|
const raw = Number(process.env.DREAMLAYER_STREAM_IDLE_MS);
|
|
275
212
|
if (!Number.isFinite(raw) || raw <= 0)
|
|
@@ -313,16 +250,12 @@ async function apiError(response, surface = "DreamLayer Agent API") {
|
|
|
313
250
|
const raw = await boundedErrorBody(response);
|
|
314
251
|
const parsed = raw ? JSON.parse(raw) : null;
|
|
315
252
|
if (isRecord(parsed) && isRecord(parsed.error)) {
|
|
316
|
-
// Treat only the closed reason/code/id fields as data. The local message table
|
|
317
|
-
// deliberately ignores arbitrary remote detail so a private upstream response
|
|
318
|
-
// cannot leak through a client even if a server regression serializes it.
|
|
319
253
|
reason = publicReason(parsed.error.reason) ?? reason;
|
|
320
254
|
code = publicIdentifier(parsed.error.code);
|
|
321
255
|
requestId = publicRequestId(parsed.error.request_id) ?? requestId;
|
|
322
256
|
}
|
|
323
257
|
}
|
|
324
258
|
catch {
|
|
325
|
-
// A malformed or oversized response still becomes a closed, status-derived error.
|
|
326
259
|
}
|
|
327
260
|
return new ApiError(response.status, surface, null, requestId, code, reason);
|
|
328
261
|
}
|
|
@@ -365,7 +298,6 @@ export function managedBalance(value) {
|
|
|
365
298
|
credit_usd: "0.17",
|
|
366
299
|
};
|
|
367
300
|
}
|
|
368
|
-
/** Convert a terminal job failure into the same safe contract used by HTTP errors. */
|
|
369
301
|
export function terminalExecutionError(execution) {
|
|
370
302
|
if (!isRecord(execution.image_job) || !isRecord(execution.image_job.sanitized_error)) {
|
|
371
303
|
return execution.status === "failed" ? new ApiError(500) : null;
|
|
@@ -374,13 +306,6 @@ export function terminalExecutionError(execution) {
|
|
|
374
306
|
const reason = publicReason(error.reason) ?? "generation_failed";
|
|
375
307
|
return new ApiError(statusForReason(reason), "DreamLayer execution", null, publicRequestId(error.request_id), publicIdentifier(error.code), reason);
|
|
376
308
|
}
|
|
377
|
-
/**
|
|
378
|
-
* Validate one sanitized event against the published contract.
|
|
379
|
-
*
|
|
380
|
-
* Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
|
|
381
|
-
* schema is that a field appearing where none is documented means something changed
|
|
382
|
-
* server-side that a client should not silently consume.
|
|
383
|
-
*/
|
|
384
309
|
export function managedEvent(event, id, value) {
|
|
385
310
|
if (!MANAGED_EVENT_NAMES.has(event) || !isRecord(value)) {
|
|
386
311
|
throw new Error("Invalid DreamLayer managed event");
|
|
@@ -449,15 +374,12 @@ export function managedEvent(event, id, value) {
|
|
|
449
374
|
}
|
|
450
375
|
return { id, event: event, data };
|
|
451
376
|
}
|
|
452
|
-
/** Parse a server-sent-event body into blocks. Handles multi-line data and comments. */
|
|
453
377
|
async function* readEventStream(body, onBytes) {
|
|
454
378
|
const reader = body.getReader();
|
|
455
379
|
const decoder = new TextDecoder();
|
|
456
380
|
let buffer = "";
|
|
457
381
|
for (;;) {
|
|
458
382
|
const { value, done } = await reader.read();
|
|
459
|
-
// Any byte at all, including a `: keepalive` comment that parses to no event,
|
|
460
|
-
// proves the connection is alive. That is the signal the idle timer needs.
|
|
461
383
|
if (!done)
|
|
462
384
|
onBytes?.();
|
|
463
385
|
buffer += decoder.decode(value, { stream: !done });
|
|
@@ -486,18 +408,6 @@ async function* readEventStream(body, onBytes) {
|
|
|
486
408
|
return;
|
|
487
409
|
}
|
|
488
410
|
}
|
|
489
|
-
/**
|
|
490
|
-
* Hosts this client will send a bearer key to.
|
|
491
|
-
*
|
|
492
|
-
* In August a build moved the endpoint default from api.dreamlayer.io to the bare
|
|
493
|
-
* marketing apex, and every request carried Authorization there for two days. The
|
|
494
|
-
* origin passed every cleanliness check below, because those check the SHAPE of a URL
|
|
495
|
-
* and never which host it names. An allowlist is the only thing that catches a host
|
|
496
|
-
* swap, which is why the gateway now has a pinned-origin test and why this mirrors it.
|
|
497
|
-
*
|
|
498
|
-
* DREAMLAYER_API_URL still works for a genuinely different deployment: set
|
|
499
|
-
* DREAMLAYER_ALLOW_ANY_HOST=1 alongside it and accept that you are vouching for the host.
|
|
500
|
-
*/
|
|
501
411
|
const ALLOWED_HOSTS = new Set(["api.dreamlayer.io"]);
|
|
502
412
|
function isLoopback(hostname) {
|
|
503
413
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]";
|
|
@@ -542,13 +452,6 @@ export class ManagedClient {
|
|
|
542
452
|
throw new Error("DREAMLAYER_API_KEY is required");
|
|
543
453
|
this.baseUrl = managedOrigin(baseUrl);
|
|
544
454
|
}
|
|
545
|
-
/**
|
|
546
|
-
* Run or continue an execution, yielding each validated event as it arrives.
|
|
547
|
-
*
|
|
548
|
-
* Streams rather than buffers. The Python server this replaces collected events into
|
|
549
|
-
* a list and threw the whole list away on overflow, taking the execution ID with it,
|
|
550
|
-
* so a caller could not even resume what it had already paid for.
|
|
551
|
-
*/
|
|
552
455
|
async *execute(input, options) {
|
|
553
456
|
validateSpriteInput(input);
|
|
554
457
|
const stream = await this.fetchStream("/v1/execute", {
|
|
@@ -562,7 +465,6 @@ export class ManagedClient {
|
|
|
562
465
|
});
|
|
563
466
|
yield* this.parse(stream);
|
|
564
467
|
}
|
|
565
|
-
/** Follow a durable job across finite streams without submitting it twice. */
|
|
566
468
|
async *follow(input, options) {
|
|
567
469
|
let executionId;
|
|
568
470
|
let cursor;
|
|
@@ -585,17 +487,29 @@ export class ManagedClient {
|
|
|
585
487
|
catch (error) {
|
|
586
488
|
if (error instanceof StreamIdleError)
|
|
587
489
|
throw error;
|
|
588
|
-
if (
|
|
490
|
+
if (error instanceof InputValidationError || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status)))
|
|
589
491
|
throw error;
|
|
492
|
+
if (!executionId && error instanceof ApiError)
|
|
493
|
+
throw error;
|
|
494
|
+
if (!executionId || ++failures > 5) {
|
|
495
|
+
const code = error?.cause?.code ?? error?.code;
|
|
496
|
+
const causes = {
|
|
497
|
+
ECONNREFUSED: "Connection refused", ENOTFOUND: "Host lookup failed", EAI_AGAIN: "Host lookup temporarily failed",
|
|
498
|
+
ECONNRESET: "Connection reset", ETIMEDOUT: "Connection timed out", UND_ERR_CONNECT_TIMEOUT: "Connection timed out",
|
|
499
|
+
UND_ERR_SOCKET: "Connection closed", CERT_HAS_EXPIRED: "TLS certificate expired", DEPTH_ZERO_SELF_SIGNED_CERT: "TLS certificate is untrusted",
|
|
500
|
+
};
|
|
501
|
+
const cause = code && causes[code] ? `${causes[code]} (${code}). ` : "Transport connection failed. ";
|
|
502
|
+
throw new RecoveryRequiredError(cause + "Execution state is uncertain. Read saved state before retrying.");
|
|
503
|
+
}
|
|
590
504
|
}
|
|
591
505
|
if (!executionId)
|
|
592
|
-
throw new
|
|
506
|
+
throw new RecoveryRequiredError("Execution stream ended before an identifier was received; reuse your idempotency key.");
|
|
593
507
|
const state = await this.getExecution(executionId);
|
|
594
508
|
if (["completed", "failed", "cancelled"].includes(state.status)) {
|
|
595
509
|
if (state.status === "completed") {
|
|
596
510
|
const assets = state.image_job?.finished_assets;
|
|
597
511
|
if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string")
|
|
598
|
-
throw new
|
|
512
|
+
throw new RecoveryRequiredError(`Execution ${executionId} has no downloadable asset yet.`);
|
|
599
513
|
yield managedEvent("asset", null, { asset_id: assets[0].asset_id, download_url: assets[0].download_url });
|
|
600
514
|
}
|
|
601
515
|
yield managedEvent("done", null, { status: state.status });
|
|
@@ -604,9 +518,8 @@ export class ManagedClient {
|
|
|
604
518
|
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, 500 * 2 ** failures)));
|
|
605
519
|
stream = this.events(executionId, cursor);
|
|
606
520
|
}
|
|
607
|
-
throw new
|
|
521
|
+
throw new RecoveryRequiredError(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`);
|
|
608
522
|
}
|
|
609
|
-
/** Resume a stream after a drop. Pass the last event id you actually processed. */
|
|
610
523
|
async *events(executionId, lastEventId) {
|
|
611
524
|
const headers = { Accept: "text/event-stream" };
|
|
612
525
|
if (lastEventId)
|
|
@@ -616,8 +529,6 @@ export class ManagedClient {
|
|
|
616
529
|
}
|
|
617
530
|
async getCapabilities() {
|
|
618
531
|
this.capabilitiesPromise ??= this.request("/v1/capabilities").catch((error) => {
|
|
619
|
-
// Cache a successful contract for the process, but never pin a transient
|
|
620
|
-
// capabilities failure as a permanent result.
|
|
621
532
|
this.capabilitiesPromise = null;
|
|
622
533
|
throw error;
|
|
623
534
|
});
|
|
@@ -701,17 +612,7 @@ export class ManagedClient {
|
|
|
701
612
|
body.append("file", file, filename);
|
|
702
613
|
return this.request("/v1/input-assets", { method: "POST", body });
|
|
703
614
|
}
|
|
704
|
-
/**
|
|
705
|
-
* Fetch a finished asset. Follows redirects on purpose: large images are served
|
|
706
|
-
* straight from storage rather than proxied, so a client that refuses redirects
|
|
707
|
-
* receives the redirect instead of the image.
|
|
708
|
-
*/
|
|
709
615
|
async download(url) {
|
|
710
|
-
// Only attach the key when the URL is OUR origin. download_url arrives in the event
|
|
711
|
-
// stream and is validated as text, so a wrong or hostile value would otherwise walk
|
|
712
|
-
// off with a live credential on the very first request. Node strips Authorization
|
|
713
|
-
// across a cross-origin redirect, so the hop to signed storage stays safe either way,
|
|
714
|
-
// and storage URLs are pre-signed and need no header from us.
|
|
715
616
|
const sameOrigin = (() => {
|
|
716
617
|
try {
|
|
717
618
|
return new URL(url).origin === this.baseUrl;
|
|
@@ -741,9 +642,6 @@ export class ManagedClient {
|
|
|
741
642
|
}
|
|
742
643
|
}
|
|
743
644
|
finally {
|
|
744
|
-
// Also runs when the consumer breaks out of the loop early, which the CLI does
|
|
745
|
-
// as soon as it sees a terminal event. Without this the timer keeps the process
|
|
746
|
-
// alive for another idle period.
|
|
747
645
|
finish();
|
|
748
646
|
}
|
|
749
647
|
}
|
|
@@ -751,9 +649,6 @@ export class ManagedClient {
|
|
|
751
649
|
const headers = new Headers(init.headers);
|
|
752
650
|
headers.set("Authorization", `Bearer ${this.apiKey}`);
|
|
753
651
|
headers.set("DreamLayer-Version", "1");
|
|
754
|
-
// One controller for the whole stream, armed on an IDLE clock that every received
|
|
755
|
-
// byte pushes forward. The signal has to outlive the fetch() call: aborting only
|
|
756
|
-
// the handshake would leave a stalled body hanging forever.
|
|
757
652
|
const controller = new AbortController();
|
|
758
653
|
let timer;
|
|
759
654
|
const keepAlive = () => {
|
package/dist/render.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal rendering of an execution.
|
|
3
|
-
*
|
|
4
|
-
* Two modes, decided by whether stdout is a TTY and whether --json was passed. A CLI
|
|
5
|
-
* that only prints prose cannot be piped into anything, and one that only prints JSON
|
|
6
|
-
* is miserable to watch. Progress goes to stderr so `dreamlayer generate ... | jq`
|
|
7
|
-
* works without the spinner corrupting the pipe.
|
|
8
|
-
*/
|
|
9
1
|
import type { ManagedEvent } from "./client.js";
|
|
10
2
|
export type Outcome = {
|
|
11
3
|
execution_id: string | null;
|
package/dist/render.js
CHANGED
|
@@ -34,7 +34,6 @@ export class Progress {
|
|
|
34
34
|
process.stderr.write(`${final}\n`);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
/** Human wording for each event. `thinking` carries no text by contract. */
|
|
38
37
|
function describe(event) {
|
|
39
38
|
switch (event.event) {
|
|
40
39
|
case "started":
|
|
@@ -97,13 +96,6 @@ export async function consume(stream, progress, onEvent) {
|
|
|
97
96
|
}
|
|
98
97
|
}
|
|
99
98
|
catch (error) {
|
|
100
|
-
// `started` arrives within seconds and carries the execution id. When the stream
|
|
101
|
-
// later dies, that id is the only way a user can find a job they may already have
|
|
102
|
-
// paid for, and it was being discarded along with the exception.
|
|
103
|
-
//
|
|
104
|
-
// Attached to the error rather than wrapped in a new one: the top-level handler
|
|
105
|
-
// branches on `instanceof ApiError`, and a wrapper would silently defeat that
|
|
106
|
-
// while looking tidier.
|
|
107
99
|
if (error !== null && typeof error === "object") {
|
|
108
100
|
error.partialOutcome = outcome;
|
|
109
101
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dreamlayer",
|
|
3
|
-
"version": "0.4.0-beta.
|
|
3
|
+
"version": "0.4.0-beta.3",
|
|
4
4
|
"description": "Generate and edit images from your terminal, over local files, with one API key.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,11 +31,16 @@
|
|
|
31
31
|
"cli",
|
|
32
32
|
"dreamlayer",
|
|
33
33
|
"ai",
|
|
34
|
-
"image-editing"
|
|
34
|
+
"image-editing",
|
|
35
|
+
"sprite-sheet",
|
|
36
|
+
"automation"
|
|
35
37
|
],
|
|
36
38
|
"repository": {
|
|
37
39
|
"type": "git",
|
|
38
40
|
"url": "git+https://github.com/TheDesignFounder/dreamlayer-cli.git"
|
|
39
41
|
},
|
|
40
|
-
"homepage": "https://docs.dreamlayer.io/cli"
|
|
42
|
+
"homepage": "https://docs.dreamlayer.io/cli",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"tag": "beta"
|
|
45
|
+
}
|
|
41
46
|
}
|