@civitai/sdk 0.11.1 → 0.12.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/BREAKING.md +1 -1
- package/README.md +269 -5
- package/dist/app/index.d.ts +15 -1
- package/dist/app/index.js +44 -10
- package/dist/http/retry.d.ts +3 -0
- package/dist/http/retry.js +14 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/orchestration/index.d.ts +1 -1
- package/dist/orchestration/index.js +1 -14
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflows/index.d.ts +181 -0
- package/dist/workflows/index.js +149 -0
- package/dist/workflows/types.generated.d.ts +1703 -0
- package/dist/workflows/types.generated.js +7 -0
- package/package.json +2 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CivitaiError } from '../core/errors.js';
|
|
2
|
+
import type { Http } from '../http/index.js';
|
|
3
|
+
import type { AppWorkflow, BlockWorkflowSnapshot, WorkflowBody } from './types.generated.js';
|
|
4
|
+
/**
|
|
5
|
+
* The host could not learn whether a submit created a run. **The run may be
|
|
6
|
+
* running, and the viewer may have been charged.**
|
|
7
|
+
*
|
|
8
|
+
* 🔴 This is not a refusal, and it must not be handled like one: do not tell
|
|
9
|
+
* the viewer nothing was spent, and do not submit again blindly — a second
|
|
10
|
+
* submit may start a second run. Tell the viewer the outcome
|
|
11
|
+
* is unknown, then look for the run with {@link WorkflowsClient.list} before
|
|
12
|
+
* offering a retry.
|
|
13
|
+
*
|
|
14
|
+
* The host reports it for a run the viewer confirmed a quote for: a
|
|
15
|
+
* `kind: 'training'` body, or a step body carrying a `quoteId`.
|
|
16
|
+
*
|
|
17
|
+
* It is an error rather than a field on the result because the snapshot the
|
|
18
|
+
* host sends with it is shaped exactly like a refusal (`status: 'failed'`, the
|
|
19
|
+
* placeholder id, a `cost.total`), and a caller that reads only the snapshot
|
|
20
|
+
* would report "not started, nothing charged".
|
|
21
|
+
*/
|
|
22
|
+
export declare class SubmissionUnconfirmedError extends CivitaiError {
|
|
23
|
+
/**
|
|
24
|
+
* The snapshot the host sent beside the flag, when it sent a usable one. Its
|
|
25
|
+
* `cost.total` is the price that was reserved; its `workflowId` is a
|
|
26
|
+
* placeholder, not a workflow to poll.
|
|
27
|
+
*/
|
|
28
|
+
readonly snapshot: BlockWorkflowSnapshot | undefined;
|
|
29
|
+
constructor(snapshot: BlockWorkflowSnapshot | undefined);
|
|
30
|
+
}
|
|
31
|
+
export interface WorkflowCallOptions {
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
export interface WorkflowSubmitOptions extends WorkflowCallOptions {
|
|
35
|
+
/**
|
|
36
|
+
* 🔴 REQUIRED. A submit spends the viewer's Buzz, and this key is what lets
|
|
37
|
+
* the host recognise a repeat of it. Mint it ONCE per thing the viewer asked for —
|
|
38
|
+
* `crypto.randomUUID()` when they press the button — and send the SAME key on
|
|
39
|
+
* every retry of that submit. A key minted inside a retry loop protects
|
|
40
|
+
* nothing.
|
|
41
|
+
*
|
|
42
|
+
* Letters, digits, `_` and `-` only, 1 to 64 characters. No colons. The host
|
|
43
|
+
* owns that rule and answers `400` for anything else; this client sends the
|
|
44
|
+
* key as given and does not rewrite it, because a rewritten key is a
|
|
45
|
+
* different key.
|
|
46
|
+
*/
|
|
47
|
+
idempotencyKey: string;
|
|
48
|
+
}
|
|
49
|
+
export interface WorkflowGetOptions extends WorkflowCallOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Asks the host to hold the reply until the workflow finishes, for up to this
|
|
52
|
+
* many seconds. The host applies its own ceiling. Leave it unset for a plain
|
|
53
|
+
* read — and never set it on a timer-driven poll, where held reads stack up.
|
|
54
|
+
*/
|
|
55
|
+
waitSeconds?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface WorkflowWatchOptions extends WorkflowCallOptions {
|
|
58
|
+
/** Seconds the host may hold each read after the first. */
|
|
59
|
+
waitSeconds?: number;
|
|
60
|
+
/** The least time between two reads, in milliseconds. */
|
|
61
|
+
intervalMs?: number;
|
|
62
|
+
}
|
|
63
|
+
export interface WorkflowListQuery {
|
|
64
|
+
/** An opaque `cursor` from a previous page. */
|
|
65
|
+
cursor?: string;
|
|
66
|
+
/** The host bounds this and applies its own default; this client sends none. */
|
|
67
|
+
limit?: number;
|
|
68
|
+
}
|
|
69
|
+
export interface WorkflowListResult {
|
|
70
|
+
workflows: AppWorkflow[];
|
|
71
|
+
/** Pass it back for the next page. `null` on the last page. */
|
|
72
|
+
cursor: string | null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Generation for a BLOCK, through the host's `blocks/workflows/*` routes: the
|
|
76
|
+
* host prices, budgets, attributes and submits the work as the viewer, and the
|
|
77
|
+
* block never talks to the orchestrator itself.
|
|
78
|
+
*
|
|
79
|
+
* 🔴 Requires the block token the host mints. This is the client a block uses
|
|
80
|
+
* instead of {@link AppClient.orchestration}, which refuses that token.
|
|
81
|
+
*
|
|
82
|
+
* 🔴 A REFUSAL IS AN ANSWER, NOT A THROW. When the host declines to price or
|
|
83
|
+
* run a body it replies `200` with a snapshot whose `status` is `'failed'`,
|
|
84
|
+
* carrying `error`, often an `errorCode`, and — for a budget refusal — the
|
|
85
|
+
* `cost.total` it would not charge. `estimate`, `submit`, `get`, `wait` and
|
|
86
|
+
* `cancel` all RESOLVE with that snapshot. Branch on `snapshot.status`.
|
|
87
|
+
*
|
|
88
|
+
* What rejects is an `ApiError` carrying the HTTP status, a `CivitaiError` for
|
|
89
|
+
* a `2xx` that carried no snapshot, or whatever `fetch` threw. A `400` is a
|
|
90
|
+
* malformed body or key; a `403` on `get` or `cancel` is a workflow that is
|
|
91
|
+
* not this viewer's or this app's.
|
|
92
|
+
*
|
|
93
|
+
* 🔴 A REJECTED `submit` IS NOT "NOT STARTED". A `5xx`, a `409` and a request
|
|
94
|
+
* that got no answer all leave the outcome UNKNOWN, and
|
|
95
|
+
* {@link SubmissionUnconfirmedError} says so outright. See
|
|
96
|
+
* {@link WorkflowsClient.submit}.
|
|
97
|
+
*/
|
|
98
|
+
export interface WorkflowsClient {
|
|
99
|
+
/**
|
|
100
|
+
* Prices a body without running it. Nothing is charged or reserved.
|
|
101
|
+
*
|
|
102
|
+
* The price is `snapshot.cost.total`. A refused estimate resolves with
|
|
103
|
+
* `status: 'failed'` and no `cost`; a body the host cannot price fails this
|
|
104
|
+
* way without that predicting a refused {@link WorkflowsClient.submit}.
|
|
105
|
+
*/
|
|
106
|
+
estimate(body: WorkflowBody, opts?: WorkflowCallOptions): Promise<BlockWorkflowSnapshot>;
|
|
107
|
+
/**
|
|
108
|
+
* Runs a body, spending the viewer's Buzz. Resolves as soon as the host has
|
|
109
|
+
* accepted or refused it — follow it with {@link WorkflowsClient.wait}.
|
|
110
|
+
*
|
|
111
|
+
* 🔴 A resolved `status: 'failed'` is one of TWO things, told apart by
|
|
112
|
+
* `workflowId`:
|
|
113
|
+
* - the placeholder id `'failed'`: the host refused THIS request before
|
|
114
|
+
* submitting it (a budget, a spending cap, a body it will not run). A
|
|
115
|
+
* budget or cap refusal carries the `cost.total` it would not charge. On a
|
|
116
|
+
* first attempt nothing was charged; it says nothing about an EARLIER
|
|
117
|
+
* attempt for the same viewer action that ended unknown (below).
|
|
118
|
+
* - a real id: a run was created and came back failed. Buzz may have been
|
|
119
|
+
* spent.
|
|
120
|
+
*
|
|
121
|
+
* A third outcome does not resolve at all: when the host could not confirm
|
|
122
|
+
* whether the run was created, this REJECTS with
|
|
123
|
+
* {@link SubmissionUnconfirmedError}. That run may be live and charged.
|
|
124
|
+
*
|
|
125
|
+
* 🔴 WHEN THIS REJECTS, TREAT THE OUTCOME AS UNKNOWN. The host refuses a
|
|
126
|
+
* bad request before submitting anything, but the reply does not say whether
|
|
127
|
+
* a rejection came before or after the orchestrator was called. By name:
|
|
128
|
+
* - a `5xx`: the host answers this both when nothing was started and when
|
|
129
|
+
* its orchestrator call failed after the orchestrator may have created the
|
|
130
|
+
* run. A workflow may exist.
|
|
131
|
+
* - a `409`: an earlier submit with the same key is still in flight (or its
|
|
132
|
+
* record was lost). That attempt may yet succeed.
|
|
133
|
+
* - no answer (a dropped connection, a timeout): unknown.
|
|
134
|
+
*
|
|
135
|
+
* Retry with the SAME `idempotencyKey` — except for the two kinds below, the
|
|
136
|
+
* host derives the run's identity on the orchestrator from it, so that a
|
|
137
|
+
* repeat can be matched to an existing run — or look for the run with
|
|
138
|
+
* {@link WorkflowsClient.list}. Never mint a new key for the same viewer
|
|
139
|
+
* action: to the host a new key is a new generation. That matching is the
|
|
140
|
+
* host's and the orchestrator's, is not guaranteed, and is not something
|
|
141
|
+
* this client can verify; `list` is the check that does not depend on it.
|
|
142
|
+
*
|
|
143
|
+
* 🔴 For a `kind: 'training'` body and a step body carrying a `quoteId` the
|
|
144
|
+
* key is NOT the run's identity: the host does not use it there, and
|
|
145
|
+
* identifies the run by the confirmed quote or the training run. A submit
|
|
146
|
+
* naming a new quote is a new run. After an unknown outcome on one of
|
|
147
|
+
* these, check `list` before asking the viewer to confirm another quote.
|
|
148
|
+
*
|
|
149
|
+
* This client does not retry a failed submit itself; it only re-sends once
|
|
150
|
+
* after refreshing an expired token, as every call does.
|
|
151
|
+
*/
|
|
152
|
+
submit(body: WorkflowBody, opts: WorkflowSubmitOptions): Promise<BlockWorkflowSnapshot>;
|
|
153
|
+
/** One workflow's current state. Only a workflow this app submitted for this viewer. */
|
|
154
|
+
get(workflowId: string, opts?: WorkflowGetOptions): Promise<BlockWorkflowSnapshot>;
|
|
155
|
+
/**
|
|
156
|
+
* Yields the workflow now and again each time it changes, ending at a final
|
|
157
|
+
* status. Break out of the loop to stop watching.
|
|
158
|
+
*
|
|
159
|
+
* A yielded snapshot can be a bare progress placeholder — just `workflowId`
|
|
160
|
+
* and `status: 'processing'` — which the host sends when it skips a read
|
|
161
|
+
* under load. Keep what an earlier snapshot showed rather than rendering
|
|
162
|
+
* that one as "no steps, no images".
|
|
163
|
+
*/
|
|
164
|
+
watch(workflowId: string, opts?: WorkflowWatchOptions): AsyncGenerator<BlockWorkflowSnapshot, void, undefined>;
|
|
165
|
+
/** Resolves once the workflow reaches a final status, whichever it is. */
|
|
166
|
+
wait(workflowId: string, opts?: WorkflowWatchOptions): Promise<BlockWorkflowSnapshot>;
|
|
167
|
+
/**
|
|
168
|
+
* Stops a running workflow on the orchestrator. Resolves with the workflow as
|
|
169
|
+
* it now stands: usually `'canceled'`, but one that had already finished
|
|
170
|
+
* keeps its real final status, and a host shedding load can answer with a
|
|
171
|
+
* state that is not final yet. Read `status` rather than assuming.
|
|
172
|
+
*/
|
|
173
|
+
cancel(workflowId: string, opts?: WorkflowCallOptions): Promise<BlockWorkflowSnapshot>;
|
|
174
|
+
/**
|
|
175
|
+
* One page of the workflows THIS APP submitted for this viewer. The host
|
|
176
|
+
* scopes it to the app from the token; there is no filter to pass, and it
|
|
177
|
+
* never includes the viewer's other generations.
|
|
178
|
+
*/
|
|
179
|
+
list(query?: WorkflowListQuery, opts?: WorkflowCallOptions): Promise<WorkflowListResult>;
|
|
180
|
+
}
|
|
181
|
+
export declare function createWorkflowsClient(http: Http): WorkflowsClient;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { CivitaiError } from '../core/errors.js';
|
|
2
|
+
import { isTransient, sleep } from '../http/retry.js';
|
|
3
|
+
/** Where the five routes live under the site base URL. */
|
|
4
|
+
const BASE = 'blocks/workflows';
|
|
5
|
+
/** Seconds the host is asked to hold each read while watching. It applies its own ceiling. */
|
|
6
|
+
const HELD_READ_SECONDS = 15;
|
|
7
|
+
/**
|
|
8
|
+
* The least time between two reads while watching. A held read normally takes
|
|
9
|
+
* far longer than this; it matters when the host answers at once instead, which
|
|
10
|
+
* it does when it sheds load.
|
|
11
|
+
*/
|
|
12
|
+
const READ_INTERVAL_MS = 1_500;
|
|
13
|
+
/** Waits between consecutive failed reads while watching; a success resets it. */
|
|
14
|
+
const RETRY_BACKOFF_MS = [250, 1_000, 4_000];
|
|
15
|
+
const FINAL = new Set([
|
|
16
|
+
'succeeded',
|
|
17
|
+
'failed',
|
|
18
|
+
'canceled',
|
|
19
|
+
'expired',
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* The host could not learn whether a submit created a run. **The run may be
|
|
23
|
+
* running, and the viewer may have been charged.**
|
|
24
|
+
*
|
|
25
|
+
* 🔴 This is not a refusal, and it must not be handled like one: do not tell
|
|
26
|
+
* the viewer nothing was spent, and do not submit again blindly — a second
|
|
27
|
+
* submit may start a second run. Tell the viewer the outcome
|
|
28
|
+
* is unknown, then look for the run with {@link WorkflowsClient.list} before
|
|
29
|
+
* offering a retry.
|
|
30
|
+
*
|
|
31
|
+
* The host reports it for a run the viewer confirmed a quote for: a
|
|
32
|
+
* `kind: 'training'` body, or a step body carrying a `quoteId`.
|
|
33
|
+
*
|
|
34
|
+
* It is an error rather than a field on the result because the snapshot the
|
|
35
|
+
* host sends with it is shaped exactly like a refusal (`status: 'failed'`, the
|
|
36
|
+
* placeholder id, a `cost.total`), and a caller that reads only the snapshot
|
|
37
|
+
* would report "not started, nothing charged".
|
|
38
|
+
*/
|
|
39
|
+
export class SubmissionUnconfirmedError extends CivitaiError {
|
|
40
|
+
/**
|
|
41
|
+
* The snapshot the host sent beside the flag, when it sent a usable one. Its
|
|
42
|
+
* `cost.total` is the price that was reserved; its `workflowId` is a
|
|
43
|
+
* placeholder, not a workflow to poll.
|
|
44
|
+
*/
|
|
45
|
+
snapshot;
|
|
46
|
+
constructor(snapshot) {
|
|
47
|
+
super('workflows submit: the host could not confirm whether this run was started. It may be running ' +
|
|
48
|
+
'and the viewer may have been charged. Do not resubmit blindly: check `app.workflows.list()` ' +
|
|
49
|
+
'for the run first.');
|
|
50
|
+
this.name = 'SubmissionUnconfirmedError';
|
|
51
|
+
this.snapshot = snapshot;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const isSnapshot = (value) => value != null && typeof value === 'object' && typeof value.status === 'string';
|
|
55
|
+
/**
|
|
56
|
+
* Asserted, not cast: every caller branches on `status`, and a reply without
|
|
57
|
+
* one would read as "not failed" to all of them.
|
|
58
|
+
*/
|
|
59
|
+
function requireSnapshot(op, res) {
|
|
60
|
+
if (!isSnapshot(res?.snapshot))
|
|
61
|
+
throw new CivitaiError(`workflows ${op}: reply carried no snapshot`);
|
|
62
|
+
return res.snapshot;
|
|
63
|
+
}
|
|
64
|
+
export function createWorkflowsClient(http) {
|
|
65
|
+
/** Every snapshot route answers `{ snapshot }`. A `2xx` without one is not an answer. */
|
|
66
|
+
const snapshotOf = async (op, body, signal) => requireSnapshot(op, await http('POST', `${BASE}/${op}`, { body, signal }));
|
|
67
|
+
const get = (workflowId, { waitSeconds, signal } = {}) =>
|
|
68
|
+
// The id travels in the body on every route: it identifies the viewer, so
|
|
69
|
+
// it stays out of URLs. `JSON.stringify` drops an unset `waitSeconds`.
|
|
70
|
+
snapshotOf('poll', { workflowId, waitSeconds }, signal);
|
|
71
|
+
async function* watch(workflowId, { signal, waitSeconds = HELD_READ_SECONDS, intervalMs = READ_INTERVAL_MS } = {}) {
|
|
72
|
+
let seen;
|
|
73
|
+
let failures = 0;
|
|
74
|
+
let held = false;
|
|
75
|
+
for (;;) {
|
|
76
|
+
let snapshot;
|
|
77
|
+
try {
|
|
78
|
+
snapshot = await get(workflowId, held ? { waitSeconds, signal } : { signal });
|
|
79
|
+
failures = 0;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (signal?.aborted || !isTransient(error) || failures >= RETRY_BACKOFF_MS.length)
|
|
83
|
+
throw error;
|
|
84
|
+
await sleep(RETRY_BACKOFF_MS[failures++], signal);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
held = true;
|
|
88
|
+
// Compared whole, so anything the host adds to a snapshot counts as progress.
|
|
89
|
+
const text = JSON.stringify(snapshot);
|
|
90
|
+
if (text !== seen) {
|
|
91
|
+
seen = text;
|
|
92
|
+
yield snapshot;
|
|
93
|
+
}
|
|
94
|
+
if (FINAL.has(snapshot.status))
|
|
95
|
+
return;
|
|
96
|
+
// 🔴 Always paced, even though the read was held. The host answers a read
|
|
97
|
+
// it sheds at once, with a snapshot that is not final, precisely so the
|
|
98
|
+
// watcher keeps going — without this gap that reply would be re-requested
|
|
99
|
+
// in a tight loop.
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
await sleep(intervalMs, signal);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
estimate: (body, { signal } = {}) => snapshotOf('estimate', { body }, signal),
|
|
106
|
+
async submit(body, opts) {
|
|
107
|
+
const idempotencyKey = opts?.idempotencyKey;
|
|
108
|
+
// Checked before the request because the host refuses a submit without
|
|
109
|
+
// one, and a caller in plain JavaScript gets no compile error. Presence
|
|
110
|
+
// only: the format is the host's rule and is not re-spelled here.
|
|
111
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey === '') {
|
|
112
|
+
throw new CivitaiError('workflows submit: `idempotencyKey` is required. Mint one per viewer action — ' +
|
|
113
|
+
'e.g. crypto.randomUUID() — and reuse it on every retry of that submit. Nothing was sent.');
|
|
114
|
+
}
|
|
115
|
+
const res = await http('POST', `${BASE}/submit`, { body: { body, idempotencyKey }, signal: opts.signal });
|
|
116
|
+
// 🔴 Read BEFORE the snapshot is trusted. The snapshot beside this flag is
|
|
117
|
+
// refusal-shaped; returning it would tell the caller nothing was charged
|
|
118
|
+
// for a run that may be live.
|
|
119
|
+
if (res?.submissionUnconfirmed === true) {
|
|
120
|
+
throw new SubmissionUnconfirmedError(isSnapshot(res.snapshot) ? res.snapshot : undefined);
|
|
121
|
+
}
|
|
122
|
+
return requireSnapshot('submit', res);
|
|
123
|
+
},
|
|
124
|
+
get,
|
|
125
|
+
watch,
|
|
126
|
+
async wait(workflowId, opts = {}) {
|
|
127
|
+
let last;
|
|
128
|
+
for await (const snapshot of watch(workflowId, opts))
|
|
129
|
+
last = snapshot;
|
|
130
|
+
return last;
|
|
131
|
+
},
|
|
132
|
+
cancel: (workflowId, { signal } = {}) => snapshotOf('cancel', { workflowId }, signal),
|
|
133
|
+
async list(query = {}, { signal } = {}) {
|
|
134
|
+
const res = await http('POST', `${BASE}/query`, {
|
|
135
|
+
// Named rather than spread: the route's body schema is closed, so any
|
|
136
|
+
// other key a caller passed would turn the whole request into a 400.
|
|
137
|
+
body: { cursor: query.cursor, limit: query.limit },
|
|
138
|
+
signal,
|
|
139
|
+
});
|
|
140
|
+
// 🔴 NO `?? []`. A malformed 200 must throw, never answer "nothing
|
|
141
|
+
// running": a block rebuilds its in-flight queue from this.
|
|
142
|
+
if (!Array.isArray(res?.workflows)) {
|
|
143
|
+
throw new CivitaiError('workflows list: reply carried no `workflows` array');
|
|
144
|
+
}
|
|
145
|
+
// The cursor is passed through untouched: not defaulted, not re-derived.
|
|
146
|
+
return { workflows: res.workflows, cursor: res.cursor };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|