@civitai/sdk 0.11.0 โ 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/core/transports/iframe-transport.js +4 -1
- 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 +2 -0
- package/dist/version.generated.js +3 -0
- 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 +4 -2
package/BREAKING.md
CHANGED
|
@@ -16,7 +16,7 @@ messages have no destination yet.
|
|
|
16
16
|
| Old message(s) | Now | Status |
|
|
17
17
|
|---|---|---|
|
|
18
18
|
| `GET_VIEWER` | `app.site.get('blocks/me')` | Route exists. ๐ด **Not `site.get('me')`** โ that resolves to `/api/v1/me`, an `AuthedEndpoint` that does not accept a block token |
|
|
19
|
-
| `SUBMIT_WORKFLOW`, `ESTIMATE_WORKFLOW`, `POLL_WORKFLOW`, `CANCEL_WORKFLOW`, `QUERY_APP_WORKFLOWS`, `CANCEL_APP_WORKFLOW` | `POST /api/v1/blocks/workflows/{submit,estimate,poll,cancel,query}` | **Routes exist
|
|
19
|
+
| `SUBMIT_WORKFLOW`, `ESTIMATE_WORKFLOW`, `POLL_WORKFLOW`, `CANCEL_WORKFLOW`, `QUERY_APP_WORKFLOWS`, `CANCEL_APP_WORKFLOW` | `app.workflows`, over `POST /api/v1/blocks/workflows/{submit,estimate,poll,cancel,query}` | **Routes exist**, and `app.workflows` wraps all five (`estimate`, `submit`, `get`/`watch`/`wait`, `cancel`, `list`). ๐ด Use it, **not** `app.orchestration` โ see *What a direct orchestrator call loses* below |
|
|
20
20
|
| `GET_IMAGES_BY_IDS` | `GET /api/v1/blocks/images?ids=1,2,3` | Batch, up to **100** ids per request. Misses are reported by OMISSION โ see below. ๐ด **Not `/api/v1/images`** โ that is a `PublicEndpoint`; it ignores your token and answers with anonymous public results rather than erroring |
|
|
21
21
|
| `APP_STORAGE_*` | `POST /api/v1/blocks/app-storage/*` | **Routes exist** โ five of them (`get`, `set`, `delete`, `list`, `quota`), civitai#5085. This row said "No v1 route"; that is no longer true. See *App storage* below |
|
|
22
22
|
| `SHARED_*` | `app.sharedStorage`, over `GET\|POST /api/v1/blocks/shared-storage/*` | **Routes exist** โ eleven of them. `app.sharedStorage` wraps the **five** key/value ones; the higher-level ops are deliberately app-layer. See *Shared storage* below |
|
package/README.md
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
The Civitai SDK: one client for the public API, the orchestrator and, inside a
|
|
4
4
|
civitai.com page, the host. The same code serves a block app and an app of your
|
|
5
|
-
own.
|
|
6
|
-
|
|
5
|
+
own. New block apps start here. `@civitai/app-sdk/blocks` remains the contract
|
|
6
|
+
underneath (the workflow body types, the manifest types, the host message
|
|
7
|
+
shapes) and `@civitai/blocks-react` the React hook binding over it; both are
|
|
8
|
+
still supported and release from this repo as separate codebases.
|
|
7
9
|
|
|
8
10
|
```ts
|
|
9
11
|
import { initialize } from '@civitai/sdk';
|
|
@@ -30,8 +32,10 @@ if (picked && (await app.requestGrants(['ai:write:budgeted']))) {
|
|
|
30
32
|
> ๐ด **If you are a BLOCK, do not copy the `app.orchestration` call above.** That
|
|
31
33
|
> reaches the orchestrator directly and carries none of the controls the block
|
|
32
34
|
> path applies โ the per-call Buzz budget, the per-viewer and per-app daily caps,
|
|
33
|
-
> the maturity clamp, and the `app-block:<appId>` attribution tag.
|
|
34
|
-
> `
|
|
35
|
+
> the maturity clamp, and the `app-block:<appId>` attribution tag. Generate with
|
|
36
|
+
> **`app.workflows`** instead: `estimate`, `submit` and `wait` go through the
|
|
37
|
+
> host's `/api/v1/blocks/workflows/*` routes, which apply all of them โ see
|
|
38
|
+
> [Multi-step workflows](#multi-step-workflows). The substitution type-checks and
|
|
35
39
|
> passes tests, which is why it is worth saying here: the snippet above is written
|
|
36
40
|
> for an app that is its own principal, not for a block. `BREAKING.md` ยง*What a
|
|
37
41
|
> direct orchestrator call loses* has the detail.
|
|
@@ -53,7 +57,8 @@ Both give you:
|
|
|
53
57
|
| Member | What it is |
|
|
54
58
|
|---|---|
|
|
55
59
|
| `app.site` | The public REST API at `/api/v1`: `get`, `post`, `request` |
|
|
56
|
-
| `app.
|
|
60
|
+
| `app.workflows` | Generation for a **block**, through the host's `blocks/workflows/*` routes: `estimate`, `submit`, `get`, `watch`, `wait`, `cancel`, `list`. Needs the block token: an OAuth access token is answered `401`, and a block holding one generates through `app.orchestration`. See [Multi-step workflows](#multi-step-workflows) |
|
|
61
|
+
| `app.orchestration` | The orchestrator itself, for an app holding an OAuth access token: `submitWorkflow`, `estimateWorkflow`, `getWorkflow`, `watchWorkflow`, `waitForWorkflow`, `cancelWorkflow`, `queryWorkflows` |
|
|
57
62
|
| `app.requestGrants(scopes)` | Asks for more scopes; resolves `false` when they cannot be granted |
|
|
58
63
|
| `app.getToken()` | For a call this client does not make |
|
|
59
64
|
|
|
@@ -93,6 +98,7 @@ the refusal sits at the surface, not at startup:
|
|
|
93
98
|
| `app.sharedStorage.*` | Reads work, anonymous included. Writes additionally need a signed-in viewer past the server's minimum-trust gate, so a `403` there is about the VIEWER, not the token |
|
|
94
99
|
| `app.site` on `blocks/โฆ` | Works. These are the routes the token was minted for |
|
|
95
100
|
| `app.site.get('me')` and the rest of `/api/v1` | The API's own 401/403, with `auth: "oauth"` named in the message. `status` and `body` are untouched, so a caller can still branch on them |
|
|
101
|
+
| `app.workflows.*` | Works. Generation for a block goes here, with the `ai:write:budgeted` scope |
|
|
96
102
|
| `app.orchestration.*` | Rejects **before** the request with a `CivitaiError` naming `auth: "oauth"` โ the orchestrator accepts no block token on any route, so there is nothing to learn from making the call |
|
|
97
103
|
| `app.requestGrants(...)` | Works. Goes to the host's consent dialog, so a `consent_required` fallback can still prompt |
|
|
98
104
|
|
|
@@ -326,6 +332,260 @@ an existence oracle, so do not report that as "someone else owns this". Put ever
|
|
|
326
332
|
string other viewers will read in `title`/`body`, which the server moderates;
|
|
327
333
|
`data` is unmoderated app structure.
|
|
328
334
|
|
|
335
|
+
## Multi-step workflows
|
|
336
|
+
|
|
337
|
+
`app.workflows` is how a **block** generates. It goes through the host's
|
|
338
|
+
`/api/v1/blocks/workflows/*` routes, so the host prices the work, holds it to
|
|
339
|
+
the viewer's and the app's budgets, tags it as this app's and submits it โ the
|
|
340
|
+
block never calls the orchestrator. It needs the block token the host mints and
|
|
341
|
+
the `ai:write:budgeted` scope in the manifest.
|
|
342
|
+
|
|
343
|
+
> **These routes take the block token only.** A block that declares
|
|
344
|
+
> `auth: "oauth"` and is handed an OAuth access token is answered `401` on every
|
|
345
|
+
> call here, and the `ApiError` message says so. Such a block generates through
|
|
346
|
+
> [`app.orchestration`](#the-orchestrator) instead, as its own principal and
|
|
347
|
+
> without the controls above; a block that wants `app.workflows` does not
|
|
348
|
+
> declare `auth: "oauth"`. `initialize({ token })` is given no token kind, so
|
|
349
|
+
> there the `401` arrives unexplained.
|
|
350
|
+
|
|
351
|
+
A `kind: 'workflow'` body runs several steps as **one** workflow, and a later
|
|
352
|
+
step can consume an earlier step's result through a `{ $ref, path }` reference:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
import { initialize, type WorkflowBody } from '@civitai/sdk';
|
|
356
|
+
|
|
357
|
+
const app = await initialize();
|
|
358
|
+
|
|
359
|
+
const body: WorkflowBody = {
|
|
360
|
+
kind: 'workflow',
|
|
361
|
+
arguments: { prompt: 'a lighthouse at dusk' },
|
|
362
|
+
steps: [
|
|
363
|
+
{
|
|
364
|
+
name: 'draw',
|
|
365
|
+
$type: 'imageGen',
|
|
366
|
+
input: {
|
|
367
|
+
engine: 'google',
|
|
368
|
+
model: 'nano-banana-2',
|
|
369
|
+
prompt: { $ref: '$arguments', path: 'prompt' },
|
|
370
|
+
},
|
|
371
|
+
suppressOutput: true, // an intermediate: its output is not surfaced
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: 'cutout',
|
|
375
|
+
$type: 'imageBackgroundRemoval',
|
|
376
|
+
input: { image: { $ref: 'draw', path: 'output.images[0].url' } },
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// 1. Price it. Nothing is charged or reserved.
|
|
382
|
+
const estimate = await app.workflows.estimate(body);
|
|
383
|
+
if (estimate.status === 'failed') {
|
|
384
|
+
// A refusal RESOLVES โ see below.
|
|
385
|
+
throw new Error(estimate.error ?? 'This workflow could not be priced.');
|
|
386
|
+
}
|
|
387
|
+
if (!(await confirmSpend(estimate.cost?.total))) throw new Error('Not confirmed.');
|
|
388
|
+
|
|
389
|
+
// 2. Run it. One key per press of the button, reused on every retry.
|
|
390
|
+
const idempotencyKey = crypto.randomUUID();
|
|
391
|
+
const submitted = await app.workflows.submit(body, { idempotencyKey });
|
|
392
|
+
if (submitted.status === 'failed') {
|
|
393
|
+
throw new Error(submitted.error ?? 'This workflow failed.');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 3. Follow it to a final status.
|
|
397
|
+
const done = await app.workflows.wait(submitted.workflowId);
|
|
398
|
+
if (done.status === 'succeeded') render(done.imageUrls);
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Every body kind the host accepts goes through the same seven calls โ a single
|
|
402
|
+
`textToImage` generation is `{ kind: 'textToImage', โฆ }` in place of the body
|
|
403
|
+
above. The body and reply types (`WorkflowBody`, `WorkflowBodyWorkflow`,
|
|
404
|
+
`WorkflowStepRef`, `WithWorkflowStepRefs`, `BlockWorkflowSnapshot`, โฆ) are
|
|
405
|
+
exported from `@civitai/sdk`; no other package is needed to write one.
|
|
406
|
+
|
|
407
|
+
A `kind: 'training'` body, and a step body carrying a `quoteId`, also need a
|
|
408
|
+
quote the viewer has confirmed in the host's own dialog before they are
|
|
409
|
+
submitted. `app.workflows` has no call for that confirmation step.
|
|
410
|
+
|
|
411
|
+
| Call | Does |
|
|
412
|
+
|---|---|
|
|
413
|
+
| `estimate(body)` | Prices the body. `snapshot.cost.total` is whole Buzz |
|
|
414
|
+
| `submit(body, { idempotencyKey })` | Runs it, spending the viewer's Buzz |
|
|
415
|
+
| `get(workflowId)` | The workflow as it stands now |
|
|
416
|
+
| `watch(workflowId)` | An async iterator: the workflow now, then each time it changes, until a final status |
|
|
417
|
+
| `wait(workflowId)` | The last value of `watch` โ the final snapshot, whichever status it is |
|
|
418
|
+
| `cancel(workflowId)` | Stops it on the orchestrator and resolves the workflow as it now stands |
|
|
419
|
+
| `list({ cursor?, limit? })` | A page of the workflows this app ran for this viewer, as `{ workflows, cursor }` |
|
|
420
|
+
|
|
421
|
+
### A refusal is an answer, not a throw
|
|
422
|
+
|
|
423
|
+
When the host declines to price or run a body it answers **`200` with a
|
|
424
|
+
snapshot whose `status` is `'failed'`**. Every call above *resolves* with it, so
|
|
425
|
+
branch on the snapshot rather than wrapping the call in `try`:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
const estimate = await app.workflows.estimate(body);
|
|
429
|
+
if (estimate.status === 'failed') {
|
|
430
|
+
estimate.error; // a sentence for the viewer, e.g. which step is at fault
|
|
431
|
+
estimate.errorCode; // a stable code to branch on, when the refusal defines one
|
|
432
|
+
estimate.cost; // absent: nothing was priced
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Three things follow from that shape:
|
|
437
|
+
|
|
438
|
+
- **A refused estimate has no `cost`.** An estimate the host could not price
|
|
439
|
+
does not predict a refused submit: the same body can still be submitted, and
|
|
440
|
+
is then reserved at the app's per-generation budget for each step.
|
|
441
|
+
- **A `failed` snapshot from `submit` is one of two things, told apart by
|
|
442
|
+
`workflowId`.** The placeholder id `'failed'` means the host refused *this
|
|
443
|
+
request* before submitting it; a budget or spending-cap refusal carries the
|
|
444
|
+
`cost.total` it would not charge. On the first attempt for a press, that
|
|
445
|
+
means nothing was charged. It says nothing about an earlier attempt for the
|
|
446
|
+
same press that ended unknown (below). A real id means a run was created and
|
|
447
|
+
came back failed, and Buzz may have been spent.
|
|
448
|
+
- **A `failed` workflow from `wait` can still have `cost` and `imageUrls`.**
|
|
449
|
+
Steps that finished before the failing one stay charged and keep their output.
|
|
450
|
+
|
|
451
|
+
The estimate is an estimate, not a ceiling: when a step's input references an
|
|
452
|
+
earlier step, the orchestrator re-prices it once that value is known.
|
|
453
|
+
|
|
454
|
+
What *does* reject is an `ApiError` with the HTTP `status`, a `CivitaiError`
|
|
455
|
+
when a `2xx` carried no snapshot, or whatever `fetch` threw when no answer
|
|
456
|
+
arrived. A `400` is a body or key the host cannot parse; a `403` on `get` or
|
|
457
|
+
`cancel` is a workflow that is not this viewer's or not this app's.
|
|
458
|
+
|
|
459
|
+
๐ด **For `submit`, a rejection does not mean "not started".** Read the next two
|
|
460
|
+
sections before writing its `catch`.
|
|
461
|
+
|
|
462
|
+
### When a submit rejects: the outcome is unknown
|
|
463
|
+
|
|
464
|
+
A rejected `submit` has **not** told you the run does not exist. The host
|
|
465
|
+
checks the request (its shape, the token, the scope, the budget on the token)
|
|
466
|
+
before it submits anything, and a rejection from one of those checks is a plain
|
|
467
|
+
refusal โ but the same status codes can also come back after the orchestrator
|
|
468
|
+
was called, and nothing in the reply says which it was. So treat every rejected
|
|
469
|
+
`submit` as unknown. Three cases are worth knowing by name:
|
|
470
|
+
|
|
471
|
+
- **A `5xx`.** The host answers with a server error both when nothing was
|
|
472
|
+
started and when its call to the orchestrator failed after the orchestrator
|
|
473
|
+
may already have created the run. On that second path the host undoes its
|
|
474
|
+
own budget reservations and forgets the key, so nothing on the host side
|
|
475
|
+
records the run โ but a workflow may exist.
|
|
476
|
+
- **A `409`.** The host answers `409` while an earlier submit with the same key
|
|
477
|
+
is still in flight (or its record was lost). The earlier attempt may yet
|
|
478
|
+
succeed.
|
|
479
|
+
- **No answer at all** โ a dropped connection, a timeout, an aborted request.
|
|
480
|
+
|
|
481
|
+
Do **one of two things, and never mint a new key for that press**:
|
|
482
|
+
|
|
483
|
+
1. **Retry `submit` with the same `idempotencyKey`.** Except for the two body
|
|
484
|
+
kinds named below, the host derives the run's identity on the orchestrator
|
|
485
|
+
from the key, so that a repeat can be matched to a run the first attempt
|
|
486
|
+
created. A new key cannot be matched to anything. After a `409`, wait before
|
|
487
|
+
retrying: the first attempt is still running. A request the host cannot
|
|
488
|
+
parse simply answers `400` again.
|
|
489
|
+
2. **Or look for the run** with `app.workflows.list()` before treating the
|
|
490
|
+
press as failed.
|
|
491
|
+
|
|
492
|
+
๐ด **For a `kind: 'training'` body and a step body carrying a `quoteId`, the
|
|
493
|
+
key is not the run's identity.** The host does not use the client key on those
|
|
494
|
+
two; the run is identified by the confirmed quote (a step) or by the training
|
|
495
|
+
run itself. A submit that names a **new** quote is a new run, whatever key it
|
|
496
|
+
carries. So after an unknown outcome on one of these, check
|
|
497
|
+
`app.workflows.list()` **before** asking the viewer to confirm another quote.
|
|
498
|
+
|
|
499
|
+
What this client can promise stops there. Matching a repeated key to an
|
|
500
|
+
existing run is done by the host and the orchestrator, not here; it is not
|
|
501
|
+
guaranteed to match, and the host remembers a key only for a limited time. So
|
|
502
|
+
`list()` is the check that does not depend on it: when a retry is late, or
|
|
503
|
+
fails again, look there before telling the viewer nothing ran.
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
import { ApiError, SubmissionUnconfirmedError } from '@civitai/sdk';
|
|
507
|
+
|
|
508
|
+
// One key for this press. It outlives the call: a retry reuses it.
|
|
509
|
+
const idempotencyKey = crypto.randomUUID();
|
|
510
|
+
// Has any attempt for this press ended without a known outcome?
|
|
511
|
+
let outcomeUnknown = false;
|
|
512
|
+
|
|
513
|
+
async function submitOnce() {
|
|
514
|
+
try {
|
|
515
|
+
const submitted = await app.workflows.submit(body, { idempotencyKey });
|
|
516
|
+
if (submitted.status !== 'failed') {
|
|
517
|
+
track(submitted.workflowId); // started: follow it with wait()
|
|
518
|
+
} else if (submitted.workflowId !== 'failed') {
|
|
519
|
+
showMessage('This run failed. Check your history before trying again.');
|
|
520
|
+
} else if (outcomeUnknown) {
|
|
521
|
+
// THIS attempt was refused, but an earlier one may have started a run.
|
|
522
|
+
const { workflows } = await app.workflows.list(); // look for it first
|
|
523
|
+
showMessage('We could not confirm this started. Check your history.');
|
|
524
|
+
} else {
|
|
525
|
+
showMessage('This could not start. Nothing was charged.'); // first attempt only
|
|
526
|
+
}
|
|
527
|
+
} catch (error) {
|
|
528
|
+
outcomeUnknown = true;
|
|
529
|
+
if (error instanceof SubmissionUnconfirmedError) {
|
|
530
|
+
// The run MAY be running and MAY have been charged. Do not resubmit.
|
|
531
|
+
showMessage('We could not confirm this run. It may still be running.');
|
|
532
|
+
const { workflows } = await app.workflows.list(); // look for it
|
|
533
|
+
} else {
|
|
534
|
+
// An ApiError (a 5xx, a 409, โฆ) or no answer: UNKNOWN. Keep
|
|
535
|
+
// `idempotencyKey`; the retry button calls submitOnce() again with the
|
|
536
|
+
// SAME key. Never a fresh key, and never "failed, nothing charged".
|
|
537
|
+
console.error(error instanceof ApiError ? error.status : error);
|
|
538
|
+
showRetry('We could not confirm this started.', submitOnce);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### When a submit could not be confirmed
|
|
545
|
+
|
|
546
|
+
One reply is neither a refusal nor a failed request. For a run the viewer
|
|
547
|
+
confirmed a quote for โ a `kind: 'training'` body, or a step body carrying a
|
|
548
|
+
`quoteId` โ the host can end up not knowing whether the run was created. It
|
|
549
|
+
then answers with a refusal-shaped snapshot and a `submissionUnconfirmed` flag
|
|
550
|
+
beside it, and `submit` **rejects** with `SubmissionUnconfirmedError` rather
|
|
551
|
+
than hand back a snapshot that reads as "not started". The example above
|
|
552
|
+
handles it.
|
|
553
|
+
|
|
554
|
+
Here the host has **kept** the budget it reserved for the run, so this is not
|
|
555
|
+
one to retry as a reflex: a second submit reserves that budget again and may
|
|
556
|
+
start a second run. Do not tell the viewer nothing was spent. Look for the run
|
|
557
|
+
with `app.workflows.list()` first. `error.snapshot`, when
|
|
558
|
+
the host sent one, carries the `cost.total` that was reserved; its `workflowId`
|
|
559
|
+
is a placeholder, not something to poll. `snapshot.error` on any reply is
|
|
560
|
+
server text โ log it and show your own copy.
|
|
561
|
+
|
|
562
|
+
### The idempotency key
|
|
563
|
+
|
|
564
|
+
`submit` spends Buzz, so it requires an `idempotencyKey` and refuses to send
|
|
565
|
+
without one. The key is what lets the host recognise a submit that was sent
|
|
566
|
+
twice โ a retry after a dropped connection, a double press โ as one request
|
|
567
|
+
(for a `kind: 'training'` body or a step with a `quoteId`, see the note above:
|
|
568
|
+
there the quote, not the key, identifies the run).
|
|
569
|
+
|
|
570
|
+
- Mint it **once per thing the viewer asked for**, keep it until that press has
|
|
571
|
+
a known outcome, and reuse it on every retry. A key minted inside the retry
|
|
572
|
+
loop, or a fresh key for "try again" after an error, protects nothing: to the
|
|
573
|
+
host that is a new generation.
|
|
574
|
+
- Letters, digits, `_` and `-`, 1 to 64 characters, **no colons**.
|
|
575
|
+
`crypto.randomUUID()` fits. The host answers `400` for anything else, and this
|
|
576
|
+
client sends the key exactly as given.
|
|
577
|
+
- `submit` does not retry a failed request. (Like every call here it re-sends
|
|
578
|
+
once after refreshing an expired token, which the host had refused unread.)
|
|
579
|
+
After a `5xx`, a `409` or no answer the workflow may or may not exist: see
|
|
580
|
+
[When a submit rejects](#when-a-submit-rejects-the-outcome-is-unknown).
|
|
581
|
+
|
|
582
|
+
Step limits, the reference rules and every `errorCode` are in the
|
|
583
|
+
[workflows guide](https://developer.civitai.com/apps/guide/workflows).
|
|
584
|
+
|
|
585
|
+
`app.orchestration`, next, is a different client for a different caller: it
|
|
586
|
+
reaches the orchestrator directly with an OAuth access token and applies none of
|
|
587
|
+
the controls above, which is why it refuses a block token outright.
|
|
588
|
+
|
|
329
589
|
## The orchestrator
|
|
330
590
|
|
|
331
591
|
Steps are typed by `$type`: `input` is checked against that step's own input,
|
|
@@ -641,6 +901,10 @@ per-viewer store is [app storage](#app-storage).
|
|
|
641
901
|
- `npm run check:layering` keeps `core` free of any domain.
|
|
642
902
|
- `npm run check:steps` fails when the step unions no longer match the
|
|
643
903
|
installed `@civitai/orchestration-client`.
|
|
904
|
+
- `src/workflows/types.generated.ts` is a generated copy of the block workflow
|
|
905
|
+
types `@civitai/app-sdk/blocks` defines. The repo's guard suite
|
|
906
|
+
(`pnpm test:guards`) fails when it no longer matches its source; refresh it
|
|
907
|
+
with `npm run gen:workflow-types`.
|
|
644
908
|
|
|
645
909
|
See [`BREAKING.md`](./BREAKING.md) for what an app gives up moving from
|
|
646
910
|
`@civitai/app-sdk` 0.x and `@civitai/blocks-react`.
|
package/dist/app/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type GrantOptions, type Scope, type TokenOptions, type TokenSessionOpti
|
|
|
6
6
|
import { type SharedStorageClient } from '../shared-storage/index.js';
|
|
7
7
|
import { type SiteClient } from '../site/index.js';
|
|
8
8
|
import { type StorageClient } from '../storage/index.js';
|
|
9
|
+
import { type WorkflowsClient } from '../workflows/index.js';
|
|
9
10
|
export interface AppClient {
|
|
10
11
|
/**
|
|
11
12
|
* The public Civitai REST API (`/api/v1`), as the viewer.
|
|
@@ -39,13 +40,26 @@ export interface AppClient {
|
|
|
39
40
|
* clear a server-side minimum-trust gate.
|
|
40
41
|
*/
|
|
41
42
|
readonly sharedStorage: SharedStorageClient;
|
|
43
|
+
/**
|
|
44
|
+
* Generation for a BLOCK: estimate, submit, watch and cancel through the
|
|
45
|
+
* host's `blocks/workflows/*` routes, which price, budget and attribute the
|
|
46
|
+
* work before it reaches the orchestrator. Multi-step `kind: 'workflow'`
|
|
47
|
+
* bodies included.
|
|
48
|
+
*
|
|
49
|
+
* ๐ด Requires the block token the host mints, like {@link AppClient.storage}:
|
|
50
|
+
* an OAuth access token is answered `401` here. It is NOT
|
|
51
|
+
* {@link AppClient.orchestration}: that one calls the orchestrator directly
|
|
52
|
+
* with an OAuth access token and applies none of those controls.
|
|
53
|
+
*/
|
|
54
|
+
readonly workflows: WorkflowsClient;
|
|
42
55
|
/**
|
|
43
56
|
* The orchestrator's workflows, as the viewer.
|
|
44
57
|
*
|
|
45
58
|
* ๐ด The orchestrator accepts no block-scoped token on any route, so unlike
|
|
46
59
|
* {@link AppClient.site} this destination IS known ahead of the call: a block
|
|
47
60
|
* holding one is refused here up front, with the manifest fix, rather than
|
|
48
|
-
* spending a request to be told.
|
|
61
|
+
* spending a request to be told. A block generates through
|
|
62
|
+
* {@link AppClient.workflows} instead.
|
|
49
63
|
*/
|
|
50
64
|
readonly orchestration: OrchestrationClient;
|
|
51
65
|
/** Asks for more scopes. `false` when they cannot be granted โ a refusal is an answer. */
|
package/dist/app/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createTokenSession, } from '../session/index.js';
|
|
|
7
7
|
import { createSharedStorageClient, } from '../shared-storage/index.js';
|
|
8
8
|
import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
|
|
9
9
|
import { createStorageClient } from '../storage/index.js';
|
|
10
|
+
import { createWorkflowsClient } from '../workflows/index.js';
|
|
10
11
|
/** Long enough for a slow host page; short enough that a block opened on its own says so. */
|
|
11
12
|
const DEFAULT_INIT_TIMEOUT_MS = 10_000;
|
|
12
13
|
export async function initialize(options = {}) {
|
|
@@ -16,8 +17,10 @@ export async function initialize(options = {}) {
|
|
|
16
17
|
await ready(transport, options.timeoutMs ?? DEFAULT_INIT_TIMEOUT_MS, options.signal);
|
|
17
18
|
const snapshot = () => transport.snapshot.get();
|
|
18
19
|
const holdsBlockTokenNow = holdsBlockToken(snapshot);
|
|
20
|
+
// Read live, like the predicate above: the host rotates the token.
|
|
21
|
+
const holdsOAuthTokenNow = () => snapshot().token.kind === 'oauth';
|
|
19
22
|
return {
|
|
20
|
-
...createAppClient(createHostSession(transport), options, holdsBlockTokenNow),
|
|
23
|
+
...createAppClient(createHostSession(transport), options, holdsBlockTokenNow, holdsOAuthTokenNow),
|
|
21
24
|
host: createHost(transport),
|
|
22
25
|
get viewer() {
|
|
23
26
|
return snapshot().viewer;
|
|
@@ -76,21 +79,23 @@ const OAUTH_OPT_IN = 'declare `auth: "oauth"` in block.manifest.json';
|
|
|
76
79
|
* refuses, with nothing in this repo able to notice it going stale.
|
|
77
80
|
*/
|
|
78
81
|
const BLOCK_NAMESPACE = /^\/*blocks\//;
|
|
79
|
-
function createAppClient(session, options, holdsBlockTokenNow = () => false) {
|
|
82
|
+
function createAppClient(session, options, holdsBlockTokenNow = () => false, holdsOAuthTokenNow = () => false) {
|
|
80
83
|
const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
|
|
81
|
-
// One instance for all
|
|
82
|
-
// routes live under the same base URL, so a `siteUrl` override
|
|
83
|
-
// together.
|
|
84
|
+
// One instance for all four site-hosted surfaces: the storage and workflows
|
|
85
|
+
// clients' routes live under the same base URL, so a `siteUrl` override
|
|
86
|
+
// redirects them together.
|
|
84
87
|
// Wrapped once, so `BLOCK_NAMESPACE` is the single place that decides where the
|
|
85
|
-
// diagnosis applies.
|
|
86
|
-
// their routes are `blocks/app-storage
|
|
87
|
-
// that namespace already excludes, and
|
|
88
|
-
// `test/shared-storage/seam.test.ts`
|
|
88
|
+
// diagnosis applies. None of the three `blocks/*` clients needs an exemption
|
|
89
|
+
// of its own: their routes are `blocks/app-storage/*`, `blocks/shared-storage/*`
|
|
90
|
+
// and `blocks/workflows/*`, which that namespace already excludes, and
|
|
91
|
+
// `test/storage/seam.test.ts`, `test/shared-storage/seam.test.ts` and
|
|
92
|
+
// `test/workflows/seam.test.ts` pin the three `BASE` values there textually.
|
|
89
93
|
const siteHttp = explainApiRefusal(http(options.siteUrl ?? DEFAULT_SITE_URL), holdsBlockTokenNow);
|
|
90
94
|
return {
|
|
91
95
|
site: createSiteClient(siteHttp),
|
|
92
96
|
storage: createStorageClient(siteHttp),
|
|
93
97
|
sharedStorage: createSharedStorageClient(siteHttp),
|
|
98
|
+
workflows: createWorkflowsClient(explainWorkflowRefusal(siteHttp, holdsOAuthTokenNow)),
|
|
94
99
|
orchestration: createOrchestrationClient(refuseBlockToken(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL), holdsBlockTokenNow)),
|
|
95
100
|
requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
|
|
96
101
|
getToken: (opts) => session.getToken(opts),
|
|
@@ -106,7 +111,8 @@ function refuseBlockToken(http, holdsBlockTokenNow) {
|
|
|
106
111
|
return (method, path, opts) => holdsBlockTokenNow()
|
|
107
112
|
? Promise.reject(new CivitaiError('The orchestrator does not accept a block-scoped token, and this block holds one. ' +
|
|
108
113
|
`To run workflows as the viewer, ${OAUTH_OPT_IN} so the host mints an OAuth ` +
|
|
109
|
-
'access token; the block token reaches the `blocks/*` API routes and app storage only.'
|
|
114
|
+
'access token; the block token reaches the `blocks/*` API routes and app storage only. ' +
|
|
115
|
+
'To generate with the block token, use `app.workflows`.'))
|
|
110
116
|
: http(method, path, opts);
|
|
111
117
|
}
|
|
112
118
|
/**
|
|
@@ -153,6 +159,34 @@ function explainApiRefusal(http, holdsBlockTokenNow) {
|
|
|
153
159
|
}
|
|
154
160
|
};
|
|
155
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* The mirror image of {@link explainApiRefusal}: the `blocks/workflows/*` routes
|
|
164
|
+
* are served to the block-scoped token only, so a block that declared
|
|
165
|
+
* `auth: "oauth"` and was handed an OAuth access token is answered `401` there.
|
|
166
|
+
* Unlike the rest of `/api/v1` this destination IS known, so the message can
|
|
167
|
+
* say what the token is and where that token does work.
|
|
168
|
+
*
|
|
169
|
+
* Only a `401`, and only when the host said the token is an OAuth one: a `403`
|
|
170
|
+
* here is about the workflow or the scope, and `initialize({ token })` knows no
|
|
171
|
+
* kind, so neither is annotated.
|
|
172
|
+
*/
|
|
173
|
+
function explainWorkflowRefusal(http, holdsOAuthTokenNow) {
|
|
174
|
+
return async (method, path, opts) => {
|
|
175
|
+
try {
|
|
176
|
+
return await http(method, path, opts);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error instanceof ApiError && error.status === 401 && holdsOAuthTokenNow()) {
|
|
180
|
+
// Appended, as in `explainApiRefusal`: `status` and `body` stay as sent.
|
|
181
|
+
error.message =
|
|
182
|
+
`${error.message} โ and this block holds an OAuth access token. \`app.workflows\` needs ` +
|
|
183
|
+
'the block-scoped token, which the host hands to a block that does not declare ' +
|
|
184
|
+
'`auth: "oauth"`. With an OAuth access token, run workflows through `app.orchestration`.';
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
156
190
|
function ready(transport, timeoutMs, signal) {
|
|
157
191
|
if (transport.snapshot.get().ready)
|
|
158
192
|
return Promise.resolve();
|
|
@@ -2,6 +2,7 @@ import { isHostMessage, parseBlockInitFragment, stripBlockInitFragment, } from '
|
|
|
2
2
|
import { EMPTY_SNAPSHOT, snapshotFromInit, tokenFromWrapped, } from '../transport.js';
|
|
3
3
|
import { BridgeError, classifyHostError } from '../errors.js';
|
|
4
4
|
import { OriginMatcher } from '../origin-matcher.js';
|
|
5
|
+
import { VERSION } from '../../version.generated.js';
|
|
5
6
|
/**
|
|
6
7
|
* The framing the host had before this package owned the protocol: fields
|
|
7
8
|
* straight in the payload, and failure as the server's own sentence. Converting
|
|
@@ -271,7 +272,9 @@ export class IframeTransport {
|
|
|
271
272
|
this.#emit();
|
|
272
273
|
this.#flushOutbound();
|
|
273
274
|
// height 0 is a placeholder; the real measurement follows once the app mounts.
|
|
274
|
-
|
|
275
|
+
// `sdkName`/`sdkVersion` are informational: the host counts which SDK
|
|
276
|
+
// versions are in the wild. Nothing is negotiated on them.
|
|
277
|
+
this.#dispatch('BLOCK_READY', { height: 0, sdkName: 'sdk', sdkVersion: VERSION });
|
|
275
278
|
}
|
|
276
279
|
return;
|
|
277
280
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ApiError } from './index.js';
|
|
2
|
+
/** A blip is worth waiting out; a refusal is an answer. */
|
|
3
|
+
export function isTransient(error) {
|
|
4
|
+
return !(error instanceof ApiError) || error.status >= 500 || error.status === 429;
|
|
5
|
+
}
|
|
6
|
+
export function sleep(ms, signal) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const timer = setTimeout(resolve, ms);
|
|
9
|
+
signal?.addEventListener('abort', () => {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
reject(signal.reason);
|
|
12
|
+
}, { once: true });
|
|
13
|
+
});
|
|
14
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export type { GrantOptions, Scope, TokenOptions, TokenSource } from './session/i
|
|
|
11
11
|
export type { SiteClient } from './site/index.js';
|
|
12
12
|
export type { SharedItem, SharedListQuery, SharedListResult, SharedStorageCallOptions, SharedStorageClient, SharedValue, } from './shared-storage/index.js';
|
|
13
13
|
export type { StorageCallOptions, StorageClient, StorageKeyEntry, StorageListQuery, StorageListResult, StorageQuota, } from './storage/index.js';
|
|
14
|
+
export { SubmissionUnconfirmedError } from './workflows/index.js';
|
|
15
|
+
export type { WorkflowCallOptions, WorkflowGetOptions, WorkflowListQuery, WorkflowListResult, WorkflowSubmitOptions, WorkflowWatchOptions, WorkflowsClient, } from './workflows/index.js';
|
|
16
|
+
export type { AiToolkitLrScheduler, AiToolkitOptimizerType, AiToolkitPlainEcosystem, AiToolkitTrainingParams, AiToolkitTrainingParamsBase, AppWorkflow, AppWorkflowImage, BlockModel3dOutput, BlockModelSubstitution, BlockPublishedModel, BlockSourceImage, BlockStepQuote, BlockStepToolCall, BlockTextToImageParams, BlockTrainedEpoch, BlockTrainingQuote, BlockWorkflowSnapshot, BlockWorkflowStepSnapshot, BuzzAccountType, InlineComfyNode, KnownBlockWorkflowErrorCode, ModelSubstitutionReason, WithWorkflowStepRefs, WorkflowBody, WorkflowBodyCustomComfy, WorkflowBodyCustomComfyInline, WorkflowBodyCustomComfyRecipe, WorkflowBodyPassThroughStep, WorkflowBodyStep, WorkflowBodyTextToImage, WorkflowBodyTraining, WorkflowBodyWorkflow, WorkflowBodyWorkflowStep, WorkflowStepRef, } from './workflows/types.generated.js';
|
|
14
17
|
export { isTerminal, isTerminalStatus } from './orchestration/index.js';
|
|
15
18
|
export type { OrchestrationClient, Step, StepTemplate, WaitOptions, Workflow, WorkflowPage, WorkflowQuery, WorkflowStatus, WorkflowTemplate, } from './orchestration/index.js';
|
|
16
19
|
export { createHost } from './host/index.js';
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
export { initialize } from './app/index.js';
|
|
17
17
|
export { createSignIn, SignInError } from './sign-in/index.js';
|
|
18
18
|
export { SCOPES } from './session/index.js';
|
|
19
|
+
export { SubmissionUnconfirmedError } from './workflows/index.js';
|
|
19
20
|
export { isTerminal, isTerminalStatus } from './orchestration/index.js';
|
|
20
21
|
export { createHost } from './host/index.js';
|
|
21
22
|
export { ApiError } from './http/index.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Workflow as GeneratedWorkflow, WorkflowStatus, WorkflowTemplate as GeneratedWorkflowTemplate } from '@civitai/orchestration-client/dist/generated/types.gen.js';
|
|
2
|
-
import {
|
|
2
|
+
import type { Http } from '../http/index.js';
|
|
3
3
|
import type { Step, StepTemplate } from './steps.generated.js';
|
|
4
4
|
export type { Step, StepTemplate, WorkflowStatus };
|
|
5
5
|
/** What to run. `currencies` defaults server-side, so it is optional here. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isTransient, sleep } from '../http/retry.js';
|
|
2
2
|
export const DEFAULT_ORCHESTRATION_URL = 'https://orchestration.civitai.com';
|
|
3
3
|
const WORKFLOWS = 'v2/consumer/workflows';
|
|
4
4
|
const TERMINAL = new Set([
|
|
@@ -70,16 +70,3 @@ export function createOrchestrationClient(http) {
|
|
|
70
70
|
queryWorkflows: (query = {}, { signal } = {}) => http('GET', WORKFLOWS, { query: { ...query }, signal }),
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
-
/** A blip is worth waiting out; a refusal is an answer. */
|
|
74
|
-
function isTransient(error) {
|
|
75
|
-
return !(error instanceof ApiError) || error.status >= 500 || error.status === 429;
|
|
76
|
-
}
|
|
77
|
-
function sleep(ms, signal) {
|
|
78
|
-
return new Promise((resolve, reject) => {
|
|
79
|
-
const timer = setTimeout(resolve, ms);
|
|
80
|
-
signal?.addEventListener('abort', () => {
|
|
81
|
-
clearTimeout(timer);
|
|
82
|
-
reject(signal.reason);
|
|
83
|
-
}, { once: true });
|
|
84
|
-
});
|
|
85
|
-
}
|