@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 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.** ๐Ÿ”ด Use these, **not** `app.orchestration` โ€” see *What a direct orchestrator call loses* below |
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. It succeeds `@civitai/app-sdk`, whose 0.x releases continue from this repo
6
- as a separate codebase.
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. Submit through
34
- > `POST /api/v1/blocks/workflows/submit` instead. The substitution type-checks and
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.orchestration` | Workflows: `submitWorkflow`, `estimateWorkflow`, `getWorkflow`, `watchWorkflow`, `waitForWorkflow`, `cancelWorkflow`, `queryWorkflows` |
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`.
@@ -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 three site-hosted surfaces: the two storage clients'
82
- // routes live under the same base URL, so a `siteUrl` override redirects them
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. Neither storage client needs an exemption of its own:
86
- // their routes are `blocks/app-storage/*` and `blocks/shared-storage/*`, which
87
- // that namespace already excludes, and `test/storage/seam.test.ts` plus
88
- // `test/shared-storage/seam.test.ts` pin both `BASE` values there textually.
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();
@@ -0,0 +1,3 @@
1
+ /** A blip is worth waiting out; a refusal is an answer. */
2
+ export declare function isTransient(error: unknown): boolean;
3
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
@@ -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 { type Http } from '../http/index.js';
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 { ApiError } from '../http/index.js';
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
- }
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
2
- export declare const VERSION = "0.11.1";
2
+ export declare const VERSION = "0.12.0";
@@ -1,3 +1,3 @@
1
1
  /* AUTOGENERATED from package.json by scripts/gen-version.mjs. DO NOT EDIT. */
2
2
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
3
- export const VERSION = "0.11.1";
3
+ export const VERSION = "0.12.0";