craterview 0.3.18 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +27 -19
  2. package/index.ts +86 -33
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -136,12 +136,16 @@ the same job.
136
136
 
137
137
  ## Errors
138
138
 
139
- Everything thrown by this client extends `CraterViewError`, so one `catch` handles the lot.
140
- `.status` carries the HTTP status where there was one.
139
+ Every answer the API or storage gives that is not a success is thrown as a
140
+ `CraterViewError`. `.status` carries the HTTP status where there was one, and `.traceId` the
141
+ 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
143
+ no answer at all throws the runtime's own error from `fetch`, which is how the retry loop
144
+ above tells the two apart.
141
145
 
142
146
  | Class | Meaning |
143
147
  |---|---|
144
- | `RateLimited` | 429 — one of four limits, and the message says which: the key's request rate, the account's upload URLs a minute, 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, or an interval to poll on for the other two. |
148
+ | `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. |
145
149
  | `JobFailed` | The job ran and did not succeed. `.message` says what you can do about it; `.errorCode` is the half to branch on. |
146
150
  | `CraterViewError` | Everything else, including 4xx and 5xx from the API. |
147
151
 
@@ -162,19 +166,19 @@ Every field the API publishes on a job is exposed here.
162
166
  | `errorCode` | The same fact, as a stable identifier. Branch on this, show the other |
163
167
  | `credits` | **What you were billed** |
164
168
  | `etaSeconds` | Seconds until the job is expected to finish, recomputed on every read — it counts down while the job runs. Absent once the job has settled. Estimated for your image's size when `upload()` could read it (or when you pass `inputMegapixels` to `submit()`), for a typical image otherwise |
165
- | `community` | True when the job is on the community queue: served after priority work, always taking a share of it, so it never stalls behind paid work |
169
+ | `community` | True when the job is on the community queue, which runs on shared, free capacity and can wait longer at busy times |
166
170
  | `outputUrl`, `downloadUrl` | The result, presigned. One to display, one to save |
167
- | `thumbUrl` | A small JPEG of the result, for listings. Null when none was drawn |
171
+ | `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 |
168
172
  | `inputUrl` | The picture the model worked from — the region, where you named one |
169
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 |
170
174
  | `contentType` | The result's media type |
171
175
  | `outputBytes` | The result's size in bytes |
172
176
  | `blob()`, `arrayBuffer()` | Download the result |
173
177
 
174
- `result` is the whole of what the job produced, and the five rows above it that describe the
175
- file are getters onto `result.output` rather than separate fields — the API states those links
176
- once. A model with no file to hand back returns its answer in `result` and leaves every one of
177
- them null; the fields it answers with are its `result_schema` in `cv.models()`.
178
+ `result` is the whole of what the job produced, and the rows above it that describe the
179
+ result's file are getters onto `result.output` rather than separate fields — the API states
180
+ those links once. A model with no file to hand back returns its answer in `result` and leaves
181
+ every one of them null; the fields it answers with are its `result_schema` in `cv.models()`.
178
182
 
179
183
  `credits` is the only figure about cost the API states, and the price is fixed and published
180
184
  per model, so an invoice reconciles against `credits` alone. For how long a job took, subtract
@@ -186,11 +190,11 @@ accepts exactly that length and nothing else. You do not pass it — it is read
186
190
  in hand, which is what makes it impossible to get wrong.
187
191
 
188
192
  **Running out of credit does not stop you.** A job submitted against a balance of zero is
189
- accepted and run, and charged when it succeeds — it simply waits in the community queue, which is served after
190
- paid work and always takes a share of it, so it never stalls behind paid work. It comes back
191
- with `community` set. There is no payment error to handle:
192
- paying — with credit, or with a subscription — buys a place at the front of the queue rather
193
- than the right to submit.
193
+ accepted and run, and charged when it succeeds — it goes to the community queue, which runs
194
+ on shared, free capacity and can wait longer at busy times. It comes back with `community`
195
+ set. There is no payment error to handle: paying — with credit, or with a subscription —
196
+ moves work onto paid compute that scales with demand, rather than buying the right to
197
+ submit.
194
198
  `etaSeconds` covers the whole wait, queue time included, so a community job simply reports
195
199
  a longer one.
196
200
 
@@ -198,13 +202,13 @@ a longer one.
198
202
  is signed in, so the second cannot be derived from the first. Both expire, so fetch the
199
203
  result rather than storing the link.
200
204
 
201
- `thumbUrl` and `inputUrl` are for building a job listing: a few-hundred-pixel preview so a
202
- page of results costs kilobytes, and the picture the model worked from so a result can be
205
+ `thumbnailUrl` and `inputUrl` are for building a job listing: a few-hundred-pixel preview so a
206
+ page of jobs costs kilobytes, and the picture the model worked from so a result can be
203
207
  shown against it. Where you named a region, `inputUrl` is that region — so a before-and-after
204
208
  is a true pair, and what was used is something you can look at rather than something to take
205
209
  on trust. It is not the file you uploaded: yours stays yours and is removed on its own
206
- schedule. Both expire with the result. A model that produces no file has no `thumbUrl`, but still
207
- has the picture it answered about.
210
+ schedule. Both expire with the job's other pictures. A model that produces no file has no
211
+ result to preview, so `thumbnailUrl` is a preview of the picture it answered about.
208
212
 
209
213
  ## Webhooks
210
214
 
@@ -299,10 +303,14 @@ fetched as 200.
299
303
  new CraterView({
300
304
  apiKey: undefined,
301
305
  baseUrl: "https://api.craterview.ai",
306
+ timeoutMs: 60_000,
302
307
  });
303
308
  ```
304
309
 
305
- `baseUrl` is what you change to point at a local server.
310
+ `baseUrl` is what you change to point at a local server. `timeoutMs` is how long to wait for
311
+ 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. Uploads and downloads go to storage
313
+ and have five minutes.
306
314
 
307
315
  ## From Claude Code
308
316
 
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.3.18";
21
+ export const VERSION = "0.4.0";
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.
@@ -62,18 +62,25 @@ export function newIdempotencyKey(): string {
62
62
  return `idem_${base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
63
63
  }
64
64
 
65
+ /**
66
+ * `status` is the HTTP status where there was one. `traceId` is the API's `X-Trace-Id` for a
67
+ * request it answered and refused — the id to quote when asking about it. Absent when the
68
+ * refusal came from storage rather than the API.
69
+ */
65
70
  export class CraterViewError extends Error {
66
- constructor(message: string, readonly status?: number) {
71
+ constructor(message: string, readonly status?: number, readonly traceId?: string) {
67
72
  super(message);
68
73
  this.name = "CraterViewError";
69
74
  }
70
75
  }
71
76
 
72
77
  /**
73
- * 429 — too fast, or too much at once. Four different limits answer with this, and the
78
+ * 429 — too fast, or too much at once. Five different limits answer with this, and the
74
79
  * message says which:
75
80
  *
76
81
  * - the key's request rate, per minute;
82
+ * - the account's new keys, per hour — counted across all its keys, and only from
83
+ * `createKey`;
77
84
  * - the account's upload URLs, per minute — counted across all its keys;
78
85
  * - the account's cap on jobs queued or running at the same time, which exists so one
79
86
  * caller cannot occupy the whole fleet. `usage()` reports the cap and what you currently
@@ -82,12 +89,13 @@ export class CraterViewError extends Error {
82
89
  * an account with credit or a subscription submits on a queue that fills separately.
83
90
  *
84
91
  * `retryAfter` is seconds to wait, and what it means depends on which limit you hit: for
85
- * the two per-minute limits it is when the window rolls over; for the in-flight cap and the
86
- * full queue it is an interval to poll on, since nothing here predicts when a slot frees.
92
+ * the two per-minute limits it is when the window rolls over; for new keys, when the account
93
+ * may be given another; for the in-flight cap and the full queue it is an interval to poll
94
+ * on, since nothing here predicts when a slot frees.
87
95
  */
88
96
  export class RateLimited extends CraterViewError {
89
- constructor(message: string, readonly retryAfter?: number) {
90
- super(message, 429);
97
+ constructor(message: string, readonly retryAfter?: number, traceId?: string) {
98
+ super(message, 429, traceId);
91
99
  this.name = "RateLimited";
92
100
  }
93
101
  }
@@ -308,9 +316,9 @@ export interface ModelInfo {
308
316
  */
309
317
  result_schema: Record<string, unknown>;
310
318
  /**
311
- * Whether your key's work goes to the community queue, which is served after priority
312
- * work and always takes a share of it, so it never stalls behind paid work. False while
313
- * the account is paying — holding credit, or on a subscription.
319
+ * Whether your key's work goes to the community queue, which runs on shared, free capacity
320
+ * and can wait longer at busy times. False while the account is paying — holding credit, or
321
+ * on a subscription — which puts its work on paid compute that scales with demand.
314
322
  *
315
323
  * The same field, meaning the same thing, as `Job.community`. How long a wait will be
316
324
  * is answered on the job, once you have one — `Job.eta_seconds` — and nowhere else.
@@ -328,7 +336,7 @@ export interface KeyInfo {
328
336
  name: string;
329
337
  created_at: string | null;
330
338
  /** When it stopped working, or null while it still does. */
331
- revoked_at: string | null;
339
+ revoked_at?: string;
332
340
  rate_limit_per_minute: number;
333
341
  /**
334
342
  * Whether this is the key you are calling with. It cannot be revoked by id while it is;
@@ -395,6 +403,12 @@ export interface JobData {
395
403
  * read them out of `result.output`.
396
404
  */
397
405
  result?: Record<string, unknown> | null;
406
+ /**
407
+ * A small JPEG of the job's picture, for a listing: the result, or the image the model
408
+ * worked from when it produced no file. Absent until the job succeeds, where none could be
409
+ * drawn, and once the job's pictures are deleted.
410
+ */
411
+ thumbnail_url?: string | null;
398
412
  /** Whole credits. TypeScript cannot say integer, but the API only ever sends one. */
399
413
  credits?: number | null;
400
414
  eta_seconds?: number | null;
@@ -423,28 +437,30 @@ export interface JobData {
423
437
  * by the same names.
424
438
  *
425
439
  * `result` is the whole of what the job produced. Where the model wrote a file,
426
- * `result.output` carries its links, and `outputUrl` / `downloadUrl` / `thumbUrl` /
440
+ * `result.output` carries its links, and `outputUrl` / `downloadUrl` /
427
441
  * `contentType` / `outputBytes` read out of it. `outputUrl` and `downloadUrl` are the same
428
442
  * object signed two ways: one to display, one to hand a person as a file. The disposition
429
443
  * is signed in, so the second cannot be derived from the first without the storage
430
444
  * credential. Both are presigned and expire — fetch the result rather than storing the link.
431
445
  *
432
- * `thumbUrl` is a small JPEG of the result, for showing a page of jobs without downloading
433
- * a page of full-size outputs. `inputUrl` is the picture the model worked from, so a result
434
- * can be shown against what it was made from. Both expire with the result. `alphaUrl` is
435
- * there only when you asked for a JPEG of a picture with transparency: the transparency, as
436
- * a file of its own.
446
+ * `thumbnailUrl` is a small JPEG of the job's picture — the result, or the image the model
447
+ * worked from when it produced no file — for showing a page of jobs without downloading a
448
+ * page of full-size pictures. `inputUrl` is the picture the model worked from, so a result
449
+ * can be shown against what it was made from. Both expire with the job's other pictures.
450
+ * `alphaUrl` is there only when you asked for a JPEG of a picture with transparency: the
451
+ * transparency, as a file of its own.
437
452
  *
438
453
  * `etaSeconds` is the whole of what is reported about waiting: how long until the result,
439
454
  * counting time spent waiting for a GPU as well as time spent on one. An estimate and never
440
455
  * a promise — read it as guidance, not a deadline. Absent once a job has settled.
441
456
  *
442
- * `community` says the job is on the queue served after priority work, which always takes
443
- * a share of it rather than only what is left over — so it waits longer at busy times and
444
- * never stalls behind paid work. That is where an account goes when it has not
445
- * paid for priority — by holding credit or by subscribing. Nothing is refused for want of
446
- * either: paying buys a place at the front of the queue, not the right to submit, so an
447
- * account that has not paid means a longer wait and never an error.
457
+ * `community` says the job is on the community queue, which runs on shared, free capacity —
458
+ * so it waits longer at busy times, and never stops. That is where an account's work goes
459
+ * when it is not paying, by holding credit or by subscribing. An empty balance is never itself
460
+ * a reason to refuse a job — paying moves work onto paid compute that scales with demand, not
461
+ * the right to submit — but on some models the community queue takes smaller files.
462
+ * `models()` lists each model's limits for your key, and a file over them is refused with
463
+ * the limit named.
448
464
  */
449
465
  export class Job {
450
466
  constructor(private readonly data: JobData) {}
@@ -468,6 +484,8 @@ export class Job {
468
484
  get flagged() { return this.data.flagged ?? null; }
469
485
  /** Retained past the ordinary expiry because you asked. */
470
486
  get kept() { return this.data.kept ?? false; }
487
+ /** A small JPEG of the job's picture, for a listing. Null where there is none. */
488
+ get thumbnailUrl() { return this.data.thumbnail_url ?? null; }
471
489
  /** Where this job stands with the public gallery, or null when it is not offered. */
472
490
  get gallery() { return this.data.gallery ?? null; }
473
491
  /** The whole answer, including where the file is when there is one. */
@@ -491,7 +509,6 @@ export class Job {
491
509
  * the disposition is signed in, so re-signing needs the storage credential.
492
510
  */
493
511
  get downloadUrl() { return (this.output["download_url"] as string) ?? null; }
494
- get thumbUrl() { return (this.output["thumbnail_url"] as string) ?? null; }
495
512
  /**
496
513
  * A link to the picture the model worked from. Where you named a region, this is that
497
514
  * region — so what was used is something you can look at rather than something to take on
@@ -543,8 +560,10 @@ export class Job {
543
560
  if (!url) {
544
561
  throw new CraterViewError(`job ${this.id} has no result (status ${this.status})`);
545
562
  }
546
- const resp = await fetch(url);
547
- if (!resp.ok) throw new CraterViewError(`downloading result failed: ${resp.status}`);
563
+ const resp = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
564
+ if (!resp.ok) {
565
+ throw new CraterViewError(`downloading result failed: ${resp.status}`, resp.status);
566
+ }
548
567
  return await resp.blob();
549
568
  }
550
569
 
@@ -556,8 +575,18 @@ export class Job {
556
575
  export interface CraterViewOptions {
557
576
  apiKey?: string;
558
577
  baseUrl?: string;
578
+ /**
579
+ * Milliseconds to wait for the API to answer a request before giving up, 60 000 by default.
580
+ * A request that times out throws the runtime's `TimeoutError`, not a `CraterViewError`:
581
+ * nothing answered, so it is one to repeat with the same idempotency key. Uploads and
582
+ * downloads go to storage and are given five minutes whatever this says.
583
+ */
584
+ timeoutMs?: number;
559
585
  }
560
586
 
587
+ /** How long a transfer to or from storage may take, however large the picture. */
588
+ const TRANSFER_TIMEOUT_MS = 300_000;
589
+
561
590
  /**
562
591
  * Width × height ÷ 10⁶ from the file's header, or `undefined` for a file the reader does not
563
592
  * know. Reads the first megabyte only — every format's dimensions sit near the front, and a
@@ -605,6 +634,7 @@ export interface SubmitOptions {
605
634
  export class CraterView {
606
635
  private readonly baseUrl: string;
607
636
  private readonly headers: Record<string, string>;
637
+ private readonly timeoutMs: number;
608
638
  // What `upload()` read from each file's header, by the key it came back with, so a later
609
639
  // `submit()` of that key declares the size without the caller carrying it. Bounded: a
610
640
  // long-lived client uploading thousands of files keeps the most recent few hundred.
@@ -612,6 +642,7 @@ export class CraterView {
612
642
 
613
643
  constructor(options: CraterViewOptions = {}) {
614
644
  this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
645
+ this.timeoutMs = options.timeoutMs ?? 60_000;
615
646
  this.headers = { "User-Agent": `craterview-ts/${VERSION}` };
616
647
  if (options.apiKey) this.headers["Authorization"] = `Bearer ${options.apiKey}`;
617
648
  }
@@ -619,6 +650,7 @@ export class CraterView {
619
650
  private async request<T>(method: string, path: string, body?: unknown,
620
651
  extraHeaders: Record<string, string> = {}): Promise<T> {
621
652
  const resp = await fetch(`${this.baseUrl}${path}`, {
653
+ signal: AbortSignal.timeout(this.timeoutMs),
622
654
  method,
623
655
  headers: { ...this.headers, ...extraHeaders,
624
656
  ...(body ? { "Content-Type": "application/json" } : {}) },
@@ -627,10 +659,11 @@ export class CraterView {
627
659
 
628
660
  if (!resp.ok) {
629
661
  const detail = await this.detail(resp);
662
+ const traceId = resp.headers.get("X-Trace-Id") ?? undefined;
630
663
  if (resp.status === 429) {
631
- throw new RateLimited(detail, Number(resp.headers.get("Retry-After") ?? 0));
664
+ throw new RateLimited(detail, Number(resp.headers.get("Retry-After") ?? 0), traceId);
632
665
  }
633
- throw new CraterViewError(detail, resp.status);
666
+ throw new CraterViewError(detail, resp.status, traceId);
634
667
  }
635
668
  // A 204 carries no body, so asking for JSON throws on a call that succeeded.
636
669
  if (resp.status === 204) return undefined as T;
@@ -685,9 +718,10 @@ export class CraterView {
685
718
  "POST", "/v1/uploads", { content_type: type, content_length: blob.size });
686
719
 
687
720
  const put = await fetch(slot.upload_url, {
721
+ signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS),
688
722
  method: "PUT", body: blob, headers: { "Content-Type": type },
689
723
  });
690
- if (!put.ok) throw new CraterViewError(`upload failed: ${put.status}`);
724
+ if (!put.ok) throw new CraterViewError(`upload failed: ${put.status}`, put.status);
691
725
  const megapixels = await megapixelsOf(blob);
692
726
  if (megapixels) {
693
727
  if (this.sizes.size >= 512) this.sizes.delete(this.sizes.keys().next().value!);
@@ -795,9 +829,24 @@ export class CraterView {
795
829
  return body.secret;
796
830
  }
797
831
 
798
- /** Every key on the account, including revoked ones, by prefix rather than value. */
832
+ /**
833
+ * Every key on the account, including revoked ones, by prefix rather than value, newest
834
+ * first. The API serves the list a page at a time; this fetches every page and returns them
835
+ * together.
836
+ */
799
837
  async keys(): Promise<KeyInfo[]> {
800
- return await this.request("GET", "/v1/keys") as KeyInfo[];
838
+ const found: KeyInfo[] = [];
839
+ let before: string | undefined;
840
+ for (;;) {
841
+ const params = new URLSearchParams({ limit: String(MAX_PAGE) });
842
+ if (before) params.set("before", before);
843
+ const page = await this.request<{ data: KeyInfo[]; has_more: boolean; next_before?: string }>(
844
+ "GET", `/v1/keys?${params}`);
845
+ found.push(...page.data);
846
+ // As in `jobs`: a missing or non-advancing cursor stops rather than spinning.
847
+ if (!page.has_more || !page.next_before || page.next_before === before) return found;
848
+ before = page.next_before;
849
+ }
801
850
  }
802
851
 
803
852
  /**
@@ -807,6 +856,9 @@ export class CraterView {
807
856
  * Several keys on an account is the ordinary arrangement, one per service or environment.
808
857
  * They share the account's credits and history. This is also how you rotate without
809
858
  * downtime: create the new key, move your clients onto it, then revoke the old one.
859
+ *
860
+ * An account is given a limited number of new keys an hour, whichever of its keys asks:
861
+ * past it this throws `RateLimited`, whose `retryAfter` says when to ask again.
810
862
  */
811
863
  async createKey(name = "api"): Promise<NewKey> {
812
864
  return await this.request("POST", "/v1/keys", { name }) as NewKey;
@@ -815,8 +867,9 @@ export class CraterView {
815
867
  /**
816
868
  * Stop a key working. Immediate, and not reversible.
817
869
  *
818
- * You cannot revoke the key this client is authenticating with — the call would succeed
819
- * and leave you unable to make another.
870
+ * You cannot revoke the key this client is authenticating with: it is refused with status
871
+ * 409, because it would leave you unable to make another. Create a replacement, move onto
872
+ * it, then revoke this one.
820
873
  */
821
874
  async revokeKey(keyId: string): Promise<void> {
822
875
  await this.request("DELETE", `/v1/keys/${encodeURIComponent(keyId)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craterview",
3
- "version": "0.3.18",
3
+ "version": "0.4.0",
4
4
  "dependencies": {
5
5
  "image-size": "^2.0.4"
6
6
  },
@@ -25,10 +25,10 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^26.2.0",
29
- "esbuild": "^0.25.0",
28
+ "@types/node": "^26.6.3",
29
+ "esbuild": "^0.28.2",
30
30
  "tsx": "^4.23.15",
31
31
  "typescript": "^7.0.2",
32
- "vitest": "^4.1.11"
32
+ "vitest": "^5.0.2"
33
33
  }
34
34
  }