dreamlayer 0.1.0 → 0.3.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/README.md +19 -8
- package/dist/cli.js +88 -21
- package/dist/client.d.ts +34 -4
- package/dist/client.js +308 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,10 +12,6 @@ dreamlayer generate "A glass greenhouse at dusk" --out greenhouse.png
|
|
|
12
12
|
Get a key at [platform.dreamlayer.io](https://platform.dreamlayer.io). A new account
|
|
13
13
|
starts at zero credits, and each finished image costs one.
|
|
14
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
15
|
## Commands
|
|
20
16
|
|
|
21
17
|
```bash
|
|
@@ -25,12 +21,17 @@ dreamlayer cutout <image> [--out file.png] # background removal
|
|
|
25
21
|
dreamlayer upscale <image> [--out file.png] # 2x
|
|
26
22
|
dreamlayer answer <conversation-id> <text> [--image file.png]
|
|
27
23
|
dreamlayer status <execution-id>
|
|
24
|
+
dreamlayer balance # spends nothing
|
|
28
25
|
dreamlayer capabilities # spends nothing
|
|
29
26
|
```
|
|
30
27
|
|
|
31
28
|
`cutout` and `upscale` name their operation rather than hoping a sentence is read the
|
|
32
29
|
way you meant, so they run a dedicated chain and never stop to ask a question.
|
|
33
30
|
|
|
31
|
+
Image inputs may be PNG, JPEG, WebP, or supported camera RAW files up to 200 MB.
|
|
32
|
+
DreamLayer develops RAW previews, applies camera orientation, and resizes oversized
|
|
33
|
+
sources on the server before any operation runs.
|
|
34
|
+
|
|
34
35
|
`upscale` doubles each side and finished images are capped at 4096 per side, so the
|
|
35
36
|
longest side of your input must be 2048 or less. Anything larger is refused before it
|
|
36
37
|
costs you a credit.
|
|
@@ -52,7 +53,7 @@ open "$(dreamlayer generate 'a fox logo')"
|
|
|
52
53
|
| 1 | Usage error |
|
|
53
54
|
| 2 | Auth or account problem |
|
|
54
55
|
| 3 | Out of credits |
|
|
55
|
-
| 4 |
|
|
56
|
+
| 4 | Non-retryable request or execution failure |
|
|
56
57
|
| 5 | Temporary, worth retrying |
|
|
57
58
|
| 6 | It asked a question instead of producing an image |
|
|
58
59
|
|
|
@@ -60,9 +61,19 @@ open "$(dreamlayer generate 'a fox logo')"
|
|
|
60
61
|
for f in shots/*.png; do dreamlayer cutout "$f" --out "cut/$(basename "$f")" || break; done
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
**`--json`** gives machine-readable output on stdout, carrying job state,
|
|
64
|
-
and the written path.
|
|
65
|
-
|
|
64
|
+
**`--json`** gives machine-readable success output on stdout, carrying job state,
|
|
65
|
+
execution ids, and the written path. Errors use the same stable `code`, `reason`,
|
|
66
|
+
`message`, `retryable`, and `request_id` fields as REST and are written to stderr, so
|
|
67
|
+
stdout stays result-only. Error envelopes deliberately exclude your prompt, images,
|
|
68
|
+
local filenames, and key. Successful image JSON includes the destination path you chose;
|
|
69
|
+
remove it before sharing if the local name is private.
|
|
70
|
+
|
|
71
|
+
Check the authenticated key's own balance without starting image work:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
dreamlayer balance
|
|
75
|
+
dreamlayer balance --json
|
|
76
|
+
```
|
|
66
77
|
|
|
67
78
|
## Retries are safe if you reuse the key
|
|
68
79
|
|
package/dist/cli.js
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
* 6 the run ended asking a question instead of producing an image
|
|
16
16
|
*/
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
|
-
import {
|
|
18
|
+
import { openAsBlob, readFileSync } from "node:fs";
|
|
19
|
+
import { stat, writeFile } from "node:fs/promises";
|
|
19
20
|
import path from "node:path";
|
|
20
|
-
import { ApiError, ManagedClient, StreamIdleError, } from "./client.js";
|
|
21
|
+
import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
|
|
21
22
|
import { Progress, consume } from "./render.js";
|
|
23
|
+
const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
22
24
|
const USAGE = `dreamlayer - generate and edit images from your terminal
|
|
23
25
|
|
|
24
26
|
USAGE
|
|
@@ -28,6 +30,7 @@ USAGE
|
|
|
28
30
|
dreamlayer upscale <image> [--out <file>]
|
|
29
31
|
dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
|
|
30
32
|
dreamlayer status <execution-id>
|
|
33
|
+
dreamlayer balance
|
|
31
34
|
dreamlayer capabilities
|
|
32
35
|
|
|
33
36
|
OPTIONS
|
|
@@ -104,24 +107,21 @@ function client() {
|
|
|
104
107
|
}
|
|
105
108
|
return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim());
|
|
106
109
|
}
|
|
107
|
-
|
|
110
|
+
const MAX_SOURCE_BYTES = 200 * 1024 * 1024;
|
|
111
|
+
/** Upload a local file; the server owns RAW, EXIF, alpha, and resize normalization. */
|
|
108
112
|
async function upload(api, file) {
|
|
109
113
|
const resolved = path.resolve(file);
|
|
110
|
-
|
|
111
|
-
if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
|
|
112
|
-
throw new UsageError(`${file} is not a PNG, JPEG, or WEBP`);
|
|
113
|
-
}
|
|
114
|
-
let bytes;
|
|
114
|
+
let fileStat;
|
|
115
115
|
try {
|
|
116
|
-
|
|
116
|
+
fileStat = await stat(resolved);
|
|
117
117
|
}
|
|
118
118
|
catch {
|
|
119
119
|
throw new UsageError(`cannot read ${file}`);
|
|
120
120
|
}
|
|
121
|
-
if (
|
|
122
|
-
throw new UsageError(`${file} is ${Math.round(
|
|
121
|
+
if (fileStat.size > MAX_SOURCE_BYTES) {
|
|
122
|
+
throw new UsageError(`${file} is ${Math.round(fileStat.size / 1024 / 1024)} MB; the limit is 200 MB`);
|
|
123
123
|
}
|
|
124
|
-
const asset = await api.uploadInput(
|
|
124
|
+
const asset = await api.uploadInput(await openAsBlob(resolved), path.basename(resolved));
|
|
125
125
|
return asset.input_asset_id;
|
|
126
126
|
}
|
|
127
127
|
function defaultOut() {
|
|
@@ -147,7 +147,12 @@ async function run(api, input, options) {
|
|
|
147
147
|
return 6;
|
|
148
148
|
}
|
|
149
149
|
if (outcome.status !== "completed" || !outcome.asset) {
|
|
150
|
-
progress.stop("Failed");
|
|
150
|
+
progress.stop(options.json ? undefined : "Failed");
|
|
151
|
+
if (outcome.status === "failed" && outcome.execution_id) {
|
|
152
|
+
const terminal = terminalExecutionError(await api.getExecution(outcome.execution_id));
|
|
153
|
+
if (terminal)
|
|
154
|
+
throw terminal;
|
|
155
|
+
}
|
|
151
156
|
if (options.json)
|
|
152
157
|
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
|
|
153
158
|
else
|
|
@@ -187,14 +192,51 @@ function recoveryHint(error) {
|
|
|
187
192
|
: null;
|
|
188
193
|
return id ? `The job may still be running. Check it with:\n dreamlayer status ${id}\n` : "";
|
|
189
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Say so when this build and the server disagree about what exists.
|
|
197
|
+
*
|
|
198
|
+
* The MCP package solves this by asking the server at startup and shaping its tool
|
|
199
|
+
* schema from the answer. The CLI cannot: `ManagedOperation` is a compile-time union and
|
|
200
|
+
* `cutout` / `upscale` are compile-time commands, so deriving the list at runtime would
|
|
201
|
+
* buy consistency by giving up type safety at every call site.
|
|
202
|
+
*
|
|
203
|
+
* So it reports instead of adapting, and it does so HERE because `capabilities` is free,
|
|
204
|
+
* spends nothing, and is the command people are told to run first. Both directions are
|
|
205
|
+
* worth naming:
|
|
206
|
+
*
|
|
207
|
+
* - the server offers something this build cannot reach -> the user is missing a
|
|
208
|
+
* feature they are paying for and would never know
|
|
209
|
+
* - this build names something the server will not run -> the failure that shipped on
|
|
210
|
+
* 2026-08-21, where a call looked like a client bug rather than a version skew
|
|
211
|
+
*
|
|
212
|
+
* stderr, never stdout: `dreamlayer capabilities` is piped into jq.
|
|
213
|
+
*/
|
|
214
|
+
function warnIfOperationsDrifted(capabilities) {
|
|
215
|
+
const listed = capabilities.operations;
|
|
216
|
+
if (!Array.isArray(listed) || listed.some((o) => typeof o !== "string"))
|
|
217
|
+
return;
|
|
218
|
+
const server = new Set(listed);
|
|
219
|
+
const mine = new Set(KNOWN_OPERATIONS);
|
|
220
|
+
const serverOnly = [...server].filter((o) => !mine.has(o));
|
|
221
|
+
const clientOnly = [...mine].filter((o) => !server.has(o));
|
|
222
|
+
if (serverOnly.length === 0 && clientOnly.length === 0)
|
|
223
|
+
return;
|
|
224
|
+
process.stderr.write("\nThis CLI and the server disagree about the operation list.\n");
|
|
225
|
+
if (serverOnly.length > 0) {
|
|
226
|
+
process.stderr.write(` The server offers, this version cannot use: ${serverOnly.join(", ")}\n` +
|
|
227
|
+
" Upgrade with: npm i -g dreamlayer\n");
|
|
228
|
+
}
|
|
229
|
+
if (clientOnly.length > 0) {
|
|
230
|
+
process.stderr.write(` This version names, the server will not run: ${clientOnly.join(", ")}\n` +
|
|
231
|
+
" Those commands will fail validation until the server catches up.\n");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
190
234
|
function exitCodeFor(error) {
|
|
191
|
-
if (error.
|
|
235
|
+
if (error.reason === "authentication_failed" || error.reason === "access_denied")
|
|
192
236
|
return 2;
|
|
193
|
-
if (error.
|
|
237
|
+
if (error.reason === "insufficient_credits" || error.reason === "quota_exceeded")
|
|
194
238
|
return 3;
|
|
195
|
-
|
|
196
|
-
return 4;
|
|
197
|
-
return 5;
|
|
239
|
+
return error.retryable ? 5 : 4;
|
|
198
240
|
}
|
|
199
241
|
async function main(argv) {
|
|
200
242
|
const [command, ...rest] = argv;
|
|
@@ -203,7 +245,7 @@ async function main(argv) {
|
|
|
203
245
|
return command ? 0 : 1;
|
|
204
246
|
}
|
|
205
247
|
if (command === "--version" || command === "-v") {
|
|
206
|
-
process.stdout.write(
|
|
248
|
+
process.stdout.write(`${PACKAGE_VERSION}\n`);
|
|
207
249
|
return 0;
|
|
208
250
|
}
|
|
209
251
|
const { positional, options } = parseOptions(rest);
|
|
@@ -256,9 +298,23 @@ async function main(argv) {
|
|
|
256
298
|
process.stdout.write(`${JSON.stringify(execution, null, 2)}\n`);
|
|
257
299
|
return 0;
|
|
258
300
|
}
|
|
301
|
+
case "balance": {
|
|
302
|
+
if (positional.length > 0)
|
|
303
|
+
throw new UsageError("balance takes no arguments");
|
|
304
|
+
const balance = await client().getBalance();
|
|
305
|
+
if (options.json) {
|
|
306
|
+
process.stdout.write(`${JSON.stringify(balance, null, 2)}\n`);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
process.stdout.write(`${balance.available} credits available ` +
|
|
310
|
+
`(${balance.promotional} promotional, ${balance.purchased} purchased)\n`);
|
|
311
|
+
}
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
259
314
|
case "capabilities": {
|
|
260
315
|
const capabilities = await client().getCapabilities();
|
|
261
316
|
process.stdout.write(`${JSON.stringify(capabilities, null, 2)}\n`);
|
|
317
|
+
warnIfOperationsDrifted(capabilities);
|
|
262
318
|
return 0;
|
|
263
319
|
}
|
|
264
320
|
default:
|
|
@@ -276,12 +332,17 @@ main(process.argv.slice(2))
|
|
|
276
332
|
return;
|
|
277
333
|
}
|
|
278
334
|
if (error instanceof ApiError) {
|
|
279
|
-
const hint = error.
|
|
335
|
+
const hint = error.reason === "insufficient_credits"
|
|
280
336
|
? "Buy credits at https://platform.dreamlayer.io/console/billing"
|
|
281
337
|
: error.retryable
|
|
282
338
|
? "Temporary. Retry with --idempotency-key to avoid paying twice."
|
|
283
339
|
: "";
|
|
284
|
-
process.
|
|
340
|
+
if (process.argv.slice(2).includes("--json")) {
|
|
341
|
+
process.stderr.write(`${JSON.stringify(error.toPublicEnvelope())}\n`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
process.stderr.write(`${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`);
|
|
345
|
+
}
|
|
285
346
|
process.exitCode = exitCodeFor(error);
|
|
286
347
|
return;
|
|
287
348
|
}
|
|
@@ -297,6 +358,12 @@ main(process.argv.slice(2))
|
|
|
297
358
|
process.exitCode = 5;
|
|
298
359
|
return;
|
|
299
360
|
}
|
|
361
|
+
if (error instanceof UploadTimeoutError) {
|
|
362
|
+
process.stderr.write(`${error.message}\n`);
|
|
363
|
+
process.stderr.write("Temporary. Retry with --idempotency-key to avoid paying twice.\n");
|
|
364
|
+
process.exitCode = 5;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
300
367
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
301
368
|
process.stderr.write(recoveryHint(error));
|
|
302
369
|
process.exitCode = 1;
|
package/dist/client.d.ts
CHANGED
|
@@ -34,7 +34,17 @@ export type ManagedEvent = {
|
|
|
34
34
|
* anyway. Confirmed against the deployment, not the source: the live /openapi.json
|
|
35
35
|
* advertises exactly these four in both ExecuteRequest and ImageJobCreate.
|
|
36
36
|
*/
|
|
37
|
-
export
|
|
37
|
+
export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale"];
|
|
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
|
+
export type ManagedOperation = (typeof KNOWN_OPERATIONS)[number];
|
|
38
48
|
export type ManagedExecuteInput = {
|
|
39
49
|
prompt?: string;
|
|
40
50
|
respond?: string;
|
|
@@ -56,17 +66,28 @@ export type ManagedExecution = {
|
|
|
56
66
|
status: string;
|
|
57
67
|
image_job: Record<string, unknown> | null;
|
|
58
68
|
};
|
|
69
|
+
export declare const PUBLIC_ERROR_REASONS: readonly ["invalid_request", "authentication_failed", "access_denied", "resource_not_found", "insufficient_credits", "conflict", "too_many_active_jobs", "rate_limited", "quota_exceeded", "content_refused", "temporarily_unavailable", "generation_failed"];
|
|
70
|
+
export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number];
|
|
59
71
|
export declare class ApiError extends Error {
|
|
60
72
|
readonly status: number;
|
|
61
|
-
readonly detail: string
|
|
62
|
-
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
73
|
+
readonly detail: string;
|
|
63
74
|
readonly requestId: string | null;
|
|
75
|
+
readonly code: string;
|
|
76
|
+
readonly reason: PublicErrorReason;
|
|
64
77
|
constructor(status: number, surface?: string, detail?: string | null,
|
|
65
78
|
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
66
|
-
requestId?: string | null);
|
|
79
|
+
requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
|
|
67
80
|
/** Whether retrying with the same idempotency key is worth doing. */
|
|
68
81
|
get retryable(): boolean;
|
|
82
|
+
/** The same stable fields exposed by REST and MCP, with no private response text. */
|
|
83
|
+
toPublicEnvelope(): Record<string, unknown>;
|
|
69
84
|
}
|
|
85
|
+
export type ManagedBalance = {
|
|
86
|
+
promotional: number;
|
|
87
|
+
purchased: number;
|
|
88
|
+
available: number;
|
|
89
|
+
credit_usd: "0.17";
|
|
90
|
+
};
|
|
70
91
|
/**
|
|
71
92
|
* A stream that went silent, as distinct from a slow one.
|
|
72
93
|
*
|
|
@@ -79,6 +100,13 @@ export declare class StreamIdleError extends Error {
|
|
|
79
100
|
readonly idleMs: number;
|
|
80
101
|
constructor();
|
|
81
102
|
}
|
|
103
|
+
export declare class UploadTimeoutError extends Error {
|
|
104
|
+
constructor();
|
|
105
|
+
}
|
|
106
|
+
export declare function uploadTimeoutMs(bytes: number): number;
|
|
107
|
+
export declare function managedBalance(value: unknown): ManagedBalance;
|
|
108
|
+
/** Convert a terminal job failure into the same safe contract used by HTTP errors. */
|
|
109
|
+
export declare function terminalExecutionError(execution: ManagedExecution): ApiError | null;
|
|
82
110
|
/**
|
|
83
111
|
* Validate one sanitized event against the published contract.
|
|
84
112
|
*
|
|
@@ -90,6 +118,7 @@ export declare function managedEvent(event: string, id: string | null, value: un
|
|
|
90
118
|
export declare class ManagedClient {
|
|
91
119
|
private readonly apiKey;
|
|
92
120
|
private readonly baseUrl;
|
|
121
|
+
private capabilitiesPromise;
|
|
93
122
|
constructor(apiKey: string, baseUrl?: string);
|
|
94
123
|
/**
|
|
95
124
|
* Run or continue an execution, yielding each validated event as it arrives.
|
|
@@ -105,6 +134,7 @@ export declare class ManagedClient {
|
|
|
105
134
|
events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
|
|
106
135
|
getCapabilities(): Promise<Record<string, unknown>>;
|
|
107
136
|
getExecution(executionId: string): Promise<ManagedExecution>;
|
|
137
|
+
getBalance(): Promise<ManagedBalance>;
|
|
108
138
|
cancel(executionId: string): Promise<ManagedExecution>;
|
|
109
139
|
listConversations(): Promise<Array<Record<string, unknown>>>;
|
|
110
140
|
deleteConversation(conversationId: string): Promise<void>;
|
package/dist/client.js
CHANGED
|
@@ -12,24 +12,174 @@
|
|
|
12
12
|
* client instead of the dead local-proxy class, and that timeouts and an explicit
|
|
13
13
|
* redirect policy were added, which the original lacked.
|
|
14
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
|
+
export const KNOWN_OPERATIONS = [
|
|
32
|
+
"text_to_image",
|
|
33
|
+
"image_to_image",
|
|
34
|
+
"background_remove",
|
|
35
|
+
"upscale",
|
|
36
|
+
];
|
|
37
|
+
const DIRECT_INPUT_BYTES = 20 * 1024 * 1024;
|
|
38
|
+
const RASTER_INPUT_SUFFIXES = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
39
|
+
const LEGACY_INPUT_SUFFIXES = new Set([
|
|
40
|
+
...RASTER_INPUT_SUFFIXES,
|
|
41
|
+
".arw", ".cr2", ".cr3", ".crw", ".dng", ".nef", ".nrw", ".orf", ".pef",
|
|
42
|
+
".raf", ".rw2", ".sr2", ".srw",
|
|
43
|
+
]);
|
|
44
|
+
export const PUBLIC_ERROR_REASONS = [
|
|
45
|
+
"invalid_request",
|
|
46
|
+
"authentication_failed",
|
|
47
|
+
"access_denied",
|
|
48
|
+
"resource_not_found",
|
|
49
|
+
"insufficient_credits",
|
|
50
|
+
"conflict",
|
|
51
|
+
"too_many_active_jobs",
|
|
52
|
+
"rate_limited",
|
|
53
|
+
"quota_exceeded",
|
|
54
|
+
"content_refused",
|
|
55
|
+
"temporarily_unavailable",
|
|
56
|
+
"generation_failed",
|
|
57
|
+
];
|
|
58
|
+
const PUBLIC_ERROR_SPECS = {
|
|
59
|
+
invalid_request: { message: "The request could not be validated.", retryable: false },
|
|
60
|
+
authentication_failed: { message: "Authentication failed.", retryable: false },
|
|
61
|
+
access_denied: {
|
|
62
|
+
message: "This request is not available for this account.",
|
|
63
|
+
retryable: false,
|
|
64
|
+
},
|
|
65
|
+
resource_not_found: { message: "The requested item was not found.", retryable: false },
|
|
66
|
+
insufficient_credits: {
|
|
67
|
+
message: "The account has insufficient credits.",
|
|
68
|
+
retryable: false,
|
|
69
|
+
},
|
|
70
|
+
conflict: { message: "The request conflicts with the current state.", retryable: false },
|
|
71
|
+
too_many_active_jobs: {
|
|
72
|
+
message: "Too many image jobs are already in progress.",
|
|
73
|
+
retryable: true,
|
|
74
|
+
},
|
|
75
|
+
rate_limited: { message: "Too many requests. Please try again shortly.", retryable: true },
|
|
76
|
+
quota_exceeded: { message: "The account quota has been reached.", retryable: false },
|
|
77
|
+
content_refused: {
|
|
78
|
+
message: "The request could not be completed under the service policy.",
|
|
79
|
+
retryable: false,
|
|
80
|
+
},
|
|
81
|
+
temporarily_unavailable: {
|
|
82
|
+
message: "The service is temporarily unavailable. Please try again.",
|
|
83
|
+
retryable: true,
|
|
84
|
+
},
|
|
85
|
+
generation_failed: { message: "Image generation failed.", retryable: false },
|
|
86
|
+
};
|
|
87
|
+
const PUBLIC_ERROR_REASON_SET = new Set(PUBLIC_ERROR_REASONS);
|
|
88
|
+
function publicReason(value) {
|
|
89
|
+
return typeof value === "string" && PUBLIC_ERROR_REASON_SET.has(value)
|
|
90
|
+
? value
|
|
91
|
+
: null;
|
|
92
|
+
}
|
|
93
|
+
function reasonForStatus(status) {
|
|
94
|
+
if (status === 400 || status === 405 || status === 422)
|
|
95
|
+
return "invalid_request";
|
|
96
|
+
if (status === 401)
|
|
97
|
+
return "authentication_failed";
|
|
98
|
+
if (status === 403)
|
|
99
|
+
return "access_denied";
|
|
100
|
+
if (status === 404)
|
|
101
|
+
return "resource_not_found";
|
|
102
|
+
if (status === 402)
|
|
103
|
+
return "insufficient_credits";
|
|
104
|
+
if (status === 409)
|
|
105
|
+
return "conflict";
|
|
106
|
+
if (status === 429)
|
|
107
|
+
return "rate_limited";
|
|
108
|
+
if (status === 502 || status === 503 || status === 504)
|
|
109
|
+
return "temporarily_unavailable";
|
|
110
|
+
return "generation_failed";
|
|
111
|
+
}
|
|
112
|
+
function defaultCode(reason) {
|
|
113
|
+
const codes = {
|
|
114
|
+
invalid_request: "VALIDATION_FAILED",
|
|
115
|
+
authentication_failed: "AUTHENTICATION_FAILED",
|
|
116
|
+
access_denied: "FORBIDDEN",
|
|
117
|
+
resource_not_found: "NOT_FOUND",
|
|
118
|
+
insufficient_credits: "BUDGET_EXCEEDED",
|
|
119
|
+
conflict: "CONFLICT",
|
|
120
|
+
too_many_active_jobs: "RATE_LIMITED",
|
|
121
|
+
rate_limited: "RATE_LIMITED",
|
|
122
|
+
quota_exceeded: "BUDGET_EXCEEDED",
|
|
123
|
+
content_refused: "CONTENT_REFUSED",
|
|
124
|
+
temporarily_unavailable: "SERVICE_UNAVAILABLE",
|
|
125
|
+
generation_failed: "INTERNAL_ERROR",
|
|
126
|
+
};
|
|
127
|
+
return codes[reason];
|
|
128
|
+
}
|
|
129
|
+
function statusForReason(reason) {
|
|
130
|
+
const statuses = {
|
|
131
|
+
invalid_request: 422,
|
|
132
|
+
authentication_failed: 401,
|
|
133
|
+
access_denied: 403,
|
|
134
|
+
resource_not_found: 404,
|
|
135
|
+
insufficient_credits: 402,
|
|
136
|
+
conflict: 409,
|
|
137
|
+
too_many_active_jobs: 429,
|
|
138
|
+
rate_limited: 429,
|
|
139
|
+
quota_exceeded: 402,
|
|
140
|
+
content_refused: 422,
|
|
141
|
+
temporarily_unavailable: 503,
|
|
142
|
+
generation_failed: 500,
|
|
143
|
+
};
|
|
144
|
+
return statuses[reason];
|
|
145
|
+
}
|
|
146
|
+
const ERROR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
147
|
+
function publicIdentifier(value) {
|
|
148
|
+
return typeof value === "string" && ERROR_IDENTIFIER_PATTERN.test(value) ? value : null;
|
|
149
|
+
}
|
|
15
150
|
export class ApiError extends Error {
|
|
16
151
|
status;
|
|
17
152
|
detail;
|
|
18
153
|
requestId;
|
|
154
|
+
code;
|
|
155
|
+
reason;
|
|
19
156
|
constructor(status, surface = "DreamLayer Agent API", detail = null,
|
|
20
157
|
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
21
|
-
requestId = null) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
: `${surface} request failed (${status})`);
|
|
158
|
+
requestId = null, code = null, reason = reasonForStatus(status)) {
|
|
159
|
+
const safeDetail = detail ?? PUBLIC_ERROR_SPECS[reason].message;
|
|
160
|
+
super(`${safeDetail || `${surface} request failed (${status})`}${requestId ? ` (request ${requestId})` : ""}`);
|
|
25
161
|
this.status = status;
|
|
26
|
-
this.detail = detail;
|
|
27
|
-
this.requestId = requestId;
|
|
28
162
|
this.name = "ApiError";
|
|
163
|
+
this.detail = safeDetail;
|
|
164
|
+
this.requestId = requestId;
|
|
165
|
+
this.code = code ?? defaultCode(reason);
|
|
166
|
+
this.reason = reason;
|
|
29
167
|
}
|
|
30
168
|
/** Whether retrying with the same idempotency key is worth doing. */
|
|
31
169
|
get retryable() {
|
|
32
|
-
return this.
|
|
170
|
+
return PUBLIC_ERROR_SPECS[this.reason].retryable;
|
|
171
|
+
}
|
|
172
|
+
/** The same stable fields exposed by REST and MCP, with no private response text. */
|
|
173
|
+
toPublicEnvelope() {
|
|
174
|
+
return {
|
|
175
|
+
error: {
|
|
176
|
+
code: this.code,
|
|
177
|
+
reason: this.reason,
|
|
178
|
+
message: this.detail,
|
|
179
|
+
retryable: this.retryable,
|
|
180
|
+
request_id: this.requestId,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
33
183
|
}
|
|
34
184
|
}
|
|
35
185
|
/**
|
|
@@ -48,12 +198,26 @@ export class StreamIdleError extends Error {
|
|
|
48
198
|
this.name = "StreamIdleError";
|
|
49
199
|
}
|
|
50
200
|
}
|
|
201
|
+
export class UploadTimeoutError extends Error {
|
|
202
|
+
constructor() {
|
|
203
|
+
super("the staged upload stopped before it completed; no image job was started");
|
|
204
|
+
this.name = "UploadTimeoutError";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
51
207
|
const ERROR_BODY_LIMIT = 16 * 1024;
|
|
52
|
-
const ERROR_DETAIL_LIMIT = 300;
|
|
53
208
|
/**
|
|
54
209
|
* A plain request: send, get a body back. Bounded work, so a total cap is right.
|
|
55
210
|
*/
|
|
56
211
|
const REQUEST_TIMEOUT_MS = 130_000;
|
|
212
|
+
const UPLOAD_MIN_BYTES_PER_SECOND = 256 * 1024;
|
|
213
|
+
const UPLOAD_MAX_TIMEOUT_MS = 15 * 60_000;
|
|
214
|
+
export function uploadTimeoutMs(bytes) {
|
|
215
|
+
const override = Number(process.env.DREAMLAYER_UPLOAD_TIMEOUT_MS);
|
|
216
|
+
if (Number.isFinite(override) && override > 0) {
|
|
217
|
+
return Math.min(Math.max(override, 100), UPLOAD_MAX_TIMEOUT_MS);
|
|
218
|
+
}
|
|
219
|
+
return Math.min(Math.max(REQUEST_TIMEOUT_MS, 60_000 + Math.ceil(bytes / UPLOAD_MIN_BYTES_PER_SECOND) * 1000), UPLOAD_MAX_TIMEOUT_MS);
|
|
220
|
+
}
|
|
57
221
|
/**
|
|
58
222
|
* A STREAM is different, and conflating the two shipped a broken `upscale`.
|
|
59
223
|
*
|
|
@@ -86,40 +250,52 @@ const STREAM_IDLE_TIMEOUT_MS = (() => {
|
|
|
86
250
|
function isRecord(value) {
|
|
87
251
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
252
|
}
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
253
|
+
async function boundedErrorBody(response) {
|
|
254
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
255
|
+
if (!Number.isFinite(length) || length < 0 || length > ERROR_BODY_LIMIT || !response.body) {
|
|
256
|
+
return "";
|
|
257
|
+
}
|
|
258
|
+
const reader = response.body.getReader();
|
|
259
|
+
const decoder = new TextDecoder();
|
|
260
|
+
let total = 0;
|
|
261
|
+
let body = "";
|
|
262
|
+
try {
|
|
263
|
+
for (;;) {
|
|
264
|
+
const { value, done } = await reader.read();
|
|
265
|
+
if (done)
|
|
266
|
+
return body + decoder.decode();
|
|
267
|
+
total += value.byteLength;
|
|
268
|
+
if (total > ERROR_BODY_LIMIT) {
|
|
269
|
+
await reader.cancel();
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
body += decoder.decode(value, { stream: true });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
reader.releaseLock();
|
|
277
|
+
}
|
|
97
278
|
}
|
|
98
279
|
async function apiError(response, surface = "DreamLayer Agent API") {
|
|
99
|
-
let
|
|
100
|
-
let
|
|
280
|
+
let reason = reasonForStatus(response.status);
|
|
281
|
+
let code = null;
|
|
282
|
+
let requestId = publicRequestId(response.headers.get("x-request-id"));
|
|
101
283
|
try {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
}
|
|
284
|
+
const raw = await boundedErrorBody(response);
|
|
285
|
+
const parsed = raw ? JSON.parse(raw) : null;
|
|
286
|
+
if (isRecord(parsed) && isRecord(parsed.error)) {
|
|
287
|
+
// Treat only the closed reason/code/id fields as data. The local message table
|
|
288
|
+
// deliberately ignores arbitrary remote detail so a private upstream response
|
|
289
|
+
// cannot leak through a client even if a server regression serializes it.
|
|
290
|
+
reason = publicReason(parsed.error.reason) ?? reason;
|
|
291
|
+
code = publicIdentifier(parsed.error.code);
|
|
292
|
+
requestId = publicRequestId(parsed.error.request_id) ?? requestId;
|
|
117
293
|
}
|
|
118
294
|
}
|
|
119
295
|
catch {
|
|
120
|
-
|
|
296
|
+
// A malformed or oversized response still becomes a closed, status-derived error.
|
|
121
297
|
}
|
|
122
|
-
return new ApiError(response.status, surface,
|
|
298
|
+
return new ApiError(response.status, surface, null, requestId, code, reason);
|
|
123
299
|
}
|
|
124
300
|
const MANAGED_EVENT_NAMES = new Set([
|
|
125
301
|
"started",
|
|
@@ -131,6 +307,41 @@ const MANAGED_EVENT_NAMES = new Set([
|
|
|
131
307
|
"done",
|
|
132
308
|
]);
|
|
133
309
|
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;
|
|
310
|
+
function publicRequestId(value) {
|
|
311
|
+
return typeof value === "string" && UUID_PATTERN.test(value) ? value : null;
|
|
312
|
+
}
|
|
313
|
+
export function managedBalance(value) {
|
|
314
|
+
if (!isRecord(value))
|
|
315
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
316
|
+
const exact = ["available", "credit_usd", "promotional", "purchased"];
|
|
317
|
+
if (Object.keys(value).sort().join("\0") !== exact.join("\0")) {
|
|
318
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
319
|
+
}
|
|
320
|
+
for (const field of ["promotional", "purchased", "available"]) {
|
|
321
|
+
if (!Number.isSafeInteger(value[field]) || Number(value[field]) < 0) {
|
|
322
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (value.credit_usd !== "0.17" ||
|
|
326
|
+
Number(value.available) !== Number(value.promotional) + Number(value.purchased)) {
|
|
327
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
promotional: Number(value.promotional),
|
|
331
|
+
purchased: Number(value.purchased),
|
|
332
|
+
available: Number(value.available),
|
|
333
|
+
credit_usd: "0.17",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** Convert a terminal job failure into the same safe contract used by HTTP errors. */
|
|
337
|
+
export function terminalExecutionError(execution) {
|
|
338
|
+
if (!isRecord(execution.image_job) || !isRecord(execution.image_job.sanitized_error)) {
|
|
339
|
+
return execution.status === "failed" ? new ApiError(500) : null;
|
|
340
|
+
}
|
|
341
|
+
const error = execution.image_job.sanitized_error;
|
|
342
|
+
const reason = publicReason(error.reason) ?? "generation_failed";
|
|
343
|
+
return new ApiError(statusForReason(reason), "DreamLayer execution", null, publicRequestId(error.request_id), publicIdentifier(error.code), reason);
|
|
344
|
+
}
|
|
134
345
|
/**
|
|
135
346
|
* Validate one sanitized event against the published contract.
|
|
136
347
|
*
|
|
@@ -292,6 +503,7 @@ function requireEventStream(response) {
|
|
|
292
503
|
export class ManagedClient {
|
|
293
504
|
apiKey;
|
|
294
505
|
baseUrl;
|
|
506
|
+
capabilitiesPromise = null;
|
|
295
507
|
constructor(apiKey, baseUrl = "https://api.dreamlayer.io") {
|
|
296
508
|
this.apiKey = apiKey;
|
|
297
509
|
if (!apiKey.trim())
|
|
@@ -326,7 +538,13 @@ export class ManagedClient {
|
|
|
326
538
|
yield* this.parse(stream);
|
|
327
539
|
}
|
|
328
540
|
async getCapabilities() {
|
|
329
|
-
|
|
541
|
+
this.capabilitiesPromise ??= this.request("/v1/capabilities").catch((error) => {
|
|
542
|
+
// Cache a successful contract for the process, but never pin a transient
|
|
543
|
+
// capabilities failure as a permanent result.
|
|
544
|
+
this.capabilitiesPromise = null;
|
|
545
|
+
throw error;
|
|
546
|
+
});
|
|
547
|
+
const capabilities = await this.capabilitiesPromise;
|
|
330
548
|
if (capabilities.api_version !== "1") {
|
|
331
549
|
throw new Error("Unsupported DreamLayer Agent API version");
|
|
332
550
|
}
|
|
@@ -335,6 +553,9 @@ export class ManagedClient {
|
|
|
335
553
|
getExecution(executionId) {
|
|
336
554
|
return this.request(`/v1/executions/${encodeURIComponent(executionId)}`);
|
|
337
555
|
}
|
|
556
|
+
async getBalance() {
|
|
557
|
+
return managedBalance(await this.request("/v1/balance"));
|
|
558
|
+
}
|
|
338
559
|
cancel(executionId) {
|
|
339
560
|
return this.request(`/v1/executions/${encodeURIComponent(executionId)}/cancel`, {
|
|
340
561
|
method: "POST",
|
|
@@ -348,7 +569,57 @@ export class ManagedClient {
|
|
|
348
569
|
method: "DELETE",
|
|
349
570
|
});
|
|
350
571
|
}
|
|
351
|
-
uploadInput(file, filename = "input.png") {
|
|
572
|
+
async uploadInput(file, filename = "input.png") {
|
|
573
|
+
const suffix = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
|
574
|
+
const capabilities = await this.getCapabilities();
|
|
575
|
+
const advertised = capabilities.supported_input_extensions;
|
|
576
|
+
const supported = Array.isArray(advertised)
|
|
577
|
+
? new Set(advertised.filter((item) => typeof item === "string"))
|
|
578
|
+
: LEGACY_INPUT_SUFFIXES;
|
|
579
|
+
if (!supported.has(suffix)) {
|
|
580
|
+
throw new Error(`${filename} is not a supported image or camera RAW file`);
|
|
581
|
+
}
|
|
582
|
+
if (file.size > DIRECT_INPUT_BYTES || !RASTER_INPUT_SUFFIXES.has(suffix)) {
|
|
583
|
+
const contentType = file.type || "application/octet-stream";
|
|
584
|
+
const upload = await this.request("/v1/input-assets/uploads", {
|
|
585
|
+
method: "POST",
|
|
586
|
+
headers: { "Content-Type": "application/json" },
|
|
587
|
+
body: JSON.stringify({ filename, size_bytes: file.size, content_type: contentType }),
|
|
588
|
+
});
|
|
589
|
+
const targetUrl = new URL(upload.upload_url, `${this.baseUrl}/`);
|
|
590
|
+
const headers = new Headers({ "Content-Type": upload.content_type });
|
|
591
|
+
if (upload.mode === "signed") {
|
|
592
|
+
headers.set("x-goog-content-length-range", `0,${upload.maximum_bytes}`);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
if (targetUrl.origin !== new URL(this.baseUrl).origin) {
|
|
596
|
+
throw new ApiError(502, "DreamLayer input upload", "refused an off-origin upload URL");
|
|
597
|
+
}
|
|
598
|
+
headers.set("Authorization", `Bearer ${this.apiKey}`);
|
|
599
|
+
headers.set("DreamLayer-Version", "1");
|
|
600
|
+
}
|
|
601
|
+
let response;
|
|
602
|
+
try {
|
|
603
|
+
response = await fetch(targetUrl, {
|
|
604
|
+
method: upload.http_method,
|
|
605
|
+
headers,
|
|
606
|
+
body: file,
|
|
607
|
+
redirect: "manual",
|
|
608
|
+
signal: AbortSignal.timeout(uploadTimeoutMs(file.size)),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
613
|
+
throw new UploadTimeoutError();
|
|
614
|
+
}
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
if (!response.ok)
|
|
618
|
+
throw await apiError(response, "DreamLayer input upload");
|
|
619
|
+
return this.request(`/v1/input-assets/uploads/${encodeURIComponent(upload.upload_id)}/finalize`, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
});
|
|
622
|
+
}
|
|
352
623
|
const body = new FormData();
|
|
353
624
|
body.append("file", file, filename);
|
|
354
625
|
return this.request("/v1/input-assets", { method: "POST", body });
|