craterview 0.4.16 → 0.4.18
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 +17 -9
- package/index.ts +303 -57
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,6 +86,10 @@ const job = await cv.run(image, { // Blob | ArrayBuffer | Uint8Array
|
|
|
86
86
|
It waits server-side first, so a job that finishes quickly costs a single round trip, then
|
|
87
87
|
falls back to polling for anything slower. A job that fails throws `JobFailed`.
|
|
88
88
|
|
|
89
|
+
Past `timeoutMs` only the waiting stops. The job carries on and is charged only if it
|
|
90
|
+
succeeds; the `CraterViewError` thrown carries its `.jobId`, and `cv.waitFor(error.jobId)`
|
|
91
|
+
collects it. Calling `run()` again would start, and bill, a second job.
|
|
92
|
+
|
|
89
93
|
Which parameters a model accepts is published by the API rather than hard-coded here — see
|
|
90
94
|
`cv.models()`.
|
|
91
95
|
|
|
@@ -105,9 +109,12 @@ a presigned URL and `blob()` GETs the result the same way; the API moves keys, n
|
|
|
105
109
|
|
|
106
110
|
## Retries are yours to write
|
|
107
111
|
|
|
108
|
-
**
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
**Outside `run()` and `waitFor()`, this client does not retry.** `run()` asks its submission
|
|
113
|
+
once more, under one idempotency key, when the first got no answer or a failure on the
|
|
114
|
+
platform's side, and `waitFor()` reads on through the same until its timeout; neither can make a
|
|
115
|
+
second job. Every other call is sent once. That is deliberate: only the caller knows where one
|
|
116
|
+
logical request ends and the next begins, and a retry the client invented would be a second
|
|
117
|
+
job you are billed for.
|
|
111
118
|
|
|
112
119
|
What it gives you instead is a key generator. Make one key per *logical request* and reuse
|
|
113
120
|
it for every attempt at that request:
|
|
@@ -139,15 +146,16 @@ the same job.
|
|
|
139
146
|
Every answer the API or storage gives that is not a success is thrown as a
|
|
140
147
|
`CraterViewError`. `.status` carries the HTTP status where there was one, and `.traceId` the
|
|
141
148
|
API's `X-Trace-Id` for a request it answered and refused — the id to quote when you ask us
|
|
142
|
-
about it. A request that got
|
|
149
|
+
about it. `.jobId` names the job an error is about, where there is one. A request that got
|
|
143
150
|
no answer at all throws the runtime's own error from `fetch`, which is how the retry loop
|
|
144
151
|
above tells the two apart.
|
|
145
152
|
|
|
146
153
|
| Class | Meaning |
|
|
147
154
|
|---|---|
|
|
148
155
|
| `RateLimited` | 429 — one of five limits, and the message says which: the key's request rate, the account's upload URLs a minute, the account's new keys an hour, the account's jobs in flight, or a full queue. `.retryAfter` is seconds to wait: until the window rolls over for the two per-minute limits, until a new key can be made for the hourly one, or an interval to poll on for the other two. |
|
|
149
|
-
| `JobFailed` | The job ran and did not succeed. `.message` says what you can do about it; `.errorCode` is the half to branch on. |
|
|
150
|
-
| `
|
|
156
|
+
| `JobFailed` | The job ran and did not succeed. `.message` says what you can do about it; `.errorCode` is the half to branch on; `.jobId` is the job. |
|
|
157
|
+
| `InvalidSignature` | A webhook delivery did not verify. Do not act on its body. |
|
|
158
|
+
| `CraterViewError` | Everything else, including 4xx and 5xx from the API, and storage refusing an upload or a download. |
|
|
151
159
|
|
|
152
160
|
These subclass `Error` under an ES2020 target so `instanceof` works. Compiling this package
|
|
153
161
|
down to ES5 silently breaks that, and `catch (e) { if (e instanceof RateLimited) }` — the
|
|
@@ -170,7 +178,6 @@ Every field the API publishes on a job is exposed here.
|
|
|
170
178
|
| `outputUrl`, `downloadUrl` | The result, presigned. One to display, one to save |
|
|
171
179
|
| `thumbnailUrl` | A small JPEG of the job's picture, for listings — the result, or what the model worked from when it produced no file. Null when none was drawn |
|
|
172
180
|
| `inputUrl` | The picture the model worked from — the region, where you named one |
|
|
173
|
-
| `alphaUrl` | Only for a JPEG result of a picture with transparency, which JPEG cannot hold: the transparency as a grayscale JPEG, white where opaque. The result is then the colour alone |
|
|
174
181
|
| `contentType` | The result's media type |
|
|
175
182
|
| `outputBytes` | The result's size in bytes |
|
|
176
183
|
| `blob()`, `arrayBuffer()` | Download the result |
|
|
@@ -309,8 +316,9 @@ new CraterView({
|
|
|
309
316
|
|
|
310
317
|
`baseUrl` is what you change to point at a local server. `timeoutMs` is how long to wait for
|
|
311
318
|
the API to answer before giving up with the runtime's `TimeoutError` — nothing answered, so
|
|
312
|
-
it is a request to repeat with the same idempotency key.
|
|
313
|
-
|
|
319
|
+
it is a request to repeat with the same idempotency key. `run()` uploads afresh each time, so
|
|
320
|
+
from it look for the job in `cv.jobs()` instead of running it again. Uploads and downloads go
|
|
321
|
+
to storage and have five minutes.
|
|
314
322
|
|
|
315
323
|
## From Claude Code
|
|
316
324
|
|
package/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { imageSize } from "image-size";
|
|
|
18
18
|
// Mirrored from package.json, which is the number a release bumps. It cannot be imported
|
|
19
19
|
// from there — this ships as TypeScript, so the import would have to resolve in the
|
|
20
20
|
// consumer's toolchain — so test/version.test.ts asserts the two agree.
|
|
21
|
-
export const VERSION = "0.4.
|
|
21
|
+
export const VERSION = "0.4.18";
|
|
22
22
|
const DEFAULT_BASE_URL = "https://api.craterview.ai";
|
|
23
23
|
// The server rejects a longer wait outright, so asking for one costs a 422 rather than the
|
|
24
24
|
// wait you asked for. `run()` clamps to this rather than letting that happen.
|
|
@@ -26,6 +26,12 @@ const MAX_SERVER_WAIT = 30;
|
|
|
26
26
|
// The largest page the job history serves. Same reasoning: a bigger `limit` is refused, so
|
|
27
27
|
// `jobs()` clamps rather than spending a request to be told no.
|
|
28
28
|
const MAX_PAGE = 200;
|
|
29
|
+
// The shape of each kind of id the API names in a path, exactly as it issues them. A method
|
|
30
|
+
// that puts an id in its path takes one of these and nothing else, because anything else builds
|
|
31
|
+
// some other request than the one the method names: `..` is a dot segment that fetch resolves
|
|
32
|
+
// before sending, so `revokeKey("..")` would be `DELETE /v1/`, and encoding does not help,
|
|
33
|
+
// since `%2e%2e` is resolved the same way.
|
|
34
|
+
const IDS = { job: /^job_[0-9a-f]{24}$/, key: /^key_[0-9a-f]{24}$/ } as const;
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
37
|
* A random key for safely repeating a submission.
|
|
@@ -51,7 +57,8 @@ const MAX_PAGE = 200;
|
|
|
51
57
|
* Reusing a key with a *different* request body is rejected with 409 rather than silently
|
|
52
58
|
* returning the earlier result.
|
|
53
59
|
*
|
|
54
|
-
*
|
|
60
|
+
* `submit()` does not retry on your behalf: the loop above is yours to write. `run()`, which is
|
|
61
|
+
* one whole request from upload to result, keeps a key of its own and asks once more.
|
|
55
62
|
*/
|
|
56
63
|
export function newIdempotencyKey(): string {
|
|
57
64
|
const bytes = new Uint8Array(24);
|
|
@@ -62,13 +69,79 @@ export function newIdempotencyKey(): string {
|
|
|
62
69
|
return `idem_${base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The media type a file's bytes are, from the signature at their start, or `""` for one this
|
|
74
|
+
* does not recognise. Formats the API does not take are named too, so that a refusal says
|
|
75
|
+
* what the file is rather than that it is unknown.
|
|
76
|
+
*/
|
|
77
|
+
export async function sniffType(blob: Blob): Promise<string> {
|
|
78
|
+
const b = new Uint8Array(await blob.slice(0, 64).arrayBuffer());
|
|
79
|
+
const at = (offset: number, text: string) =>
|
|
80
|
+
[...text].every((c, i) => b[offset + i] === c.charCodeAt(0));
|
|
81
|
+
const starts = (...bytes: number[]) => bytes.every((x, i) => b[i] === x);
|
|
82
|
+
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
|
83
|
+
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
|
84
|
+
if (at(0, "RIFF") && at(8, "WEBP")) return "image/webp";
|
|
85
|
+
if (starts(0x49, 0x49, 0x2a, 0x00) || starts(0x4d, 0x4d, 0x00, 0x2a)
|
|
86
|
+
|| starts(0x49, 0x49, 0x2b, 0x00) || starts(0x4d, 0x4d, 0x00, 0x2b)) return "image/tiff";
|
|
87
|
+
if (starts(0xff, 0x0a) || (starts(0, 0, 0, 0x0c) && at(4, "JXL "))) return "image/jxl";
|
|
88
|
+
if (at(0, "GIF87a") || at(0, "GIF89a")) return "image/gif";
|
|
89
|
+
if (at(4, "ftyp")) {
|
|
90
|
+
// An ISO base media file names its brands: the major one, then the compatible ones to
|
|
91
|
+
// the end of the `ftyp` box. An AVIF may declare the generic `mif1` first and `avif`
|
|
92
|
+
// among the rest, so every brand is read before deciding.
|
|
93
|
+
const word = (o: number) => String.fromCharCode(...b.slice(o, o + 4));
|
|
94
|
+
const end = Math.min(new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(0), b.length);
|
|
95
|
+
const major = word(8);
|
|
96
|
+
const brands = [major];
|
|
97
|
+
for (let o = 16; o + 4 <= end; o += 4) brands.push(word(o));
|
|
98
|
+
if (brands.includes("avif") || brands.includes("avis")) return "image/avif";
|
|
99
|
+
if (["heic", "heix", "heim", "heis"].includes(major)) return "image/heic";
|
|
100
|
+
if (["mif1", "mif2", "msf1"].includes(major)) return "image/heif";
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `value`, if it is a `kind` id as the API issues them; otherwise a {@link CraterViewError}. */
|
|
106
|
+
function issued(kind: keyof typeof IDS, value: unknown, argument: string): string {
|
|
107
|
+
if (typeof value === "string" && IDS[kind].test(value)) return value;
|
|
108
|
+
throw new CraterViewError(`${argument} must be a ${kind} id as the API returned it — ${kind}_ `
|
|
109
|
+
+ `and 24 hexadecimal digits — not ${JSON.stringify(value)}; nothing was sent`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** How long `run()` waits before asking a submission again: the pause the web page, the phone
|
|
113
|
+
* app and the assistant connector take. */
|
|
114
|
+
const RESUBMIT_AFTER_MS = 2_000;
|
|
115
|
+
|
|
116
|
+
const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
117
|
+
|
|
118
|
+
/** No answer, or a failure on the platform's side or on the way to it: asked again, the request
|
|
119
|
+
* may be answered. `fetch` rejects with a `TypeError` only when nothing came back. A refusal, a
|
|
120
|
+
* 4xx, is the same answer the next time. */
|
|
121
|
+
function worthAskingAgain(e: unknown): boolean {
|
|
122
|
+
return (e as Error)?.name === "TimeoutError" || e instanceof TypeError
|
|
123
|
+
|| (e instanceof CraterViewError && (e.status ?? 0) >= 500);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The API's own 503 to a submission: the one status it answers before anything is recorded,
|
|
127
|
+
* so nothing was queued or charged. A 503 page from something on the way carries no trace id
|
|
128
|
+
* and says nothing about the job. */
|
|
129
|
+
function queuedNothing(e: unknown): boolean {
|
|
130
|
+
return e instanceof CraterViewError && e.status === 503 && e.traceId !== undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
/**
|
|
66
134
|
* `status` is the HTTP status where there was one. `traceId` is the API's `X-Trace-Id` for a
|
|
67
135
|
* request it answered and refused — the id to quote when asking about it. Absent when the
|
|
68
136
|
* refusal came from storage rather than the API.
|
|
137
|
+
*
|
|
138
|
+
* `jobId` is the job an error is about, where there is one: a job that failed, one whose wait
|
|
139
|
+
* ran out while it carried on, or one whose result could not be fetched. Quote it when asking
|
|
140
|
+
* about the job, and collect a job still going by it.
|
|
69
141
|
*/
|
|
70
142
|
export class CraterViewError extends Error {
|
|
71
|
-
constructor(message: string, readonly status?: number, readonly traceId?: string
|
|
143
|
+
constructor(message: string, readonly status?: number, readonly traceId?: string,
|
|
144
|
+
readonly jobId?: string) {
|
|
72
145
|
super(message);
|
|
73
146
|
this.name = "CraterViewError";
|
|
74
147
|
}
|
|
@@ -108,11 +181,12 @@ export class RateLimited extends CraterViewError {
|
|
|
108
181
|
* ours, nothing was charged, and the same call is worth making again.
|
|
109
182
|
*
|
|
110
183
|
* An unfamiliar code is a failure with no special handling, not an error in itself: new ones
|
|
111
|
-
* appear as new things become worth telling apart.
|
|
184
|
+
* appear as new things become worth telling apart. `jobId` is the job, to quote when asking
|
|
185
|
+
* about it.
|
|
112
186
|
*/
|
|
113
187
|
export class JobFailed extends CraterViewError {
|
|
114
|
-
constructor(message: string, readonly errorCode?: string) {
|
|
115
|
-
super(message);
|
|
188
|
+
constructor(message: string, readonly errorCode?: string, jobId?: string) {
|
|
189
|
+
super(message, undefined, undefined, jobId);
|
|
116
190
|
this.name = "JobFailed";
|
|
117
191
|
}
|
|
118
192
|
}
|
|
@@ -134,7 +208,12 @@ export const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
|
134
208
|
* header, a timestamp too old — because from a receiver's point of view they are one
|
|
135
209
|
* outcome: this is not something we sent, so it does not get to do anything.
|
|
136
210
|
*/
|
|
137
|
-
export class InvalidSignature extends CraterViewError {
|
|
211
|
+
export class InvalidSignature extends CraterViewError {
|
|
212
|
+
constructor(message: string) {
|
|
213
|
+
super(message);
|
|
214
|
+
this.name = "InvalidSignature";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
138
217
|
|
|
139
218
|
/**
|
|
140
219
|
* Check that a callback really came from CraterView, and return its parsed body.
|
|
@@ -162,6 +241,12 @@ export async function verifyWebhook(
|
|
|
162
241
|
options: { toleranceSeconds?: number } = {},
|
|
163
242
|
): Promise<WebhookEvent> {
|
|
164
243
|
const { toleranceSeconds = DEFAULT_TOLERANCE_SECONDS } = options;
|
|
244
|
+
// Before the delivery is looked at: an unset secret is this receiver's configuration, not a
|
|
245
|
+
// delivery that failed to check out, and refusing every delivery as forged would hide it.
|
|
246
|
+
if (!secret) {
|
|
247
|
+
throw new CraterViewError("verifyWebhook was given no secret: pass the one "
|
|
248
|
+
+ "webhookSecret() returns, or the one on the dashboard");
|
|
249
|
+
}
|
|
165
250
|
|
|
166
251
|
const signature = readHeader(headers, SIGNATURE_HEADER);
|
|
167
252
|
const timestamp = readHeader(headers, TIMESTAMP_HEADER);
|
|
@@ -169,21 +254,30 @@ export async function verifyWebhook(
|
|
|
169
254
|
throw new InvalidSignature("delivery is missing its signature headers");
|
|
170
255
|
}
|
|
171
256
|
|
|
172
|
-
|
|
173
|
-
|
|
257
|
+
// Whole seconds in ASCII digits, as the platform writes it, and nothing else: `Number()`
|
|
258
|
+
// would also take whitespace of any script, a sign, an exponent or hex. Fifteen digits is
|
|
259
|
+
// past any time a clock will show.
|
|
260
|
+
if (!/^[0-9]{1,15}$/.test(timestamp)) {
|
|
174
261
|
throw new InvalidSignature("delivery has an unreadable timestamp");
|
|
175
262
|
}
|
|
263
|
+
const sentAt = Number(timestamp);
|
|
176
264
|
// Both directions. A clock ahead of ours is as much a reason to distrust a delivery as one
|
|
177
265
|
// behind it, and only checking the past accepts anything dated next year.
|
|
178
266
|
const age = Date.now() / 1000 - sentAt;
|
|
179
|
-
if (toleranceSeconds &&
|
|
180
|
-
throw new InvalidSignature(`delivery
|
|
267
|
+
if (toleranceSeconds && age > toleranceSeconds) {
|
|
268
|
+
throw new InvalidSignature(`delivery was signed ${age.toFixed(0)}s ago, longer ago than the `
|
|
269
|
+
+ `${toleranceSeconds}s allowed: a copy replayed late, or a clock here that is wrong`);
|
|
270
|
+
}
|
|
271
|
+
if (toleranceSeconds && -age > toleranceSeconds) {
|
|
272
|
+
throw new InvalidSignature(`delivery is dated ${(-age).toFixed(0)}s ahead of this machine's `
|
|
273
|
+
+ `clock, more than the ${toleranceSeconds}s allowed: check the clock here`);
|
|
181
274
|
}
|
|
182
275
|
|
|
183
276
|
const bytes = toBytes(body);
|
|
184
|
-
const
|
|
185
|
-
signed
|
|
186
|
-
signed.set(
|
|
277
|
+
const prefix = new TextEncoder().encode(`${timestamp}.`);
|
|
278
|
+
const signed = new Uint8Array(prefix.length + bytes.length);
|
|
279
|
+
signed.set(prefix, 0);
|
|
280
|
+
signed.set(bytes, prefix.length);
|
|
187
281
|
|
|
188
282
|
const key = await crypto.subtle.importKey(
|
|
189
283
|
"raw", new TextEncoder().encode(secret),
|
|
@@ -318,6 +412,11 @@ export interface ModelInfo {
|
|
|
318
412
|
credits_per_video_second?: number | null;
|
|
319
413
|
reference_fps: number;
|
|
320
414
|
params_schema: Record<string, unknown>;
|
|
415
|
+
/**
|
|
416
|
+
* Every name in `params_schema`'s `properties`, in the order a form presents them. JSON does
|
|
417
|
+
* not order an object's keys, so a form drawn from the schema reads its order from here.
|
|
418
|
+
*/
|
|
419
|
+
params_order: string[];
|
|
321
420
|
/**
|
|
322
421
|
* JSON Schema for the fields a finished job of this model states under `result`, beside
|
|
323
422
|
* any files it links. Empty for a model whose whole answer is its file.
|
|
@@ -431,8 +530,9 @@ export interface JobData {
|
|
|
431
530
|
community?: boolean;
|
|
432
531
|
/**
|
|
433
532
|
* Whether an automated check thought this job's images may fall outside what the
|
|
434
|
-
* service allows: the
|
|
435
|
-
*
|
|
533
|
+
* service allows: the part of your image the job worked on (all of it, unless you
|
|
534
|
+
* named a region), and for some jobs the one that came back. Absent means they were
|
|
535
|
+
* not all checked.
|
|
436
536
|
*/
|
|
437
537
|
flagged?: boolean | null;
|
|
438
538
|
}
|
|
@@ -456,8 +556,6 @@ export interface JobData {
|
|
|
456
556
|
* worked from when it produced no file — for showing a page of jobs without downloading a
|
|
457
557
|
* page of full-size pictures. `inputUrl` is the picture the model worked from, so a result
|
|
458
558
|
* can be shown against what it was made from. Both expire with the job's other pictures.
|
|
459
|
-
* `alphaUrl` is there only when you asked for a JPEG of a picture with transparency: the
|
|
460
|
-
* transparency, as a file of its own.
|
|
461
559
|
*
|
|
462
560
|
* `etaSeconds` is the whole of what is reported about waiting: how long until the result,
|
|
463
561
|
* counting time spent waiting for a GPU as well as time spent on one. An estimate and never
|
|
@@ -533,18 +631,6 @@ export class Job {
|
|
|
533
631
|
? ((given as Record<string, unknown>)["url"] as string) ?? null
|
|
534
632
|
: null;
|
|
535
633
|
}
|
|
536
|
-
/**
|
|
537
|
-
* The transparency of a result you asked for as JPEG, which cannot hold it: a grayscale
|
|
538
|
-
* JPEG the size of the result, white where it is opaque and black where it is transparent.
|
|
539
|
-
* The result is then the colour alone, not placed on any background, so the two together
|
|
540
|
-
* are the picture. Null for every other result.
|
|
541
|
-
*/
|
|
542
|
-
get alphaUrl() {
|
|
543
|
-
const mask = (this.data.result ?? {})["alpha"];
|
|
544
|
-
return (mask && typeof mask === "object")
|
|
545
|
-
? ((mask as Record<string, unknown>)["url"] as string) ?? null
|
|
546
|
-
: null;
|
|
547
|
-
}
|
|
548
634
|
get contentType() { return (this.output["content_type"] as string) ?? null; }
|
|
549
635
|
get outputBytes() { return (this.output["bytes"] as number) ?? null; }
|
|
550
636
|
|
|
@@ -566,16 +652,40 @@ export class Job {
|
|
|
566
652
|
/** Download the result. */
|
|
567
653
|
async blob(): Promise<Blob> {
|
|
568
654
|
const url = this.outputUrl;
|
|
569
|
-
if (!url)
|
|
570
|
-
throw new CraterViewError(`job ${this.id} has no result (status ${this.status})`);
|
|
571
|
-
}
|
|
655
|
+
if (!url) throw new CraterViewError(this.noFile(), undefined, undefined, this.id);
|
|
572
656
|
const resp = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
|
|
657
|
+
// A link is as old as the read that brought it, and storage answers one past its expiry
|
|
658
|
+
// with 403 — so that is what a 403 almost always is, and a fresh read the fix.
|
|
659
|
+
if (resp.status === 403) {
|
|
660
|
+
throw new CraterViewError(`storage refused the result of job ${this.id} (HTTP 403): its `
|
|
661
|
+
+ `link has most likely expired. Read the job again, cv.job("${this.id}"), for fresh `
|
|
662
|
+
+ `links.`, 403, undefined, this.id);
|
|
663
|
+
}
|
|
573
664
|
if (!resp.ok) {
|
|
574
|
-
throw new CraterViewError(`
|
|
665
|
+
throw new CraterViewError(`storage refused the result of job ${this.id}: HTTP `
|
|
666
|
+
+ `${resp.status}`, resp.status, undefined, this.id);
|
|
575
667
|
}
|
|
576
668
|
return await resp.blob();
|
|
577
669
|
}
|
|
578
670
|
|
|
671
|
+
/** Why there is no file to download, which depends on where the job stands. */
|
|
672
|
+
private noFile(): string {
|
|
673
|
+
if (!this.done) {
|
|
674
|
+
return `job ${this.id} has no result yet: it is still ${this.status}. Wait for it with `
|
|
675
|
+
+ `waitFor() first.`;
|
|
676
|
+
}
|
|
677
|
+
if (this.status === "failed") {
|
|
678
|
+
const code = this.errorCode ? ` (${this.errorCode})` : "";
|
|
679
|
+
return `job ${this.id} failed, so it has no result: `
|
|
680
|
+
+ `${this.error ?? "no reason was given"}${code}`;
|
|
681
|
+
}
|
|
682
|
+
// Succeeded, with no file. Either its model answers in `result` alone, or the file has
|
|
683
|
+
// gone; a job read after its files expire looks the same as one that never had any, so
|
|
684
|
+
// both are said rather than one guessed at.
|
|
685
|
+
return `job ${this.id} has no file to download: its model answers in \`result\` alone, `
|
|
686
|
+
+ "or its file has expired or been deleted";
|
|
687
|
+
}
|
|
688
|
+
|
|
579
689
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
580
690
|
return await (await this.blob()).arrayBuffer();
|
|
581
691
|
}
|
|
@@ -587,8 +697,10 @@ export interface CraterViewOptions {
|
|
|
587
697
|
/**
|
|
588
698
|
* Milliseconds to wait for the API to answer a request before giving up, 60 000 by default.
|
|
589
699
|
* A request that times out throws the runtime's `TimeoutError`, not a `CraterViewError`:
|
|
590
|
-
* nothing answered, so it is one to repeat with the same idempotency key
|
|
591
|
-
*
|
|
700
|
+
* nothing answered, so it is one to repeat with the same idempotency key — except from
|
|
701
|
+
* `run()`, which uploads afresh each time, where the message says to look in `jobs()` for
|
|
702
|
+
* the job instead. Uploads and downloads go to storage and are given five minutes whatever
|
|
703
|
+
* this says.
|
|
592
704
|
*/
|
|
593
705
|
timeoutMs?: number;
|
|
594
706
|
}
|
|
@@ -640,6 +752,26 @@ export interface SubmitOptions {
|
|
|
640
752
|
[param: string]: unknown;
|
|
641
753
|
}
|
|
642
754
|
|
|
755
|
+
/**
|
|
756
|
+
* The API's `detail` as one sentence, or `undefined` where it holds no words.
|
|
757
|
+
*
|
|
758
|
+
* Usually a string. A request its validator refuses comes back as a list instead, one entry
|
|
759
|
+
* per field, which reads here as "field: what is wrong" — and without the validator's own
|
|
760
|
+
* "Value error, " in front of a sentence the API wrote for a reader.
|
|
761
|
+
*/
|
|
762
|
+
function spoken(detail: unknown): string | undefined {
|
|
763
|
+
if (typeof detail === "string") return detail || undefined;
|
|
764
|
+
if (!Array.isArray(detail)) return undefined;
|
|
765
|
+
const said = detail
|
|
766
|
+
.filter((e): e is { loc?: unknown[]; msg: string } => typeof e?.msg === "string" && !!e.msg)
|
|
767
|
+
.map(({ loc, msg }) => {
|
|
768
|
+
const message = msg.replace(/^Value error, /, "");
|
|
769
|
+
// A location of one part — the body as a whole — names no field worth printing.
|
|
770
|
+
return Array.isArray(loc) && loc.length > 1 ? `${loc[loc.length - 1]}: ${message}` : message;
|
|
771
|
+
});
|
|
772
|
+
return said.join("; ") || undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
643
775
|
export class CraterView {
|
|
644
776
|
private readonly baseUrl: string;
|
|
645
777
|
private readonly headers: Record<string, string>;
|
|
@@ -666,9 +798,9 @@ export class CraterView {
|
|
|
666
798
|
body: body ? JSON.stringify(body) : undefined,
|
|
667
799
|
});
|
|
668
800
|
|
|
801
|
+
const traceId = resp.headers.get("X-Trace-Id") ?? undefined;
|
|
669
802
|
if (!resp.ok) {
|
|
670
803
|
const detail = await this.detail(resp);
|
|
671
|
-
const traceId = resp.headers.get("X-Trace-Id") ?? undefined;
|
|
672
804
|
if (resp.status === 429) {
|
|
673
805
|
throw new RateLimited(detail, Number(resp.headers.get("Retry-After") ?? 0), traceId);
|
|
674
806
|
}
|
|
@@ -676,16 +808,38 @@ export class CraterView {
|
|
|
676
808
|
}
|
|
677
809
|
// A 204 carries no body, so asking for JSON throws on a call that succeeded.
|
|
678
810
|
if (resp.status === 204) return undefined as T;
|
|
679
|
-
|
|
811
|
+
try {
|
|
812
|
+
return await resp.json() as T;
|
|
813
|
+
} catch {
|
|
814
|
+
throw new CraterViewError(this.notTheApi(resp.status), resp.status, traceId);
|
|
815
|
+
}
|
|
680
816
|
}
|
|
681
817
|
|
|
818
|
+
/** The API's own words for a refusal, or a sentence saying the answer was not its. */
|
|
682
819
|
private async detail(resp: Response): Promise<string> {
|
|
820
|
+
let body: unknown;
|
|
683
821
|
try {
|
|
684
|
-
|
|
685
|
-
return body.detail ?? `HTTP ${resp.status}`;
|
|
822
|
+
body = await resp.json();
|
|
686
823
|
} catch {
|
|
687
|
-
return
|
|
824
|
+
return this.notTheApi(resp.status);
|
|
825
|
+
}
|
|
826
|
+
return spoken((body as { detail?: unknown } | null)?.detail) ?? this.notTheApi(resp.status);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* For an answer that is not the API's JSON: an error page from something between here and
|
|
831
|
+
* the API, or a `baseUrl` that names something else. Its body is left out — a page of markup
|
|
832
|
+
* buries the one line worth reading.
|
|
833
|
+
*/
|
|
834
|
+
private notTheApi(status: number): string {
|
|
835
|
+
const said = `${new URL(this.baseUrl).host} answered HTTP ${status} with something other `
|
|
836
|
+
+ "than the API's JSON";
|
|
837
|
+
if (status >= 500) {
|
|
838
|
+
return `${said}: a failure on the way to the API rather than a refusal. Worth repeating — `
|
|
839
|
+
+ "with the same idempotency key, for a submission, so it cannot start a second job.";
|
|
688
840
|
}
|
|
841
|
+
if (status === 429) return `${said}.`;
|
|
842
|
+
return `${said}; check that baseUrl is the API's address (${DEFAULT_BASE_URL} by default).`;
|
|
689
843
|
}
|
|
690
844
|
|
|
691
845
|
/**
|
|
@@ -713,12 +867,21 @@ export class CraterView {
|
|
|
713
867
|
* key, so `submit()` can tell the API what you sent and `eta_seconds` is estimated for
|
|
714
868
|
* your image rather than a typical one. A file the reader does not know is uploaded just
|
|
715
869
|
* the same; only the estimate is less specific.
|
|
870
|
+
*
|
|
871
|
+
* The type sent is `contentType` when given, else the one the bytes' signature shows, else
|
|
872
|
+
* the Blob's own, else none — an undeclared upload is accepted and identified when the job
|
|
873
|
+
* runs. The bytes come before the Blob's type because that is usually read off the file's
|
|
874
|
+
* name, and a misnamed file is then refused here rather than as a job. A type the API does not take is refused by the first request, before any
|
|
875
|
+
* bytes are sent, with the list of what it does take.
|
|
716
876
|
*/
|
|
717
877
|
async upload(image: Blob | ArrayBuffer | Uint8Array, contentType?: string): Promise<string> {
|
|
718
|
-
const blob = image instanceof Blob
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
878
|
+
const blob = image instanceof Blob ? image : new Blob([image as BlobPart]);
|
|
879
|
+
// Sent, it would come back as a refusal of `content_length`, a field the caller never
|
|
880
|
+
// wrote. Said here in their terms instead, before anything is requested.
|
|
881
|
+
if (!blob.size) {
|
|
882
|
+
throw new CraterViewError("the image given is empty (0 bytes): there is nothing to upload");
|
|
883
|
+
}
|
|
884
|
+
const type = contentType || await sniffType(blob) || blob.type || "application/octet-stream";
|
|
722
885
|
|
|
723
886
|
// Declared, then signed into the URL, so it has to be the size actually sent — storage
|
|
724
887
|
// refuses any other with a signature error. Taken from the Blob rather than trusted from
|
|
@@ -740,7 +903,8 @@ export class CraterView {
|
|
|
740
903
|
}
|
|
741
904
|
|
|
742
905
|
/**
|
|
743
|
-
* Queue a job. `wait` holds the response open for a settled result
|
|
906
|
+
* Queue a job. `wait` holds the response open for a settled result, for up to that many
|
|
907
|
+
* seconds; the API holds one for at most 30, so a longer wait is asked as 30.
|
|
744
908
|
*
|
|
745
909
|
* Pass `idempotencyKey` — see `newIdempotencyKey` — if you intend to retry this
|
|
746
910
|
* submission. Without one, a repeat after a failed or uncertain request starts a second
|
|
@@ -755,7 +919,11 @@ export class CraterView {
|
|
|
755
919
|
const headers: Record<string, string> = idempotencyKey
|
|
756
920
|
? { "Idempotency-Key": idempotencyKey }
|
|
757
921
|
: {};
|
|
758
|
-
|
|
922
|
+
// Held to the API's ceiling, so a longer wait is a long poll rather than a refusal. None at
|
|
923
|
+
// all is the API's own default, and is left unsaid.
|
|
924
|
+
const held = Math.min(wait, MAX_SERVER_WAIT);
|
|
925
|
+
const path = held > 0 ? `/v1/jobs?wait=${held}` : "/v1/jobs";
|
|
926
|
+
const data = await this.request<JobData>("POST", path, {
|
|
759
927
|
model, input_key: inputKey, params,
|
|
760
928
|
...(webhookUrl ? { webhook_url: webhookUrl } : {}),
|
|
761
929
|
...(customId ? { custom_id: customId } : {}),
|
|
@@ -764,8 +932,12 @@ export class CraterView {
|
|
|
764
932
|
return new Job(data);
|
|
765
933
|
}
|
|
766
934
|
|
|
935
|
+
/**
|
|
936
|
+
* One job, by the `id` the API gave it. Anything that is not a job id throws a
|
|
937
|
+
* `CraterViewError` without a request being made.
|
|
938
|
+
*/
|
|
767
939
|
async job(id: string): Promise<Job> {
|
|
768
|
-
return new Job(await this.request<JobData>("GET", `/v1/jobs/${id}`));
|
|
940
|
+
return new Job(await this.request<JobData>("GET", `/v1/jobs/${issued("job", id, "id")}`));
|
|
769
941
|
}
|
|
770
942
|
|
|
771
943
|
/**
|
|
@@ -879,33 +1051,107 @@ export class CraterView {
|
|
|
879
1051
|
* You cannot revoke the key this client is authenticating with: it is refused with status
|
|
880
1052
|
* 409, because it would leave you unable to make another. Create a replacement, move onto
|
|
881
1053
|
* it, then revoke this one.
|
|
1054
|
+
*
|
|
1055
|
+
* `keyId` is a key's `id` from `keys()` or `createKey()`, never its value or its prefix.
|
|
1056
|
+
* Anything else throws a `CraterViewError` without a request being made.
|
|
882
1057
|
*/
|
|
883
1058
|
async revokeKey(keyId: string): Promise<void> {
|
|
884
|
-
await this.request("DELETE", `/v1/keys/${
|
|
1059
|
+
await this.request("DELETE", `/v1/keys/${issued("key", keyId, "keyId")}`);
|
|
885
1060
|
}
|
|
886
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* Wait until a job settles. Prefer `wait` on submit for short jobs.
|
|
1064
|
+
*
|
|
1065
|
+
* Past `timeoutMs` this throws a `CraterViewError` with the job's `jobId`. Only the wait has
|
|
1066
|
+
* ended: the job carries on, and is collected by calling this again with that id.
|
|
1067
|
+
*
|
|
1068
|
+
* A read that gets no answer, a failure on the way to the API or a request to slow down is
|
|
1069
|
+
* not the job's, and the wait goes on through it until `timeoutMs`. A refusal it throws
|
|
1070
|
+
* carries the `jobId` too, so no failure here loses the job.
|
|
1071
|
+
*/
|
|
887
1072
|
async waitFor(job: Job | string, timeoutMs = 600_000, pollMs = 1000): Promise<Job> {
|
|
888
|
-
const id = typeof job === "string" ? job : job.id;
|
|
1073
|
+
const id = issued("job", typeof job === "string" ? job : job.id, "job");
|
|
1074
|
+
let current = typeof job === "string" ? undefined : job;
|
|
889
1075
|
const deadline = Date.now() + timeoutMs;
|
|
890
1076
|
while (Date.now() < deadline) {
|
|
891
|
-
|
|
1077
|
+
try {
|
|
1078
|
+
current = await this.job(id);
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
if (e instanceof RateLimited || worthAskingAgain(e)) {
|
|
1081
|
+
await pause(Math.max(pollMs, ((e as RateLimited).retryAfter ?? 0) * 1000));
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (e instanceof CraterViewError && !e.jobId) {
|
|
1085
|
+
throw new CraterViewError(e.message, e.status, e.traceId, id);
|
|
1086
|
+
}
|
|
1087
|
+
throw e;
|
|
1088
|
+
}
|
|
892
1089
|
if (current.done) return current;
|
|
893
|
-
await
|
|
1090
|
+
await pause(pollMs);
|
|
894
1091
|
}
|
|
895
|
-
|
|
1092
|
+
const where = current ? `still ${current.status}` : "not finished";
|
|
1093
|
+
throw new CraterViewError(`job ${id} is ${where} after ${timeoutMs / 1000}s of waiting. It `
|
|
1094
|
+
+ "has not failed: it carries on, and is charged only if it succeeds. Collect it with "
|
|
1095
|
+
+ `waitFor("${id}") or job("${id}"); submitting the request again starts a second job, `
|
|
1096
|
+
+ "charged as well.", undefined, undefined, id);
|
|
896
1097
|
}
|
|
897
1098
|
|
|
898
1099
|
/**
|
|
899
1100
|
* Upload, submit and wait, in one call — the common case. Uses server-side waiting
|
|
900
|
-
* first so a fast job costs a single round trip, then polls for anything slower.
|
|
1101
|
+
* first so a fast job costs a single round trip, then polls for anything slower. The
|
|
1102
|
+
* server is asked to hold the submission for at most half this client's own `timeoutMs`,
|
|
1103
|
+
* so its answer arrives before the client stops listening for it.
|
|
1104
|
+
*
|
|
1105
|
+
* Past `timeoutMs` the job is not given up on, only waited for: the error carries its
|
|
1106
|
+
* `jobId`, to collect it by with `waitFor`.
|
|
1107
|
+
*
|
|
1108
|
+
* **One submission, asked at most twice.** A submission that got no answer, or met a failure
|
|
1109
|
+
* on the platform's side, may have made the job before it failed, so it is asked once more
|
|
1110
|
+
* under one idempotency key, and the API answers with the job the first attempt made if it
|
|
1111
|
+
* made one — never a second. Pass `idempotencyKey` to choose the key. Refused both times by
|
|
1112
|
+
* the API's own 503, which it answers before anything is recorded, the error says so in its
|
|
1113
|
+
* words.
|
|
901
1114
|
*/
|
|
902
1115
|
async run(image: Blob | ArrayBuffer | Uint8Array,
|
|
903
1116
|
options: SubmitOptions & { timeoutMs?: number } = {}): Promise<Job> {
|
|
904
|
-
const { timeoutMs = 600_000, wait = MAX_SERVER_WAIT,
|
|
1117
|
+
const { timeoutMs = 600_000, wait = MAX_SERVER_WAIT, idempotencyKey = newIdempotencyKey(),
|
|
1118
|
+
...rest } = options;
|
|
905
1119
|
const inputKey = await this.upload(image);
|
|
906
|
-
|
|
1120
|
+
const submitted = () => this.submit(inputKey, {
|
|
1121
|
+
...rest, idempotencyKey, wait: Math.min(wait, this.timeoutMs / 2000) });
|
|
1122
|
+
let job: Job;
|
|
1123
|
+
let first: unknown;
|
|
1124
|
+
try {
|
|
1125
|
+
try {
|
|
1126
|
+
job = await submitted();
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
if (!worthAskingAgain(e)) throw e;
|
|
1129
|
+
first = e;
|
|
1130
|
+
await pause(RESUBMIT_AFTER_MS);
|
|
1131
|
+
job = await submitted();
|
|
1132
|
+
}
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
if (!worthAskingAgain(e)) throw e;
|
|
1135
|
+
if (queuedNothing(first) && queuedNothing(e)) {
|
|
1136
|
+
const refused = e as CraterViewError;
|
|
1137
|
+
throw new CraterViewError(
|
|
1138
|
+
`the submission was asked twice, and both times CraterView said why: ${refused.message}`,
|
|
1139
|
+
refused.status, refused.traceId);
|
|
1140
|
+
}
|
|
1141
|
+
// Thrown as the kind it was, so a caller telling no answer from a refusal by type sees
|
|
1142
|
+
// what it saw before. Only the advice changes: run() uploads afresh each time.
|
|
1143
|
+
const said = `the submission was asked twice and answered neither time `
|
|
1144
|
+
+ `(${(e as Error).message}), so a job may have been made; if it was, it runs and is `
|
|
1145
|
+
+ "charged only if it succeeds. Look for it with jobs(), newest first, before running "
|
|
1146
|
+
+ "this again: run() uploads afresh, so repeating it starts a second job.";
|
|
1147
|
+
if (e instanceof CraterViewError) throw new CraterViewError(said, e.status, e.traceId);
|
|
1148
|
+
if ((e as Error).name === "TimeoutError") throw new DOMException(said, "TimeoutError");
|
|
1149
|
+
throw new TypeError(said);
|
|
1150
|
+
}
|
|
907
1151
|
if (!job.done) job = await this.waitFor(job, timeoutMs);
|
|
908
|
-
if (!job.succeeded)
|
|
1152
|
+
if (!job.succeeded) {
|
|
1153
|
+
throw new JobFailed(job.error ?? "job failed", job.errorCode ?? undefined, job.id);
|
|
1154
|
+
}
|
|
909
1155
|
return job;
|
|
910
1156
|
}
|
|
911
1157
|
}
|