dreamlayer 0.2.0 → 0.4.0-beta.1

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
@@ -21,6 +21,7 @@ dreamlayer cutout <image> [--out file.png] # background removal
21
21
  dreamlayer upscale <image> [--out file.png] # 2x
22
22
  dreamlayer answer <conversation-id> <text> [--image file.png]
23
23
  dreamlayer status <execution-id>
24
+ dreamlayer balance # spends nothing
24
25
  dreamlayer capabilities # spends nothing
25
26
  ```
26
27
 
@@ -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
 
@@ -96,3 +107,20 @@ Node.js 22.12 or later.
96
107
  ## License
97
108
 
98
109
  MIT. See LICENSE and NOTICE.
110
+
111
+ ## Sprite-sheet beta
112
+
113
+ Sprite requests accept exactly one of `options.animation_prompt` (1–4000 characters) or an `options.action` preset (`walk`, `run`, `idle`). Custom prompts can describe characters, creatures, objects, effects or 360° turntables. `animation_mode` is `loop` or `once`; presets default to loop, custom prompts to once. For a turntable, request a stationary camera and rotating subject. Broad requests do not guarantee correct motion, unseen details or successful effect transparency.
114
+
115
+ Request integer `frame_count` 7–100 (default 12) and `frame_size` 32, 64, 128, 256, 512, 720 or 1080 (default 512). These are square export canvases; a larger export does not guarantee additional detail. Aspect ratio and shared alignment are preserved with transparent padding. A bundle contains transparent PNG frames, sheet, atlas, preview and import instructions, including each frame's playback duration. Choose a repeating loop or a one-time action with a beginning and ending. If the requested number of distinct frames cannot be delivered, the job fails and held credits are returned. Translucent effects can lose detail or fail; small exports are not automatically pixel art.
116
+
117
+ Pricing is unchanged across sizes: frames 1–14 cost $0.14 each; additional frames $0.07 each. One credit is $0.17. Round the complete order upward once to a tenth of a credit. Check `sprite_pricing` in capabilities and approve the quote with `max_credits`. Credits are held during processing, settled after complete delivery and restored on failure/timeout. There is no customer cancellation. Keep the execution ID to resume status. Custom requests need the matching broad-animation server release; older servers reject them. New live generation quality, 100-frame duration and actual cost remain unverified.
118
+
119
+ ```sh
120
+ dreamlayer sprite character.png --action walk --frames 12 --max-credits 9.9 --out walk.zip
121
+ dreamlayer status EXECUTION_ID
122
+ ```
123
+
124
+ Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes.
125
+
126
+ For affordability, compare the complete rounded quote in **credits** with `available`. One tenth of a credit is $0.017. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.1 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier.
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { spriteCreditPrice } from "./client.js";
2
3
  /**
3
4
  * DreamLayer CLI.
4
5
  *
@@ -18,7 +19,7 @@ import { randomUUID } from "node:crypto";
18
19
  import { openAsBlob, readFileSync } from "node:fs";
19
20
  import { stat, writeFile } from "node:fs/promises";
20
21
  import path from "node:path";
21
- import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, } from "./client.js";
22
+ import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
22
23
  import { Progress, consume } from "./render.js";
23
24
  const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
24
25
  const USAGE = `dreamlayer - generate and edit images from your terminal
@@ -28,14 +29,22 @@ USAGE
28
29
  dreamlayer edit <image> <prompt> [--out <file>]
29
30
  dreamlayer cutout <image> [--out <file>]
30
31
  dreamlayer upscale <image> [--out <file>]
32
+ dreamlayer sprite <image> --action <walk|run|idle> [--frames <7–100>] --max-credits <n> [--out <zip>]
31
33
  dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
32
34
  dreamlayer status <execution-id>
35
+ dreamlayer balance
33
36
  dreamlayer capabilities
34
37
 
35
38
  OPTIONS
36
39
  --out <file> Where to write the image. Default: dreamlayer-<n>.png
37
40
  --image <file> Attach an image when answering a question that asks for one
38
41
  --aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
42
+ --action <name> Sprite preset: walk, run, idle (walk if no custom prompt)
43
+ --animation-prompt <text> Custom animation; cannot combine with --action
44
+ --animation-mode <loop|once> Default: loop for presets, once for custom
45
+ --frame-size <px> Square export: 32, 64, 128, 256, 512 (default), 720, 1080
46
+ --frames <n> Frame count: integer 7–100, default 12
47
+ --max-credits <n> Maximum approved charge for the sprite job
39
48
  --json Machine-readable output on stdout
40
49
  --quiet No progress on stderr
41
50
  --idempotency-key <key> Reuse to retry safely after an uncertain response
@@ -44,13 +53,15 @@ ENVIRONMENT
44
53
  DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
45
54
  DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
46
55
 
47
- Every finished image costs one credit. A new account starts at zero.
56
+ Image operations cost one credit. Sprite pricing is listed in capabilities. A new account starts at zero.
48
57
  `;
49
58
  class UsageError extends Error {
50
59
  }
51
60
  function parseOptions(argv) {
52
61
  const positional = [];
53
62
  const options = {
63
+ maxCredits: 1,
64
+ frameCount: 12,
54
65
  out: null,
55
66
  image: null,
56
67
  aspect: "1:1",
@@ -70,6 +81,42 @@ function parseOptions(argv) {
70
81
  throw new UsageError("--out needs a file path");
71
82
  options.out = value;
72
83
  }
84
+ else if (token === "--action") {
85
+ const value = argv[++i];
86
+ if (value !== "walk" && value !== "run" && value !== "idle")
87
+ throw new UsageError("--action must be walk, run, or idle");
88
+ options.action = value;
89
+ }
90
+ else if (token === "--animation-prompt") {
91
+ const value = argv[++i];
92
+ if (!value?.trim() || [...value].length > 4000)
93
+ throw new UsageError("--animation-prompt needs 1–4000 characters");
94
+ options.animationPrompt = value;
95
+ }
96
+ else if (token === "--animation-mode") {
97
+ const value = argv[++i];
98
+ if (value !== "loop" && value !== "once")
99
+ throw new UsageError("--animation-mode must be loop or once");
100
+ options.animationMode = value;
101
+ }
102
+ else if (token === "--frame-size") {
103
+ const value = Number(argv[++i]);
104
+ if (![32, 64, 128, 256, 512, 720, 1080].includes(value))
105
+ throw new UsageError("--frame-size must be 32, 64, 128, 256, 512, 720 or 1080");
106
+ options.frameSize = value;
107
+ }
108
+ else if (token === "--frames") {
109
+ const value = Number(argv[++i]);
110
+ if (!Number.isInteger(value) || value < 7 || value > 100)
111
+ throw new UsageError("--frames must be an integer from 7 to 100");
112
+ options.frameCount = value;
113
+ }
114
+ else if (token === "--max-credits") {
115
+ const value = Number(argv[++i]);
116
+ if (!Number.isFinite(value) || value < 0.1 || value > 100)
117
+ throw new UsageError("--max-credits must be 0.1 to 100");
118
+ options.maxCredits = value;
119
+ }
73
120
  else if (token === "--image") {
74
121
  const value = argv[++i];
75
122
  if (!value)
@@ -129,7 +176,7 @@ function defaultOut() {
129
176
  async function run(api, input, options) {
130
177
  const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
131
178
  const idempotencyKey = options.idempotencyKey ?? randomUUID();
132
- const outcome = await consume(api.execute(input, { idempotencyKey }), progress);
179
+ const outcome = await consume(api.follow(input, { idempotencyKey }), progress);
133
180
  if (outcome.question) {
134
181
  progress.stop();
135
182
  if (options.json) {
@@ -146,7 +193,12 @@ async function run(api, input, options) {
146
193
  return 6;
147
194
  }
148
195
  if (outcome.status !== "completed" || !outcome.asset) {
149
- progress.stop("Failed");
196
+ progress.stop(options.json ? undefined : "Failed");
197
+ if (outcome.status === "failed" && outcome.execution_id) {
198
+ const terminal = terminalExecutionError(await api.getExecution(outcome.execution_id));
199
+ if (terminal)
200
+ throw terminal;
201
+ }
150
202
  if (options.json)
151
203
  process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
152
204
  else
@@ -155,7 +207,7 @@ async function run(api, input, options) {
155
207
  }
156
208
  progress.set("Downloading");
157
209
  const bytes = await api.download(outcome.asset.download_url);
158
- const target = options.out ?? defaultOut();
210
+ const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut());
159
211
  await writeFile(target, bytes);
160
212
  progress.stop();
161
213
  if (options.json) {
@@ -212,7 +264,7 @@ function warnIfOperationsDrifted(capabilities) {
212
264
  const server = new Set(listed);
213
265
  const mine = new Set(KNOWN_OPERATIONS);
214
266
  const serverOnly = [...server].filter((o) => !mine.has(o));
215
- const clientOnly = [...mine].filter((o) => !server.has(o));
267
+ const clientOnly = [...mine].filter((o) => !server.has(o) && o !== "sprite_sheet");
216
268
  if (serverOnly.length === 0 && clientOnly.length === 0)
217
269
  return;
218
270
  process.stderr.write("\nThis CLI and the server disagree about the operation list.\n");
@@ -226,13 +278,11 @@ function warnIfOperationsDrifted(capabilities) {
226
278
  }
227
279
  }
228
280
  function exitCodeFor(error) {
229
- if (error.status === 401 || error.status === 403)
281
+ if (error.reason === "authentication_failed" || error.reason === "access_denied")
230
282
  return 2;
231
- if (error.status === 402)
283
+ if (error.reason === "insufficient_credits" || error.reason === "quota_exceeded")
232
284
  return 3;
233
- if (error.status === 409 || error.status === 422)
234
- return 4;
235
- return 5;
285
+ return error.retryable ? 5 : 4;
236
286
  }
237
287
  async function main(argv) {
238
288
  const [command, ...rest] = argv;
@@ -246,6 +296,23 @@ async function main(argv) {
246
296
  }
247
297
  const { positional, options } = parseOptions(rest);
248
298
  switch (command) {
299
+ case "sprite": {
300
+ if (options.action && options.animationPrompt)
301
+ throw new UsageError("Use either --action or --animation-prompt, not both");
302
+ const file = positional[0];
303
+ if (!file)
304
+ throw new UsageError("sprite needs a reference image");
305
+ const api = client();
306
+ const caps = await api.getCapabilities();
307
+ if (!Array.isArray(caps.operations) || !caps.operations.includes("sprite_sheet"))
308
+ throw new UsageError("sprite beta access is not enabled for this account");
309
+ if (!caps.sprite_pricing)
310
+ throw new UsageError("The server does not support configurable sprite pricing yet");
311
+ const price = spriteCreditPrice(options.frameCount);
312
+ if (options.maxCredits < price)
313
+ throw new UsageError(`Sprite jobs require ${price} credits. Set --max-credits to approve that amount.`);
314
+ return run(api, { operation: "sprite_sheet", input_asset_id: await upload(api, file), options: { ...(options.animationPrompt ? { animation_prompt: options.animationPrompt } : { action: options.action ?? "walk" }), ...(options.animationMode ? { animation_mode: options.animationMode } : {}), ...(options.frameSize ? { frame_size: options.frameSize } : {}), frame_count: options.frameCount }, max_credits: options.maxCredits }, options);
315
+ }
249
316
  case "generate": {
250
317
  const prompt = positional[0];
251
318
  if (!prompt)
@@ -294,6 +361,22 @@ async function main(argv) {
294
361
  process.stdout.write(`${JSON.stringify(execution, null, 2)}\n`);
295
362
  return 0;
296
363
  }
364
+ case "balance": {
365
+ if (positional.length > 0)
366
+ throw new UsageError("balance takes no arguments");
367
+ const balance = await client().getBalance();
368
+ if (options.json) {
369
+ process.stdout.write(`${JSON.stringify(balance, null, 2)}\n`);
370
+ }
371
+ else {
372
+ process.stdout.write(`${balance.available} credits available ` +
373
+ `(${balance.promotional} promotional, ${balance.purchased} purchased)\n`);
374
+ }
375
+ if (!options.json && Math.round(balance.available * 10) > Math.round(balance.promotional * 10) + Math.round(balance.purchased * 10)) {
376
+ process.stdout.write("Use the available total for affordability. Funding balances are rounded down separately; stored fractions are preserved.\n");
377
+ }
378
+ return 0;
379
+ }
297
380
  case "capabilities": {
298
381
  const capabilities = await client().getCapabilities();
299
382
  process.stdout.write(`${JSON.stringify(capabilities, null, 2)}\n`);
@@ -315,12 +398,17 @@ main(process.argv.slice(2))
315
398
  return;
316
399
  }
317
400
  if (error instanceof ApiError) {
318
- const hint = error.status === 402
401
+ const hint = error.reason === "insufficient_credits"
319
402
  ? "Buy credits at https://platform.dreamlayer.io/console/billing"
320
403
  : error.retryable
321
404
  ? "Temporary. Retry with --idempotency-key to avoid paying twice."
322
405
  : "";
323
- process.stderr.write(`${error.message}${hint ? `\n${hint}` : ""}\n`);
406
+ if (process.argv.slice(2).includes("--json")) {
407
+ process.stderr.write(`${JSON.stringify(error.toPublicEnvelope())}\n`);
408
+ }
409
+ else {
410
+ process.stderr.write(`${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`);
411
+ }
324
412
  process.exitCode = exitCodeFor(error);
325
413
  return;
326
414
  }
package/dist/client.d.ts CHANGED
@@ -34,7 +34,7 @@ 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 declare const KNOWN_OPERATIONS: readonly ["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", "sprite_sheet"];
38
38
  /**
39
39
  * Derived from the array above, not written twice.
40
40
  *
@@ -53,7 +53,17 @@ export type ManagedExecuteInput = {
53
53
  aspect_ratio?: string;
54
54
  /** Requires the gateway build that added it. See ManagedOperation. */
55
55
  operation?: ManagedOperation;
56
+ options?: {
57
+ action?: "walk" | "run" | "idle";
58
+ animation_prompt?: string;
59
+ animation_mode?: "loop" | "once";
60
+ frame_count?: number;
61
+ frame_size?: 32 | 64 | 128 | 256 | 512 | 720 | 1080;
62
+ };
63
+ max_credits?: number;
56
64
  };
65
+ export declare function spriteCreditPrice(frameCount: number): number;
66
+ export declare function validateSpriteInput(input: ManagedExecuteInput): void;
57
67
  export type ManagedInputAsset = {
58
68
  input_asset_id: string;
59
69
  width: number;
@@ -66,17 +76,28 @@ export type ManagedExecution = {
66
76
  status: string;
67
77
  image_job: Record<string, unknown> | null;
68
78
  };
79
+ 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", "insufficient_frames"];
80
+ export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number];
69
81
  export declare class ApiError extends Error {
70
82
  readonly status: number;
71
- readonly detail: string | null;
72
- /** Server-assigned id for this failure. The only handle support can search on. */
83
+ readonly detail: string;
73
84
  readonly requestId: string | null;
85
+ readonly code: string;
86
+ readonly reason: PublicErrorReason;
74
87
  constructor(status: number, surface?: string, detail?: string | null,
75
88
  /** Server-assigned id for this failure. The only handle support can search on. */
76
- requestId?: string | null);
89
+ requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
77
90
  /** Whether retrying with the same idempotency key is worth doing. */
78
91
  get retryable(): boolean;
92
+ /** The same stable fields exposed by REST and MCP, with no private response text. */
93
+ toPublicEnvelope(): Record<string, unknown>;
79
94
  }
95
+ export type ManagedBalance = {
96
+ promotional: number;
97
+ purchased: number;
98
+ available: number;
99
+ credit_usd: "0.17";
100
+ };
80
101
  /**
81
102
  * A stream that went silent, as distinct from a slow one.
82
103
  *
@@ -93,6 +114,9 @@ export declare class UploadTimeoutError extends Error {
93
114
  constructor();
94
115
  }
95
116
  export declare function uploadTimeoutMs(bytes: number): number;
117
+ export declare function managedBalance(value: unknown): ManagedBalance;
118
+ /** Convert a terminal job failure into the same safe contract used by HTTP errors. */
119
+ export declare function terminalExecutionError(execution: ManagedExecution): ApiError | null;
96
120
  /**
97
121
  * Validate one sanitized event against the published contract.
98
122
  *
@@ -116,10 +140,15 @@ export declare class ManagedClient {
116
140
  execute(input: ManagedExecuteInput, options: {
117
141
  idempotencyKey: string;
118
142
  }): AsyncGenerator<ManagedEvent>;
143
+ /** Follow a durable job across finite streams without submitting it twice. */
144
+ follow(input: ManagedExecuteInput, options: {
145
+ idempotencyKey: string;
146
+ }): AsyncGenerator<ManagedEvent>;
119
147
  /** Resume a stream after a drop. Pass the last event id you actually processed. */
120
148
  events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
121
149
  getCapabilities(): Promise<Record<string, unknown>>;
122
150
  getExecution(executionId: string): Promise<ManagedExecution>;
151
+ getBalance(): Promise<ManagedBalance>;
123
152
  cancel(executionId: string): Promise<ManagedExecution>;
124
153
  listConversations(): Promise<Array<Record<string, unknown>>>;
125
154
  deleteConversation(conversationId: string): Promise<void>;
package/dist/client.js CHANGED
@@ -33,7 +33,32 @@ export const KNOWN_OPERATIONS = [
33
33
  "image_to_image",
34
34
  "background_remove",
35
35
  "upscale",
36
+ "sprite_sheet",
36
37
  ];
38
+ export function spriteCreditPrice(frameCount) {
39
+ if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100)
40
+ throw new Error("frame_count must be an integer from 7 to 100");
41
+ const cents = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
42
+ return Math.ceil(cents * 10 / 17) / 10;
43
+ }
44
+ export function validateSpriteInput(input) {
45
+ if (input.operation !== "sprite_sheet")
46
+ return;
47
+ const options = input.options;
48
+ if (!options || (options.action === undefined) === (options.animation_prompt === undefined))
49
+ throw new Error("Sprite requests require exactly one of options.action or options.animation_prompt");
50
+ if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action))
51
+ throw new Error("Invalid sprite preset");
52
+ if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000))
53
+ throw new Error("animation_prompt must contain 1–4000 characters");
54
+ if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode))
55
+ throw new Error("animation_mode must be loop or once");
56
+ const price = spriteCreditPrice(options.frame_count ?? 12);
57
+ if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size))
58
+ throw new Error("frame_size must be 32, 64, 128, 256, 512, 720 or 1080");
59
+ if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100)
60
+ throw new Error(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
61
+ }
37
62
  const DIRECT_INPUT_BYTES = 20 * 1024 * 1024;
38
63
  const RASTER_INPUT_SUFFIXES = new Set([".png", ".jpg", ".jpeg", ".webp"]);
39
64
  const LEGACY_INPUT_SUFFIXES = new Set([
@@ -41,24 +66,149 @@ const LEGACY_INPUT_SUFFIXES = new Set([
41
66
  ".arw", ".cr2", ".cr3", ".crw", ".dng", ".nef", ".nrw", ".orf", ".pef",
42
67
  ".raf", ".rw2", ".sr2", ".srw",
43
68
  ]);
69
+ export const PUBLIC_ERROR_REASONS = [
70
+ "invalid_request",
71
+ "authentication_failed",
72
+ "access_denied",
73
+ "resource_not_found",
74
+ "insufficient_credits",
75
+ "conflict",
76
+ "too_many_active_jobs",
77
+ "rate_limited",
78
+ "quota_exceeded",
79
+ "content_refused",
80
+ "temporarily_unavailable",
81
+ "generation_failed",
82
+ "insufficient_frames",
83
+ ];
84
+ const PUBLIC_ERROR_SPECS = {
85
+ invalid_request: { message: "The request could not be validated.", retryable: false },
86
+ authentication_failed: { message: "Authentication failed.", retryable: false },
87
+ access_denied: {
88
+ message: "This request is not available for this account.",
89
+ retryable: false,
90
+ },
91
+ resource_not_found: { message: "The requested item was not found.", retryable: false },
92
+ insufficient_credits: {
93
+ message: "The account has insufficient credits.",
94
+ retryable: false,
95
+ },
96
+ conflict: { message: "The request conflicts with the current state.", retryable: false },
97
+ too_many_active_jobs: {
98
+ message: "Too many image jobs are already in progress.",
99
+ retryable: true,
100
+ },
101
+ rate_limited: { message: "Too many requests. Please try again shortly.", retryable: true },
102
+ quota_exceeded: { message: "The account quota has been reached.", retryable: false },
103
+ content_refused: {
104
+ message: "The request could not be completed under the service policy.",
105
+ retryable: false,
106
+ },
107
+ temporarily_unavailable: {
108
+ message: "The service is temporarily unavailable. Please try again.",
109
+ retryable: true,
110
+ },
111
+ insufficient_frames: { message: "Not enough distinct animation frames. Try a lower frame count.", retryable: false },
112
+ generation_failed: { message: "Image generation failed.", retryable: false },
113
+ };
114
+ const PUBLIC_ERROR_REASON_SET = new Set(PUBLIC_ERROR_REASONS);
115
+ function publicReason(value) {
116
+ return typeof value === "string" && PUBLIC_ERROR_REASON_SET.has(value)
117
+ ? value
118
+ : null;
119
+ }
120
+ function reasonForStatus(status) {
121
+ if (status === 400 || status === 405 || status === 422)
122
+ return "invalid_request";
123
+ if (status === 401)
124
+ return "authentication_failed";
125
+ if (status === 403)
126
+ return "access_denied";
127
+ if (status === 404)
128
+ return "resource_not_found";
129
+ if (status === 402)
130
+ return "insufficient_credits";
131
+ if (status === 409)
132
+ return "conflict";
133
+ if (status === 429)
134
+ return "rate_limited";
135
+ if (status === 502 || status === 503 || status === 504)
136
+ return "temporarily_unavailable";
137
+ return "generation_failed";
138
+ }
139
+ function defaultCode(reason) {
140
+ const codes = {
141
+ invalid_request: "VALIDATION_FAILED",
142
+ authentication_failed: "AUTHENTICATION_FAILED",
143
+ access_denied: "FORBIDDEN",
144
+ resource_not_found: "NOT_FOUND",
145
+ insufficient_credits: "BUDGET_EXCEEDED",
146
+ conflict: "CONFLICT",
147
+ too_many_active_jobs: "RATE_LIMITED",
148
+ rate_limited: "RATE_LIMITED",
149
+ quota_exceeded: "BUDGET_EXCEEDED",
150
+ content_refused: "CONTENT_REFUSED",
151
+ temporarily_unavailable: "SERVICE_UNAVAILABLE",
152
+ generation_failed: "INTERNAL_ERROR",
153
+ insufficient_frames: "INSUFFICIENT_FRAMES",
154
+ };
155
+ return codes[reason];
156
+ }
157
+ function statusForReason(reason) {
158
+ const statuses = {
159
+ invalid_request: 422,
160
+ authentication_failed: 401,
161
+ access_denied: 403,
162
+ resource_not_found: 404,
163
+ insufficient_credits: 402,
164
+ conflict: 409,
165
+ too_many_active_jobs: 429,
166
+ rate_limited: 429,
167
+ quota_exceeded: 402,
168
+ content_refused: 422,
169
+ temporarily_unavailable: 503,
170
+ generation_failed: 500,
171
+ insufficient_frames: 422,
172
+ };
173
+ return statuses[reason];
174
+ }
175
+ const ERROR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
176
+ function publicIdentifier(value) {
177
+ return typeof value === "string" && ERROR_IDENTIFIER_PATTERN.test(value) ? value : null;
178
+ }
44
179
  export class ApiError extends Error {
45
180
  status;
46
181
  detail;
47
182
  requestId;
183
+ code;
184
+ reason;
48
185
  constructor(status, surface = "DreamLayer Agent API", detail = null,
49
186
  /** Server-assigned id for this failure. The only handle support can search on. */
50
- requestId = null) {
51
- super(detail
52
- ? `${detail}${requestId ? ` (request ${requestId})` : ""}`
53
- : `${surface} request failed (${status})`);
187
+ requestId = null, code = null, reason = reasonForStatus(status)) {
188
+ const safeDetail = detail ?? PUBLIC_ERROR_SPECS[reason].message;
189
+ super(`${safeDetail || `${surface} request failed (${status})`}${requestId ? ` (request ${requestId})` : ""}`);
54
190
  this.status = status;
55
- this.detail = detail;
56
- this.requestId = requestId;
57
191
  this.name = "ApiError";
192
+ this.detail = safeDetail;
193
+ this.requestId = requestId;
194
+ this.code = code ?? defaultCode(reason);
195
+ this.reason = reason;
58
196
  }
59
197
  /** Whether retrying with the same idempotency key is worth doing. */
60
198
  get retryable() {
61
- return this.status === 429 || this.status >= 500;
199
+ return PUBLIC_ERROR_SPECS[this.reason].retryable;
200
+ }
201
+ /** The same stable fields exposed by REST and MCP, with no private response text. */
202
+ toPublicEnvelope() {
203
+ return {
204
+ error: {
205
+ code: this.code,
206
+ reason: this.reason,
207
+ message: this.detail,
208
+ retryable: this.retryable,
209
+ request_id: this.requestId,
210
+ },
211
+ };
62
212
  }
63
213
  }
64
214
  /**
@@ -84,7 +234,6 @@ export class UploadTimeoutError extends Error {
84
234
  }
85
235
  }
86
236
  const ERROR_BODY_LIMIT = 16 * 1024;
87
- const ERROR_DETAIL_LIMIT = 300;
88
237
  /**
89
238
  * A plain request: send, get a body back. Bounded work, so a total cap is right.
90
239
  */
@@ -130,40 +279,52 @@ const STREAM_IDLE_TIMEOUT_MS = (() => {
130
279
  function isRecord(value) {
131
280
  return typeof value === "object" && value !== null && !Array.isArray(value);
132
281
  }
133
- function sanitizedErrorDetail(value) {
134
- if (typeof value !== "string")
135
- return null;
136
- const normalized = value
137
- .replace(/[\u0000-\u001f\u007f]/g, " ")
138
- .replace(/\s+/g, " ")
139
- .trim();
140
- return normalized ? normalized.slice(0, ERROR_DETAIL_LIMIT) : null;
282
+ async function boundedErrorBody(response) {
283
+ const length = Number(response.headers.get("content-length") ?? "0");
284
+ if (!Number.isFinite(length) || length < 0 || length > ERROR_BODY_LIMIT || !response.body) {
285
+ return "";
286
+ }
287
+ const reader = response.body.getReader();
288
+ const decoder = new TextDecoder();
289
+ let total = 0;
290
+ let body = "";
291
+ try {
292
+ for (;;) {
293
+ const { value, done } = await reader.read();
294
+ if (done)
295
+ return body + decoder.decode();
296
+ total += value.byteLength;
297
+ if (total > ERROR_BODY_LIMIT) {
298
+ await reader.cancel();
299
+ return "";
300
+ }
301
+ body += decoder.decode(value, { stream: true });
302
+ }
303
+ }
304
+ finally {
305
+ reader.releaseLock();
306
+ }
141
307
  }
142
308
  async function apiError(response, surface = "DreamLayer Agent API") {
143
- let detail = null;
144
- let requestId = null;
309
+ let reason = reasonForStatus(response.status);
310
+ let code = null;
311
+ let requestId = publicRequestId(response.headers.get("x-request-id"));
145
312
  try {
146
- const length = Number(response.headers.get("content-length") ?? "0");
147
- if (!Number.isFinite(length) || length < 0 || length > ERROR_BODY_LIMIT) {
148
- return new ApiError(response.status, surface);
149
- }
150
- const parsed = JSON.parse(await response.text());
151
- if (isRecord(parsed)) {
152
- // Two shapes in the wild. The gateway returns
153
- // {"error":{"code","message","category","request_id"}}; older surfaces and
154
- // FastAPI's own handlers return {"detail": "..."}. Reading only the latter is
155
- // why every failure used to print a bare status code and nothing else.
156
- detail = sanitizedErrorDetail(parsed.detail);
157
- if (isRecord(parsed.error)) {
158
- detail = detail ?? sanitizedErrorDetail(parsed.error.message);
159
- requestId = sanitizedErrorDetail(parsed.error.request_id);
160
- }
313
+ const raw = await boundedErrorBody(response);
314
+ const parsed = raw ? JSON.parse(raw) : null;
315
+ if (isRecord(parsed) && isRecord(parsed.error)) {
316
+ // Treat only the closed reason/code/id fields as data. The local message table
317
+ // deliberately ignores arbitrary remote detail so a private upstream response
318
+ // cannot leak through a client even if a server regression serializes it.
319
+ reason = publicReason(parsed.error.reason) ?? reason;
320
+ code = publicIdentifier(parsed.error.code);
321
+ requestId = publicRequestId(parsed.error.request_id) ?? requestId;
161
322
  }
162
323
  }
163
324
  catch {
164
- detail = null;
325
+ // A malformed or oversized response still becomes a closed, status-derived error.
165
326
  }
166
- return new ApiError(response.status, surface, detail, requestId);
327
+ return new ApiError(response.status, surface, null, requestId, code, reason);
167
328
  }
168
329
  const MANAGED_EVENT_NAMES = new Set([
169
330
  "started",
@@ -175,6 +336,44 @@ const MANAGED_EVENT_NAMES = new Set([
175
336
  "done",
176
337
  ]);
177
338
  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;
339
+ function publicRequestId(value) {
340
+ return typeof value === "string" && UUID_PATTERN.test(value) ? value : null;
341
+ }
342
+ export function managedBalance(value) {
343
+ if (!isRecord(value))
344
+ throw new Error("Invalid DreamLayer balance response");
345
+ const exact = ["available", "credit_usd", "promotional", "purchased"];
346
+ if (Object.keys(value).sort().join("\0") !== exact.join("\0")) {
347
+ throw new Error("Invalid DreamLayer balance response");
348
+ }
349
+ for (const field of ["promotional", "purchased", "available"]) {
350
+ if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / 10) {
351
+ throw new Error("Invalid DreamLayer balance response");
352
+ }
353
+ }
354
+ if (value.credit_usd !== "0.17" ||
355
+ Math.round(Number(value.available) * 10) < Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) ||
356
+ Math.round(Number(value.available) * 10) > Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) + 1) {
357
+ throw new Error("Invalid DreamLayer balance response");
358
+ }
359
+ if ([value.promotional, value.purchased, value.available].some(v => Math.abs(Number(v) * 10 - Math.round(Number(v) * 10)) > 1e-7))
360
+ throw new Error("Invalid DreamLayer balance response");
361
+ return {
362
+ promotional: Number(value.promotional),
363
+ purchased: Number(value.purchased),
364
+ available: Number(value.available),
365
+ credit_usd: "0.17",
366
+ };
367
+ }
368
+ /** Convert a terminal job failure into the same safe contract used by HTTP errors. */
369
+ export function terminalExecutionError(execution) {
370
+ if (!isRecord(execution.image_job) || !isRecord(execution.image_job.sanitized_error)) {
371
+ return execution.status === "failed" ? new ApiError(500) : null;
372
+ }
373
+ const error = execution.image_job.sanitized_error;
374
+ const reason = publicReason(error.reason) ?? "generation_failed";
375
+ return new ApiError(statusForReason(reason), "DreamLayer execution", null, publicRequestId(error.request_id), publicIdentifier(error.code), reason);
376
+ }
178
377
  /**
179
378
  * Validate one sanitized event against the published contract.
180
379
  *
@@ -351,6 +550,7 @@ export class ManagedClient {
351
550
  * so a caller could not even resume what it had already paid for.
352
551
  */
353
552
  async *execute(input, options) {
553
+ validateSpriteInput(input);
354
554
  const stream = await this.fetchStream("/v1/execute", {
355
555
  method: "POST",
356
556
  headers: {
@@ -362,6 +562,50 @@ export class ManagedClient {
362
562
  });
363
563
  yield* this.parse(stream);
364
564
  }
565
+ /** Follow a durable job across finite streams without submitting it twice. */
566
+ async *follow(input, options) {
567
+ let executionId;
568
+ let cursor;
569
+ let stream = this.execute(input, options);
570
+ const deadline = Date.now() + 16 * 60_000;
571
+ let failures = 0;
572
+ while (Date.now() < deadline) {
573
+ try {
574
+ for await (const event of stream) {
575
+ if (event.event === "started")
576
+ executionId = String(event.data.execution_id);
577
+ if (event.id)
578
+ cursor = event.id;
579
+ yield event;
580
+ if (event.event === "done")
581
+ return;
582
+ }
583
+ failures = 0;
584
+ }
585
+ catch (error) {
586
+ if (error instanceof StreamIdleError)
587
+ throw error;
588
+ if (!executionId || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status)) || ++failures > 5)
589
+ throw error;
590
+ }
591
+ if (!executionId)
592
+ throw new Error("Execution stream ended before an identifier was received; reuse your idempotency key.");
593
+ const state = await this.getExecution(executionId);
594
+ if (["completed", "failed", "cancelled"].includes(state.status)) {
595
+ if (state.status === "completed") {
596
+ const assets = state.image_job?.finished_assets;
597
+ if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string")
598
+ throw new Error(`Execution ${executionId} has no downloadable asset yet.`);
599
+ yield managedEvent("asset", null, { asset_id: assets[0].asset_id, download_url: assets[0].download_url });
600
+ }
601
+ yield managedEvent("done", null, { status: state.status });
602
+ return;
603
+ }
604
+ await new Promise((resolve) => setTimeout(resolve, Math.min(5000, 500 * 2 ** failures)));
605
+ stream = this.events(executionId, cursor);
606
+ }
607
+ throw new Error(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`);
608
+ }
365
609
  /** Resume a stream after a drop. Pass the last event id you actually processed. */
366
610
  async *events(executionId, lastEventId) {
367
611
  const headers = { Accept: "text/event-stream" };
@@ -386,6 +630,9 @@ export class ManagedClient {
386
630
  getExecution(executionId) {
387
631
  return this.request(`/v1/executions/${encodeURIComponent(executionId)}`);
388
632
  }
633
+ async getBalance() {
634
+ return managedBalance(await this.request("/v1/balance"));
635
+ }
389
636
  cancel(executionId) {
390
637
  return this.request(`/v1/executions/${encodeURIComponent(executionId)}/cancel`, {
391
638
  method: "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamlayer",
3
- "version": "0.2.0",
3
+ "version": "0.4.0-beta.1",
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",