craterview 0.3.19 → 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 +18 -10
  2. package/index.ts +71 -26
  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,7 +166,7 @@ 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
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 |
@@ -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
 
@@ -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.19";
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;
@@ -446,12 +454,13 @@ export interface JobData {
446
454
  * counting time spent waiting for a GPU as well as time spent on one. An estimate and never
447
455
  * a promise — read it as guidance, not a deadline. Absent once a job has settled.
448
456
  *
449
- * `community` says the job is on the queue served after priority work, which always takes
450
- * a share of it rather than only what is left over — so it waits longer at busy times and
451
- * never stalls behind paid work. That is where an account goes when it has not
452
- * paid for priority — by holding credit or by subscribing. Nothing is refused for want of
453
- * either: paying buys a place at the front of the queue, not the right to submit, so an
454
- * 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.
455
464
  */
456
465
  export class Job {
457
466
  constructor(private readonly data: JobData) {}
@@ -551,8 +560,10 @@ export class Job {
551
560
  if (!url) {
552
561
  throw new CraterViewError(`job ${this.id} has no result (status ${this.status})`);
553
562
  }
554
- const resp = await fetch(url);
555
- 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
+ }
556
567
  return await resp.blob();
557
568
  }
558
569
 
@@ -564,8 +575,18 @@ export class Job {
564
575
  export interface CraterViewOptions {
565
576
  apiKey?: string;
566
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;
567
585
  }
568
586
 
587
+ /** How long a transfer to or from storage may take, however large the picture. */
588
+ const TRANSFER_TIMEOUT_MS = 300_000;
589
+
569
590
  /**
570
591
  * Width × height ÷ 10⁶ from the file's header, or `undefined` for a file the reader does not
571
592
  * know. Reads the first megabyte only — every format's dimensions sit near the front, and a
@@ -613,6 +634,7 @@ export interface SubmitOptions {
613
634
  export class CraterView {
614
635
  private readonly baseUrl: string;
615
636
  private readonly headers: Record<string, string>;
637
+ private readonly timeoutMs: number;
616
638
  // What `upload()` read from each file's header, by the key it came back with, so a later
617
639
  // `submit()` of that key declares the size without the caller carrying it. Bounded: a
618
640
  // long-lived client uploading thousands of files keeps the most recent few hundred.
@@ -620,6 +642,7 @@ export class CraterView {
620
642
 
621
643
  constructor(options: CraterViewOptions = {}) {
622
644
  this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
645
+ this.timeoutMs = options.timeoutMs ?? 60_000;
623
646
  this.headers = { "User-Agent": `craterview-ts/${VERSION}` };
624
647
  if (options.apiKey) this.headers["Authorization"] = `Bearer ${options.apiKey}`;
625
648
  }
@@ -627,6 +650,7 @@ export class CraterView {
627
650
  private async request<T>(method: string, path: string, body?: unknown,
628
651
  extraHeaders: Record<string, string> = {}): Promise<T> {
629
652
  const resp = await fetch(`${this.baseUrl}${path}`, {
653
+ signal: AbortSignal.timeout(this.timeoutMs),
630
654
  method,
631
655
  headers: { ...this.headers, ...extraHeaders,
632
656
  ...(body ? { "Content-Type": "application/json" } : {}) },
@@ -635,10 +659,11 @@ export class CraterView {
635
659
 
636
660
  if (!resp.ok) {
637
661
  const detail = await this.detail(resp);
662
+ const traceId = resp.headers.get("X-Trace-Id") ?? undefined;
638
663
  if (resp.status === 429) {
639
- throw new RateLimited(detail, Number(resp.headers.get("Retry-After") ?? 0));
664
+ throw new RateLimited(detail, Number(resp.headers.get("Retry-After") ?? 0), traceId);
640
665
  }
641
- throw new CraterViewError(detail, resp.status);
666
+ throw new CraterViewError(detail, resp.status, traceId);
642
667
  }
643
668
  // A 204 carries no body, so asking for JSON throws on a call that succeeded.
644
669
  if (resp.status === 204) return undefined as T;
@@ -693,9 +718,10 @@ export class CraterView {
693
718
  "POST", "/v1/uploads", { content_type: type, content_length: blob.size });
694
719
 
695
720
  const put = await fetch(slot.upload_url, {
721
+ signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS),
696
722
  method: "PUT", body: blob, headers: { "Content-Type": type },
697
723
  });
698
- if (!put.ok) throw new CraterViewError(`upload failed: ${put.status}`);
724
+ if (!put.ok) throw new CraterViewError(`upload failed: ${put.status}`, put.status);
699
725
  const megapixels = await megapixelsOf(blob);
700
726
  if (megapixels) {
701
727
  if (this.sizes.size >= 512) this.sizes.delete(this.sizes.keys().next().value!);
@@ -803,9 +829,24 @@ export class CraterView {
803
829
  return body.secret;
804
830
  }
805
831
 
806
- /** 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
+ */
807
837
  async keys(): Promise<KeyInfo[]> {
808
- 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
+ }
809
850
  }
810
851
 
811
852
  /**
@@ -815,6 +856,9 @@ export class CraterView {
815
856
  * Several keys on an account is the ordinary arrangement, one per service or environment.
816
857
  * They share the account's credits and history. This is also how you rotate without
817
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.
818
862
  */
819
863
  async createKey(name = "api"): Promise<NewKey> {
820
864
  return await this.request("POST", "/v1/keys", { name }) as NewKey;
@@ -823,8 +867,9 @@ export class CraterView {
823
867
  /**
824
868
  * Stop a key working. Immediate, and not reversible.
825
869
  *
826
- * You cannot revoke the key this client is authenticating with — the call would succeed
827
- * 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.
828
873
  */
829
874
  async revokeKey(keyId: string): Promise<void> {
830
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.19",
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
  }