dreamlayer 0.2.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
@@ -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
 
package/dist/cli.js CHANGED
@@ -18,7 +18,7 @@ import { randomUUID } from "node:crypto";
18
18
  import { openAsBlob, readFileSync } from "node:fs";
19
19
  import { stat, writeFile } from "node:fs/promises";
20
20
  import path from "node:path";
21
- import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, } from "./client.js";
21
+ import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
22
22
  import { Progress, consume } from "./render.js";
23
23
  const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
24
24
  const USAGE = `dreamlayer - generate and edit images from your terminal
@@ -30,6 +30,7 @@ USAGE
30
30
  dreamlayer upscale <image> [--out <file>]
31
31
  dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
32
32
  dreamlayer status <execution-id>
33
+ dreamlayer balance
33
34
  dreamlayer capabilities
34
35
 
35
36
  OPTIONS
@@ -146,7 +147,12 @@ async function run(api, input, options) {
146
147
  return 6;
147
148
  }
148
149
  if (outcome.status !== "completed" || !outcome.asset) {
149
- 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
+ }
150
156
  if (options.json)
151
157
  process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
152
158
  else
@@ -226,13 +232,11 @@ function warnIfOperationsDrifted(capabilities) {
226
232
  }
227
233
  }
228
234
  function exitCodeFor(error) {
229
- if (error.status === 401 || error.status === 403)
235
+ if (error.reason === "authentication_failed" || error.reason === "access_denied")
230
236
  return 2;
231
- if (error.status === 402)
237
+ if (error.reason === "insufficient_credits" || error.reason === "quota_exceeded")
232
238
  return 3;
233
- if (error.status === 409 || error.status === 422)
234
- return 4;
235
- return 5;
239
+ return error.retryable ? 5 : 4;
236
240
  }
237
241
  async function main(argv) {
238
242
  const [command, ...rest] = argv;
@@ -294,6 +298,19 @@ async function main(argv) {
294
298
  process.stdout.write(`${JSON.stringify(execution, null, 2)}\n`);
295
299
  return 0;
296
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
+ }
297
314
  case "capabilities": {
298
315
  const capabilities = await client().getCapabilities();
299
316
  process.stdout.write(`${JSON.stringify(capabilities, null, 2)}\n`);
@@ -315,12 +332,17 @@ main(process.argv.slice(2))
315
332
  return;
316
333
  }
317
334
  if (error instanceof ApiError) {
318
- const hint = error.status === 402
335
+ const hint = error.reason === "insufficient_credits"
319
336
  ? "Buy credits at https://platform.dreamlayer.io/console/billing"
320
337
  : error.retryable
321
338
  ? "Temporary. Retry with --idempotency-key to avoid paying twice."
322
339
  : "";
323
- 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
+ }
324
346
  process.exitCode = exitCodeFor(error);
325
347
  return;
326
348
  }
package/dist/client.d.ts CHANGED
@@ -66,17 +66,28 @@ export type ManagedExecution = {
66
66
  status: string;
67
67
  image_job: Record<string, unknown> | null;
68
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];
69
71
  export declare class ApiError extends Error {
70
72
  readonly status: number;
71
- readonly detail: string | null;
72
- /** Server-assigned id for this failure. The only handle support can search on. */
73
+ readonly detail: string;
73
74
  readonly requestId: string | null;
75
+ readonly code: string;
76
+ readonly reason: PublicErrorReason;
74
77
  constructor(status: number, surface?: string, detail?: string | null,
75
78
  /** Server-assigned id for this failure. The only handle support can search on. */
76
- requestId?: string | null);
79
+ requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
77
80
  /** Whether retrying with the same idempotency key is worth doing. */
78
81
  get retryable(): boolean;
82
+ /** The same stable fields exposed by REST and MCP, with no private response text. */
83
+ toPublicEnvelope(): Record<string, unknown>;
79
84
  }
85
+ export type ManagedBalance = {
86
+ promotional: number;
87
+ purchased: number;
88
+ available: number;
89
+ credit_usd: "0.17";
90
+ };
80
91
  /**
81
92
  * A stream that went silent, as distinct from a slow one.
82
93
  *
@@ -93,6 +104,9 @@ export declare class UploadTimeoutError extends Error {
93
104
  constructor();
94
105
  }
95
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;
96
110
  /**
97
111
  * Validate one sanitized event against the published contract.
98
112
  *
@@ -120,6 +134,7 @@ export declare class ManagedClient {
120
134
  events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
121
135
  getCapabilities(): Promise<Record<string, unknown>>;
122
136
  getExecution(executionId: string): Promise<ManagedExecution>;
137
+ getBalance(): Promise<ManagedBalance>;
123
138
  cancel(executionId: string): Promise<ManagedExecution>;
124
139
  listConversations(): Promise<Array<Record<string, unknown>>>;
125
140
  deleteConversation(conversationId: string): Promise<void>;
package/dist/client.js CHANGED
@@ -41,24 +41,145 @@ const LEGACY_INPUT_SUFFIXES = new Set([
41
41
  ".arw", ".cr2", ".cr3", ".crw", ".dng", ".nef", ".nrw", ".orf", ".pef",
42
42
  ".raf", ".rw2", ".sr2", ".srw",
43
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
+ }
44
150
  export class ApiError extends Error {
45
151
  status;
46
152
  detail;
47
153
  requestId;
154
+ code;
155
+ reason;
48
156
  constructor(status, surface = "DreamLayer Agent API", detail = null,
49
157
  /** 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})`);
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})` : ""}`);
54
161
  this.status = status;
55
- this.detail = detail;
56
- this.requestId = requestId;
57
162
  this.name = "ApiError";
163
+ this.detail = safeDetail;
164
+ this.requestId = requestId;
165
+ this.code = code ?? defaultCode(reason);
166
+ this.reason = reason;
58
167
  }
59
168
  /** Whether retrying with the same idempotency key is worth doing. */
60
169
  get retryable() {
61
- 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
+ };
62
183
  }
63
184
  }
64
185
  /**
@@ -84,7 +205,6 @@ export class UploadTimeoutError extends Error {
84
205
  }
85
206
  }
86
207
  const ERROR_BODY_LIMIT = 16 * 1024;
87
- const ERROR_DETAIL_LIMIT = 300;
88
208
  /**
89
209
  * A plain request: send, get a body back. Bounded work, so a total cap is right.
90
210
  */
@@ -130,40 +250,52 @@ const STREAM_IDLE_TIMEOUT_MS = (() => {
130
250
  function isRecord(value) {
131
251
  return typeof value === "object" && value !== null && !Array.isArray(value);
132
252
  }
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;
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
+ }
141
278
  }
142
279
  async function apiError(response, surface = "DreamLayer Agent API") {
143
- let detail = null;
144
- let requestId = null;
280
+ let reason = reasonForStatus(response.status);
281
+ let code = null;
282
+ let requestId = publicRequestId(response.headers.get("x-request-id"));
145
283
  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
- }
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;
161
293
  }
162
294
  }
163
295
  catch {
164
- detail = null;
296
+ // A malformed or oversized response still becomes a closed, status-derived error.
165
297
  }
166
- return new ApiError(response.status, surface, detail, requestId);
298
+ return new ApiError(response.status, surface, null, requestId, code, reason);
167
299
  }
168
300
  const MANAGED_EVENT_NAMES = new Set([
169
301
  "started",
@@ -175,6 +307,41 @@ const MANAGED_EVENT_NAMES = new Set([
175
307
  "done",
176
308
  ]);
177
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
+ }
178
345
  /**
179
346
  * Validate one sanitized event against the published contract.
180
347
  *
@@ -386,6 +553,9 @@ export class ManagedClient {
386
553
  getExecution(executionId) {
387
554
  return this.request(`/v1/executions/${encodeURIComponent(executionId)}`);
388
555
  }
556
+ async getBalance() {
557
+ return managedBalance(await this.request("/v1/balance"));
558
+ }
389
559
  cancel(executionId) {
390
560
  return this.request(`/v1/executions/${encodeURIComponent(executionId)}/cancel`, {
391
561
  method: "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamlayer",
3
- "version": "0.2.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",