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 +15 -4
- package/dist/cli.js +31 -9
- package/dist/client.d.ts +18 -3
- package/dist/client.js +205 -35
- package/package.json +1 -1
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 |
|
|
56
|
+
| 4 | Non-retryable request or execution failure |
|
|
56
57
|
| 5 | Temporary, worth retrying |
|
|
57
58
|
| 6 | It asked a question instead of producing an image |
|
|
58
59
|
|
|
@@ -60,9 +61,19 @@ open "$(dreamlayer generate 'a fox logo')"
|
|
|
60
61
|
for f in shots/*.png; do dreamlayer cutout "$f" --out "cut/$(basename "$f")" || break; done
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
**`--json`** gives machine-readable output on stdout, carrying job state,
|
|
64
|
-
and the written path.
|
|
65
|
-
|
|
64
|
+
**`--json`** gives machine-readable success output on stdout, carrying job state,
|
|
65
|
+
execution ids, and the written path. Errors use the same stable `code`, `reason`,
|
|
66
|
+
`message`, `retryable`, and `request_id` fields as REST and are written to stderr, so
|
|
67
|
+
stdout stays result-only. Error envelopes deliberately exclude your prompt, images,
|
|
68
|
+
local filenames, and key. Successful image JSON includes the destination path you chose;
|
|
69
|
+
remove it before sharing if the local name is private.
|
|
70
|
+
|
|
71
|
+
Check the authenticated key's own balance without starting image work:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
dreamlayer balance
|
|
75
|
+
dreamlayer balance --json
|
|
76
|
+
```
|
|
66
77
|
|
|
67
78
|
## Retries are safe if you reuse the key
|
|
68
79
|
|
package/dist/cli.js
CHANGED
|
@@ -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.
|
|
235
|
+
if (error.reason === "authentication_failed" || error.reason === "access_denied")
|
|
230
236
|
return 2;
|
|
231
|
-
if (error.
|
|
237
|
+
if (error.reason === "insufficient_credits" || error.reason === "quota_exceeded")
|
|
232
238
|
return 3;
|
|
233
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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.
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
let
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
296
|
+
// A malformed or oversized response still becomes a closed, status-derived error.
|
|
165
297
|
}
|
|
166
|
-
return new ApiError(response.status, surface,
|
|
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",
|