dreamlayer 0.1.0
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/LICENSE +21 -0
- package/NOTICE +9 -0
- package/README.md +98 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +303 -0
- package/dist/client.d.ts +121 -0
- package/dist/client.js +458 -0
- package/dist/render.d.ts +33 -0
- package/dist/render.js +113 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DreamLayer AI
|
|
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.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
DreamLayer CLI
|
|
2
|
+
Copyright (c) 2026 DreamLayer AI
|
|
3
|
+
|
|
4
|
+
This package is a thin client. It sends requests to the DreamLayer Agent API at
|
|
5
|
+
https://api.dreamlayer.io and returns what that service replies. The service itself
|
|
6
|
+
is proprietary and is not included here.
|
|
7
|
+
|
|
8
|
+
Nothing in this package inspects, stores, or transmits your prompts or images anywhere
|
|
9
|
+
other than the DreamLayer Agent API, using the key you supply in DREAMLAYER_API_KEY.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# dreamlayer
|
|
2
|
+
|
|
3
|
+
Generate and edit images from your terminal, over local files, with one API key.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g dreamlayer
|
|
7
|
+
export DREAMLAYER_API_KEY="dlr_live_your_key"
|
|
8
|
+
|
|
9
|
+
dreamlayer generate "A glass greenhouse at dusk" --out greenhouse.png
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Get a key at [platform.dreamlayer.io](https://platform.dreamlayer.io). A new account
|
|
13
|
+
starts at zero credits, and each finished image costs one.
|
|
14
|
+
|
|
15
|
+
> **Not yet publishable.** This package sends an `operation` field that requires
|
|
16
|
+
> the gateway build adding it to `ExecuteRequest`. Against the currently deployed
|
|
17
|
+
> API every call returns `422 extra_forbidden`. Deploy that build first.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
dreamlayer generate <prompt> [--aspect 16:9] [--out file.png]
|
|
23
|
+
dreamlayer edit <image> <prompt> [--out file.png]
|
|
24
|
+
dreamlayer cutout <image> [--out file.png] # background removal
|
|
25
|
+
dreamlayer upscale <image> [--out file.png] # 2x
|
|
26
|
+
dreamlayer answer <conversation-id> <text> [--image file.png]
|
|
27
|
+
dreamlayer status <execution-id>
|
|
28
|
+
dreamlayer capabilities # spends nothing
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`cutout` and `upscale` name their operation rather than hoping a sentence is read the
|
|
32
|
+
way you meant, so they run a dedicated chain and never stop to ask a question.
|
|
33
|
+
|
|
34
|
+
`upscale` doubles each side and finished images are capped at 4096 per side, so the
|
|
35
|
+
longest side of your input must be 2048 or less. Anything larger is refused before it
|
|
36
|
+
costs you a credit.
|
|
37
|
+
|
|
38
|
+
## It composes
|
|
39
|
+
|
|
40
|
+
**stdout is the result, stderr is the narration.** On success stdout is the file path
|
|
41
|
+
and nothing else, so this works:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
open "$(dreamlayer generate 'a fox logo')"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Exit codes mean something**, so a script can branch instead of grepping:
|
|
48
|
+
|
|
49
|
+
| Code | Meaning |
|
|
50
|
+
|---|---|
|
|
51
|
+
| 0 | Success |
|
|
52
|
+
| 1 | Usage error |
|
|
53
|
+
| 2 | Auth or account problem |
|
|
54
|
+
| 3 | Out of credits |
|
|
55
|
+
| 4 | Request rejected, will fail identically until you change it |
|
|
56
|
+
| 5 | Temporary, worth retrying |
|
|
57
|
+
| 6 | It asked a question instead of producing an image |
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
for f in shots/*.png; do dreamlayer cutout "$f" --out "cut/$(basename "$f")" || break; done
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**`--json`** gives machine-readable output on stdout, carrying job state, execution ids,
|
|
64
|
+
and the written path. It deliberately excludes your prompt, your images, and your key,
|
|
65
|
+
so it is safe to paste into a bug report.
|
|
66
|
+
|
|
67
|
+
## Retries are safe if you reuse the key
|
|
68
|
+
|
|
69
|
+
An idempotency key is generated per run. After an uncertain response, pass the same one
|
|
70
|
+
back and the original result replays instead of paying twice:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
dreamlayer generate "a fox logo" --idempotency-key fox-001
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## A question is not a failure
|
|
77
|
+
|
|
78
|
+
An edit-shaped prompt with no image exits 6 and asks for one:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
dreamlayer generate "remove the background"
|
|
82
|
+
# Which image should I use? Upload or attach one, then respond.
|
|
83
|
+
# dreamlayer answer <id> "your answer" --image <file>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Answer it with the image attached. Words alone are refused, because the question is
|
|
87
|
+
asking for a picture, not a clarification.
|
|
88
|
+
|
|
89
|
+
Naming an operation avoids the round trip entirely, which is why `cutout`, `upscale`,
|
|
90
|
+
`edit`, and `generate` all do.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
Node.js 22.12 or later.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT. See LICENSE and NOTICE.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DreamLayer CLI.
|
|
4
|
+
*
|
|
5
|
+
* Generate and edit images from a terminal, over local files. The API is the same one
|
|
6
|
+
* the MCP server and the web app use, and it spends from the same credit balance.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes are meaningful, so this composes in a script:
|
|
9
|
+
* 0 success
|
|
10
|
+
* 1 usage error
|
|
11
|
+
* 2 authentication or account problem (401, 403)
|
|
12
|
+
* 3 out of credits (402)
|
|
13
|
+
* 4 the request was rejected (409, 422)
|
|
14
|
+
* 5 temporary, worth retrying (429, 5xx)
|
|
15
|
+
* 6 the run ended asking a question instead of producing an image
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { ApiError, ManagedClient, StreamIdleError, } from "./client.js";
|
|
21
|
+
import { Progress, consume } from "./render.js";
|
|
22
|
+
const USAGE = `dreamlayer - generate and edit images from your terminal
|
|
23
|
+
|
|
24
|
+
USAGE
|
|
25
|
+
dreamlayer generate <prompt> [--aspect <ratio>] [--out <file>]
|
|
26
|
+
dreamlayer edit <image> <prompt> [--out <file>]
|
|
27
|
+
dreamlayer cutout <image> [--out <file>]
|
|
28
|
+
dreamlayer upscale <image> [--out <file>]
|
|
29
|
+
dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
|
|
30
|
+
dreamlayer status <execution-id>
|
|
31
|
+
dreamlayer capabilities
|
|
32
|
+
|
|
33
|
+
OPTIONS
|
|
34
|
+
--out <file> Where to write the image. Default: dreamlayer-<n>.png
|
|
35
|
+
--image <file> Attach an image when answering a question that asks for one
|
|
36
|
+
--aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
|
|
37
|
+
--json Machine-readable output on stdout
|
|
38
|
+
--quiet No progress on stderr
|
|
39
|
+
--idempotency-key <key> Reuse to retry safely after an uncertain response
|
|
40
|
+
|
|
41
|
+
ENVIRONMENT
|
|
42
|
+
DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
|
|
43
|
+
DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
|
|
44
|
+
|
|
45
|
+
Every finished image costs one credit. A new account starts at zero.
|
|
46
|
+
`;
|
|
47
|
+
class UsageError extends Error {
|
|
48
|
+
}
|
|
49
|
+
function parseOptions(argv) {
|
|
50
|
+
const positional = [];
|
|
51
|
+
const options = {
|
|
52
|
+
out: null,
|
|
53
|
+
image: null,
|
|
54
|
+
aspect: "1:1",
|
|
55
|
+
json: false,
|
|
56
|
+
quiet: false,
|
|
57
|
+
idempotencyKey: null,
|
|
58
|
+
};
|
|
59
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
60
|
+
const token = argv[i];
|
|
61
|
+
if (token === "--json")
|
|
62
|
+
options.json = true;
|
|
63
|
+
else if (token === "--quiet")
|
|
64
|
+
options.quiet = true;
|
|
65
|
+
else if (token === "--out" || token === "-o") {
|
|
66
|
+
const value = argv[++i];
|
|
67
|
+
if (!value)
|
|
68
|
+
throw new UsageError("--out needs a file path");
|
|
69
|
+
options.out = value;
|
|
70
|
+
}
|
|
71
|
+
else if (token === "--image") {
|
|
72
|
+
const value = argv[++i];
|
|
73
|
+
if (!value)
|
|
74
|
+
throw new UsageError("--image needs a file path");
|
|
75
|
+
options.image = value;
|
|
76
|
+
}
|
|
77
|
+
else if (token === "--aspect") {
|
|
78
|
+
const value = argv[++i];
|
|
79
|
+
if (!value)
|
|
80
|
+
throw new UsageError("--aspect needs a ratio");
|
|
81
|
+
options.aspect = value;
|
|
82
|
+
}
|
|
83
|
+
else if (token === "--idempotency-key") {
|
|
84
|
+
const value = argv[++i];
|
|
85
|
+
if (!value)
|
|
86
|
+
throw new UsageError("--idempotency-key needs a value");
|
|
87
|
+
options.idempotencyKey = value;
|
|
88
|
+
}
|
|
89
|
+
else if (token !== undefined && token.startsWith("-")) {
|
|
90
|
+
throw new UsageError(`unknown option ${token}`);
|
|
91
|
+
}
|
|
92
|
+
else if (token !== undefined) {
|
|
93
|
+
positional.push(token);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { positional, options };
|
|
97
|
+
}
|
|
98
|
+
function client() {
|
|
99
|
+
const key = (process.env.DREAMLAYER_API_KEY ?? "").trim();
|
|
100
|
+
if (!key) {
|
|
101
|
+
throw new UsageError("DREAMLAYER_API_KEY is not set.\n" +
|
|
102
|
+
" export DREAMLAYER_API_KEY=dlr_live_...\n" +
|
|
103
|
+
" Get a key at https://platform.dreamlayer.io");
|
|
104
|
+
}
|
|
105
|
+
return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim());
|
|
106
|
+
}
|
|
107
|
+
/** Upload a local file and return its asset id, with size and type checked here first. */
|
|
108
|
+
async function upload(api, file) {
|
|
109
|
+
const resolved = path.resolve(file);
|
|
110
|
+
const extension = path.extname(resolved).toLowerCase();
|
|
111
|
+
if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
|
|
112
|
+
throw new UsageError(`${file} is not a PNG, JPEG, or WEBP`);
|
|
113
|
+
}
|
|
114
|
+
let bytes;
|
|
115
|
+
try {
|
|
116
|
+
bytes = await readFile(resolved);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new UsageError(`cannot read ${file}`);
|
|
120
|
+
}
|
|
121
|
+
if (bytes.byteLength > 20 * 1024 * 1024) {
|
|
122
|
+
throw new UsageError(`${file} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB; the limit is 20 MB`);
|
|
123
|
+
}
|
|
124
|
+
const asset = await api.uploadInput(new Blob([new Uint8Array(bytes)]), path.basename(resolved));
|
|
125
|
+
return asset.input_asset_id;
|
|
126
|
+
}
|
|
127
|
+
function defaultOut() {
|
|
128
|
+
return `dreamlayer-${Date.now()}.png`;
|
|
129
|
+
}
|
|
130
|
+
async function run(api, input, options) {
|
|
131
|
+
const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
|
|
132
|
+
const idempotencyKey = options.idempotencyKey ?? randomUUID();
|
|
133
|
+
const outcome = await consume(api.execute(input, { idempotencyKey }), progress);
|
|
134
|
+
if (outcome.question) {
|
|
135
|
+
progress.stop();
|
|
136
|
+
if (options.json) {
|
|
137
|
+
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
process.stderr.write(`\nDreamLayer needs one more thing:\n ${outcome.question.text}\n\n`);
|
|
141
|
+
// The server's needs_input question always asks for an image, so point at the
|
|
142
|
+
// flag that can supply one rather than the bare form that will 422.
|
|
143
|
+
const wantsImage = /image/i.test(outcome.question.text);
|
|
144
|
+
process.stderr.write(`Answer it with:\n dreamlayer answer ${outcome.conversation_id} "your answer"` +
|
|
145
|
+
`${wantsImage ? " --image <file>" : ""}\n`);
|
|
146
|
+
}
|
|
147
|
+
return 6;
|
|
148
|
+
}
|
|
149
|
+
if (outcome.status !== "completed" || !outcome.asset) {
|
|
150
|
+
progress.stop("Failed");
|
|
151
|
+
if (options.json)
|
|
152
|
+
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
|
|
153
|
+
else
|
|
154
|
+
process.stderr.write(`Run ended as ${outcome.status}. No credit was settled.\n`);
|
|
155
|
+
return 5;
|
|
156
|
+
}
|
|
157
|
+
progress.set("Downloading");
|
|
158
|
+
const bytes = await api.download(outcome.asset.download_url);
|
|
159
|
+
const target = options.out ?? defaultOut();
|
|
160
|
+
await writeFile(target, bytes);
|
|
161
|
+
progress.stop();
|
|
162
|
+
if (options.json) {
|
|
163
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, file: path.resolve(target) }, null, 2)}\n`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// The path on stdout and nothing else, so `$(dreamlayer generate ...)` is the file.
|
|
167
|
+
process.stdout.write(`${target}\n`);
|
|
168
|
+
}
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
async function imageCommand(operation, prompt, file, options) {
|
|
172
|
+
const api = client();
|
|
173
|
+
const inputAssetId = await upload(api, file);
|
|
174
|
+
return run(api, { prompt, operation, input_asset_id: inputAssetId }, options);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Point a user at the job they may have paid for.
|
|
178
|
+
*
|
|
179
|
+
* Without this, a timed-out upscale left nothing to go on: no id, no command, and no
|
|
180
|
+
* key-authenticated way to check a balance. "It might have charged you, good luck" is
|
|
181
|
+
* not an acceptable end state for a paid call.
|
|
182
|
+
*/
|
|
183
|
+
function recoveryHint(error) {
|
|
184
|
+
const id = error !== null && typeof error === "object"
|
|
185
|
+
? error.partialOutcome
|
|
186
|
+
?.execution_id
|
|
187
|
+
: null;
|
|
188
|
+
return id ? `The job may still be running. Check it with:\n dreamlayer status ${id}\n` : "";
|
|
189
|
+
}
|
|
190
|
+
function exitCodeFor(error) {
|
|
191
|
+
if (error.status === 401 || error.status === 403)
|
|
192
|
+
return 2;
|
|
193
|
+
if (error.status === 402)
|
|
194
|
+
return 3;
|
|
195
|
+
if (error.status === 409 || error.status === 422)
|
|
196
|
+
return 4;
|
|
197
|
+
return 5;
|
|
198
|
+
}
|
|
199
|
+
async function main(argv) {
|
|
200
|
+
const [command, ...rest] = argv;
|
|
201
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
202
|
+
process.stdout.write(USAGE);
|
|
203
|
+
return command ? 0 : 1;
|
|
204
|
+
}
|
|
205
|
+
if (command === "--version" || command === "-v") {
|
|
206
|
+
process.stdout.write("0.1.0\n");
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
const { positional, options } = parseOptions(rest);
|
|
210
|
+
switch (command) {
|
|
211
|
+
case "generate": {
|
|
212
|
+
const prompt = positional[0];
|
|
213
|
+
if (!prompt)
|
|
214
|
+
throw new UsageError("generate needs a prompt");
|
|
215
|
+
return run(client(), { prompt, operation: "text_to_image", aspect_ratio: options.aspect }, options);
|
|
216
|
+
}
|
|
217
|
+
case "edit": {
|
|
218
|
+
const [file, prompt] = positional;
|
|
219
|
+
if (!file || !prompt)
|
|
220
|
+
throw new UsageError("edit needs an image and a prompt");
|
|
221
|
+
return imageCommand("image_to_image", prompt, file, options);
|
|
222
|
+
}
|
|
223
|
+
case "cutout": {
|
|
224
|
+
const file = positional[0];
|
|
225
|
+
if (!file)
|
|
226
|
+
throw new UsageError("cutout needs an image");
|
|
227
|
+
return imageCommand("background_remove", "remove the background", file, options);
|
|
228
|
+
}
|
|
229
|
+
case "upscale": {
|
|
230
|
+
const file = positional[0];
|
|
231
|
+
if (!file)
|
|
232
|
+
throw new UsageError("upscale needs an image");
|
|
233
|
+
return imageCommand("upscale", "upscale this image", file, options);
|
|
234
|
+
}
|
|
235
|
+
case "answer": {
|
|
236
|
+
const [conversationId, text] = positional;
|
|
237
|
+
if (!conversationId || !text)
|
|
238
|
+
throw new UsageError("answer needs a conversation id and text");
|
|
239
|
+
const api = client();
|
|
240
|
+
// A question that asks for an image cannot be answered with words alone: the
|
|
241
|
+
// server refuses a reply with no asset when the pending question required one.
|
|
242
|
+
// Without --image this command reached a clean 422 and the documented path
|
|
243
|
+
// dead-ended, telling the user to attach an image and offering no way to do it.
|
|
244
|
+
const input = options.image ? await upload(api, options.image) : undefined;
|
|
245
|
+
return run(api, {
|
|
246
|
+
respond: text,
|
|
247
|
+
conversation_id: conversationId,
|
|
248
|
+
...(input ? { input_asset_id: input } : {}),
|
|
249
|
+
}, options);
|
|
250
|
+
}
|
|
251
|
+
case "status": {
|
|
252
|
+
const executionId = positional[0];
|
|
253
|
+
if (!executionId)
|
|
254
|
+
throw new UsageError("status needs an execution id");
|
|
255
|
+
const execution = await client().getExecution(executionId);
|
|
256
|
+
process.stdout.write(`${JSON.stringify(execution, null, 2)}\n`);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
case "capabilities": {
|
|
260
|
+
const capabilities = await client().getCapabilities();
|
|
261
|
+
process.stdout.write(`${JSON.stringify(capabilities, null, 2)}\n`);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
default:
|
|
265
|
+
throw new UsageError(`unknown command ${command}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
main(process.argv.slice(2))
|
|
269
|
+
.then((code) => {
|
|
270
|
+
process.exitCode = code;
|
|
271
|
+
})
|
|
272
|
+
.catch((error) => {
|
|
273
|
+
if (error instanceof UsageError) {
|
|
274
|
+
process.stderr.write(`${error.message}\n`);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (error instanceof ApiError) {
|
|
279
|
+
const hint = error.status === 402
|
|
280
|
+
? "Buy credits at https://platform.dreamlayer.io/console/billing"
|
|
281
|
+
: error.retryable
|
|
282
|
+
? "Temporary. Retry with --idempotency-key to avoid paying twice."
|
|
283
|
+
: "";
|
|
284
|
+
process.stderr.write(`${error.message}${hint ? `\n${hint}` : ""}\n`);
|
|
285
|
+
process.exitCode = exitCodeFor(error);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// A stream that went silent is retryable, and it is the failure MOST likely to
|
|
289
|
+
// have been charged for: the server may have finished the job we stopped listening
|
|
290
|
+
// to. It reached this generic branch as a bare DOMException, so it exited 1 with no
|
|
291
|
+
// guidance, and the --idempotency-key advice that exists precisely to prevent
|
|
292
|
+
// double payment never printed on the one case that needs it.
|
|
293
|
+
if (error instanceof StreamIdleError) {
|
|
294
|
+
process.stderr.write(`${error.message}\n`);
|
|
295
|
+
process.stderr.write("Temporary. Retry with --idempotency-key to avoid paying twice.\n");
|
|
296
|
+
process.stderr.write(recoveryHint(error));
|
|
297
|
+
process.exitCode = 5;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
301
|
+
process.stderr.write(recoveryHint(error));
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
export type ManagedEventName = "started" | "thinking" | "progress" | "job" | "question" | "asset" | "done";
|
|
16
|
+
export type ManagedEvent = {
|
|
17
|
+
id: string | null;
|
|
18
|
+
event: ManagedEventName;
|
|
19
|
+
data: Record<string, unknown>;
|
|
20
|
+
};
|
|
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
|
+
export type ManagedOperation = "text_to_image" | "image_to_image" | "background_remove" | "upscale";
|
|
38
|
+
export type ManagedExecuteInput = {
|
|
39
|
+
prompt?: string;
|
|
40
|
+
respond?: string;
|
|
41
|
+
conversation_id?: string;
|
|
42
|
+
input_asset_id?: string;
|
|
43
|
+
aspect_ratio?: string;
|
|
44
|
+
/** Requires the gateway build that added it. See ManagedOperation. */
|
|
45
|
+
operation?: ManagedOperation;
|
|
46
|
+
};
|
|
47
|
+
export type ManagedInputAsset = {
|
|
48
|
+
input_asset_id: string;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
expires_at: string;
|
|
52
|
+
};
|
|
53
|
+
export type ManagedExecution = {
|
|
54
|
+
execution_id: string;
|
|
55
|
+
conversation_id: string;
|
|
56
|
+
status: string;
|
|
57
|
+
image_job: Record<string, unknown> | null;
|
|
58
|
+
};
|
|
59
|
+
export declare class ApiError extends Error {
|
|
60
|
+
readonly status: number;
|
|
61
|
+
readonly detail: string | null;
|
|
62
|
+
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
63
|
+
readonly requestId: string | null;
|
|
64
|
+
constructor(status: number, surface?: string, detail?: string | null,
|
|
65
|
+
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
66
|
+
requestId?: string | null);
|
|
67
|
+
/** Whether retrying with the same idempotency key is worth doing. */
|
|
68
|
+
get retryable(): boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A stream that went silent, as distinct from a slow one.
|
|
72
|
+
*
|
|
73
|
+
* Thrown as a real error type because the CLI's exit codes and its retry advice are
|
|
74
|
+
* driven off the error, and a bare DOMException from AbortSignal fell through to the
|
|
75
|
+
* generic handler: exit 1 with no guidance, on the one failure most likely to have been
|
|
76
|
+
* charged for. See ManagedApiError.retryable.
|
|
77
|
+
*/
|
|
78
|
+
export declare class StreamIdleError extends Error {
|
|
79
|
+
readonly idleMs: number;
|
|
80
|
+
constructor();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Validate one sanitized event against the published contract.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
|
|
86
|
+
* schema is that a field appearing where none is documented means something changed
|
|
87
|
+
* server-side that a client should not silently consume.
|
|
88
|
+
*/
|
|
89
|
+
export declare function managedEvent(event: string, id: string | null, value: unknown): ManagedEvent;
|
|
90
|
+
export declare class ManagedClient {
|
|
91
|
+
private readonly apiKey;
|
|
92
|
+
private readonly baseUrl;
|
|
93
|
+
constructor(apiKey: string, baseUrl?: string);
|
|
94
|
+
/**
|
|
95
|
+
* Run or continue an execution, yielding each validated event as it arrives.
|
|
96
|
+
*
|
|
97
|
+
* Streams rather than buffers. The Python server this replaces collected events into
|
|
98
|
+
* a list and threw the whole list away on overflow, taking the execution ID with it,
|
|
99
|
+
* so a caller could not even resume what it had already paid for.
|
|
100
|
+
*/
|
|
101
|
+
execute(input: ManagedExecuteInput, options: {
|
|
102
|
+
idempotencyKey: string;
|
|
103
|
+
}): AsyncGenerator<ManagedEvent>;
|
|
104
|
+
/** Resume a stream after a drop. Pass the last event id you actually processed. */
|
|
105
|
+
events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
|
|
106
|
+
getCapabilities(): Promise<Record<string, unknown>>;
|
|
107
|
+
getExecution(executionId: string): Promise<ManagedExecution>;
|
|
108
|
+
cancel(executionId: string): Promise<ManagedExecution>;
|
|
109
|
+
listConversations(): Promise<Array<Record<string, unknown>>>;
|
|
110
|
+
deleteConversation(conversationId: string): Promise<void>;
|
|
111
|
+
uploadInput(file: Blob, filename?: string): Promise<ManagedInputAsset>;
|
|
112
|
+
/**
|
|
113
|
+
* Fetch a finished asset. Follows redirects on purpose: large images are served
|
|
114
|
+
* straight from storage rather than proxied, so a client that refuses redirects
|
|
115
|
+
* receives the redirect instead of the image.
|
|
116
|
+
*/
|
|
117
|
+
download(url: string): Promise<Uint8Array>;
|
|
118
|
+
private parse;
|
|
119
|
+
private fetchStream;
|
|
120
|
+
private request;
|
|
121
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
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
|
+
export class ApiError extends Error {
|
|
16
|
+
status;
|
|
17
|
+
detail;
|
|
18
|
+
requestId;
|
|
19
|
+
constructor(status, surface = "DreamLayer Agent API", detail = null,
|
|
20
|
+
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
21
|
+
requestId = null) {
|
|
22
|
+
super(detail
|
|
23
|
+
? `${detail}${requestId ? ` (request ${requestId})` : ""}`
|
|
24
|
+
: `${surface} request failed (${status})`);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.detail = detail;
|
|
27
|
+
this.requestId = requestId;
|
|
28
|
+
this.name = "ApiError";
|
|
29
|
+
}
|
|
30
|
+
/** Whether retrying with the same idempotency key is worth doing. */
|
|
31
|
+
get retryable() {
|
|
32
|
+
return this.status === 429 || this.status >= 500;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A stream that went silent, as distinct from a slow one.
|
|
37
|
+
*
|
|
38
|
+
* Thrown as a real error type because the CLI's exit codes and its retry advice are
|
|
39
|
+
* driven off the error, and a bare DOMException from AbortSignal fell through to the
|
|
40
|
+
* generic handler: exit 1 with no guidance, on the one failure most likely to have been
|
|
41
|
+
* charged for. See ManagedApiError.retryable.
|
|
42
|
+
*/
|
|
43
|
+
export class StreamIdleError extends Error {
|
|
44
|
+
idleMs = STREAM_IDLE_TIMEOUT_MS;
|
|
45
|
+
constructor() {
|
|
46
|
+
super(`the stream sent nothing for ${Math.round(STREAM_IDLE_TIMEOUT_MS / 1000)}s, so the ` +
|
|
47
|
+
"connection is treated as dead. The job may still be running on the server.");
|
|
48
|
+
this.name = "StreamIdleError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const ERROR_BODY_LIMIT = 16 * 1024;
|
|
52
|
+
const ERROR_DETAIL_LIMIT = 300;
|
|
53
|
+
/**
|
|
54
|
+
* A plain request: send, get a body back. Bounded work, so a total cap is right.
|
|
55
|
+
*/
|
|
56
|
+
const REQUEST_TIMEOUT_MS = 130_000;
|
|
57
|
+
/**
|
|
58
|
+
* A STREAM is different, and conflating the two shipped a broken `upscale`.
|
|
59
|
+
*
|
|
60
|
+
* AbortSignal.timeout() caps TOTAL duration. An upscale of a 2048px image takes about
|
|
61
|
+
* 150s server-side, so a 130s total cap aborted every single one: a command that failed
|
|
62
|
+
* 100% of the time on a normal input, while the server had done the work and charged
|
|
63
|
+
* for it.
|
|
64
|
+
*
|
|
65
|
+
* Raising the number would fix upscale and break again on the next slower operation.
|
|
66
|
+
* The right question is not "how long may a job take" (unknowable, and it is the
|
|
67
|
+
* server's business) but "how long may we hear NOTHING before the connection is dead".
|
|
68
|
+
* The server sends `: keepalive` comments precisely so a client can tell those apart;
|
|
69
|
+
* a total-duration timeout throws that information away.
|
|
70
|
+
*
|
|
71
|
+
* So: idle timeout, reset on every byte received.
|
|
72
|
+
*/
|
|
73
|
+
const STREAM_IDLE_DEFAULT_MS = 90_000;
|
|
74
|
+
/**
|
|
75
|
+
* Overridable, within bounds. Two honest reasons rather than one: a test cannot wait
|
|
76
|
+
* 90 seconds to prove a timeout fires, and a user on a genuinely bad link may need
|
|
77
|
+
* longer. Clamped so a typo cannot disable the guard entirely or set it to zero, and
|
|
78
|
+
* an unparseable value falls back rather than becoming NaN, which would abort instantly.
|
|
79
|
+
*/
|
|
80
|
+
const STREAM_IDLE_TIMEOUT_MS = (() => {
|
|
81
|
+
const raw = Number(process.env.DREAMLAYER_STREAM_IDLE_MS);
|
|
82
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
83
|
+
return STREAM_IDLE_DEFAULT_MS;
|
|
84
|
+
return Math.min(Math.max(raw, 100), 15 * 60_000);
|
|
85
|
+
})();
|
|
86
|
+
function isRecord(value) {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
function sanitizedErrorDetail(value) {
|
|
90
|
+
if (typeof value !== "string")
|
|
91
|
+
return null;
|
|
92
|
+
const normalized = value
|
|
93
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
94
|
+
.replace(/\s+/g, " ")
|
|
95
|
+
.trim();
|
|
96
|
+
return normalized ? normalized.slice(0, ERROR_DETAIL_LIMIT) : null;
|
|
97
|
+
}
|
|
98
|
+
async function apiError(response, surface = "DreamLayer Agent API") {
|
|
99
|
+
let detail = null;
|
|
100
|
+
let requestId = null;
|
|
101
|
+
try {
|
|
102
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
103
|
+
if (!Number.isFinite(length) || length < 0 || length > ERROR_BODY_LIMIT) {
|
|
104
|
+
return new ApiError(response.status, surface);
|
|
105
|
+
}
|
|
106
|
+
const parsed = JSON.parse(await response.text());
|
|
107
|
+
if (isRecord(parsed)) {
|
|
108
|
+
// Two shapes in the wild. The gateway returns
|
|
109
|
+
// {"error":{"code","message","category","request_id"}}; older surfaces and
|
|
110
|
+
// FastAPI's own handlers return {"detail": "..."}. Reading only the latter is
|
|
111
|
+
// why every failure used to print a bare status code and nothing else.
|
|
112
|
+
detail = sanitizedErrorDetail(parsed.detail);
|
|
113
|
+
if (isRecord(parsed.error)) {
|
|
114
|
+
detail = detail ?? sanitizedErrorDetail(parsed.error.message);
|
|
115
|
+
requestId = sanitizedErrorDetail(parsed.error.request_id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
detail = null;
|
|
121
|
+
}
|
|
122
|
+
return new ApiError(response.status, surface, detail, requestId);
|
|
123
|
+
}
|
|
124
|
+
const MANAGED_EVENT_NAMES = new Set([
|
|
125
|
+
"started",
|
|
126
|
+
"thinking",
|
|
127
|
+
"progress",
|
|
128
|
+
"job",
|
|
129
|
+
"question",
|
|
130
|
+
"asset",
|
|
131
|
+
"done",
|
|
132
|
+
]);
|
|
133
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
134
|
+
/**
|
|
135
|
+
* Validate one sanitized event against the published contract.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
|
|
138
|
+
* schema is that a field appearing where none is documented means something changed
|
|
139
|
+
* server-side that a client should not silently consume.
|
|
140
|
+
*/
|
|
141
|
+
export function managedEvent(event, id, value) {
|
|
142
|
+
if (!MANAGED_EVENT_NAMES.has(event) || !isRecord(value)) {
|
|
143
|
+
throw new Error("Invalid DreamLayer managed event");
|
|
144
|
+
}
|
|
145
|
+
const data = { ...value };
|
|
146
|
+
const exact = (required, optional = []) => {
|
|
147
|
+
const keys = Object.keys(data);
|
|
148
|
+
if (!required.every((key) => keys.includes(key)) ||
|
|
149
|
+
!keys.every((key) => required.includes(key) || optional.includes(key))) {
|
|
150
|
+
throw new Error("Invalid DreamLayer managed event fields");
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const uuid = (key) => {
|
|
154
|
+
const candidate = data[key];
|
|
155
|
+
if (typeof candidate !== "string" || !UUID_PATTERN.test(candidate)) {
|
|
156
|
+
throw new Error("Invalid DreamLayer managed event identifier");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const text = (key, maximum) => {
|
|
160
|
+
const candidate = data[key];
|
|
161
|
+
if (typeof candidate !== "string" || candidate.length === 0 || candidate.length > maximum) {
|
|
162
|
+
throw new Error("Invalid DreamLayer managed event text");
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
switch (event) {
|
|
166
|
+
case "started":
|
|
167
|
+
exact(["execution_id", "conversation_id"]);
|
|
168
|
+
uuid("execution_id");
|
|
169
|
+
uuid("conversation_id");
|
|
170
|
+
break;
|
|
171
|
+
case "thinking":
|
|
172
|
+
exact([]);
|
|
173
|
+
break;
|
|
174
|
+
case "progress":
|
|
175
|
+
exact(["text"]);
|
|
176
|
+
text("text", 300);
|
|
177
|
+
break;
|
|
178
|
+
case "job":
|
|
179
|
+
exact(["public_job_id", "status"]);
|
|
180
|
+
uuid("public_job_id");
|
|
181
|
+
if (!["queued", "running", "completed", "failed"].includes(String(data.status))) {
|
|
182
|
+
throw new Error("Invalid DreamLayer managed job status");
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
case "question":
|
|
186
|
+
exact(["question_id", "conversation_id", "text"]);
|
|
187
|
+
uuid("question_id");
|
|
188
|
+
uuid("conversation_id");
|
|
189
|
+
text("text", 300);
|
|
190
|
+
break;
|
|
191
|
+
case "asset":
|
|
192
|
+
exact(["asset_id", "download_url"]);
|
|
193
|
+
uuid("asset_id");
|
|
194
|
+
text("download_url", 500);
|
|
195
|
+
break;
|
|
196
|
+
case "done":
|
|
197
|
+
exact(["status"], ["conversation_id", "message"]);
|
|
198
|
+
if (!["needs_input", "completed", "failed", "cancelled"].includes(String(data.status))) {
|
|
199
|
+
throw new Error("Invalid DreamLayer managed completion status");
|
|
200
|
+
}
|
|
201
|
+
if (data.conversation_id !== undefined)
|
|
202
|
+
uuid("conversation_id");
|
|
203
|
+
if (data.message !== undefined)
|
|
204
|
+
text("message", 300);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
return { id, event: event, data };
|
|
208
|
+
}
|
|
209
|
+
/** Parse a server-sent-event body into blocks. Handles multi-line data and comments. */
|
|
210
|
+
async function* readEventStream(body, onBytes) {
|
|
211
|
+
const reader = body.getReader();
|
|
212
|
+
const decoder = new TextDecoder();
|
|
213
|
+
let buffer = "";
|
|
214
|
+
for (;;) {
|
|
215
|
+
const { value, done } = await reader.read();
|
|
216
|
+
// Any byte at all, including a `: keepalive` comment that parses to no event,
|
|
217
|
+
// proves the connection is alive. That is the signal the idle timer needs.
|
|
218
|
+
if (!done)
|
|
219
|
+
onBytes?.();
|
|
220
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
221
|
+
let boundary = buffer.indexOf("\n\n");
|
|
222
|
+
while (boundary >= 0) {
|
|
223
|
+
const block = buffer.slice(0, boundary);
|
|
224
|
+
buffer = buffer.slice(boundary + 2);
|
|
225
|
+
const lines = block.split("\n");
|
|
226
|
+
const event = lines
|
|
227
|
+
.find((line) => line.startsWith("event:"))
|
|
228
|
+
?.slice(6)
|
|
229
|
+
.trim();
|
|
230
|
+
const id = lines
|
|
231
|
+
.find((line) => line.startsWith("id:"))
|
|
232
|
+
?.slice(3)
|
|
233
|
+
.trim() ?? null;
|
|
234
|
+
const data = lines
|
|
235
|
+
.filter((line) => line.startsWith("data:"))
|
|
236
|
+
.map((line) => line.slice(5).trim())
|
|
237
|
+
.join("\n");
|
|
238
|
+
if (event && data)
|
|
239
|
+
yield { event, id, data: JSON.parse(data) };
|
|
240
|
+
boundary = buffer.indexOf("\n\n");
|
|
241
|
+
}
|
|
242
|
+
if (done)
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Hosts this client will send a bearer key to.
|
|
248
|
+
*
|
|
249
|
+
* In August a build moved the endpoint default from api.dreamlayer.io to the bare
|
|
250
|
+
* marketing apex, and every request carried Authorization there for two days. The
|
|
251
|
+
* origin passed every cleanliness check below, because those check the SHAPE of a URL
|
|
252
|
+
* and never which host it names. An allowlist is the only thing that catches a host
|
|
253
|
+
* swap, which is why the gateway now has a pinned-origin test and why this mirrors it.
|
|
254
|
+
*
|
|
255
|
+
* DREAMLAYER_API_URL still works for a genuinely different deployment: set
|
|
256
|
+
* DREAMLAYER_ALLOW_ANY_HOST=1 alongside it and accept that you are vouching for the host.
|
|
257
|
+
*/
|
|
258
|
+
const ALLOWED_HOSTS = new Set(["api.dreamlayer.io"]);
|
|
259
|
+
function isLoopback(hostname) {
|
|
260
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]";
|
|
261
|
+
}
|
|
262
|
+
function managedOrigin(value) {
|
|
263
|
+
if (!value || value.endsWith("?") || value.endsWith("#")) {
|
|
264
|
+
throw new Error("Managed endpoint must be a clean HTTPS origin");
|
|
265
|
+
}
|
|
266
|
+
const parsed = new URL(value);
|
|
267
|
+
if (parsed.username ||
|
|
268
|
+
parsed.password ||
|
|
269
|
+
(parsed.pathname !== "" && parsed.pathname !== "/") ||
|
|
270
|
+
parsed.search ||
|
|
271
|
+
parsed.hash) {
|
|
272
|
+
throw new Error("Managed endpoint must be a clean HTTPS origin");
|
|
273
|
+
}
|
|
274
|
+
const loopback = isLoopback(parsed.hostname);
|
|
275
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
|
|
276
|
+
throw new Error("Managed endpoint must use HTTPS, except for exact loopback development");
|
|
277
|
+
}
|
|
278
|
+
const permitted = ALLOWED_HOSTS.has(parsed.hostname) ||
|
|
279
|
+
loopback ||
|
|
280
|
+
(process.env.DREAMLAYER_ALLOW_ANY_HOST ?? "").trim() === "1";
|
|
281
|
+
if (!permitted) {
|
|
282
|
+
throw new Error(`Refusing to send an API key to ${parsed.hostname}. ` +
|
|
283
|
+
`Expected api.dreamlayer.io. Set DREAMLAYER_ALLOW_ANY_HOST=1 to override.`);
|
|
284
|
+
}
|
|
285
|
+
return parsed.origin;
|
|
286
|
+
}
|
|
287
|
+
function requireEventStream(response) {
|
|
288
|
+
if (!response.headers.get("content-type")?.includes("text/event-stream")) {
|
|
289
|
+
throw new Error("DreamLayer managed endpoint did not return an event stream");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export class ManagedClient {
|
|
293
|
+
apiKey;
|
|
294
|
+
baseUrl;
|
|
295
|
+
constructor(apiKey, baseUrl = "https://api.dreamlayer.io") {
|
|
296
|
+
this.apiKey = apiKey;
|
|
297
|
+
if (!apiKey.trim())
|
|
298
|
+
throw new Error("DREAMLAYER_API_KEY is required");
|
|
299
|
+
this.baseUrl = managedOrigin(baseUrl);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Run or continue an execution, yielding each validated event as it arrives.
|
|
303
|
+
*
|
|
304
|
+
* Streams rather than buffers. The Python server this replaces collected events into
|
|
305
|
+
* a list and threw the whole list away on overflow, taking the execution ID with it,
|
|
306
|
+
* so a caller could not even resume what it had already paid for.
|
|
307
|
+
*/
|
|
308
|
+
async *execute(input, options) {
|
|
309
|
+
const stream = await this.fetchStream("/v1/execute", {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: {
|
|
312
|
+
Accept: "text/event-stream",
|
|
313
|
+
"Content-Type": "application/json",
|
|
314
|
+
"Idempotency-Key": options.idempotencyKey,
|
|
315
|
+
},
|
|
316
|
+
body: JSON.stringify(input),
|
|
317
|
+
});
|
|
318
|
+
yield* this.parse(stream);
|
|
319
|
+
}
|
|
320
|
+
/** Resume a stream after a drop. Pass the last event id you actually processed. */
|
|
321
|
+
async *events(executionId, lastEventId) {
|
|
322
|
+
const headers = { Accept: "text/event-stream" };
|
|
323
|
+
if (lastEventId)
|
|
324
|
+
headers["Last-Event-ID"] = lastEventId;
|
|
325
|
+
const stream = await this.fetchStream(`/v1/executions/${encodeURIComponent(executionId)}/events`, { headers });
|
|
326
|
+
yield* this.parse(stream);
|
|
327
|
+
}
|
|
328
|
+
async getCapabilities() {
|
|
329
|
+
const capabilities = await this.request("/v1/capabilities");
|
|
330
|
+
if (capabilities.api_version !== "1") {
|
|
331
|
+
throw new Error("Unsupported DreamLayer Agent API version");
|
|
332
|
+
}
|
|
333
|
+
return capabilities;
|
|
334
|
+
}
|
|
335
|
+
getExecution(executionId) {
|
|
336
|
+
return this.request(`/v1/executions/${encodeURIComponent(executionId)}`);
|
|
337
|
+
}
|
|
338
|
+
cancel(executionId) {
|
|
339
|
+
return this.request(`/v1/executions/${encodeURIComponent(executionId)}/cancel`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
listConversations() {
|
|
344
|
+
return this.request("/v1/conversations");
|
|
345
|
+
}
|
|
346
|
+
deleteConversation(conversationId) {
|
|
347
|
+
return this.request(`/v1/conversations/${encodeURIComponent(conversationId)}`, {
|
|
348
|
+
method: "DELETE",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
uploadInput(file, filename = "input.png") {
|
|
352
|
+
const body = new FormData();
|
|
353
|
+
body.append("file", file, filename);
|
|
354
|
+
return this.request("/v1/input-assets", { method: "POST", body });
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Fetch a finished asset. Follows redirects on purpose: large images are served
|
|
358
|
+
* straight from storage rather than proxied, so a client that refuses redirects
|
|
359
|
+
* receives the redirect instead of the image.
|
|
360
|
+
*/
|
|
361
|
+
async download(url) {
|
|
362
|
+
// Only attach the key when the URL is OUR origin. download_url arrives in the event
|
|
363
|
+
// stream and is validated as text, so a wrong or hostile value would otherwise walk
|
|
364
|
+
// off with a live credential on the very first request. Node strips Authorization
|
|
365
|
+
// across a cross-origin redirect, so the hop to signed storage stays safe either way,
|
|
366
|
+
// and storage URLs are pre-signed and need no header from us.
|
|
367
|
+
const sameOrigin = (() => {
|
|
368
|
+
try {
|
|
369
|
+
return new URL(url).origin === this.baseUrl;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
})();
|
|
375
|
+
const response = await fetch(url, {
|
|
376
|
+
headers: sameOrigin ? { Authorization: `Bearer ${this.apiKey}` } : {},
|
|
377
|
+
redirect: "follow",
|
|
378
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
379
|
+
});
|
|
380
|
+
if (!response.ok)
|
|
381
|
+
throw await apiError(response);
|
|
382
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
383
|
+
}
|
|
384
|
+
async *parse(stream) {
|
|
385
|
+
const { response, keepAlive, finish } = stream;
|
|
386
|
+
if (!response.body) {
|
|
387
|
+
finish();
|
|
388
|
+
throw new Error("DreamLayer managed endpoint returned no body");
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
for await (const block of readEventStream(response.body, keepAlive)) {
|
|
392
|
+
yield managedEvent(block.event, block.id, block.data);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
// Also runs when the consumer breaks out of the loop early, which the CLI does
|
|
397
|
+
// as soon as it sees a terminal event. Without this the timer keeps the process
|
|
398
|
+
// alive for another idle period.
|
|
399
|
+
finish();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async fetchStream(path, init) {
|
|
403
|
+
const headers = new Headers(init.headers);
|
|
404
|
+
headers.set("Authorization", `Bearer ${this.apiKey}`);
|
|
405
|
+
headers.set("DreamLayer-Version", "1");
|
|
406
|
+
// One controller for the whole stream, armed on an IDLE clock that every received
|
|
407
|
+
// byte pushes forward. The signal has to outlive the fetch() call: aborting only
|
|
408
|
+
// the handshake would leave a stalled body hanging forever.
|
|
409
|
+
const controller = new AbortController();
|
|
410
|
+
let timer;
|
|
411
|
+
const keepAlive = () => {
|
|
412
|
+
if (timer)
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
timer = setTimeout(() => controller.abort(new StreamIdleError()), STREAM_IDLE_TIMEOUT_MS);
|
|
415
|
+
timer.unref?.();
|
|
416
|
+
};
|
|
417
|
+
const finish = () => {
|
|
418
|
+
if (timer)
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
timer = undefined;
|
|
421
|
+
};
|
|
422
|
+
keepAlive();
|
|
423
|
+
try {
|
|
424
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
425
|
+
...init,
|
|
426
|
+
headers,
|
|
427
|
+
redirect: "manual",
|
|
428
|
+
signal: controller.signal,
|
|
429
|
+
});
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
finish();
|
|
432
|
+
throw await apiError(response);
|
|
433
|
+
}
|
|
434
|
+
requireEventStream(response);
|
|
435
|
+
return { response, keepAlive, finish };
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
finish();
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async request(path, init = {}) {
|
|
443
|
+
const headers = new Headers(init.headers);
|
|
444
|
+
headers.set("Authorization", `Bearer ${this.apiKey}`);
|
|
445
|
+
headers.set("DreamLayer-Version", "1");
|
|
446
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
447
|
+
...init,
|
|
448
|
+
headers,
|
|
449
|
+
redirect: "manual",
|
|
450
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
451
|
+
});
|
|
452
|
+
if (!response.ok)
|
|
453
|
+
throw await apiError(response);
|
|
454
|
+
if (response.status === 204)
|
|
455
|
+
return undefined;
|
|
456
|
+
return (await response.json());
|
|
457
|
+
}
|
|
458
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
import type { ManagedEvent } from "./client.js";
|
|
10
|
+
export type Outcome = {
|
|
11
|
+
execution_id: string | null;
|
|
12
|
+
conversation_id: string | null;
|
|
13
|
+
status: string;
|
|
14
|
+
asset: {
|
|
15
|
+
asset_id: string;
|
|
16
|
+
download_url: string;
|
|
17
|
+
} | null;
|
|
18
|
+
question: {
|
|
19
|
+
question_id: string;
|
|
20
|
+
text: string;
|
|
21
|
+
} | null;
|
|
22
|
+
last_event_id: string | null;
|
|
23
|
+
};
|
|
24
|
+
export declare class Progress {
|
|
25
|
+
private readonly enabled;
|
|
26
|
+
private timer;
|
|
27
|
+
private frame;
|
|
28
|
+
private label;
|
|
29
|
+
constructor(enabled: boolean);
|
|
30
|
+
set(label: string): void;
|
|
31
|
+
stop(final?: string): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function consume(stream: AsyncGenerator<ManagedEvent>, progress: Progress, onEvent?: (event: ManagedEvent) => void): Promise<Outcome>;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
export class Progress {
|
|
3
|
+
enabled;
|
|
4
|
+
timer = null;
|
|
5
|
+
frame = 0;
|
|
6
|
+
label = "";
|
|
7
|
+
constructor(enabled) {
|
|
8
|
+
this.enabled = enabled;
|
|
9
|
+
}
|
|
10
|
+
set(label) {
|
|
11
|
+
this.label = label;
|
|
12
|
+
if (!this.enabled)
|
|
13
|
+
return;
|
|
14
|
+
if (this.timer === null) {
|
|
15
|
+
this.timer = setInterval(() => {
|
|
16
|
+
this.frame = (this.frame + 1) % FRAMES.length;
|
|
17
|
+
process.stderr.write(`\r${FRAMES[this.frame]} ${this.label}[K`);
|
|
18
|
+
}, 90);
|
|
19
|
+
this.timer.unref();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
stop(final) {
|
|
23
|
+
if (this.timer !== null) {
|
|
24
|
+
clearInterval(this.timer);
|
|
25
|
+
this.timer = null;
|
|
26
|
+
}
|
|
27
|
+
if (!this.enabled) {
|
|
28
|
+
if (final)
|
|
29
|
+
process.stderr.write(`${final}\n`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
process.stderr.write(`\r[K`);
|
|
33
|
+
if (final)
|
|
34
|
+
process.stderr.write(`${final}\n`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Human wording for each event. `thinking` carries no text by contract. */
|
|
38
|
+
function describe(event) {
|
|
39
|
+
switch (event.event) {
|
|
40
|
+
case "started":
|
|
41
|
+
return "Starting";
|
|
42
|
+
case "thinking":
|
|
43
|
+
return "Working";
|
|
44
|
+
case "progress":
|
|
45
|
+
return String(event.data.text ?? "Working");
|
|
46
|
+
case "job":
|
|
47
|
+
return `Job ${String(event.data.status)}`;
|
|
48
|
+
case "question":
|
|
49
|
+
return null;
|
|
50
|
+
case "asset":
|
|
51
|
+
return "Downloading";
|
|
52
|
+
case "done":
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function consume(stream, progress, onEvent) {
|
|
57
|
+
const outcome = {
|
|
58
|
+
execution_id: null,
|
|
59
|
+
conversation_id: null,
|
|
60
|
+
status: "unknown",
|
|
61
|
+
asset: null,
|
|
62
|
+
question: null,
|
|
63
|
+
last_event_id: null,
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
for await (const event of stream) {
|
|
67
|
+
onEvent?.(event);
|
|
68
|
+
outcome.last_event_id = event.id;
|
|
69
|
+
const label = describe(event);
|
|
70
|
+
if (label)
|
|
71
|
+
progress.set(label);
|
|
72
|
+
if (event.event === "started") {
|
|
73
|
+
outcome.execution_id = String(event.data.execution_id);
|
|
74
|
+
outcome.conversation_id = String(event.data.conversation_id);
|
|
75
|
+
}
|
|
76
|
+
else if (event.event === "asset") {
|
|
77
|
+
outcome.asset = {
|
|
78
|
+
asset_id: String(event.data.asset_id),
|
|
79
|
+
download_url: String(event.data.download_url),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
else if (event.event === "question") {
|
|
83
|
+
outcome.question = {
|
|
84
|
+
question_id: String(event.data.question_id),
|
|
85
|
+
text: String(event.data.text),
|
|
86
|
+
};
|
|
87
|
+
if (event.data.conversation_id) {
|
|
88
|
+
outcome.conversation_id = String(event.data.conversation_id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (event.event === "done") {
|
|
92
|
+
outcome.status = String(event.data.status);
|
|
93
|
+
if (event.data.conversation_id) {
|
|
94
|
+
outcome.conversation_id = String(event.data.conversation_id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
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
|
+
if (error !== null && typeof error === "object") {
|
|
108
|
+
error.partialOutcome = outcome;
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return outcome;
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dreamlayer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate and edit images from your terminal, over local files, with one API key.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dreamlayer": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"NOTICE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.12"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
21
|
+
"test": "pnpm build && node --test test/*.test.mjs",
|
|
22
|
+
"pack:check": "npm pack --dry-run",
|
|
23
|
+
"prepublishOnly": "pnpm test"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "5.9.2",
|
|
27
|
+
"@types/node": "^22.10.0"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"image-generation",
|
|
31
|
+
"cli",
|
|
32
|
+
"dreamlayer",
|
|
33
|
+
"ai",
|
|
34
|
+
"image-editing"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/TheDesignFounder/dreamlayer-cli.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://docs.dreamlayer.io/cli"
|
|
41
|
+
}
|