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 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 | Request rejected, will fail identically until you change it |
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, 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.
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 { readFile, writeFile } from "node:fs/promises";
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
- /** Upload a local file and return its asset id, with size and type checked here first. */
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
- 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;
114
+ let fileStat;
115
115
  try {
116
- bytes = await readFile(resolved);
116
+ fileStat = await stat(resolved);
117
117
  }
118
118
  catch {
119
119
  throw new UsageError(`cannot read ${file}`);
120
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`);
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(new Blob([new Uint8Array(bytes)]), path.basename(resolved));
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.status === 401 || error.status === 403)
235
+ if (error.reason === "authentication_failed" || error.reason === "access_denied")
192
236
  return 2;
193
- if (error.status === 402)
237
+ if (error.reason === "insufficient_credits" || error.reason === "quota_exceeded")
194
238
  return 3;
195
- if (error.status === 409 || error.status === 422)
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("0.1.0\n");
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.status === 402
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.stderr.write(`${error.message}${hint ? `\n${hint}` : ""}\n`);
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 type ManagedOperation = "text_to_image" | "image_to_image" | "background_remove" | "upscale";
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 | null;
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
- super(detail
23
- ? `${detail}${requestId ? ` (request ${requestId})` : ""}`
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.status === 429 || this.status >= 500;
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 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;
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 detail = null;
100
- let requestId = null;
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 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
- }
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
- detail = null;
296
+ // A malformed or oversized response still becomes a closed, status-derived error.
121
297
  }
122
- return new ApiError(response.status, surface, detail, requestId);
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
- const capabilities = await this.request("/v1/capabilities");
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamlayer",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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",