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.
- package/README.md +18 -10
- package/index.ts +71 -26
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -136,12 +136,16 @@ the same job.
|
|
|
136
136
|
|
|
137
137
|
## Errors
|
|
138
138
|
|
|
139
|
-
|
|
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
|
|
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
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
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.
|
|
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
|
|
86
|
-
*
|
|
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
|
|
312
|
-
*
|
|
313
|
-
*
|
|
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
|
|
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
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
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)
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
827
|
-
*
|
|
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
|
+
"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.
|
|
29
|
-
"esbuild": "^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": "^
|
|
32
|
+
"vitest": "^5.0.2"
|
|
33
33
|
}
|
|
34
34
|
}
|